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
17package com.android.documentsui.clipping;
18
19import android.content.SharedPreferences;
20import android.net.Uri;
21import android.os.AsyncTask;
22import android.support.annotation.VisibleForTesting;
23import android.system.ErrnoException;
24import android.system.Os;
25import android.util.Log;
26
27import com.android.documentsui.base.Files;
28
29import java.io.Closeable;
30import java.io.File;
31import java.io.FileOutputStream;
32import java.io.IOException;
33import java.nio.channels.FileLock;
34import 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 */
52public 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     */
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
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
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
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     */
150    private Writer createWriter(int slot) throws IOException {
151        File file = toSlotDataFile(slot);
152        return new Writer(file);
153    }
154
155    @Override
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            e.rethrowAsIOException();
168        }
169        return link;
170    }
171
172    @Override
173    public ClipStorageReader createReader(File file) throws IOException {
174        assert(file.getParentFile().getParentFile().equals(mOutDir));
175        return new ClipStorageReader(file);
176    }
177
178    private File toSlotDataFile(int pos) {
179        assert(mSlots[pos] != null);
180        return new File(mSlots[pos], PRIMARY_DATA_FILE_NAME);
181    }
182
183    private void createSlotFileObject(int pos) {
184        if (mSlots[pos] == null) {
185            mSlots[pos] = new File(mOutDir, Integer.toString(pos));
186        }
187    }
188
189    /**
190     * Provides initialization of the clip data storage directory.
191     */
192    public static File prepareStorage(File cacheDir) {
193        File clipDir = getClipDir(cacheDir);
194        clipDir.mkdir();
195
196        assert(clipDir.isDirectory());
197        return clipDir;
198    }
199
200    private static File getClipDir(File cacheDir) {
201        return new File(cacheDir, "clippings");
202    }
203
204    public static final class Writer implements Closeable {
205
206        private final FileOutputStream mOut;
207        private final FileLock mLock;
208
209        private Writer(File file) throws IOException {
210            assert(!file.exists());
211
212            mOut = new FileOutputStream(file);
213
214            // Lock the file here so copy tasks would wait until everything is flushed to disk
215            // before start to run.
216            mLock = mOut.getChannel().lock();
217        }
218
219        public void write(Uri uri) throws IOException {
220            mOut.write(uri.toString().getBytes());
221            mOut.write(LINE_SEPARATOR);
222        }
223
224        @Override
225        public void close() throws IOException {
226            if (mLock != null) {
227                mLock.release();
228            }
229
230            if (mOut != null) {
231                mOut.close();
232            }
233        }
234    }
235
236    @Override
237    public int persistUris(Iterable<Uri> uris) {
238        int slot = claimStorageSlot();
239        persistUris(uris, slot);
240        return slot;
241    }
242
243    @VisibleForTesting
244    void persistUris(Iterable<Uri> uris, int slot) {
245        new PersistTask(this, uris, slot).execute();
246    }
247
248    /**
249     * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
250     */
251    private static final class PersistTask extends AsyncTask<Void, Void, Void> {
252
253        private final ClipStorage mClipStore;
254        private final Iterable<Uri> mUris;
255        private final int mSlot;
256
257        PersistTask(ClipStorage clipStore, Iterable<Uri> uris, int slot) {
258            mClipStore = clipStore;
259            mUris = uris;
260            mSlot = slot;
261        }
262
263        @Override
264        protected Void doInBackground(Void... params) {
265            try(Writer writer = mClipStore.createWriter(mSlot)){
266                for (Uri uri: mUris) {
267                    assert(uri != null);
268                    writer.write(uri);
269                }
270            } catch (IOException e) {
271                Log.e(TAG, "Caught exception trying to write jumbo clip to disk.", e);
272            }
273
274            return null;
275        }
276    }
277}
278