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

Commit 5ba81e88 authored by TreeHugger Robot's avatar TreeHugger Robot Committed by Android (Google) Code Review
Browse files

Merge "[multi-part] Eliminate 1k selection limit"

parents 4611abe4 908f9889
Loading
Loading
Loading
Loading
+172 −37
Original line number Diff line number Diff line
@@ -16,9 +16,12 @@

package com.android.documentsui;

import android.content.SharedPreferences;
import android.net.Uri;
import android.os.AsyncTask;
import android.support.annotation.VisibleForTesting;
import android.system.ErrnoException;
import android.system.Os;
import android.util.Log;

import java.io.Closeable;
@@ -27,62 +30,157 @@ import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileLock;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.TimeUnit;

/**
 * Provides support for storing lists of documents identified by Uri.
 *
 * <li>Access to this object *must* be synchronized externally.
 * <li>All calls to this class are I/O intensive and must be wrapped in an AsyncTask.
 * This class uses a ring buffer to recycle clip file slots, to mitigate the issue of clip file
 * deletions.
 */
public final class ClipStorage {

    public static final int NO_SELECTION_TAG = -1;

    static final String PREF_NAME = "ClipStoragePref";

    @VisibleForTesting
    static final int NUM_OF_SLOTS = 20;

    private static final String TAG = "ClipStorage";

    private static final long STALENESS_THRESHOLD = TimeUnit.DAYS.toMillis(2);

    private static final String NEXT_POS_TAG = "NextPosTag";
    private static final String PRIMARY_DATA_FILE_NAME = "primary";

    private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
    public static final long NO_SELECTION_TAG = -1;

    private final File mOutDir;
    private final SharedPreferences mPref;

    private final File[] mSlots = new File[NUM_OF_SLOTS];
    private int mNextPos;

    /**
     * @param outDir see {@link #prepareStorage(File)}.
     */
    public ClipStorage(File outDir) {
    public ClipStorage(File outDir, SharedPreferences pref) {
        assert(outDir.isDirectory());
        mOutDir = outDir;
        mPref = pref;

        mNextPos = mPref.getInt(NEXT_POS_TAG, 0);
    }

    /**
     * Creates a clip tag.
     * Tries to get the next available clip slot. It's guaranteed to return one. If none of
     * slots is available, it returns the next slot of the most recently returned slot by this
     * method.
     *
     * NOTE: this tag doesn't guarantee perfect uniqueness, but should work well unless user creates
     * clips more than hundreds of times per second.
     * <p>This is not a perfect solution, but should be enough for most regular use. There are
     * several situations this method may not work:
     * <ul>
     *     <li>Making {@link #NUM_OF_SLOTS} - 1 times of large drag and drop or moveTo/copyTo/delete
     *     operations after cutting a primary clip, then the primary clip is overwritten.</li>
     *     <li>Having more than {@link #NUM_OF_SLOTS} queued jumbo file operations, one or more clip
     *     file may be overwritten.</li>
     * </ul>
     */
    public long createTag() {
        return System.currentTimeMillis();
    public synchronized int claimStorageSlot() {
        int curPos = mNextPos;
        for (int i = 0; i < NUM_OF_SLOTS; ++i, curPos = (curPos + 1) % NUM_OF_SLOTS) {
            createSlotFile(curPos);

            if (!mSlots[curPos].exists()) {
                break;
            }

            // No file or only primary file exists, we deem it available.
            if (mSlots[curPos].list().length <= 1) {
                break;
            }
            // This slot doesn't seem available, but still need to check if it's a legacy of
            // service being killed or a service crash etc. If it's stale, it's available.
            else if(checkStaleFiles(curPos)) {
                break;
            }
        }

        prepareSlot(curPos);

        mNextPos = (curPos + 1) % NUM_OF_SLOTS;
        mPref.edit().putInt(NEXT_POS_TAG, mNextPos).commit();
        return curPos;
    }

    private boolean checkStaleFiles(int pos) {
        File slotData = toSlotDataFile(pos);

        // No need to check if the file exists. File.lastModified() returns 0L if the file doesn't
        // exist.
        return slotData.lastModified() + STALENESS_THRESHOLD <= System.currentTimeMillis();
    }

    private void prepareSlot(int pos) {
        assert(mSlots[pos] != null);

        Files.deleteRecursively(mSlots[pos]);
        mSlots[pos].mkdir();
        assert(mSlots[pos].isDirectory());
    }

    /**
     * Returns a writer. Callers must close the writer when finished.
     */
    public Writer createWriter(long tag) throws IOException {
        File file = toTagFile(tag);
    private Writer createWriter(int tag) throws IOException {
        File file = toSlotDataFile(tag);
        return new Writer(file);
    }

    @VisibleForTesting
    public Reader createReader(long tag) throws IOException {
        File file = toTagFile(tag);
    /**
     * Gets a {@link File} instance given a tag.
     *
     * This method creates a symbolic link in the slot folder to the data file as a reference
     * counting method. When someone is done using this symlink, it's responsible to delete it.
     * Therefore we can have a neat way to track how many things are still using this slot.
     */
    public File getFile(int tag) throws IOException {
        createSlotFile(tag);

        File primary = toSlotDataFile(tag);

        String linkFileName = Integer.toString(mSlots[tag].list().length);
        File link = new File(mSlots[tag], linkFileName);

        try {
            Os.symlink(primary.getAbsolutePath(), link.getAbsolutePath());
        } catch (ErrnoException e) {
            e.rethrowAsIOException();
        }
        return link;
    }

    /**
     * Returns a Reader. Callers must close the reader when finished.
     */
    public Reader createReader(File file) throws IOException {
        assert(file.getParentFile().getParentFile().equals(mOutDir));
        return new Reader(file);
    }

    @VisibleForTesting
    public void delete(long tag) throws IOException {
        toTagFile(tag).delete();
    private File toSlotDataFile(int pos) {
        assert(mSlots[pos] != null);
        return new File(mSlots[pos], PRIMARY_DATA_FILE_NAME);
    }

    private File toTagFile(long tag) {
        return new File(mOutDir, String.valueOf(tag));
    private void createSlotFile(int pos) {
        if (mSlots[pos] == null) {
            mSlots[pos] = new File(mOutDir, Integer.toString(pos));
        }
    }

    /**
@@ -96,27 +194,39 @@ public final class ClipStorage {
        return clipDir;
    }

    public static boolean hasDocList(long tag) {
        return tag != NO_SELECTION_TAG;
    }

    private static File getClipDir(File cacheDir) {
        return new File(cacheDir, "clippings");
    }

    static final class Reader implements Iterable<Uri>, Closeable {

        /**
         * FileLock can't be held multiple times in a single JVM, but it's possible to have multiple
         * readers reading the same clip file. Share the FileLock here so that it can be released
         * when it's not needed.
         */
        private static final Map<String, FileLockEntry> sLocks = new HashMap<>();

        private final String mCanonicalPath;
        private final Scanner mScanner;
        private final FileLock mLock;

        private Reader(File file) throws IOException {
            FileInputStream inStream = new FileInputStream(file);

            // Lock the file here so it won't pass this line until the corresponding writer is done
            // writing.
            mLock = inStream.getChannel().lock(0L, Long.MAX_VALUE, true);

            mScanner = new Scanner(inStream);

            mCanonicalPath = file.getCanonicalPath(); // Resolve symlink
            synchronized (sLocks) {
                if (sLocks.containsKey(mCanonicalPath)) {
                    // Read lock is already held by someone in this JVM, just increment the ref
                    // count.
                    sLocks.get(mCanonicalPath).mCount++;
                } else {
                    // No map entry, need to lock the file so it won't pass this line until the
                    // corresponding writer is done writing.
                    FileLock lock = inStream.getChannel().lock(0L, Long.MAX_VALUE, true);
                    sLocks.put(mCanonicalPath, new FileLockEntry(1, lock, mScanner));
                }
            }
        }

        @Override
@@ -126,15 +236,24 @@ public final class ClipStorage {

        @Override
        public void close() throws IOException {
            if (mLock != null) {
                mLock.release();
            synchronized (sLocks) {
                FileLockEntry ref = sLocks.get(mCanonicalPath);

                assert(ref.mCount > 0);
                if (--ref.mCount == 0) {
                    // If ref count is 0 now, then there is no one who needs to hold the read lock.
                    // Release the lock, and remove the entry.
                    ref.mLock.release();
                    ref.mScanner.close();
                    sLocks.remove(mCanonicalPath);
                }

            if (mScanner != null) {
                if (mScanner != ref.mScanner) {
                    mScanner.close();
                }
            }
        }
    }

    private static final class Iterator implements java.util.Iterator {
        private final Scanner mScanner;
@@ -155,12 +274,28 @@ public final class ClipStorage {
        }
    }

    private static final class FileLockEntry {
        private int mCount;
        private FileLock mLock;
        // We need to keep this scanner here because if the scanner is closed, the file lock is
        // closed too.
        private Scanner mScanner;

        private FileLockEntry(int count, FileLock lock, Scanner scanner) {
            mCount = count;
            mLock = lock;
            mScanner = scanner;
        }
    }

    private static final class Writer implements Closeable {

        private final FileOutputStream mOut;
        private final FileLock mLock;

        private Writer(File file) throws IOException {
            assert(!file.exists());

            mOut = new FileOutputStream(file);

            // Lock the file here so copy tasks would wait until everything is flushed to disk
@@ -192,9 +327,9 @@ public final class ClipStorage {

        private final ClipStorage mClipStorage;
        private final Iterable<Uri> mUris;
        private final long mTag;
        private final int mTag;

        PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, long tag) {
        PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, int tag) {
            mClipStorage = clipStorage;
            mUris = uris;
            mTag = tag;
@@ -202,7 +337,7 @@ public final class ClipStorage {

        @Override
        protected Void doInBackground(Void... params) {
            try (ClipStorage.Writer writer = mClipStorage.createWriter(mTag)) {
            try(Writer writer = mClipStorage.createWriter(mTag)){
                for (Uri uri: mUris) {
                    assert(uri != null);
                    writer.write(uri);
+34 −151
Original line number Diff line number Diff line
@@ -21,9 +21,7 @@ import android.content.ClipDescription;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.BaseBundle;
import android.os.PersistableBundle;
import android.provider.DocumentsContract;
import android.support.annotation.Nullable;
@@ -48,7 +46,7 @@ import java.util.function.Function;
 * ClipboardManager wrapper class providing higher level logical
 * support for dealing with Documents.
 */
public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChangedListener {
public final class DocumentClipper {

    private static final String TAG = "DocumentClipper";

@@ -57,34 +55,14 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
    static final String OP_JUMBO_SELECTION_SIZE = "jumboSelection-size";
    static final String OP_JUMBO_SELECTION_TAG = "jumboSelection-tag";

    // Use shared preference to store last seen primary clip tag, so that we can delete the file
    // when we realize primary clip has been changed when we're not running.
    private static final String PREF_NAME = "DocumentClipperPref";
    private static final String LAST_PRIMARY_CLIP_TAG = "lastPrimaryClipTag";

    private final Context mContext;
    private final ClipStorage mClipStorage;
    private final ClipboardManager mClipboard;

    // Here we're tracking the last clipped tag ids so we can delete them later.
    private long mLastDragClipTag = ClipStorage.NO_SELECTION_TAG;
    private long mLastUnusedPrimaryClipTag = ClipStorage.NO_SELECTION_TAG;

    private final SharedPreferences mPref;

    DocumentClipper(Context context, ClipStorage storage) {
        mContext = context;
        mClipStorage = storage;
        mClipboard = context.getSystemService(ClipboardManager.class);

        mClipboard.addPrimaryClipChangedListener(this);

        // Primary clips may be changed when we're not running, now it's time to clean up the
        // remnant.
        mPref = context.getSharedPreferences(PREF_NAME, 0);
        mLastUnusedPrimaryClipTag =
                mPref.getLong(LAST_PRIMARY_CLIP_TAG, ClipStorage.NO_SELECTION_TAG);
        deleteLastUnusedPrimaryClip();
    }

    public boolean hasItemsToPaste() {
@@ -109,27 +87,11 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
        return uri != null && DocumentsContract.isDocumentUri(mContext, uri);
    }

    /**
     * Returns {@link ClipData} representing the selection, or null if selection is empty,
     * or cannot be converted.
     *
     * This is specialized for drag and drop so that we know which file to delete if nobody accepts
     * the drop.
     */
    public @Nullable ClipData getClipDataForDrag(
            Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {
        ClipData data = getClipDataForDocuments(uriBuilder, selection, opType);

        mLastDragClipTag = getTag(data);

        return data;
    }

    /**
     * Returns {@link ClipData} representing the selection, or null if selection is empty,
     * or cannot be converted.
     */
    private @Nullable ClipData getClipDataForDocuments(
    public ClipData getClipDataForDocuments(
        Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {

        assert(selection != null);
@@ -147,7 +109,7 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
    /**
     * Returns ClipData representing the selection.
     */
    private @Nullable ClipData createStandardClipData(
    private ClipData createStandardClipData(
            Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {

        assert(!selection.isEmpty());
@@ -178,7 +140,7 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
    /**
     * Returns ClipData representing the list of docs
     */
    private @Nullable ClipData createJumboClipData(
    private ClipData createJumboClipData(
            Function<String, Uri> uriBuilder, Selection selection, @OpType int opType) {

        assert(!selection.isEmpty());
@@ -210,8 +172,8 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
        bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size());

        // Creates a clip tag
        long tag = mClipStorage.createTag();
        bundle.putLong(OP_JUMBO_SELECTION_TAG, tag);
        int tag = mClipStorage.claimStorageSlot();
        bundle.putInt(OP_JUMBO_SELECTION_TAG, tag);

        ClipDescription description = new ClipDescription(
                "", // Currently "label" is not displayed anywhere in the UI.
@@ -232,7 +194,7 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
                getClipDataForDocuments(uriBuilder, selection, FileOperationService.OPERATION_COPY);
        assert(data != null);

        setPrimaryClip(data);
        mClipboard.setPrimaryClip(data);
    }

    /**
@@ -250,67 +212,9 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
        PersistableBundle bundle = data.getDescription().getExtras();
        bundle.putString(SRC_PARENT_KEY, parent.derivedUri.toString());

        setPrimaryClip(data);
    }

    private void setPrimaryClip(ClipData data) {
        deleteLastPrimaryClip();

        long tag = getTag(data);
        setLastUnusedPrimaryClipTag(tag);

        mClipboard.setPrimaryClip(data);
    }

    /**
     * Sets this primary tag to both class variable and shared preference.
     */
    private void setLastUnusedPrimaryClipTag(long tag) {
        mLastUnusedPrimaryClipTag = tag;
        mPref.edit().putLong(LAST_PRIMARY_CLIP_TAG, tag).commit();
    }

    /**
     * This is a good chance for us to remove previous clip file for cut/copy because we know a new
     * primary clip is set.
     */
    @Override
    public void onPrimaryClipChanged() {
        deleteLastUnusedPrimaryClip();
    }

    private void deleteLastUnusedPrimaryClip() {
        ClipData primary = mClipboard.getPrimaryClip();
        long primaryTag = getTag(primary);

        // onPrimaryClipChanged is also called after we call setPrimaryClip(), so make sure we don't
        // delete the clip file we just created.
        if (mLastUnusedPrimaryClipTag != primaryTag) {
            deleteLastPrimaryClip();
        }
    }

    private void deleteLastPrimaryClip() {
        deleteClip(mLastUnusedPrimaryClipTag);
        setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG);
    }

    /**
     * Deletes the last seen drag clip file.
     */
    public void deleteDragClip() {
        deleteClip(mLastDragClipTag);
        mLastDragClipTag = ClipStorage.NO_SELECTION_TAG;
    }

    private void deleteClip(long tag) {
        try {
            mClipStorage.delete(tag);
        } catch (IOException e) {
            Log.w(TAG, "Error deleting clip file with tag: " + tag, e);
        }
    }

    /**
     * Copies documents from clipboard. It's the same as {@link #copyFromClipData} with clipData
     * returned from {@link ClipboardManager#getPrimaryClip()}.
@@ -324,10 +228,6 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
            DocumentStack docStack,
            FileOperations.Callback callback) {

        // The primary clip has been claimed by a file operation. It's now the operation's duty
        // to make sure the clip file is deleted after use.
        setLastUnusedPrimaryClipTag(ClipStorage.NO_SELECTION_TAG);

        copyFromClipData(destination, docStack, mClipboard.getPrimaryClip(), callback);
    }

@@ -352,8 +252,8 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan

        PersistableBundle bundle = clipData.getDescription().getExtras();
        @OpType int opType = getOpType(bundle);
        UrisSupplier uris = UrisSupplier.create(clipData);

        try {
            UrisSupplier uris = UrisSupplier.create(clipData, mContext);
            if (!canCopy(destination)) {
                callback.onOperationResult(
                        FileOperations.Callback.STATUS_REJECTED, opType, 0);
@@ -379,6 +279,11 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
                    .build();

            FileOperations.start(mContext, operation, callback);
        } catch(IOException e) {
            Log.e(TAG, "Cannot create uris supplier.", e);
            callback.onOperationResult(FileOperations.Callback.STATUS_REJECTED, opType, 0);
            return;
        }
    }

    /**
@@ -397,28 +302,6 @@ public final class DocumentClipper implements ClipboardManager.OnPrimaryClipChan
        return true;
    }

    /**
     * Obtains tag from {@link ClipData}. Returns {@link ClipStorage#NO_SELECTION_TAG}
     * if it's not a jumbo clip.
     */
    private static long getTag(@Nullable ClipData data) {
        if (data == null) {
            return ClipStorage.NO_SELECTION_TAG;
        }

        ClipDescription description = data.getDescription();
        if (description == null) {
            return ClipStorage.NO_SELECTION_TAG;
        }

        BaseBundle bundle = description.getExtras();
        if (bundle == null) {
            return ClipStorage.NO_SELECTION_TAG;
        }

        return bundle.getLong(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG);
    }

    public static @OpType int getOpType(ClipData data) {
        PersistableBundle bundle = data.getDescription().getExtras();
        return getOpType(bundle);
+3 −1
Original line number Diff line number Diff line
@@ -77,7 +77,9 @@ public class DocumentsApplication extends Application {

        mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4);

        mClipStorage = new ClipStorage(ClipStorage.prepareStorage(getCacheDir()));
        mClipStorage = new ClipStorage(
                ClipStorage.prepareStorage(getCacheDir()),
                getSharedPreferences(ClipStorage.PREF_NAME, 0));
        mClipper = new DocumentClipper(this, mClipStorage);

        final IntentFilter packageFilter = new IntentFilter();
+4 −6
Original line number Diff line number Diff line
@@ -26,7 +26,6 @@ public final class Files {
    private Files() {}  // no initialization for utility classes.

    public static void deleteRecursively(File file) {
        if (file.exists()) {
        if (file.isDirectory()) {
            for (File child : file.listFiles()) {
                deleteRecursively(child);
@@ -35,4 +34,3 @@ public final class Files {
        file.delete();
    }
}
}
+38 −33

File changed.

Preview size limit exceeded, changes collapsed.

Loading