reduce cookie tracking (4/4): onResume deletion of cookies

Optionally delete unwanted cookies (and localstorage) at every Browser
resume. The default for this feature is off - ie. maintain the current
Browser "keep every cookie" behaviour.

Optionally localstorage files (site databases) are also removed whenever
cookies have been deleted. This helps to reduce evercookie/supercookie
persistence.

A whitelist of sites that are permitted to keep cookies is stored in the
standard Browser shared_prefs. The site's cookie preference is set via a
menu checkbox when viewing the page. This allows opt-in whitelisting
behaviour on a per-site basis, suitable for saving eg. login cookies.

The cookie deletion itself is done by using existing API's to delete all
cookies and then selectively restore just those from the whitelisted sites.
Cookie counting is the only new API needed by this patch, and is used to
eliminate unnecessary cookie and localstorage deletes.

Although simplistic, onResume cookie filtering seems to work well and in
testing hasn't broken any web browsing. The underlying CookieMonster
functions operate on cached copies in ram and are asynchronous to disk so
there should be little or no measurable performance impact on browsing from
cookies. localstorage deletion is not cached by any layer so, if enabled,
might have some minor performance impact.

Change-Id: I55c69292a5ddc460e0e50b340dc4330c28becc5e
diff --git a/res/menu/browser.xml b/res/menu/browser.xml
index 3c217c0..7325467 100644
--- a/res/menu/browser.xml
+++ b/res/menu/browser.xml
@@ -67,6 +67,11 @@
                 android:id="@+id/ua_desktop_menu_id"
                 android:checkable="true"
                 android:title="@string/ua_switcher_desktop" />
+            <item
+                android:id="@+id/cookies_whitelisted_menu_id"
+                android:checkable="true"
+                android:title="@string/site_cookies_whitelisted"
+                android:visible="false" />
         </group>
         <group
             android:id="@+id/SNAPSHOT_MENU">
diff --git a/res/values/cm_arrays.xml b/res/values/cm_arrays.xml
index f042a6a..c3d9ce8 100644
--- a/res/values/cm_arrays.xml
+++ b/res/values/cm_arrays.xml
@@ -31,4 +31,15 @@
         <item>4</item>
         <item>5</item>
     </string-array>
+
+    <string-array name="pref_security_site_whitelist_entries" translatable="false">
+        <item>@string/pref_security_site_whitelist_cookies_never</item>
+        <item>@string/pref_security_site_whitelist_cookies_only</item>
+        <item>@string/pref_security_site_whitelist_cookies_and_data</item>
+    </string-array>
+    <string-array name="pref_security_site_whitelist_values" translatable="false">
+        <item>0</item>
+        <item>1</item>
+        <item>2</item>
+    </string-array>
 </resources>
diff --git a/res/values/cm_strings.xml b/res/values/cm_strings.xml
index 711d9a0..21876af 100644
--- a/res/values/cm_strings.xml
+++ b/res/values/cm_strings.xml
@@ -32,4 +32,20 @@
 
     <!-- Content description for home screen button [CHAR LIMIT=NONE] -->
     <string name="accessibility_button_homescreen">Home screen</string>
+
+    <!-- Browser security/privacy anti-tracking label -->
+    <string name="pref_security_site_whitelist_cookies">Reduce tracking. Frequently delete cookies</string>
+    <!-- Browser security/privacy anti-tracking options -->
+    <string name="pref_security_site_whitelist_cookies_never">Never delete</string>
+    <string name="pref_security_site_whitelist_cookies_only">Delete from  all sites that aren\'t in my whitelist</string>
+    <string name="pref_security_site_whitelist_cookies_and_data">Also delete all localstorage databases</string>
+    <!-- Browser security/privacy anti-tracking verbose checkbox label -->
+    <string name="pref_security_site_whitelist_cookies_verbose">Cookie count</string>
+    <!-- Browser security/privacy anti-tracking verbose checkbox summary -->
+    <string name="pref_security_site_whitelist_cookies_verbose_summary">Show the number of deleted and remaining cookies</string>
+    <!-- Browser popup menu checkbox that allows the user to whitelist cookies from this site [CHAR LIMIT=50] -->
+    <string name="site_cookies_whitelisted">Keep site\'s cookies</string>
+    <!-- Toast to inform the user of the number of cookies deleted and remaining [CHAR LIMIT=50] -->
+    <string name="cookies_deleted_info">cookies deleted</string>
+    <string name="cookies_remain_info">cookies remain</string>
 </resources>
diff --git a/res/xml/privacy_security_preferences.xml b/res/xml/privacy_security_preferences.xml
index 2633600..26c19ff 100644
--- a/res/xml/privacy_security_preferences.xml
+++ b/res/xml/privacy_security_preferences.xml
@@ -44,6 +44,22 @@
                 android:title="@string/pref_security_accept_cookies"
                 android:summary="@string/pref_security_accept_cookies_summary" />
 
+        <ListPreference
+                android:key="site_whitelist_cookies"
+                android:dependency="accept_cookies"
+                android:dialogTitle="@string/pref_security_site_whitelist_cookies"
+                android:title="@string/pref_security_site_whitelist_cookies"
+                android:entries="@array/pref_security_site_whitelist_entries"
+                android:entryValues="@array/pref_security_site_whitelist_values"
+                android:defaultValue="0" />
+
+        <CheckBoxPreference
+                android:key="site_whitelist_cookies_verbose"
+                android:defaultValue="false"
+                android:dependency="accept_cookies"
+                android:title="@string/pref_security_site_whitelist_cookies_verbose"
+                android:summary="@string/pref_security_site_whitelist_cookies_verbose_summary" />
+
         <com.android.browser.BrowserYesNoPreference
                 android:key="privacy_clear_cookies"
                 android:title="@string/pref_privacy_clear_cookies"
diff --git a/src/com/android/browser/BrowserSettings.java b/src/com/android/browser/BrowserSettings.java
index 7305972..6495955 100644
--- a/src/com/android/browser/BrowserSettings.java
+++ b/src/com/android/browser/BrowserSettings.java
@@ -51,6 +51,14 @@
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.WeakHashMap;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.Arrays;
+
+import android.util.Log;
+import android.net.WebAddress;
+import android.widget.Toast;
 
 /**
  * Class for managing settings
@@ -58,6 +66,8 @@
 public class BrowserSettings implements OnSharedPreferenceChangeListener,
         PreferenceKeys {
 
+    private final static String TAG = "BrowserSettings";
+
     // TODO: Do something with this UserAgent stuff
     private static final String DESKTOP_USERAGENT = "Mozilla/5.0 (X11; " +
         "Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " +
@@ -502,6 +512,151 @@
         }
     }
 
+    private Set<String> getCookieWhitelist() {
+        requireInitialization();
+        return mPrefs.getStringSet("cookies_whitelist", new HashSet<String>());
+    }
+
+    private void setCookieWhitelist(Set<String> wl) {
+        mPrefs.edit().putStringSet("cookies_whitelist", wl).apply();
+    }
+
+    private boolean addToCookieWhitelist(String s) {
+        Set<String> wl = getCookieWhitelist();
+        boolean ok = wl.add(s);
+        setCookieWhitelist(wl);
+        return ok;
+    }
+
+    private boolean removeFromCookieWhitelist(String s) {
+        Set<String> wl = getCookieWhitelist();
+        boolean ok = wl.remove(s);
+        setCookieWhitelist(wl);
+        return ok;
+    }
+
+    public boolean hasCookiesWhitelisted(WebView view) {
+        if (view == null) {
+            return false;
+        }
+        String url = view.getUrl();
+        if (url == null) { // page hasn't finished loading yet
+            return false;
+        }
+        String host = new WebAddress(url).getHost();
+        Set<String> wl = getCookieWhitelist();
+        return wl.contains(host);
+    }
+
+    public void toggleCookiesWhitelisted(WebView view) {
+        if (view == null) {
+            return;
+        }
+        if (!enableDeleteCookies()) {
+            return;
+        }
+
+        String url = view.getUrl();
+        if (url == null) { // page hasn't finished loading yet
+            return;
+        }
+        String host = new WebAddress(url).getHost();
+        if (hasCookiesWhitelisted(view)) {
+            removeFromCookieWhitelist(host);
+            clearCookiesExceptWhitelist();
+        }
+        else {
+            addToCookieWhitelist(host);
+        }
+    }
+
+    public void clearCookiesExceptWhitelist() {
+        if (!enableDeleteCookies()) {
+            return;
+        }
+
+        Set<String> domains = getCookieWhitelist();
+        HashMap cookies = new HashMap();
+
+        // look for both domain and host cookies (domain cookies have a '.' prefix).
+        // query by https as this returns both http and https cookies.
+        String[] prefixes = { "https://.", "https://" };
+
+        // find all cookies in the whitelist
+        int savedCookies = 0;
+        for (String domain : domains) {
+            for (int p = 0; p < 2; p++) {
+                String url = prefixes[p] + domain;
+                String c = CookieManager.getInstance().getCookie(url);
+                if (c != null && !(c.equals("") || c.equals(null))) {
+                    String[] cc = c.split(";"); // split into individual cookies
+                    for (int i = 0; i < cc.length; i++) // get rid of leading blanks
+                        cc[i] = cc[i].trim();
+                    Set<String> ccUniq = new HashSet<String>(Arrays.asList(cc));
+                    if (p == 1) { // remove domain cookies duplicated to the host
+                        String urlDom = prefixes[0] + domain;
+                        if (cookies.containsKey(urlDom)) {
+                            Set<String> ccDom = (HashSet<String>)cookies.get(urlDom);
+                            ccUniq.removeAll(ccDom);
+                        }
+                    }
+                    savedCookies += ccUniq.size();
+                    cookies.put(url, ccUniq);
+                }
+            }
+        }
+
+        int cookiesBefore = CookieManager.getInstance().countCookies();
+        if (savedCookies == cookiesBefore) {
+            // all cookies are whitelisted cookies. our job is done.
+            return;
+        }
+
+        // delete all cookies
+        CookieManager.getInstance().removeAllCookie();
+
+        // re-add all the whitelisted cookies
+        for (String domain : domains) {
+            for (String prefix : prefixes) {
+                String url = prefix + domain;
+                if (cookies.containsKey(url)) {
+                    Set<String> cc = (HashSet<String>)cookies.get(url);
+                    for (String i : cc) {
+                        CookieManager.getInstance().setCookie(url, i + ";");
+                    }
+                }
+            }
+        }
+
+        int cookiesAfter = CookieManager.getInstance().countCookies();
+        int cookiesDeleted = cookiesBefore - cookiesAfter;
+        boolean munched = cookiesDeleted > 0;
+
+        if (!munched) { // no cookies were deleted
+            return;
+        }
+
+        // optionally clear all localstorage too
+        if (enableDeleteLocaldata()) {
+            clearDatabases();
+        }
+
+        Log.d(TAG, "clearCookiesExceptWhitelist: "
+             + cookiesDeleted + " cookies deleted, "
+             + cookiesAfter + " cookies remain");
+
+        // toast if we have munched cookies
+        if (munchToast()) {
+            String deleted_str = mContext.getResources()
+                 .getString(R.string.cookies_deleted_info);
+            String remain_str = mContext.getResources()
+                 .getString(R.string.cookies_remain_info);
+            CharSequence text = "" + cookiesDeleted + " " + deleted_str
+                            + "\n" + cookiesAfter + " " + remain_str;
+            Toast.makeText(mContext, text, Toast.LENGTH_SHORT).show();
+        }
+    }
+
     public static int getAdjustedMinimumFontSize(int rawValue) {
         rawValue++; // Preference starts at 0, min font at 1
         if (rawValue > 1) {
@@ -811,6 +966,20 @@
         return mPrefs.getBoolean(PREF_ACCEPT_COOKIES, true);
     }
 
+    public boolean enableDeleteCookies() {
+        int value = Integer.valueOf(mPrefs.getString(PREF_SITE_WHITELIST_COOKIES, "0"));
+        return value != 0;
+    }
+
+    public boolean enableDeleteLocaldata() {
+        int value = Integer.valueOf(mPrefs.getString(PREF_SITE_WHITELIST_COOKIES, "0"));
+        return value == 2;
+    }
+
+    public boolean munchToast() {
+        return mPrefs.getBoolean(PREF_SITE_WHITELIST_COOKIES_VERBOSE, false);
+    }
+
     public boolean saveFormdata() {
         return mPrefs.getBoolean(PREF_SAVE_FORMDATA, true);
     }
diff --git a/src/com/android/browser/Controller.java b/src/com/android/browser/Controller.java
index 6364f6a..9ef4874 100644
--- a/src/com/android/browser/Controller.java
+++ b/src/com/android/browser/Controller.java
@@ -689,6 +689,11 @@
             return;
         }
         mSettings.setLastRunPaused(false);
+
+        // delete cookies (and localstorage) early in the resume to
+        // avoid potential races with regular cookie reads and writes.
+        mSettings.clearCookiesExceptWhitelist();
+
         mActivityPaused = false;
         Tab current = mTabControl.getCurrentTab();
         if (current != null) {
@@ -1520,12 +1525,14 @@
         boolean canGoForward = false;
         boolean isHome = false;
         boolean isDesktopUa = false;
+        boolean hasCookiesWhitelisted = false;
         boolean isLive = false;
         if (tab != null) {
             canGoBack = tab.canGoBack();
             canGoForward = tab.canGoForward();
             isHome = mSettings.getHomePage().equals(tab.getUrl());
             isDesktopUa = mSettings.hasDesktopUseragent(tab.getWebView());
+            hasCookiesWhitelisted = mSettings.hasCookiesWhitelisted(tab.getWebView());
             isLive = !tab.isSnapshot();
         }
         final MenuItem back = menu.findItem(R.id.back_menu_id);
@@ -1570,6 +1577,11 @@
         menu.setGroupVisible(R.id.SNAPSHOT_MENU, !isLive);
         menu.setGroupVisible(R.id.COMBO_MENU, false);
 
+        // individual Visible needs to be after the group setting
+        final MenuItem cwSwitcher = menu.findItem(R.id.cookies_whitelisted_menu_id);
+        cwSwitcher.setChecked(hasCookiesWhitelisted);
+        cwSwitcher.setVisible(isLive && mSettings.enableDeleteCookies());
+
         mUi.updateMenuState(tab, menu);
     }
 
@@ -1697,6 +1709,10 @@
                 toggleUserAgent();
                 break;
 
+            case R.id.cookies_whitelisted_menu_id:
+                toggleCookiesWhitelisted();
+                break;
+
             case R.id.fullscreen_menu_id:
                 toggleFullscreen();
 
@@ -1737,6 +1753,12 @@
     }
 
     @Override
+    public void toggleCookiesWhitelisted() {
+        WebView web = getCurrentWebView();
+        mSettings.toggleCookiesWhitelisted(web);
+    }
+
+    @Override
     public void toggleFullscreen() {
         mUi.setFullscreen(!mUi.isFullscreen());
     }
diff --git a/src/com/android/browser/PreferenceKeys.java b/src/com/android/browser/PreferenceKeys.java
index ec81ba9..e547dd9 100644
--- a/src/com/android/browser/PreferenceKeys.java
+++ b/src/com/android/browser/PreferenceKeys.java
@@ -89,6 +89,8 @@
     // Keys for privacy_security_preferences.xml
     // ----------------------
     static final String PREF_ACCEPT_COOKIES = "accept_cookies";
+    static final String PREF_SITE_WHITELIST_COOKIES = "site_whitelist_cookies";
+    static final String PREF_SITE_WHITELIST_COOKIES_VERBOSE = "site_whitelist_cookies_verbose";
     static final String PREF_ENABLE_GEOLOCATION = "enable_geolocation";
     static final String PREF_PRIVACY_CLEAR_CACHE = "privacy_clear_cache";
     static final String PREF_PRIVACY_CLEAR_COOKIES = "privacy_clear_cookies";
diff --git a/src/com/android/browser/UiController.java b/src/com/android/browser/UiController.java
index 643fb40..eac9a05 100644
--- a/src/com/android/browser/UiController.java
+++ b/src/com/android/browser/UiController.java
@@ -105,6 +105,8 @@
 
     void toggleUserAgent();
 
+    void toggleCookiesWhitelisted();
+
     void toggleFullscreen();
 
     BrowserSettings getSettings();
diff --git a/src/com/android/browser/preferences/PrivacySecurityPreferencesFragment.java b/src/com/android/browser/preferences/PrivacySecurityPreferencesFragment.java
index 35e6e43..24301cb 100644
--- a/src/com/android/browser/preferences/PrivacySecurityPreferencesFragment.java
+++ b/src/com/android/browser/preferences/PrivacySecurityPreferencesFragment.java
@@ -23,6 +23,7 @@
 import android.content.Intent;
 import android.os.Bundle;
 import android.preference.Preference;
+import android.preference.ListPreference;
 import android.preference.PreferenceFragment;
 
 public class PrivacySecurityPreferencesFragment extends PreferenceFragment
@@ -37,6 +38,24 @@
 
         Preference e = findPreference(PreferenceKeys.PREF_PRIVACY_CLEAR_HISTORY);
         e.setOnPreferenceChangeListener(this);
+
+        ListPreference lp = (ListPreference) findPreference(PreferenceKeys.PREF_SITE_WHITELIST_COOKIES);
+        lp.setOnPreferenceChangeListener(this);
+        updateListPreferenceSummary(lp);
+        cookiesVerboseEnable(lp.getValue());
+    }
+
+    private void cookiesVerboseEnable(String str) {
+        // if never, disable verbose option
+        int value = Integer.valueOf(str);
+        Preference pref = findPreference(PreferenceKeys.PREF_SITE_WHITELIST_COOKIES_VERBOSE);
+        if (pref != null) {
+            pref.setEnabled(value != 0);
+        }
+    }
+
+    void updateListPreferenceSummary(ListPreference e) {
+        e.setSummary(e.getEntry());
     }
 
     @Override
@@ -54,6 +73,13 @@
                     pref.getKey()));
             return true;
         }
+        else if (pref.getKey().equals(PreferenceKeys.PREF_SITE_WHITELIST_COOKIES)) {
+            ListPreference lp = (ListPreference) pref;
+            lp.setValue((String) objValue);
+            updateListPreferenceSummary(lp);
+            cookiesVerboseEnable((String) objValue);
+            return false;
+        }
 
         return false;
     }