1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.nfc.beam;
18
19import com.android.nfc.R;
20
21import android.app.Notification;
22import android.app.NotificationChannel;
23import android.app.NotificationManager;
24import android.app.PendingIntent;
25import android.app.Notification.Builder;
26import android.bluetooth.BluetoothDevice;
27import android.content.ContentResolver;
28import android.content.Context;
29import android.content.Intent;
30import android.media.MediaScannerConnection;
31import android.net.Uri;
32import android.os.Environment;
33import android.os.Handler;
34import android.os.Looper;
35import android.os.Message;
36import android.os.SystemClock;
37import android.os.UserHandle;
38import android.util.Log;
39
40import java.io.File;
41import java.text.SimpleDateFormat;
42import java.util.ArrayList;
43import java.util.Arrays;
44import java.util.Date;
45import java.util.HashMap;
46import java.util.Locale;
47
48import android.support.v4.content.FileProvider;
49
50/**
51 * A BeamTransferManager object represents a set of files
52 * that were received through NFC connection handover
53 * from the same source address.
54 *
55 * It manages starting, stopping, and processing the transfer, as well
56 * as the user visible notification.
57 *
58 * For Bluetooth, files are received through OPP, and
59 * we have no knowledge how many files will be transferred
60 * as part of a single transaction.
61 * Hence, a transfer has a notion of being "alive": if
62 * the last update to a transfer was within WAIT_FOR_NEXT_TRANSFER_MS
63 * milliseconds, we consider a new file transfer from the
64 * same source address as part of the same transfer.
65 * The corresponding URIs will be grouped in a single folder.
66 *
67 * @hide
68 */
69
70public class BeamTransferManager implements Handler.Callback,
71        MediaScannerConnection.OnScanCompletedListener {
72    interface Callback {
73
74        void onTransferComplete(BeamTransferManager transfer, boolean success);
75    };
76    static final String TAG = "BeamTransferManager";
77
78    static final Boolean DBG = true;
79
80    // In the states below we still accept new file transfer
81    static final int STATE_NEW = 0;
82    static final int STATE_IN_PROGRESS = 1;
83    static final int STATE_W4_NEXT_TRANSFER = 2;
84    // In the states below no new files are accepted.
85    static final int STATE_W4_MEDIA_SCANNER = 3;
86    static final int STATE_FAILED = 4;
87    static final int STATE_SUCCESS = 5;
88    static final int STATE_CANCELLED = 6;
89    static final int STATE_CANCELLING = 7;
90    static final int MSG_NEXT_TRANSFER_TIMER = 0;
91
92    static final int MSG_TRANSFER_TIMEOUT = 1;
93    static final int DATA_LINK_TYPE_BLUETOOTH = 1;
94
95    // We need to receive an update within this time period
96    // to still consider this transfer to be "alive" (ie
97    // a reason to keep the handover transport enabled).
98    static final int ALIVE_CHECK_MS = 20000;
99
100    // The amount of time to wait for a new transfer
101    // once the current one completes.
102    static final int WAIT_FOR_NEXT_TRANSFER_MS = 4000;
103
104    static final String BEAM_DIR = "beam";
105
106    static final String BEAM_NOTIFICATION_CHANNEL = "beam_notification_channel";
107
108    static final String ACTION_WHITELIST_DEVICE =
109            "android.btopp.intent.action.WHITELIST_DEVICE";
110
111    static final String ACTION_STOP_BLUETOOTH_TRANSFER =
112            "android.btopp.intent.action.STOP_HANDOVER_TRANSFER";
113
114    final boolean mIncoming;  // whether this is an incoming transfer
115
116    final int mTransferId; // Unique ID of this transfer used for notifications
117    int mBluetoothTransferId; // ID of this transfer in Bluetooth namespace
118
119    final PendingIntent mCancelIntent;
120    final Context mContext;
121    final Handler mHandler;
122    final NotificationManager mNotificationManager;
123    final BluetoothDevice mRemoteDevice;
124    final Callback mCallback;
125    final boolean mRemoteActivating;
126
127    // Variables below are only accessed on the main thread
128    int mState;
129    int mCurrentCount;
130    int mSuccessCount;
131    int mTotalCount;
132    int mDataLinkType;
133    boolean mCalledBack;
134    Long mLastUpdate; // Last time an event occurred for this transfer
135    float mProgress; // Progress in range [0..1]
136    ArrayList<Uri> mUris; // Received uris from transport
137    ArrayList<String> mTransferMimeTypes; // Mime-types received from transport
138    Uri[] mOutgoingUris; // URIs to send
139    ArrayList<String> mPaths; // Raw paths on the filesystem for Beam-stored files
140    HashMap<String, String> mMimeTypes; // Mime-types associated with each path
141    HashMap<String, Uri> mMediaUris; // URIs found by the media scanner for each path
142    int mUrisScanned;
143    Long mStartTime;
144
145    public BeamTransferManager(Context context, Callback callback,
146                               BeamTransferRecord pendingTransfer, boolean incoming) {
147        mContext = context;
148        mCallback = callback;
149        mRemoteDevice = pendingTransfer.remoteDevice;
150        mIncoming = incoming;
151        mTransferId = pendingTransfer.id;
152        mBluetoothTransferId = -1;
153        mDataLinkType = pendingTransfer.dataLinkType;
154        mRemoteActivating = pendingTransfer.remoteActivating;
155        mStartTime = 0L;
156        // For incoming transfers, count can be set later
157        mTotalCount = (pendingTransfer.uris != null) ? pendingTransfer.uris.length : 0;
158        mLastUpdate = SystemClock.elapsedRealtime();
159        mProgress = 0.0f;
160        mState = STATE_NEW;
161        mUris = pendingTransfer.uris == null
162                ? new ArrayList<Uri>()
163                : new ArrayList<Uri>(Arrays.asList(pendingTransfer.uris));
164        mTransferMimeTypes = new ArrayList<String>();
165        mMimeTypes = new HashMap<String, String>();
166        mPaths = new ArrayList<String>();
167        mMediaUris = new HashMap<String, Uri>();
168        mCancelIntent = buildCancelIntent();
169        mUrisScanned = 0;
170        mCurrentCount = 0;
171        mSuccessCount = 0;
172        mOutgoingUris = pendingTransfer.uris;
173        mHandler = new Handler(Looper.getMainLooper(), this);
174        mHandler.sendEmptyMessageDelayed(MSG_TRANSFER_TIMEOUT, ALIVE_CHECK_MS);
175        mNotificationManager = (NotificationManager) mContext.getSystemService(
176                Context.NOTIFICATION_SERVICE);
177        NotificationChannel notificationChannel = new NotificationChannel(
178                BEAM_NOTIFICATION_CHANNEL, mContext.getString(R.string.app_name),
179                NotificationManager.IMPORTANCE_HIGH);
180        mNotificationManager.createNotificationChannel(notificationChannel);
181    }
182
183    void whitelistOppDevice(BluetoothDevice device) {
184        if (DBG) Log.d(TAG, "Whitelisting " + device + " for BT OPP");
185        Intent intent = new Intent(ACTION_WHITELIST_DEVICE);
186        intent.setPackage(mContext.getString(R.string.bluetooth_package));
187        intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device);
188        mContext.sendBroadcastAsUser(intent, UserHandle.CURRENT);
189    }
190
191    public void start() {
192        if (mStartTime > 0) {
193            // already started
194            return;
195        }
196
197        mStartTime = System.currentTimeMillis();
198
199        if (!mIncoming) {
200            if (mDataLinkType == BeamTransferRecord.DATA_LINK_TYPE_BLUETOOTH) {
201                new BluetoothOppHandover(mContext, mRemoteDevice, mUris, mRemoteActivating).start();
202            }
203        }
204    }
205
206    public void updateFileProgress(float progress) {
207        if (!isRunning()) return; // Ignore when we're no longer running
208
209        mHandler.removeMessages(MSG_NEXT_TRANSFER_TIMER);
210
211        this.mProgress = progress;
212
213        // We're still receiving data from this device - keep it in
214        // the whitelist for a while longer
215        if (mIncoming && mRemoteDevice != null) whitelistOppDevice(mRemoteDevice);
216
217        updateStateAndNotification(STATE_IN_PROGRESS);
218    }
219
220    public synchronized void setBluetoothTransferId(int id) {
221        if (mBluetoothTransferId == -1 && id != -1) {
222            mBluetoothTransferId = id;
223            if (mState == STATE_CANCELLING) {
224                sendBluetoothCancelIntentAndUpdateState();
225            }
226        }
227    }
228
229    public void finishTransfer(boolean success, Uri uri, String mimeType) {
230        if (!isRunning()) return; // Ignore when we're no longer running
231
232        mCurrentCount++;
233        if (success && uri != null) {
234            mSuccessCount++;
235            if (DBG) Log.d(TAG, "Transfer success, uri " + uri + " mimeType " + mimeType);
236            mProgress = 0.0f;
237            if (mimeType == null) {
238                mimeType = MimeTypeUtil.getMimeTypeForUri(mContext, uri);
239            }
240            if (mimeType != null) {
241                mUris.add(uri);
242                mTransferMimeTypes.add(mimeType);
243            } else {
244                if (DBG) Log.d(TAG, "Could not get mimeType for file.");
245            }
246        } else {
247            Log.e(TAG, "Handover transfer failed");
248            // Do wait to see if there's another file coming.
249        }
250        mHandler.removeMessages(MSG_NEXT_TRANSFER_TIMER);
251        if (mCurrentCount == mTotalCount) {
252            if (mIncoming) {
253                processFiles();
254            } else {
255                updateStateAndNotification(mSuccessCount > 0 ? STATE_SUCCESS : STATE_FAILED);
256            }
257        } else {
258            mHandler.sendEmptyMessageDelayed(MSG_NEXT_TRANSFER_TIMER, WAIT_FOR_NEXT_TRANSFER_MS);
259            updateStateAndNotification(STATE_W4_NEXT_TRANSFER);
260        }
261    }
262
263    public boolean isRunning() {
264        if (mState != STATE_NEW && mState != STATE_IN_PROGRESS && mState != STATE_W4_NEXT_TRANSFER
265            && mState != STATE_CANCELLING) {
266            return false;
267        } else {
268            return true;
269        }
270    }
271
272    public void setObjectCount(int objectCount) {
273        mTotalCount = objectCount;
274    }
275
276    void cancel() {
277        if (!isRunning()) return;
278
279        // Delete all files received so far
280        for (Uri uri : mUris) {
281            File file = new File(uri.getPath());
282            if (file.exists()) file.delete();
283        }
284
285        if (mBluetoothTransferId != -1) {
286            // we know the ID, we can cancel immediately
287            sendBluetoothCancelIntentAndUpdateState();
288        } else {
289            updateStateAndNotification(STATE_CANCELLING);
290        }
291
292    }
293
294    private void sendBluetoothCancelIntentAndUpdateState() {
295        Intent cancelIntent = new Intent(ACTION_STOP_BLUETOOTH_TRANSFER);
296        cancelIntent.setPackage(mContext.getString(R.string.bluetooth_package));
297        cancelIntent.putExtra(BeamStatusReceiver.EXTRA_TRANSFER_ID, mBluetoothTransferId);
298        mContext.sendBroadcast(cancelIntent);
299        updateStateAndNotification(STATE_CANCELLED);
300    }
301
302    void updateNotification() {
303        Builder notBuilder = new Notification.Builder(mContext, BEAM_NOTIFICATION_CHANNEL);
304        notBuilder.setColor(mContext.getResources().getColor(
305                com.android.internal.R.color.system_notification_accent_color));
306        notBuilder.setWhen(mStartTime);
307        notBuilder.setVisibility(Notification.VISIBILITY_PUBLIC);
308        notBuilder.setOnlyAlertOnce(true);
309        String beamString;
310        if (mIncoming) {
311            beamString = mContext.getString(R.string.beam_progress);
312        } else {
313            beamString = mContext.getString(R.string.beam_outgoing);
314        }
315        if (mState == STATE_NEW || mState == STATE_IN_PROGRESS ||
316                mState == STATE_W4_NEXT_TRANSFER || mState == STATE_W4_MEDIA_SCANNER) {
317            notBuilder.setAutoCancel(false);
318            notBuilder.setSmallIcon(mIncoming ? android.R.drawable.stat_sys_download :
319                    android.R.drawable.stat_sys_upload);
320            notBuilder.setTicker(beamString);
321            notBuilder.setContentTitle(beamString);
322            notBuilder.addAction(R.drawable.ic_menu_cancel_holo_dark,
323                    mContext.getString(R.string.cancel), mCancelIntent);
324            float progress = 0;
325            if (mTotalCount > 0) {
326                float progressUnit = 1.0f / mTotalCount;
327                progress = (float) mCurrentCount * progressUnit + mProgress * progressUnit;
328            }
329            if (mTotalCount > 0 && progress > 0) {
330                notBuilder.setProgress(100, (int) (100 * progress), false);
331            } else {
332                notBuilder.setProgress(100, 0, true);
333            }
334        } else if (mState == STATE_SUCCESS) {
335            notBuilder.setAutoCancel(true);
336            notBuilder.setSmallIcon(mIncoming ? android.R.drawable.stat_sys_download_done :
337                    android.R.drawable.stat_sys_upload_done);
338            notBuilder.setTicker(mContext.getString(R.string.beam_complete));
339            notBuilder.setContentTitle(mContext.getString(R.string.beam_complete));
340
341            if (mIncoming) {
342                notBuilder.setContentText(mContext.getString(R.string.beam_tap_to_view));
343                Intent viewIntent = buildViewIntent();
344                PendingIntent contentIntent = PendingIntent.getActivity(
345                        mContext, mTransferId, viewIntent, 0, null);
346
347                notBuilder.setContentIntent(contentIntent);
348            }
349        } else if (mState == STATE_FAILED) {
350            notBuilder.setAutoCancel(false);
351            notBuilder.setSmallIcon(mIncoming ? android.R.drawable.stat_sys_download_done :
352                    android.R.drawable.stat_sys_upload_done);
353            notBuilder.setTicker(mContext.getString(R.string.beam_failed));
354            notBuilder.setContentTitle(mContext.getString(R.string.beam_failed));
355        } else if (mState == STATE_CANCELLED || mState == STATE_CANCELLING) {
356            notBuilder.setAutoCancel(false);
357            notBuilder.setSmallIcon(mIncoming ? android.R.drawable.stat_sys_download_done :
358                    android.R.drawable.stat_sys_upload_done);
359            notBuilder.setTicker(mContext.getString(R.string.beam_canceled));
360            notBuilder.setContentTitle(mContext.getString(R.string.beam_canceled));
361        } else {
362            return;
363        }
364
365        mNotificationManager.notify(null, mTransferId, notBuilder.build());
366    }
367
368    void updateStateAndNotification(int newState) {
369        this.mState = newState;
370        this.mLastUpdate = SystemClock.elapsedRealtime();
371
372        mHandler.removeMessages(MSG_TRANSFER_TIMEOUT);
373        if (isRunning()) {
374            // Update timeout timer if we're still running
375            mHandler.sendEmptyMessageDelayed(MSG_TRANSFER_TIMEOUT, ALIVE_CHECK_MS);
376        }
377
378        updateNotification();
379
380        if ((mState == STATE_SUCCESS || mState == STATE_FAILED || mState == STATE_CANCELLED)
381                && !mCalledBack) {
382            mCalledBack = true;
383            // Notify that we're done with this transfer
384            mCallback.onTransferComplete(this, mState == STATE_SUCCESS);
385        }
386    }
387
388    void processFiles() {
389        // Check the amount of files we received in this transfer;
390        // If more than one, create a separate directory for it.
391        String extRoot = Environment.getExternalStorageDirectory().getPath();
392        File beamPath = new File(extRoot + "/" + BEAM_DIR);
393
394        if (!checkMediaStorage(beamPath) || mUris.size() == 0) {
395            Log.e(TAG, "Media storage not valid or no uris received.");
396            updateStateAndNotification(STATE_FAILED);
397            return;
398        }
399
400        if (mUris.size() > 1) {
401            beamPath = generateMultiplePath(extRoot + "/" + BEAM_DIR + "/");
402            if (!beamPath.isDirectory() && !beamPath.mkdir()) {
403                Log.e(TAG, "Failed to create multiple path " + beamPath.toString());
404                updateStateAndNotification(STATE_FAILED);
405                return;
406            }
407        }
408
409        for (int i = 0; i < mUris.size(); i++) {
410            Uri uri = mUris.get(i);
411            String mimeType = mTransferMimeTypes.get(i);
412
413            File srcFile = new File(uri.getPath());
414
415            File dstFile = generateUniqueDestination(beamPath.getAbsolutePath(),
416                    uri.getLastPathSegment());
417            Log.d(TAG, "Renaming from " + srcFile);
418            if (!srcFile.renameTo(dstFile)) {
419                if (DBG) Log.d(TAG, "Failed to rename from " + srcFile + " to " + dstFile);
420                srcFile.delete();
421                return;
422            } else {
423                mPaths.add(dstFile.getAbsolutePath());
424                mMimeTypes.put(dstFile.getAbsolutePath(), mimeType);
425                if (DBG) Log.d(TAG, "Did successful rename from " + srcFile + " to " + dstFile);
426            }
427        }
428
429        // We can either add files to the media provider, or provide an ACTION_VIEW
430        // intent to the file directly. We base this decision on the mime type
431        // of the first file; if it's media the platform can deal with,
432        // use the media provider, if it's something else, just launch an ACTION_VIEW
433        // on the file.
434        String mimeType = mMimeTypes.get(mPaths.get(0));
435        if (mimeType.startsWith("image/") || mimeType.startsWith("video/") ||
436                mimeType.startsWith("audio/")) {
437            String[] arrayPaths = new String[mPaths.size()];
438            MediaScannerConnection.scanFile(mContext, mPaths.toArray(arrayPaths), null, this);
439            updateStateAndNotification(STATE_W4_MEDIA_SCANNER);
440        } else {
441            // We're done.
442            updateStateAndNotification(STATE_SUCCESS);
443        }
444
445    }
446
447    public boolean handleMessage(Message msg) {
448        if (msg.what == MSG_NEXT_TRANSFER_TIMER) {
449            // We didn't receive a new transfer in time, finalize this one
450            if (mIncoming) {
451                processFiles();
452            } else {
453                updateStateAndNotification(mSuccessCount > 0 ? STATE_SUCCESS : STATE_FAILED);
454            }
455            return true;
456        } else if (msg.what == MSG_TRANSFER_TIMEOUT) {
457            // No update on this transfer for a while, fail it.
458            if (DBG) Log.d(TAG, "Transfer timed out for id: " + Integer.toString(mTransferId));
459            updateStateAndNotification(STATE_FAILED);
460        }
461        return false;
462    }
463
464    public synchronized void onScanCompleted(String path, Uri uri) {
465        if (DBG) Log.d(TAG, "Scan completed, path " + path + " uri " + uri);
466        if (uri != null) {
467            mMediaUris.put(path, uri);
468        }
469        mUrisScanned++;
470        if (mUrisScanned == mPaths.size()) {
471            // We're done
472            updateStateAndNotification(STATE_SUCCESS);
473        }
474    }
475
476
477    Intent buildViewIntent() {
478        if (mPaths.size() == 0) return null;
479
480        Intent viewIntent = new Intent(Intent.ACTION_VIEW);
481
482        String filePath = mPaths.get(0);
483        Uri mediaUri = mMediaUris.get(filePath);
484        Uri uri =  mediaUri != null ? mediaUri :
485            FileProvider.getUriForFile(mContext, "com.google.android.nfc.fileprovider",
486                    new File(filePath));
487        viewIntent.setDataAndTypeAndNormalize(uri, mMimeTypes.get(filePath));
488        viewIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
489                Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
490        return viewIntent;
491    }
492
493    PendingIntent buildCancelIntent() {
494        Intent intent = new Intent(BeamStatusReceiver.ACTION_CANCEL_HANDOVER_TRANSFER);
495        intent.putExtra(BeamStatusReceiver.EXTRA_ADDRESS, mRemoteDevice.getAddress());
496        intent.putExtra(BeamStatusReceiver.EXTRA_INCOMING, mIncoming ?
497                BeamStatusReceiver.DIRECTION_INCOMING : BeamStatusReceiver.DIRECTION_OUTGOING);
498        PendingIntent pi = PendingIntent.getBroadcast(mContext, mTransferId, intent,
499                PendingIntent.FLAG_ONE_SHOT);
500
501        return pi;
502    }
503
504    static boolean checkMediaStorage(File path) {
505        if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
506            if (!path.isDirectory() && !path.mkdir()) {
507                Log.e(TAG, "Not dir or not mkdir " + path.getAbsolutePath());
508                return false;
509            }
510            return true;
511        } else {
512            Log.e(TAG, "External storage not mounted, can't store file.");
513            return false;
514        }
515    }
516
517    static File generateUniqueDestination(String path, String fileName) {
518        int dotIndex = fileName.lastIndexOf(".");
519        String extension = null;
520        String fileNameWithoutExtension = null;
521        if (dotIndex < 0) {
522            extension = "";
523            fileNameWithoutExtension = fileName;
524        } else {
525            extension = fileName.substring(dotIndex);
526            fileNameWithoutExtension = fileName.substring(0, dotIndex);
527        }
528        File dstFile = new File(path + File.separator + fileName);
529        int count = 0;
530        while (dstFile.exists()) {
531            dstFile = new File(path + File.separator + fileNameWithoutExtension + "-" +
532                    Integer.toString(count) + extension);
533            count++;
534        }
535        return dstFile;
536    }
537
538    static File generateMultiplePath(String beamRoot) {
539        // Generate a unique directory with the date
540        String format = "yyyy-MM-dd";
541        SimpleDateFormat sdf = new SimpleDateFormat(format, Locale.US);
542        String newPath = beamRoot + "beam-" + sdf.format(new Date());
543        File newFile = new File(newPath);
544        int count = 0;
545        while (newFile.exists()) {
546            newPath = beamRoot + "beam-" + sdf.format(new Date()) + "-" +
547                    Integer.toString(count);
548            newFile = new File(newPath);
549            count++;
550        }
551        return newFile;
552    }
553}
554
555