summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
blob: 89b12904bb3cd0bdbe0793424bd1db38a1066bfc (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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
/* -*- 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.db;

import android.content.Context;
import android.content.ContentResolver;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.annotation.RobocopTarget;
import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.Locales;
import org.mozilla.gecko.R;
import org.mozilla.gecko.distribution.Distribution;
import org.mozilla.gecko.restrictions.Restrictions;
import org.mozilla.gecko.util.RawResource;
import org.mozilla.gecko.util.ThreadUtils;
import org.mozilla.gecko.preferences.GeckoPreferences;

/**
 * {@code SuggestedSites} provides API to get a list of locale-specific
 * suggested sites to be used in Fennec's top sites panel. It provides
 * only a single method to fetch the list as a {@code Cursor}. This cursor
 * will then be wrapped by {@code TopSitesCursorWrapper} to blend top,
 * pinned, and suggested sites in the UI. The returned {@code Cursor}
 * uses its own schema defined in {@code BrowserContract.SuggestedSites}
 * for clarity.
 *
 * Under the hood, {@code SuggestedSites} keeps reference to the
 * parsed list of sites to avoid reparsing the JSON file on every
 * {@code get()} call.
 *
 * The default list of suggested sites is stored in a raw Android
 * resource ({@code R.raw.suggestedsites}) which is dynamically
 * generated at build time for each target locale.
 *
 * Changes to the list of suggested sites are saved in SharedPreferences.
 */
@RobocopTarget
public class SuggestedSites {
    private static final String LOGTAG = "GeckoSuggestedSites";

    // SharedPreference key for suggested sites that should be hidden.
    public static final String PREF_SUGGESTED_SITES_HIDDEN = GeckoPreferences.NON_PREF_PREFIX + "suggestedSites.hidden";
    public static final String PREF_SUGGESTED_SITES_HIDDEN_OLD = "suggestedSites.hidden";

    // Locale used to generate the current suggested sites.
    public static final String PREF_SUGGESTED_SITES_LOCALE = GeckoPreferences.NON_PREF_PREFIX + "suggestedSites.locale";
    public static final String PREF_SUGGESTED_SITES_LOCALE_OLD = "suggestedSites.locale";

    // File in profile dir with the list of suggested sites.
    private static final String FILENAME = "suggestedsites.json";

    private static final String[] COLUMNS = new String[] {
        BrowserContract.SuggestedSites._ID,
        BrowserContract.SuggestedSites.URL,
        BrowserContract.SuggestedSites.TITLE,
        BrowserContract.Combined.HISTORY_ID
    };

    private static final String JSON_KEY_URL = "url";
    private static final String JSON_KEY_TITLE = "title";
    private static final String JSON_KEY_IMAGE_URL = "imageurl";
    private static final String JSON_KEY_BG_COLOR = "bgcolor";
    private static final String JSON_KEY_RESTRICTED = "restricted";

    private static class Site {
        public final String url;
        public final String title;
        public final String imageUrl;
        public final String bgColor;
        public final boolean restricted;

        public Site(JSONObject json) throws JSONException {
            this.restricted =  !json.isNull(JSON_KEY_RESTRICTED);
            this.url = json.getString(JSON_KEY_URL);
            this.title = json.getString(JSON_KEY_TITLE);
            this.imageUrl = json.getString(JSON_KEY_IMAGE_URL);
            this.bgColor = json.getString(JSON_KEY_BG_COLOR);

            validate();
        }

        public Site(String url, String title, String imageUrl, String bgColor) {
            this.url = url;
            this.title = title;
            this.imageUrl = imageUrl;
            this.bgColor = bgColor;
            this.restricted = false;

            validate();
        }

        private void validate() {
            // Site instances must have non-empty values for all properties except IDs.
            if (TextUtils.isEmpty(url) ||
                TextUtils.isEmpty(title) ||
                TextUtils.isEmpty(imageUrl) ||
                TextUtils.isEmpty(bgColor)) {
                throw new IllegalStateException("Suggested sites must have a URL, title, " +
                                                "image URL, and background color.");
            }
        }

        @Override
        public String toString() {
            return "{ url = " + url + "\n" +
                     "restricted = " + restricted + "\n" +
                     "title = " + title + "\n" +
                     "imageUrl = " + imageUrl + "\n" +
                     "bgColor = " + bgColor + " }";
        }

        public JSONObject toJSON() throws JSONException {
            final JSONObject json = new JSONObject();

            if (restricted) {
                json.put(JSON_KEY_RESTRICTED, true);
            }

            json.put(JSON_KEY_URL, url);
            json.put(JSON_KEY_TITLE, title);
            json.put(JSON_KEY_IMAGE_URL, imageUrl);
            json.put(JSON_KEY_BG_COLOR, bgColor);

            return json;
        }
    }

    final Context context;
    final Distribution distribution;
    private File cachedFile;
    private Map<String, Site> cachedSites;
    private Set<String> cachedBlacklist;

    public SuggestedSites(Context appContext) {
        this(appContext, null);
    }

    public SuggestedSites(Context appContext, Distribution distribution) {
        this(appContext, distribution, null);
    }

    public SuggestedSites(Context appContext, Distribution distribution, File file) {
        this.context = appContext;
        this.distribution = distribution;
        this.cachedFile = file;
    }

    synchronized File getFile() {
        if (cachedFile == null) {
            cachedFile = GeckoProfile.get(context).getFile(FILENAME);
        }
        return cachedFile;
    }

    private static boolean isNewLocale(Context context, Locale requestedLocale) {
        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);

        String locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE_OLD, null);
        if (locale != null) {
          // Migrate the old pref and remove it
          final Editor editor = prefs.edit();
          editor.remove(PREF_SUGGESTED_SITES_LOCALE_OLD);
          editor.putString(PREF_SUGGESTED_SITES_LOCALE, locale);
          editor.apply();
        } else {
          locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE, null);
        }
        if (locale == null) {
            // Initialize config with the current locale
            updateSuggestedSitesLocale(context);
            return true;
        }

        return !TextUtils.equals(requestedLocale.toString(), locale);
    }

    /**
     * Return the current locale and its fallback (en_US) in order.
     */
    private static List<Locale> getAcceptableLocales() {
        final List<Locale> locales = new ArrayList<Locale>();

        final Locale defaultLocale = Locale.getDefault();
        locales.add(defaultLocale);

        if (!defaultLocale.equals(Locale.US)) {
            locales.add(Locale.US);
        }

        return locales;
    }

    private static Map<String, Site> loadSites(File f) throws IOException {
        Scanner scanner = null;

        try {
            scanner = new Scanner(f, "UTF-8");
            return loadSites(scanner.useDelimiter("\\A").next());
        } finally {
            if (scanner != null) {
                scanner.close();
            }
        }
    }

    private static Map<String, Site> loadSites(String jsonString) {
        if (TextUtils.isEmpty(jsonString)) {
            return null;
        }

        Map<String, Site> sites = null;

        try {
            final JSONArray jsonSites = new JSONArray(jsonString);
            sites = new LinkedHashMap<String, Site>(jsonSites.length());

            final int count = jsonSites.length();
            for (int i = 0; i < count; i++) {
                final Site site = new Site(jsonSites.getJSONObject(i));
                sites.put(site.url, site);
            }
        } catch (Exception e) {
            Log.e(LOGTAG, "Failed to refresh suggested sites", e);
            return null;
        }

        return sites;
    }

    /**
     * Saves suggested sites file to disk. Access to this method should
     * be synchronized on 'file'.
     */
    static void saveSites(File f, Map<String, Site> sites) {
        ThreadUtils.assertNotOnUiThread();

        if (sites == null || sites.isEmpty()) {
            return;
        }

        OutputStreamWriter osw = null;

        try {
            final JSONArray jsonSites = new JSONArray();
            for (Site site : sites.values()) {
                jsonSites.put(site.toJSON());
            }

            osw = new OutputStreamWriter(new FileOutputStream(f), "UTF-8");

            final String jsonString = jsonSites.toString();
            osw.write(jsonString, 0, jsonString.length());
        } catch (Exception e) {
            Log.e(LOGTAG, "Failed to save suggested sites", e);
        } finally {
            if (osw != null) {
                try {
                    osw.close();
                } catch (IOException e) {
                    // Ignore.
                }
            }
        }
    }

    private void maybeWaitForDistribution() {
        if (distribution == null) {
            return;
        }

        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
            @Override
            public void distributionNotFound() {
                // If distribution doesn't exist, simply continue to load
                // suggested sites directly from resources. See refresh().
            }

            @Override
            public void distributionFound(Distribution distribution) {
                Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
                // Merge suggested sites from distribution with the
                // default ones. Distribution takes precedence.
                Map<String, Site> sites = loadFromDistribution(distribution);
                if (sites == null) {
                    sites = new LinkedHashMap<String, Site>();
                }
                sites.putAll(loadFromResource());

                // Update cached list of sites.
                setCachedSites(sites);

                // Save the result to disk.
                final File file = getFile();
                synchronized (file) {
                    saveSites(file, sites);
                }

                // Then notify any active loaders about the changes.
                final ContentResolver cr = context.getContentResolver();
                cr.notifyChange(BrowserContract.SuggestedSites.CONTENT_URI, null);
            }

            @Override
            public void distributionArrivedLate(Distribution distribution) {
                distributionFound(distribution);
            }
        });
    }

    /**
     * Loads suggested sites from a distribution file either matching the
     * current locale or with the fallback locale (en-US).
     *
     * It's assumed that the given distribution instance is ready to be
     * used and exists.
     */
    static Map<String, Site> loadFromDistribution(Distribution dist) {
        for (Locale locale : getAcceptableLocales()) {
            try {
                final String languageTag = Locales.getLanguageTag(locale);
                final String path = String.format("suggestedsites/locales/%s/%s",
                                                  languageTag, FILENAME);

                final File f = dist.getDistributionFile(path);
                if (f == null) {
                    Log.d(LOGTAG, "No suggested sites for locale: " + languageTag);
                    continue;
                }

                return loadSites(f);
            } catch (Exception e) {
                Log.e(LOGTAG, "Failed to open suggested sites for locale " +
                              locale + " in distribution.", e);
            }
        }

        return null;
    }

    private Map<String, Site> loadFromProfile() {
        try {
            final File file = getFile();
            synchronized (file) {
                return loadSites(file);
            }
        } catch (FileNotFoundException e) {
            maybeWaitForDistribution();
        } catch (IOException e) {
            // Fall through, return null.
        }

        return null;
    }

    Map<String, Site> loadFromResource() {
        try {
            return loadSites(RawResource.getAsString(context, R.raw.suggestedsites));
        } catch (IOException e) {
            return null;
        }
    }

    private synchronized void setCachedSites(Map<String, Site> sites) {
        cachedSites = Collections.unmodifiableMap(sites);
        updateSuggestedSitesLocale(context);
    }

    /**
     * Refreshes the cached list of sites either from the default raw
     * source or standard file location. This will be called on every
     * cache miss during a {@code get()} call.
     */
    private void refresh() {
        Log.d(LOGTAG, "Refreshing suggested sites from file");

        Map<String, Site> sites = loadFromProfile();
        if (sites == null) {
            sites = loadFromResource();
        }

        // Update cached list of sites.
        if (sites != null) {
            setCachedSites(sites);
        }
    }

    private static void updateSuggestedSitesLocale(Context context) {
        final Editor editor = GeckoSharedPrefs.forProfile(context).edit();
        editor.putString(PREF_SUGGESTED_SITES_LOCALE, Locale.getDefault().toString());
        editor.apply();
    }

    private synchronized Site getSiteForUrl(String url) {
        if (cachedSites == null) {
            return null;
        }

        return cachedSites.get(url);
    }

    /**
     * Returns a {@code Cursor} with the list of suggested websites.
     *
     * @param limit maximum number of suggested sites.
     */
    public Cursor get(int limit) {
        return get(limit, Locale.getDefault());
    }

    /**
     * Returns a {@code Cursor} with the list of suggested websites.
     *
     * @param limit maximum number of suggested sites.
     * @param locale the target locale.
     */
    public Cursor get(int limit, Locale locale) {
        return get(limit, locale, null);
    }

    /**
     * Returns a {@code Cursor} with the list of suggested websites.
     *
     * @param limit maximum number of suggested sites.
     * @param excludeUrls list of URLs to be excluded from the list.
     */
    public Cursor get(int limit, List<String> excludeUrls) {
        return get(limit, Locale.getDefault(), excludeUrls);
    }

    /**
     * Returns a {@code Cursor} with the list of suggested websites.
     *
     * @param limit maximum number of suggested sites.
     * @param locale the target locale.
     * @param excludeUrls list of URLs to be excluded from the list.
     */
    public synchronized Cursor get(int limit, Locale locale, List<String> excludeUrls) {
        final MatrixCursor cursor = new MatrixCursor(COLUMNS);
        final boolean isNewLocale = isNewLocale(context, locale);

        // Force the suggested sites file in profile dir to be re-generated
        // if the locale has changed.
        if (isNewLocale) {
            getFile().delete();
        }

        if (cachedSites == null || isNewLocale) {
            Log.d(LOGTAG, "No cached sites, refreshing.");
            refresh();
        }

        // Return empty cursor if there was an error when
        // loading the suggested sites or the list is empty.
        if (cachedSites == null || cachedSites.isEmpty()) {
            return cursor;
        }

        excludeUrls = includeBlacklist(excludeUrls);

        final int sitesCount = cachedSites.size();
        Log.d(LOGTAG, "Number of suggested sites: " + sitesCount);

        final int maxCount = Math.min(limit, sitesCount);
        // History IDS: real history is positive, -1 is no history id in the combined table
        // hence we can start at -2 for suggested sites
        int id = -1;
        for (Site site : cachedSites.values()) {
            // Decrement ID here: this ensure we have a consistent ID to URL mapping, even if items
            // are removed. If we instead decremented at the point of insertion we'd end up with
            // ID conflicts when a suggested site is removed. (note that cachedSites does not change
            // while we're already showing topsites)
            --id;
            if (cursor.getCount() == maxCount) {
                break;
            }

            if (excludeUrls != null && excludeUrls.contains(site.url)) {
                continue;
            }

            final boolean restrictedProfile =  Restrictions.isRestrictedProfile(context);

            if (restrictedProfile == site.restricted) {
                final RowBuilder row = cursor.newRow();
                row.add(id);
                row.add(site.url);
                row.add(site.title);
                row.add(id);
            }
        }

        cursor.setNotificationUri(context.getContentResolver(),
                                  BrowserContract.SuggestedSites.CONTENT_URI);

        return cursor;
    }

    public boolean contains(String url) {
        return (getSiteForUrl(url) != null);
    }

    public String getImageUrlForUrl(String url) {
        final Site site = getSiteForUrl(url);
        return (site != null ? site.imageUrl : null);
    }

    public String getBackgroundColorForUrl(String url) {
        final Site site = getSiteForUrl(url);
        return (site != null ? site.bgColor : null);
    }

    private Set<String> loadBlacklist() {
        Log.d(LOGTAG, "Loading blacklisted suggested sites from SharedPreferences.");
        final Set<String> blacklist = new HashSet<String>();

        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
        String sitesString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN_OLD, null);
        if (sitesString != null) {
          // Migrate the old pref and remove it
          final Editor editor = prefs.edit();
          editor.remove(PREF_SUGGESTED_SITES_HIDDEN_OLD);
          editor.putString(PREF_SUGGESTED_SITES_HIDDEN, sitesString);
          editor.apply();
        } else {
          sitesString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, null);
        }

        if (sitesString != null) {
            for (String site : sitesString.trim().split(" ")) {
                blacklist.add(Uri.decode(site));
            }
        }

        return blacklist;
    }

    private List<String> includeBlacklist(List<String> originalList) {
        if (cachedBlacklist == null) {
            cachedBlacklist = loadBlacklist();
        }

        if (cachedBlacklist.isEmpty()) {
            return originalList;
        }

        if (originalList == null) {
            originalList = new ArrayList<String>();
        }

        originalList.addAll(cachedBlacklist);
        return originalList;
    }

    /**
     * Blacklist a suggested site so it will no longer be returned as a suggested site.
     * This method should only be called from a background thread because it may write
     * to SharedPreferences.
     *
     * Urls that are not Suggested Sites are ignored.
     *
     * @param url String url of site to blacklist
     * @return true is blacklisted, false otherwise
     */
    public synchronized boolean hideSite(String url) {
        ThreadUtils.assertNotOnUiThread();

        if (cachedSites == null) {
            refresh();
            if (cachedSites == null) {
                Log.w(LOGTAG, "Could not load suggested sites!");
                return false;
            }
        }

        if (cachedSites.containsKey(url)) {
            if (cachedBlacklist == null) {
                cachedBlacklist = loadBlacklist();
            }

            // Check if site has already been blacklisted, just in case.
            if (!cachedBlacklist.contains(url)) {

                saveToBlacklist(url);
                cachedBlacklist.add(url);

                return true;
            }
        }

        return false;
    }

    private void saveToBlacklist(String url) {
        final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
        final String prefString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, "");
        final String siteString = prefString.concat(" " + Uri.encode(url));
        prefs.edit().putString(PREF_SUGGESTED_SITES_HIDDEN, siteString).apply();
    }
}