Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit d99109fc authored by Ben Kwa's avatar Ben Kwa
Browse files

Enable directory selection. Add an IntentService to copy files.

Change-Id: I0bec0224aa1b52766664c23f77d60affec702111
parent 3bcc9488
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -65,5 +65,10 @@
                <data android:scheme="package" />
            </intent-filter>
        </receiver>

        <service
            android:name=".CopyService"
            android:exported="false">
        </service>
    </application>
</manifest>
+4 −0
Original line number Diff line number Diff line
@@ -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>
+14 −0
Original line number Diff line number Diff line
@@ -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>
+280 −0
Original line number Diff line number Diff line
/*
 * 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);
            }
        }
    }
}
+24 −0
Original line number Diff line number Diff line
@@ -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();
    }