diff options
author | Ben Kwa <kenobi@google.com> | 2015-03-31 10:11:43 -0700 |
---|---|---|
committer | Ben Kwa <kenobi@google.com> | 2015-04-03 13:49:24 -0700 |
commit | d99109fca847895233b0bdfafa131ebca8dfe3d5 (patch) | |
tree | a72b11439c756a9137fb9b11144980d098dea1b5 /packages/DocumentsUI | |
parent | 3bcc9488a17b25e569c982e9e7596e6d34384c5b (diff) | |
download | frameworks_base-d99109fca847895233b0bdfafa131ebca8dfe3d5.zip frameworks_base-d99109fca847895233b0bdfafa131ebca8dfe3d5.tar.gz frameworks_base-d99109fca847895233b0bdfafa131ebca8dfe3d5.tar.bz2 |
Enable directory selection. Add an IntentService to copy files.
Change-Id: I0bec0224aa1b52766664c23f77d60affec702111
Diffstat (limited to 'packages/DocumentsUI')
5 files changed, 327 insertions, 0 deletions
diff --git a/packages/DocumentsUI/AndroidManifest.xml b/packages/DocumentsUI/AndroidManifest.xml index 165b11e..a6f7a26 100644 --- a/packages/DocumentsUI/AndroidManifest.xml +++ b/packages/DocumentsUI/AndroidManifest.xml @@ -65,5 +65,10 @@ <data android:scheme="package" /> </intent-filter> </receiver> + + <service + android:name=".CopyService" + android:exported="false"> + </service> </application> </manifest> diff --git a/packages/DocumentsUI/res/menu/mode_directory.xml b/packages/DocumentsUI/res/menu/mode_directory.xml index 695060d..4b89823 100644 --- a/packages/DocumentsUI/res/menu/mode_directory.xml +++ b/packages/DocumentsUI/res/menu/mode_directory.xml @@ -33,4 +33,8 @@ android:id="@+id/menu_select_all" android:title="@string/menu_select_all" android:showAsAction="never" /> + <item + android:id="@+id/menu_copy" + android:title="@string/menu_copy" + android:showAsAction="never" /> </menu> diff --git a/packages/DocumentsUI/res/values/strings.xml b/packages/DocumentsUI/res/values/strings.xml index 4ad337d..310ccf0 100644 --- a/packages/DocumentsUI/res/values/strings.xml +++ b/packages/DocumentsUI/res/values/strings.xml @@ -48,6 +48,8 @@ <string name="menu_select">Select \"<xliff:g id="directory" example="My Directory">^1</xliff:g>\"</string> <!-- Menu item title that selects all documents in the current directory [CHAR LIMIT=24] --> <string name="menu_select_all">Select All</string> + <!-- Menu item title that copies the selected documents [CHAR LIMIT=24] --> + <string name="menu_copy">Copy to\u2026</string> <!-- Menu item that reveals internal storage built into the device [CHAR LIMIT=24] --> <string name="menu_advanced_show" product="nosdcard">Show internal storage</string> @@ -110,4 +112,16 @@ <!-- Title of dialog when prompting user to select an app to share documents with [CHAR LIMIT=32] --> <string name="share_via">Share via</string> + <!-- Title of the cancel button [CHAR LIMIT=24] --> + <string name="cancel">Cancel</string> + <!-- Title of the copy notification [CHAR LIMIT=24] --> + <string name="copy_notification_title">Copying files</string> + <!-- Text shown on the copy notification to indicate remaining time, in minutes [CHAR LIMIT=24] --> + <string name="copy_remaining"><xliff:g id="duration" example="3 minutes">%s</xliff:g> left</string> + <!-- Toast shown when a file copy is kicked off --> + <plurals name="copy_begin"> + <item quantity="one">Copying <xliff:g id="count" example="1">%1$d</xliff:g> file.</item> + <item quantity="other">Copying <xliff:g id="count" example="3">%1$d</xliff:g> files.</item> + </plurals> + </resources> diff --git a/packages/DocumentsUI/src/com/android/documentsui/CopyService.java b/packages/DocumentsUI/src/com/android/documentsui/CopyService.java new file mode 100644 index 0000000..f7d8cc4 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/CopyService.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui; + +import android.app.IntentService; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.SystemClock; +import android.text.format.DateUtils; +import android.util.Log; + +import com.android.documentsui.model.DocumentInfo; + +import libcore.io.IoUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.NumberFormat; +import java.util.ArrayList; + +public class CopyService extends IntentService { + public static final String TAG = "CopyService"; + public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST"; + private static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL"; + + private NotificationManager mNotificationManager; + private Notification.Builder mProgressBuilder; + + // Jobs are serialized but a job ID is used, to avoid mixing up cancellation requests. + private String mJobId; + private volatile boolean mIsCancelled; + // Parameters of the copy job. Requests to an IntentService are serialized so this code only + // needs to deal with one job at a time. + private long mBatchSize; + private long mBytesCopied; + private long mStartTime; + private long mLastNotificationTime; + // Speed estimation + private long mBytesCopiedSample; + private long mSampleTime; + private long mSpeed; + private long mRemainingTime; + + public CopyService() { + super("CopyService"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent.hasExtra(EXTRA_CANCEL)) { + handleCancel(intent); + } + return super.onStartCommand(intent, flags, startId); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent.hasExtra(EXTRA_CANCEL)) { + handleCancel(intent); + return; + } + + ArrayList<DocumentInfo> srcs = intent.getParcelableArrayListExtra(EXTRA_SRC_LIST); + // Use the app local files dir as a copy destination for now. This resolves to + // /data/data/com.android.documentsui/files. + // TODO: Add actual destination picking. + File destinationDir = getFilesDir(); + + setupCopyJob(srcs, destinationDir); + + ArrayList<String> failedFilenames = new ArrayList<String>(); + for (int i = 0; i < srcs.size() && !mIsCancelled; ++i) { + DocumentInfo src = srcs.get(i); + try { + copyFile(src, destinationDir); + } catch (IOException e) { + Log.e(TAG, "Failed to copy " + src.displayName, e); + failedFilenames.add(src.displayName); + } + } + + if (failedFilenames.size() > 0) { + // TODO: Display a notification when an error has occurred. + } + + // Dismiss the ongoing copy notification when the copy is done. + mNotificationManager.cancel(mJobId, 0); + + // TODO: Display a toast if the copy was cancelled. + } + + @Override + public void onCreate() { + super.onCreate(); + mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + } + + /** + * Sets up the CopyService to start tracking and sending notifications for the given batch of + * files. + * + * @param srcs A list of src files to copy. + */ + private void setupCopyJob(ArrayList<DocumentInfo> srcs, File destinationDir) { + // Create an ID for this copy job. Use the timestamp. + mJobId = String.valueOf(SystemClock.elapsedRealtime()); + // Reset the cancellation flag. + mIsCancelled = false; + + mProgressBuilder = new Notification.Builder(this) + .setContentTitle(getString(R.string.copy_notification_title)) + .setCategory(Notification.CATEGORY_PROGRESS) + .setSmallIcon(R.drawable.ic_menu_copy).setOngoing(true); + + Intent cancelIntent = new Intent(this, CopyService.class); + cancelIntent.putExtra(EXTRA_CANCEL, mJobId); + mProgressBuilder.addAction(R.drawable.ic_cab_cancel, + getString(R.string.cancel), PendingIntent.getService(this, 0, + cancelIntent, PendingIntent.FLAG_ONE_SHOT)); + + // TODO: Add a content intent to open the destination folder. + + // Send an initial progress notification. + mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); + + // Reset batch parameters. + mBatchSize = 0; + for (DocumentInfo doc : srcs) { + mBatchSize += doc.size; + } + mBytesCopied = 0; + mStartTime = SystemClock.elapsedRealtime(); + mLastNotificationTime = 0; + mBytesCopiedSample = 0; + mSampleTime = 0; + mSpeed = 0; + mRemainingTime = 0; + + // TODO: Check preconditions for copy. + // - check that the destination has enough space and is writeable? + // - check MIME types? + } + + /** + * Cancels the current copy job, if its ID matches the given ID. + * + * @param intent The cancellation intent. + */ + private void handleCancel(Intent intent) { + final String cancelledId = intent.getStringExtra(EXTRA_CANCEL); + // Do nothing if the cancelled ID doesn't match the current job ID. This prevents racey + // cancellation requests from affecting unrelated copy jobs. + if (java.util.Objects.equals(mJobId, cancelledId)) { + // Set the cancel flag. This causes the copy loops to exit. + mIsCancelled = true; + // Dismiss the progress notification here rather than in the copy loop. This preserves + // interactivity for the user in case the copy loop is stalled. + mNotificationManager.cancel(mJobId, 0); + } + } + + /** + * Logs progress on the current copy operation. Displays/Updates the progress notification. + * + * @param bytesCopied + */ + private void makeProgress(long bytesCopied) { + mBytesCopied += bytesCopied; + double done = (double) mBytesCopied / mBatchSize; + String percent = NumberFormat.getPercentInstance().format(done); + + // Update time estimate + long currentTime = SystemClock.elapsedRealtime(); + long elapsedTime = currentTime - mStartTime; + + // Send out progress notifications once a second. + if (currentTime - mLastNotificationTime > 1000) { + updateRemainingTimeEstimate(elapsedTime); + mProgressBuilder.setProgress(100, (int) (done * 100), false); + mProgressBuilder.setContentInfo(percent); + if (mRemainingTime > 0) { + mProgressBuilder.setContentText(getString(R.string.copy_remaining, + DateUtils.formatDuration(mRemainingTime))); + } else { + mProgressBuilder.setContentText(null); + } + mNotificationManager.notify(mJobId, 0, mProgressBuilder.build()); + mLastNotificationTime = currentTime; + } + } + + /** + * Generates an estimate of the remaining time in the copy. + * + * @param elapsedTime The time elapsed so far. + */ + private void updateRemainingTimeEstimate(long elapsedTime) { + final long sampleDuration = elapsedTime - mSampleTime; + final long sampleSpeed = ((mBytesCopied - mBytesCopiedSample) * 1000) / sampleDuration; + if (mSpeed == 0) { + mSpeed = sampleSpeed; + } else { + mSpeed = ((3 * mSpeed) + sampleSpeed) / 4; + } + + if (mSampleTime > 0 && mSpeed > 0) { + mRemainingTime = ((mBatchSize - mBytesCopied) * 1000) / mSpeed; + } else { + mRemainingTime = 0; + } + + mSampleTime = elapsedTime; + mBytesCopiedSample = mBytesCopied; + } + + /** + * Copies a file to a given location. + * + * @param srcInfo The source file. + * @param destination The directory to copy into. + * @throws IOException + */ + private void copyFile(DocumentInfo srcInfo, File destinationDir) + throws IOException { + final Context context = getApplicationContext(); + final ContentResolver resolver = context.getContentResolver(); + final File destinationFile = new File(destinationDir, srcInfo.displayName); + final Uri destinationUri = Uri.fromFile(destinationFile); + + InputStream source = null; + OutputStream destination = null; + + boolean errorOccurred = false; + try { + source = resolver.openInputStream(srcInfo.derivedUri); + destination = resolver.openOutputStream(destinationUri); + + byte[] buffer = new byte[8192]; + int len; + while (!mIsCancelled && ((len = source.read(buffer)) != -1)) { + destination.write(buffer, 0, len); + makeProgress(len); + } + } catch (IOException e) { + errorOccurred = true; + Log.e(TAG, "Error while copying " + srcInfo.displayName, e); + } finally { + IoUtils.closeQuietly(source); + IoUtils.closeQuietly(destination); + } + + if (errorOccurred || mIsCancelled) { + // Clean up half-copied files. + if (!destinationFile.delete()) { + Log.w(TAG, "Failed to clean up partially copied file " + srcInfo.displayName); + } + } + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java index a75dc42..d6d691f 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java @@ -50,6 +50,7 @@ import android.os.Bundle; import android.os.CancellationSignal; import android.os.OperationCanceledException; import android.os.Parcelable; +import android.os.SystemProperties; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.text.format.DateUtils; @@ -77,6 +78,7 @@ import android.widget.TextView; import android.widget.Toast; import com.android.documentsui.BaseActivity.State; +import com.android.documentsui.CopyService; import com.android.documentsui.ProviderExecutor.Preemptable; import com.android.documentsui.RecentsProvider.StateColumns; import com.android.documentsui.model.DocumentInfo; @@ -463,11 +465,14 @@ public class DirectoryFragment extends Fragment { final MenuItem open = menu.findItem(R.id.menu_open); final MenuItem share = menu.findItem(R.id.menu_share); final MenuItem delete = menu.findItem(R.id.menu_delete); + final MenuItem copy = menu.findItem(R.id.menu_copy); final boolean manageMode = state.action == ACTION_MANAGE; open.setVisible(!manageMode); share.setVisible(manageMode); delete.setVisible(manageMode); + // Hide the copy feature by default. + copy.setVisible(SystemProperties.getBoolean("debug.documentsui.enable_copy", false)); return true; } @@ -501,6 +506,11 @@ public class DirectoryFragment extends Fragment { mode.finish(); return true; + } else if (id == R.id.menu_copy) { + onCopyDocuments(docs); + mode.finish(); + return true; + } else if (id == R.id.menu_select_all) { int count = mCurrentView.getCount(); for (int i = 0; i < count; i++) { @@ -623,6 +633,20 @@ public class DirectoryFragment extends Fragment { } } + private void onCopyDocuments(List<DocumentInfo> docs) { + final Context context = getActivity(); + final Resources res = context.getResources(); + + Intent copyIntent = new Intent(context, CopyService.class); + copyIntent.putParcelableArrayListExtra(CopyService.EXTRA_SRC_LIST, + new ArrayList<DocumentInfo>(docs)); + + Toast.makeText(context, + res.getQuantityString(R.plurals.copy_begin, docs.size(), docs.size()), + Toast.LENGTH_SHORT).show(); + context.startService(copyIntent); + } + private static State getDisplayState(Fragment fragment) { return ((BaseActivity) fragment.getActivity()).getDisplayState(); } |