blob: 8db9b3c0b4640ac377fe4f1432f5a455969dc97c [file] [log] [blame]
/*
* Copyright (C) 2016 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.clipping;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.AsyncTask;
import androidx.annotation.VisibleForTesting;
import android.system.ErrnoException;
import android.system.Os;
import android.util.Log;
import com.android.documentsui.base.Files;
import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileLock;
import java.util.concurrent.TimeUnit;
/**
* Provides support for storing lists of documents identified by Uri.
*
* This class uses a ring buffer to recycle clip file slots, to mitigate the issue of clip file
* deletions. Below is the directory layout:
* [cache dir]
* - [dir] 1
* - [dir] 2
* - ... to {@link #NUM_OF_SLOTS}
* When a clip data is actively being used:
* [cache dir]
* - [dir] 1
* - [file] primary
* - [symlink] 1 > primary # copying to location X
* - [symlink] 2 > primary # copying to location Y
*/
public final class ClipStorage implements ClipStore {
public static final int NO_SELECTION_TAG = -1;
public 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_AVAIL_SLOT = "NextAvailableSlot";
private static final String PRIMARY_DATA_FILE_NAME = "primary";
private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
private final File mOutDir;
private final SharedPreferences mPref;
private final File[] mSlots = new File[NUM_OF_SLOTS];
private int mNextSlot;
/**
* @param outDir see {@link #prepareStorage(File)}.
*/
public ClipStorage(File outDir, SharedPreferences pref) {
assert(outDir.isDirectory());
mOutDir = outDir;
mPref = pref;
mNextSlot = mPref.getInt(NEXT_AVAIL_SLOT, 0);
}
/**
* 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.
*
* <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>
*
* Implementations should take caution to serialize access.
*/
@VisibleForTesting
synchronized int claimStorageSlot() {
int curSlot = mNextSlot;
for (int i = 0; i < NUM_OF_SLOTS; ++i, curSlot = (curSlot + 1) % NUM_OF_SLOTS) {
createSlotFileObject(curSlot);
if (!mSlots[curSlot].exists()) {
break;
}
// No file or only primary file exists, we deem it available.
if (mSlots[curSlot].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(curSlot)) {
break;
}
}
prepareSlot(curSlot);
mNextSlot = (curSlot + 1) % NUM_OF_SLOTS;
mPref.edit().putInt(NEXT_AVAIL_SLOT, mNextSlot).commit();
return curSlot;
}
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.
*/
private Writer createWriter(int slot) throws IOException {
File file = toSlotDataFile(slot);
return new Writer(file);
}
@Override
public synchronized File getFile(int slot) throws IOException {
createSlotFileObject(slot);
File primary = toSlotDataFile(slot);
String linkFileName = Integer.toString(mSlots[slot].list().length);
File link = new File(mSlots[slot], linkFileName);
try {
Os.symlink(primary.getAbsolutePath(), link.getAbsolutePath());
} catch (ErrnoException e) {
IOException newException = new IOException(e.getMessage());
newException.initCause(e);
throw newException;
}
return link;
}
@Override
public ClipStorageReader createReader(File file) throws IOException {
assert(file.getParentFile().getParentFile().equals(mOutDir));
return new ClipStorageReader(file);
}
private File toSlotDataFile(int pos) {
assert(mSlots[pos] != null);
return new File(mSlots[pos], PRIMARY_DATA_FILE_NAME);
}
private void createSlotFileObject(int pos) {
if (mSlots[pos] == null) {
mSlots[pos] = new File(mOutDir, Integer.toString(pos));
}
}
/**
* Provides initialization of the clip data storage directory.
*/
public static File prepareStorage(File cacheDir) {
File clipDir = getClipDir(cacheDir);
clipDir.mkdir();
assert(clipDir.isDirectory());
return clipDir;
}
private static File getClipDir(File cacheDir) {
return new File(cacheDir, "clippings");
}
public 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
// before start to run.
mLock = mOut.getChannel().lock();
}
public void write(Uri uri) throws IOException {
mOut.write(uri.toString().getBytes());
mOut.write(LINE_SEPARATOR);
}
@Override
public void close() throws IOException {
if (mLock != null) {
mLock.release();
}
if (mOut != null) {
mOut.close();
}
}
}
@Override
public int persistUris(Iterable<Uri> uris) {
int slot = claimStorageSlot();
persistUris(uris, slot);
return slot;
}
@VisibleForTesting
void persistUris(Iterable<Uri> uris, int slot) {
new PersistTask(this, uris, slot).execute();
}
/**
* An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
*/
private static final class PersistTask extends AsyncTask<Void, Void, Void> {
private final ClipStorage mClipStore;
private final Iterable<Uri> mUris;
private final int mSlot;
PersistTask(ClipStorage clipStore, Iterable<Uri> uris, int slot) {
mClipStore = clipStore;
mUris = uris;
mSlot = slot;
}
@Override
protected Void doInBackground(Void... params) {
try(Writer writer = mClipStore.createWriter(mSlot)){
for (Uri uri: mUris) {
assert(uri != null);
writer.write(uri);
}
} catch (IOException e) {
Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e);
}
return null;
}
}
}