summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java
blob: e60abac71b77e63e192619cb006d6ad9a3eb61f8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.reader;

import android.content.Context;
import android.support.annotation.NonNull;
import android.util.Log;

import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.annotation.RobocopTarget;
import org.mozilla.gecko.db.BrowserDB;
import org.mozilla.gecko.db.UrlAnnotations;
import org.mozilla.gecko.util.ThreadUtils;

import java.io.File;
import java.io.IOException;
import java.util.Iterator;

/**
 * Helper to keep track of items that are stored in the reader view cache. This is an in-memory list
 * of the reader view items that are cached on disk. It is intended to allow quickly determining whether
 * a given URL is in the cache, and also how many cached items there are.
 *
 * Currently we have 1:1 correspondence of reader view items (in the URL-annotations table)
 * to cached items. This is _not_ a true cache, we never purge/cleanup items here - we only remove
 * items when we un-reader-view/bookmark them. This is an acceptable model while we can guarantee the
 * 1:1 correspondence.
 *
 * It isn't strictly necessary to mirror cached items in SQL at this stage, however it seems sensible
 * to maintain URL anotations to avoid additional DB migrations in future.
 * It is also simpler to implement the reading list smart-folder using the annotations (even if we do
 * all other decoration from our in-memory cache record), as that is what we will need when
 * we move away from the 1:1 correspondence.
 *
 * Bookmarks can be in one of two states - plain bookmark, or reader view bookmark that is also saved
 * offline. We're hoping to introduce real cache management / cleanup in future, in which case a
 * third user-visible state (reader view bookmark without a cache entry) will be added. However that logic is
 * much more complicated and requires substantial changes in how we decorate reader view bookmarks.
 * With the current 1:1 correspondence we can use this in-memory helper to quickly decorate
 * bookmarks (in all the various lists and panels that are used), whereas supporting
 * the third state requires significant changes in order to allow joining with the
 * URL-annotations table wherever bookmarks might be retrieved (i.e. multiple homepanels, each with
 * their own loaders and adapter).
 *
 * If/when cache cleanup and sync are implemented, URL annotations will be the canonical record of
 * user intent, and the cache will no longer represent all reader view bookmarks. We will have (A)
 * cached items that are not a bookmark, or bookmarks without the reader view annotation (both of
 * these would need purging), and (B) bookmarks with a reader view annotation, but not stored in
 * the cache (which we might want to download in the background). Supporting (B) is currently difficult,
 * see previous paragraph.
 */
public class SavedReaderViewHelper {
    private static final String LOG_TAG = "SavedReaderViewHelper";

    private static final String PATH = "path";
    private static final String SIZE = "size";

    private static final String DIRECTORY = "readercache";
    private static final String FILE_NAME = "items.json";
    private static final String FILE_PATH = DIRECTORY + "/" + FILE_NAME;

    // We use null to indicate that the cache hasn't yet been loaded. Loading has to be explicitly
    // requested by client code, and must happen on the background thread. Attempting to access
    // items (which happens mainly on the UI thread) before explicitly loading them is not permitted.
    private JSONObject mItems = null;

    private final Context mContext;

    private static SavedReaderViewHelper instance = null;

    private SavedReaderViewHelper(Context context) {
        mContext = context;
    }

    public static synchronized SavedReaderViewHelper getSavedReaderViewHelper(final Context context) {
        if (instance == null) {
            instance = new SavedReaderViewHelper(context);
        }

        return instance;
    }

    /**
     * Load the reader view cache list from our JSON file.
     *
     * Must not be run on the UI thread due to file access.
     */
    public synchronized void loadItems() {
        // TODO bug 1264489
        // This is a band aid fix for Bug 1264134. We need to figure out the root cause and reenable this
        // assertion.
        // ThreadUtils.assertNotOnUiThread();

        if (mItems != null) {
            return;
        }

        try {
            mItems = GeckoProfile.get(mContext).readJSONObjectFromFile(FILE_PATH);
        } catch (IOException e) {
            mItems = new JSONObject();
        }
    }

    private synchronized void assertItemsLoaded() {
        if (mItems == null) {
            throw new IllegalStateException("SavedReaderView items must be explicitly loaded using loadItems() before access.");
        }
    }

    private JSONObject makeItem(@NonNull String path, long size) throws JSONException {
        final JSONObject item = new JSONObject();

        item.put(PATH, path);
        item.put(SIZE, size);

        return item;
    }

    public synchronized boolean isURLCached(@NonNull final String URL) {
        assertItemsLoaded();
        return mItems.has(URL);
    }

    /**
     * Insert an item into the list of cached items.
     *
     * This may be called from any thread.
     */
    public synchronized void put(@NonNull final String pageURL, @NonNull final String path, final long size) {
        assertItemsLoaded();

        try {
            mItems.put(pageURL, makeItem(path, size));
        } catch (JSONException e) {
            Log.w(LOG_TAG, "Item insertion failed:", e);
            // This should never happen, absent any errors in our own implementation
            throw new IllegalStateException("Failure inserting into SavedReaderViewHelper json");
        }

        ThreadUtils.postToBackgroundThread(new Runnable() {
            @Override
            public void run() {
                UrlAnnotations annotations = BrowserDB.from(mContext).getUrlAnnotations();
                annotations.insertReaderViewUrl(mContext.getContentResolver(), pageURL);

                commit();
            }
        });
    }

    protected synchronized void remove(@NonNull final String pageURL) {
        assertItemsLoaded();

        mItems.remove(pageURL);

        ThreadUtils.postToBackgroundThread(new Runnable() {
            @Override
            public void run() {
                UrlAnnotations annotations = BrowserDB.from(mContext).getUrlAnnotations();
                annotations.deleteReaderViewUrl(mContext.getContentResolver(), pageURL);

                commit();
            }
        });
    }

    @RobocopTarget
    public synchronized int size() {
        assertItemsLoaded();
        return mItems.length();
    }

    private synchronized void commit() {
        ThreadUtils.assertOnBackgroundThread();

        GeckoProfile profile = GeckoProfile.get(mContext);
        File cacheDir = new File(profile.getDir(), DIRECTORY);

        if (!cacheDir.exists()) {
            Log.i(LOG_TAG, "No preexisting cache directory, creating now");

            boolean cacheDirCreated = cacheDir.mkdir();
            if (!cacheDirCreated) {
                throw new IllegalStateException("Couldn't create cache directory, unable to track reader view cache");
            }
        }

        profile.writeFile(FILE_PATH, mItems.toString());
    }

    /**
     * Return the Reader View URL for a given URL if it is contained in the cache. Returns the
     * plain URL if the page is not cached.
     */
    public static String getReaderURLIfCached(final Context context, @NonNull final String pageURL) {
        SavedReaderViewHelper rvh = getSavedReaderViewHelper(context);

        if (rvh.isURLCached(pageURL)) {
            return ReaderModeUtils.getAboutReaderForUrl(pageURL);
        } else {
            return pageURL;
        }
    }

    /**
     * Obtain the total disk space used for saved reader view items, in KB.
     *
     * @return Total disk space used (KB), or Integer.MAX_VALUE on overflow.
     */
    public synchronized int getDiskSpacedUsedKB() {
        // JSONObject is not thread safe - we need to be synchronized to avoid issues (most likely to
        // occur if items are removed during iteration).
        final Iterator<String> keys = mItems.keys();
        long bytes = 0;

        while (keys.hasNext()) {
            final String pageURL = keys.next();
            try {
                final JSONObject item = mItems.getJSONObject(pageURL);
                bytes += item.getLong(SIZE);

                // Overflow is highly unlikely (we will hit device storage limits before we hit integer limits),
                // but we should still handle this for correctness.
                // We definitely can't store our output in an int if we overflow the long here.
                if (bytes < 0) {
                    return Integer.MAX_VALUE;
                }
            } catch (JSONException e) {
                // This shouldn't ever happen:
                throw new IllegalStateException("Must be able to access items in saved reader view list", e);
            }
        }

        long kb = bytes / 1024;
        if (kb > Integer.MAX_VALUE) {
            return Integer.MAX_VALUE;
        } else {
            return (int) kb;
        }
    }
}