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