1 /*
2  * Copyright (C) 2016 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.documentsui.clipping;
18 
19 import android.content.SharedPreferences;
20 import android.net.Uri;
21 import android.os.AsyncTask;
22 import androidx.annotation.VisibleForTesting;
23 import android.system.ErrnoException;
24 import android.system.Os;
25 import android.util.Log;
26 
27 import com.android.documentsui.base.Files;
28 
29 import java.io.Closeable;
30 import java.io.File;
31 import java.io.FileOutputStream;
32 import java.io.IOException;
33 import java.nio.channels.FileLock;
34 import java.util.concurrent.TimeUnit;
35 
36 /**
37  * Provides support for storing lists of documents identified by Uri.
38  *
39  * This class uses a ring buffer to recycle clip file slots, to mitigate the issue of clip file
40  * deletions. Below is the directory layout:
41  * [cache dir]
42  *      - [dir] 1
43  *      - [dir] 2
44  *      - ... to {@link #NUM_OF_SLOTS}
45  * When a clip data is actively being used:
46  * [cache dir]
47  *      - [dir] 1
48  *          - [file] primary
49  *          - [symlink] 1 > primary # copying to location X
50  *          - [symlink] 2 > primary # copying to location Y
51  */
52 public final class ClipStorage implements ClipStore {
53 
54     public static final int NO_SELECTION_TAG = -1;
55 
56     public static final String PREF_NAME = "ClipStoragePref";
57 
58     @VisibleForTesting
59     static final int NUM_OF_SLOTS = 20;
60 
61     private static final String TAG = "ClipStorage";
62 
63     private static final long STALENESS_THRESHOLD = TimeUnit.DAYS.toMillis(2);
64 
65     private static final String NEXT_AVAIL_SLOT = "NextAvailableSlot";
66     private static final String PRIMARY_DATA_FILE_NAME = "primary";
67 
68     private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
69 
70     private final File mOutDir;
71     private final SharedPreferences mPref;
72 
73     private final File[] mSlots = new File[NUM_OF_SLOTS];
74     private int mNextSlot;
75 
76     /**
77      * @param outDir see {@link #prepareStorage(File)}.
78      */
ClipStorage(File outDir, SharedPreferences pref)79     public ClipStorage(File outDir, SharedPreferences pref) {
80         assert(outDir.isDirectory());
81         mOutDir = outDir;
82         mPref = pref;
83 
84         mNextSlot = mPref.getInt(NEXT_AVAIL_SLOT, 0);
85     }
86 
87     /**
88      * Tries to get the next available clip slot. It's guaranteed to return one. If none of
89      * slots is available, it returns the next slot of the most recently returned slot by this
90      * method.
91      *
92      * <p>This is not a perfect solution, but should be enough for most regular use. There are
93      * several situations this method may not work:
94      * <ul>
95      *     <li>Making {@link #NUM_OF_SLOTS} - 1 times of large drag and drop or moveTo/copyTo/delete
96      *     operations after cutting a primary clip, then the primary clip is overwritten.</li>
97      *     <li>Having more than {@link #NUM_OF_SLOTS} queued jumbo file operations, one or more clip
98      *     file may be overwritten.</li>
99      * </ul>
100      *
101      * Implementations should take caution to serialize access.
102      */
103     @VisibleForTesting
claimStorageSlot()104     synchronized int claimStorageSlot() {
105         int curSlot = mNextSlot;
106         for (int i = 0; i < NUM_OF_SLOTS; ++i, curSlot = (curSlot + 1) % NUM_OF_SLOTS) {
107             createSlotFileObject(curSlot);
108 
109             if (!mSlots[curSlot].exists()) {
110                 break;
111             }
112 
113             // No file or only primary file exists, we deem it available.
114             if (mSlots[curSlot].list().length <= 1) {
115                 break;
116             }
117             // This slot doesn't seem available, but still need to check if it's a legacy of
118             // service being killed or a service crash etc. If it's stale, it's available.
119             else if (checkStaleFiles(curSlot)) {
120                 break;
121             }
122         }
123 
124         prepareSlot(curSlot);
125 
126         mNextSlot = (curSlot + 1) % NUM_OF_SLOTS;
127         mPref.edit().putInt(NEXT_AVAIL_SLOT, mNextSlot).commit();
128         return curSlot;
129     }
130 
checkStaleFiles(int pos)131     private boolean checkStaleFiles(int pos) {
132         File slotData = toSlotDataFile(pos);
133 
134         // No need to check if the file exists. File.lastModified() returns 0L if the file doesn't
135         // exist.
136         return slotData.lastModified() + STALENESS_THRESHOLD <= System.currentTimeMillis();
137     }
138 
prepareSlot(int pos)139     private void prepareSlot(int pos) {
140         assert(mSlots[pos] != null);
141 
142         Files.deleteRecursively(mSlots[pos]);
143         mSlots[pos].mkdir();
144         assert(mSlots[pos].isDirectory());
145     }
146 
147     /**
148      * Returns a writer. Callers must close the writer when finished.
149      */
createWriter(int slot)150     private Writer createWriter(int slot) throws IOException {
151         File file = toSlotDataFile(slot);
152         return new Writer(file);
153     }
154 
155     @Override
getFile(int slot)156     public synchronized File getFile(int slot) throws IOException {
157         createSlotFileObject(slot);
158 
159         File primary = toSlotDataFile(slot);
160 
161         String linkFileName = Integer.toString(mSlots[slot].list().length);
162         File link = new File(mSlots[slot], linkFileName);
163 
164         try {
165             Os.symlink(primary.getAbsolutePath(), link.getAbsolutePath());
166         } catch (ErrnoException e) {
167             IOException newException = new IOException(e.getMessage());
168             newException.initCause(e);
169             throw newException;
170         }
171         return link;
172     }
173 
174     @Override
createReader(File file)175     public ClipStorageReader createReader(File file) throws IOException {
176         assert(file.getParentFile().getParentFile().equals(mOutDir));
177         return new ClipStorageReader(file);
178     }
179 
toSlotDataFile(int pos)180     private File toSlotDataFile(int pos) {
181         assert(mSlots[pos] != null);
182         return new File(mSlots[pos], PRIMARY_DATA_FILE_NAME);
183     }
184 
createSlotFileObject(int pos)185     private void createSlotFileObject(int pos) {
186         if (mSlots[pos] == null) {
187             mSlots[pos] = new File(mOutDir, Integer.toString(pos));
188         }
189     }
190 
191     /**
192      * Provides initialization of the clip data storage directory.
193      */
prepareStorage(File cacheDir)194     public static File prepareStorage(File cacheDir) {
195         File clipDir = getClipDir(cacheDir);
196         clipDir.mkdir();
197 
198         assert(clipDir.isDirectory());
199         return clipDir;
200     }
201 
getClipDir(File cacheDir)202     private static File getClipDir(File cacheDir) {
203         return new File(cacheDir, "clippings");
204     }
205 
206     public static final class Writer implements Closeable {
207 
208         private final FileOutputStream mOut;
209         private final FileLock mLock;
210 
Writer(File file)211         private Writer(File file) throws IOException {
212             assert(!file.exists());
213 
214             mOut = new FileOutputStream(file);
215 
216             // Lock the file here so copy tasks would wait until everything is flushed to disk
217             // before start to run.
218             mLock = mOut.getChannel().lock();
219         }
220 
write(Uri uri)221         public void write(Uri uri) throws IOException {
222             mOut.write(uri.toString().getBytes());
223             mOut.write(LINE_SEPARATOR);
224         }
225 
226         @Override
close()227         public void close() throws IOException {
228             if (mLock != null) {
229                 mLock.release();
230             }
231 
232             if (mOut != null) {
233                 mOut.close();
234             }
235         }
236     }
237 
238     @Override
persistUris(Iterable<Uri> uris)239     public int persistUris(Iterable<Uri> uris) {
240         int slot = claimStorageSlot();
241         persistUris(uris, slot);
242         return slot;
243     }
244 
245     @VisibleForTesting
persistUris(Iterable<Uri> uris, int slot)246     void persistUris(Iterable<Uri> uris, int slot) {
247         new PersistTask(this, uris, slot).execute();
248     }
249 
250     /**
251      * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
252      */
253     private static final class PersistTask extends AsyncTask<Void, Void, Void> {
254 
255         private final ClipStorage mClipStore;
256         private final Iterable<Uri> mUris;
257         private final int mSlot;
258 
PersistTask(ClipStorage clipStore, Iterable<Uri> uris, int slot)259         PersistTask(ClipStorage clipStore, Iterable<Uri> uris, int slot) {
260             mClipStore = clipStore;
261             mUris = uris;
262             mSlot = slot;
263         }
264 
265         @Override
doInBackground(Void... params)266         protected Void doInBackground(Void... params) {
267             try(Writer writer = mClipStore.createWriter(mSlot)){
268                 for (Uri uri: mUris) {
269                     assert(uri != null);
270                     writer.write(uri);
271                 }
272             } catch (IOException e) {
273                 Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e);
274             }
275 
276             return null;
277         }
278     }
279 }
280