diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org')
516 files changed, 116063 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java b/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java new file mode 100644 index 000000000..3c29edef3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java @@ -0,0 +1,596 @@ +/* -*- 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; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.util.Locale; +import java.util.UUID; +import java.util.regex.Pattern; + +import org.json.JSONObject; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +public final class ANRReporter extends BroadcastReceiver +{ + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoANRReporter"; + + private static final String ANR_ACTION = "android.intent.action.ANR"; + // Number of lines to search traces.txt to decide whether it's a Gecko ANR + private static final int LINES_TO_IDENTIFY_TRACES = 10; + // ANRs may happen because of memory pressure, + // so don't use up too much memory here + // Size of buffer to hold one line of text + private static final int TRACES_LINE_SIZE = 100; + // Size of block to use when processing traces.txt + private static final int TRACES_BLOCK_SIZE = 2000; + private static final String TRACES_CHARSET = "utf-8"; + private static final String PING_CHARSET = "utf-8"; + + private static final ANRReporter sInstance = new ANRReporter(); + private static int sRegisteredCount; + private Handler mHandler; + private volatile boolean mPendingANR; + + @WrapForJNI + private static native boolean requestNativeStack(boolean unwind); + @WrapForJNI + private static native String getNativeStack(); + @WrapForJNI + private static native void releaseNativeStack(); + + public static void register(Context context) { + if (sRegisteredCount++ != 0) { + // Already registered + return; + } + sInstance.start(context); + } + + public static void unregister() { + if (sRegisteredCount == 0) { + Log.w(LOGTAG, "register/unregister mismatch"); + return; + } + if (--sRegisteredCount != 0) { + // Should still be registered + return; + } + sInstance.stop(); + } + + private void start(final Context context) { + + Thread receiverThread = new Thread(new Runnable() { + @Override + public void run() { + Looper.prepare(); + synchronized (ANRReporter.this) { + mHandler = new Handler(); + ANRReporter.this.notify(); + } + if (DEBUG) { + Log.d(LOGTAG, "registering receiver"); + } + context.registerReceiver(ANRReporter.this, + new IntentFilter(ANR_ACTION), + null, + mHandler); + Looper.loop(); + + if (DEBUG) { + Log.d(LOGTAG, "unregistering receiver"); + } + context.unregisterReceiver(ANRReporter.this); + mHandler = null; + } + }, LOGTAG); + + receiverThread.setDaemon(true); + receiverThread.start(); + } + + private void stop() { + synchronized (this) { + while (mHandler == null) { + try { + wait(1000); + if (mHandler == null) { + // We timed out; just give up. The process is probably + // quitting anyways, so we let the OS do the clean up + Log.w(LOGTAG, "timed out waiting for handler"); + return; + } + } catch (InterruptedException e) { + } + } + } + Looper looper = mHandler.getLooper(); + looper.quit(); + try { + looper.getThread().join(); + } catch (InterruptedException e) { + } + } + + private ANRReporter() { + } + + // Return the "traces.txt" file, or null if there is no such file + private static File getTracesFile() { + // Check most common location first. + File tracesFile = new File("/data/anr/traces.txt"); + if (tracesFile.isFile() && tracesFile.canRead()) { + return tracesFile; + } + + // Find the traces file name if we can. + try { + // getprop [prop-name [default-value]] + Process propProc = (new ProcessBuilder()) + .command("/system/bin/getprop", "dalvik.vm.stack-trace-file") + .redirectErrorStream(true) + .start(); + try { + BufferedReader buf = new BufferedReader( + new InputStreamReader(propProc.getInputStream()), TRACES_LINE_SIZE); + String propVal = buf.readLine(); + if (DEBUG) { + Log.d(LOGTAG, "getprop returned " + String.valueOf(propVal)); + } + // getprop can return empty string when the prop value is empty + // or prop is undefined, treat both cases the same way + if (propVal != null && propVal.length() != 0) { + tracesFile = new File(propVal); + if (tracesFile.isFile() && tracesFile.canRead()) { + return tracesFile; + } else if (DEBUG) { + Log.d(LOGTAG, "cannot access traces file"); + } + } else if (DEBUG) { + Log.d(LOGTAG, "empty getprop result"); + } + } finally { + propProc.destroy(); + } + } catch (IOException e) { + Log.w(LOGTAG, e); + } catch (ClassCastException e) { + Log.w(LOGTAG, e); // Bug 975436 + } + return null; + } + + private static File getPingFile() { + if (GeckoAppShell.getContext() == null) { + return null; + } + GeckoProfile profile = GeckoAppShell.getGeckoInterface().getProfile(); + if (profile == null) { + return null; + } + File profDir = profile.getDir(); + if (profDir == null) { + return null; + } + File pingDir = new File(profDir, "saved-telemetry-pings"); + pingDir.mkdirs(); + if (!(pingDir.exists() && pingDir.isDirectory())) { + return null; + } + return new File(pingDir, UUID.randomUUID().toString()); + } + + // Return true if the traces file corresponds to a Gecko ANR + private static boolean isGeckoTraces(String pkgName, File tracesFile) { + try { + final String END_OF_PACKAGE_NAME = "([^a-zA-Z0-9_]|$)"; + // Regex for finding our package name in the traces file + Pattern pkgPattern = Pattern.compile(Pattern.quote(pkgName) + END_OF_PACKAGE_NAME); + Pattern mangledPattern = null; + if (!AppConstants.MANGLED_ANDROID_PACKAGE_NAME.equals(pkgName)) { + mangledPattern = Pattern.compile(Pattern.quote( + AppConstants.MANGLED_ANDROID_PACKAGE_NAME) + END_OF_PACKAGE_NAME); + } + if (DEBUG) { + Log.d(LOGTAG, "trying to match package: " + pkgName); + } + BufferedReader traces = new BufferedReader( + new FileReader(tracesFile), TRACES_BLOCK_SIZE); + try { + for (int count = 0; count < LINES_TO_IDENTIFY_TRACES; count++) { + String line = traces.readLine(); + if (DEBUG) { + Log.d(LOGTAG, "identifying line: " + String.valueOf(line)); + } + if (line == null) { + if (DEBUG) { + Log.d(LOGTAG, "reached end of traces file"); + } + return false; + } + if (pkgPattern.matcher(line).find()) { + // traces.txt file contains our package + return true; + } + if (mangledPattern != null && mangledPattern.matcher(line).find()) { + // traces.txt file contains our alternate package + return true; + } + } + } finally { + traces.close(); + } + } catch (IOException e) { + // meh, can't even read from it right. just return false + } + return false; + } + + private static long getUptimeMins() { + + long uptimeMins = (new File("/proc/self/stat")).lastModified(); + if (uptimeMins != 0L) { + uptimeMins = (System.currentTimeMillis() - uptimeMins) / 1000L / 60L; + if (DEBUG) { + Log.d(LOGTAG, "uptime " + String.valueOf(uptimeMins)); + } + return uptimeMins; + } + if (DEBUG) { + Log.d(LOGTAG, "could not get uptime"); + } + return 0L; + } + + /* + a saved telemetry ping file consists of JSON in the following format, + { + "reason": "android-anr-report", + "slug": "<uuid-string>", + "payload": <json-object> + } + for Android ANR, our JSON payload should look like, + { + "ver": 1, + "simpleMeasurements": { + "uptime": <uptime> + }, + "info": { + "reason": "android-anr-report", + "OS": "Android", + ... + }, + "androidANR": "...", + "androidLogcat": "..." + } + */ + + private static int writePingPayload(OutputStream ping, + String payload) throws IOException { + byte [] data = payload.getBytes(PING_CHARSET); + ping.write(data); + return data.length; + } + + private static void fillPingHeader(OutputStream ping, String slug) + throws IOException { + + // ping file header + byte [] data = ("{" + + "\"reason\":\"android-anr-report\"," + + "\"slug\":" + JSONObject.quote(slug) + "," + + "\"payload\":").getBytes(PING_CHARSET); + ping.write(data); + if (DEBUG) { + Log.d(LOGTAG, "wrote ping header, size = " + String.valueOf(data.length)); + } + + // payload start + int size = writePingPayload(ping, ("{" + + "\"ver\":1," + + "\"simpleMeasurements\":{" + + "\"uptime\":" + String.valueOf(getUptimeMins()) + + "}," + + "\"info\":{" + + "\"reason\":\"android-anr-report\"," + + "\"OS\":" + JSONObject.quote(SysInfo.getName()) + "," + + "\"version\":\"" + String.valueOf(SysInfo.getVersion()) + "\"," + + "\"appID\":" + JSONObject.quote(AppConstants.MOZ_APP_ID) + "," + + "\"appVersion\":" + JSONObject.quote(AppConstants.MOZ_APP_VERSION) + "," + + "\"appName\":" + JSONObject.quote(AppConstants.MOZ_APP_BASENAME) + "," + + "\"appBuildID\":" + JSONObject.quote(AppConstants.MOZ_APP_BUILDID) + "," + + "\"appUpdateChannel\":" + JSONObject.quote(AppConstants.MOZ_UPDATE_CHANNEL) + "," + + // Technically the platform build ID may be different, but we'll never know + "\"platformBuildID\":" + JSONObject.quote(AppConstants.MOZ_APP_BUILDID) + "," + + "\"locale\":" + JSONObject.quote(Locales.getLanguageTag(Locale.getDefault())) + "," + + "\"cpucount\":" + String.valueOf(SysInfo.getCPUCount()) + "," + + "\"memsize\":" + String.valueOf(SysInfo.getMemSize()) + "," + + "\"arch\":" + JSONObject.quote(SysInfo.getArchABI()) + "," + + "\"kernel_version\":" + JSONObject.quote(SysInfo.getKernelVersion()) + "," + + "\"device\":" + JSONObject.quote(SysInfo.getDevice()) + "," + + "\"manufacturer\":" + JSONObject.quote(SysInfo.getManufacturer()) + "," + + "\"hardware\":" + JSONObject.quote(SysInfo.getHardware()) + + "}," + + "\"androidANR\":\"")); + if (DEBUG) { + Log.d(LOGTAG, "wrote metadata, size = " + String.valueOf(size)); + } + + // We are at the start of ANR data + } + + // Block is a section of the larger input stream, and we want to find pattern within + // the stream. This is straightforward if the entire pattern is within one block; + // however, if the pattern spans across two blocks, we have to match both the start of + // the pattern in the first block and the end of the pattern in the second block. + // * If pattern is found in block, this method returns the index at the end of the + // found pattern, which must always be > 0. + // * If pattern is not found, it returns 0. + // * If the start of the pattern matches the end of the block, it returns a number + // < 0, which equals the negated value of how many characters in pattern are already + // matched; when processing the next block, this number is passed in through + // prevIndex, and the rest of the characters in pattern are matched against the + // start of this second block. The method returns value > 0 if the rest of the + // characters match, or 0 if they do not. + private static int getEndPatternIndex(String block, String pattern, int prevIndex) { + if (pattern == null || block.length() < pattern.length()) { + // Nothing to do + return 0; + } + if (prevIndex < 0) { + // Last block ended with a partial start; now match start of block to rest of pattern + if (block.startsWith(pattern.substring(-prevIndex, pattern.length()))) { + // Rest of pattern matches; return index at end of pattern + return pattern.length() + prevIndex; + } + // Not a match; continue with normal search + } + // Did not find pattern in last block; see if entire pattern is inside this block + int index = block.indexOf(pattern); + if (index >= 0) { + // Found pattern; return index at end of the pattern + return index + pattern.length(); + } + // Block does not contain the entire pattern, but see if the end of the block + // contains the start of pattern. To do that, we see if block ends with the + // first n-1 characters of pattern, the first n-2 characters of pattern, etc. + for (index = block.length() - pattern.length() + 1; index < block.length(); index++) { + // Using index as a start, see if the rest of block contains the start of pattern + if (block.charAt(index) == pattern.charAt(0) && + block.endsWith(pattern.substring(0, block.length() - index))) { + // Found partial match; return -(number of characters matched), + // i.e. -1 for 1 character matched, -2 for 2 characters matched, etc. + return index - block.length(); + } + } + return 0; + } + + // Copy the content of reader to ping; + // copying stops when endPattern is found in the input stream + private static int fillPingBlock(OutputStream ping, + Reader reader, String endPattern) + throws IOException { + + int total = 0; + int endIndex = 0; + char [] block = new char[TRACES_BLOCK_SIZE]; + for (int size = reader.read(block); size >= 0; size = reader.read(block)) { + String stringBlock = new String(block, 0, size); + endIndex = getEndPatternIndex(stringBlock, endPattern, endIndex); + if (endIndex > 0) { + // Found end pattern; clip the string + stringBlock = stringBlock.substring(0, endIndex); + } + String quoted = JSONObject.quote(stringBlock); + total += writePingPayload(ping, quoted.substring(1, quoted.length() - 1)); + if (endIndex > 0) { + // End pattern already found; return now + break; + } + } + return total; + } + + private static void fillLogcat(final OutputStream ping) { + if (Versions.preJB) { + // Logcat retrieval is not supported on pre-JB devices. + return; + } + + try { + // get the last 200 lines of logcat + Process proc = (new ProcessBuilder()) + .command("/system/bin/logcat", "-v", "threadtime", "-t", "200", "-d", "*:D") + .redirectErrorStream(true) + .start(); + try { + Reader procOut = new InputStreamReader(proc.getInputStream(), TRACES_CHARSET); + int size = fillPingBlock(ping, procOut, null); + if (DEBUG) { + Log.d(LOGTAG, "wrote logcat, size = " + String.valueOf(size)); + } + } finally { + proc.destroy(); + } + } catch (IOException e) { + // ignore because logcat is not essential + Log.w(LOGTAG, e); + } + } + + private static void fillPingFooter(OutputStream ping, + boolean haveNativeStack) + throws IOException { + + // We are at the end of ANR data + + int total = writePingPayload(ping, ("\"," + + "\"androidLogcat\":\"")); + fillLogcat(ping); + + if (haveNativeStack) { + total += writePingPayload(ping, ("\"," + + "\"androidNativeStack\":")); + + String nativeStack = String.valueOf(getNativeStack()); + int size = writePingPayload(ping, nativeStack); + if (DEBUG) { + Log.d(LOGTAG, "wrote native stack, size = " + String.valueOf(size)); + } + total += size + writePingPayload(ping, "}"); + } else { + total += writePingPayload(ping, "\"}"); + } + + byte [] data = ( + "}").getBytes(PING_CHARSET); + ping.write(data); + if (DEBUG) { + Log.d(LOGTAG, "wrote ping footer, size = " + String.valueOf(data.length + total)); + } + } + + private static void processTraces(Reader traces, File pingFile) { + + // Only get native stack if Gecko is running. + // Also, unwinding is memory intensive, so only unwind if we have enough memory. + final boolean haveNativeStack = + GeckoThread.isRunning() ? + requestNativeStack(/* unwind */ SysInfo.getMemSize() >= 640) : false; + + try { + OutputStream ping = new BufferedOutputStream( + new FileOutputStream(pingFile), TRACES_BLOCK_SIZE); + try { + fillPingHeader(ping, pingFile.getName()); + // Traces file has the format + // ----- pid xxx at xxx ----- + // Cmd line: org.mozilla.xxx + // * stack trace * + // ----- end xxx ----- + // ----- pid xxx at xxx ----- + // Cmd line: com.android.xxx + // * stack trace * + // ... + // If we end the stack dump at the first end marker, + // only Fennec stacks will be dumped + int size = fillPingBlock(ping, traces, "\n----- end"); + if (DEBUG) { + Log.d(LOGTAG, "wrote traces, size = " + String.valueOf(size)); + } + fillPingFooter(ping, haveNativeStack); + if (DEBUG) { + Log.d(LOGTAG, "finished creating ping file"); + } + return; + } finally { + ping.close(); + if (haveNativeStack) { + releaseNativeStack(); + } + } + } catch (IOException e) { + Log.w(LOGTAG, e); + } + // exception; delete ping file + if (pingFile.exists()) { + pingFile.delete(); + } + } + + private static void processTraces(File tracesFile, File pingFile) { + try { + Reader traces = new InputStreamReader( + new FileInputStream(tracesFile), TRACES_CHARSET); + try { + processTraces(traces, pingFile); + } finally { + traces.close(); + } + } catch (IOException e) { + Log.w(LOGTAG, e); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + if (mPendingANR) { + // we already processed an ANR without getting unstuck; skip this one + if (DEBUG) { + Log.d(LOGTAG, "skipping duplicate ANR"); + } + return; + } + if (ThreadUtils.getUiHandler() != null) { + mPendingANR = true; + // detect when the main thread gets unstuck + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // okay to reset mPendingANR on main thread + mPendingANR = false; + if (DEBUG) { + Log.d(LOGTAG, "yay we got unstuck!"); + } + } + }); + } + if (DEBUG) { + Log.d(LOGTAG, "receiving " + String.valueOf(intent)); + } + if (!ANR_ACTION.equals(intent.getAction())) { + return; + } + + // make sure we have a good save location first + File pingFile = getPingFile(); + if (DEBUG) { + Log.d(LOGTAG, "using ping file: " + String.valueOf(pingFile)); + } + if (pingFile == null) { + return; + } + + File tracesFile = getTracesFile(); + if (DEBUG) { + Log.d(LOGTAG, "using traces file: " + String.valueOf(tracesFile)); + } + if (tracesFile == null) { + return; + } + + // We get ANR intents from all ANRs in the system, but we only want Gecko ANRs + if (!isGeckoTraces(context.getPackageName(), tracesFile)) { + if (DEBUG) { + Log.d(LOGTAG, "traces is not Gecko ANR"); + } + return; + } + Log.i(LOGTAG, "processing Gecko ANR"); + processTraces(tracesFile, pingFile); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/AboutPages.java b/mobile/android/base/java/org/mozilla/gecko/AboutPages.java new file mode 100644 index 000000000..705d700af --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/AboutPages.java @@ -0,0 +1,117 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.home.HomeConfig; +import org.mozilla.gecko.home.HomeConfig.PanelType; +import org.mozilla.gecko.util.StringUtils; + +public class AboutPages { + // All of our special pages. + public static final String ACCOUNTS = "about:accounts"; + public static final String ADDONS = "about:addons"; + public static final String CONFIG = "about:config"; + public static final String DOWNLOADS = "about:downloads"; + public static final String FIREFOX = "about:firefox"; + public static final String HEALTHREPORT = "about:healthreport"; + public static final String HOME = "about:home"; + public static final String LOGINS = "about:logins"; + public static final String PRIVATEBROWSING = "about:privatebrowsing"; + public static final String READER = "about:reader"; + public static final String UPDATER = "about:"; + + public static final String URL_FILTER = "about:%"; + + public static final String PANEL_PARAM = "panel"; + + public static final boolean isAboutPage(final String url) { + return url != null && url.startsWith("about:"); + } + + public static final boolean isTitlelessAboutPage(final String url) { + return isAboutHome(url) || + PRIVATEBROWSING.equals(url); + } + + public static final boolean isAboutHome(final String url) { + if (url == null || !url.startsWith(HOME)) { + return false; + } + // We sometimes append a parameter to "about:home" to specify which page to + // show when we open the home pager. Discard this parameter when checking + // whether or not this URL is "about:home". + return HOME.equals(url.split("\\?")[0]); + } + + public static final String getPanelIdFromAboutHomeUrl(String aboutHomeUrl) { + return StringUtils.getQueryParameter(aboutHomeUrl, PANEL_PARAM); + } + + public static boolean isAboutReader(final String url) { + return isAboutPage(READER, url); + } + + public static boolean isAboutConfig(final String url) { + return isAboutPage(CONFIG, url); + } + + public static boolean isAboutAddons(final String url) { + return isAboutPage(ADDONS, url); + } + + public static boolean isAboutPrivateBrowsing(final String url) { + return isAboutPage(PRIVATEBROWSING, url); + } + + public static boolean isAboutPage(String page, String url) { + return url != null && url.toLowerCase().startsWith(page); + + } + + public static final String[] DEFAULT_ICON_PAGES = new String[] { + HOME, + ACCOUNTS, + ADDONS, + CONFIG, + DOWNLOADS, + FIREFOX, + HEALTHREPORT, + UPDATER + }; + + public static boolean isBuiltinIconPage(final String url) { + if (url == null || + !url.startsWith("about:")) { + return false; + } + + // about:home uses a separate search built-in icon. + if (isAboutHome(url)) { + return true; + } + + // TODO: it'd be quicker to not compare the "about:" part every time. + for (int i = 0; i < DEFAULT_ICON_PAGES.length; ++i) { + if (DEFAULT_ICON_PAGES[i].equals(url)) { + return true; + } + } + return false; + } + + /** + * Get a URL that navigates to the specified built-in Home Panel. + * + * @param panelType to navigate to. + * @return URL. + * @throws IllegalArgumentException if the built-in panel type is not a built-in panel. + */ + @RobocopTarget + public static String getURLForBuiltinPanelType(PanelType panelType) throws IllegalArgumentException { + return HOME + "?panel=" + HomeConfig.getIdForBuiltinPanelType(panelType); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java b/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java new file mode 100644 index 000000000..5892c16b6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java @@ -0,0 +1,318 @@ +/* -*- 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; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.background.fxa.FxAccountUtils; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.Engaged; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.sync.SyncConfiguration; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Map; + +/** + * Helper class to manage Android Accounts corresponding to Firefox Accounts. + */ +public class AccountsHelper implements NativeEventListener { + public static final String LOGTAG = "GeckoAccounts"; + + protected final Context mContext; + protected final GeckoProfile mProfile; + + public AccountsHelper(Context context, GeckoProfile profile) { + mContext = context; + mProfile = profile; + + EventDispatcher dispatcher = GeckoApp.getEventDispatcher(); + if (dispatcher == null) { + Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException()); + return; + } + dispatcher.registerGeckoThreadListener(this, + "Accounts:CreateFirefoxAccountFromJSON", + "Accounts:UpdateFirefoxAccountFromJSON", + "Accounts:Create", + "Accounts:DeleteFirefoxAccount", + "Accounts:Exist", + "Accounts:ProfileUpdated", + "Accounts:ShowSyncPreferences"); + } + + public synchronized void uninit() { + EventDispatcher dispatcher = GeckoApp.getEventDispatcher(); + if (dispatcher == null) { + Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException()); + return; + } + dispatcher.unregisterGeckoThreadListener(this, + "Accounts:CreateFirefoxAccountFromJSON", + "Accounts:UpdateFirefoxAccountFromJSON", + "Accounts:Create", + "Accounts:DeleteFirefoxAccount", + "Accounts:Exist", + "Accounts:ProfileUpdated", + "Accounts:ShowSyncPreferences"); + } + + @Override + public void handleMessage(String event, NativeJSObject message, final EventCallback callback) { + if (!Restrictions.isAllowed(mContext, Restrictable.MODIFY_ACCOUNTS)) { + // We register for messages in all contexts; we drop, with a log and an error to JavaScript, + // when the profile is restricted. It's better to return errors than silently ignore messages. + Log.e(LOGTAG, "Profile is not allowed to modify accounts! Ignoring event: " + event); + if (callback != null) { + callback.sendError("Profile is not allowed to modify accounts!"); + } + return; + } + + if ("Accounts:CreateFirefoxAccountFromJSON".equals(event)) { + // As we are about to create a new account, let's ensure our in-memory accounts cache + // is empty so that there are no undesired side-effects. + AndroidFxAccount.invalidateCaches(); + + AndroidFxAccount fxAccount = null; + try { + final NativeJSObject json = message.getObject("json"); + final String email = json.getString("email"); + final String uid = json.getString("uid"); + final boolean verified = json.optBoolean("verified", false); + final byte[] unwrapkB = Utils.hex2Byte(json.getString("unwrapBKey")); + final byte[] sessionToken = Utils.hex2Byte(json.getString("sessionToken")); + final byte[] keyFetchToken = Utils.hex2Byte(json.getString("keyFetchToken")); + final String authServerEndpoint = + json.optString("authServerEndpoint", FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT); + final String tokenServerEndpoint = + json.optString("tokenServerEndpoint", FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT); + final String profileServerEndpoint = + json.optString("profileServerEndpoint", FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT); + // TODO: handle choose what to Sync. + State state = new Engaged(email, uid, verified, unwrapkB, sessionToken, keyFetchToken); + fxAccount = AndroidFxAccount.addAndroidAccount(mContext, + email, + mProfile.getName(), + authServerEndpoint, + tokenServerEndpoint, + profileServerEndpoint, + state, + AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP); + + final String[] declinedSyncEngines = json.optStringArray("declinedSyncEngines", null); + if (declinedSyncEngines != null) { + Log.i(LOGTAG, "User has selected engines; storing to prefs."); + final Map<String, Boolean> selectedEngines = new HashMap<String, Boolean>(); + for (String enabledSyncEngine : SyncConfiguration.validEngineNames()) { + selectedEngines.put(enabledSyncEngine, true); + } + for (String declinedSyncEngine : declinedSyncEngines) { + selectedEngines.put(declinedSyncEngine, false); + } + // The "forms" engine has the same state as the "history" engine. + selectedEngines.put("forms", selectedEngines.get("history")); + FxAccountUtils.pii(LOGTAG, "User selected engines: " + selectedEngines.toString()); + try { + SyncConfiguration.storeSelectedEnginesToPrefs(fxAccount.getSyncPrefs(), selectedEngines); + } catch (UnsupportedEncodingException | GeneralSecurityException e) { + Log.e(LOGTAG, "Got exception storing selected engines; ignoring.", e); + } + } + } catch (URISyntaxException | GeneralSecurityException | UnsupportedEncodingException e) { + Log.w(LOGTAG, "Got exception creating Firefox Account from JSON; ignoring.", e); + if (callback != null) { + callback.sendError("Could not create Firefox Account from JSON: " + e.toString()); + return; + } + } + if (callback != null) { + callback.sendSuccess(fxAccount != null); + } + + } else if ("Accounts:UpdateFirefoxAccountFromJSON".equals(event)) { + // We might be significantly changing state of the account; let's ensure our in-memory + // accounts cache is empty so that there are no undesired side-effects. + AndroidFxAccount.invalidateCaches(); + + try { + final Account account = FirefoxAccounts.getFirefoxAccount(mContext); + if (account == null) { + if (callback != null) { + callback.sendError("Could not update Firefox Account since none exists"); + } + return; + } + + final NativeJSObject json = message.getObject("json"); + final String email = json.getString("email"); + final String uid = json.getString("uid"); + + // Protect against cross-connecting accounts. + if (account.name == null || !account.name.equals(email)) { + final String errorMessage = "Cannot update Firefox Account from JSON: datum has different email address!"; + Log.e(LOGTAG, errorMessage); + if (callback != null) { + callback.sendError(errorMessage); + } + return; + } + + final boolean verified = json.optBoolean("verified", false); + final byte[] unwrapkB = Utils.hex2Byte(json.getString("unwrapBKey")); + final byte[] sessionToken = Utils.hex2Byte(json.getString("sessionToken")); + final byte[] keyFetchToken = Utils.hex2Byte(json.getString("keyFetchToken")); + final State state = new Engaged(email, uid, verified, unwrapkB, sessionToken, keyFetchToken); + + final AndroidFxAccount fxAccount = new AndroidFxAccount(mContext, account); + fxAccount.setState(state); + + if (callback != null) { + callback.sendSuccess(true); + } + } catch (NativeJSObject.InvalidPropertyException e) { + Log.w(LOGTAG, "Got exception updating Firefox Account from JSON; ignoring.", e); + if (callback != null) { + callback.sendError("Could not update Firefox Account from JSON: " + e.toString()); + return; + } + } + + } else if ("Accounts:Create".equals(event)) { + // Do exactly the same thing as if you tapped 'Sync' in Settings. + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + final NativeJSObject extras = message.optObject("extras", null); + if (extras != null) { + intent.putExtra("extras", extras.toString()); + } + mContext.startActivity(intent); + + } else if ("Accounts:DeleteFirefoxAccount".equals(event)) { + try { + final Account account = FirefoxAccounts.getFirefoxAccount(mContext); + if (account == null) { + Log.w(LOGTAG, "Could not delete Firefox Account since none exists!"); + if (callback != null) { + callback.sendError("Could not delete Firefox Account since none exists"); + } + return; + } + + final AccountManagerCallback<Boolean> accountManagerCallback = new AccountManagerCallback<Boolean>() { + @Override + public void run(AccountManagerFuture<Boolean> future) { + try { + final boolean result = future.getResult(); + Log.i(LOGTAG, "Account named like " + Utils.obfuscateEmail(account.name) + " removed: " + result); + if (callback != null) { + callback.sendSuccess(result); + } + } catch (OperationCanceledException | IOException | AuthenticatorException e) { + if (callback != null) { + callback.sendError("Could not delete Firefox Account: " + e.toString()); + } + } + } + }; + + AccountManager.get(mContext).removeAccount(account, accountManagerCallback, null); + } catch (Exception e) { + Log.w(LOGTAG, "Got exception updating Firefox Account from JSON; ignoring.", e); + if (callback != null) { + callback.sendError("Could not update Firefox Account from JSON: " + e.toString()); + return; + } + } + + } else if ("Accounts:Exist".equals(event)) { + if (callback == null) { + Log.w(LOGTAG, "Accounts:Exist requires a callback"); + return; + } + + final String kind = message.optString("kind", null); + final JSONObject response = new JSONObject(); + + try { + if ("any".equals(kind)) { + response.put("exists", FirefoxAccounts.firefoxAccountsExist(mContext)); + callback.sendSuccess(response); + } else if ("fxa".equals(kind)) { + final Account account = FirefoxAccounts.getFirefoxAccount(mContext); + response.put("exists", account != null); + if (account != null) { + response.put("email", account.name); + // We should always be able to extract the server endpoints. + final AndroidFxAccount fxAccount = new AndroidFxAccount(mContext, account); + response.put("authServerEndpoint", fxAccount.getAccountServerURI()); + response.put("profileServerEndpoint", fxAccount.getProfileServerURI()); + response.put("tokenServerEndpoint", fxAccount.getTokenServerURI()); + try { + // It is possible for the state fetch to fail and us to not be able to provide a UID. + // Long term, the UID (and verification flag) will be attached to the Android account + // user data and not the internal state representation. + final State state = fxAccount.getState(); + response.put("uid", state.uid); + } catch (Exception e) { + Log.w(LOGTAG, "Got exception extracting account UID; ignoring.", e); + } + } + + callback.sendSuccess(response); + } else { + callback.sendError("Could not query account existence: unknown kind."); + } + } catch (JSONException e) { + Log.w(LOGTAG, "Got exception querying account existence; ignoring.", e); + callback.sendError("Could not query account existence: " + e.toString()); + return; + } + } else if ("Accounts:ProfileUpdated".equals(event)) { + final Account account = FirefoxAccounts.getFirefoxAccount(mContext); + if (account == null) { + Log.w(LOGTAG, "Can't change profile of non-existent Firefox Account!; ignored"); + return; + } + final AndroidFxAccount androidFxAccount = new AndroidFxAccount(mContext, account); + androidFxAccount.fetchProfileJSON(); + } else if ("Accounts:ShowSyncPreferences".equals(event)) { + final Account account = FirefoxAccounts.getFirefoxAccount(mContext); + if (account == null) { + Log.w(LOGTAG, "Can't change show Sync preferences of non-existent Firefox Account!; ignored"); + return; + } + // We don't necessarily have an Activity context here, so we always start in a new task. + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_STATUS); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(intent); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java new file mode 100644 index 000000000..7f2eb219e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java @@ -0,0 +1,256 @@ +/* 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; + +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.GeckoMenuItem; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.text.TextSelection; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.ActionModeCompat.Callback; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.MenuItem; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Timer; +import java.util.TimerTask; + +import android.util.Log; + +class ActionBarTextSelection implements TextSelection, GeckoEventListener { + private static final String LOGTAG = "GeckoTextSelection"; + private static final int SHUTDOWN_DELAY_MS = 250; + + private final Context context; + + private boolean mDraggingHandles; + + private String selectionID; // Unique ID provided for each selection action. + + private String mCurrentItems; + + private TextSelectionActionModeCallback mCallback; + + // These timers are used to avoid flicker caused by selection handles showing/hiding quickly. + // For instance when moving between single handle caret mode and two handle selection mode. + private final Timer mActionModeTimer = new Timer("actionMode"); + private class ActionModeTimerTask extends TimerTask { + @Override + public void run() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + endActionMode(); + } + }); + } + }; + private ActionModeTimerTask mActionModeTimerTask; + + ActionBarTextSelection(Context context) { + this.context = context; + } + + @Override + public void create() { + // Only register listeners if we have valid start/middle/end handles + if (context == null) { + Log.e(LOGTAG, "Failed to initialize text selection because at least one context is null"); + } else { + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "TextSelection:ActionbarInit", + "TextSelection:ActionbarStatus", + "TextSelection:ActionbarUninit", + "TextSelection:Update"); + } + } + + @Override + public boolean dismiss() { + // We do not call endActionMode() here because this is already handled by the activity. + return false; + } + + @Override + public void destroy() { + if (context == null) { + Log.e(LOGTAG, "Do not unregister TextSelection:* listeners since context is null"); + } else { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "TextSelection:ActionbarInit", + "TextSelection:ActionbarStatus", + "TextSelection:ActionbarUninit", + "TextSelection:Update"); + } + } + + @Override + public void handleMessage(final String event, final JSONObject message) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + try { + if (event.equals("TextSelection:Update")) { + if (mActionModeTimerTask != null) + mActionModeTimerTask.cancel(); + showActionMode(message.getJSONArray("actions")); + } else if (event.equals("TextSelection:ActionbarInit")) { + // Init / Open the action bar. Note the current selectionID, + // cancel any pending actionBar close. + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, + TelemetryContract.Method.CONTENT, "text_selection"); + + selectionID = message.getString("selectionID"); + mCurrentItems = null; + if (mActionModeTimerTask != null) { + mActionModeTimerTask.cancel(); + } + + } else if (event.equals("TextSelection:ActionbarStatus")) { + // Ensure async updates from SearchService for example are valid. + if (selectionID != message.optString("selectionID")) { + return; + } + + // Update the actionBar actions as provided by Gecko. + showActionMode(message.getJSONArray("actions")); + + } else if (event.equals("TextSelection:ActionbarUninit")) { + // Uninit the actionbar. Schedule a cancellable close + // action to avoid UI jank. (During SelectionAll for ex). + mCurrentItems = null; + mActionModeTimerTask = new ActionModeTimerTask(); + mActionModeTimer.schedule(mActionModeTimerTask, SHUTDOWN_DELAY_MS); + } + + } catch (JSONException e) { + Log.e(LOGTAG, "JSON exception", e); + } + } + }); + } + + private void showActionMode(final JSONArray items) { + String itemsString = items.toString(); + if (itemsString.equals(mCurrentItems)) { + return; + } + mCurrentItems = itemsString; + + if (mCallback != null) { + mCallback.updateItems(items); + return; + } + + if (context instanceof ActionModeCompat.Presenter) { + final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context; + mCallback = new TextSelectionActionModeCallback(items); + presenter.startActionModeCompat(mCallback); + mCallback.animateIn(); + } + } + + private void endActionMode() { + if (context instanceof ActionModeCompat.Presenter) { + final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context; + presenter.endActionModeCompat(); + } + mCurrentItems = null; + } + + private class TextSelectionActionModeCallback implements Callback { + private JSONArray mItems; + private ActionModeCompat mActionMode; + + public TextSelectionActionModeCallback(JSONArray items) { + mItems = items; + } + + public void updateItems(JSONArray items) { + mItems = items; + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + public void animateIn() { + if (mActionMode != null) { + mActionMode.animateIn(); + } + } + + @Override + public boolean onPrepareActionMode(final ActionModeCompat mode, final GeckoMenu menu) { + // Android would normally expect us to only update the state of menu items here + // To make the js-java interaction a bit simpler, we just wipe out the menu here and recreate all + // the javascript menu items in onPrepare instead. This will be called any time invalidate() is called on the + // action mode. + menu.clear(); + + int length = mItems.length(); + for (int i = 0; i < length; i++) { + try { + final JSONObject obj = mItems.getJSONObject(i); + final GeckoMenuItem menuitem = (GeckoMenuItem) menu.add(0, i, 0, obj.optString("label")); + final int actionEnum = obj.optBoolean("showAsAction") ? GeckoMenuItem.SHOW_AS_ACTION_ALWAYS : GeckoMenuItem.SHOW_AS_ACTION_NEVER; + menuitem.setShowAsAction(actionEnum, R.attr.menuItemActionModeStyle); + + final String iconString = obj.optString("icon"); + ResourceDrawableUtils.getDrawable(context, iconString, new ResourceDrawableUtils.BitmapLoader() { + @Override + public void onBitmapFound(Drawable d) { + if (d != null) { + menuitem.setIcon(d); + } + } + }); + } catch (Exception ex) { + Log.i(LOGTAG, "Exception building menu", ex); + } + } + return true; + } + + @Override + public boolean onCreateActionMode(ActionModeCompat mode, GeckoMenu unused) { + mActionMode = mode; + return true; + } + + @Override + public boolean onActionItemClicked(ActionModeCompat mode, MenuItem item) { + try { + final JSONObject obj = mItems.getJSONObject(item.getItemId()); + GeckoAppShell.notifyObservers("TextSelection:Action", obj.optString("id")); + return true; + } catch (Exception ex) { + Log.i(LOGTAG, "Exception calling action", ex); + } + return false; + } + + // Called when the user exits the action mode + @Override + public void onDestroyActionMode(ActionModeCompat mode) { + mActionMode = null; + mCallback = null; + final JSONObject args = new JSONObject(); + try { + args.put("selectionID", selectionID); + } catch (JSONException e) { + Log.e(LOGTAG, "Error building JSON arguments for TextSelection:End", e); + return; + } + + GeckoAppShell.notifyObservers("TextSelection:End", args.toString()); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java new file mode 100644 index 000000000..709c0056f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java @@ -0,0 +1,135 @@ +/* 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; + +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.GeckoMenuItem; +import org.mozilla.gecko.widget.GeckoPopupMenu; + +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +class ActionModeCompat implements GeckoPopupMenu.OnMenuItemClickListener, + GeckoPopupMenu.OnMenuItemLongClickListener, + View.OnClickListener { + private final String LOGTAG = "GeckoActionModeCompat"; + + private final Callback mCallback; + private final ActionModeCompatView mView; + private final Presenter mPresenter; + + /* A set of callbacks to be called during this ActionMode's lifecycle. These will control the + * creation, interaction with, and destruction of menuitems for the view */ + public static interface Callback { + /* Called when action mode is first created. Implementors should use this to inflate menu resources. */ + public boolean onCreateActionMode(ActionModeCompat mode, GeckoMenu menu); + + /* Called to refresh an action mode's action menu. Called whenever the mode is invalidated. Implementors + * should use this to enable/disable/show/hide menu items. */ + public boolean onPrepareActionMode(ActionModeCompat mode, GeckoMenu menu); + + /* Called to report a user click on an action button. */ + public boolean onActionItemClicked(ActionModeCompat mode, MenuItem item); + + /* Called when an action mode is about to be exited and destroyed. */ + public void onDestroyActionMode(ActionModeCompat mode); + } + + /* Presenters handle the actual showing/hiding of the action mode UI in the app. Its their responsibility + * to create an action mode, and assign it Callbacks and ActionModeCompatView's. */ + public static interface Presenter { + /* Called when an action mode should be shown */ + public void startActionModeCompat(final Callback callback); + + /* Called when whatever action mode is showing should be hidden */ + public void endActionModeCompat(); + } + + public ActionModeCompat(Presenter presenter, Callback callback, ActionModeCompatView view) { + mPresenter = presenter; + mCallback = callback; + + mView = view; + mView.initForMode(this); + } + + public void finish() { + // Clearing the menu will also clear the ActionItemBar + final GeckoMenu menu = mView.getMenu(); + menu.clear(); + menu.close(); + + if (mCallback != null) { + mCallback.onDestroyActionMode(this); + } + } + + public CharSequence getTitle() { + return mView.getTitle(); + } + + public void setTitle(CharSequence title) { + mView.setTitle(title); + } + + public void setTitle(int resId) { + mView.setTitle(resId); + } + + public GeckoMenu getMenu() { + return mView.getMenu(); + } + + public void invalidate() { + if (mCallback != null) { + mCallback.onPrepareActionMode(this, mView.getMenu()); + } + mView.invalidate(); + } + + public void animateIn() { + mView.animateIn(); + } + + /* GeckoPopupMenu.OnMenuItemClickListener */ + @Override + public boolean onMenuItemClick(MenuItem item) { + if (mCallback != null) { + return mCallback.onActionItemClicked(this, item); + } + return false; + } + + /* GeckoPopupMenu.onMenuItemLongClickListener */ + @Override + public boolean onMenuItemLongClick(MenuItem item) { + showTooltip((GeckoMenuItem) item); + return true; + } + + /* View.OnClickListener*/ + @Override + public void onClick(View v) { + mPresenter.endActionModeCompat(); + } + + private void showTooltip(GeckoMenuItem item) { + // Computes the tooltip toast screen position (shown when long-tapping the menu item) with regards to the + // menu item's position (i.e below the item and slightly to the left) + int[] location = new int[2]; + final View view = item.getActionView(); + view.getLocationOnScreen(location); + + int xOffset = location[0] - view.getWidth(); + int yOffset = location[1] + view.getHeight() / 2; + + Toast toast = Toast.makeText(view.getContext(), item.getTitle(), Toast.LENGTH_SHORT); + toast.setGravity(Gravity.TOP | Gravity.LEFT, xOffset, yOffset); + toast.show(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java new file mode 100644 index 000000000..c9021b710 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java @@ -0,0 +1,202 @@ +/* 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; + +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import org.mozilla.gecko.animation.AnimationUtils; +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.widget.GeckoPopupMenu; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +class ActionModeCompatView extends LinearLayout implements GeckoMenu.ActionItemBarPresenter { + private final String LOGTAG = "GeckoActionModeCompatPresenter"; + + private static final int SPEC = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + + private Button mTitleView; + private ImageButton mMenuButton; + private ViewGroup mActionButtonBar; + private GeckoPopupMenu mPopupMenu; + + // Maximum number of items to show as actions + private static final int MAX_ACTION_ITEMS = 4; + + private int mActionButtonsWidth; + + private Paint mBottomDividerPaint; + private int mBottomDividerOffset; + + public ActionModeCompatView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0); + } + + public ActionModeCompatView(Context context, AttributeSet attrs, int style) { + super(context, attrs, style); + init(context, attrs, style); + } + + public void init(final Context context, final AttributeSet attrs, final int defStyle) { + LayoutInflater.from(context).inflate(R.layout.actionbar, this); + + mTitleView = (Button) findViewById(R.id.actionmode_title); + mMenuButton = (ImageButton) findViewById(R.id.actionbar_menu); + mActionButtonBar = (ViewGroup) findViewById(R.id.actionbar_buttons); + + mPopupMenu = new GeckoPopupMenu(getContext(), mMenuButton); + mPopupMenu.getMenu().setActionItemBarPresenter(this); + + mMenuButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + openMenu(); + } + }); + + // The built-in action bar uses colorAccent for the divider so we duplicate that here. + final TypedArray arr = context.obtainStyledAttributes(attrs, new int[] { R.attr.colorAccent }, defStyle, 0); + final int bottomDividerColor = arr.getColor(0, 0); + arr.recycle(); + + mBottomDividerPaint = new Paint(); + mBottomDividerPaint.setColor(bottomDividerColor); + mBottomDividerOffset = getResources().getDimensionPixelSize(R.dimen.action_bar_divider_height); + } + + public void initForMode(final ActionModeCompat mode) { + mTitleView.setOnClickListener(mode); + mPopupMenu.setOnMenuItemClickListener(mode); + mPopupMenu.setOnMenuItemLongClickListener(mode); + } + + public CharSequence getTitle() { + return mTitleView.getText(); + } + + public void setTitle(CharSequence title) { + mTitleView.setText(title); + } + + public void setTitle(int resId) { + mTitleView.setText(resId); + } + + public GeckoMenu getMenu() { + return mPopupMenu.getMenu(); + } + + @Override + public void invalidate() { + // onFinishInflate may not have been called yet on some versions of Android + if (mPopupMenu != null && mMenuButton != null) { + mMenuButton.setVisibility(mPopupMenu.getMenu().hasVisibleItems() ? View.VISIBLE : View.GONE); + } + super.invalidate(); + } + + /* GeckoMenu.ActionItemBarPresenter */ + @Override + public boolean addActionItem(View actionItem) { + final int count = mActionButtonBar.getChildCount(); + if (count >= MAX_ACTION_ITEMS) { + return false; + } + + int maxWidth = mActionButtonBar.getMeasuredWidth(); + if (maxWidth == 0) { + mActionButtonBar.measure(SPEC, SPEC); + maxWidth = mActionButtonBar.getMeasuredWidth(); + } + + // If the menu button is already visible, no need to account for it + if (mMenuButton.getVisibility() == View.GONE) { + // Since we don't know how many items will be added, we always reserve space for the overflow menu + mMenuButton.measure(SPEC, SPEC); + maxWidth -= mMenuButton.getMeasuredWidth(); + } + + if (mActionButtonsWidth <= 0) { + mActionButtonsWidth = 0; + + // Loop over child views, measure them, and add their width to the taken width + for (int i = 0; i < count; i++) { + View v = mActionButtonBar.getChildAt(i); + v.measure(SPEC, SPEC); + mActionButtonsWidth += v.getMeasuredWidth(); + } + } + + actionItem.measure(SPEC, SPEC); + int w = actionItem.getMeasuredWidth(); + if (mActionButtonsWidth + w < maxWidth) { + // We cache the new width of our children. + mActionButtonsWidth += w; + mActionButtonBar.addView(actionItem); + return true; + } + + return false; + } + + /* GeckoMenu.ActionItemBarPresenter */ + @Override + public void removeActionItem(View actionItem) { + actionItem.measure(SPEC, SPEC); + mActionButtonsWidth -= actionItem.getMeasuredWidth(); + mActionButtonBar.removeView(actionItem); + } + + public void openMenu() { + mPopupMenu.openMenu(); + } + + public void closeMenu() { + mPopupMenu.dismiss(); + } + + public void animateIn() { + long duration = AnimationUtils.getShortDuration(getContext()); + TranslateAnimation t = new TranslateAnimation(Animation.RELATIVE_TO_SELF, -0.5f, Animation.RELATIVE_TO_SELF, 0f, + Animation.RELATIVE_TO_SELF, 0f, Animation.RELATIVE_TO_SELF, 0f); + t.setDuration(duration); + + ScaleAnimation s = new ScaleAnimation(1f, 1f, 0f, 1f, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); + s.setDuration((long) (duration * 1.5f)); + + mTitleView.startAnimation(t); + mActionButtonBar.startAnimation(s); + + if ((mMenuButton.getVisibility() == View.VISIBLE) && + (mPopupMenu.getMenu().size() > 0)) { + mMenuButton.startAnimation(s); + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // Draw the divider at the bottom of the screen. We could do this with a layer-list + // but then we'd have overdraw (http://stackoverflow.com/a/13509472). + final int bottom = getHeight(); + final int top = bottom - mBottomDividerOffset; + canvas.drawRect(0, top, getWidth(), bottom, mBottomDividerPaint); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java b/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java new file mode 100644 index 000000000..7174c6580 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java @@ -0,0 +1,61 @@ +/* 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; + +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.ActivityResultHandlerMap; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class ActivityHandlerHelper { + private static final String LOGTAG = "GeckoActivityHandlerHelper"; + private static final ActivityResultHandlerMap mActivityResultHandlerMap = new ActivityResultHandlerMap(); + + private static int makeRequestCode(ActivityResultHandler aHandler) { + return mActivityResultHandlerMap.put(aHandler); + } + + public static void startIntent(Intent intent, ActivityResultHandler activityResultHandler) { + startIntentForActivity(GeckoAppShell.getGeckoInterface().getActivity(), intent, activityResultHandler); + } + + /** + * Starts the Activity, catching & logging if the Activity fails to start. + * + * We catch to prevent callers from passing in invalid Intents and crashing the browser. + * + * @return true if the Activity is successfully started, false otherwise. + */ + public static boolean startIntentAndCatch(final String logtag, final Context context, final Intent intent) { + try { + context.startActivity(intent); + return true; + } catch (final ActivityNotFoundException e) { + Log.w(logtag, "Activity not found.", e); + return false; + } catch (final SecurityException e) { + Log.w(logtag, "Forbidden to launch activity.", e); + return false; + } + } + + public static void startIntentForActivity(Activity activity, Intent intent, ActivityResultHandler activityResultHandler) { + activity.startActivityForResult(intent, mActivityResultHandlerMap.put(activityResultHandler)); + } + + + public static boolean handleActivityResult(int requestCode, int resultCode, Intent data) { + ActivityResultHandler handler = mActivityResultHandlerMap.getAndRemove(requestCode); + if (handler != null) { + handler.onActivityResult(resultCode, data); + return true; + } + return false; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java b/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java new file mode 100644 index 000000000..39ca25b67 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java @@ -0,0 +1,27 @@ +/* -*- 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; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.mozilla.gecko.feeds.FeedService; + +/** + * This broadcast receiver receives ACTION_BOOT_COMPLETED broadcasts and starts components that should + * run after the device has booted. + */ +public class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || !intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { + return; // This is not the broadcast you are looking for. + } + + FeedService.setup(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java new file mode 100644 index 000000000..5eddca3cf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java @@ -0,0 +1,4261 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.DownloadManager; +import android.content.ContentProviderClient; +import android.os.Environment; +import android.os.Process; +import android.support.annotation.NonNull; + +import android.graphics.Rect; + +import org.json.JSONArray; +import org.mozilla.gecko.activitystream.ActivityStream; +import org.mozilla.gecko.adjust.AdjustBrowserAppDelegate; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.DynamicToolbar.VisibilityTransition; +import org.mozilla.gecko.Tabs.TabEvents; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.cleanup.FileCleanupController; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.SuggestedSites; +import org.mozilla.gecko.delegates.BrowserAppDelegate; +import org.mozilla.gecko.delegates.OfflineTabStatusDelegate; +import org.mozilla.gecko.delegates.ScreenshotDelegate; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.distribution.DistributionStoreCallback; +import org.mozilla.gecko.distribution.PartnerBrowserCustomizationsClient; +import org.mozilla.gecko.dlc.DownloadContentService; +import org.mozilla.gecko.icons.decoders.IconDirectoryEntry; +import org.mozilla.gecko.feeds.ContentNotificationsDelegate; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.firstrun.FirstrunAnimationContainer; +import org.mozilla.gecko.gfx.DynamicToolbarAnimator; +import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.home.BrowserSearch; +import org.mozilla.gecko.home.HomeBanner; +import org.mozilla.gecko.home.HomeConfig; +import org.mozilla.gecko.home.HomeConfig.PanelType; +import org.mozilla.gecko.home.HomeConfigPrefsBackend; +import org.mozilla.gecko.home.HomeFragment; +import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.HomePanelsManager; +import org.mozilla.gecko.home.HomeScreen; +import org.mozilla.gecko.home.SearchEngine; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.javaaddons.JavaAddonManager; +import org.mozilla.gecko.media.VideoPlayer; +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.GeckoMenuItem; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.notifications.NotificationHelper; +import org.mozilla.gecko.overlays.ui.ShareDialog; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.preferences.ClearOnShutdownPref; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.promotion.AddToHomeScreenPromotion; +import org.mozilla.gecko.delegates.BookmarkStateChangeDelegate; +import org.mozilla.gecko.promotion.ReaderViewBookmarkPromotion; +import org.mozilla.gecko.prompts.Prompt; +import org.mozilla.gecko.reader.SavedReaderViewHelper; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.reader.ReadingListHelper; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.RestrictedProfileConfiguration; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.search.SearchEngineManager; +import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository; +import org.mozilla.gecko.tabqueue.TabQueueHelper; +import org.mozilla.gecko.tabqueue.TabQueuePrompt; +import org.mozilla.gecko.tabs.TabHistoryController; +import org.mozilla.gecko.tabs.TabHistoryController.OnShowTabHistory; +import org.mozilla.gecko.tabs.TabHistoryFragment; +import org.mozilla.gecko.tabs.TabHistoryPage; +import org.mozilla.gecko.tabs.TabsPanel; +import org.mozilla.gecko.telemetry.TelemetryUploadService; +import org.mozilla.gecko.telemetry.TelemetryCorePingDelegate; +import org.mozilla.gecko.telemetry.measurements.SearchCountMeasurements; +import org.mozilla.gecko.toolbar.AutocompleteHandler; +import org.mozilla.gecko.toolbar.BrowserToolbar; +import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState; +import org.mozilla.gecko.toolbar.ToolbarProgressView; +import org.mozilla.gecko.trackingprotection.TrackingProtectionPrompt; +import org.mozilla.gecko.updater.PostUpdateHandler; +import org.mozilla.gecko.updater.UpdateServiceHelper; +import org.mozilla.gecko.util.ActivityUtils; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.ContextUtils; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.FloatUtils; +import org.mozilla.gecko.util.GamepadUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.util.MenuUtils; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.PrefUtils; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.AnchoredPopup; + +import org.mozilla.gecko.widget.GeckoActionProvider; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; +import android.nfc.NdefMessage; +import android.nfc.NdefRecord; +import android.nfc.NfcAdapter; +import android.nfc.NfcEvent; +import android.os.Build; +import android.os.Bundle; +import android.os.StrictMode; +import android.support.design.widget.Snackbar; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.NotificationCompat; +import android.support.v4.view.MenuItemCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Base64; +import android.util.Base64OutputStream; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SubMenu; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.view.animation.Interpolator; +import android.widget.Button; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.ViewFlipper; +import com.keepsafe.switchboard.AsyncConfigLoader; +import com.keepsafe.switchboard.SwitchBoard; +import android.animation.Animator; +import android.animation.ObjectAnimator; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Vector; +import java.util.regex.Pattern; + +public class BrowserApp extends GeckoApp + implements TabsPanel.TabsLayoutChangeListener, + PropertyAnimator.PropertyAnimationListener, + View.OnKeyListener, + LayerView.DynamicToolbarListener, + BrowserSearch.OnSearchListener, + BrowserSearch.OnEditSuggestionListener, + OnUrlOpenListener, + OnUrlOpenInBackgroundListener, + AnchoredPopup.OnVisibilityChangeListener, + ActionModeCompat.Presenter, + LayoutInflater.Factory { + private static final String LOGTAG = "GeckoBrowserApp"; + + private static final int TABS_ANIMATION_DURATION = 450; + + // Intent String extras used to specify custom Switchboard configurations. + private static final String INTENT_KEY_SWITCHBOARD_SERVER = "switchboard-server"; + + // TODO: Replace with kinto endpoint. + private static final String SWITCHBOARD_SERVER = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/experiments/records"; + + private static final String STATE_ABOUT_HOME_TOP_PADDING = "abouthome_top_padding"; + + private static final String BROWSER_SEARCH_TAG = "browser_search"; + + // Request ID for startActivityForResult. + private static final int ACTIVITY_REQUEST_PREFERENCES = 1001; + private static final int ACTIVITY_REQUEST_TAB_QUEUE = 2001; + public static final int ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK = 3001; + public static final int ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS = 3002; + public static final int ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE = 3003; + public static final int ACTIVITY_REQUEST_TRIPLE_READERVIEW = 4001; + public static final int ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK = 4002; + public static final int ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE = 4003; + + public static final String ACTION_VIEW_MULTIPLE = AppConstants.ANDROID_PACKAGE_NAME + ".action.VIEW_MULTIPLE"; + + @RobocopTarget + public static final String EXTRA_SKIP_STARTPANE = "skipstartpane"; + private static final String EOL_NOTIFIED = "eol_notified"; + + private BrowserSearch mBrowserSearch; + private View mBrowserSearchContainer; + + public ViewGroup mBrowserChrome; + public ViewFlipper mActionBarFlipper; + public ActionModeCompatView mActionBar; + private VideoPlayer mVideoPlayer; + private BrowserToolbar mBrowserToolbar; + private View mDoorhangerOverlay; + // We can't name the TabStrip class because it's not included on API 9. + private TabStripInterface mTabStrip; + private ToolbarProgressView mProgressView; + private FirstrunAnimationContainer mFirstrunAnimationContainer; + private HomeScreen mHomeScreen; + private TabsPanel mTabsPanel; + /** + * Container for the home screen implementation. This will be populated with any valid + * home screen implementation (currently that is just the HomePager, but that will be extended + * to permit further experimental replacement panels such as the activity-stream panel). + */ + private ViewGroup mHomeScreenContainer; + private int mCachedRecentTabsCount; + private ActionModeCompat mActionMode; + private TabHistoryController tabHistoryController; + private ZoomedView mZoomedView; + + private static final int GECKO_TOOLS_MENU = -1; + private static final int ADDON_MENU_OFFSET = 1000; + public static final String TAB_HISTORY_FRAGMENT_TAG = "tabHistoryFragment"; + + private static class MenuItemInfo { + public int id; + public String label; + public boolean checkable; + public boolean checked; + public boolean enabled = true; + public boolean visible = true; + public int parent; + public boolean added; // So we can re-add after a locale change. + } + + // The types of guest mode dialogs we show. + public static enum GuestModeDialog { + ENTERING, + LEAVING + } + + private Vector<MenuItemInfo> mAddonMenuItemsCache; + private PropertyAnimator mMainLayoutAnimator; + + private static final Interpolator sTabsInterpolator = new Interpolator() { + @Override + public float getInterpolation(float t) { + t -= 1.0f; + return t * t * t * t * t + 1.0f; + } + }; + + private FindInPageBar mFindInPageBar; + private MediaCastingBar mMediaCastingBar; + + // We'll ask for feedback after the user launches the app this many times. + private static final int FEEDBACK_LAUNCH_COUNT = 15; + + // Stored value of the toolbar height, so we know when it's changed. + private int mToolbarHeight; + + private SharedPreferencesHelper mSharedPreferencesHelper; + + private ReadingListHelper mReadingListHelper; + + private AccountsHelper mAccountsHelper; + + // The tab to be selected on editing mode exit. + private Integer mTargetTabForEditingMode; + + private final TabEditingState mLastTabEditingState = new TabEditingState(); + + // The animator used to toggle HomePager visibility has a race where if the HomePager is shown + // (starting the animation), the HomePager is hidden, and the HomePager animation completes, + // both the web content and the HomePager will be hidden. This flag is used to prevent the + // race by determining if the web content should be hidden at the animation's end. + private boolean mHideWebContentOnAnimationEnd; + + private final DynamicToolbar mDynamicToolbar = new DynamicToolbar(); + + private final TelemetryCorePingDelegate mTelemetryCorePingDelegate = new TelemetryCorePingDelegate(); + + private final List<BrowserAppDelegate> delegates = Collections.unmodifiableList(Arrays.asList( + new AddToHomeScreenPromotion(), + new ScreenshotDelegate(), + new BookmarkStateChangeDelegate(), + new ReaderViewBookmarkPromotion(), + new ContentNotificationsDelegate(), + new PostUpdateHandler(), + mTelemetryCorePingDelegate, + new OfflineTabStatusDelegate(), + new AdjustBrowserAppDelegate(mTelemetryCorePingDelegate) + )); + + @NonNull + private SearchEngineManager mSearchEngineManager; // Contains reference to Context - DO NOT LEAK! + + private boolean mHasResumed; + + @Override + public View onCreateView(final String name, final Context context, final AttributeSet attrs) { + final View view; + if (BrowserToolbar.class.getName().equals(name)) { + view = BrowserToolbar.create(context, attrs); + } else if (TabsPanel.TabsLayout.class.getName().equals(name)) { + view = TabsPanel.createTabsLayout(context, attrs); + } else { + view = super.onCreateView(name, context, attrs); + } + return view; + } + + @Override + public void onTabChanged(Tab tab, TabEvents msg, String data) { + if (tab == null) { + // Only RESTORED is allowed a null tab: it's the only event that + // isn't tied to a specific tab. + if (msg != Tabs.TabEvents.RESTORED) { + throw new IllegalArgumentException("onTabChanged:" + msg + " must specify a tab."); + } + + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + // After restoring the tabs we want to update the home pager immediately. Otherwise we + // might wait for an event coming from Gecko and this can take several seconds. (Bug 1283627) + updateHomePagerForTab(selectedTab); + } + + return; + } + + Log.d(LOGTAG, "BrowserApp.onTabChanged: " + tab.getId() + ": " + msg); + switch (msg) { + case SELECTED: + if (mVideoPlayer.isPlaying()) { + mVideoPlayer.stop(); + } + + if (Tabs.getInstance().isSelectedTab(tab) && mDynamicToolbar.isEnabled()) { + final VisibilityTransition transition = (tab.getShouldShowToolbarWithoutAnimationOnFirstSelection()) ? + VisibilityTransition.IMMEDIATE : VisibilityTransition.ANIMATE; + mDynamicToolbar.setVisible(true, transition); + + // The first selection has happened - reset the state. + tab.setShouldShowToolbarWithoutAnimationOnFirstSelection(false); + } + // fall through + case LOCATION_CHANGE: + if (mZoomedView != null) { + mZoomedView.stopZoomDisplay(false); + } + if (Tabs.getInstance().isSelectedTab(tab)) { + updateHomePagerForTab(tab); + } + + mDynamicToolbar.persistTemporaryVisibility(); + break; + case START: + if (Tabs.getInstance().isSelectedTab(tab)) { + invalidateOptionsMenu(); + + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } + } + break; + case LOAD_ERROR: + case STOP: + case MENU_UPDATED: + if (Tabs.getInstance().isSelectedTab(tab)) { + invalidateOptionsMenu(); + } + break; + case PAGE_SHOW: + tab.loadFavicon(); + break; + + case UNSELECTED: + // We receive UNSELECTED immediately after the SELECTED listeners run + // so we are ensured that the unselectedTabEditingText has not changed. + if (tab.isEditing()) { + // Copy to avoid constructing new objects. + tab.getEditingState().copyFrom(mLastTabEditingState); + } + break; + } + + if (HardwareUtils.isTablet() && msg == TabEvents.SELECTED) { + updateEditingModeForTab(tab); + } + + super.onTabChanged(tab, msg, data); + } + + private void updateEditingModeForTab(final Tab selectedTab) { + // (bug 1086983 comment 11) Because the tab may be selected from the gecko thread and we're + // running this code on the UI thread, the selected tab argument may not still refer to the + // selected tab. However, that means this code should be run again and the initial state + // changes will be overridden. As an optimization, we can skip this update, but it may have + // unknown side-effects so we don't. + if (!Tabs.getInstance().isSelectedTab(selectedTab)) { + Log.w(LOGTAG, "updateEditingModeForTab: Given tab is expected to be selected tab"); + } + + saveTabEditingState(mLastTabEditingState); + + if (selectedTab.isEditing()) { + enterEditingMode(); + restoreTabEditingState(selectedTab.getEditingState()); + } else { + mBrowserToolbar.cancelEdit(); + } + } + + private void saveTabEditingState(final TabEditingState editingState) { + mBrowserToolbar.saveTabEditingState(editingState); + editingState.setIsBrowserSearchShown(mBrowserSearch.getUserVisibleHint()); + } + + private void restoreTabEditingState(final TabEditingState editingState) { + mBrowserToolbar.restoreTabEditingState(editingState); + + // Since changing the editing text will show/hide browser search, this + // must be called after we restore the editing state in the edit text View. + if (editingState.isBrowserSearchShown()) { + showBrowserSearch(); + } else { + hideBrowserSearch(); + } + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (AndroidGamepadManager.handleKeyEvent(event)) { + return true; + } + + // Global onKey handler. This is called if the focused UI doesn't + // handle the key event, and before Gecko swallows the events. + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + + if ((event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + switch (keyCode) { + case KeyEvent.KEYCODE_BUTTON_Y: + // Toggle/focus the address bar on gamepad-y button. + if (mBrowserChrome.getVisibility() == View.VISIBLE) { + if (mDynamicToolbar.isEnabled() && !isHomePagerVisible()) { + mDynamicToolbar.setVisible(false, VisibilityTransition.ANIMATE); + if (mLayerView != null) { + mLayerView.requestFocus(); + } + } else { + // Just focus the address bar when about:home is visible + // or when the dynamic toolbar isn't enabled. + mBrowserToolbar.requestFocusFromTouch(); + } + } else { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + mBrowserToolbar.requestFocusFromTouch(); + } + return true; + case KeyEvent.KEYCODE_BUTTON_L1: + // Go back on L1 + Tabs.getInstance().getSelectedTab().doBack(); + return true; + case KeyEvent.KEYCODE_BUTTON_R1: + // Go forward on R1 + Tabs.getInstance().getSelectedTab().doForward(); + return true; + } + } + + // Check if this was a shortcut. Meta keys exists only on 11+. + final Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null && event.isCtrlPressed()) { + switch (keyCode) { + case KeyEvent.KEYCODE_LEFT_BRACKET: + tab.doBack(); + return true; + + case KeyEvent.KEYCODE_RIGHT_BRACKET: + tab.doForward(); + return true; + + case KeyEvent.KEYCODE_R: + tab.doReload(false); + return true; + + case KeyEvent.KEYCODE_PERIOD: + tab.doStop(); + return true; + + case KeyEvent.KEYCODE_T: + addTab(); + return true; + + case KeyEvent.KEYCODE_W: + Tabs.getInstance().closeTab(tab); + return true; + + case KeyEvent.KEYCODE_F: + mFindInPageBar.show(); + return true; + } + } + + return false; + } + + private Runnable mCheckLongPress; + { + // Only initialise the runnable if we are >= N. + // See onKeyDown() for more details of the back-button long-press workaround + if (!Versions.preN) { + mCheckLongPress = new Runnable() { + public void run() { + handleBackLongPress(); + } + }; + } + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Bug 1304688: Android N has broken passing onKeyLongPress events for the back button, so we + // instead copy the long-press-handler technique from Android's KeyButtonView. + // - For short presses, we cancel the callback in onKeyUp + // - For long presses, the normal keypress is marked as cancelled, hence won't be handled elsewhere + // (but Android still provides the haptic feedback), and the runnable is run. + if (!Versions.preN && + keyCode == KeyEvent.KEYCODE_BACK) { + ThreadUtils.getUiHandler().removeCallbacks(mCheckLongPress); + ThreadUtils.getUiHandler().postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout()); + } + + if (!mBrowserToolbar.isEditing() && onKey(null, keyCode, event)) { + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (!Versions.preN && + keyCode == KeyEvent.KEYCODE_BACK) { + ThreadUtils.getUiHandler().removeCallbacks(mCheckLongPress); + } + + if (AndroidGamepadManager.handleKeyEvent(event)) { + return true; + } + return super.onKeyUp(keyCode, event); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + if (!HardwareUtils.isSupportedSystem()) { + // This build does not support the Android version of the device; Exit early. + super.onCreate(savedInstanceState); + return; + } + + final SafeIntent intent = new SafeIntent(getIntent()); + final boolean isInAutomation = IntentUtils.getIsInAutomationFromEnvironment(intent); + + // This has to be prepared prior to calling GeckoApp.onCreate, because + // widget code and BrowserToolbar need it, and they're created by the + // layout, which GeckoApp takes care of. + ((GeckoApplication) getApplication()).prepareLightweightTheme(); + + super.onCreate(savedInstanceState); + + final Context appContext = getApplicationContext(); + + initSwitchboard(this, intent, isInAutomation); + initTelemetryUploader(isInAutomation); + + mBrowserChrome = (ViewGroup) findViewById(R.id.browser_chrome); + mActionBarFlipper = (ViewFlipper) findViewById(R.id.browser_actionbar); + mActionBar = (ActionModeCompatView) findViewById(R.id.actionbar); + + mVideoPlayer = (VideoPlayer) findViewById(R.id.video_player); + mVideoPlayer.setFullScreenListener(new VideoPlayer.FullScreenListener() { + @Override + public void onFullScreenChanged(boolean fullScreen) { + mVideoPlayer.setFullScreen(fullScreen); + setFullScreen(fullScreen); + } + }); + + mBrowserToolbar = (BrowserToolbar) findViewById(R.id.browser_toolbar); + mBrowserToolbar.setTouchEventInterceptor(new TouchEventInterceptor() { + @Override + public boolean onInterceptTouchEvent(View view, MotionEvent event) { + // Manually dismiss text selection bar if it's not overlaying the toolbar. + mTextSelection.dismiss(); + return false; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + return false; + } + }); + + mProgressView = (ToolbarProgressView) findViewById(R.id.progress); + mBrowserToolbar.setProgressBar(mProgressView); + + // Initialize Tab History Controller. + tabHistoryController = new TabHistoryController(new OnShowTabHistory() { + @Override + public void onShowHistory(final List<TabHistoryPage> historyPageList, final int toIndex) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (BrowserApp.this.isFinishing()) { + // TabHistoryController is rather slow - and involves calling into Gecko + // to retrieve tab history. That means there can be a significant + // delay between the back-button long-press, and onShowHistory() + // being called. Hence we need to guard against the Activity being + // shut down (in which case trying to perform UI changes, such as showing + // fragments below, will crash). + return; + } + + final TabHistoryFragment fragment = TabHistoryFragment.newInstance(historyPageList, toIndex); + final FragmentManager fragmentManager = getSupportFragmentManager(); + GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec)); + fragment.show(R.id.tab_history_panel, fragmentManager.beginTransaction(), TAB_HISTORY_FRAGMENT_TAG); + } + }); + } + }); + mBrowserToolbar.setTabHistoryController(tabHistoryController); + + final String action = intent.getAction(); + if (Intent.ACTION_VIEW.equals(action)) { + // Show the target URL immediately in the toolbar. + mBrowserToolbar.setTitle(intent.getDataString()); + + showTabQueuePromptIfApplicable(intent); + } else if (ACTION_VIEW_MULTIPLE.equals(action) && savedInstanceState == null) { + // We only want to handle this intent if savedInstanceState is null. In the case where + // savedInstanceState is not null this activity is being re-created and we already + // opened tabs for the URLs the last time. Our session store will take care of restoring + // them. + openMultipleTabsFromIntent(intent); + } else if (GuestSession.NOTIFICATION_INTENT.equals(action)) { + GuestSession.onNotificationIntentReceived(this); + } else if (TabQueueHelper.LOAD_URLS_ACTION.equals(action)) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, "tabqueue"); + } + + if (HardwareUtils.isTablet()) { + mTabStrip = (TabStripInterface) (((ViewStub) findViewById(R.id.tablet_tab_strip)).inflate()); + } + + ((GeckoApp.MainLayout) mMainLayout).setTouchEventInterceptor(new HideOnTouchListener()); + ((GeckoApp.MainLayout) mMainLayout).setMotionEventInterceptor(new MotionEventInterceptor() { + @Override + public boolean onInterceptMotionEvent(View view, MotionEvent event) { + // If we get a gamepad panning MotionEvent while the focus is not on the layerview, + // put the focus on the layerview and carry on + if (mLayerView != null && !mLayerView.hasFocus() && GamepadUtils.isPanningControl(event)) { + if (mHomeScreen == null) { + return false; + } + + if (isHomePagerVisible()) { + mLayerView.requestFocus(); + } else { + mHomeScreen.requestFocus(); + } + } + return false; + } + }); + + mHomeScreenContainer = (ViewGroup) findViewById(R.id.home_screen_container); + + mBrowserSearchContainer = findViewById(R.id.search_container); + mBrowserSearch = (BrowserSearch) getSupportFragmentManager().findFragmentByTag(BROWSER_SEARCH_TAG); + if (mBrowserSearch == null) { + mBrowserSearch = BrowserSearch.newInstance(); + mBrowserSearch.setUserVisibleHint(false); + } + + setBrowserToolbarListeners(); + + mFindInPageBar = (FindInPageBar) findViewById(R.id.find_in_page); + mMediaCastingBar = (MediaCastingBar) findViewById(R.id.media_casting); + + mDoorhangerOverlay = findViewById(R.id.doorhanger_overlay); + + EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener)this, + "Gecko:DelayedStartup", + "Menu:Open", + "Menu:Update", + "LightweightTheme:Update", + "Search:Keyword", + "Prompt:ShowTop", + "Tab:Added", + "Video:Play"); + + EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this, + "CharEncoding:Data", + "CharEncoding:State", + "Download:AndroidDownloadManager", + "Experiments:GetActive", + "Experiments:SetOverride", + "Experiments:ClearOverride", + "Favicon:CacheLoad", + "Feedback:MaybeLater", + "Menu:Add", + "Menu:Remove", + "Sanitize:ClearHistory", + "Sanitize:ClearSyncedTabs", + "Settings:Show", + "Telemetry:Gather", + "Updater:Launch", + "Website:Metadata"); + + final GeckoProfile profile = getProfile(); + + // We want to upload the telemetry core ping as soon after startup as possible. It relies on the + // Distribution being initialized. If you move this initialization, ensure it plays well with telemetry. + final Distribution distribution = Distribution.init(getApplicationContext()); + distribution.addOnDistributionReadyCallback( + new DistributionStoreCallback(getApplicationContext(), profile.getName())); + + mSearchEngineManager = new SearchEngineManager(this, distribution); + + // Init suggested sites engine in BrowserDB. + final SuggestedSites suggestedSites = new SuggestedSites(appContext, distribution); + final BrowserDB db = BrowserDB.from(profile); + db.setSuggestedSites(suggestedSites); + + JavaAddonManager.getInstance().init(appContext); + mSharedPreferencesHelper = new SharedPreferencesHelper(appContext); + mReadingListHelper = new ReadingListHelper(appContext, profile); + mAccountsHelper = new AccountsHelper(appContext, profile); + + if (AppConstants.MOZ_ANDROID_BEAM) { + NfcAdapter nfc = NfcAdapter.getDefaultAdapter(this); + if (nfc != null) { + nfc.setNdefPushMessageCallback(new NfcAdapter.CreateNdefMessageCallback() { + @Override + public NdefMessage createNdefMessage(NfcEvent event) { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab == null || tab.isPrivate()) { + return null; + } + return new NdefMessage(new NdefRecord[] { NdefRecord.createUri(tab.getURL()) }); + } + }, this); + } + } + + if (savedInstanceState != null) { + mDynamicToolbar.onRestoreInstanceState(savedInstanceState); + mHomeScreenContainer.setPadding(0, savedInstanceState.getInt(STATE_ABOUT_HOME_TOP_PADDING), 0, 0); + } + + mDynamicToolbar.setEnabledChangedListener(new DynamicToolbar.OnEnabledChangedListener() { + @Override + public void onEnabledChanged(boolean enabled) { + setDynamicToolbarEnabled(enabled); + } + }); + + // Set the maximum bits-per-pixel the favicon system cares about. + IconDirectoryEntry.setMaxBPP(GeckoAppShell.getScreenDepth()); + + // The update service is enabled for RELEASE_OR_BETA, which includes the release and beta channels. + // However, no updates are served. Therefore, we don't trust the update service directly, and + // try to avoid prompting unnecessarily. See Bug 1232798. + if (!AppConstants.RELEASE_OR_BETA && UpdateServiceHelper.isUpdaterEnabled(this)) { + Permissions.from(this) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .doNotPrompt() + .andFallback(new Runnable() { + @Override + public void run() { + showUpdaterPermissionSnackbar(); + } + }) + .run(); + } + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onCreate(this, savedInstanceState); + } + + // We want to get an understanding of how our user base is spread (bug 1221646). + final String installerPackageName = getPackageManager().getInstallerPackageName(getPackageName()); + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, TelemetryContract.Method.SYSTEM, "installer_" + installerPackageName); + } + + /** + * Initializes the default Switchboard URLs the first time. + * @param intent + */ + private static void initSwitchboard(final Context context, final SafeIntent intent, final boolean isInAutomation) { + if (isInAutomation) { + Log.d(LOGTAG, "Switchboard disabled - in automation"); + return; + } else if (!AppConstants.MOZ_SWITCHBOARD) { + Log.d(LOGTAG, "Switchboard compile-time disabled"); + return; + } + + final String serverExtra = intent.getStringExtra(INTENT_KEY_SWITCHBOARD_SERVER); + final String serverUrl = TextUtils.isEmpty(serverExtra) ? SWITCHBOARD_SERVER : serverExtra; + new AsyncConfigLoader(context, serverUrl).execute(); + } + + private static void initTelemetryUploader(final boolean isInAutomation) { + TelemetryUploadService.setDisabled(isInAutomation); + } + + private void showUpdaterPermissionSnackbar() { + SnackbarBuilder.SnackbarCallback allowCallback = new SnackbarBuilder.SnackbarCallback() { + @Override + public void onClick(View v) { + Permissions.from(BrowserApp.this) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .run(); + } + }; + + SnackbarBuilder.builder(this) + .message(R.string.updater_permission_text) + .duration(Snackbar.LENGTH_INDEFINITE) + .action(R.string.updater_permission_allow) + .callback(allowCallback) + .buildAndShow(); + } + + private void conditionallyNotifyEOL() { + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + try { + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this); + if (!prefs.contains(EOL_NOTIFIED)) { + + // Launch main App to load SUMO url on EOL notification. + final String link = getString(R.string.eol_notification_url, + AppConstants.MOZ_APP_VERSION, + AppConstants.OS_TARGET, + Locales.getLanguageTag(Locale.getDefault())); + + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + intent.setData(Uri.parse(link)); + final PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + final Notification notification = new NotificationCompat.Builder(this) + .setContentTitle(getString(R.string.eol_notification_title)) + .setContentText(getString(R.string.eol_notification_summary)) + .setSmallIcon(R.drawable.ic_status_logo) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build(); + + final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + final int notificationID = EOL_NOTIFIED.hashCode(); + notificationManager.notify(notificationID, notification); + + GeckoSharedPrefs.forProfile(this) + .edit() + .putBoolean(EOL_NOTIFIED, true) + .apply(); + } + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + /** + * Check and show the firstrun pane if the browser has never been launched and + * is not opening an external link from another application. + * + * @param context Context of application; used to show firstrun pane if appropriate + * @param intent Intent that launched this activity + */ + private void checkFirstrun(Context context, SafeIntent intent) { + if (getProfile().inGuestMode()) { + // We do not want to show any first run tour for guest profiles. + return; + } + + if (intent.getBooleanExtra(EXTRA_SKIP_STARTPANE, false)) { + // Note that we don't set the pref, so subsequent launches can result + // in the firstrun pane being shown. + return; + } + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + + try { + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this); + + if (prefs.getBoolean(FirstrunAnimationContainer.PREF_FIRSTRUN_ENABLED, true)) { + if (!Intent.ACTION_VIEW.equals(intent.getAction())) { + showFirstrunPager(); + + if (HardwareUtils.isTablet()) { + mTabStrip.setOnTabChangedListener(new TabStripInterface.OnTabAddedOrRemovedListener() { + @Override + public void onTabChanged() { + hideFirstrunPager(TelemetryContract.Method.BUTTON); + mTabStrip.setOnTabChangedListener(null); + } + }); + } + } + + // Don't bother trying again to show the v1 minimal first run. + prefs.edit().putBoolean(FirstrunAnimationContainer.PREF_FIRSTRUN_ENABLED, false).apply(); + + // We have no intention of stopping this session. The FIRSTRUN session + // ends when the browsing session/activity has ended. All events + // during firstrun will be tagged as FIRSTRUN. + Telemetry.startUISession(TelemetryContract.Session.FIRSTRUN); + } + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + private Class<?> getMediaPlayerManager() { + if (AppConstants.MOZ_MEDIA_PLAYER) { + try { + return Class.forName("org.mozilla.gecko.MediaPlayerManager"); + } catch (Exception ex) { + // Ignore failures + Log.e(LOGTAG, "No native casting support", ex); + } + } + + return null; + } + + @Override + public void onBackPressed() { + if (mTextSelection.dismiss()) { + return; + } + + if (getSupportFragmentManager().getBackStackEntryCount() > 0) { + super.onBackPressed(); + return; + } + + if (mBrowserToolbar.onBackPressed()) { + return; + } + + if (mActionMode != null) { + endActionModeCompat(); + return; + } + + if (hideFirstrunPager(TelemetryContract.Method.BACK)) { + return; + } + + if (mVideoPlayer.isFullScreen()) { + mVideoPlayer.setFullScreen(false); + setFullScreen(false); + return; + } + + if (mVideoPlayer.isPlaying()) { + mVideoPlayer.stop(); + return; + } + + super.onBackPressed(); + } + + @Override + public void onAttachedToWindow() { + // We can't show the first run experience until Gecko has finished initialization (bug 1077583). + checkFirstrun(this, new SafeIntent(getIntent())); + } + + @Override + protected void processTabQueue() { + if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + if (TabQueueHelper.shouldOpenTabQueueUrls(BrowserApp.this)) { + openQueuedTabs(); + } + } + }); + } + } + + @Override + protected void openQueuedTabs() { + ThreadUtils.assertNotOnUiThread(); + + int queuedTabCount = TabQueueHelper.getTabQueueLength(BrowserApp.this); + + Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-delayed"); + + TabQueueHelper.openQueuedUrls(BrowserApp.this, getProfile(), TabQueueHelper.FILE_NAME, false); + + // If there's more than one tab then also show the tabs panel. + if (queuedTabCount > 1) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + showNormalTabs(); + } + }); + } + } + + private void openMultipleTabsFromIntent(final SafeIntent intent) { + final List<String> urls = intent.getStringArrayListExtra("urls"); + if (urls != null) { + openUrls(urls); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (mIsAbortingAppLaunch) { + return; + } + + if (!mHasResumed) { + EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) this, + "Prompt:ShowTop"); + mHasResumed = true; + } + + processTabQueue(); + + for (BrowserAppDelegate delegate : delegates) { + delegate.onResume(this); + } + } + + @Override + public void onPause() { + super.onPause(); + if (mIsAbortingAppLaunch) { + return; + } + + if (mHasResumed) { + // Register for Prompt:ShowTop so we can foreground this activity even if it's hidden. + EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this, + "Prompt:ShowTop"); + mHasResumed = false; + } + + for (BrowserAppDelegate delegate : delegates) { + delegate.onPause(this); + } + } + + @Override + public void onRestart() { + super.onRestart(); + if (mIsAbortingAppLaunch) { + return; + } + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onRestart(this); + } + } + + @Override + public void onStart() { + super.onStart(); + if (mIsAbortingAppLaunch) { + return; + } + + // Queue this work so that the first launch of the activity doesn't + // trigger profile init too early. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final GeckoProfile profile = getProfile(); + if (profile.inGuestMode()) { + GuestSession.showNotification(BrowserApp.this); + } else { + // If we're restarting, we won't destroy the activity. + // Make sure we remove any guest notifications that might + // have been shown. + GuestSession.hideNotification(BrowserApp.this); + } + + // It'd be better to launch this once, in onCreate, but there's ambiguity for when the + // profile is created so we run here instead. Don't worry, call start short-circuits pretty fast. + final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfileName(BrowserApp.this, profile.getName()); + FileCleanupController.startIfReady(BrowserApp.this, sharedPrefs, profile.getDir().getAbsolutePath()); + } + }); + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onStart(this); + } + } + + @Override + public void onStop() { + super.onStop(); + if (mIsAbortingAppLaunch) { + return; + } + + // We only show the guest mode notification when our activity is in the foreground. + GuestSession.hideNotification(this); + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onStop(this); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + + // Sending a message to the toolbar when the browser window gains focus + // This is needed for qr code input + if (hasFocus) { + mBrowserToolbar.onParentFocus(); + } + } + + private void setBrowserToolbarListeners() { + mBrowserToolbar.setOnActivateListener(new BrowserToolbar.OnActivateListener() { + @Override + public void onActivate() { + enterEditingMode(); + } + }); + + mBrowserToolbar.setOnCommitListener(new BrowserToolbar.OnCommitListener() { + @Override + public void onCommit() { + commitEditingMode(); + } + }); + + mBrowserToolbar.setOnDismissListener(new BrowserToolbar.OnDismissListener() { + @Override + public void onDismiss() { + mBrowserToolbar.cancelEdit(); + } + }); + + mBrowserToolbar.setOnFilterListener(new BrowserToolbar.OnFilterListener() { + @Override + public void onFilter(String searchText, AutocompleteHandler handler) { + filterEditingMode(searchText, handler); + } + }); + + mBrowserToolbar.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (isHomePagerVisible()) { + mHomeScreen.onToolbarFocusChange(hasFocus); + } + } + }); + + mBrowserToolbar.setOnStartEditingListener(new BrowserToolbar.OnStartEditingListener() { + @Override + public void onStartEditing() { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + selectedTab.setIsEditing(true); + } + + // Temporarily disable doorhanger notifications. + if (mDoorHangerPopup != null) { + mDoorHangerPopup.disable(); + } + } + }); + + mBrowserToolbar.setOnStopEditingListener(new BrowserToolbar.OnStopEditingListener() { + @Override + public void onStopEditing() { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + selectedTab.setIsEditing(false); + } + + selectTargetTabForEditingMode(); + + // Since the underlying LayerView is set visible in hideHomePager, we would + // ordinarily want to call it first. However, hideBrowserSearch changes the + // visibility of the HomePager and hideHomePager will take no action if the + // HomePager is hidden, so we want to call hideBrowserSearch to restore the + // HomePager visibility first. + hideBrowserSearch(); + hideHomePager(); + + // Re-enable doorhanger notifications. They may trigger on the selected tab above. + if (mDoorHangerPopup != null) { + mDoorHangerPopup.enable(); + } + } + }); + + // Intercept key events for gamepad shortcuts + mBrowserToolbar.setOnKeyListener(this); + } + + private void setDynamicToolbarEnabled(boolean enabled) { + ThreadUtils.assertOnUiThread(); + + if (enabled) { + if (mLayerView != null) { + mLayerView.getDynamicToolbarAnimator().addTranslationListener(this); + } + setToolbarMargin(0); + mHomeScreenContainer.setPadding(0, mBrowserChrome.getHeight(), 0, 0); + } else { + // Immediately show the toolbar when disabling the dynamic + // toolbar. + if (mLayerView != null) { + mLayerView.getDynamicToolbarAnimator().removeTranslationListener(this); + } + mHomeScreenContainer.setPadding(0, 0, 0, 0); + if (mBrowserChrome != null) { + ViewHelper.setTranslationY(mBrowserChrome, 0); + } + if (mLayerView != null) { + mLayerView.setSurfaceTranslation(0); + } + } + + refreshToolbarHeight(); + } + + private static boolean isAboutHome(final Tab tab) { + return AboutPages.isAboutHome(tab.getURL()); + } + + @Override + public boolean onSearchRequested() { + enterEditingMode(); + return true; + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.pasteandgo) { + hideFirstrunPager(TelemetryContract.Method.CONTEXT_MENU); + + String text = Clipboard.getText(); + if (!TextUtils.isEmpty(text)) { + loadUrlOrKeywordSearch(text); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "pasteandgo"); + } + return true; + } + + if (itemId == R.id.paste) { + String text = Clipboard.getText(); + if (!TextUtils.isEmpty(text)) { + enterEditingMode(text); + showBrowserSearch(); + mBrowserSearch.filter(text, null); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "paste"); + } + return true; + } + + if (itemId == R.id.subscribe) { + // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone. + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null && tab.hasFeeds()) { + JSONObject args = new JSONObject(); + try { + args.put("tabId", tab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "error building json arguments", e); + } + GeckoAppShell.notifyObservers("Feeds:Subscribe", args.toString()); + } + return true; + } + + if (itemId == R.id.add_search_engine) { + // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone. + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null && tab.hasOpenSearch()) { + JSONObject args = new JSONObject(); + try { + args.put("tabId", tab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "error building json arguments", e); + return true; + } + GeckoAppShell.notifyObservers("SearchEngines:Add", args.toString()); + } + return true; + } + + if (itemId == R.id.copyurl) { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + String url = ReaderModeUtils.stripAboutReaderUrl(tab.getURL()); + if (url != null) { + Clipboard.setText(url); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "copyurl"); + } + } + return true; + } + + if (itemId == R.id.add_to_launcher) { + final Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab == null) { + return true; + } + + final String url = tab.getURL(); + final String title = tab.getDisplayTitle(); + if (url == null || title == null) { + return true; + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GeckoAppShell.createShortcut(title, url); + + } + }); + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, + getResources().getResourceEntryName(itemId)); + return true; + } + + return false; + } + + @Override + public void setAccessibilityEnabled(boolean enabled) { + super.setAccessibilityEnabled(enabled); + mDynamicToolbar.setAccessibilityEnabled(enabled); + } + + @Override + public void onDestroy() { + if (mIsAbortingAppLaunch) { + super.onDestroy(); + return; + } + + mDynamicToolbar.destroy(); + + if (mBrowserToolbar != null) + mBrowserToolbar.onDestroy(); + + if (mFindInPageBar != null) { + mFindInPageBar.onDestroy(); + mFindInPageBar = null; + } + + if (mMediaCastingBar != null) { + mMediaCastingBar.onDestroy(); + mMediaCastingBar = null; + } + + if (mSharedPreferencesHelper != null) { + mSharedPreferencesHelper.uninit(); + mSharedPreferencesHelper = null; + } + + if (mReadingListHelper != null) { + mReadingListHelper.uninit(); + mReadingListHelper = null; + } + + if (mAccountsHelper != null) { + mAccountsHelper.uninit(); + mAccountsHelper = null; + } + + if (mZoomedView != null) { + mZoomedView.destroy(); + } + + mSearchEngineManager.unregisterListeners(); + + EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) this, + "Gecko:DelayedStartup", + "Menu:Open", + "Menu:Update", + "LightweightTheme:Update", + "Search:Keyword", + "Prompt:ShowTop", + "Tab:Added", + "Video:Play"); + + EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) this, + "CharEncoding:Data", + "CharEncoding:State", + "Download:AndroidDownloadManager", + "Experiments:GetActive", + "Experiments:SetOverride", + "Experiments:ClearOverride", + "Favicon:CacheLoad", + "Feedback:MaybeLater", + "Menu:Add", + "Menu:Remove", + "Sanitize:ClearHistory", + "Sanitize:ClearSyncedTabs", + "Settings:Show", + "Telemetry:Gather", + "Updater:Launch", + "Website:Metadata"); + + if (AppConstants.MOZ_ANDROID_BEAM) { + NfcAdapter nfc = NfcAdapter.getDefaultAdapter(this); + if (nfc != null) { + // null this out even though the docs say it's not needed, + // because the source code looks like it will only do this + // automatically on API 14+ + nfc.setNdefPushMessageCallback(null, this); + } + } + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onDestroy(this); + } + + deleteTempFiles(); + + if (mDoorHangerPopup != null) + mDoorHangerPopup.destroy(); + if (mFormAssistPopup != null) + mFormAssistPopup.destroy(); + if (mTextSelection != null) + mTextSelection.destroy(); + NotificationHelper.destroy(); + IntentHelper.destroy(); + GeckoNetworkManager.destroy(); + + super.onDestroy(); + + if (!isFinishing()) { + // GeckoApp was not intentionally destroyed, so keep our process alive. + return; + } + + // Wait for Gecko to handle our pause event sent in onPause. + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + GeckoThread.waitOnGecko(); + } + + if (mRestartIntent != null) { + // Restarting, so let Restarter kill us. + final Intent intent = new Intent(); + intent.setClass(getApplicationContext(), Restarter.class) + .putExtra("pid", Process.myPid()) + .putExtra(Intent.EXTRA_INTENT, mRestartIntent); + startService(intent); + } else { + // Exiting, so kill our own process. + Process.killProcess(Process.myPid()); + } + } + + @Override + protected void initializeChrome() { + super.initializeChrome(); + + mDoorHangerPopup.setAnchor(mBrowserToolbar.getDoorHangerAnchor()); + mDoorHangerPopup.setOnVisibilityChangeListener(this); + + mDynamicToolbar.setLayerView(mLayerView); + setDynamicToolbarEnabled(mDynamicToolbar.isEnabled()); + + // Intercept key events for gamepad shortcuts + mLayerView.setOnKeyListener(this); + + // Initialize the actionbar menu items on startup for both large and small tablets + if (HardwareUtils.isTablet()) { + onCreatePanelMenu(Window.FEATURE_OPTIONS_PANEL, null); + invalidateOptionsMenu(); + } + } + + @Override + public void onDoorHangerShow() { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(mDoorhangerOverlay, "alpha", 1); + alphaAnimator.setDuration(250); + + alphaAnimator.start(); + } + + @Override + public void onDoorHangerHide() { + final Animator alphaAnimator = ObjectAnimator.ofFloat(mDoorhangerOverlay, "alpha", 0); + alphaAnimator.setDuration(200); + + alphaAnimator.start(); + } + + private void handleClearHistory(final boolean clearSearchHistory) { + final BrowserDB db = BrowserDB.from(getProfile()); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.clearHistory(getContentResolver(), clearSearchHistory); + } + }); + } + + private void handleClearSyncedTabs() { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + FennecTabsRepository.deleteNonLocalClientsAndTabs(getContext()); + } + }); + } + + private void setToolbarMargin(int margin) { + ((RelativeLayout.LayoutParams) mGeckoLayout.getLayoutParams()).topMargin = margin; + mGeckoLayout.requestLayout(); + } + + @Override + public void onTranslationChanged(float aToolbarTranslation, float aLayerViewTranslation) { + if (mBrowserChrome == null) { + return; + } + + final View browserChrome = mBrowserChrome; + final ToolbarProgressView progressView = mProgressView; + + ViewHelper.setTranslationY(browserChrome, -aToolbarTranslation); + mLayerView.setSurfaceTranslation(mToolbarHeight - aLayerViewTranslation); + + // Stop the progressView from moving all the way up so that we can still see a good chunk of it + // when the chrome is offscreen. + final float offset = getResources().getDimensionPixelOffset(R.dimen.progress_bar_scroll_offset); + final float progressTranslationY = Math.min(aToolbarTranslation, mToolbarHeight - offset); + ViewHelper.setTranslationY(progressView, -progressTranslationY); + + if (mFormAssistPopup != null) { + mFormAssistPopup.onTranslationChanged(); + } + } + + @Override + public void onMetricsChanged(ImmutableViewportMetrics aMetrics) { + if (isHomePagerVisible() || mBrowserChrome == null) { + return; + } + + if (mFormAssistPopup != null) { + mFormAssistPopup.onMetricsChanged(aMetrics); + } + } + + @Override + public void onPanZoomStopped() { + if (!mDynamicToolbar.isEnabled() || isHomePagerVisible() || + mBrowserChrome.getVisibility() != View.VISIBLE) { + return; + } + + // Make sure the toolbar is fully hidden or fully shown when the user + // lifts their finger, depending on various conditions. + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + float toolbarTranslation = mLayerView.getDynamicToolbarAnimator().getToolbarTranslation(); + + boolean shortPage = metrics.getPageHeight() < metrics.getHeight(); + boolean atBottomOfLongPage = + FloatUtils.fuzzyEquals(metrics.pageRectBottom, metrics.viewportRectBottom()) + && (metrics.pageRectBottom > 2 * metrics.getHeight()); + Log.v(LOGTAG, "On pan/zoom stopped, short page: " + shortPage + + "; atBottomOfLongPage: " + atBottomOfLongPage); + if (shortPage || atBottomOfLongPage) { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } + } + + public void refreshToolbarHeight() { + ThreadUtils.assertOnUiThread(); + + int height = 0; + if (mBrowserChrome != null) { + height = mBrowserChrome.getHeight(); + } + + if (!mDynamicToolbar.isEnabled() || isHomePagerVisible()) { + // Use aVisibleHeight here so that when the dynamic toolbar is + // enabled, the padding will animate with the toolbar becoming + // visible. + if (mDynamicToolbar.isEnabled()) { + // When the dynamic toolbar is enabled, set the padding on the + // about:home widget directly - this is to avoid resizing the + // LayerView, which can cause visible artifacts. + mHomeScreenContainer.setPadding(0, height, 0, 0); + } else { + setToolbarMargin(height); + height = 0; + } + } else { + setToolbarMargin(0); + } + + if (mLayerView != null && height != mToolbarHeight) { + mToolbarHeight = height; + mLayerView.setMaxTranslation(height); + mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE); + } + } + + @Override + void toggleChrome(final boolean aShow) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (aShow) { + mBrowserChrome.setVisibility(View.VISIBLE); + } else { + mBrowserChrome.setVisibility(View.GONE); + } + } + }); + + super.toggleChrome(aShow); + } + + @Override + void focusChrome() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mBrowserChrome.setVisibility(View.VISIBLE); + mActionBarFlipper.requestFocusFromTouch(); + } + }); + } + + @Override + public void refreshChrome() { + invalidateOptionsMenu(); + + if (mTabsPanel != null) { + mTabsPanel.refresh(); + } + + if (mTabStrip != null) { + mTabStrip.refresh(); + } + + mBrowserToolbar.refresh(); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, + final EventCallback callback) { + switch (event) { + case "CharEncoding:Data": + final NativeJSObject[] charsets = message.getObjectArray("charsets"); + final int selected = message.getInt("selected"); + + final String[] titleArray = new String[charsets.length]; + final String[] codeArray = new String[charsets.length]; + for (int i = 0; i < charsets.length; i++) { + final NativeJSObject charset = charsets[i]; + titleArray[i] = charset.getString("title"); + codeArray[i] = charset.getString("code"); + } + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); + dialogBuilder.setSingleChoiceItems(titleArray, selected, + new AlertDialog.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + GeckoAppShell.notifyObservers("CharEncoding:Set", codeArray[which]); + dialog.dismiss(); + } + }); + dialogBuilder.setNegativeButton(R.string.button_cancel, + new AlertDialog.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + dialog.dismiss(); + } + }); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + dialogBuilder.show(); + } + }); + break; + + case "CharEncoding:State": + final boolean visible = message.getString("visible").equals("true"); + GeckoPreferences.setCharEncodingState(visible); + final Menu menu = mMenu; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (menu != null) { + menu.findItem(R.id.char_encoding).setVisible(visible); + } + } + }); + break; + + case "Experiments:GetActive": + final List<String> experiments = SwitchBoard.getActiveExperiments(this); + final JSONArray json = new JSONArray(experiments); + callback.sendSuccess(json.toString()); + break; + + case "Experiments:SetOverride": + Experiments.setOverride(getContext(), message.getString("name"), message.getBoolean("isEnabled")); + break; + + case "Experiments:ClearOverride": + Experiments.clearOverride(getContext(), message.getString("name")); + break; + + case "Favicon:CacheLoad": + final String url = message.getString("url"); + getFaviconFromCache(callback, url); + break; + + case "Feedback:MaybeLater": + resetFeedbackLaunchCount(); + break; + + case "Menu:Add": + final MenuItemInfo info = new MenuItemInfo(); + info.label = message.getString("name"); + info.id = message.getInt("id") + ADDON_MENU_OFFSET; + info.checked = message.optBoolean("checked", false); + info.enabled = message.optBoolean("enabled", true); + info.visible = message.optBoolean("visible", true); + info.checkable = message.optBoolean("checkable", false); + final int parent = message.optInt("parent", 0); + info.parent = parent <= 0 ? parent : parent + ADDON_MENU_OFFSET; + final MenuItemInfo menuItemInfo = info; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + addAddonMenuItem(menuItemInfo); + } + }); + break; + + case "Menu:Remove": + final int id = message.getInt("id") + ADDON_MENU_OFFSET; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + removeAddonMenuItem(id); + } + }); + break; + + case "Sanitize:ClearHistory": + handleClearHistory(message.optBoolean("clearSearchHistory", false)); + callback.sendSuccess(true); + break; + + case "Sanitize:ClearSyncedTabs": + handleClearSyncedTabs(); + callback.sendSuccess(true); + break; + + case "Settings:Show": + final String resource = + message.optString(GeckoPreferences.INTENT_EXTRA_RESOURCES, null); + final Intent settingsIntent = new Intent(this, GeckoPreferences.class); + GeckoPreferences.setResourceToOpen(settingsIntent, resource); + startActivityForResult(settingsIntent, ACTIVITY_REQUEST_PREFERENCES); + + // Don't use a transition to settings if we're on a device where that + // would look bad. + if (HardwareUtils.IS_KINDLE_DEVICE) { + overridePendingTransition(0, 0); + } + break; + + case "Telemetry:Gather": + final BrowserDB db = BrowserDB.from(getProfile()); + final ContentResolver cr = getContentResolver(); + Telemetry.addToHistogram("PLACES_PAGES_COUNT", db.getCount(cr, "history")); + Telemetry.addToHistogram("FENNEC_BOOKMARKS_COUNT", db.getCount(cr, "bookmarks")); + Telemetry.addToHistogram("BROWSER_IS_USER_DEFAULT", (isDefaultBrowser(Intent.ACTION_VIEW) ? 1 : 0)); + Telemetry.addToHistogram("FENNEC_CUSTOM_HOMEPAGE", (TextUtils.isEmpty(getHomepage()) ? 0 : 1)); + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext()); + final boolean hasCustomHomepanels = + prefs.contains(HomeConfigPrefsBackend.PREFS_CONFIG_KEY) || prefs.contains(HomeConfigPrefsBackend.PREFS_CONFIG_KEY_OLD); + Telemetry.addToHistogram("FENNEC_HOMEPANELS_CUSTOM", hasCustomHomepanels ? 1 : 0); + + Telemetry.addToHistogram("FENNEC_READER_VIEW_CACHE_SIZE", + SavedReaderViewHelper.getSavedReaderViewHelper(getContext()).getDiskSpacedUsedKB()); + + if (Versions.feature16Plus) { + Telemetry.addToHistogram("BROWSER_IS_ASSIST_DEFAULT", (isDefaultBrowser(Intent.ACTION_ASSIST) ? 1 : 0)); + } + + Telemetry.addToHistogram("FENNEC_ORBOT_INSTALLED", + ContextUtils.isPackageInstalled(getContext(), "org.torproject.android") ? 1 : 0); + break; + + case "Updater:Launch": + handleUpdaterLaunch(); + break; + + case "Download:AndroidDownloadManager": + // Downloading via Android's download manager + + final String uri = message.getString("uri"); + final String filename = message.getString("filename"); + final String mimeType = message.getString("mimeType"); + + final DownloadManager.Request request = new DownloadManager.Request(Uri.parse(uri)); + request.setMimeType(mimeType); + + try { + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename); + } catch (IllegalStateException e) { + Log.e(LOGTAG, "Cannot create download directory"); + return; + } + + request.allowScanningByMediaScanner(); + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + request.addRequestHeader("User-Agent", HardwareUtils.isTablet() ? + AppConstants.USER_AGENT_FENNEC_TABLET : + AppConstants.USER_AGENT_FENNEC_MOBILE); + + try { + DownloadManager manager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); + manager.enqueue(request); + + Log.d(LOGTAG, "Enqueued download (Download Manager)"); + } catch (RuntimeException e) { + Log.e(LOGTAG, "Download failed: " + e); + } + break; + + case "Website:Metadata": + final NativeJSObject metadata = message.getObject("metadata"); + final String location = message.getString("location"); + + final boolean hasImage = !TextUtils.isEmpty(metadata.optString("image_url", null)); + final String metadataJSON = metadata.toString(); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final ContentProviderClient contentProviderClient = getContentResolver() + .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI); + if (contentProviderClient == null) { + Log.w(LOGTAG, "Failed to obtain content provider client for: " + BrowserContract.PageMetadata.CONTENT_URI); + return; + } + try { + GlobalPageMetadata.getInstance().add( + BrowserDB.from(getProfile()), + contentProviderClient, + location, hasImage, metadataJSON); + } finally { + contentProviderClient.release(); + } + } + }); + + break; + + default: + super.handleMessage(event, message, callback); + break; + } + } + + private void getFaviconFromCache(final EventCallback callback, final String url) { + Icons.with(this) + .pageUrl(url) + .skipNetwork() + .executeCallbackOnBackgroundThread() + .build() + .execute(new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + ByteArrayOutputStream out = null; + Base64OutputStream b64 = null; + + try { + out = new ByteArrayOutputStream(); + out.write("data:image/png;base64,".getBytes()); + b64 = new Base64OutputStream(out, Base64.NO_WRAP); + response.getBitmap().compress(Bitmap.CompressFormat.PNG, 100, b64); + callback.sendSuccess(new String(out.toByteArray())); + } catch (IOException e) { + Log.w(LOGTAG, "Failed to convert to base64 data URI"); + callback.sendError("Failed to convert favicon to a base64 data URI"); + } finally { + try { + if (out != null) { + out.close(); + } + if (b64 != null) { + b64.close(); + } + } catch (IOException e) { + Log.w(LOGTAG, "Failed to close the streams"); + } + } + } + }); + } + + /** + * Use a dummy Intent to do a default browser check. + * + * @return true if this package is the default browser on this device, false otherwise. + */ + private boolean isDefaultBrowser(String action) { + final Intent viewIntent = new Intent(action, Uri.parse("http://www.mozilla.org")); + final ResolveInfo info = getPackageManager().resolveActivity(viewIntent, PackageManager.MATCH_DEFAULT_ONLY); + if (info == null) { + // No default is set + return false; + } + + final String packageName = info.activityInfo.packageName; + return (TextUtils.equals(packageName, getPackageName())); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + switch (event) { + case "Menu:Open": + if (mBrowserToolbar.isEditing()) { + mBrowserToolbar.cancelEdit(); + } + + openOptionsMenu(); + break; + + case "Menu:Update": + final int id = message.getInt("id") + ADDON_MENU_OFFSET; + final JSONObject options = message.getJSONObject("options"); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + updateAddonMenuItem(id, options); + } + }); + break; + + case "Gecko:DelayedStartup": + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Force tabs panel inflation once the initial + // pageload is finished. + ensureTabsPanelExists(); + if (AppConstants.NIGHTLY_BUILD && mZoomedView == null) { + ViewStub stub = (ViewStub) findViewById(R.id.zoomed_view_stub); + mZoomedView = (ZoomedView) stub.inflate(); + } + } + }); + + if (AppConstants.MOZ_MEDIA_PLAYER) { + // Check if the fragment is already added. This should never be true here, but this is + // a nice safety check. + // If casting is disabled, these classes aren't built. We use reflection to initialize them. + final Class<?> mediaManagerClass = getMediaPlayerManager(); + + if (mediaManagerClass != null) { + try { + final String tag = ""; + mediaManagerClass.getDeclaredField("MEDIA_PLAYER_TAG").get(tag); + Log.i(LOGTAG, "Found tag " + tag); + final Fragment frag = getSupportFragmentManager().findFragmentByTag(tag); + if (frag == null) { + final Method getInstance = mediaManagerClass.getMethod("getInstance", (Class[]) null); + final Fragment mpm = (Fragment) getInstance.invoke(null); + getSupportFragmentManager().beginTransaction().disallowAddToBackStack().add(mpm, tag).commit(); + } + } catch (Exception ex) { + Log.e(LOGTAG, "Error initializing media manager", ex); + } + } + } + + if (AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED && Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + // Start (this acts as ping if started already) the stumbler lib; if the stumbler has queued data it will upload it. + // Stumbler operates on its own thread, and startup impact is further minimized by delaying work (such as upload) a few seconds. + // Avoid any potential startup CPU/thread contention by delaying the pref broadcast. + final long oneSecondInMillis = 1000; + ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() { + @Override + public void run() { + GeckoPreferences.broadcastStumblerPref(BrowserApp.this); + } + }, oneSecondInMillis); + } + + if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) { + // TODO: Better scheduling of sync action (Bug 1257492) + DownloadContentService.startSync(this); + + DownloadContentService.startVerification(this); + } + + FeedService.setup(this); + + super.handleMessage(event, message); + break; + + case "Gecko:Ready": + // Handle this message in GeckoApp, but also enable the Settings + // menuitem, which is specific to BrowserApp. + super.handleMessage(event, message); + final Menu menu = mMenu; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (menu != null) { + menu.findItem(R.id.settings).setEnabled(true); + menu.findItem(R.id.help).setEnabled(true); + } + } + }); + + // Display notification for Mozilla data reporting, if data should be collected. + if (AppConstants.MOZ_DATA_REPORTING && Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + DataReportingNotification.checkAndNotifyPolicy(GeckoAppShell.getContext()); + } + break; + + case "Search:Keyword": + storeSearchQuery(message.getString("query")); + recordSearch(GeckoSharedPrefs.forProfile(this), message.getString("identifier"), + TelemetryContract.Method.ACTIONBAR); + break; + + case "LightweightTheme:Update": + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } + }); + break; + + case "Video:Play": + if (SwitchBoard.isInExperiment(this, Experiments.HLS_VIDEO_PLAYBACK)) { + final String uri = message.getString("uri"); + final String uuid = message.getString("uuid"); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mVideoPlayer.start(Uri.parse(uri)); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.CONTENT, "playhls"); + } + }); + } + break; + + case "Prompt:ShowTop": + // Bring this activity to front so the prompt is visible.. + Intent bringToFrontIntent = new Intent(); + bringToFrontIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + bringToFrontIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + startActivity(bringToFrontIntent); + break; + + case "Tab:Added": + if (message.getBoolean("cancelEditMode")) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Set the target tab to null so it does not get selected (on editing + // mode exit) in lieu of the tab that we're going to open and select. + mTargetTabForEditingMode = null; + mBrowserToolbar.cancelEdit(); + } + }); + } + break; + + default: + super.handleMessage(event, message); + break; + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + @Override + public void addTab() { + Tabs.getInstance().addTab(); + } + + @Override + public void addPrivateTab() { + Tabs.getInstance().addPrivateTab(); + } + + public void showTrackingProtectionPromptIfApplicable() { + final SharedPreferences prefs = getSharedPreferences(); + + final boolean hasTrackingProtectionPromptBeShownBefore = prefs.getBoolean(GeckoPreferences.PREFS_TRACKING_PROTECTION_PROMPT_SHOWN, false); + + if (hasTrackingProtectionPromptBeShownBefore) { + return; + } + + prefs.edit().putBoolean(GeckoPreferences.PREFS_TRACKING_PROTECTION_PROMPT_SHOWN, true).apply(); + + startActivity(new Intent(BrowserApp.this, TrackingProtectionPrompt.class)); + } + + @Override + public void showNormalTabs() { + showTabs(TabsPanel.Panel.NORMAL_TABS); + } + + @Override + public void showPrivateTabs() { + showTabs(TabsPanel.Panel.PRIVATE_TABS); + } + /** + * Ensure the TabsPanel view is properly inflated and returns + * true when the view has been inflated, false otherwise. + */ + private boolean ensureTabsPanelExists() { + if (mTabsPanel != null) { + return false; + } + + ViewStub tabsPanelStub = (ViewStub) findViewById(R.id.tabs_panel); + mTabsPanel = (TabsPanel) tabsPanelStub.inflate(); + + mTabsPanel.setTabsLayoutChangeListener(this); + + return true; + } + + private void showTabs(final TabsPanel.Panel panel) { + if (Tabs.getInstance().getDisplayCount() == 0) + return; + + hideFirstrunPager(TelemetryContract.Method.BUTTON); + + if (ensureTabsPanelExists()) { + // If we've just inflated the tabs panel, only show it once the current + // layout pass is done to avoid displayed temporary UI states during + // relayout. + ViewTreeObserver vto = mTabsPanel.getViewTreeObserver(); + if (vto.isAlive()) { + vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mTabsPanel.getViewTreeObserver().removeGlobalOnLayoutListener(this); + showTabs(panel); + } + }); + } + } else { + if (mDoorHangerPopup != null) { + mDoorHangerPopup.disable(); + } + mTabsPanel.show(panel); + + // Hide potentially visible "find in page" bar (Bug 1177338) + mFindInPageBar.hide(); + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onTabsTrayShown(this, mTabsPanel); + } + } + } + + @Override + public void hideTabs() { + mTabsPanel.hide(); + if (mDoorHangerPopup != null) { + mDoorHangerPopup.enable(); + } + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onTabsTrayHidden(this, mTabsPanel); + } + } + + @Override + public boolean autoHideTabs() { + if (areTabsShown()) { + hideTabs(); + return true; + } + return false; + } + + @Override + public boolean areTabsShown() { + return (mTabsPanel != null && mTabsPanel.isShown()); + } + + @Override + public String getHomepage() { + final SharedPreferences preferences = GeckoSharedPrefs.forProfile(this); + final String homepagePreference = preferences.getString(GeckoPreferences.PREFS_HOMEPAGE, null); + + final boolean readFromPartnerProvider = preferences.getBoolean( + GeckoPreferences.PREFS_READ_PARTNER_CUSTOMIZATIONS_PROVIDER, false); + + if (!readFromPartnerProvider) { + // Just return homepage as set by the user (or null). + return homepagePreference; + } + + + final String homepagePrevious = preferences.getString(GeckoPreferences.PREFS_HOMEPAGE_PARTNER_COPY, null); + if (homepagePrevious != null && !homepagePrevious.equals(homepagePreference)) { + // We have read the homepage once and the user has changed it since then. Just use the + // value the user has set. + return homepagePreference; + } + + // This is the first time we read the partner provider or the value has not been altered by the user + final String homepagePartner = PartnerBrowserCustomizationsClient.getHomepage(this); + + if (homepagePartner == null) { + // We didn't get anything from the provider. Let's just use what we have locally. + return homepagePreference; + } + + if (!homepagePartner.equals(homepagePrevious)) { + // We have a new value. Update the preferences. + preferences.edit() + .putString(GeckoPreferences.PREFS_HOMEPAGE, homepagePartner) + .putString(GeckoPreferences.PREFS_HOMEPAGE_PARTNER_COPY, homepagePartner) + .apply(); + } + + return homepagePartner; + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void onTabsLayoutChange(int width, int height) { + int animationLength = TABS_ANIMATION_DURATION; + + if (mMainLayoutAnimator != null) { + animationLength = Math.max(1, animationLength - (int)mMainLayoutAnimator.getRemainingTime()); + mMainLayoutAnimator.stop(false); + } + + if (areTabsShown()) { + mTabsPanel.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + // Hide the web content from accessibility tools even though it's visible + // so that you can't examine it as long as the tabs are being shown. + if (Versions.feature16Plus) { + mLayerView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + } + } else { + if (Versions.feature16Plus) { + mLayerView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + } + + mMainLayoutAnimator = new PropertyAnimator(animationLength, sTabsInterpolator); + mMainLayoutAnimator.addPropertyAnimationListener(this); + mMainLayoutAnimator.attach(mMainLayout, + PropertyAnimator.Property.SCROLL_Y, + -height); + + mTabsPanel.prepareTabsAnimation(mMainLayoutAnimator); + mBrowserToolbar.triggerTabsPanelTransition(mMainLayoutAnimator, areTabsShown()); + + // If the tabs panel is animating onto the screen, pin the dynamic + // toolbar. + if (mDynamicToolbar.isEnabled()) { + if (width > 0 && height > 0) { + mDynamicToolbar.setPinned(true, PinReason.RELAYOUT); + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } else { + mDynamicToolbar.setPinned(false, PinReason.RELAYOUT); + } + } + + mMainLayoutAnimator.start(); + } + + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + if (!areTabsShown()) { + mTabsPanel.setVisibility(View.INVISIBLE); + mTabsPanel.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } else { + // Cancel editing mode to return to page content when the TabsPanel closes. We cancel + // it here because there are graphical glitches if it's canceled while it's visible. + mBrowserToolbar.cancelEdit(); + } + + mTabsPanel.finishTabsAnimation(); + + mMainLayoutAnimator = null; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mDynamicToolbar.onSaveInstanceState(outState); + outState.putInt(STATE_ABOUT_HOME_TOP_PADDING, mHomeScreenContainer.getPaddingTop()); + } + + /** + * Attempts to switch to an open tab with the given URL. + * <p> + * If the tab exists, this method cancels any in-progress editing as well as + * calling {@link Tabs#selectTab(int)}. + * + * @param url of tab to switch to. + * @param flags to obey: if {@link OnUrlOpenListener.Flags#ALLOW_SWITCH_TO_TAB} + * is not present, return false. + * @return true if we successfully switched to a tab, false otherwise. + */ + private boolean maybeSwitchToTab(String url, EnumSet<OnUrlOpenListener.Flags> flags) { + if (!flags.contains(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)) { + return false; + } + + final Tabs tabs = Tabs.getInstance(); + final Tab tab; + + if (AboutPages.isAboutReader(url)) { + tab = tabs.getFirstReaderTabForUrl(url, tabs.getSelectedTab().isPrivate()); + } else { + tab = tabs.getFirstTabForUrl(url, tabs.getSelectedTab().isPrivate()); + } + + if (tab == null) { + return false; + } + + return maybeSwitchToTab(tab.getId()); + } + + /** + * Attempts to switch to an open tab with the given unique tab ID. + * <p> + * If the tab exists, this method cancels any in-progress editing as well as + * calling {@link Tabs#selectTab(int)}. + * + * @param id of tab to switch to. + * @return true if we successfully switched to the tab, false otherwise. + */ + private boolean maybeSwitchToTab(int id) { + final Tabs tabs = Tabs.getInstance(); + final Tab tab = tabs.getTab(id); + + if (tab == null) { + return false; + } + + final Tab oldTab = tabs.getSelectedTab(); + if (oldTab != null) { + oldTab.setIsEditing(false); + } + + // Set the target tab to null so it does not get selected (on editing + // mode exit) in lieu of the tab we are about to select. + mTargetTabForEditingMode = null; + tabs.selectTab(tab.getId()); + + mBrowserToolbar.cancelEdit(); + + return true; + } + + public void openUrlAndStopEditing(String url) { + openUrlAndStopEditing(url, null, false); + } + + private void openUrlAndStopEditing(String url, boolean newTab) { + openUrlAndStopEditing(url, null, newTab); + } + + private void openUrlAndStopEditing(String url, String searchEngine) { + openUrlAndStopEditing(url, searchEngine, false); + } + + private void openUrlAndStopEditing(String url, String searchEngine, boolean newTab) { + int flags = Tabs.LOADURL_NONE; + if (newTab) { + flags |= Tabs.LOADURL_NEW_TAB; + if (Tabs.getInstance().getSelectedTab().isPrivate()) { + flags |= Tabs.LOADURL_PRIVATE; + } + } + + Tabs.getInstance().loadUrl(url, searchEngine, -1, flags); + + mBrowserToolbar.cancelEdit(); + } + + private boolean isHomePagerVisible() { + return (mHomeScreen != null && mHomeScreen.isVisible() + && mHomeScreenContainer != null && mHomeScreenContainer.getVisibility() == View.VISIBLE); + } + + private boolean isFirstrunVisible() { + return (mFirstrunAnimationContainer != null && mFirstrunAnimationContainer.isVisible() + && mHomeScreenContainer != null && mHomeScreenContainer.getVisibility() == View.VISIBLE); + } + + /** + * Enters editing mode with the current tab's URL. There might be no + * tabs loaded by the time the user enters editing mode e.g. just after + * the app starts. In this case, we simply fallback to an empty URL. + */ + private void enterEditingMode() { + String url = ""; + String telemetryMsg = "urlbar-empty"; + + final Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + final String userSearchTerm = tab.getUserRequested(); + final String tabURL = tab.getURL(); + + // Check to see if there's a user-entered search term, + // which we save whenever the user performs a search. + if (!TextUtils.isEmpty(userSearchTerm)) { + url = userSearchTerm; + telemetryMsg = "urlbar-userentered"; + } else if (!TextUtils.isEmpty(tabURL)) { + url = tabURL; + telemetryMsg = "urlbar-url"; + } + } + + enterEditingMode(url); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.ACTIONBAR, telemetryMsg); + } + + /** + * Enters editing mode with the specified URL. If a null + * url is given, the empty String will be used instead. + */ + private void enterEditingMode(@NonNull String url) { + hideFirstrunPager(TelemetryContract.Method.ACTIONBAR); + + if (mBrowserToolbar.isEditing() || mBrowserToolbar.isAnimating()) { + return; + } + + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + final String panelId; + if (selectedTab != null) { + mTargetTabForEditingMode = selectedTab.getId(); + panelId = selectedTab.getMostRecentHomePanel(); + } else { + mTargetTabForEditingMode = null; + panelId = null; + } + + final PropertyAnimator animator = new PropertyAnimator(250); + animator.setUseHardwareLayer(false); + + mBrowserToolbar.startEditing(url, animator); + + showHomePagerWithAnimator(panelId, null, animator); + + animator.start(); + Telemetry.startUISession(TelemetryContract.Session.AWESOMESCREEN); + } + + private void commitEditingMode() { + if (!mBrowserToolbar.isEditing()) { + return; + } + + Telemetry.stopUISession(TelemetryContract.Session.AWESOMESCREEN, + TelemetryContract.Reason.COMMIT); + + final String url = mBrowserToolbar.commitEdit(); + + // HACK: We don't know the url that will be loaded when hideHomePager is initially called + // in BrowserToolbar's onStopEditing listener so on the awesomescreen, hideHomePager will + // use the url "about:home" and return without taking any action. hideBrowserSearch is + // then called, but since hideHomePager changes both HomePager and LayerView visibility + // and exited without taking an action, no Views are displayed and graphical corruption is + // visible instead. + // + // Here we call hideHomePager for the second time with the URL to be loaded so that + // hideHomePager is called with the correct state for the upcoming page load. + // + // Expected to be fixed by bug 915825. + hideHomePager(url); + loadUrlOrKeywordSearch(url); + clearSelectedTabApplicationId(); + } + + private void clearSelectedTabApplicationId() { + final Tab selected = Tabs.getInstance().getSelectedTab(); + if (selected != null) { + selected.setApplicationId(null); + } + } + + private void loadUrlOrKeywordSearch(final String url) { + // Don't do anything if the user entered an empty URL. + if (TextUtils.isEmpty(url)) { + return; + } + + // If the URL doesn't look like a search query, just load it. + if (!StringUtils.isSearchQuery(url, true)) { + Tabs.getInstance().loadUrl(url, Tabs.LOADURL_USER_ENTERED); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.ACTIONBAR, "user"); + return; + } + + // Otherwise, check for a bookmark keyword. + final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfile(this); + final BrowserDB db = BrowserDB.from(getProfile()); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final String keyword; + final String keywordSearch; + + final int index = url.indexOf(" "); + if (index == -1) { + keyword = url; + keywordSearch = ""; + } else { + keyword = url.substring(0, index); + keywordSearch = url.substring(index + 1); + } + + final String keywordUrl = db.getUrlForKeyword(getContentResolver(), keyword); + + // If there isn't a bookmark keyword, load the url. This may result in a query + // using the default search engine. + if (TextUtils.isEmpty(keywordUrl)) { + Tabs.getInstance().loadUrl(url, Tabs.LOADURL_USER_ENTERED); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.ACTIONBAR, "user"); + return; + } + + // Otherwise, construct a search query from the bookmark keyword. + // Replace lower case bookmark keywords with URLencoded search query or + // replace upper case bookmark keywords with un-encoded search query. + // This makes it match the same behaviour as on Firefox for the desktop. + final String searchUrl = keywordUrl.replace("%s", URLEncoder.encode(keywordSearch)).replace("%S", keywordSearch); + + Tabs.getInstance().loadUrl(searchUrl, Tabs.LOADURL_USER_ENTERED); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, + TelemetryContract.Method.ACTIONBAR, + "keyword"); + } + }); + } + + /** + * Records in telemetry that a search has occurred. + * + * @param where where the search was started from + */ + private static void recordSearch(@NonNull final SharedPreferences prefs, @NonNull final String engineIdentifier, + @NonNull final TelemetryContract.Method where) { + // We could include the engine identifier as an extra but we'll + // just capture that with core ping telemetry (bug 1253319). + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, where); + SearchCountMeasurements.incrementSearch(prefs, engineIdentifier, where.toString()); + } + + /** + * Store search query in SearchHistoryProvider. + * + * @param query + * a search query to store. We won't store empty queries. + */ + private void storeSearchQuery(final String query) { + if (TextUtils.isEmpty(query)) { + return; + } + + // Filter out URLs and long suggestions + if (query.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", query)) { + return; + } + + final GeckoProfile profile = getProfile(); + // Don't bother storing search queries in guest mode + if (profile.inGuestMode()) { + return; + } + + final BrowserDB db = BrowserDB.from(profile); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.getSearches().insert(getContentResolver(), query); + } + }); + } + + void filterEditingMode(String searchTerm, AutocompleteHandler handler) { + if (TextUtils.isEmpty(searchTerm)) { + hideBrowserSearch(); + } else { + showBrowserSearch(); + mBrowserSearch.filter(searchTerm, handler); + } + } + + /** + * Selects the target tab for editing mode. This is expected to be the tab selected on editing + * mode entry, unless it is subsequently overridden. + * + * A background tab may be selected while editing mode is active (e.g. popups), causing the + * new url to load in the newly selected tab. Call this method on editing mode exit to + * mitigate this. + * + * Note that this method is disabled for new tablets because we can see the selected tab in the + * tab strip and, when the selected tab changes during editing mode as in this hack, the + * temporarily selected tab is visible to users. + */ + private void selectTargetTabForEditingMode() { + if (HardwareUtils.isTablet()) { + return; + } + + if (mTargetTabForEditingMode != null) { + Tabs.getInstance().selectTab(mTargetTabForEditingMode); + } + + mTargetTabForEditingMode = null; + } + + /** + * Shows or hides the home pager for the given tab. + */ + private void updateHomePagerForTab(Tab tab) { + // Don't change the visibility of the home pager if we're in editing mode. + if (mBrowserToolbar.isEditing()) { + return; + } + + // History will only store that we were visiting about:home, however the specific panel + // isn't stored. (We are able to navigate directly to homepanels using an about:home?panel=... + // URL, but the reverse doesn't apply: manually switching panels doesn't update the URL.) + // Hence we need to restore the panel, in addition to panel state, here. + if (isAboutHome(tab)) { + String panelId = AboutPages.getPanelIdFromAboutHomeUrl(tab.getURL()); + Bundle panelRestoreData = null; + if (panelId == null) { + // No panel was specified in the URL. Try loading the most recent + // home panel for this tab. + // Note: this isn't necessarily correct. We don't update the URL when we switch tabs. + // If a user explicitly navigated to about:reader?panel=FOO, and then switches + // to panel BAR, the history URL still contains FOO, and we restore to FOO. In most + // cases however we aren't supplying a panel ID in the URL so this code still works + // for most cases. + // We can't fix this directly since we can't ignore the panelId if we're explicitly + // loading a specific panel, and we currently can't distinguish between loading + // history, and loading new pages, see Bug 1268887 + panelId = tab.getMostRecentHomePanel(); + panelRestoreData = tab.getMostRecentHomePanelData(); + } else if (panelId.equals(HomeConfig.getIdForBuiltinPanelType(PanelType.DEPRECATED_RECENT_TABS))) { + // Redirect to the Combined History panel. + panelId = HomeConfig.getIdForBuiltinPanelType(PanelType.COMBINED_HISTORY); + panelRestoreData = new Bundle(); + // Jump directly to the Recent Tabs subview of the Combined History panel. + panelRestoreData.putBoolean("goToRecentTabs", true); + } + showHomePager(panelId, panelRestoreData); + + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } + } else { + hideHomePager(); + } + } + + @Override + public void onLocaleReady(final String locale) { + Log.d(LOGTAG, "onLocaleReady: " + locale); + super.onLocaleReady(locale); + + HomePanelsManager.getInstance().onLocaleReady(locale); + + if (mMenu != null) { + mMenu.clear(); + onCreateOptionsMenu(mMenu); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + Log.d(LOGTAG, "onActivityResult: " + requestCode + ", " + resultCode + ", " + data); + switch (requestCode) { + case ACTIVITY_REQUEST_PREFERENCES: + // We just returned from preferences. If our locale changed, + // we need to redisplay at this point, and do any other browser-level + // bookkeeping that we associate with a locale change. + if (resultCode != GeckoPreferences.RESULT_CODE_LOCALE_DID_CHANGE) { + Log.d(LOGTAG, "No locale change returning from preferences; nothing to do."); + return; + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + final Locale locale = localeManager.getCurrentLocale(getApplicationContext()); + Log.d(LOGTAG, "Read persisted locale " + locale); + if (locale == null) { + return; + } + onLocaleChanged(Locales.getLanguageTag(locale)); + } + }); + break; + + case ACTIVITY_REQUEST_TAB_QUEUE: + TabQueueHelper.processTabQueuePromptResponse(resultCode, this); + break; + + default: + for (final BrowserAppDelegate delegate : delegates) { + delegate.onActivityResult(this, requestCode, resultCode, data); + } + + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void showFirstrunPager() { + if (Experiments.isInExperimentLocal(getContext(), Experiments.ONBOARDING3_A)) { + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_A); + GeckoSharedPrefs.forProfile(getContext()).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_A).apply(); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_A); + return; + } + + if (mFirstrunAnimationContainer == null) { + final ViewStub firstrunPagerStub = (ViewStub) findViewById(R.id.firstrun_pager_stub); + mFirstrunAnimationContainer = (FirstrunAnimationContainer) firstrunPagerStub.inflate(); + mFirstrunAnimationContainer.load(getApplicationContext(), getSupportFragmentManager()); + mFirstrunAnimationContainer.registerOnFinishListener(new FirstrunAnimationContainer.OnFinishListener() { + @Override + public void onFinish() { + if (mFirstrunAnimationContainer.showBrowserHint() && + TextUtils.isEmpty(getHomepage())) { + enterEditingMode(); + } + } + }); + } + + mHomeScreenContainer.setVisibility(View.VISIBLE); + } + + private void showHomePager(String panelId, Bundle panelRestoreData) { + showHomePagerWithAnimator(panelId, panelRestoreData, null); + } + + private void showHomePagerWithAnimator(String panelId, Bundle panelRestoreData, PropertyAnimator animator) { + if (isHomePagerVisible()) { + // Home pager already visible, make sure it shows the correct panel. + mHomeScreen.showPanel(panelId, panelRestoreData); + return; + } + + // This must be called before the dynamic toolbar is set visible because it calls + // FormAssistPopup.onMetricsChanged, which queues a runnable that undoes the effect of hide. + // With hide first, onMetricsChanged will return early instead. + mFormAssistPopup.hide(); + mFindInPageBar.hide(); + + // Refresh toolbar height to possibly restore the toolbar padding + refreshToolbarHeight(); + + // Show the toolbar before hiding about:home so the + // onMetricsChanged callback still works. + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE); + } + + if (mHomeScreen == null) { + if (ActivityStream.isEnabled(this) && + !ActivityStream.isHomePanel()) { + final ViewStub asStub = (ViewStub) findViewById(R.id.activity_stream_stub); + mHomeScreen = (HomeScreen) asStub.inflate(); + } else { + final ViewStub homePagerStub = (ViewStub) findViewById(R.id.home_pager_stub); + mHomeScreen = (HomeScreen) homePagerStub.inflate(); + + // For now these listeners are HomePager specific. In future we might want + // to have a more abstracted data storage, with one Bundle containing all + // relevant restore data. + mHomeScreen.setOnPanelChangeListener(new HomeScreen.OnPanelChangeListener() { + @Override + public void onPanelSelected(String panelId) { + final Tab currentTab = Tabs.getInstance().getSelectedTab(); + if (currentTab != null) { + currentTab.setMostRecentHomePanel(panelId); + } + } + }); + + // Set this listener to persist restore data (via the Tab) every time panel state changes. + mHomeScreen.setPanelStateChangeListener(new HomeFragment.PanelStateChangeListener() { + @Override + public void onStateChanged(Bundle bundle) { + final Tab currentTab = Tabs.getInstance().getSelectedTab(); + if (currentTab != null) { + currentTab.setMostRecentHomePanelData(bundle); + } + } + + @Override + public void setCachedRecentTabsCount(int count) { + mCachedRecentTabsCount = count; + } + + @Override + public int getCachedRecentTabsCount() { + return mCachedRecentTabsCount; + } + }); + } + + // Don't show the banner in guest mode. + if (!Restrictions.isUserRestricted()) { + final ViewStub homeBannerStub = (ViewStub) findViewById(R.id.home_banner_stub); + final HomeBanner homeBanner = (HomeBanner) homeBannerStub.inflate(); + mHomeScreen.setBanner(homeBanner); + + // Remove the banner from the view hierarchy if it is dismissed. + homeBanner.setOnDismissListener(new HomeBanner.OnDismissListener() { + @Override + public void onDismiss() { + mHomeScreen.setBanner(null); + mHomeScreenContainer.removeView(homeBanner); + } + }); + } + } + + mHomeScreenContainer.setVisibility(View.VISIBLE); + mHomeScreen.load(getSupportLoaderManager(), + getSupportFragmentManager(), + panelId, + panelRestoreData, + animator); + + // Hide the web content so it cannot be focused by screen readers. + hideWebContentOnPropertyAnimationEnd(animator); + } + + private void hideWebContentOnPropertyAnimationEnd(final PropertyAnimator animator) { + if (animator == null) { + hideWebContent(); + return; + } + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + mHideWebContentOnAnimationEnd = true; + } + + @Override + public void onPropertyAnimationEnd() { + if (mHideWebContentOnAnimationEnd) { + hideWebContent(); + } + } + }); + } + + private void hideWebContent() { + // The view is set to INVISIBLE, rather than GONE, to avoid + // the additional requestLayout() call. + mLayerView.setVisibility(View.INVISIBLE); + } + + /** + * Hide the Onboarding pager on user action, and don't show any onFinish hints. + * @param method TelemetryContract method by which action was taken + * @return boolean of whether pager was visible + */ + private boolean hideFirstrunPager(TelemetryContract.Method method) { + if (!isFirstrunVisible()) { + return false; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, method, "firstrun-pane"); + + // Don't show any onFinish actions when hiding from this Activity. + mFirstrunAnimationContainer.registerOnFinishListener(null); + mFirstrunAnimationContainer.hide(); + return true; + } + + /** + * Hides the HomePager, using the url of the currently selected tab as the url to be + * loaded. + */ + private void hideHomePager() { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + final String url = (selectedTab != null) ? selectedTab.getURL() : null; + + hideHomePager(url); + } + + /** + * Hides the HomePager. The given url should be the url of the page to be loaded, or null + * if a new page is not being loaded. + */ + private void hideHomePager(final String url) { + if (!isHomePagerVisible() || AboutPages.isAboutHome(url)) { + return; + } + + // Prevent race in hiding web content - see declaration for more info. + mHideWebContentOnAnimationEnd = false; + + // Display the previously hidden web content (which prevented screen reader access). + mLayerView.setVisibility(View.VISIBLE); + mHomeScreenContainer.setVisibility(View.GONE); + + if (mHomeScreen != null) { + mHomeScreen.unload(); + } + + mBrowserToolbar.setNextFocusDownId(R.id.layer_view); + + // Refresh toolbar height to possibly restore the toolbar padding + refreshToolbarHeight(); + } + + private void showBrowserSearchAfterAnimation(PropertyAnimator animator) { + if (animator == null) { + showBrowserSearch(); + return; + } + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + showBrowserSearch(); + } + }); + } + + private void showBrowserSearch() { + if (mBrowserSearch.getUserVisibleHint()) { + return; + } + + mBrowserSearchContainer.setVisibility(View.VISIBLE); + + // Prevent overdraw by hiding the underlying web content and HomePager View + hideWebContent(); + mHomeScreenContainer.setVisibility(View.INVISIBLE); + + final FragmentManager fm = getSupportFragmentManager(); + + // In certain situations, showBrowserSearch() can be called immediately after hideBrowserSearch() + // (see bug 925012). Because of an Android bug (http://code.google.com/p/android/issues/detail?id=61179), + // calling FragmentTransaction#add immediately after FragmentTransaction#remove won't add the fragment's + // view to the layout. Calling FragmentManager#executePendingTransactions before re-adding the fragment + // prevents this issue. + fm.executePendingTransactions(); + + Fragment f = fm.findFragmentById(R.id.search_container); + + // checking if fragment is already present + if (f != null) { + fm.beginTransaction().show(f).commitAllowingStateLoss(); + mBrowserSearch.resetScrollState(); + } else { + // add fragment if not already present + fm.beginTransaction().add(R.id.search_container, mBrowserSearch, BROWSER_SEARCH_TAG).commitAllowingStateLoss(); + } + mBrowserSearch.setUserVisibleHint(true); + + // We want to adjust the window size when the keyboard appears to bring the + // SearchEngineBar above the keyboard. However, adjusting the window size + // when hiding the keyboard results in graphical glitches where the keyboard was + // because nothing was being drawn underneath (bug 933422). This can be + // prevented drawing content under the keyboard (i.e. in the Window). + // + // We do this here because there are glitches when unlocking a device with + // BrowserSearch in the foreground if we use BrowserSearch.onStart/Stop. + getActivity().getWindow().setBackgroundDrawableResource(android.R.color.white); + } + + private void hideBrowserSearch() { + if (!mBrowserSearch.getUserVisibleHint()) { + return; + } + + // To prevent overdraw, the HomePager is hidden when BrowserSearch is displayed: + // reverse that. + showHomePager(Tabs.getInstance().getSelectedTab().getMostRecentHomePanel(), + Tabs.getInstance().getSelectedTab().getMostRecentHomePanelData()); + + mBrowserSearchContainer.setVisibility(View.INVISIBLE); + + getSupportFragmentManager().beginTransaction() + .hide(mBrowserSearch).commitAllowingStateLoss(); + mBrowserSearch.setUserVisibleHint(false); + + getWindow().setBackgroundDrawable(null); + } + + /** + * Hides certain UI elements (e.g. button toast, tabs panel) when the + * user touches the main layout. + */ + private class HideOnTouchListener implements TouchEventInterceptor { + private boolean mIsHidingTabs; + private final Rect mTempRect = new Rect(); + + @Override + public boolean onInterceptTouchEvent(View view, MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + SnackbarBuilder.dismissCurrentSnackbar(); + } + + + + // We need to account for scroll state for the touched view otherwise + // tapping on an "empty" part of the view will still be considered a + // valid touch event. + if (view.getScrollX() != 0 || view.getScrollY() != 0) { + view.getHitRect(mTempRect); + mTempRect.offset(-view.getScrollX(), -view.getScrollY()); + + int[] viewCoords = new int[2]; + view.getLocationOnScreen(viewCoords); + + int x = (int) event.getRawX() - viewCoords[0]; + int y = (int) event.getRawY() - viewCoords[1]; + + if (!mTempRect.contains(x, y)) + return false; + } + + // If the tabs panel is showing, hide the tab panel and don't send the event to content. + if (event.getActionMasked() == MotionEvent.ACTION_DOWN && autoHideTabs()) { + mIsHidingTabs = true; + return true; + } + return false; + } + + @Override + public boolean onTouch(View view, MotionEvent event) { + if (mIsHidingTabs) { + // Keep consuming events until the gesture finishes. + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + mIsHidingTabs = false; + } + return true; + } + return false; + } + } + + private static Menu findParentMenu(Menu menu, MenuItem item) { + final int itemId = item.getItemId(); + + final int count = (menu != null) ? menu.size() : 0; + for (int i = 0; i < count; i++) { + MenuItem menuItem = menu.getItem(i); + if (menuItem.getItemId() == itemId) { + return menu; + } + if (menuItem.hasSubMenu()) { + Menu parent = findParentMenu(menuItem.getSubMenu(), item); + if (parent != null) { + return parent; + } + } + } + + return null; + } + + /** + * Add the provided item to the provided menu, which should be + * the root (mMenu). + */ + private void addAddonMenuItemToMenu(final Menu menu, final MenuItemInfo info) { + info.added = true; + + final Menu destination; + if (info.parent == 0) { + destination = menu; + } else if (info.parent == GECKO_TOOLS_MENU) { + // The tools menu only exists in our -v11 resources. + final MenuItem tools = menu.findItem(R.id.tools); + destination = tools != null ? tools.getSubMenu() : menu; + } else { + final MenuItem parent = menu.findItem(info.parent); + if (parent == null) { + return; + } + + Menu parentMenu = findParentMenu(menu, parent); + + if (!parent.hasSubMenu()) { + parentMenu.removeItem(parent.getItemId()); + destination = parentMenu.addSubMenu(Menu.NONE, parent.getItemId(), Menu.NONE, parent.getTitle()); + if (parent.getIcon() != null) { + ((SubMenu) destination).getItem().setIcon(parent.getIcon()); + } + } else { + destination = parent.getSubMenu(); + } + } + + final MenuItem item = destination.add(Menu.NONE, info.id, Menu.NONE, info.label); + + item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + GeckoAppShell.notifyObservers("Menu:Clicked", Integer.toString(info.id - ADDON_MENU_OFFSET)); + return true; + } + }); + + item.setCheckable(info.checkable); + item.setChecked(info.checked); + item.setEnabled(info.enabled); + item.setVisible(info.visible); + } + + private void addAddonMenuItem(final MenuItemInfo info) { + if (mAddonMenuItemsCache == null) { + mAddonMenuItemsCache = new Vector<MenuItemInfo>(); + } + + // Mark it as added if the menu was ready. + info.added = (mMenu != null); + + // Always cache so we can rebuild after a locale switch. + mAddonMenuItemsCache.add(info); + + if (mMenu == null) { + return; + } + + addAddonMenuItemToMenu(mMenu, info); + } + + private void removeAddonMenuItem(int id) { + // Remove add-on menu item from cache, if available. + if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) { + for (MenuItemInfo item : mAddonMenuItemsCache) { + if (item.id == id) { + mAddonMenuItemsCache.remove(item); + break; + } + } + } + + if (mMenu == null) + return; + + final MenuItem menuItem = mMenu.findItem(id); + if (menuItem != null) + mMenu.removeItem(id); + } + + private void updateAddonMenuItem(int id, JSONObject options) { + // Set attribute for the menu item in cache, if available + if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) { + for (MenuItemInfo item : mAddonMenuItemsCache) { + if (item.id == id) { + item.label = options.optString("name", item.label); + item.checkable = options.optBoolean("checkable", item.checkable); + item.checked = options.optBoolean("checked", item.checked); + item.enabled = options.optBoolean("enabled", item.enabled); + item.visible = options.optBoolean("visible", item.visible); + item.added = (mMenu != null); + break; + } + } + } + + if (mMenu == null) { + return; + } + + final MenuItem menuItem = mMenu.findItem(id); + if (menuItem != null) { + menuItem.setTitle(options.optString("name", menuItem.getTitle().toString())); + menuItem.setCheckable(options.optBoolean("checkable", menuItem.isCheckable())); + menuItem.setChecked(options.optBoolean("checked", menuItem.isChecked())); + menuItem.setEnabled(options.optBoolean("enabled", menuItem.isEnabled())); + menuItem.setVisible(options.optBoolean("visible", menuItem.isVisible())); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Sets mMenu = menu. + super.onCreateOptionsMenu(menu); + + // Inform the menu about the action-items bar. + if (menu instanceof GeckoMenu && + HardwareUtils.isTablet()) { + ((GeckoMenu) menu).setActionItemBarPresenter(mBrowserToolbar); + } + + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.browser_app_menu, mMenu); + + // Add add-on menu items, if any exist. + if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) { + for (MenuItemInfo item : mAddonMenuItemsCache) { + addAddonMenuItemToMenu(mMenu, item); + } + } + + // Action providers are available only ICS+. + GeckoMenuItem share = (GeckoMenuItem) mMenu.findItem(R.id.share); + + GeckoActionProvider provider = GeckoActionProvider.getForType(GeckoActionProvider.DEFAULT_MIME_TYPE, this); + + share.setActionProvider(provider); + + return true; + } + + @Override + public void openOptionsMenu() { + hideFirstrunPager(TelemetryContract.Method.MENU); + + // Disable menu access (for hardware buttons) when the software menu button is inaccessible. + // Note that the software button is always accessible on new tablet. + if (mBrowserToolbar.isEditing() && !HardwareUtils.isTablet()) { + return; + } + + if (ActivityUtils.isFullScreen(this)) { + return; + } + + if (areTabsShown()) { + mTabsPanel.showMenu(); + return; + } + + // Scroll custom menu to the top + if (mMenuPanel != null) + mMenuPanel.scrollTo(0, 0); + + // Scroll menu ListView (potentially in MenuPanel ViewGroup) to top. + if (mMenu instanceof GeckoMenu) { + ((GeckoMenu) mMenu).setSelection(0); + } + + if (!mBrowserToolbar.openOptionsMenu()) + super.openOptionsMenu(); + + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE); + } + } + + @Override + public void closeOptionsMenu() { + if (!mBrowserToolbar.closeOptionsMenu()) + super.closeOptionsMenu(); + } + + @Override + public void setFullScreen(final boolean fullscreen) { + super.setFullScreen(fullscreen); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (fullscreen) { + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setVisible(false, VisibilityTransition.IMMEDIATE); + mDynamicToolbar.setPinned(true, PinReason.FULL_SCREEN); + } else { + setToolbarMargin(0); + } + mBrowserChrome.setVisibility(View.GONE); + } else { + mBrowserChrome.setVisibility(View.VISIBLE); + if (mDynamicToolbar.isEnabled()) { + mDynamicToolbar.setPinned(false, PinReason.FULL_SCREEN); + mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE); + } else { + setToolbarMargin(mBrowserChrome.getHeight()); + } + } + } + }); + } + + @Override + public boolean onPrepareOptionsMenu(Menu aMenu) { + if (aMenu == null) + return false; + + // Hide the tab history panel when hardware menu button is pressed. + TabHistoryFragment frag = (TabHistoryFragment) getSupportFragmentManager().findFragmentByTag(TAB_HISTORY_FRAGMENT_TAG); + if (frag != null) { + frag.dismiss(); + } + + if (!GeckoThread.isRunning()) { + aMenu.findItem(R.id.settings).setEnabled(false); + aMenu.findItem(R.id.help).setEnabled(false); + } + + Tab tab = Tabs.getInstance().getSelectedTab(); + // Unlike other menu items, the bookmark star is not tinted. See {@link ThemedImageButton#setTintedDrawable}. + final MenuItem bookmark = aMenu.findItem(R.id.bookmark); + final MenuItem back = aMenu.findItem(R.id.back); + final MenuItem forward = aMenu.findItem(R.id.forward); + final MenuItem share = aMenu.findItem(R.id.share); + final MenuItem bookmarksList = aMenu.findItem(R.id.bookmarks_list); + final MenuItem historyList = aMenu.findItem(R.id.history_list); + final MenuItem saveAsPDF = aMenu.findItem(R.id.save_as_pdf); + final MenuItem print = aMenu.findItem(R.id.print); + final MenuItem charEncoding = aMenu.findItem(R.id.char_encoding); + final MenuItem findInPage = aMenu.findItem(R.id.find_in_page); + final MenuItem desktopMode = aMenu.findItem(R.id.desktop_mode); + final MenuItem enterGuestMode = aMenu.findItem(R.id.new_guest_session); + final MenuItem exitGuestMode = aMenu.findItem(R.id.exit_guest_session); + + // Only show the "Quit" menu item on pre-ICS, television devices, + // or if the user has explicitly enabled the clear on shutdown pref. + // (We check the pref last to save the pref read.) + // In ICS+, it's easy to kill an app through the task switcher. + final boolean visible = HardwareUtils.isTelevision() || + !PrefUtils.getStringSet(GeckoSharedPrefs.forProfile(this), + ClearOnShutdownPref.PREF, + new HashSet<String>()).isEmpty(); + aMenu.findItem(R.id.quit).setVisible(visible); + + // If tab data is unavailable we disable most of the context menu and related items and + // return early. + if (tab == null || tab.getURL() == null) { + bookmark.setEnabled(false); + back.setEnabled(false); + forward.setEnabled(false); + share.setEnabled(false); + saveAsPDF.setEnabled(false); + print.setEnabled(false); + findInPage.setEnabled(false); + + // NOTE: Use MenuUtils.safeSetEnabled because some actions might + // be on the BrowserToolbar context menu. + MenuUtils.safeSetEnabled(aMenu, R.id.page, false); + MenuUtils.safeSetEnabled(aMenu, R.id.subscribe, false); + MenuUtils.safeSetEnabled(aMenu, R.id.add_search_engine, false); + MenuUtils.safeSetEnabled(aMenu, R.id.add_to_launcher, false); + + return true; + } + + // If tab data IS available we need to manually enable items as necessary. They may have + // been disabled if returning early above, hence every item must be toggled, even if it's + // always expected to be enabled (e.g. the bookmark star is always enabled, except when + // we don't have tab data). + + final boolean inGuestMode = GeckoProfile.get(this).inGuestMode(); + + bookmark.setEnabled(true); // Might have been disabled above, ensure it's reenabled + bookmark.setVisible(!inGuestMode); + bookmark.setCheckable(true); + bookmark.setChecked(tab.isBookmark()); + bookmark.setTitle(resolveBookmarkTitleID(tab.isBookmark())); + + // We don't use icons on GB builds so not resolving icons might conserve resources. + bookmark.setIcon(resolveBookmarkIconID(tab.isBookmark())); + + back.setEnabled(tab.canDoBack()); + forward.setEnabled(tab.canDoForward()); + desktopMode.setChecked(tab.getDesktopMode()); + + View backButtonView = MenuItemCompat.getActionView(back); + + if (backButtonView != null) { + backButtonView.setOnLongClickListener(new Button.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + closeOptionsMenu(); + return tabHistoryController.showTabHistory(tab, + TabHistoryController.HistoryAction.BACK); + } + return false; + } + }); + } + + View forwardButtonView = MenuItemCompat.getActionView(forward); + + if (forwardButtonView != null) { + forwardButtonView.setOnLongClickListener(new Button.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + closeOptionsMenu(); + return tabHistoryController.showTabHistory(tab, + TabHistoryController.HistoryAction.FORWARD); + } + return false; + } + }); + } + + String url = tab.getURL(); + if (AboutPages.isAboutReader(url)) { + url = ReaderModeUtils.stripAboutReaderUrl(url); + } + + // Disable share menuitem for about:, chrome:, file:, and resource: URIs + final boolean shareVisible = Restrictions.isAllowed(this, Restrictable.SHARE); + share.setVisible(shareVisible); + final boolean shareEnabled = StringUtils.isShareableUrl(url) && shareVisible; + share.setEnabled(shareEnabled); + MenuUtils.safeSetEnabled(aMenu, R.id.downloads, Restrictions.isAllowed(this, Restrictable.DOWNLOAD)); + + // NOTE: Use MenuUtils.safeSetEnabled because some actions might + // be on the BrowserToolbar context menu. + MenuUtils.safeSetEnabled(aMenu, R.id.page, !isAboutHome(tab)); + MenuUtils.safeSetEnabled(aMenu, R.id.subscribe, tab.hasFeeds()); + MenuUtils.safeSetEnabled(aMenu, R.id.add_search_engine, tab.hasOpenSearch()); + MenuUtils.safeSetEnabled(aMenu, R.id.add_to_launcher, !isAboutHome(tab)); + + // This provider also applies to the quick share menu item. + final GeckoActionProvider provider = ((GeckoMenuItem) share).getGeckoActionProvider(); + if (provider != null) { + Intent shareIntent = provider.getIntent(); + + // For efficiency, the provider's intent is only set once + if (shareIntent == null) { + shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + provider.setIntent(shareIntent); + } + + // Replace the existing intent's extras + shareIntent.putExtra(Intent.EXTRA_TEXT, url); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, tab.getDisplayTitle()); + shareIntent.putExtra(Intent.EXTRA_TITLE, tab.getDisplayTitle()); + shareIntent.putExtra(ShareDialog.INTENT_EXTRA_DEVICES_ONLY, true); + + // Clear the existing thumbnail extras so we don't share an old thumbnail. + shareIntent.removeExtra("share_screenshot_uri"); + + // Include the thumbnail of the page being shared. + BitmapDrawable drawable = tab.getThumbnail(); + if (drawable != null) { + Bitmap thumbnail = drawable.getBitmap(); + + // Kobo uses a custom intent extra for sharing thumbnails. + if (Build.MANUFACTURER.equals("Kobo") && thumbnail != null) { + File cacheDir = getExternalCacheDir(); + + if (cacheDir != null) { + File outFile = new File(cacheDir, "thumbnail.png"); + + try { + final java.io.FileOutputStream out = new java.io.FileOutputStream(outFile); + try { + thumbnail.compress(Bitmap.CompressFormat.PNG, 90, out); + } finally { + try { + out.close(); + } catch (final IOException e) { /* Nothing to do here. */ } + } + } catch (FileNotFoundException e) { + Log.e(LOGTAG, "File not found", e); + } + + shareIntent.putExtra("share_screenshot_uri", Uri.parse(outFile.getPath())); + } + } + } + } + + final boolean privateTabVisible = Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING); + MenuUtils.safeSetVisible(aMenu, R.id.new_private_tab, privateTabVisible); + + // Disable PDF generation (save and print) for about:home and xul pages. + boolean allowPDF = (!(isAboutHome(tab) || + tab.getContentType().equals("application/vnd.mozilla.xul+xml") || + tab.getContentType().startsWith("video/"))); + saveAsPDF.setEnabled(allowPDF); + print.setEnabled(allowPDF); + print.setVisible(Versions.feature19Plus); + + // Disable find in page for about:home, since it won't work on Java content. + findInPage.setEnabled(!isAboutHome(tab)); + + charEncoding.setVisible(GeckoPreferences.getCharEncodingState()); + + if (getProfile().inGuestMode()) { + exitGuestMode.setVisible(true); + } else { + enterGuestMode.setVisible(true); + } + + if (!Restrictions.isAllowed(this, Restrictable.GUEST_BROWSING)) { + MenuUtils.safeSetVisible(aMenu, R.id.new_guest_session, false); + } + + if (!Restrictions.isAllowed(this, Restrictable.INSTALL_EXTENSION)) { + MenuUtils.safeSetVisible(aMenu, R.id.addons, false); + } + + // Hide panel menu items if the panels themselves are hidden. + // If we don't know whether the panels are hidden, just show the menu items. + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext()); + bookmarksList.setVisible(prefs.getBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, true)); + historyList.setVisible(prefs.getBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, true)); + + return true; + } + + private int resolveBookmarkIconID(final boolean isBookmark) { + if (isBookmark) { + return R.drawable.star_blue; + } else { + return R.drawable.ic_menu_bookmark_add; + } + } + + private int resolveBookmarkTitleID(final boolean isBookmark) { + return (isBookmark ? R.string.bookmark_remove : R.string.bookmark); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + Tab tab = null; + Intent intent = null; + + final int itemId = item.getItemId(); + + // Track the menu action. We don't know much about the context, but we can use this to determine + // the frequency of use for various actions. + String extras = getResources().getResourceEntryName(itemId); + if (TextUtils.equals(extras, "new_private_tab")) { + // Mask private browsing + extras = "new_tab"; + } + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, extras); + + mBrowserToolbar.cancelEdit(); + + if (itemId == R.id.bookmark) { + tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + final String extra; + if (AboutPages.isAboutReader(tab.getURL())) { + extra = "bookmark_reader"; + } else { + extra = "bookmark"; + } + + if (item.isChecked()) { + Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.MENU, extra); + tab.removeBookmark(); + item.setTitle(resolveBookmarkTitleID(false)); + item.setIcon(resolveBookmarkIconID(false)); + } else { + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, extra); + tab.addBookmark(); + item.setTitle(resolveBookmarkTitleID(true)); + item.setIcon(resolveBookmarkIconID(true)); + } + } + return true; + } + + if (itemId == R.id.share) { + tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + String url = tab.getURL(); + if (url != null) { + url = ReaderModeUtils.stripAboutReaderUrl(url); + + // Context: Sharing via chrome list (no explicit session is active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "menu"); + + IntentHelper.openUriExternal(url, "text/plain", "", "", Intent.ACTION_SEND, tab.getDisplayTitle(), false); + } + } + return true; + } + + if (itemId == R.id.reload) { + tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) + tab.doReload(false); + return true; + } + + if (itemId == R.id.back) { + tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) + tab.doBack(); + return true; + } + + if (itemId == R.id.forward) { + tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) + tab.doForward(); + return true; + } + + if (itemId == R.id.bookmarks_list) { + final String url = AboutPages.getURLForBuiltinPanelType(PanelType.BOOKMARKS); + Tabs.getInstance().loadUrl(url); + return true; + } + + if (itemId == R.id.history_list) { + final String url = AboutPages.getURLForBuiltinPanelType(PanelType.COMBINED_HISTORY); + Tabs.getInstance().loadUrl(url); + return true; + } + + if (itemId == R.id.save_as_pdf) { + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "pdf"); + GeckoAppShell.notifyObservers("SaveAs:PDF", null); + return true; + } + + if (itemId == R.id.print) { + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "print"); + PrintHelper.printPDF(this); + return true; + } + + if (itemId == R.id.settings) { + intent = new Intent(this, GeckoPreferences.class); + + // We want to know when the Settings activity returns, because + // we might need to redisplay based on a locale change. + startActivityForResult(intent, ACTIVITY_REQUEST_PREFERENCES); + return true; + } + + if (itemId == R.id.help) { + final String VERSION = AppConstants.MOZ_APP_VERSION; + final String OS = AppConstants.OS_TARGET; + final String LOCALE = Locales.getLanguageTag(Locale.getDefault()); + + final String URL = getResources().getString(R.string.help_link, VERSION, OS, LOCALE); + Tabs.getInstance().loadUrlInTab(URL); + return true; + } + + if (itemId == R.id.addons) { + Tabs.getInstance().loadUrlInTab(AboutPages.ADDONS); + return true; + } + + if (itemId == R.id.logins) { + Tabs.getInstance().loadUrlInTab(AboutPages.LOGINS); + return true; + } + + if (itemId == R.id.downloads) { + Tabs.getInstance().loadUrlInTab(AboutPages.DOWNLOADS); + return true; + } + + if (itemId == R.id.char_encoding) { + GeckoAppShell.notifyObservers("CharEncoding:Get", null); + return true; + } + + if (itemId == R.id.find_in_page) { + mFindInPageBar.show(); + return true; + } + + if (itemId == R.id.desktop_mode) { + Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab == null) + return true; + JSONObject args = new JSONObject(); + try { + args.put("desktopMode", !item.isChecked()); + args.put("tabId", selectedTab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "error building json arguments", e); + } + GeckoAppShell.notifyObservers("DesktopMode:Change", args.toString()); + return true; + } + + if (itemId == R.id.new_tab) { + addTab(); + return true; + } + + if (itemId == R.id.new_private_tab) { + addPrivateTab(); + return true; + } + + if (itemId == R.id.new_guest_session) { + showGuestModeDialog(GuestModeDialog.ENTERING); + return true; + } + + if (itemId == R.id.exit_guest_session) { + showGuestModeDialog(GuestModeDialog.LEAVING); + return true; + } + + // We have a few menu items that can also be in the context menu. If + // we have not already handled the item, give the context menu handler + // a chance. + if (onContextItemSelected(item)) { + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onMenuItemLongClick(MenuItem item) { + if (item.getItemId() == R.id.reload) { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + tab.doReload(true); + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "reload_force"); + } + return true; + } + + return super.onMenuItemLongClick(item); + } + + public void showGuestModeDialog(final GuestModeDialog type) { + if ((type == GuestModeDialog.ENTERING) == getProfile().inGuestMode()) { + // Don't show enter dialog if we are already in guest mode; same with leaving. + return; + } + + final Prompt ps = new Prompt(this, new Prompt.PromptCallback() { + @Override + public void onPromptFinished(String result) { + try { + int itemId = new JSONObject(result).getInt("button"); + if (itemId == 0) { + final Context context = GeckoAppShell.getApplicationContext(); + if (type == GuestModeDialog.ENTERING) { + GeckoProfile.enterGuestMode(context); + } else { + GeckoProfile.leaveGuestMode(context); + // Now's a good time to make sure we're not displaying the + // Guest Browsing notification. + GuestSession.hideNotification(context); + } + doRestart(); + } + } catch (JSONException ex) { + Log.e(LOGTAG, "Exception reading guest mode prompt result", ex); + } + } + }); + + Resources res = getResources(); + ps.setButtons(new String[] { + res.getString(R.string.guest_session_dialog_continue), + res.getString(R.string.guest_session_dialog_cancel) + }); + + int titleString = 0; + int msgString = 0; + if (type == GuestModeDialog.ENTERING) { + titleString = R.string.new_guest_session_title; + msgString = R.string.new_guest_session_text; + } else { + titleString = R.string.exit_guest_session_title; + msgString = R.string.exit_guest_session_text; + } + + ps.show(res.getString(titleString), res.getString(msgString), null, ListView.CHOICE_MODE_NONE); + } + + /** + * Handle a long press on the back button + */ + private boolean handleBackLongPress() { + // If the tab search history is already shown, do nothing. + TabHistoryFragment frag = (TabHistoryFragment) getSupportFragmentManager().findFragmentByTag(TAB_HISTORY_FRAGMENT_TAG); + if (frag != null) { + return true; + } + + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null && !tab.isEditing()) { + return tabHistoryController.showTabHistory(tab, TabHistoryController.HistoryAction.ALL); + } + + return false; + } + + /** + * This will detect if the key pressed is back. If so, will show the history. + */ + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + // onKeyLongPress is broken in Android N, see onKeyDown() for more information. We add a version + // check here to match our fallback code in order to avoid handling a long press twice (which + // could happen if newer versions of android and/or other vendors were to fix this problem). + if (Versions.preN && + keyCode == KeyEvent.KEYCODE_BACK) { + if (handleBackLongPress()) { + return true; + } + + } + return super.onKeyLongPress(keyCode, event); + } + + /* + * If the app has been launched a certain number of times, and we haven't asked for feedback before, + * open a new tab with about:feedback when launching the app from the icon shortcut. + */ + @Override + protected void onNewIntent(Intent externalIntent) { + final SafeIntent intent = new SafeIntent(externalIntent); + String action = intent.getAction(); + + final boolean isViewAction = Intent.ACTION_VIEW.equals(action); + final boolean isBookmarkAction = GeckoApp.ACTION_HOMESCREEN_SHORTCUT.equals(action); + final boolean isTabQueueAction = TabQueueHelper.LOAD_URLS_ACTION.equals(action); + final boolean isViewMultipleAction = ACTION_VIEW_MULTIPLE.equals(action); + + if (mInitialized && (isViewAction || isBookmarkAction)) { + // Dismiss editing mode if the user is loading a URL from an external app. + mBrowserToolbar.cancelEdit(); + + // Hide firstrun-pane if the user is loading a URL from an external app. + hideFirstrunPager(TelemetryContract.Method.NONE); + + if (isBookmarkAction) { + // GeckoApp.ACTION_HOMESCREEN_SHORTCUT means we're opening a bookmark that + // was added to Android's homescreen. + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.HOMESCREEN); + } + } + + showTabQueuePromptIfApplicable(intent); + + // GeckoApp will wrap this unsafe external intent in a SafeIntent. + super.onNewIntent(externalIntent); + + if (AppConstants.MOZ_ANDROID_BEAM && NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) { + String uri = intent.getDataString(); + mLayerView.loadUri(uri, GeckoView.LOAD_NEW_TAB); + } + + // Only solicit feedback when the app has been launched from the icon shortcut. + if (GuestSession.NOTIFICATION_INTENT.equals(action)) { + GuestSession.onNotificationIntentReceived(this); + } + + // If the user has clicked the tab queue notification then load the tabs. + if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized && isTabQueueAction) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, "tabqueue"); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + openQueuedTabs(); + } + }); + } + + // Custom intent action for opening multiple URLs at once + if (isViewMultipleAction) { + openMultipleTabsFromIntent(intent); + } + + for (final BrowserAppDelegate delegate : delegates) { + delegate.onNewIntent(this, intent); + } + + if (!mInitialized || !Intent.ACTION_MAIN.equals(action)) { + return; + } + + // Check to see how many times the app has been launched. + final String keyName = getPackageName() + ".feedback_launch_count"; + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + + // Faster on main thread with an async apply(). + try { + SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE); + int launchCount = settings.getInt(keyName, 0); + if (launchCount < FEEDBACK_LAUNCH_COUNT) { + // Increment the launch count and store the new value. + launchCount++; + settings.edit().putInt(keyName, launchCount).apply(); + + // If we've reached our magic number, show the feedback page. + if (launchCount == FEEDBACK_LAUNCH_COUNT) { + GeckoAppShell.notifyObservers("Feedback:Show", null); + } + } + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + public void openUrls(List<String> urls) { + try { + JSONArray array = new JSONArray(); + for (String url : urls) { + array.put(url); + } + + JSONObject object = new JSONObject(); + object.put("urls", array); + + GeckoAppShell.notifyObservers("Tabs:OpenMultiple", object.toString()); + } catch (JSONException e) { + Log.e(LOGTAG, "Unable to create JSON for opening multiple URLs"); + } + } + + private void showTabQueuePromptIfApplicable(final SafeIntent intent) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // We only want to show the prompt if the browser has been opened from an external url + if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized + && Intent.ACTION_VIEW.equals(intent.getAction()) + && !intent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false) + && TabQueueHelper.shouldShowTabQueuePrompt(BrowserApp.this)) { + Intent promptIntent = new Intent(BrowserApp.this, TabQueuePrompt.class); + startActivityForResult(promptIntent, ACTIVITY_REQUEST_TAB_QUEUE); + } + } + }); + } + + private void resetFeedbackLaunchCount() { + SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE); + settings.edit().putInt(getPackageName() + ".feedback_launch_count", 0).apply(); + } + + // HomePager.OnUrlOpenListener + @Override + public void onUrlOpen(String url, EnumSet<OnUrlOpenListener.Flags> flags) { + if (flags.contains(OnUrlOpenListener.Flags.OPEN_WITH_INTENT)) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(url)); + startActivity(intent); + } else { + // By default this listener is used for lists where the offline reader-view icon + // is shown - hence we need to redirect to the reader-view page by default. + // However there are some cases where we might not want to use this, e.g. + // for topsites where we do not indicate that a page is an offline reader-view bookmark too. + final String pageURL; + if (!flags.contains(OnUrlOpenListener.Flags.NO_READER_VIEW)) { + pageURL = SavedReaderViewHelper.getReaderURLIfCached(getContext(), url); + } else { + pageURL = url; + } + + if (!maybeSwitchToTab(pageURL, flags)) { + openUrlAndStopEditing(pageURL); + clearSelectedTabApplicationId(); + } + } + } + + // HomePager.OnUrlOpenInBackgroundListener + @Override + public void onUrlOpenInBackground(final String url, EnumSet<OnUrlOpenInBackgroundListener.Flags> flags) { + if (url == null) { + throw new IllegalArgumentException("url must not be null"); + } + if (flags == null) { + throw new IllegalArgumentException("flags must not be null"); + } + + // We only use onUrlOpenInBackgroundListener for the homepanel context menus, hence + // we should always be checking whether we want the readermode version + final String pageURL = SavedReaderViewHelper.getReaderURLIfCached(getContext(), url); + + final boolean isPrivate = flags.contains(OnUrlOpenInBackgroundListener.Flags.PRIVATE); + + int loadFlags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_BACKGROUND; + if (isPrivate) { + loadFlags |= Tabs.LOADURL_PRIVATE; + } + + final Tab newTab = Tabs.getInstance().loadUrl(pageURL, loadFlags); + + // We switch to the desired tab by unique ID, which closes any window + // for a race between opening the tab and closing it, and switching to + // it. We could also switch to the Tab explicitly, but we don't want to + // hold a reference to the Tab itself in the anonymous listener class. + final int newTabId = newTab.getId(); + + final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.TOAST, "switchtab"); + + maybeSwitchToTab(newTabId); + } + }; + + final String message = isPrivate ? + getResources().getString(R.string.new_private_tab_opened) : + getResources().getString(R.string.new_tab_opened); + final String buttonMessage = getResources().getString(R.string.switch_button_message); + + SnackbarBuilder.builder(this) + .message(message) + .duration(Snackbar.LENGTH_LONG) + .action(buttonMessage) + .callback(callback) + .buildAndShow(); + } + + // BrowserSearch.OnSearchListener + @Override + public void onSearch(SearchEngine engine, final String text, final TelemetryContract.Method method) { + // Don't store searches that happen in private tabs. This assumes the user can only + // perform a search inside the currently selected tab, which is true for searches + // that come from SearchEngineRow. + if (!Tabs.getInstance().getSelectedTab().isPrivate()) { + storeSearchQuery(text); + } + + // We don't use SearchEngine.getEngineIdentifier because it can + // return a custom search engine name, which is a privacy concern. + final String identifierToRecord = (engine.identifier != null) ? engine.identifier : "other"; + recordSearch(GeckoSharedPrefs.forProfile(this), identifierToRecord, method); + openUrlAndStopEditing(text, engine.name); + } + + // BrowserSearch.OnEditSuggestionListener + @Override + public void onEditSuggestion(String suggestion) { + mBrowserToolbar.onEditSuggestion(suggestion); + } + + @Override + public int getLayout() { return R.layout.gecko_app; } + + public SearchEngineManager getSearchEngineManager() { + return mSearchEngineManager; + } + + // For use from tests only. + @RobocopTarget + public ReadingListHelper getReadingListHelper() { + return mReadingListHelper; + } + + /** + * Launch UI that lets the user update Firefox. + * + * This depends on the current channel: Release and Beta both direct to the + * Google Play Store. If updating is enabled, Aurora, Nightly, and custom + * builds open about:, which provides an update interface. + * + * If updating is not enabled, this simply logs an error. + * + * @return true if update UI was launched. + */ + protected boolean handleUpdaterLaunch() { + if (AppConstants.RELEASE_OR_BETA) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("market://details?id=" + getPackageName())); + startActivity(intent); + return true; + } + + if (AppConstants.MOZ_UPDATER) { + Tabs.getInstance().loadUrlInTab(AboutPages.UPDATER); + return true; + } + + Log.w(LOGTAG, "No candidate updater found; ignoring launch request."); + return false; + } + + /* Implementing ActionModeCompat.Presenter */ + @Override + public void startActionModeCompat(final ActionModeCompat.Callback callback) { + // If actionMode is null, we're not currently showing one. Flip to the action mode view + if (mActionMode == null) { + mActionBarFlipper.showNext(); + DynamicToolbarAnimator toolbar = mLayerView.getDynamicToolbarAnimator(); + + // If the toolbar is dynamic and not currently showing, just slide it in + if (mDynamicToolbar.isEnabled() && toolbar.getToolbarTranslation() != 0) { + mDynamicToolbar.setTemporarilyVisible(true, VisibilityTransition.ANIMATE); + } + mDynamicToolbar.setPinned(true, PinReason.ACTION_MODE); + + } else { + // Otherwise, we're already showing an action mode. Just finish it and show the new one + mActionMode.finish(); + } + + mActionMode = new ActionModeCompat(BrowserApp.this, callback, mActionBar); + if (callback.onCreateActionMode(mActionMode, mActionMode.getMenu())) { + mActionMode.invalidate(); + } + } + + /* Implementing ActionModeCompat.Presenter */ + @Override + public void endActionModeCompat() { + if (mActionMode == null) { + return; + } + + mActionMode.finish(); + mActionMode = null; + mDynamicToolbar.setPinned(false, PinReason.ACTION_MODE); + + mActionBarFlipper.showPrevious(); + + // Only slide the urlbar out if it was hidden when the action mode started + // Don't animate hiding it so that there's no flash as we switch back to url mode + mDynamicToolbar.setTemporarilyVisible(false, VisibilityTransition.IMMEDIATE); + } + + public static interface TabStripInterface { + public void refresh(); + void setOnTabChangedListener(OnTabAddedOrRemovedListener listener); + interface OnTabAddedOrRemovedListener { + void onTabChanged(); + } + } + + @Override + protected void recordStartupActionTelemetry(final String passedURL, final String action) { + final TelemetryContract.Method method; + if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) { + // This action is also recorded via "loadurl.1" > "homescreen". + method = TelemetryContract.Method.HOMESCREEN; + } else if (passedURL == null) { + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, TelemetryContract.Method.HOMESCREEN, "launcher"); + method = TelemetryContract.Method.HOMESCREEN; + } else { + // This is action is also recorded via "loadurl.1" > "intent". + method = TelemetryContract.Method.INTENT; + } + + if (GeckoProfile.get(this).inGuestMode()) { + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, method, "guest"); + } else if (Restrictions.isRestrictedProfile(this)) { + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, method, "restricted"); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java b/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java new file mode 100644 index 000000000..c5c041c7a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java @@ -0,0 +1,439 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import java.io.File; +import java.util.Collection; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.util.GeckoJarReader; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.Log; + +/** + * This class manages persistence, application, and otherwise handling of + * user-specified locales. + * + * Of note: + * + * * It's a singleton, because its scope extends to that of the application, + * and definitionally all changes to the locale of the app must go through + * this. + * * It's lazy. + * * It has ties into the Gecko event system, because it has to tell Gecko when + * to switch locale. + * * It relies on using the SharedPreferences file owned by the browser (in + * Fennec's case, "GeckoApp") for performance. + */ +public class BrowserLocaleManager implements LocaleManager { + private static final String LOG_TAG = "GeckoLocales"; + + private static final String EVENT_LOCALE_CHANGED = "Locale:Changed"; + private static final String PREF_LOCALE = "locale"; + + private static final String FALLBACK_LOCALE_TAG = "en-US"; + + // These are volatile because we don't impose restrictions + // over which thread calls our methods. + private volatile Locale currentLocale; + private volatile Locale systemLocale = Locale.getDefault(); + + private final AtomicBoolean inited = new AtomicBoolean(false); + private boolean systemLocaleDidChange; + private BroadcastReceiver receiver; + + private static final AtomicReference<LocaleManager> instance = new AtomicReference<LocaleManager>(); + + @ReflectionTarget + public static LocaleManager getInstance() { + LocaleManager localeManager = instance.get(); + if (localeManager != null) { + return localeManager; + } + + localeManager = new BrowserLocaleManager(); + if (instance.compareAndSet(null, localeManager)) { + return localeManager; + } else { + return instance.get(); + } + } + + @Override + public boolean isEnabled() { + return AppConstants.MOZ_LOCALE_SWITCHER; + } + + /** + * Ensure that you call this early in your application startup, + * and with a context that's sufficiently long-lived (typically + * the application context). + * + * Calling multiple times is harmless. + */ + @Override + public void initialize(final Context context) { + if (!inited.compareAndSet(false, true)) { + return; + } + + receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final Locale current = systemLocale; + + // We don't trust Locale.getDefault() here, because we make a + // habit of mutating it! Use the one Android supplies, because + // that gets regularly reset. + // The default value of systemLocale is fine, because we haven't + // yet swizzled Locale during static initialization. + systemLocale = context.getResources().getConfiguration().locale; + systemLocaleDidChange = true; + + Log.d(LOG_TAG, "System locale changed from " + current + " to " + systemLocale); + } + }; + context.registerReceiver(receiver, new IntentFilter(Intent.ACTION_LOCALE_CHANGED)); + } + + @Override + public boolean systemLocaleDidChange() { + return systemLocaleDidChange; + } + + /** + * Every time the system gives us a new configuration, it + * carries the external locale. Fix it. + */ + @Override + public void correctLocale(Context context, Resources res, Configuration config) { + final Locale current = getCurrentLocale(context); + if (current == null) { + Log.d(LOG_TAG, "No selected locale. No correction needed."); + return; + } + + // I know it's tempting to short-circuit here if the config seems to be + // up-to-date, but the rest is necessary. + + config.locale = current; + + // The following two lines are heavily commented in case someone + // decides to chase down performance improvements and decides to + // question what's going on here. + // Both lines should be cheap, *but*... + + // This is unnecessary for basic string choice, but it almost + // certainly comes into play when rendering numbers, deciding on RTL, + // etc. Take it out if you can prove that's not the case. + Locale.setDefault(current); + + // This seems to be a no-op, but every piece of documentation under the + // sun suggests that it's necessary, and it certainly makes sense. + res.updateConfiguration(config, null); + } + + /** + * We can be in one of two states. + * + * If the user has not explicitly chosen a Firefox-specific locale, we say + * we are "mirroring" the system locale. + * + * When we are not mirroring, system locale changes do not impact Firefox + * and are essentially ignored; the user's locale selection is the only + * thing we care about, and we actively correct incoming configuration + * changes to reflect the user's chosen locale. + * + * By contrast, when we are mirroring, system locale changes cause Firefox + * to reflect the new system locale, as if the user picked the new locale. + * + * If we're currently mirroring the system locale, this method returns the + * supplied configuration's locale, unless the current activity locale is + * correct. If we're not currently mirroring, this method updates the + * configuration object to match the user's currently selected locale, and + * returns that, unless the current activity locale is correct. + * + * If the current activity locale is correct, returns null. + * + * The caller is expected to redisplay themselves accordingly. + * + * This method is intended to be called from inside + * <code>onConfigurationChanged(Configuration)</code> as part of a strategy + * to detect and either apply or undo system locale changes. + */ + @Override + public Locale onSystemConfigurationChanged(final Context context, final Resources resources, final Configuration configuration, final Locale currentActivityLocale) { + if (!isMirroringSystemLocale(context)) { + correctLocale(context, resources, configuration); + } + + final Locale changed = configuration.locale; + if (changed.equals(currentActivityLocale)) { + return null; + } + + return changed; + } + + /** + * Gecko needs to know the OS locale to compute a useful Accept-Language + * header. If it changed since last time, send a message to Gecko and + * persist the new value. If unchanged, returns immediately. + * + * @param prefs the SharedPreferences instance to use. Cannot be null. + * @param osLocale the new locale instance. Safe if null. + */ + public static void storeAndNotifyOSLocale(final SharedPreferences prefs, + final Locale osLocale) { + if (osLocale == null) { + return; + } + + final String lastOSLocale = prefs.getString("osLocale", null); + final String osLocaleString = osLocale.toString(); + + if (osLocaleString.equals(lastOSLocale)) { + return; + } + + // Store the Java-native form. + prefs.edit().putString("osLocale", osLocaleString).apply(); + + // The value we send to Gecko should be a language tag, not + // a Java locale string. + final String osLanguageTag = Locales.getLanguageTag(osLocale); + GeckoAppShell.notifyObservers("Locale:OS", osLanguageTag); + } + + @Override + public String getAndApplyPersistedLocale(Context context) { + initialize(context); + + final long t1 = android.os.SystemClock.uptimeMillis(); + final String localeCode = getPersistedLocale(context); + if (localeCode == null) { + return null; + } + + // Note that we don't tell Gecko about this. We notify Gecko when the + // locale is set, not when we update Java. + final String resultant = updateLocale(context, localeCode); + + if (resultant == null) { + // Update the configuration anyway. + updateConfiguration(context, currentLocale); + } + + final long t2 = android.os.SystemClock.uptimeMillis(); + Log.i(LOG_TAG, "Locale read and update took: " + (t2 - t1) + "ms."); + return resultant; + } + + /** + * Returns the set locale if it changed. + * + * Always persists and notifies Gecko. + */ + @Override + public String setSelectedLocale(Context context, String localeCode) { + final String resultant = updateLocale(context, localeCode); + + // We always persist and notify Gecko, even if nothing seemed to + // change. This might happen if you're picking a locale that's the same + // as the current OS locale. The OS locale might change next time we + // launch, and we need the Gecko pref and persisted locale to have been + // set by the time that happens. + persistLocale(context, localeCode); + + // Tell Gecko. + GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, Locales.getLanguageTag(getCurrentLocale(context))); + + return resultant; + } + + @Override + public void resetToSystemLocale(Context context) { + // Wipe the pref. + final SharedPreferences settings = getSharedPreferences(context); + settings.edit().remove(PREF_LOCALE).apply(); + + // Apply the system locale. + updateLocale(context, systemLocale); + + // Tell Gecko. + GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, ""); + } + + /** + * This is public to allow for an activity to force the + * current locale to be applied if necessary (e.g., when + * a new activity launches). + */ + @Override + public void updateConfiguration(Context context, Locale locale) { + Resources res = context.getResources(); + Configuration config = res.getConfiguration(); + + // We should use setLocale, but it's unexpectedly missing + // on real devices. + config.locale = locale; + res.updateConfiguration(config, null); + } + + private SharedPreferences getSharedPreferences(Context context) { + return GeckoSharedPrefs.forApp(context); + } + + /** + * @return the persisted locale in Java format: "en_US". + */ + private String getPersistedLocale(Context context) { + final SharedPreferences settings = getSharedPreferences(context); + final String locale = settings.getString(PREF_LOCALE, ""); + + if ("".equals(locale)) { + return null; + } + return locale; + } + + private void persistLocale(Context context, String localeCode) { + final SharedPreferences settings = getSharedPreferences(context); + settings.edit().putString(PREF_LOCALE, localeCode).apply(); + } + + @Override + public Locale getCurrentLocale(Context context) { + if (currentLocale != null) { + return currentLocale; + } + + final String current = getPersistedLocale(context); + if (current == null) { + return null; + } + return currentLocale = Locales.parseLocaleCode(current); + } + + /** + * Updates the Java locale and the Android configuration. + * + * Returns the persisted locale if it differed. + * + * Does not notify Gecko. + * + * @param localeCode a locale string in Java format: "en_US". + * @return if it differed, a locale string in Java format: "en_US". + */ + private String updateLocale(Context context, String localeCode) { + // Fast path. + final Locale defaultLocale = Locale.getDefault(); + if (defaultLocale.toString().equals(localeCode)) { + return null; + } + + final Locale locale = Locales.parseLocaleCode(localeCode); + + return updateLocale(context, locale); + } + + /** + * @return the Java locale string: e.g., "en_US". + */ + private String updateLocale(Context context, final Locale locale) { + // Fast path. + if (Locale.getDefault().equals(locale)) { + return null; + } + + Locale.setDefault(locale); + currentLocale = locale; + + // Update resources. + updateConfiguration(context, locale); + + return locale.toString(); + } + + private boolean isMirroringSystemLocale(final Context context) { + return getPersistedLocale(context) == null; + } + + /** + * Examines <code>multilocale.json</code>, returning the included list of + * locale codes. + * + * If <code>multilocale.json</code> is not present, returns + * <code>null</code>. In that case, consider {@link #getFallbackLocaleTag()}. + * + * multilocale.json currently looks like this: + * + * <code> + * {"locales": ["en-US", "be", "ca", "cs", "da", "de", "en-GB", + * "en-ZA", "es-AR", "es-ES", "es-MX", "et", "fi", + * "fr", "ga-IE", "hu", "id", "it", "ja", "ko", + * "lt", "lv", "nb-NO", "nl", "pl", "pt-BR", + * "pt-PT", "ro", "ru", "sk", "sl", "sv-SE", "th", + * "tr", "uk", "zh-CN", "zh-TW", "en-US"]} + * </code> + */ + public static Collection<String> getPackagedLocaleTags(final Context context) { + final String resPath = "res/multilocale.json"; + final String jarURL = GeckoJarReader.getJarURL(context, resPath); + + final String contents = GeckoJarReader.getText(context, jarURL); + if (contents == null) { + // GeckoJarReader logs and swallows exceptions. + return null; + } + + try { + final JSONObject multilocale = new JSONObject(contents); + final JSONArray locales = multilocale.getJSONArray("locales"); + if (locales == null) { + Log.e(LOG_TAG, "No 'locales' array in multilocales.json!"); + return null; + } + + final Set<String> out = new HashSet<String>(locales.length()); + for (int i = 0; i < locales.length(); ++i) { + // If any item in the array is invalid, this will throw, + // and the entire clause will fail, being caught below + // and returning null. + out.add(locales.getString(i)); + } + + return out; + } catch (JSONException e) { + Log.e(LOG_TAG, "Unable to parse multilocale.json.", e); + return null; + } + } + + /** + * @return the single default locale baked into this application. + * Applicable when there is no multilocale.json present. + */ + @SuppressWarnings("static-method") + public String getFallbackLocaleTag() { + return FALLBACK_LOCALE_TAG; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java b/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java new file mode 100644 index 000000000..cff6ea643 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java @@ -0,0 +1,112 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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; + +import org.json.JSONObject; +import org.json.JSONException; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.EventCallback; + +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.CastRemoteDisplayLocalService; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; +import com.google.android.gms.common.api.Status; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.support.v7.media.MediaRouter.RouteInfo; +import android.util.Log; + +public class ChromeCastDisplay implements GeckoPresentationDisplay { + + static final String REMOTE_DISPLAY_APP_ID = "4574A331"; + + private static final String LOGTAG = "GeckoChromeCastDisplay"; + private final Context context; + private final RouteInfo route; + private CastDevice castDevice; + + public ChromeCastDisplay(Context context, RouteInfo route) { + int status = GooglePlayServicesUtil.isGooglePlayServicesAvailable(context); + if (status != ConnectionResult.SUCCESS) { + throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")"); + } + + this.context = context; + this.route = route; + this.castDevice = CastDevice.getFromBundle(route.getExtras()); + } + + public JSONObject toJSON() { + final JSONObject obj = new JSONObject(); + try { + if (castDevice == null) { + return null; + } + obj.put("uuid", route.getId()); + obj.put("friendlyName", castDevice.getFriendlyName()); + obj.put("type", "chromecast"); + } catch (JSONException ex) { + Log.d(LOGTAG, "Error building route", ex); + } + + return obj; + } + + @Override + public void start(final EventCallback callback) { + + if (CastRemoteDisplayLocalService.getInstance() != null) { + Log.d(LOGTAG, "CastRemoteDisplayLocalService already existed."); + GeckoAppShell.notifyObservers("presentation-view-ready", route.getId()); + callback.sendSuccess("Succeed to start presentation."); + return; + } + + Intent intent = new Intent(context, RemotePresentationService.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent notificationPendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + CastRemoteDisplayLocalService.NotificationSettings settings = + new CastRemoteDisplayLocalService.NotificationSettings.Builder() + .setNotificationPendingIntent(notificationPendingIntent).build(); + + CastRemoteDisplayLocalService.startService( + context, + RemotePresentationService.class, + REMOTE_DISPLAY_APP_ID, + castDevice, + settings, + new CastRemoteDisplayLocalService.Callbacks() { + @Override + public void onServiceCreated(CastRemoteDisplayLocalService service) { + ((RemotePresentationService) service).setDeviceId(route.getId()); + } + + @Override + public void onRemoteDisplaySessionStarted(CastRemoteDisplayLocalService service) { + Log.d(LOGTAG, "Remote presentation launched!"); + callback.sendSuccess("Succeed to start presentation."); + } + + @Override + public void onRemoteDisplaySessionError(Status errorReason) { + int code = errorReason.getStatusCode(); + callback.sendError("Fail to start presentation. Error code: " + code); + } + }); + } + + @Override + public void stop(EventCallback callback) { + CastRemoteDisplayLocalService.stopService(); + callback.sendSuccess("Succeed to stop presentation."); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java b/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java new file mode 100644 index 000000000..c531b8c37 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java @@ -0,0 +1,509 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import java.io.IOException; + +import org.mozilla.gecko.util.EventCallback; +import org.json.JSONObject; +import org.json.JSONException; + +import com.google.android.gms.cast.Cast.MessageReceivedCallback; +import com.google.android.gms.cast.ApplicationMetadata; +import com.google.android.gms.cast.Cast; +import com.google.android.gms.cast.Cast.ApplicationConnectionResult; +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.RemoteMediaPlayer; +import com.google.android.gms.cast.RemoteMediaPlayer.MediaChannelResult; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.common.GooglePlayServicesUtil; + +import android.content.Context; +import android.os.Bundle; +import android.support.v7.media.MediaRouter.RouteInfo; +import android.util.Log; + +/* Implementation of GeckoMediaPlayer for talking to ChromeCast devices */ +class ChromeCastPlayer implements GeckoMediaPlayer { + private static final boolean SHOW_DEBUG = false; + + static final String MIRROR_RECEIVER_APP_ID = "08FF1091"; + + private final Context context; + private final RouteInfo route; + private GoogleApiClient apiClient; + private RemoteMediaPlayer remoteMediaPlayer; + private final boolean canMirror; + private String mSessionId; + private MirrorChannel mMirrorChannel; + private boolean mApplicationStarted = false; + + // EventCallback which is actually a GeckoEventCallback is sometimes being invoked more + // than once. That causes the IllegalStateException to be thrown. To prevent a crash, + // catch the exception and report it as an error to the log. + private static void sendSuccess(final EventCallback callback, final String msg) { + try { + callback.sendSuccess(msg); + } catch (final IllegalStateException e) { + Log.e(LOGTAG, "Attempting to invoke callback.sendSuccess more than once.", e); + } + } + + private static void sendError(final EventCallback callback, final String msg) { + try { + callback.sendError(msg); + } catch (final IllegalStateException e) { + Log.e(LOGTAG, "Attempting to invoke callback.sendError more than once.", e); + } + } + + // Callback to start playback of a url on a remote device + private class VideoPlayCallback implements ResultCallback<ApplicationConnectionResult>, + RemoteMediaPlayer.OnStatusUpdatedListener, + RemoteMediaPlayer.OnMetadataUpdatedListener { + private final String url; + private final String type; + private final String title; + private final EventCallback callback; + + public VideoPlayCallback(String url, String type, String title, EventCallback callback) { + this.url = url; + this.type = type; + this.title = title; + this.callback = callback; + } + + @Override + public void onStatusUpdated() { + MediaStatus mediaStatus = remoteMediaPlayer.getMediaStatus(); + + switch (mediaStatus.getPlayerState()) { + case MediaStatus.PLAYER_STATE_PLAYING: + GeckoAppShell.notifyObservers("MediaPlayer:Playing", null); + break; + case MediaStatus.PLAYER_STATE_PAUSED: + GeckoAppShell.notifyObservers("MediaPlayer:Paused", null); + break; + case MediaStatus.PLAYER_STATE_IDLE: + // TODO: Do we want to shutdown when there are errors? + if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_FINISHED) { + GeckoAppShell.notifyObservers("Casting:Stop", null); + } + break; + default: + // TODO: Do we need to handle other status such as buffering / unknown? + break; + } + } + + @Override + public void onMetadataUpdated() { } + + @Override + public void onResult(ApplicationConnectionResult result) { + Status status = result.getStatus(); + debug("ApplicationConnectionResultCallback.onResult: statusCode" + status.getStatusCode()); + if (status.isSuccess()) { + remoteMediaPlayer = new RemoteMediaPlayer(); + remoteMediaPlayer.setOnStatusUpdatedListener(this); + remoteMediaPlayer.setOnMetadataUpdatedListener(this); + mSessionId = result.getSessionId(); + if (!verifySession(callback)) { + return; + } + + try { + Cast.CastApi.setMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace(), remoteMediaPlayer); + } catch (IOException e) { + debug("Exception while creating media channel", e); + } + + startPlayback(); + } else { + sendError(callback, status.toString()); + } + } + + private void startPlayback() { + MediaMetadata mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE); + mediaMetadata.putString(MediaMetadata.KEY_TITLE, title); + MediaInfo mediaInfo = new MediaInfo.Builder(url) + .setContentType(type) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setMetadata(mediaMetadata) + .build(); + try { + remoteMediaPlayer.load(apiClient, mediaInfo, true).setResultCallback(new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() { + @Override + public void onResult(MediaChannelResult result) { + if (result.getStatus().isSuccess()) { + sendSuccess(callback, null); + debug("Media loaded successfully"); + return; + } + + debug("Media load failed " + result.getStatus()); + sendError(callback, result.getStatus().toString()); + } + }); + + return; + } catch (IllegalStateException e) { + debug("Problem occurred with media during loading", e); + } catch (Exception e) { + debug("Problem opening media during loading", e); + } + + sendError(callback, ""); + } + } + + public ChromeCastPlayer(Context context, RouteInfo route) { + int status = GooglePlayServicesUtil.isGooglePlayServicesAvailable(context); + if (status != ConnectionResult.SUCCESS) { + throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")"); + } + + this.context = context; + this.route = route; + this.canMirror = route.supportsControlCategory(CastMediaControlIntent.categoryForCast(MIRROR_RECEIVER_APP_ID)); + } + + /** + * This dumps everything we can find about the device into JSON. This will hopefully make it + * easier to filter out duplicate devices from different sources in JS. + * Returns null if the device can't be found. + */ + @Override + public JSONObject toJSON() { + final JSONObject obj = new JSONObject(); + try { + final CastDevice device = CastDevice.getFromBundle(route.getExtras()); + if (device == null) { + return null; + } + + obj.put("uuid", route.getId()); + obj.put("version", device.getDeviceVersion()); + obj.put("friendlyName", device.getFriendlyName()); + obj.put("location", device.getIpAddress().toString()); + obj.put("modelName", device.getModelName()); + obj.put("mirror", canMirror); + // For now we just assume all of these are Google devices + obj.put("manufacturer", "Google Inc."); + } catch (JSONException ex) { + debug("Error building route", ex); + } + + return obj; + } + + @Override + public void load(final String title, final String url, final String type, final EventCallback callback) { + final CastDevice device = CastDevice.getFromBundle(route.getExtras()); + Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() { + @Override + public void onApplicationStatusChanged() { } + + @Override + public void onVolumeChanged() { } + + @Override + public void onApplicationDisconnected(int errorCode) { } + }); + + apiClient = new GoogleApiClient.Builder(context) + .addApi(Cast.API, apiOptionsBuilder.build()) + .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { + @Override + public void onConnected(Bundle connectionHint) { + // Sometimes apiClient is null here. See bug 1061032 + if (apiClient != null && !apiClient.isConnected()) { + debug("Connection failed"); + sendError(callback, "Not connected"); + return; + } + + // Launch the media player app and launch this url once its loaded + try { + Cast.CastApi.launchApplication(apiClient, CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID, true) + .setResultCallback(new VideoPlayCallback(url, type, title, callback)); + } catch (Exception e) { + debug("Failed to launch application", e); + } + } + + @Override + public void onConnectionSuspended(int cause) { + debug("suspended"); + } + }).build(); + + apiClient.connect(); + } + + @Override + public void start(final EventCallback callback) { + // Nothing to be done here + sendSuccess(callback, null); + } + + @Override + public void stop(final EventCallback callback) { + // Nothing to be done here + sendSuccess(callback, null); + } + + public boolean verifySession(final EventCallback callback) { + String msg = null; + if (apiClient == null || !apiClient.isConnected()) { + msg = "Not connected"; + } + + if (mSessionId == null) { + msg = "No session"; + } + + if (msg != null) { + debug(msg); + if (callback != null) { + sendError(callback, msg); + } + return false; + } + + return true; + } + + @Override + public void play(final EventCallback callback) { + if (!verifySession(callback)) { + return; + } + + try { + remoteMediaPlayer.play(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() { + @Override + public void onResult(MediaChannelResult result) { + Status status = result.getStatus(); + if (!status.isSuccess()) { + debug("Unable to play: " + status.getStatusCode()); + sendError(callback, status.toString()); + } else { + sendSuccess(callback, null); + } + } + }); + } catch (IllegalStateException ex) { + // The media player may throw if the session has been killed. For now, we're just catching this here. + sendError(callback, "Error playing"); + } + } + + @Override + public void pause(final EventCallback callback) { + if (!verifySession(callback)) { + return; + } + + try { + remoteMediaPlayer.pause(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() { + @Override + public void onResult(MediaChannelResult result) { + Status status = result.getStatus(); + if (!status.isSuccess()) { + debug("Unable to pause: " + status.getStatusCode()); + sendError(callback, status.toString()); + } else { + sendSuccess(callback, null); + } + } + }); + } catch (IllegalStateException ex) { + // The media player may throw if the session has been killed. For now, we're just catching this here. + sendError(callback, "Error pausing"); + } + } + + @Override + public void end(final EventCallback callback) { + if (!verifySession(callback)) { + return; + } + + try { + Cast.CastApi.stopApplication(apiClient).setResultCallback(new ResultCallback<Status>() { + @Override + public void onResult(Status result) { + if (result.isSuccess()) { + try { + Cast.CastApi.removeMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace()); + remoteMediaPlayer = null; + mSessionId = null; + apiClient.disconnect(); + apiClient = null; + + if (callback != null) { + sendSuccess(callback, null); + } + + return; + } catch (Exception ex) { + debug("Error ending", ex); + } + } + + if (callback != null) { + sendError(callback, result.getStatus().toString()); + } + } + }); + } catch (IllegalStateException ex) { + // The media player may throw if the session has been killed. For now, we're just catching this here. + sendError(callback, "Error stopping"); + } + } + + class MirrorChannel implements MessageReceivedCallback { + /** + * @return custom namespace + */ + public String getNamespace() { + return "urn:x-cast:org.mozilla.mirror"; + } + + /* + * Receive message from the receiver app + */ + @Override + public void onMessageReceived(CastDevice castDevice, String namespace, + String message) { + GeckoAppShell.notifyObservers("MediaPlayer:Response", message); + } + + public void sendMessage(String message) { + if (apiClient != null && mMirrorChannel != null) { + try { + Cast.CastApi.sendMessage(apiClient, mMirrorChannel.getNamespace(), message) + .setResultCallback( + new ResultCallback<Status>() { + @Override + public void onResult(Status result) { + } + }); + } catch (Exception e) { + Log.e(LOGTAG, "Exception while sending message", e); + } + } + } + } + private class MirrorCallback implements ResultCallback<ApplicationConnectionResult> { + final EventCallback callback; + MirrorCallback(final EventCallback callback) { + this.callback = callback; + } + + + @Override + public void onResult(ApplicationConnectionResult result) { + Status status = result.getStatus(); + if (status.isSuccess()) { + ApplicationMetadata applicationMetadata = result.getApplicationMetadata(); + mSessionId = result.getSessionId(); + String applicationStatus = result.getApplicationStatus(); + boolean wasLaunched = result.getWasLaunched(); + mApplicationStarted = true; + + // Create the custom message + // channel + mMirrorChannel = new MirrorChannel(); + try { + Cast.CastApi.setMessageReceivedCallbacks(apiClient, + mMirrorChannel + .getNamespace(), + mMirrorChannel); + sendSuccess(callback, null); + } catch (IOException e) { + Log.e(LOGTAG, "Exception while creating channel", e); + } + + GeckoAppShell.notifyObservers("Casting:Mirror", route.getId()); + } else { + sendError(callback, status.toString()); + } + } + } + + @Override + public void message(String msg, final EventCallback callback) { + if (mMirrorChannel != null) { + mMirrorChannel.sendMessage(msg); + } + } + + @Override + public void mirror(final EventCallback callback) { + final CastDevice device = CastDevice.getFromBundle(route.getExtras()); + Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() { + @Override + public void onApplicationStatusChanged() { } + + @Override + public void onVolumeChanged() { } + + @Override + public void onApplicationDisconnected(int errorCode) { } + }); + + apiClient = new GoogleApiClient.Builder(context) + .addApi(Cast.API, apiOptionsBuilder.build()) + .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { + @Override + public void onConnected(Bundle connectionHint) { + // Sometimes apiClient is null here. See bug 1061032 + if (apiClient == null || !apiClient.isConnected()) { + return; + } + + // Launch the media player app and launch this url once its loaded + try { + Cast.CastApi.launchApplication(apiClient, MIRROR_RECEIVER_APP_ID, true) + .setResultCallback(new MirrorCallback(callback)); + } catch (Exception e) { + debug("Failed to launch application", e); + } + } + + @Override + public void onConnectionSuspended(int cause) { + debug("suspended"); + } + }).build(); + + apiClient.connect(); + } + + private static final String LOGTAG = "GeckoChromeCastPlayer"; + private void debug(String msg, Exception e) { + if (SHOW_DEBUG) { + Log.e(LOGTAG, msg, e); + } + } + + private void debug(String msg) { + if (SHOW_DEBUG) { + Log.d(LOGTAG, msg); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java b/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java new file mode 100644 index 000000000..ce2384a4d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java @@ -0,0 +1,480 @@ +/* -*- Mode: Java; 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; + +import java.util.HashMap; +import java.util.Map; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.InputStreamReader; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.util.zip.GZIPOutputStream; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.support.v7.app.AppCompatActivity; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; + +@SuppressLint("Registered") // This activity is only registered in the manifest if MOZ_CRASHREPORTER is set +public class CrashReporter extends AppCompatActivity +{ + private static final String LOGTAG = "GeckoCrashReporter"; + + private static final String PASSED_MINI_DUMP_KEY = "minidumpPath"; + private static final String PASSED_MINI_DUMP_SUCCESS_KEY = "minidumpSuccess"; + private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump"; + private static final String PAGE_URL_KEY = "URL"; + private static final String NOTES_KEY = "Notes"; + private static final String SERVER_URL_KEY = "ServerURL"; + + private static final String CRASH_REPORT_SUFFIX = "/mozilla/Crash Reports/"; + private static final String PENDING_SUFFIX = CRASH_REPORT_SUFFIX + "pending"; + private static final String SUBMITTED_SUFFIX = CRASH_REPORT_SUFFIX + "submitted"; + + private static final String PREFS_SEND_REPORT = "sendReport"; + private static final String PREFS_INCLUDE_URL = "includeUrl"; + private static final String PREFS_ALLOW_CONTACT = "allowContact"; + private static final String PREFS_CONTACT_EMAIL = "contactEmail"; + + private Handler mHandler; + private ProgressDialog mProgressDialog; + private File mPendingMinidumpFile; + private File mPendingExtrasFile; + private HashMap<String, String> mExtrasStringMap; + private boolean mMinidumpSucceeded; + + private boolean moveFile(File inFile, File outFile) { + Log.i(LOGTAG, "moving " + inFile + " to " + outFile); + if (inFile.renameTo(outFile)) + return true; + try { + outFile.createNewFile(); + Log.i(LOGTAG, "couldn't rename minidump file"); + // so copy it instead + FileChannel inChannel = new FileInputStream(inFile).getChannel(); + FileChannel outChannel = new FileOutputStream(outFile).getChannel(); + long transferred = inChannel.transferTo(0, inChannel.size(), outChannel); + inChannel.close(); + outChannel.close(); + + if (transferred > 0) + inFile.delete(); + } catch (Exception e) { + Log.e(LOGTAG, "exception while copying minidump file: ", e); + return false; + } + return true; + } + + private void doFinish() { + if (mHandler != null) { + mHandler.post(new Runnable() { + @Override + public void run() { + finish(); + } + }); + } + } + + @Override + public void finish() { + try { + if (mProgressDialog.isShowing()) { + mProgressDialog.dismiss(); + } + } catch (Exception e) { + Log.e(LOGTAG, "exception while closing progress dialog: ", e); + } + super.finish(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // mHandler is created here so runnables can be run on the main thread + mHandler = new Handler(); + setContentView(R.layout.crash_reporter); + mProgressDialog = new ProgressDialog(this); + mProgressDialog.setMessage(getString(R.string.sending_crash_report)); + + mMinidumpSucceeded = getIntent().getBooleanExtra(PASSED_MINI_DUMP_SUCCESS_KEY, false); + if (!mMinidumpSucceeded) { + Log.i(LOGTAG, "Failed to get minidump."); + } + String passedMinidumpPath = getIntent().getStringExtra(PASSED_MINI_DUMP_KEY); + File passedMinidumpFile = new File(passedMinidumpPath); + File pendingDir = new File(getFilesDir(), PENDING_SUFFIX); + pendingDir.mkdirs(); + mPendingMinidumpFile = new File(pendingDir, passedMinidumpFile.getName()); + moveFile(passedMinidumpFile, mPendingMinidumpFile); + + File extrasFile = new File(passedMinidumpPath.replaceAll("\\.dmp", ".extra")); + mPendingExtrasFile = new File(pendingDir, extrasFile.getName()); + moveFile(extrasFile, mPendingExtrasFile); + + mExtrasStringMap = new HashMap<String, String>(); + readStringsFromFile(mPendingExtrasFile.getPath(), mExtrasStringMap); + + // Notify GeckoApp that we've crashed, so it can react appropriately during the next start. + try { + File crashFlag = new File(GeckoProfileDirectories.getMozillaDirectory(this), "CRASHED"); + crashFlag.createNewFile(); + } catch (GeckoProfileDirectories.NoMozillaDirectoryException | IOException e) { + Log.e(LOGTAG, "Cannot set crash flag: ", e); + } + + final CheckBox allowContactCheckBox = (CheckBox) findViewById(R.id.allow_contact); + final CheckBox includeUrlCheckBox = (CheckBox) findViewById(R.id.include_url); + final CheckBox sendReportCheckBox = (CheckBox) findViewById(R.id.send_report); + final EditText commentsEditText = (EditText) findViewById(R.id.comment); + final EditText emailEditText = (EditText) findViewById(R.id.email); + + // Load CrashReporter preferences to avoid redundant user input. + SharedPreferences prefs = GeckoSharedPrefs.forCrashReporter(this); + final boolean sendReport = prefs.getBoolean(PREFS_SEND_REPORT, true); + final boolean includeUrl = prefs.getBoolean(PREFS_INCLUDE_URL, false); + final boolean allowContact = prefs.getBoolean(PREFS_ALLOW_CONTACT, false); + final String contactEmail = prefs.getString(PREFS_CONTACT_EMAIL, ""); + + allowContactCheckBox.setChecked(allowContact); + includeUrlCheckBox.setChecked(includeUrl); + sendReportCheckBox.setChecked(sendReport); + emailEditText.setText(contactEmail); + + sendReportCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) { + commentsEditText.setEnabled(isChecked); + commentsEditText.requestFocus(); + + includeUrlCheckBox.setEnabled(isChecked); + allowContactCheckBox.setEnabled(isChecked); + emailEditText.setEnabled(isChecked && allowContactCheckBox.isChecked()); + } + }); + + allowContactCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) { + // We need to check isEnabled() here because this listener is + // fired on rotation -- even when the checkbox is disabled. + emailEditText.setEnabled(checkbox.isEnabled() && isChecked); + emailEditText.requestFocus(); + } + }); + + emailEditText.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // Even if the email EditText is disabled, allow it to be + // clicked and focused. + if (sendReportCheckBox.isChecked() && !v.isEnabled()) { + allowContactCheckBox.setChecked(true); + v.setEnabled(true); + v.requestFocus(); + } + } + }); + } + + @Override + public void onBackPressed() { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.crash_closing_alert); + builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + builder.setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + CrashReporter.this.finish(); + } + }); + builder.show(); + } + + private void backgroundSendReport() { + final CheckBox sendReportCheckbox = (CheckBox) findViewById(R.id.send_report); + if (!sendReportCheckbox.isChecked()) { + doFinish(); + return; + } + + // Persist settings to avoid redundant user input. + savePrefs(); + + mProgressDialog.show(); + new Thread(new Runnable() { + @Override + public void run() { + sendReport(mPendingMinidumpFile, mExtrasStringMap, mPendingExtrasFile); + } + }, "CrashReporter Thread").start(); + } + + private void savePrefs() { + SharedPreferences.Editor editor = GeckoSharedPrefs.forCrashReporter(this).edit(); + + final boolean allowContact = ((CheckBox) findViewById(R.id.allow_contact)).isChecked(); + final boolean includeUrl = ((CheckBox) findViewById(R.id.include_url)).isChecked(); + final boolean sendReport = ((CheckBox) findViewById(R.id.send_report)).isChecked(); + final String contactEmail = ((EditText) findViewById(R.id.email)).getText().toString(); + + editor.putBoolean(PREFS_ALLOW_CONTACT, allowContact); + editor.putBoolean(PREFS_INCLUDE_URL, includeUrl); + editor.putBoolean(PREFS_SEND_REPORT, sendReport); + editor.putString(PREFS_CONTACT_EMAIL, contactEmail); + + // A slight performance improvement via async apply() vs. blocking on commit(). + editor.apply(); + } + + public void onCloseClick(View v) { // bound via crash_reporter.xml + backgroundSendReport(); + } + + public void onRestartClick(View v) { // bound via crash_reporter.xml + doRestart(); + backgroundSendReport(); + } + + private boolean readStringsFromFile(String filePath, Map<String, String> stringMap) { + try { + BufferedReader reader = new BufferedReader(new FileReader(filePath)); + return readStringsFromReader(reader, stringMap); + } catch (Exception e) { + Log.e(LOGTAG, "exception while reading strings: ", e); + return false; + } + } + + private boolean readStringsFromReader(BufferedReader reader, Map<String, String> stringMap) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + int equalsPos = -1; + if ((equalsPos = line.indexOf('=')) != -1) { + String key = line.substring(0, equalsPos); + String val = unescape(line.substring(equalsPos + 1)); + stringMap.put(key, val); + } + } + reader.close(); + return true; + } + + private String generateBoundary() { + // Generate some random numbers to fill out the boundary + int r0 = (int)(Integer.MAX_VALUE * Math.random()); + int r1 = (int)(Integer.MAX_VALUE * Math.random()); + return String.format("---------------------------%08X%08X", r0, r1); + } + + private void sendPart(OutputStream os, String boundary, String name, String data) { + try { + os.write(("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"" + name + "\"\r\n" + + "\r\n" + + data + "\r\n" + ).getBytes()); + } catch (Exception ex) { + Log.e(LOGTAG, "Exception when sending \"" + name + "\"", ex); + } + } + + private void sendFile(OutputStream os, String boundary, String name, File file) throws IOException { + os.write(("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"" + name + "\"; " + + "filename=\"" + file.getName() + "\"\r\n" + + "Content-Type: application/octet-stream\r\n" + + "\r\n" + ).getBytes()); + FileChannel fc = new FileInputStream(file).getChannel(); + fc.transferTo(0, fc.size(), Channels.newChannel(os)); + fc.close(); + } + + private String readLogcat() { + final String crashReporterProc = " " + android.os.Process.myPid() + ' '; + BufferedReader br = null; + try { + // get at most the last 400 lines of logcat + Process proc = Runtime.getRuntime().exec(new String[] { + "logcat", "-v", "threadtime", "-t", "400", "-d", "*:D" + }); + StringBuilder sb = new StringBuilder(); + br = new BufferedReader(new InputStreamReader(proc.getInputStream())); + for (String s = br.readLine(); s != null; s = br.readLine()) { + if (s.contains(crashReporterProc)) { + // Don't include logs from the crash reporter's process. + break; + } + sb.append(s).append('\n'); + } + return sb.toString(); + } catch (Exception e) { + return "Unable to get logcat: " + e.toString(); + } finally { + if (br != null) { + try { + br.close(); + } catch (Exception e) { + // ignore + } + } + } + } + + private void sendReport(File minidumpFile, Map<String, String> extras, File extrasFile) { + Log.i(LOGTAG, "sendReport: " + minidumpFile.getPath()); + final CheckBox includeURLCheckbox = (CheckBox) findViewById(R.id.include_url); + + String spec = extras.get(SERVER_URL_KEY); + if (spec == null) { + doFinish(); + return; + } + + Log.i(LOGTAG, "server url: " + spec); + try { + URL url = new URL(spec); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + conn.setRequestMethod("POST"); + String boundary = generateBoundary(); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + conn.setRequestProperty("Content-Encoding", "gzip"); + + OutputStream os = new GZIPOutputStream(conn.getOutputStream()); + for (String key : extras.keySet()) { + if (key.equals(PAGE_URL_KEY)) { + if (includeURLCheckbox.isChecked()) + sendPart(os, boundary, key, extras.get(key)); + } else if (!key.equals(SERVER_URL_KEY) && !key.equals(NOTES_KEY)) { + sendPart(os, boundary, key, extras.get(key)); + } + } + + // Add some extra information to notes so its displayed by + // crash-stats.mozilla.org. Remove this when bug 607942 is fixed. + StringBuilder sb = new StringBuilder(); + sb.append(extras.containsKey(NOTES_KEY) ? extras.get(NOTES_KEY) + "\n" : ""); + if (AppConstants.MOZ_MIN_CPU_VERSION < 7) { + sb.append("nothumb Build\n"); + } + sb.append(Build.MANUFACTURER).append(' ') + .append(Build.MODEL).append('\n') + .append(Build.FINGERPRINT); + sendPart(os, boundary, NOTES_KEY, sb.toString()); + + sendPart(os, boundary, "Min_ARM_Version", Integer.toString(AppConstants.MOZ_MIN_CPU_VERSION)); + sendPart(os, boundary, "Android_Manufacturer", Build.MANUFACTURER); + sendPart(os, boundary, "Android_Model", Build.MODEL); + sendPart(os, boundary, "Android_Board", Build.BOARD); + sendPart(os, boundary, "Android_Brand", Build.BRAND); + sendPart(os, boundary, "Android_Device", Build.DEVICE); + sendPart(os, boundary, "Android_Display", Build.DISPLAY); + sendPart(os, boundary, "Android_Fingerprint", Build.FINGERPRINT); + sendPart(os, boundary, "Android_APP_ABI", AppConstants.MOZ_APP_ABI); + sendPart(os, boundary, "Android_CPU_ABI", Build.CPU_ABI); + sendPart(os, boundary, "Android_MIN_SDK", Integer.toString(AppConstants.Versions.MIN_SDK_VERSION)); + sendPart(os, boundary, "Android_MAX_SDK", Integer.toString(AppConstants.Versions.MAX_SDK_VERSION)); + try { + sendPart(os, boundary, "Android_CPU_ABI2", Build.CPU_ABI2); + sendPart(os, boundary, "Android_Hardware", Build.HARDWARE); + } catch (Exception ex) { + Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex); + } + sendPart(os, boundary, "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")"); + if (Versions.feature16Plus && includeURLCheckbox.isChecked()) { + sendPart(os, boundary, "Android_Logcat", readLogcat()); + } + + String comment = ((EditText) findViewById(R.id.comment)).getText().toString(); + if (!TextUtils.isEmpty(comment)) { + sendPart(os, boundary, "Comments", comment); + } + + if (((CheckBox) findViewById(R.id.allow_contact)).isChecked()) { + String email = ((EditText) findViewById(R.id.email)).getText().toString(); + sendPart(os, boundary, "Email", email); + } + + sendPart(os, boundary, PASSED_MINI_DUMP_SUCCESS_KEY, mMinidumpSucceeded ? "True" : "False"); + sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile); + os.write(("\r\n--" + boundary + "--\r\n").getBytes()); + os.flush(); + os.close(); + BufferedReader br = new BufferedReader( + new InputStreamReader(conn.getInputStream())); + HashMap<String, String> responseMap = new HashMap<String, String>(); + readStringsFromReader(br, responseMap); + + if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) { + File submittedDir = new File(getFilesDir(), + SUBMITTED_SUFFIX); + submittedDir.mkdirs(); + minidumpFile.delete(); + extrasFile.delete(); + String crashid = responseMap.get("CrashID"); + File file = new File(submittedDir, crashid + ".txt"); + FileOutputStream fos = new FileOutputStream(file); + fos.write("Crash ID: ".getBytes()); + fos.write(crashid.getBytes()); + fos.close(); + } else { + Log.i(LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode()); + } + } catch (IOException e) { + Log.e(LOGTAG, "exception during send: ", e); + } + + doFinish(); + } + + private void doRestart() { + try { + String action = "android.intent.action.MAIN"; + Intent intent = new Intent(action); + intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, + AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + intent.putExtra("didRestart", true); + Log.i(LOGTAG, intent.toString()); + startActivity(intent); + } catch (Exception e) { + Log.e(LOGTAG, "error while trying to restart", e); + } + } + + private String unescape(String string) { + return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t"); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java b/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java new file mode 100644 index 000000000..98274b752 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java @@ -0,0 +1,89 @@ +/* -*- 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; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.widget.themed.ThemedEditText; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.View; + +public class CustomEditText extends ThemedEditText { + private OnKeyPreImeListener mOnKeyPreImeListener; + private OnSelectionChangedListener mOnSelectionChangedListener; + private OnWindowFocusChangeListener mOnWindowFocusChangeListener; + private int mHighlightColor; + + public CustomEditText(Context context, AttributeSet attrs) { + super(context, attrs); + setPrivateMode(false); // Initialize mHighlightColor. + } + + public interface OnKeyPreImeListener { + public boolean onKeyPreIme(View v, int keyCode, KeyEvent event); + } + + public void setOnKeyPreImeListener(OnKeyPreImeListener listener) { + mOnKeyPreImeListener = listener; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (mOnKeyPreImeListener != null) + return mOnKeyPreImeListener.onKeyPreIme(this, keyCode, event); + + return false; + } + + public interface OnSelectionChangedListener { + public void onSelectionChanged(int selStart, int selEnd); + } + + public void setOnSelectionChangedListener(OnSelectionChangedListener listener) { + mOnSelectionChangedListener = listener; + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (mOnSelectionChangedListener != null) + mOnSelectionChangedListener.onSelectionChanged(selStart, selEnd); + + super.onSelectionChanged(selStart, selEnd); + } + + public interface OnWindowFocusChangeListener { + public void onWindowFocusChanged(boolean hasFocus); + } + + public void setOnWindowFocusChangeListener(OnWindowFocusChangeListener listener) { + mOnWindowFocusChangeListener = listener; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (mOnWindowFocusChangeListener != null) + mOnWindowFocusChangeListener.onWindowFocusChanged(hasFocus); + } + + // Provide a getHighlightColor implementation for API level < 16. + @Override + public int getHighlightColor() { + return mHighlightColor; + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + + mHighlightColor = ContextCompat.getColor(getContext(), isPrivate + ? R.color.url_bar_text_highlight_pb : R.color.fennec_ui_orange); + // android:textColorHighlight cannot support a ColorStateList. + setHighlightColor(mHighlightColor); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java b/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java new file mode 100644 index 000000000..725c25d6e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java @@ -0,0 +1,133 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.preferences.GeckoPreferences; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Typeface; +import android.support.v4.app.NotificationCompat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.StyleSpan; + +public class DataReportingNotification { + + private static final String LOGTAG = "DataReportNotification"; + + public static final String ALERT_NAME_DATAREPORTING_NOTIFICATION = "datareporting-notification"; + + private static final String PREFS_POLICY_NOTIFIED_TIME = "datareporting.policy.dataSubmissionPolicyNotifiedTime"; + private static final String PREFS_POLICY_VERSION = "datareporting.policy.dataSubmissionPolicyVersion"; + private static final int DATA_REPORTING_VERSION = 2; + + public static void checkAndNotifyPolicy(Context context) { + SharedPreferences dataPrefs = GeckoSharedPrefs.forApp(context); + final int currentVersion = dataPrefs.getInt(PREFS_POLICY_VERSION, -1); + + if (currentVersion < 1) { + // This is a first run, so notify user about data policy. + notifyDataPolicy(context, dataPrefs); + + // If healthreport is enabled, set default preference value. + if (AppConstants.MOZ_SERVICES_HEALTHREPORT) { + SharedPreferences.Editor editor = dataPrefs.edit(); + editor.putBoolean(GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true); + editor.apply(); + } + return; + } + + if (currentVersion == 1) { + // Redisplay notification only for Beta because version 2 updates Beta policy and update version. + if (TextUtils.equals("beta", AppConstants.MOZ_UPDATE_CHANNEL)) { + notifyDataPolicy(context, dataPrefs); + } else { + // Silently update the version. + SharedPreferences.Editor editor = dataPrefs.edit(); + editor.putInt(PREFS_POLICY_VERSION, DATA_REPORTING_VERSION); + editor.apply(); + } + return; + } + + if (currentVersion >= DATA_REPORTING_VERSION) { + // Do nothing, we're at a current (or future) version. + return; + } + } + + /** + * Launch a notification of the data policy, and record notification time and version. + */ + public static void notifyDataPolicy(Context context, SharedPreferences sharedPrefs) { + boolean result = false; + try { + // Launch main App to launch Data choices when notification is clicked. + Intent prefIntent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS); + prefIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + + GeckoPreferences.setResourceToOpen(prefIntent, "preferences_privacy"); + prefIntent.putExtra(ALERT_NAME_DATAREPORTING_NOTIFICATION, true); + + PendingIntent contentIntent = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT); + final Resources resources = context.getResources(); + + // Create and send notification. + String notificationTitle = resources.getString(R.string.datareporting_notification_title); + String notificationSummary; + if (Versions.preJB) { + notificationSummary = resources.getString(R.string.datareporting_notification_action); + } else { + // Display partial version of Big Style notification for supporting devices. + notificationSummary = resources.getString(R.string.datareporting_notification_summary); + } + String notificationAction = resources.getString(R.string.datareporting_notification_action); + String notificationBigSummary = resources.getString(R.string.datareporting_notification_summary); + + // Make styled ticker text for display in notification bar. + String tickerString = resources.getString(R.string.datareporting_notification_ticker_text); + SpannableString tickerText = new SpannableString(tickerString); + // Bold the notification title of the ticker text, which is the same string as notificationTitle. + tickerText.setSpan(new StyleSpan(Typeface.BOLD), 0, notificationTitle.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + + Notification notification = new NotificationCompat.Builder(context) + .setContentTitle(notificationTitle) + .setContentText(notificationSummary) + .setSmallIcon(R.drawable.ic_status_logo) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(notificationBigSummary)) + .addAction(R.drawable.firefox_settings_alert, notificationAction, contentIntent) + .setTicker(tickerText) + .build(); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + int notificationID = ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode(); + notificationManager.notify(notificationID, notification); + + // Record version and notification time. + SharedPreferences.Editor editor = sharedPrefs.edit(); + long now = System.currentTimeMillis(); + editor.putLong(PREFS_POLICY_NOTIFIED_TIME, now); + editor.putInt(PREFS_POLICY_VERSION, DATA_REPORTING_VERSION); + editor.apply(); + result = true; + } finally { + // We want to track any errors, so record notification outcome. + Telemetry.sendUIEvent(TelemetryContract.Event.POLICY_NOTIFICATION_SUCCESS, result); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java b/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java new file mode 100644 index 000000000..44aaa14a0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java @@ -0,0 +1,52 @@ +/* -*- 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; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.util.Log; +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.InputOptionsUtils; + +/** + * Supports the DevTools WiFi debugging authentication flow by invoking a QR decoder. + */ +public class DevToolsAuthHelper { + + private static final String LOGTAG = "GeckoDevToolsAuthHelper"; + + public static void scan(Context context, final EventCallback callback) { + final Intent intent = InputOptionsUtils.createQRCodeReaderIntent(); + + intent.putExtra("PROMPT_MESSAGE", context.getString(R.string.devtools_auth_scan_header)); + + // Check ahead of time if an activity exists for the intent. This + // avoids a case where we get both an ActivityNotFoundException *and* + // an activity result when the activity is missing. + PackageManager pm = context.getPackageManager(); + if (pm.resolveActivity(intent, 0) == null) { + Log.w(LOGTAG, "PackageManager can't resolve the activity."); + callback.sendError("PackageManager can't resolve the activity."); + return; + } + + ActivityHandlerHelper.startIntent(intent, new ActivityResultHandler() { + @Override + public void onActivityResult(int resultCode, Intent intent) { + if (resultCode == Activity.RESULT_OK) { + String text = intent.getStringExtra("SCAN_RESULT"); + callback.sendSuccess(text); + } else { + callback.sendError(resultCode); + } + } + }); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java b/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java new file mode 100644 index 000000000..9aa3f96a4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java @@ -0,0 +1,361 @@ +/* -*- 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; + +import java.util.HashSet; + +import android.text.TextUtils; +import android.widget.PopupWindow; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONArray; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.AnchoredPopup; +import org.mozilla.gecko.widget.DoorHanger; + +import android.content.Context; +import android.util.Log; +import android.view.View; +import org.mozilla.gecko.widget.DoorhangerConfig; + +public class DoorHangerPopup extends AnchoredPopup + implements GeckoEventListener, + Tabs.OnTabsChangedListener, + PopupWindow.OnDismissListener, + DoorHanger.OnButtonClickListener { + private static final String LOGTAG = "GeckoDoorHangerPopup"; + + // Stores a set of all active DoorHanger notifications. A DoorHanger is + // uniquely identified by its tabId and value. + private final HashSet<DoorHanger> mDoorHangers; + + // Whether or not the doorhanger popup is disabled. + private boolean mDisabled; + + public DoorHangerPopup(Context context) { + super(context); + + mDoorHangers = new HashSet<DoorHanger>(); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "Doorhanger:Add", + "Doorhanger:Remove"); + Tabs.registerOnTabsChangedListener(this); + + setOnDismissListener(this); + } + + void destroy() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "Doorhanger:Add", + "Doorhanger:Remove"); + Tabs.unregisterOnTabsChangedListener(this); + } + + /** + * Temporarily disables the doorhanger popup. If the popup is disabled, + * it will not be shown to the user, but it will continue to process + * calls to add/remove doorhanger notifications. + */ + void disable() { + mDisabled = true; + updatePopup(); + } + + /** + * Re-enables the doorhanger popup. + */ + void enable() { + mDisabled = false; + updatePopup(); + } + + @Override + public void handleMessage(String event, JSONObject geckoObject) { + try { + if (event.equals("Doorhanger:Add")) { + final DoorhangerConfig config = makeConfigFromJSON(geckoObject); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + addDoorHanger(config); + } + }); + } else if (event.equals("Doorhanger:Remove")) { + final int tabId = geckoObject.getInt("tabID"); + final String value = geckoObject.getString("value"); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + DoorHanger doorHanger = getDoorHanger(tabId, value); + if (doorHanger == null) + return; + + removeDoorHanger(doorHanger); + updatePopup(); + } + }); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + private DoorhangerConfig makeConfigFromJSON(JSONObject json) throws JSONException { + final int tabId = json.getInt("tabID"); + final String id = json.getString("value"); + + final String typeString = json.optString("category"); + DoorHanger.Type doorhangerType = DoorHanger.Type.DEFAULT; + if (DoorHanger.Type.LOGIN.toString().equals(typeString)) { + doorhangerType = DoorHanger.Type.LOGIN; + } else if (DoorHanger.Type.GEOLOCATION.toString().equals(typeString)) { + doorhangerType = DoorHanger.Type.GEOLOCATION; + } else if (DoorHanger.Type.DESKTOPNOTIFICATION2.toString().equals(typeString)) { + doorhangerType = DoorHanger.Type.DESKTOPNOTIFICATION2; + } else if (DoorHanger.Type.WEBRTC.toString().equals(typeString)) { + doorhangerType = DoorHanger.Type.WEBRTC; + } else if (DoorHanger.Type.VIBRATION.toString().equals(typeString)) { + doorhangerType = DoorHanger.Type.VIBRATION; + } + + final DoorhangerConfig config = new DoorhangerConfig(tabId, id, doorhangerType, this); + + config.setMessage(json.getString("message")); + config.setOptions(json.getJSONObject("options")); + + final JSONArray buttonArray = json.getJSONArray("buttons"); + int numButtons = buttonArray.length(); + if (numButtons > 2) { + Log.e(LOGTAG, "Doorhanger can have a maximum of two buttons!"); + numButtons = 2; + } + + for (int i = 0; i < numButtons; i++) { + final JSONObject buttonJSON = buttonArray.getJSONObject(i); + final boolean isPositive = buttonJSON.optBoolean("positive", false); + config.setButton(buttonJSON.getString("label"), buttonJSON.getInt("callback"), isPositive); + } + + return config; + } + + // This callback is automatically executed on the UI thread. + @Override + public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) { + switch (msg) { + case CLOSED: + // Remove any doorhangers for a tab when it's closed (make + // a temporary set to avoid a ConcurrentModificationException) + removeTabDoorHangers(tab.getId(), true); + break; + + case LOCATION_CHANGE: + // Only remove doorhangers if the popup is hidden or if we're navigating to a new URL + if (!isShowing() || !data.equals(tab.getURL())) + removeTabDoorHangers(tab.getId(), false); + + // Update the popup if the location change was on the current tab + if (Tabs.getInstance().isSelectedTab(tab)) + updatePopup(); + break; + + case SELECTED: + // Always update the popup when a new tab is selected. This will cover cases + // where a different tab was closed, since we always need to select a new tab. + updatePopup(); + break; + } + } + + /** + * Adds a doorhanger. + * + * This method must be called on the UI thread. + */ + void addDoorHanger(DoorhangerConfig config) { + final int tabId = config.getTabId(); + // Don't add a doorhanger for a tab that doesn't exist + if (Tabs.getInstance().getTab(tabId) == null) { + return; + } + + // Replace the doorhanger if it already exists + DoorHanger oldDoorHanger = getDoorHanger(tabId, config.getId()); + if (oldDoorHanger != null) { + removeDoorHanger(oldDoorHanger); + } + + if (!mInflated) { + init(); + } + + final DoorHanger newDoorHanger = DoorHanger.Get(mContext, config); + + mDoorHangers.add(newDoorHanger); + mContent.addView(newDoorHanger); + + // Only update the popup if we're adding a notification to the selected tab + if (tabId == Tabs.getInstance().getSelectedTab().getId()) + updatePopup(); + } + + + /* + * DoorHanger.OnButtonClickListener implementation + */ + @Override + public void onButtonClick(JSONObject response, DoorHanger doorhanger) { + GeckoAppShell.notifyObservers("Doorhanger:Reply", response.toString()); + removeDoorHanger(doorhanger); + updatePopup(); + } + + /** + * Gets a doorhanger. + * + * This method must be called on the UI thread. + */ + DoorHanger getDoorHanger(int tabId, String value) { + for (DoorHanger dh : mDoorHangers) { + if (dh.getTabId() == tabId && dh.getIdentifier().equals(value)) + return dh; + } + + // If there's no doorhanger for the given tabId and value, return null + return null; + } + + /** + * Removes a doorhanger. + * + * This method must be called on the UI thread. + */ + void removeDoorHanger(final DoorHanger doorHanger) { + mDoorHangers.remove(doorHanger); + mContent.removeView(doorHanger); + } + + /** + * Removes doorhangers for a given tab. + * @param tabId identifier of the tab to remove doorhangers from + * @param forceRemove boolean for force-removing tabs. If true, all doorhangers associated + * with the tab specified are removed; if false, only remove the doorhangers + * that are not persistent, as specified by the doorhanger options. + * + * This method must be called on the UI thread. + */ + void removeTabDoorHangers(int tabId, boolean forceRemove) { + // Make a temporary set to avoid a ConcurrentModificationException + HashSet<DoorHanger> doorHangersToRemove = new HashSet<DoorHanger>(); + for (DoorHanger dh : mDoorHangers) { + // Only remove transient doorhangers for the given tab + if (dh.getTabId() == tabId + && (forceRemove || (!forceRemove && dh.shouldRemove(isShowing())))) { + doorHangersToRemove.add(dh); + } + } + + for (DoorHanger dh : doorHangersToRemove) { + removeDoorHanger(dh); + } + } + + /** + * Updates the popup state. + * + * This method must be called on the UI thread. + */ + void updatePopup() { + // Bail if the selected tab is null, if there are no active doorhangers, + // if we haven't inflated the layout yet (this can happen if updatePopup() + // is called before the runnable from addDoorHanger() runs), or if the + // doorhanger popup is temporarily disabled. + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab == null || mDoorHangers.size() == 0 || !mInflated || mDisabled) { + dismiss(); + return; + } + + // Show doorhangers for the selected tab + int tabId = tab.getId(); + boolean shouldShowPopup = false; + DoorHanger firstDoorhanger = null; + for (DoorHanger dh : mDoorHangers) { + if (dh.getTabId() == tabId) { + dh.setVisibility(View.VISIBLE); + shouldShowPopup = true; + if (firstDoorhanger == null) { + firstDoorhanger = dh; + } else { + dh.hideTitle(); + } + } else { + dh.setVisibility(View.GONE); + } + } + + // Dismiss the popup if there are no doorhangers to show for this tab + if (!shouldShowPopup) { + dismiss(); + return; + } + + showDividers(); + + final String baseDomain = tab.getBaseDomain(); + + if (TextUtils.isEmpty(baseDomain)) { + firstDoorhanger.hideTitle(); + } else { + firstDoorhanger.showTitle(tab.getFavicon(), baseDomain); + } + + if (isShowing()) { + show(); + return; + } + + setFocusable(true); + + show(); + } + + //Show all inter-DoorHanger dividers (ie. Dividers on all visible DoorHangers except the last one) + private void showDividers() { + int count = mContent.getChildCount(); + DoorHanger lastVisibleDoorHanger = null; + + for (int i = 0; i < count; i++) { + DoorHanger dh = (DoorHanger) mContent.getChildAt(i); + dh.showDivider(); + if (dh.getVisibility() == View.VISIBLE) { + lastVisibleDoorHanger = dh; + } + } + if (lastVisibleDoorHanger != null) { + lastVisibleDoorHanger.hideDivider(); + } + } + + @Override + public void onDismiss() { + final int tabId = Tabs.getInstance().getSelectedTab().getId(); + removeTabDoorHangers(tabId, true); + } + + @Override + public void dismiss() { + // If the popup is focusable while it is hidden, we run into crashes + // on pre-ICS devices when the popup gets focus before it is shown. + setFocusable(false); + super.dismiss(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java b/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java new file mode 100644 index 000000000..ff3ac6110 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java @@ -0,0 +1,235 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.EventCallback; + +import java.io.File; +import java.lang.IllegalArgumentException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import android.app.DownloadManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; +import android.net.Uri; +import android.os.Environment; +import android.text.TextUtils; +import android.util.Log; + +public class DownloadsIntegration implements NativeEventListener +{ + private static final String LOGTAG = "GeckoDownloadsIntegration"; + + private static final List<String> UNKNOWN_MIME_TYPES; + static { + final ArrayList<String> tempTypes = new ArrayList<>(3); + tempTypes.add("unknown/unknown"); // This will be used as a default mime type for unknown files + tempTypes.add("application/unknown"); + tempTypes.add("application/octet-stream"); // Github uses this for APK files + UNKNOWN_MIME_TYPES = Collections.unmodifiableList(tempTypes); + } + + private static final String DOWNLOAD_REMOVE = "Download:Remove"; + + private DownloadsIntegration() { + EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this, DOWNLOAD_REMOVE); + } + + private static DownloadsIntegration sInstance; + + private static class Download { + final File file; + final long id; + + final private static int UNKNOWN_ID = -1; + + public Download(final String path) { + this(path, UNKNOWN_ID); + } + + public Download(final String path, final long id) { + file = new File(path); + this.id = id; + } + + public static Download fromJSON(final NativeJSObject obj) { + final String path = obj.getString("path"); + return new Download(path); + } + + public static Download fromCursor(final Cursor c) { + final String path = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME)); + final long id = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); + return new Download(path, id); + } + + public boolean equals(final Download download) { + return file.equals(download.file); + } + } + + public static void init() { + if (sInstance == null) { + sInstance = new DownloadsIntegration(); + } + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, + final EventCallback callback) { + if (DOWNLOAD_REMOVE.equals(event)) { + final Download d = Download.fromJSON(message); + removeDownload(d); + } + } + + private static boolean useSystemDownloadManager() { + if (!AppConstants.ANDROID_DOWNLOADS_INTEGRATION) { + return false; + } + + int state = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT; + try { + state = GeckoAppShell.getContext().getPackageManager().getApplicationEnabledSetting("com.android.providers.downloads"); + } catch (IllegalArgumentException e) { + // Download Manager package does not exist + return false; + } + + return (PackageManager.COMPONENT_ENABLED_STATE_ENABLED == state || + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT == state); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getTemporaryDownloadDirectory() { + Context context = GeckoAppShell.getApplicationContext(); + + if (Permissions.has(context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + // We do have the STORAGE permission, so we can save the file directly to the public + // downloads directory. + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .getAbsolutePath(); + } else { + // Without the permission we are going to start to download the file to the cache + // directory. Later in the process we will ask for the permission and the download + // process will move the file to the actual downloads directory. If we do not get the + // permission then the download will be cancelled. + return context.getCacheDir().getAbsolutePath(); + } + } + + + @WrapForJNI(calledFrom = "gecko") + public static void scanMedia(final String aFile, String aMimeType) { + String mimeType = aMimeType; + if (UNKNOWN_MIME_TYPES.contains(mimeType)) { + // If this is a generic undefined mimetype, erase it so that we can try to determine + // one from the file extension below. + mimeType = ""; + } + + // If the platform didn't give us a mimetype, try to guess one from the filename + if (TextUtils.isEmpty(mimeType)) { + final int extPosition = aFile.lastIndexOf("."); + if (extPosition > 0 && extPosition < aFile.length() - 1) { + mimeType = GeckoAppShell.getMimeTypeFromExtension(aFile.substring(extPosition + 1)); + } + } + + // addCompletedDownload will throw if it received any null parameters. Use aMimeType or a default + // if we still don't have one. + if (TextUtils.isEmpty(mimeType)) { + if (TextUtils.isEmpty(aMimeType)) { + mimeType = UNKNOWN_MIME_TYPES.get(0); + } else { + mimeType = aMimeType; + } + } + + if (useSystemDownloadManager()) { + final File f = new File(aFile); + final DownloadManager dm = (DownloadManager) GeckoAppShell.getContext().getSystemService(Context.DOWNLOAD_SERVICE); + dm.addCompletedDownload(f.getName(), + f.getName(), + true, // Media scanner should scan this + mimeType, + f.getAbsolutePath(), + Math.max(1, f.length()), // Some versions of Android require downloads to be at least length 1 + false); // Don't show a notification. + } else { + final Context context = GeckoAppShell.getContext(); + final GeckoMediaScannerClient client = new GeckoMediaScannerClient(context, aFile, mimeType); + client.connect(); + } + } + + public static void removeDownload(final Download download) { + if (!useSystemDownloadManager()) { + return; + } + + final DownloadManager dm = (DownloadManager) GeckoAppShell.getContext().getSystemService(Context.DOWNLOAD_SERVICE); + + Cursor c = null; + try { + c = dm.query((new DownloadManager.Query()).setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)); + if (c == null || !c.moveToFirst()) { + return; + } + + do { + final Download d = Download.fromCursor(c); + // Try hard as we can to verify this download is the one we think it is + if (download.equals(d)) { + dm.remove(d.id); + } + } while (c.moveToNext()); + } finally { + if (c != null) { + c.close(); + } + } + } + + private static final class GeckoMediaScannerClient implements MediaScannerConnectionClient { + private final String mFile; + private final String mMimeType; + private MediaScannerConnection mScanner; + + public GeckoMediaScannerClient(Context context, String file, String mimeType) { + mFile = file; + mMimeType = mimeType; + mScanner = new MediaScannerConnection(context, this); + } + + public void connect() { + mScanner.connect(); + } + + @Override + public void onMediaScannerConnected() { + mScanner.scanFile(mFile, mMimeType); + } + + @Override + public void onScanCompleted(String path, Uri uri) { + if (path.equals(mFile)) { + mScanner.disconnect(); + mScanner = null; + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java b/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java new file mode 100644 index 000000000..28f542d5c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java @@ -0,0 +1,218 @@ +package org.mozilla.gecko; + +import org.mozilla.gecko.PrefsHelper.PrefHandlerBase; +import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.util.ThreadUtils; + +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +public class DynamicToolbar { + private static final String LOGTAG = "DynamicToolbar"; + + private static final String STATE_ENABLED = "dynamic_toolbar"; + private static final String CHROME_PREF = "browser.chrome.dynamictoolbar"; + + // DynamicToolbar is enabled iff prefEnabled is true *and* accessibilityEnabled is false, + // so it is disabled by default on startup. We do not enable it until we explicitly get + // the pref from Gecko telling us to turn it on. + private volatile boolean prefEnabled; + private boolean accessibilityEnabled; + // On some device we have to force-disable the dynamic toolbar because of + // bugs in the Android code. See bug 1231554. + private final boolean forceDisabled; + + private final PrefsHelper.PrefHandler prefObserver; + private LayerView layerView; + private OnEnabledChangedListener enabledChangedListener; + private boolean temporarilyVisible; + + public enum VisibilityTransition { + IMMEDIATE, + ANIMATE + } + + /** + * Listener for changes to the dynamic toolbar's enabled state. + */ + public interface OnEnabledChangedListener { + /** + * This callback is executed on the UI thread. + */ + public void onEnabledChanged(boolean enabled); + } + + public DynamicToolbar() { + // Listen to the dynamic toolbar pref + prefObserver = new PrefHandler(); + PrefsHelper.addObserver(new String[] { CHROME_PREF }, prefObserver); + forceDisabled = isForceDisabled(); + if (forceDisabled) { + Log.i(LOGTAG, "Force-disabling dynamic toolbar for " + Build.MODEL + " (" + Build.DEVICE + "/" + Build.PRODUCT + ")"); + } + } + + public static boolean isForceDisabled() { + // Force-disable dynamic toolbar on the variants of the Galaxy Note 10.1 + // and Note 8.0 running Android 4.1.2. (Bug 1231554). This includes + // the following model numbers: + // GT-N8000, GT-N8005, GT-N8010, GT-N8013, GT-N8020 + // GT-N5100, GT-N5110, GT-N5120 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN + && (Build.MODEL.startsWith("GT-N80") || + Build.MODEL.startsWith("GT-N51"))) { + return true; + } + // Also disable variants of the Galaxy Note 4 on Android 5.0.1 (Bug 1301593) + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP + && (Build.MODEL.startsWith("SM-N910"))) { + return true; + } + return false; + } + + public void destroy() { + PrefsHelper.removeObserver(prefObserver); + } + + public void setLayerView(LayerView layerView) { + ThreadUtils.assertOnUiThread(); + + this.layerView = layerView; + } + + public void setEnabledChangedListener(OnEnabledChangedListener listener) { + ThreadUtils.assertOnUiThread(); + + enabledChangedListener = listener; + } + + public void onSaveInstanceState(Bundle outState) { + ThreadUtils.assertOnUiThread(); + + outState.putBoolean(STATE_ENABLED, prefEnabled); + } + + public void onRestoreInstanceState(Bundle savedInstanceState) { + ThreadUtils.assertOnUiThread(); + + if (savedInstanceState != null) { + prefEnabled = savedInstanceState.getBoolean(STATE_ENABLED); + } + } + + public boolean isEnabled() { + ThreadUtils.assertOnUiThread(); + + if (forceDisabled) { + return false; + } + + return prefEnabled && !accessibilityEnabled; + } + + public void setAccessibilityEnabled(boolean enabled) { + ThreadUtils.assertOnUiThread(); + + if (accessibilityEnabled == enabled) { + return; + } + + // Disable the dynamic toolbar when accessibility features are enabled, + // and re-read the preference when they're disabled. + accessibilityEnabled = enabled; + if (prefEnabled) { + triggerEnabledListener(); + } + } + + public void setVisible(boolean visible, VisibilityTransition transition) { + ThreadUtils.assertOnUiThread(); + + if (layerView == null) { + return; + } + + // Don't hide the ActionBar/Toolbar, if it's pinned open by TextSelection. + if (visible == false && + layerView.getDynamicToolbarAnimator().isPinnedBy(PinReason.ACTION_MODE)) { + return; + } + + final boolean isImmediate = transition == VisibilityTransition.IMMEDIATE; + if (visible) { + layerView.getDynamicToolbarAnimator().showToolbar(isImmediate); + } else { + layerView.getDynamicToolbarAnimator().hideToolbar(isImmediate); + } + } + + public void setTemporarilyVisible(boolean visible, VisibilityTransition transition) { + ThreadUtils.assertOnUiThread(); + + if (layerView == null) { + return; + } + + if (visible == temporarilyVisible) { + // nothing to do + return; + } + + temporarilyVisible = visible; + final boolean isImmediate = transition == VisibilityTransition.IMMEDIATE; + if (visible) { + layerView.getDynamicToolbarAnimator().showToolbar(isImmediate); + } else { + layerView.getDynamicToolbarAnimator().hideToolbar(isImmediate); + } + } + + public void persistTemporaryVisibility() { + ThreadUtils.assertOnUiThread(); + + if (temporarilyVisible) { + temporarilyVisible = false; + setVisible(true, VisibilityTransition.IMMEDIATE); + } + } + + public void setPinned(boolean pinned, PinReason reason) { + ThreadUtils.assertOnUiThread(); + if (layerView == null) { + return; + } + + layerView.getDynamicToolbarAnimator().setPinned(pinned, reason); + } + + private void triggerEnabledListener() { + if (enabledChangedListener != null) { + enabledChangedListener.onEnabledChanged(isEnabled()); + } + } + + private class PrefHandler extends PrefHandlerBase { + @Override + public void prefValue(String pref, boolean value) { + if (value == prefEnabled) { + return; + } + + prefEnabled = value; + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // If accessibility is enabled, the dynamic toolbar is + // forced to be off. + if (!accessibilityEnabled) { + triggerEnabledListener(); + } + } + }); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java b/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java new file mode 100644 index 000000000..38c38a9eb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java @@ -0,0 +1,252 @@ +/* -*- 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; + +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.database.Cursor; +import android.support.design.widget.Snackbar; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; + +/** + * A dialog that allows editing a bookmarks url, title, or keywords + * <p> + * Invoked by calling one of the {@link org.mozilla.gecko.EditBookmarkDialog#show(String)} + * methods. + */ +public class EditBookmarkDialog { + private final Context mContext; + + public EditBookmarkDialog(Context context) { + mContext = context; + } + + /** + * A private struct to make it easier to pass bookmark data across threads + */ + private class Bookmark { + final int id; + final String title; + final String url; + final String keyword; + + public Bookmark(int aId, String aTitle, String aUrl, String aKeyword) { + id = aId; + title = aTitle; + url = aUrl; + keyword = aKeyword; + } + } + + /** + * This text watcher to enable or disable the OK button if the dialog contains + * valid information. This class is overridden to do data checking on different fields. + * By itself, it always enables the button. + * + * Callers can also assign a paired partner to the TextWatcher, and callers will check + * that both are enabled before enabling the ok button. + */ + private class EditBookmarkTextWatcher implements TextWatcher { + // A stored reference to the dialog containing the text field being watched + protected AlertDialog mDialog; + + // A stored text watcher to do the real verification of a field + protected EditBookmarkTextWatcher mPairedTextWatcher; + + // Whether or not the ok button should be enabled. + protected boolean mEnabled = true; + + public EditBookmarkTextWatcher(AlertDialog aDialog) { + mDialog = aDialog; + } + + public void setPairedTextWatcher(EditBookmarkTextWatcher aTextWatcher) { + mPairedTextWatcher = aTextWatcher; + } + + public boolean isEnabled() { + return mEnabled; + } + + // Textwatcher interface + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Disable if the we're disabled or the paired partner is disabled + boolean enabled = mEnabled && (mPairedTextWatcher == null || mPairedTextWatcher.isEnabled()); + mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(enabled); + } + + @Override + public void afterTextChanged(Editable s) {} + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + } + + /** + * A version of the EditBookmarkTextWatcher for the url field of the dialog. + * Only checks if the field is empty or not. + */ + private class LocationTextWatcher extends EditBookmarkTextWatcher { + public LocationTextWatcher(AlertDialog aDialog) { + super(aDialog); + } + + // Disables the ok button if the location field is empty. + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + mEnabled = (s.toString().trim().length() > 0); + super.onTextChanged(s, start, before, count); + } + } + + /** + * A version of the EditBookmarkTextWatcher for the keyword field of the dialog. + * Checks if the field has any (non leading or trailing) spaces. + */ + private class KeywordTextWatcher extends EditBookmarkTextWatcher { + public KeywordTextWatcher(AlertDialog aDialog) { + super(aDialog); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Disable if the keyword contains spaces + mEnabled = (s.toString().trim().indexOf(' ') == -1); + super.onTextChanged(s, start, before, count); + } + } + + /** + * Show the Edit bookmark dialog for a particular url. If the url is bookmarked multiple times + * this will just edit the first instance it finds. + * + * @param url The url of the bookmark to edit. The dialog will look up other information like the id, + * current title, or keywords associated with this url. If the url isn't bookmarked, the + * dialog will fail silently. If the url is bookmarked multiple times, this will only show + * information about the first it finds. + */ + public void show(final String url) { + final ContentResolver cr = mContext.getContentResolver(); + final BrowserDB db = BrowserDB.from(mContext); + (new UIAsyncTask.WithoutParams<Bookmark>(ThreadUtils.getBackgroundHandler()) { + @Override + public Bookmark doInBackground() { + final Cursor cursor = db.getBookmarkForUrl(cr, url); + if (cursor == null) { + return null; + } + + Bookmark bookmark = null; + try { + cursor.moveToFirst(); + bookmark = new Bookmark(cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.TITLE)), + cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL)), + cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.KEYWORD))); + } finally { + cursor.close(); + } + return bookmark; + } + + @Override + public void onPostExecute(Bookmark bookmark) { + if (bookmark == null) { + return; + } + + show(bookmark.id, bookmark.title, bookmark.url, bookmark.keyword); + } + }).execute(); + } + + /** + * Show the Edit bookmark dialog for a set of data. This will show the dialog whether + * a bookmark with this url exists or not, but the results will NOT be saved if the id + * is not a valid bookmark id. + * + * @param id The id of the bookmark to change. If there is no bookmark with this ID, the dialog + * will fail silently. + * @param title The initial title to show in the dialog + * @param url The initial url to show in the dialog + * @param keyword The initial keyword to show in the dialog + */ + public void show(final int id, final String title, final String url, final String keyword) { + final Context context = mContext; + + AlertDialog.Builder editPrompt = new AlertDialog.Builder(context); + final View editView = LayoutInflater.from(context).inflate(R.layout.bookmark_edit, null); + editPrompt.setTitle(R.string.bookmark_edit_title); + editPrompt.setView(editView); + + final EditText nameText = ((EditText) editView.findViewById(R.id.edit_bookmark_name)); + final EditText locationText = ((EditText) editView.findViewById(R.id.edit_bookmark_location)); + final EditText keywordText = ((EditText) editView.findViewById(R.id.edit_bookmark_keyword)); + nameText.setText(title); + locationText.setText(url); + keywordText.setText(keyword); + + final BrowserDB db = BrowserDB.from(mContext); + editPrompt.setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) { + @Override + public Void doInBackground() { + String newUrl = locationText.getText().toString().trim(); + String newKeyword = keywordText.getText().toString().trim(); + + db.updateBookmark(context.getContentResolver(), id, newUrl, nameText.getText().toString(), newKeyword); + return null; + } + + @Override + public void onPostExecute(Void result) { + SnackbarBuilder.builder((Activity) context) + .message(R.string.bookmark_updated) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + }).execute(); + } + }); + + editPrompt.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + // do nothing + } + }); + + final AlertDialog dialog = editPrompt.create(); + + // Create our TextWatchers + LocationTextWatcher locationTextWatcher = new LocationTextWatcher(dialog); + KeywordTextWatcher keywordTextWatcher = new KeywordTextWatcher(dialog); + + // Cross reference the TextWatchers + locationTextWatcher.setPairedTextWatcher(keywordTextWatcher); + keywordTextWatcher.setPairedTextWatcher(locationTextWatcher); + + // Add the TextWatcher Listeners + locationText.addTextChangedListener(locationTextWatcher); + keywordText.addTextChangedListener(keywordTextWatcher); + + dialog.show(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Experiments.java b/mobile/android/base/java/org/mozilla/gecko/Experiments.java new file mode 100644 index 000000000..e71bb4c52 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Experiments.java @@ -0,0 +1,119 @@ +/* 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; + +import android.content.Context; + +import android.util.Log; +import android.text.TextUtils; + +import com.keepsafe.switchboard.Preferences; +import com.keepsafe.switchboard.SwitchBoard; + +import java.util.LinkedList; +import java.util.List; + +/** + * This class should reflect the experiment names found in the Switchboard experiments config here: + * https://github.com/mozilla-services/switchboard-experiments + */ +public class Experiments { + private static final String LOGTAG = "GeckoExperiments"; + + // Show a system notification linking to a "What's New" page on app update. + public static final String WHATSNEW_NOTIFICATION = "whatsnew-notification"; + + // Subscribe to known, bookmarked sites and show a notification if new content is available. + public static final String CONTENT_NOTIFICATIONS_12HRS = "content-notifications-12hrs"; + public static final String CONTENT_NOTIFICATIONS_8AM = "content-notifications-8am"; + public static final String CONTENT_NOTIFICATIONS_5PM = "content-notifications-5pm"; + + // Onboarding: "Features and Story". These experiments are determined + // on the client, they are not part of the server config. + public static final String ONBOARDING3_A = "onboarding3-a"; // Control: No first run + public static final String ONBOARDING3_B = "onboarding3-b"; // 4 static Feature + 1 dynamic slides + public static final String ONBOARDING3_C = "onboarding3-c"; // Differentiating features slides + + // Synchronizing the catalog of downloadable content from Kinto + public static final String DOWNLOAD_CONTENT_CATALOG_SYNC = "download-content-catalog-sync"; + + // Promotion for "Add to homescreen" + public static final String PROMOTE_ADD_TO_HOMESCREEN = "promote-add-to-homescreen"; + + public static final String PREF_ONBOARDING_VERSION = "onboarding_version"; + + // Promotion to bookmark reader-view items after entering reader view three times (Bug 1247689) + public static final String TRIPLE_READERVIEW_BOOKMARK_PROMPT = "triple-readerview-bookmark-prompt"; + + // Only show origin in URL bar instead of full URL (Bug 1236431) + public static final String URLBAR_SHOW_ORIGIN_ONLY = "urlbar-show-origin-only"; + + // Show name of organization (EV cert) instead of full URL in URL bar (Bug 1249594). + public static final String URLBAR_SHOW_EV_CERT_OWNER = "urlbar-show-ev-cert-owner"; + + // Play HLS videos in a VideoView (Bug 1313391) + public static final String HLS_VIDEO_PLAYBACK = "hls-video-playback"; + + // Make new activity stream panel available (to replace top sites) (Bug 1313316) + public static final String ACTIVITY_STREAM = "activity-stream"; + + /** + * Returns if a user is in certain local experiment. + * @param experiment Name of experiment to look up + * @return returns value for experiment or false if experiment does not exist. + */ + public static boolean isInExperimentLocal(Context context, String experiment) { + if (SwitchBoard.isInBucket(context, 0, 20)) { + return Experiments.ONBOARDING3_A.equals(experiment); + } else if (SwitchBoard.isInBucket(context, 20, 60)) { + return Experiments.ONBOARDING3_B.equals(experiment); + } else if (SwitchBoard.isInBucket(context, 60, 100)) { + return Experiments.ONBOARDING3_C.equals(experiment); + } else { + return false; + } + } + + /** + * Returns list of all active experiments, remote and local. + * @return List of experiment names Strings + */ + public static List<String> getActiveExperiments(Context c) { + final List<String> experiments = new LinkedList<>(); + experiments.addAll(SwitchBoard.getActiveExperiments(c)); + + // Add onboarding version. + final String onboardingExperiment = GeckoSharedPrefs.forProfile(c).getString(Experiments.PREF_ONBOARDING_VERSION, null); + if (!TextUtils.isEmpty(onboardingExperiment)) { + experiments.add(onboardingExperiment); + } + + return experiments; + } + + /** + * Sets an override to force an experiment to be enabled or disabled. This value + * will be read and used before reading the switchboard server configuration. + * + * @param c Context + * @param experimentName Experiment name + * @param isEnabled Whether or not the experiment should be enabled + */ + public static void setOverride(Context c, String experimentName, boolean isEnabled) { + Log.d(LOGTAG, "setOverride: " + experimentName + " = " + isEnabled); + Preferences.setOverrideValue(c, experimentName, isEnabled); + } + + /** + * Clears the override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + */ + public static void clearOverride(Context c, String experimentName) { + Log.d(LOGTAG, "clearOverride: " + experimentName); + Preferences.clearOverrideValue(c, experimentName); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/FilePicker.java b/mobile/android/base/java/org/mozilla/gecko/FilePicker.java new file mode 100644 index 000000000..8ac5428a4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/FilePicker.java @@ -0,0 +1,227 @@ +/* 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; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.GeckoEventListener; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Environment; +import android.os.Parcelable; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +public class FilePicker implements GeckoEventListener { + private static final String LOGTAG = "GeckoFilePicker"; + private static FilePicker sFilePicker; + private final Context context; + + public interface ResultHandler { + public void gotFile(String filename); + } + + public static void init(Context context) { + if (sFilePicker == null) { + sFilePicker = new FilePicker(context.getApplicationContext()); + } + } + + protected FilePicker(Context context) { + this.context = context; + EventDispatcher.getInstance().registerGeckoThreadListener(this, "FilePicker:Show"); + } + + @Override + public void handleMessage(String event, final JSONObject message) { + if (event.equals("FilePicker:Show")) { + String mimeType = "*/*"; + final String mode = message.optString("mode"); + final int tabId = message.optInt("tabId", -1); + final String title = message.optString("title"); + + if ("mimeType".equals(mode)) + mimeType = message.optString("mimeType"); + else if ("extension".equals(mode)) + mimeType = GeckoAppShell.getMimeTypeFromExtensions(message.optString("extensions")); + + showFilePickerAsync(title, mimeType, new ResultHandler() { + @Override + public void gotFile(String filename) { + try { + message.put("file", filename); + } catch (JSONException ex) { + Log.i(LOGTAG, "Can't add filename to message " + filename); + } + + + GeckoAppShell.notifyObservers("FilePicker:Result", message.toString()); + } + }, tabId); + } + } + + private void addActivities(Intent intent, HashMap<String, Intent> intents, HashMap<String, Intent> filters) { + PackageManager pm = context.getPackageManager(); + List<ResolveInfo> lri = pm.queryIntentActivities(intent, 0); + for (ResolveInfo ri : lri) { + ComponentName cn = new ComponentName(ri.activityInfo.applicationInfo.packageName, ri.activityInfo.name); + if (filters != null && !filters.containsKey(cn.toString())) { + Intent rintent = new Intent(intent); + rintent.setComponent(cn); + intents.put(cn.toString(), rintent); + } + } + } + + private Intent getIntent(String mimeType) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType(mimeType); + intent.addCategory(Intent.CATEGORY_OPENABLE); + return intent; + } + + private List<Intent> getIntentsForFilePicker(final String mimeType, + final FilePickerResultHandler fileHandler) { + // The base intent to use for the file picker. Even if this is an implicit intent, Android will + // still show a list of Activities that match this action/type. + Intent baseIntent; + // A HashMap of Activities the base intent will show in the chooser. This is used + // to filter activities from other intents so that we don't show duplicates. + HashMap<String, Intent> baseIntents = new HashMap<String, Intent>(); + // A list of other activities to shwo in the picker (and the intents to launch them). + HashMap<String, Intent> intents = new HashMap<String, Intent> (); + + if ("audio/*".equals(mimeType)) { + // For audio the only intent is the mimetype + baseIntent = getIntent(mimeType); + addActivities(baseIntent, baseIntents, null); + } else if ("image/*".equals(mimeType)) { + // For images the base is a capture intent + baseIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + baseIntent.putExtra(MediaStore.EXTRA_OUTPUT, + Uri.fromFile(new File(Environment.getExternalStorageDirectory(), + fileHandler.generateImageName()))); + addActivities(baseIntent, baseIntents, null); + + // We also add the mimetype intent + addActivities(getIntent(mimeType), intents, baseIntents); + } else if ("video/*".equals(mimeType)) { + // For videos the base is a capture intent + baseIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); + addActivities(baseIntent, baseIntents, null); + + // We also add the mimetype intent + addActivities(getIntent(mimeType), intents, baseIntents); + } else { + baseIntent = getIntent("*/*"); + addActivities(baseIntent, baseIntents, null); + + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + intent.putExtra(MediaStore.EXTRA_OUTPUT, + Uri.fromFile(new File(Environment.getExternalStorageDirectory(), + fileHandler.generateImageName()))); + addActivities(intent, intents, baseIntents); + intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); + addActivities(intent, intents, baseIntents); + } + + // If we didn't find any activities, we fall back to the */* mimetype intent + if (baseIntents.size() == 0 && intents.size() == 0) { + intents.clear(); + + baseIntent = getIntent("*/*"); + addActivities(baseIntent, baseIntents, null); + } + + ArrayList<Intent> vals = new ArrayList<Intent>(intents.values()); + vals.add(0, baseIntent); + return vals; + } + + private String getFilePickerTitle(String mimeType) { + if (mimeType.equals("audio/*")) { + return context.getString(R.string.filepicker_audio_title); + } else if (mimeType.equals("image/*")) { + return context.getString(R.string.filepicker_image_title); + } else if (mimeType.equals("video/*")) { + return context.getString(R.string.filepicker_video_title); + } else { + return context.getString(R.string.filepicker_title); + } + } + + private interface IntentHandler { + public void gotIntent(Intent intent); + } + + /* Gets an intent that can open a particular mimetype. Will show a prompt with a list + * of Activities that can handle the mietype. Asynchronously calls the handler when + * one of the intents is selected. If the caller passes in null for the handler, will still + * prompt for the activity, but will throw away the result. + */ + private void getFilePickerIntentAsync(String title, + final String mimeType, + final FilePickerResultHandler fileHandler, + final IntentHandler handler) { + List<Intent> intents = getIntentsForFilePicker(mimeType, fileHandler); + + if (intents.size() == 0) { + Log.i(LOGTAG, "no activities for the file picker!"); + handler.gotIntent(null); + return; + } + + Intent base = intents.remove(0); + + if (intents.size() == 0) { + handler.gotIntent(base); + return; + } + + if (TextUtils.isEmpty(title)) { + title = getFilePickerTitle(mimeType); + } + Intent chooser = Intent.createChooser(base, title); + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[intents.size()])); + handler.gotIntent(chooser); + } + + /* Allows the user to pick an activity to load files from using a list prompt. Then opens the activity and + * sends the file returned to the passed in handler. If a null handler is passed in, will still + * pick and launch the file picker, but will throw away the result. + */ + protected void showFilePickerAsync(final String title, final String mimeType, final ResultHandler handler, final int tabId) { + final FilePickerResultHandler fileHandler = new FilePickerResultHandler(handler, context, tabId); + getFilePickerIntentAsync(title, mimeType, fileHandler, new IntentHandler() { + @Override + public void gotIntent(Intent intent) { + if (handler == null) { + return; + } + + if (intent == null) { + handler.gotFile(""); + return; + } + + ActivityHandlerHelper.startIntent(intent, fileHandler); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java b/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java new file mode 100644 index 000000000..7629ea546 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java @@ -0,0 +1,282 @@ +/* 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; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Process; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.support.v4.app.FragmentActivity; +import android.support.v4.app.LoaderManager; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.text.TextUtils; +import android.text.format.Time; +import android.util.Log; + +class FilePickerResultHandler implements ActivityResultHandler { + private static final String LOGTAG = "GeckoFilePickerResultHandler"; + private static final String UPLOADS_DIR = "uploads"; + + private final FilePicker.ResultHandler handler; + private final int tabId; + private final File cacheDir; + + // this code is really hacky and doesn't belong anywhere so I'm putting it here for now + // until I can come up with a better solution. + private String mImageName = ""; + + /* Use this constructor to asynchronously listen for results */ + public FilePickerResultHandler(final FilePicker.ResultHandler handler, final Context context, final int tabId) { + this.tabId = tabId; + this.cacheDir = new File(context.getCacheDir(), UPLOADS_DIR); + this.handler = handler; + } + + void sendResult(String res) { + if (handler != null) { + handler.gotFile(res); + } + } + + @Override + public void onActivityResult(int resultCode, Intent intent) { + if (resultCode != Activity.RESULT_OK) { + sendResult(""); + return; + } + + // Camera results won't return an Intent. Use the file name we passed to the original intent. + // In Android M, camera results return an empty Intent rather than null. + if (intent == null || (intent.getAction() == null && intent.getData() == null)) { + if (mImageName != null) { + File file = new File(Environment.getExternalStorageDirectory(), mImageName); + sendResult(file.getAbsolutePath()); + } else { + sendResult(""); + } + return; + } + + Uri uri = intent.getData(); + if (uri == null) { + sendResult(""); + return; + } + + // Some file pickers may return a file uri + if ("file".equals(uri.getScheme())) { + String path = uri.getPath(); + sendResult(path == null ? "" : path); + return; + } + + final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity(); + final LoaderManager lm = fa.getSupportLoaderManager(); + + // Finally, Video pickers and some file pickers may return a content provider. + final ContentResolver cr = fa.getContentResolver(); + final Cursor cursor = cr.query(uri, new String[] { MediaStore.Video.Media.DATA }, null, null, null); + if (cursor != null) { + try { + // Try a query to make sure the expected columns exist + int index = cursor.getColumnIndex(MediaStore.Video.Media.DATA); + if (index >= 0) { + lm.initLoader(intent.hashCode(), null, new VideoLoaderCallbacks(uri)); + return; + } + } catch (Exception ex) { + // We'll try a different loader below + } finally { + cursor.close(); + } + } + + lm.initLoader(uri.hashCode(), null, new FileLoaderCallbacks(uri, cacheDir, tabId)); + } + + public String generateImageName() { + Time now = new Time(); + now.setToNow(); + mImageName = now.format("%Y-%m-%d %H.%M.%S") + ".jpg"; + return mImageName; + } + + private class VideoLoaderCallbacks implements LoaderCallbacks<Cursor> { + final private Uri uri; + public VideoLoaderCallbacks(Uri uri) { + this.uri = uri; + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity(); + return new CursorLoader(fa, + uri, + new String[] { MediaStore.Video.Media.DATA }, + null, // selection + null, // selectionArgs + null); // sortOrder + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { + if (cursor.moveToFirst()) { + String res = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)); + + // Some pickers (the KitKat Documents one for instance) won't return a temporary file here. + // Fall back to the normal FileLoader if we didn't find anything. + if (TextUtils.isEmpty(res)) { + tryFileLoaderCallback(); + return; + } + + sendResult(res); + } else { + tryFileLoaderCallback(); + } + } + + private void tryFileLoaderCallback() { + final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity(); + final LoaderManager lm = fa.getSupportLoaderManager(); + lm.initLoader(uri.hashCode(), null, new FileLoaderCallbacks(uri, cacheDir, tabId)); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { } + } + + /** + * This class's only dependency on FilePickerResultHandler is sendResult. + */ + private class FileLoaderCallbacks implements LoaderCallbacks<Cursor>, + Tabs.OnTabsChangedListener { + private final Uri uri; + private final File cacheDir; + private final int tabId; + String tempFile; + + public FileLoaderCallbacks(Uri uri, File cacheDir, int tabId) { + this.uri = uri; + this.cacheDir = cacheDir; + this.tabId = tabId; + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity(); + return new CursorLoader(fa, + uri, + new String[] { OpenableColumns.DISPLAY_NAME }, + null, // selection + null, // selectionArgs + null); // sortOrder + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { + if (cursor.moveToFirst()) { + String name = cursor.getString(0); + // tmp filenames must be at least 3 characters long. Add a prefix to make sure that happens + String fileName = "tmp_" + Process.myPid() + "-"; + String fileExt; + int period; + + final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity(); + final ContentResolver cr = fa.getContentResolver(); + + // Generate an extension if we don't already have one + if (name == null || (period = name.lastIndexOf('.')) == -1) { + String mimeType = cr.getType(uri); + fileExt = "." + GeckoAppShell.getExtensionFromMimeType(mimeType); + } else { + fileExt = name.substring(period); + fileName += name.substring(0, period); + } + + // Now write the data to the temp file + FileOutputStream fos = null; + try { + cacheDir.mkdir(); + + File file = File.createTempFile(fileName, fileExt, cacheDir); + fos = new FileOutputStream(file); + InputStream is = cr.openInputStream(uri); + byte[] buf = new byte[4096]; + int len = is.read(buf); + while (len != -1) { + fos.write(buf, 0, len); + len = is.read(buf); + } + fos.close(); + is.close(); + tempFile = file.getAbsolutePath(); + sendResult((tempFile == null) ? "" : tempFile); + + if (tabId > -1 && !TextUtils.isEmpty(tempFile)) { + Tabs.registerOnTabsChangedListener(this); + } + } catch (IOException ex) { + Log.i(LOGTAG, "Error writing file", ex); + } finally { + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { /* not much to do here */ } + } + } + } else { + sendResult(""); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { } + + /*Tabs.OnTabsChangedListener*/ + // This cleans up our temp file. If it doesn't run, we just hope that Android + // will eventually does the cleanup for us. + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + if ((tab == null) || (tab.getId() != tabId)) { + return; + } + + if (msg == Tabs.TabEvents.LOCATION_CHANGE || + msg == Tabs.TabEvents.CLOSED) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + File f = new File(tempFile); + f.delete(); + } + }); + + // Tabs' listener array is safe to modify during use: its + // iteration pattern is based on snapshots. + Tabs.unregisterOnTabsChangedListener(this); + } + } + } + +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java b/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java new file mode 100644 index 000000000..efa04a04e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java @@ -0,0 +1,256 @@ +/* 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; + +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONObject; + +import android.content.Context; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.LinearLayout; +import android.widget.TextView; + +public class FindInPageBar extends LinearLayout implements TextWatcher, View.OnClickListener, GeckoEventListener { + private static final String LOGTAG = "GeckoFindInPageBar"; + private static final String REQUEST_ID = "FindInPageBar"; + + private final Context mContext; + private CustomEditText mFindText; + private TextView mStatusText; + private boolean mInflated; + + public FindInPageBar(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + setFocusable(true); + } + + public void inflateContent() { + LayoutInflater inflater = LayoutInflater.from(mContext); + View content = inflater.inflate(R.layout.find_in_page_content, this); + + content.findViewById(R.id.find_prev).setOnClickListener(this); + content.findViewById(R.id.find_next).setOnClickListener(this); + content.findViewById(R.id.find_close).setOnClickListener(this); + + // Capture clicks on the rest of the view to prevent them from + // leaking into other views positioned below. + content.setOnClickListener(this); + + mFindText = (CustomEditText) content.findViewById(R.id.find_text); + mFindText.addTextChangedListener(this); + mFindText.setOnKeyPreImeListener(new CustomEditText.OnKeyPreImeListener() { + @Override + public boolean onKeyPreIme(View v, int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + hide(); + return true; + } + return false; + } + }); + + mStatusText = (TextView) content.findViewById(R.id.find_status); + + mInflated = true; + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "FindInPage:MatchesCountResult", + "TextSelection:Data"); + } + + public void show() { + if (!mInflated) + inflateContent(); + + setVisibility(VISIBLE); + mFindText.requestFocus(); + + // handleMessage() receives response message and determines initial state of softInput + GeckoAppShell.notifyObservers("TextSelection:Get", REQUEST_ID); + GeckoAppShell.notifyObservers("FindInPage:Opened", null); + } + + public void hide() { + if (!mInflated || getVisibility() == View.GONE) { + // There's nothing to hide yet. + return; + } + + // Always clear the Find string, primarily for privacy. + mFindText.setText(""); + + // Only close the IMM if its EditText is the one with focus. + if (mFindText.isFocused()) { + getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0); + } + + // Close the FIPB / FindHelper state. + setVisibility(GONE); + GeckoAppShell.notifyObservers("FindInPage:Closed", null); + } + + private InputMethodManager getInputMethodManager(View view) { + Context context = view.getContext(); + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public void onDestroy() { + if (!mInflated) { + return; + } + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "FindInPage:MatchesCountResult", + "TextSelection:Data"); + } + + private void onMatchesCountResult(final int total, final int current, final int limit, final String searchString) { + if (total == -1) { + updateResult(Integer.toString(limit) + "+"); + } else if (total > 0) { + updateResult(Integer.toString(current) + "/" + Integer.toString(total)); + } else if (TextUtils.isEmpty(searchString)) { + updateResult(""); + } else { + // We display 0/0, when there were no + // matches found, or if matching has been turned off by setting + // pref accessibility.typeaheadfind.matchesCountLimit to 0. + updateResult("0/0"); + } + } + + private void updateResult(final String statusText) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mStatusText.setVisibility(statusText.isEmpty() ? View.GONE : View.VISIBLE); + mStatusText.setText(statusText); + } + }); + } + + // TextWatcher implementation + + @Override + public void afterTextChanged(Editable s) { + sendRequestToFinderHelper("FindInPage:Find", s.toString()); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // ignore + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // ignore + } + + // View.OnClickListener implementation + + @Override + public void onClick(View v) { + final int viewId = v.getId(); + + String extras = getResources().getResourceEntryName(viewId); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, extras); + + if (viewId == R.id.find_prev) { + sendRequestToFinderHelper("FindInPage:Prev", mFindText.getText().toString()); + getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0); + return; + } + + if (viewId == R.id.find_next) { + sendRequestToFinderHelper("FindInPage:Next", mFindText.getText().toString()); + getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0); + return; + } + + if (viewId == R.id.find_close) { + hide(); + } + } + + // GeckoEventListener implementation + + @Override + public void handleMessage(String event, JSONObject message) { + if (event.equals("FindInPage:MatchesCountResult")) { + onMatchesCountResult(message.optInt("total", 0), + message.optInt("current", 0), + message.optInt("limit", 0), + message.optString("searchString")); + return; + } + + if (!event.equals("TextSelection:Data") || !REQUEST_ID.equals(message.optString("requestId"))) { + return; + } + + final String text = message.optString("text"); + + // Populate an initial find string, virtual keyboard not required. + if (!TextUtils.isEmpty(text)) { + // Populate initial selection + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mFindText.setText(text); + } + }); + return; + } + + // Show the virtual keyboard. + if (mFindText.hasWindowFocus()) { + getInputMethodManager(mFindText).showSoftInput(mFindText, 0); + } else { + // showSoftInput won't work until after the window is focused. + mFindText.setOnWindowFocusChangeListener(new CustomEditText.OnWindowFocusChangeListener() { + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (!hasFocus) + return; + + mFindText.setOnWindowFocusChangeListener(null); + getInputMethodManager(mFindText).showSoftInput(mFindText, 0); + } + }); + } + } + + /** + * Request find operation, and update matchCount results (current count and total). + */ + private void sendRequestToFinderHelper(final String request, final String searchString) { + GeckoAppShell.sendRequestToGecko(new GeckoRequest(request, searchString) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + // We don't care about the return value, because `onMatchesCountResult` + // does the heavy lifting. + } + + @Override + public void onError(NativeJSObject error) { + // Gecko didn't respond due to state change, javascript error, etc. + Log.d(LOGTAG, "No response from Gecko on request to match string: [" + + searchString + "]"); + updateResult(""); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java b/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java new file mode 100644 index 000000000..5c7f932c0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java @@ -0,0 +1,459 @@ +/* -*- 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; + +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.gfx.FloatSize; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener; +import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener.OnDismissCallback; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PointF; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.RelativeLayout.LayoutParams; +import android.widget.TextView; + +import java.util.Arrays; +import java.util.Collection; + +public class FormAssistPopup extends RelativeLayout implements GeckoEventListener { + private final Context mContext; + private final Animation mAnimation; + + private ListView mAutoCompleteList; + private RelativeLayout mValidationMessage; + private TextView mValidationMessageText; + private ImageView mValidationMessageArrow; + private ImageView mValidationMessageArrowInverted; + + private double mX; + private double mY; + private double mW; + private double mH; + + private enum PopupType { + AUTOCOMPLETE, + VALIDATIONMESSAGE; + } + private PopupType mPopupType; + + private static final int MAX_VISIBLE_ROWS = 5; + + private static int sAutoCompleteMinWidth; + private static int sAutoCompleteRowHeight; + private static int sValidationMessageHeight; + private static int sValidationTextMarginTop; + private static LayoutParams sValidationTextLayoutNormal; + private static LayoutParams sValidationTextLayoutInverted; + + private static final String LOGTAG = "GeckoFormAssistPopup"; + + // The blocklist is so short that ArrayList is probably cheaper than HashSet. + private static final Collection<String> sInputMethodBlocklist = Arrays.asList( + InputMethods.METHOD_GOOGLE_JAPANESE_INPUT, // bug 775850 + InputMethods.METHOD_OPENWNN_PLUS, // bug 768108 + InputMethods.METHOD_SIMEJI, // bug 768108 + InputMethods.METHOD_SWYPE, // bug 755909 + InputMethods.METHOD_SWYPE_BETA // bug 755909 + ); + + public FormAssistPopup(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + + mAnimation = AnimationUtils.loadAnimation(context, R.anim.grow_fade_in); + mAnimation.setDuration(75); + + setFocusable(false); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "FormAssist:AutoComplete", + "FormAssist:ValidationMessage", + "FormAssist:Hide"); + } + + void destroy() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "FormAssist:AutoComplete", + "FormAssist:ValidationMessage", + "FormAssist:Hide"); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("FormAssist:AutoComplete")) { + handleAutoCompleteMessage(message); + } else if (event.equals("FormAssist:ValidationMessage")) { + handleValidationMessage(message); + } else if (event.equals("FormAssist:Hide")) { + handleHideMessage(message); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + private void handleAutoCompleteMessage(JSONObject message) throws JSONException { + final JSONArray suggestions = message.getJSONArray("suggestions"); + final JSONObject rect = message.getJSONObject("rect"); + final boolean isEmpty = message.getBoolean("isEmpty"); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + showAutoCompleteSuggestions(suggestions, rect, isEmpty); + } + }); + } + + private void handleValidationMessage(JSONObject message) throws JSONException { + final String validationMessage = message.getString("validationMessage"); + final JSONObject rect = message.getJSONObject("rect"); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + showValidationMessage(validationMessage, rect); + } + }); + } + + private void handleHideMessage(JSONObject message) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + hide(); + } + }); + } + + private void showAutoCompleteSuggestions(JSONArray suggestions, JSONObject rect, boolean isEmpty) { + final String inputMethod = InputMethods.getCurrentInputMethod(mContext); + if (!isEmpty && sInputMethodBlocklist.contains(inputMethod)) { + // Don't display the form auto-complete popup after the user starts typing + // to avoid confusing somes IME. See bug 758820 and bug 632744. + hide(); + return; + } + + if (mAutoCompleteList == null) { + LayoutInflater inflater = LayoutInflater.from(mContext); + mAutoCompleteList = (ListView) inflater.inflate(R.layout.autocomplete_list, null); + + mAutoCompleteList.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parentView, View view, int position, long id) { + // Use the value stored with the autocomplete view, not the label text, + // since they can be different. + TextView textView = (TextView) view; + String value = (String) textView.getTag(); + broadcastGeckoEvent("FormAssist:AutoComplete", value); + hide(); + } + }); + + // Create a ListView-specific touch listener. ListViews are given special treatment because + // by default they handle touches for their list items... i.e. they're in charge of drawing + // the pressed state (the list selector), handling list item clicks, etc. + final SwipeDismissListViewTouchListener touchListener = new SwipeDismissListViewTouchListener(mAutoCompleteList, new OnDismissCallback() { + @Override + public void onDismiss(ListView listView, final int position) { + // Use the value stored with the autocomplete view, not the label text, + // since they can be different. + AutoCompleteListAdapter adapter = (AutoCompleteListAdapter) listView.getAdapter(); + Pair<String, String> item = adapter.getItem(position); + + // Remove the item from form history. + broadcastGeckoEvent("FormAssist:Remove", item.second); + + // Update the list + adapter.remove(item); + adapter.notifyDataSetChanged(); + positionAndShowPopup(); + } + }); + mAutoCompleteList.setOnTouchListener(touchListener); + + // Setting this scroll listener is required to ensure that during ListView scrolling, + // we don't look for swipes. + mAutoCompleteList.setOnScrollListener(touchListener.makeScrollListener()); + + // Setting this recycler listener is required to make sure animated views are reset. + mAutoCompleteList.setRecyclerListener(touchListener.makeRecyclerListener()); + + addView(mAutoCompleteList); + } + + AutoCompleteListAdapter adapter = new AutoCompleteListAdapter(mContext, R.layout.autocomplete_list_item); + adapter.populateSuggestionsList(suggestions); + mAutoCompleteList.setAdapter(adapter); + + if (setGeckoPositionData(rect, true)) { + positionAndShowPopup(); + } + } + + private void showValidationMessage(String validationMessage, JSONObject rect) { + if (mValidationMessage == null) { + LayoutInflater inflater = LayoutInflater.from(mContext); + mValidationMessage = (RelativeLayout) inflater.inflate(R.layout.validation_message, null); + + addView(mValidationMessage); + mValidationMessageText = (TextView) mValidationMessage.findViewById(R.id.validation_message_text); + + sValidationTextMarginTop = (int) (mContext.getResources().getDimension(R.dimen.validation_message_margin_top)); + + sValidationTextLayoutNormal = new LayoutParams(mValidationMessageText.getLayoutParams()); + sValidationTextLayoutNormal.setMargins(0, sValidationTextMarginTop, 0, 0); + + sValidationTextLayoutInverted = new LayoutParams((ViewGroup.MarginLayoutParams) sValidationTextLayoutNormal); + sValidationTextLayoutInverted.setMargins(0, 0, 0, 0); + + mValidationMessageArrow = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow); + mValidationMessageArrowInverted = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow_inverted); + } + + mValidationMessageText.setText(validationMessage); + + // We need to set the text as selected for the marquee text to work. + mValidationMessageText.setSelected(true); + + if (setGeckoPositionData(rect, false)) { + positionAndShowPopup(); + } + } + + private boolean setGeckoPositionData(JSONObject rect, boolean isAutoComplete) { + try { + mX = rect.getDouble("x"); + mY = rect.getDouble("y"); + mW = rect.getDouble("w"); + mH = rect.getDouble("h"); + } catch (JSONException e) { + // Bail if we can't get the correct dimensions for the popup. + Log.e(LOGTAG, "Error getting FormAssistPopup dimensions", e); + return false; + } + + mPopupType = (isAutoComplete ? + PopupType.AUTOCOMPLETE : PopupType.VALIDATIONMESSAGE); + return true; + } + + private void positionAndShowPopup() { + positionAndShowPopup(GeckoAppShell.getLayerView().getViewportMetrics()); + } + + private void positionAndShowPopup(ImmutableViewportMetrics aMetrics) { + ThreadUtils.assertOnUiThread(); + + // Don't show the form assist popup when using fullscreen VKB + InputMethodManager imm = + (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm.isFullscreenMode()) { + return; + } + + // Hide/show the appropriate popup contents + if (mAutoCompleteList != null) { + mAutoCompleteList.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? VISIBLE : GONE); + } + if (mValidationMessage != null) { + mValidationMessage.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? GONE : VISIBLE); + } + + if (sAutoCompleteMinWidth == 0) { + Resources res = mContext.getResources(); + sAutoCompleteMinWidth = (int) (res.getDimension(R.dimen.autocomplete_min_width)); + sAutoCompleteRowHeight = (int) (res.getDimension(R.dimen.autocomplete_row_height)); + sValidationMessageHeight = (int) (res.getDimension(R.dimen.validation_message_height)); + } + + float zoom = aMetrics.zoomFactor; + + // These values correspond to the input box for which we want to + // display the FormAssistPopup. + int left = (int) (mX * zoom - aMetrics.viewportRectLeft); + int top = (int) (mY * zoom - aMetrics.viewportRectTop + GeckoAppShell.getLayerView().getSurfaceTranslation()); + int width = (int) (mW * zoom); + int height = (int) (mH * zoom); + + int popupWidth = LayoutParams.MATCH_PARENT; + int popupLeft = left < 0 ? 0 : left; + + FloatSize viewport = aMetrics.getSize(); + + // For autocomplete suggestions, if the input is smaller than the screen-width, + // shrink the popup's width. Otherwise, keep it as MATCH_PARENT. + if ((mPopupType == PopupType.AUTOCOMPLETE) && (left + width) < viewport.width) { + popupWidth = left < 0 ? left + width : width; + + // Ensure the popup has a minimum width. + if (popupWidth < sAutoCompleteMinWidth) { + popupWidth = sAutoCompleteMinWidth; + + // Move the popup to the left if there isn't enough room for it. + if ((popupLeft + popupWidth) > viewport.width) { + popupLeft = (int) (viewport.width - popupWidth); + } + } + } + + int popupHeight; + if (mPopupType == PopupType.AUTOCOMPLETE) { + // Limit the amount of visible rows. + int rows = mAutoCompleteList.getAdapter().getCount(); + if (rows > MAX_VISIBLE_ROWS) { + rows = MAX_VISIBLE_ROWS; + } + + popupHeight = sAutoCompleteRowHeight * rows; + } else { + popupHeight = sValidationMessageHeight; + } + + int popupTop = top + height; + + if (mPopupType == PopupType.VALIDATIONMESSAGE) { + mValidationMessageText.setLayoutParams(sValidationTextLayoutNormal); + mValidationMessageArrow.setVisibility(VISIBLE); + mValidationMessageArrowInverted.setVisibility(GONE); + } + + // If the popup doesn't fit below the input box, shrink its height, or + // see if we can place it above the input instead. + if ((popupTop + popupHeight) > viewport.height) { + // Find where the maximum space is, and put the popup there. + if ((viewport.height - popupTop) > top) { + // Shrink the height to fit it below the input box. + popupHeight = (int) (viewport.height - popupTop); + } else { + if (popupHeight < top) { + // No shrinking needed to fit on top. + popupTop = (top - popupHeight); + } else { + // Shrink to available space on top. + popupTop = 0; + popupHeight = top; + } + + if (mPopupType == PopupType.VALIDATIONMESSAGE) { + mValidationMessageText.setLayoutParams(sValidationTextLayoutInverted); + mValidationMessageArrow.setVisibility(GONE); + mValidationMessageArrowInverted.setVisibility(VISIBLE); + } + } + } + + LayoutParams layoutParams = new LayoutParams(popupWidth, popupHeight); + layoutParams.setMargins(popupLeft, popupTop, 0, 0); + setLayoutParams(layoutParams); + requestLayout(); + + if (!isShown()) { + setVisibility(VISIBLE); + startAnimation(mAnimation); + } + } + + public void hide() { + if (isShown()) { + setVisibility(GONE); + broadcastGeckoEvent("FormAssist:Hidden", null); + } + } + + void onTranslationChanged() { + ThreadUtils.assertOnUiThread(); + if (!isShown()) { + return; + } + positionAndShowPopup(); + } + + void onMetricsChanged(final ImmutableViewportMetrics aMetrics) { + if (!isShown()) { + return; + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + positionAndShowPopup(aMetrics); + } + }); + } + + private static void broadcastGeckoEvent(String eventName, String eventData) { + GeckoAppShell.notifyObservers(eventName, eventData); + } + + private class AutoCompleteListAdapter extends ArrayAdapter<Pair<String, String>> { + private final LayoutInflater mInflater; + private final int mTextViewResourceId; + + public AutoCompleteListAdapter(Context context, int textViewResourceId) { + super(context, textViewResourceId); + + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mTextViewResourceId = textViewResourceId; + } + + // This method takes an array of autocomplete suggestions with label/value properties + // and adds label/value Pair objects to the array that backs the adapter. + public void populateSuggestionsList(JSONArray suggestions) { + try { + for (int i = 0; i < suggestions.length(); i++) { + JSONObject suggestion = suggestions.getJSONObject(i); + String label = suggestion.getString("label"); + String value = suggestion.getString("value"); + add(new Pair<String, String>(label, value)); + } + } catch (JSONException e) { + Log.e(LOGTAG, "JSONException", e); + } + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = mInflater.inflate(mTextViewResourceId, null); + } + + Pair<String, String> item = getItem(position); + TextView itemView = (TextView) convertView; + + // Set the text with the suggestion label + itemView.setText(item.first); + + // Set a tag with the suggestion value + itemView.setTag(item.second); + + return convertView; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java b/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java new file mode 100644 index 000000000..774ca6024 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java @@ -0,0 +1,100 @@ +/* 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; + +import android.content.ComponentName; +import android.content.Intent; +import android.support.v7.app.AppCompatActivity; + +public abstract class GeckoActivity extends AppCompatActivity implements GeckoActivityStatus { + // has this activity recently started another Gecko activity? + private boolean mGeckoActivityOpened; + + /** + * Display any resources that show strings or encompass locale-specific + * representations. + * + * onLocaleReady must always be called on the UI thread. + */ + public void onLocaleReady(final String locale) { + } + + @Override + public void onPause() { + super.onPause(); + + if (getApplication() instanceof GeckoApplication) { + ((GeckoApplication) getApplication()).onActivityPause(this); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (getApplication() instanceof GeckoApplication) { + ((GeckoApplication) getApplication()).onActivityResume(this); + mGeckoActivityOpened = false; + } + } + + @Override + public void onCreate(android.os.Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (AppConstants.MOZ_ANDROID_ANR_REPORTER) { + ANRReporter.register(getApplicationContext()); + } + } + + @Override + public void onDestroy() { + if (AppConstants.MOZ_ANDROID_ANR_REPORTER) { + ANRReporter.unregister(); + } + super.onDestroy(); + } + + @Override + public void startActivity(Intent intent) { + mGeckoActivityOpened = checkIfGeckoActivity(intent); + super.startActivity(intent); + } + + @Override + public void startActivityForResult(Intent intent, int request) { + mGeckoActivityOpened = checkIfGeckoActivity(intent); + super.startActivityForResult(intent, request); + } + + private static boolean checkIfGeckoActivity(Intent intent) { + // Whenever we call our own activity, the component and its package name is set. + // If we call an activity from another package, or an open intent (leaving android to resolve) + // component has a different package name or it is null. + ComponentName component = intent.getComponent(); + return (component != null && + AppConstants.ANDROID_PACKAGE_NAME.equals(component.getPackageName())); + } + + @Override + public boolean isGeckoActivityOpened() { + return mGeckoActivityOpened; + } + + public boolean isApplicationInBackground() { + return ((GeckoApplication) getApplication()).isApplicationInBackground(); + } + + @Override + public void onLowMemory() { + MemoryMonitor.getInstance().onLowMemory(); + super.onLowMemory(); + } + + @Override + public void onTrimMemory(int level) { + MemoryMonitor.getInstance().onTrimMemory(level); + super.onTrimMemory(level); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java b/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java new file mode 100644 index 000000000..ce6b8abd0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java @@ -0,0 +1,10 @@ +/* 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; + +public interface GeckoActivityStatus { + public boolean isGeckoActivityOpened(); + public boolean isFinishing(); // typically from android.app.Activity +}; diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java new file mode 100644 index 000000000..05fa2bbf8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java @@ -0,0 +1,2878 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.gfx.FullScreenState; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.health.HealthRecorder; +import org.mozilla.gecko.health.SessionInformation; +import org.mozilla.gecko.health.StubbedHealthRecorder; +import org.mozilla.gecko.home.HomeConfig.PanelType; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.GeckoMenuInflater; +import org.mozilla.gecko.menu.MenuPanel; +import org.mozilla.gecko.notifications.NotificationClient; +import org.mozilla.gecko.notifications.NotificationHelper; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.preferences.ClearOnShutdownPref; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.prompts.PromptService; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.tabqueue.TabQueueHelper; +import org.mozilla.gecko.text.FloatingToolbarTextSelection; +import org.mozilla.gecko.text.TextSelection; +import org.mozilla.gecko.updater.UpdateServiceHelper; +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.ActivityUtils; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.FileUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.PrefUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.PowerManager; +import android.os.Process; +import android.os.StrictMode; +import android.provider.ContactsContract; +import android.provider.MediaStore.Images.Media; +import android.support.annotation.WorkerThread; +import android.support.design.widget.Snackbar; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Base64; +import android.util.Log; +import android.util.SparseBooleanArray; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.OrientationEventListener; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.widget.AbsoluteLayout; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.SimpleAdapter; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +public abstract class GeckoApp + extends GeckoActivity + implements + ContextGetter, + GeckoAppShell.GeckoInterface, + GeckoEventListener, + GeckoMenu.Callback, + GeckoMenu.MenuPresenter, + NativeEventListener, + Tabs.OnTabsChangedListener, + ViewTreeObserver.OnGlobalLayoutListener { + + private static final String LOGTAG = "GeckoApp"; + private static final long ONE_DAY_MS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS); + + public static final String ACTION_ALERT_CALLBACK = "org.mozilla.gecko.ALERT_CALLBACK"; + public static final String ACTION_HOMESCREEN_SHORTCUT = "org.mozilla.gecko.BOOKMARK"; + public static final String ACTION_DEBUG = "org.mozilla.gecko.DEBUG"; + public static final String ACTION_LAUNCH_SETTINGS = "org.mozilla.gecko.SETTINGS"; + public static final String ACTION_LOAD = "org.mozilla.gecko.LOAD"; + public static final String ACTION_INIT_PW = "org.mozilla.gecko.INIT_PW"; + public static final String ACTION_SWITCH_TAB = "org.mozilla.gecko.SWITCH_TAB"; + + public static final String INTENT_REGISTER_STUMBLER_LISTENER = "org.mozilla.gecko.STUMBLER_REGISTER_LOCAL_LISTENER"; + + public static final String EXTRA_STATE_BUNDLE = "stateBundle"; + + public static final String LAST_SELECTED_TAB = "lastSelectedTab"; + + public static final String PREFS_ALLOW_STATE_BUNDLE = "allowStateBundle"; + public static final String PREFS_VERSION_CODE = "versionCode"; + public static final String PREFS_WAS_STOPPED = "wasStopped"; + public static final String PREFS_CRASHED_COUNT = "crashedCount"; + public static final String PREFS_CLEANUP_TEMP_FILES = "cleanupTempFiles"; + + public static final String SAVED_STATE_IN_BACKGROUND = "inBackground"; + public static final String SAVED_STATE_PRIVATE_SESSION = "privateSession"; + + // Delay before running one-time "cleanup" tasks that may be needed + // after a version upgrade. + private static final int CLEANUP_DEFERRAL_SECONDS = 15; + + private static boolean sAlreadyLoaded; + + private static WeakReference<GeckoApp> lastActiveGeckoApp; + + protected RelativeLayout mRootLayout; + protected RelativeLayout mMainLayout; + + protected RelativeLayout mGeckoLayout; + private OrientationEventListener mCameraOrientationEventListener; + public List<GeckoAppShell.AppStateListener> mAppStateListeners = new LinkedList<GeckoAppShell.AppStateListener>(); + protected MenuPanel mMenuPanel; + protected Menu mMenu; + protected boolean mIsRestoringActivity; + + /** Tells if we're aborting app launch, e.g. if this is an unsupported device configuration. */ + protected boolean mIsAbortingAppLaunch; + + private PromptService mPromptService; + protected TextSelection mTextSelection; + + protected DoorHangerPopup mDoorHangerPopup; + protected FormAssistPopup mFormAssistPopup; + + + protected GeckoView mLayerView; + private AbsoluteLayout mPluginContainer; + + private FullScreenHolder mFullScreenPluginContainer; + private View mFullScreenPluginView; + + private final HashMap<String, PowerManager.WakeLock> mWakeLocks = new HashMap<String, PowerManager.WakeLock>(); + + protected boolean mLastSessionCrashed; + protected boolean mShouldRestore; + private boolean mSessionRestoreParsingFinished = false; + + private EventDispatcher eventDispatcher; + + private int lastSelectedTabId = -1; + + private static final class LastSessionParser extends SessionParser { + private JSONArray tabs; + private JSONObject windowObject; + private boolean isExternalURL; + + private boolean selectNextTab; + private boolean tabsWereSkipped; + private boolean tabsWereProcessed; + + public LastSessionParser(JSONArray tabs, JSONObject windowObject, boolean isExternalURL) { + this.tabs = tabs; + this.windowObject = windowObject; + this.isExternalURL = isExternalURL; + } + + public boolean allTabsSkipped() { + return tabsWereSkipped && !tabsWereProcessed; + } + + @Override + public void onTabRead(final SessionTab sessionTab) { + if (sessionTab.isAboutHomeWithoutHistory()) { + // This is a tab pointing to about:home with no history. We won't restore + // this tab. If we end up restoring no tabs then the browser will decide + // whether it needs to open about:home or a different 'homepage'. If we'd + // always restore about:home only tabs then we'd never open the homepage. + // See bug 1261008. + + if (sessionTab.isSelected()) { + // Unfortunately this tab is the selected tab. Let's just try to select + // the first tab. If we haven't restored any tabs so far then remember + // to select the next tab that gets restored. + + if (!Tabs.getInstance().selectLastTab()) { + selectNextTab = true; + } + } + + // Do not restore this tab. + tabsWereSkipped = true; + return; + } + + tabsWereProcessed = true; + + JSONObject tabObject = sessionTab.getTabObject(); + + int flags = Tabs.LOADURL_NEW_TAB; + flags |= ((isExternalURL || !sessionTab.isSelected()) ? Tabs.LOADURL_DELAY_LOAD : 0); + flags |= (tabObject.optBoolean("desktopMode") ? Tabs.LOADURL_DESKTOP : 0); + flags |= (tabObject.optBoolean("isPrivate") ? Tabs.LOADURL_PRIVATE : 0); + + final Tab tab = Tabs.getInstance().loadUrl(sessionTab.getUrl(), flags); + + if (selectNextTab) { + // We did not restore the selected tab previously. Now let's select this tab. + Tabs.getInstance().selectTab(tab.getId()); + selectNextTab = false; + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + tab.updateTitle(sessionTab.getTitle()); + } + }); + + try { + tabObject.put("tabId", tab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + tabs.put(tabObject); + } + + @Override + public void onClosedTabsRead(final JSONArray closedTabData) throws JSONException { + windowObject.put("closedTabs", closedTabData); + } + }; + + protected boolean mInitialized; + protected boolean mWindowFocusInitialized; + private Telemetry.Timer mJavaUiStartupTimer; + private Telemetry.Timer mGeckoReadyStartupTimer; + + private String mPrivateBrowsingSession; + + private volatile HealthRecorder mHealthRecorder; + private volatile Locale mLastLocale; + + protected Intent mRestartIntent; + + private boolean mWasFirstTabShownAfterActivityUnhidden; + + abstract public int getLayout(); + + protected void processTabQueue() {}; + + protected void openQueuedTabs() {}; + + @SuppressWarnings("serial") + class SessionRestoreException extends Exception { + public SessionRestoreException(Exception e) { + super(e); + } + + public SessionRestoreException(String message) { + super(message); + } + } + + void toggleChrome(final boolean aShow) { } + + void focusChrome() { } + + @Override + public Context getContext() { + return this; + } + + @Override + public SharedPreferences getSharedPreferences() { + return GeckoSharedPrefs.forApp(this); + } + + @Override + public Activity getActivity() { + return this; + } + + @Override + public void addAppStateListener(GeckoAppShell.AppStateListener listener) { + mAppStateListeners.add(listener); + } + + @Override + public void removeAppStateListener(GeckoAppShell.AppStateListener listener) { + mAppStateListeners.remove(listener); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + // When a tab is closed, it is always unselected first. + // When a tab is unselected, another tab is always selected first. + switch (msg) { + case UNSELECTED: + break; + + case LOCATION_CHANGE: + // We only care about location change for the selected tab. + if (!Tabs.getInstance().isSelectedTab(tab)) + break; + // Fall through... + case SELECTED: + invalidateOptionsMenu(); + if (mFormAssistPopup != null) + mFormAssistPopup.hide(); + break; + + case DESKTOP_MODE_CHANGE: + if (Tabs.getInstance().isSelectedTab(tab)) + invalidateOptionsMenu(); + break; + } + } + + public void refreshChrome() { } + + @Override + public void invalidateOptionsMenu() { + if (mMenu == null) { + return; + } + + onPrepareOptionsMenu(mMenu); + + super.invalidateOptionsMenu(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + mMenu = menu; + + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.gecko_app_menu, mMenu); + return true; + } + + @Override + public MenuInflater getMenuInflater() { + return new GeckoMenuInflater(this); + } + + public MenuPanel getMenuPanel() { + if (mMenuPanel == null) { + onCreatePanelMenu(Window.FEATURE_OPTIONS_PANEL, null); + invalidateOptionsMenu(); + } + return mMenuPanel; + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + return onOptionsItemSelected(item); + } + + @Override + public boolean onMenuItemLongClick(MenuItem item) { + return false; + } + + @Override + public void openMenu() { + openOptionsMenu(); + } + + @Override + public void showMenu(final View menu) { + // On devices using the custom menu, focus is cleared from the menu when its tapped. + // Close and then reshow it to avoid these issues. See bug 794581 and bug 968182. + closeMenu(); + + // Post the reshow code back to the UI thread to avoid some optimizations Android + // has put in place for menus that hide/show themselves quickly. See bug 985400. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mMenuPanel.removeAllViews(); + mMenuPanel.addView(menu); + openOptionsMenu(); + } + }); + } + + @Override + public void closeMenu() { + closeOptionsMenu(); + } + + @Override + public View onCreatePanelView(int featureId) { + if (featureId == Window.FEATURE_OPTIONS_PANEL) { + if (mMenuPanel == null) { + mMenuPanel = new MenuPanel(this, null); + } else { + // Prepare the panel every time before showing the menu. + onPreparePanel(featureId, mMenuPanel, mMenu); + } + + return mMenuPanel; + } + + return super.onCreatePanelView(featureId); + } + + @Override + public boolean onCreatePanelMenu(int featureId, Menu menu) { + if (featureId == Window.FEATURE_OPTIONS_PANEL) { + if (mMenuPanel == null) { + mMenuPanel = (MenuPanel) onCreatePanelView(featureId); + } + + GeckoMenu gMenu = new GeckoMenu(this, null); + gMenu.setCallback(this); + gMenu.setMenuPresenter(this); + menu = gMenu; + mMenuPanel.addView(gMenu); + + return onCreateOptionsMenu(menu); + } + + return super.onCreatePanelMenu(featureId, menu); + } + + @Override + public boolean onPreparePanel(int featureId, View view, Menu menu) { + if (featureId == Window.FEATURE_OPTIONS_PANEL) { + return onPrepareOptionsMenu(menu); + } + + return super.onPreparePanel(featureId, view, menu); + } + + @Override + public boolean onMenuOpened(int featureId, Menu menu) { + // exit full-screen mode whenever the menu is opened + if (mLayerView != null && mLayerView.isFullScreen()) { + GeckoAppShell.notifyObservers("FullScreen:Exit", null); + } + + if (featureId == Window.FEATURE_OPTIONS_PANEL) { + if (mMenu == null) { + // getMenuPanel() will force the creation of the menu as well + MenuPanel panel = getMenuPanel(); + onPreparePanel(featureId, panel, mMenu); + } + + // Scroll custom menu to the top + if (mMenuPanel != null) + mMenuPanel.scrollTo(0, 0); + + return true; + } + + return super.onMenuOpened(featureId, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.quit) { + // Make sure the Guest Browsing notification goes away when we quit. + GuestSession.hideNotification(this); + + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this); + final Set<String> clearSet = + PrefUtils.getStringSet(prefs, ClearOnShutdownPref.PREF, new HashSet<String>()); + + final JSONObject clearObj = new JSONObject(); + for (String clear : clearSet) { + try { + clearObj.put(clear, true); + } catch (JSONException ex) { + Log.e(LOGTAG, "Error adding clear object " + clear, ex); + } + } + + final JSONObject res = new JSONObject(); + try { + res.put("sanitize", clearObj); + } catch (JSONException ex) { + Log.e(LOGTAG, "Error adding sanitize object", ex); + } + + // If the user has opted out of session restore, and does want to clear history + // we also want to prevent the current session info from being saved. + if (clearObj.has("private.data.history")) { + final String sessionRestore = getSessionRestorePreference(getSharedPreferences()); + try { + res.put("dontSaveSession", "quit".equals(sessionRestore)); + } catch (JSONException ex) { + Log.e(LOGTAG, "Error adding session restore data", ex); + } + } + + GeckoAppShell.notifyObservers("Browser:Quit", res.toString()); + // We don't call doShutdown() here because this creates a race condition which can + // cause the clearing of private data to fail. Instead, we shut down the UI only after + // we're done sanitizing. + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void onOptionsMenuClosed(Menu menu) { + mMenuPanel.removeAllViews(); + mMenuPanel.addView((GeckoMenu) mMenu); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Handle hardware menu key presses separately so that we can show a custom menu in some cases. + if (keyCode == KeyEvent.KEYCODE_MENU) { + openOptionsMenu(); + return true; + } + + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putBoolean(SAVED_STATE_IN_BACKGROUND, isApplicationInBackground()); + outState.putString(SAVED_STATE_PRIVATE_SESSION, mPrivateBrowsingSession); + outState.putInt(LAST_SELECTED_TAB, lastSelectedTabId); + } + + @Override + protected void onRestoreInstanceState(final Bundle inState) { + lastSelectedTabId = inState.getInt(LAST_SELECTED_TAB); + } + + public void addTab() { } + + public void addPrivateTab() { } + + public void showNormalTabs() { } + + public void showPrivateTabs() { } + + public void hideTabs() { } + + /** + * Close the tab UI indirectly (not as the result of a direct user + * action). This does not force the UI to close; for example in Firefox + * tablet mode it will remain open unless the user explicitly closes it. + * + * @return True if the tab UI was hidden. + */ + public boolean autoHideTabs() { return false; } + + @Override + public boolean areTabsShown() { return false; } + + @Override + public void handleMessage(final String event, final NativeJSObject message, + final EventCallback callback) { + if ("Accessibility:Ready".equals(event)) { + GeckoAccessibility.updateAccessibilitySettings(this); + + } else if ("Bookmark:Insert".equals(event)) { + final String url = message.getString("url"); + final String title = message.getString("title"); + final Context context = this; + final BrowserDB db = BrowserDB.from(getProfile()); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final boolean bookmarkAdded = db.addBookmark(getContentResolver(), title, url); + final int resId = bookmarkAdded ? R.string.bookmark_added : R.string.bookmark_already_added; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + SnackbarBuilder.builder(GeckoApp.this) + .message(resId) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + }); + } + }); + + } else if ("Contact:Add".equals(event)) { + final String email = message.optString("email", null); + final String phone = message.optString("phone", null); + if (email != null) { + Uri contactUri = Uri.parse(email); + Intent i = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, contactUri); + startActivity(i); + } else if (phone != null) { + Uri contactUri = Uri.parse(phone); + Intent i = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, contactUri); + startActivity(i); + } else { + // something went wrong. + Log.e(LOGTAG, "Received Contact:Add message with no email nor phone number"); + } + + } else if ("DevToolsAuth:Scan".equals(event)) { + DevToolsAuthHelper.scan(this, callback); + + } else if ("DOMFullScreen:Start".equals(event)) { + // Local ref to layerView for thread safety + LayerView layerView = mLayerView; + if (layerView != null) { + layerView.setFullScreenState(message.getBoolean("rootElement") + ? FullScreenState.ROOT_ELEMENT : FullScreenState.NON_ROOT_ELEMENT); + } + + } else if ("DOMFullScreen:Stop".equals(event)) { + // Local ref to layerView for thread safety + LayerView layerView = mLayerView; + if (layerView != null) { + layerView.setFullScreenState(FullScreenState.NONE); + } + + } else if ("Image:SetAs".equals(event)) { + String src = message.getString("url"); + setImageAs(src); + + } else if ("Locale:Set".equals(event)) { + setLocale(message.getString("locale")); + + } else if ("Permissions:Data".equals(event)) { + final NativeJSObject[] permissions = message.getObjectArray("permissions"); + showSiteSettingsDialog(permissions); + + } else if ("PrivateBrowsing:Data".equals(event)) { + mPrivateBrowsingSession = message.optString("session", null); + + } else if ("Session:StatePurged".equals(event)) { + onStatePurged(); + + } else if ("Sanitize:Finished".equals(event)) { + if (message.getBoolean("shutdown")) { + // Gecko is shutting down and has called our sanitize handlers, + // so we can start exiting, too. + doShutdown(); + } + + } else if ("Share:Text".equals(event)) { + final String text = message.getString("text"); + final Tab tab = Tabs.getInstance().getSelectedTab(); + String title = ""; + if (tab != null) { + title = tab.getDisplayTitle(); + } + IntentHelper.openUriExternal(text, "text/plain", "", "", Intent.ACTION_SEND, title, false); + + // Context: Sharing via chrome list (no explicit session is active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "text"); + + } else if ("Snackbar:Show".equals(event)) { + SnackbarBuilder.builder(this) + .fromEvent(message) + .callback(callback) + .buildAndShow(); + + } else if ("SystemUI:Visibility".equals(event)) { + setSystemUiVisible(message.getBoolean("visible")); + + } else if ("ToggleChrome:Focus".equals(event)) { + focusChrome(); + + } else if ("ToggleChrome:Hide".equals(event)) { + toggleChrome(false); + + } else if ("ToggleChrome:Show".equals(event)) { + toggleChrome(true); + + } else if ("Update:Check".equals(event)) { + UpdateServiceHelper.checkForUpdate(this); + } else if ("Update:Download".equals(event)) { + UpdateServiceHelper.downloadUpdate(this); + } else if ("Update:Install".equals(event)) { + UpdateServiceHelper.applyUpdate(this); + } else if ("RuntimePermissions:Prompt".equals(event)) { + String[] permissions = message.getStringArray("permissions"); + if (callback == null || permissions == null) { + return; + } + + Permissions.from(this) + .withPermissions(permissions) + .andFallback(new Runnable() { + @Override + public void run() { + callback.sendSuccess(false); + } + }) + .run(new Runnable() { + @Override + public void run() { + callback.sendSuccess(true); + } + }); + } + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("Gecko:Ready")) { + mGeckoReadyStartupTimer.stop(); + geckoConnected(); + + // This method is already running on the background thread, so we + // know that mHealthRecorder will exist. That doesn't stop us being + // paranoid. + // This method is cheap, so don't spawn a new runnable. + final HealthRecorder rec = mHealthRecorder; + if (rec != null) { + rec.recordGeckoStartupTime(mGeckoReadyStartupTimer.getElapsed()); + } + + GeckoApplication.get().onDelayedStartup(); + + } else if (event.equals("Gecko:Exited")) { + // Gecko thread exited first; let GeckoApp die too. + doShutdown(); + return; + + } else if (event.equals("Accessibility:Event")) { + GeckoAccessibility.sendAccessibilityEvent(message); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + void onStatePurged() { } + + /** + * @param permissions + * Array of JSON objects to represent site permissions. + * Example: { type: "offline-app", setting: "Store Offline Data", value: "Allow" } + */ + private void showSiteSettingsDialog(final NativeJSObject[] permissions) { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.site_settings_title); + + final ArrayList<HashMap<String, String>> itemList = + new ArrayList<HashMap<String, String>>(); + for (final NativeJSObject permObj : permissions) { + final HashMap<String, String> map = new HashMap<String, String>(); + map.put("setting", permObj.getString("setting")); + map.put("value", permObj.getString("value")); + itemList.add(map); + } + + // setMultiChoiceItems doesn't support using an adapter, so we're creating a hack with + // setSingleChoiceItems and changing the choiceMode below when we create the dialog + builder.setSingleChoiceItems(new SimpleAdapter( + GeckoApp.this, + itemList, + R.layout.site_setting_item, + new String[] { "setting", "value" }, + new int[] { R.id.setting, R.id.value } + ), -1, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { } + }); + + builder.setPositiveButton(R.string.site_settings_clear, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + ListView listView = ((AlertDialog) dialog).getListView(); + SparseBooleanArray checkedItemPositions = listView.getCheckedItemPositions(); + + // An array of the indices of the permissions we want to clear + JSONArray permissionsToClear = new JSONArray(); + for (int i = 0; i < checkedItemPositions.size(); i++) + if (checkedItemPositions.get(i)) + permissionsToClear.put(i); + + GeckoAppShell.notifyObservers("Permissions:Clear", permissionsToClear.toString()); + } + }); + + builder.setNegativeButton(R.string.site_settings_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + AlertDialog dialog = builder.create(); + dialog.show(); + + final ListView listView = dialog.getListView(); + if (listView != null) { + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + } + + final Button clearButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + clearButton.setEnabled(false); + + dialog.getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) { + if (listView.getCheckedItemCount() == 0) { + clearButton.setEnabled(false); + } else { + clearButton.setEnabled(true); + } + } + }); + } + }); + } + + + + /* package */ void addFullScreenPluginView(View view) { + if (mFullScreenPluginView != null) { + Log.w(LOGTAG, "Already have a fullscreen plugin view"); + return; + } + + setFullScreen(true); + + view.setWillNotDraw(false); + if (view instanceof SurfaceView) { + ((SurfaceView) view).setZOrderOnTop(true); + } + + mFullScreenPluginContainer = new FullScreenHolder(this); + + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + Gravity.CENTER); + mFullScreenPluginContainer.addView(view, layoutParams); + + + FrameLayout decor = (FrameLayout)getWindow().getDecorView(); + decor.addView(mFullScreenPluginContainer, layoutParams); + + mFullScreenPluginView = view; + } + + @Override + public void addPluginView(final View view) { + + if (ThreadUtils.isOnUiThread()) { + addFullScreenPluginView(view); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + addFullScreenPluginView(view); + } + }); + } + } + + /* package */ void removeFullScreenPluginView(View view) { + if (mFullScreenPluginView == null) { + Log.w(LOGTAG, "Don't have a fullscreen plugin view"); + return; + } + + if (mFullScreenPluginView != view) { + Log.w(LOGTAG, "Passed view is not the current full screen view"); + return; + } + + mFullScreenPluginContainer.removeView(mFullScreenPluginView); + + // We need do do this on the next iteration in order to avoid + // a deadlock, see comment below in FullScreenHolder + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mLayerView.showSurface(); + } + }); + + FrameLayout decor = (FrameLayout)getWindow().getDecorView(); + decor.removeView(mFullScreenPluginContainer); + + mFullScreenPluginView = null; + + GeckoScreenOrientation.getInstance().unlock(); + setFullScreen(false); + } + + @Override + public void removePluginView(final View view) { + if (ThreadUtils.isOnUiThread()) { + removePluginView(view); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + removeFullScreenPluginView(view); + } + }); + } + } + + // This method starts downloading an image synchronously and displays the Chooser activity to set the image as wallpaper. + private void setImageAs(final String aSrc) { + boolean isDataURI = aSrc.startsWith("data:"); + Bitmap image = null; + InputStream is = null; + ByteArrayOutputStream os = null; + try { + if (isDataURI) { + int dataStart = aSrc.indexOf(","); + byte[] buf = Base64.decode(aSrc.substring(dataStart + 1), Base64.DEFAULT); + image = BitmapUtils.decodeByteArray(buf); + } else { + int byteRead; + byte[] buf = new byte[4192]; + os = new ByteArrayOutputStream(); + URL url = new URL(aSrc); + is = url.openStream(); + + // Cannot read from same stream twice. Also, InputStream from + // URL does not support reset. So converting to byte array. + + while ((byteRead = is.read(buf)) != -1) { + os.write(buf, 0, byteRead); + } + byte[] imgBuffer = os.toByteArray(); + image = BitmapUtils.decodeByteArray(imgBuffer); + } + if (image != null) { + // Some devices don't have a DCIM folder and the Media.insertImage call will fail. + File dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + + if (!dcimDir.mkdirs() && !dcimDir.isDirectory()) { + SnackbarBuilder.builder(this) + .message(R.string.set_image_path_fail) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + return; + } + String path = Media.insertImage(getContentResolver(), image, null, null); + if (path == null) { + SnackbarBuilder.builder(this) + .message(R.string.set_image_path_fail) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + return; + } + final Intent intent = new Intent(Intent.ACTION_ATTACH_DATA); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setData(Uri.parse(path)); + + // Removes the image from storage once the chooser activity ends. + Intent chooser = Intent.createChooser(intent, getString(R.string.set_image_chooser_title)); + ActivityResultHandler handler = new ActivityResultHandler() { + @Override + public void onActivityResult (int resultCode, Intent data) { + getContentResolver().delete(intent.getData(), null, null); + } + }; + ActivityHandlerHelper.startIntentForActivity(this, chooser, handler); + } else { + SnackbarBuilder.builder(this) + .message(R.string.set_image_fail) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + } catch (OutOfMemoryError ome) { + Log.e(LOGTAG, "Out of Memory when converting to byte array", ome); + } catch (IOException ioe) { + Log.e(LOGTAG, "I/O Exception while setting wallpaper", ioe); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException ioe) { + Log.w(LOGTAG, "I/O Exception while closing stream", ioe); + } + } + if (os != null) { + try { + os.close(); + } catch (IOException ioe) { + Log.w(LOGTAG, "I/O Exception while closing stream", ioe); + } + } + } + } + + private int getBitmapSampleSize(BitmapFactory.Options options, int idealWidth, int idealHeight) { + int width = options.outWidth; + int height = options.outHeight; + int inSampleSize = 1; + if (height > idealHeight || width > idealWidth) { + if (width > height) { + inSampleSize = Math.round((float)height / idealHeight); + } else { + inSampleSize = Math.round((float)width / idealWidth); + } + } + return inSampleSize; + } + + public void requestRender() { + mLayerView.requestRender(); + } + + @Override + public void setFullScreen(final boolean fullscreen) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + ActivityUtils.setFullScreen(GeckoApp.this, fullscreen); + } + }); + } + + /** + * Check and start the Java profiler if MOZ_PROFILER_STARTUP env var is specified. + **/ + protected static void earlyStartJavaSampler(SafeIntent intent) { + String env = intent.getStringExtra("env0"); + for (int i = 1; env != null; i++) { + if (env.startsWith("MOZ_PROFILER_STARTUP=")) { + if (!env.endsWith("=")) { + GeckoJavaSampler.start(10, 1000); + Log.d(LOGTAG, "Profiling Java on startup"); + } + break; + } + env = intent.getStringExtra("env" + i); + } + } + + /** + * Called when the activity is first created. + * + * Here we initialize all of our profile settings, Firefox Health Report, + * and other one-shot constructions. + **/ + @Override + public void onCreate(Bundle savedInstanceState) { + GeckoAppShell.ensureCrashHandling(); + + eventDispatcher = new EventDispatcher(); + + // Enable Android Strict Mode for developers' local builds (the "default" channel). + if ("default".equals(AppConstants.MOZ_UPDATE_CHANNEL)) { + enableStrictMode(); + } + + if (!HardwareUtils.isSupportedSystem()) { + // This build does not support the Android version of the device: Show an error and finish the app. + mIsAbortingAppLaunch = true; + super.onCreate(savedInstanceState); + showSDKVersionError(); + finish(); + return; + } + + // The clock starts...now. Better hurry! + mJavaUiStartupTimer = new Telemetry.UptimeTimer("FENNEC_STARTUP_TIME_JAVAUI"); + mGeckoReadyStartupTimer = new Telemetry.UptimeTimer("FENNEC_STARTUP_TIME_GECKOREADY"); + + final SafeIntent intent = new SafeIntent(getIntent()); + + earlyStartJavaSampler(intent); + + // GeckoLoader wants to dig some environment variables out of the + // incoming intent, so pass it in here. GeckoLoader will do its + // business later and dispose of the reference. + GeckoLoader.setLastIntent(intent); + + // Workaround for <http://code.google.com/p/android/issues/detail?id=20915>. + try { + Class.forName("android.os.AsyncTask"); + } catch (ClassNotFoundException e) { } + + MemoryMonitor.getInstance().init(getApplicationContext()); + + // GeckoAppShell is tightly coupled to us, rather than + // the app context, because various parts of Fennec (e.g., + // GeckoScreenOrientation) use GAS to access the Activity in + // the guise of fetching a Context. + // When that's fixed, `this` can change to + // `(GeckoApplication) getApplication()` here. + GeckoAppShell.setContextGetter(this); + GeckoAppShell.setGeckoInterface(this); + + // Tell Stumbler to register a local broadcast listener to listen for preference intents. + // We do this via intents since we can't easily access Stumbler directly, + // as it might be compiled outside of Fennec. + getApplicationContext().sendBroadcast( + new Intent(INTENT_REGISTER_STUMBLER_LISTENER) + ); + + // Did the OS locale change while we were backgrounded? If so, + // we need to die so that Gecko will re-init add-ons that touch + // the UI. + // This is using a sledgehammer to crack a nut, but it'll do for + // now. + // Our OS locale pref will be detected as invalid after the + // restart, and will be propagated to Gecko accordingly, so there's + // no need to touch that here. + if (BrowserLocaleManager.getInstance().systemLocaleDidChange()) { + Log.i(LOGTAG, "System locale changed. Restarting."); + doRestart(); + return; + } + + if (sAlreadyLoaded) { + // This happens when the GeckoApp activity is destroyed by Android + // without killing the entire application (see Bug 769269). + mIsRestoringActivity = true; + Telemetry.addToHistogram("FENNEC_RESTORING_ACTIVITY", 1); + + } else { + final String action = intent.getAction(); + final String args = intent.getStringExtra("args"); + + sAlreadyLoaded = true; + GeckoThread.init(/* profile */ null, args, action, + /* debugging */ ACTION_DEBUG.equals(action)); + + // Speculatively pre-fetch the profile in the background. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + getProfile(); + } + }); + + final String uri = getURIFromIntent(intent); + if (!TextUtils.isEmpty(uri)) { + // Start a speculative connection as soon as Gecko loads. + GeckoThread.speculativeConnect(uri); + } + } + + // GeckoThread has to register for "Gecko:Ready" first, so GeckoApp registers + // for events after initializing GeckoThread but before launching it. + + getAppEventDispatcher().registerGeckoThreadListener((GeckoEventListener)this, + "Gecko:Ready", + "Gecko:Exited", + "Accessibility:Event"); + + getAppEventDispatcher().registerGeckoThreadListener((NativeEventListener)this, + "Accessibility:Ready", + "Bookmark:Insert", + "Contact:Add", + "DevToolsAuth:Scan", + "DOMFullScreen:Start", + "DOMFullScreen:Stop", + "Image:SetAs", + "Locale:Set", + "Permissions:Data", + "PrivateBrowsing:Data", + "RuntimePermissions:Prompt", + "Sanitize:Finished", + "Session:StatePurged", + "Share:Text", + "Snackbar:Show", + "SystemUI:Visibility", + "ToggleChrome:Focus", + "ToggleChrome:Hide", + "ToggleChrome:Show", + "Update:Check", + "Update:Download", + "Update:Install"); + + GeckoThread.launch(); + + Bundle stateBundle = IntentUtils.getBundleExtraSafe(getIntent(), EXTRA_STATE_BUNDLE); + if (stateBundle != null) { + // Use the state bundle if it was given as an intent extra. This is + // only intended to be used internally via Robocop, so a boolean + // is read from a private shared pref to prevent other apps from + // injecting states. + final SharedPreferences prefs = getSharedPreferences(); + if (prefs.getBoolean(PREFS_ALLOW_STATE_BUNDLE, false)) { + prefs.edit().remove(PREFS_ALLOW_STATE_BUNDLE).apply(); + savedInstanceState = stateBundle; + } + } else if (savedInstanceState != null) { + // Bug 896992 - This intent has already been handled; reset the intent. + setIntent(new Intent(Intent.ACTION_MAIN)); + } + + super.onCreate(savedInstanceState); + + GeckoScreenOrientation.getInstance().update(getResources().getConfiguration().orientation); + + setContentView(getLayout()); + + // Set up Gecko layout. + mRootLayout = (RelativeLayout) findViewById(R.id.root_layout); + mGeckoLayout = (RelativeLayout) findViewById(R.id.gecko_layout); + mMainLayout = (RelativeLayout) findViewById(R.id.main_layout); + mLayerView = (GeckoView) findViewById(R.id.layer_view); + + Tabs.getInstance().attachToContext(this, mLayerView); + + // Use global layout state change to kick off additional initialization + mMainLayout.getViewTreeObserver().addOnGlobalLayoutListener(this); + + if (Versions.preMarshmallow) { + mTextSelection = new ActionBarTextSelection(this); + } else { + mTextSelection = new FloatingToolbarTextSelection(this, mLayerView); + } + mTextSelection.create(); + + // Determine whether we should restore tabs. + mLastSessionCrashed = updateCrashedState(); + mShouldRestore = getSessionRestoreState(savedInstanceState); + if (mShouldRestore && savedInstanceState != null) { + boolean wasInBackground = + savedInstanceState.getBoolean(SAVED_STATE_IN_BACKGROUND, false); + + // Don't log OOM-kills if only one activity was destroyed. (For example + // from "Don't keep activities" on ICS) + if (!wasInBackground && !mIsRestoringActivity) { + Telemetry.addToHistogram("FENNEC_WAS_KILLED", 1); + } + + mPrivateBrowsingSession = savedInstanceState.getString(SAVED_STATE_PRIVATE_SESSION); + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // If we are doing a restore, read the session data so we can send it to Gecko later. + String restoreMessage = null; + if (!mIsRestoringActivity && mShouldRestore) { + final boolean isExternalURL = invokedWithExternalURL(getIntentURI(new SafeIntent(getIntent()))); + try { + // restoreSessionTabs() will create simple tab stubs with the + // URL and title for each page, but we also need to restore + // session history. restoreSessionTabs() will inject the IDs + // of the tab stubs into the JSON data (which holds the session + // history). This JSON data is then sent to Gecko so session + // history can be restored for each tab. + restoreMessage = restoreSessionTabs(isExternalURL, false); + } catch (SessionRestoreException e) { + // If mShouldRestore was set to false in restoreSessionTabs(), this means + // either that we intentionally skipped all tabs read from the session file, + // or else that the file was syntactically valid, but didn't contain any + // tabs (e.g. because the user cleared history), therefore we don't need + // to switch to the backup copy. + if (mShouldRestore) { + Log.e(LOGTAG, "An error occurred during restore, switching to backup file", e); + // To be on the safe side, we will always attempt to restore from the backup + // copy if we end up here. + // Since we will also hit this situation regularly during first run though, + // we'll only report it in telemetry if we failed to restore despite the + // file existing, which means it's very probably damaged. + if (getProfile().sessionFileExists()) { + Telemetry.addToHistogram("FENNEC_SESSIONSTORE_DAMAGED_SESSION_FILE", 1); + } + try { + restoreMessage = restoreSessionTabs(isExternalURL, true); + Telemetry.addToHistogram("FENNEC_SESSIONSTORE_RESTORING_FROM_BACKUP", 1); + } catch (SessionRestoreException ex) { + if (!mShouldRestore) { + // Restoring only "failed" because the backup copy was deliberately empty, too. + Telemetry.addToHistogram("FENNEC_SESSIONSTORE_RESTORING_FROM_BACKUP", 1); + } else { + // Restoring the backup failed, too, so do a normal startup. + Log.e(LOGTAG, "An error occurred during restore", ex); + mShouldRestore = false; + } + } + } + } + } + + synchronized (GeckoApp.this) { + mSessionRestoreParsingFinished = true; + GeckoApp.this.notifyAll(); + } + + // If we are doing a restore, send the parsed session data to Gecko. + if (!mIsRestoringActivity) { + GeckoAppShell.notifyObservers("Session:Restore", restoreMessage); + } + + // Make sure sessionstore.old is either updated or deleted as necessary. + getProfile().updateSessionFile(mShouldRestore); + } + }); + + // Perform background initialization. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final SharedPreferences prefs = GeckoApp.this.getSharedPreferences(); + + // Wait until now to set this, because we'd rather throw an exception than + // have a caller of BrowserLocaleManager regress startup. + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + localeManager.initialize(getApplicationContext()); + + SessionInformation previousSession = SessionInformation.fromSharedPrefs(prefs); + if (previousSession.wasKilled()) { + Telemetry.addToHistogram("FENNEC_WAS_KILLED", 1); + } + + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(GeckoAppShell.PREFS_OOM_EXCEPTION, false); + + // Put a flag to check if we got a normal `onSaveInstanceState` + // on exit, or if we were suddenly killed (crash or native OOM). + editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); + + editor.apply(); + + // The lifecycle of mHealthRecorder is "shortly after onCreate" + // through "onDestroy" -- essentially the same as the lifecycle + // of the activity itself. + final String profilePath = getProfile().getDir().getAbsolutePath(); + final EventDispatcher dispatcher = getAppEventDispatcher(); + + // This is the locale prior to fixing it up. + final Locale osLocale = Locale.getDefault(); + + // Both of these are Java-format locale strings: "en_US", not "en-US". + final String osLocaleString = osLocale.toString(); + String appLocaleString = localeManager.getAndApplyPersistedLocale(GeckoApp.this); + Log.d(LOGTAG, "OS locale is " + osLocaleString + ", app locale is " + appLocaleString); + + if (appLocaleString == null) { + appLocaleString = osLocaleString; + } + + mHealthRecorder = GeckoApp.this.createHealthRecorder(GeckoApp.this, + profilePath, + dispatcher, + osLocaleString, + appLocaleString, + previousSession); + + final String uiLocale = appLocaleString; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + GeckoApp.this.onLocaleReady(uiLocale); + } + }); + + // We use per-profile prefs here, because we're tracking against + // a Gecko pref. The same applies to the locale switcher! + BrowserLocaleManager.storeAndNotifyOSLocale(GeckoSharedPrefs.forProfile(GeckoApp.this), osLocale); + } + }); + + IntentHelper.init(this); + } + + @Override + public void onStart() { + super.onStart(); + if (mIsAbortingAppLaunch) { + return; + } + + mWasFirstTabShownAfterActivityUnhidden = false; // onStart indicates we were hidden. + } + + @Override + protected void onStop() { + super.onStop(); + // Overriding here is not necessary, but we do this so we don't + // forget to add the abort if we override this method later. + if (mIsAbortingAppLaunch) { + return; + } + } + + /** + * At this point, the resource system and the rest of the browser are + * aware of the locale. + * + * Now we can display strings! + * + * You can think of this as being something like a second phase of onCreate, + * where you can do string-related operations. Use this in place of embedding + * strings in view XML. + * + * By contrast, onConfigurationChanged does some locale operations, but is in + * response to device changes. + */ + @Override + public void onLocaleReady(final String locale) { + if (!ThreadUtils.isOnUiThread()) { + throw new RuntimeException("onLocaleReady must always be called from the UI thread."); + } + + final Locale loc = Locales.parseLocaleCode(locale); + if (loc.equals(mLastLocale)) { + Log.d(LOGTAG, "New locale same as old; onLocaleReady has nothing to do."); + } + + // The URL bar hint needs to be populated. + TextView urlBar = (TextView) findViewById(R.id.url_bar_title); + if (urlBar != null) { + final String hint = getResources().getString(R.string.url_bar_default_text); + urlBar.setHint(hint); + } else { + Log.d(LOGTAG, "No URL bar in GeckoApp. Not loading localized hint string."); + } + + mLastLocale = loc; + + // Allow onConfigurationChanged to take care of the rest. + // We don't call this.onConfigurationChanged, because (a) that does + // work that's unnecessary after this locale action, and (b) it can + // cause a loop! See Bug 1011008, Comment 12. + super.onConfigurationChanged(getResources().getConfiguration()); + } + + protected void initializeChrome() { + mDoorHangerPopup = new DoorHangerPopup(this); + mPluginContainer = (AbsoluteLayout) findViewById(R.id.plugin_container); + mFormAssistPopup = (FormAssistPopup) findViewById(R.id.form_assist_popup); + } + + /** + * Loads the initial tab at Fennec startup. If we don't restore tabs, this + * tab will be about:home, or the homepage if the user has set one. + * If we've temporarily disabled restoring to break out of a crash loop, we'll show + * the Recent Tabs folder of the Combined History panel, so the user can manually + * restore tabs as needed. + * If we restore tabs, we don't need to create a new tab. + */ + protected void loadStartupTab(final int flags) { + if (!mShouldRestore) { + if (mLastSessionCrashed) { + // The Recent Tabs panel no longer exists, but BrowserApp will redirect us + // to the Recent Tabs folder of the Combined History panel. + Tabs.getInstance().loadUrl(AboutPages.getURLForBuiltinPanelType(PanelType.DEPRECATED_RECENT_TABS), flags); + } else { + final String homepage = getHomepage(); + Tabs.getInstance().loadUrl(!TextUtils.isEmpty(homepage) ? homepage : AboutPages.HOME, flags); + } + } + } + + /** + * Loads the initial tab at Fennec startup. This tab will load with the given + * external URL. If that URL is invalid, a startup tab will be loaded. + * + * @param url External URL to load. + * @param intent External intent whose extras modify the request + * @param flags Flags used to load the load + */ + protected void loadStartupTab(final String url, final SafeIntent intent, final int flags) { + // Invalid url + if (url == null) { + loadStartupTab(flags); + return; + } + + Tabs.getInstance().loadUrlWithIntentExtras(url, intent, flags); + } + + public String getHomepage() { + return null; + } + + private String getIntentURI(SafeIntent intent) { + final String passedUri; + final String uri = getURIFromIntent(intent); + + if (!TextUtils.isEmpty(uri)) { + passedUri = uri; + } else { + passedUri = null; + } + return passedUri; + } + + private boolean invokedWithExternalURL(String uri) { + return uri != null && !AboutPages.isAboutHome(uri); + } + + private void initialize() { + mInitialized = true; + + final boolean isFirstTab = !mWasFirstTabShownAfterActivityUnhidden; + mWasFirstTabShownAfterActivityUnhidden = true; // Reset since we'll be loading a tab. + + final SafeIntent intent = new SafeIntent(getIntent()); + final String action = intent.getAction(); + + final String passedUri = getIntentURI(intent); + + final boolean isExternalURL = invokedWithExternalURL(passedUri); + + // Start migrating as early as possible, can do this in + // parallel with Gecko load. + checkMigrateProfile(); + + Tabs.registerOnTabsChangedListener(this); + + initializeChrome(); + + // We need to wait here because mShouldRestore can revert back to + // false if a parsing error occurs and the startup tab we load + // depends on whether we restore tabs or not. + synchronized (this) { + while (!mSessionRestoreParsingFinished) { + try { + wait(); + } catch (final InterruptedException e) { + // Ignore and wait again. + } + } + } + + // External URLs should always be loaded regardless of whether Gecko is + // already running. + if (isExternalURL) { + // Restore tabs before opening an external URL so that the new tab + // is animated properly. + Tabs.getInstance().notifyListeners(null, Tabs.TabEvents.RESTORED); + processActionViewIntent(new Runnable() { + @Override + public void run() { + int flags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_USER_ENTERED | Tabs.LOADURL_EXTERNAL; + if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) { + flags |= Tabs.LOADURL_PINNED; + } + if (isFirstTab) { + flags |= Tabs.LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN; + } + loadStartupTab(passedUri, intent, flags); + } + }); + } else { + if (!mIsRestoringActivity) { + loadStartupTab(Tabs.LOADURL_NEW_TAB); + } + + Tabs.getInstance().notifyListeners(null, Tabs.TabEvents.RESTORED); + + processTabQueue(); + } + + recordStartupActionTelemetry(passedUri, action); + + // Check if launched from data reporting notification. + if (ACTION_LAUNCH_SETTINGS.equals(action)) { + Intent settingsIntent = new Intent(GeckoApp.this, GeckoPreferences.class); + // Copy extras. + settingsIntent.putExtras(intent.getUnsafe()); + startActivity(settingsIntent); + } + + //app state callbacks + mAppStateListeners = new LinkedList<GeckoAppShell.AppStateListener>(); + + mPromptService = new PromptService(this); + + // Trigger the completion of the telemetry timer that wraps activity startup, + // then grab the duration to give to FHR. + mJavaUiStartupTimer.stop(); + final long javaDuration = mJavaUiStartupTimer.getElapsed(); + + ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() { + @Override + public void run() { + final HealthRecorder rec = mHealthRecorder; + if (rec != null) { + rec.recordJavaStartupTime(javaDuration); + } + } + }, 50); + + final int updateServiceDelay = 30 * 1000; + ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() { + @Override + public void run() { + UpdateServiceHelper.registerForUpdates(GeckoAppShell.getApplicationContext()); + } + }, updateServiceDelay); + + if (mIsRestoringActivity) { + Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + Tabs.getInstance().notifyListeners(selectedTab, Tabs.TabEvents.SELECTED); + } + + if (GeckoThread.isRunning()) { + geckoConnected(); + if (mLayerView != null) { + mLayerView.setPaintState(LayerView.PAINT_BEFORE_FIRST); + } + } + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void onGlobalLayout() { + if (Versions.preJB) { + mMainLayout.getViewTreeObserver().removeGlobalOnLayoutListener(this); + } else { + mMainLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + if (!mInitialized) { + initialize(); + } + } + + protected void processActionViewIntent(final Runnable openTabsRunnable) { + // We need to ensure that if we receive a VIEW action and there are tabs queued then the + // site loaded from the intent is on top (last loaded) and selected with all other tabs + // being opened behind it. We process the tab queue first and request a callback from the JS - the + // listener will open the url from the intent as normal when the tab queue has been processed. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + if (TabQueueHelper.TAB_QUEUE_ENABLED && TabQueueHelper.shouldOpenTabQueueUrls(GeckoApp.this)) { + + getAppEventDispatcher().registerGeckoThreadListener(new NativeEventListener() { + @Override + public void handleMessage(String event, NativeJSObject message, EventCallback callback) { + if ("Tabs:TabsOpened".equals(event)) { + getAppEventDispatcher().unregisterGeckoThreadListener(this, "Tabs:TabsOpened"); + openTabsRunnable.run(); + } + } + }, "Tabs:TabsOpened"); + TabQueueHelper.openQueuedUrls(GeckoApp.this, getProfile(), TabQueueHelper.FILE_NAME, true); + } else { + openTabsRunnable.run(); + } + } + }); + } + + @WorkerThread + private String restoreSessionTabs(final boolean isExternalURL, boolean useBackup) throws SessionRestoreException { + try { + String sessionString = getProfile().readSessionFile(useBackup); + if (sessionString == null) { + throw new SessionRestoreException("Could not read from session file"); + } + + // If we are doing an OOM restore, parse the session data and + // stub the restored tabs immediately. This allows the UI to be + // updated before Gecko has restored. + final JSONArray tabs = new JSONArray(); + final JSONObject windowObject = new JSONObject(); + final boolean sessionDataValid; + + LastSessionParser parser = new LastSessionParser(tabs, windowObject, isExternalURL); + + if (mPrivateBrowsingSession == null) { + sessionDataValid = parser.parse(sessionString); + } else { + sessionDataValid = parser.parse(sessionString, mPrivateBrowsingSession); + } + + if (tabs.length() > 0) { + windowObject.put("tabs", tabs); + sessionString = new JSONObject().put("windows", new JSONArray().put(windowObject)).toString(); + } else { + if (parser.allTabsSkipped() || sessionDataValid) { + // If we intentionally skipped all tabs we've read from the session file, we + // set mShouldRestore back to false at this point already, so the calling code + // can infer that the exception wasn't due to a damaged session store file. + // The same applies if the session file was syntactically valid and + // simply didn't contain any tabs. + mShouldRestore = false; + } + throw new SessionRestoreException("No tabs could be read from session file"); + } + + JSONObject restoreData = new JSONObject(); + restoreData.put("sessionString", sessionString); + return restoreData.toString(); + } catch (JSONException e) { + throw new SessionRestoreException(e); + } + } + + public static EventDispatcher getEventDispatcher() { + final GeckoApp geckoApp = (GeckoApp) GeckoAppShell.getGeckoInterface(); + return geckoApp.getAppEventDispatcher(); + } + + @Override + public EventDispatcher getAppEventDispatcher() { + return eventDispatcher; + } + + @Override + public GeckoProfile getProfile() { + return GeckoThread.getActiveProfile(); + } + + /** + * Check whether we've crashed during the last browsing session. + * + * @return True if the crash reporter ran after the last session. + */ + protected boolean updateCrashedState() { + try { + File crashFlag = new File(GeckoProfileDirectories.getMozillaDirectory(this), "CRASHED"); + if (crashFlag.exists() && crashFlag.delete()) { + // Set the flag that indicates we were stopped as expected, as + // the crash reporter has run, so it is not a silent OOM crash. + getSharedPreferences().edit().putBoolean(PREFS_WAS_STOPPED, true).apply(); + return true; + } + } catch (NoMozillaDirectoryException e) { + // If we can't access the Mozilla directory, we're in trouble anyway. + Log.e(LOGTAG, "Cannot read crash flag: ", e); + } + return false; + } + + /** + * Determine whether the session should be restored. + * + * @param savedInstanceState Saved instance state given to the activity + * @return Whether to restore + */ + protected boolean getSessionRestoreState(Bundle savedInstanceState) { + final SharedPreferences prefs = getSharedPreferences(); + boolean shouldRestore = false; + + final int versionCode = getVersionCode(); + if (mLastSessionCrashed) { + if (incrementCrashCount(prefs) <= getSessionStoreMaxCrashResumes(prefs) && + getSessionRestoreAfterCrashPreference(prefs)) { + shouldRestore = true; + } else { + shouldRestore = false; + } + } else if (prefs.getInt(PREFS_VERSION_CODE, 0) != versionCode) { + // If the version has changed, the user has done an upgrade, so restore + // previous tabs. + prefs.edit().putInt(PREFS_VERSION_CODE, versionCode).apply(); + shouldRestore = true; + } else if (savedInstanceState != null || + getSessionRestorePreference(prefs).equals("always") || + getRestartFromIntent()) { + // We're coming back from a background kill by the OS, the user + // has chosen to always restore, or we restarted. + shouldRestore = true; + } + + return shouldRestore; + } + + private int incrementCrashCount(SharedPreferences prefs) { + final int crashCount = getSuccessiveCrashesCount(prefs) + 1; + prefs.edit().putInt(PREFS_CRASHED_COUNT, crashCount).apply(); + return crashCount; + } + + private int getSuccessiveCrashesCount(SharedPreferences prefs) { + return prefs.getInt(PREFS_CRASHED_COUNT, 0); + } + + private int getSessionStoreMaxCrashResumes(SharedPreferences prefs) { + return prefs.getInt(GeckoPreferences.PREFS_RESTORE_SESSION_MAX_CRASH_RESUMES, 1); + } + + private boolean getSessionRestoreAfterCrashPreference(SharedPreferences prefs) { + return prefs.getBoolean(GeckoPreferences.PREFS_RESTORE_SESSION_FROM_CRASH, true); + } + + private String getSessionRestorePreference(SharedPreferences prefs) { + return prefs.getString(GeckoPreferences.PREFS_RESTORE_SESSION, "always"); + } + + private boolean getRestartFromIntent() { + return IntentUtils.getBooleanExtraSafe(getIntent(), "didRestart", false); + } + + /** + * Enable Android StrictMode checks (for supported OS versions). + * http://developer.android.com/reference/android/os/StrictMode.html + */ + private void enableStrictMode() { + Log.d(LOGTAG, "Enabling Android StrictMode"); + + StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build()); + + StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build()); + } + + @Override + public void enableOrientationListener() { + // Start listening for orientation events + mCameraOrientationEventListener = new OrientationEventListener(this) { + @Override + public void onOrientationChanged(int orientation) { + if (mAppStateListeners != null) { + for (GeckoAppShell.AppStateListener listener: mAppStateListeners) { + listener.onOrientationChanged(); + } + } + } + }; + mCameraOrientationEventListener.enable(); + } + + @Override + public void disableOrientationListener() { + if (mCameraOrientationEventListener != null) { + mCameraOrientationEventListener.disable(); + mCameraOrientationEventListener = null; + } + } + + @Override + public String getDefaultUAString() { + return HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET : + AppConstants.USER_AGENT_FENNEC_MOBILE; + } + + @Override + public void createShortcut(final String title, final String url) { + Icons.with(this) + .pageUrl(url) + .skipNetwork() + .skipMemory() + .forLauncherIcon() + .build() + .execute(new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + doCreateShortcut(title, url, response.getBitmap()); + } + }); + } + + private void doCreateShortcut(final String aTitle, final String aURI, final Bitmap aIcon) { + // The intent to be launched by the shortcut. + Intent shortcutIntent = new Intent(); + shortcutIntent.setAction(GeckoApp.ACTION_HOMESCREEN_SHORTCUT); + shortcutIntent.setData(Uri.parse(aURI)); + shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, + AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + + Intent intent = new Intent(); + intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent); + intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, getLauncherIcon(aIcon, GeckoAppShell.getPreferredIconSize())); + + if (aTitle != null) { + intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, aTitle); + } else { + intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, aURI); + } + + // Do not allow duplicate items. + intent.putExtra("duplicate", false); + + intent.setAction("com.android.launcher.action.INSTALL_SHORTCUT"); + getApplicationContext().sendBroadcast(intent); + + // Remember interaction + final UrlAnnotations urlAnnotations = BrowserDB.from(getApplicationContext()).getUrlAnnotations(); + urlAnnotations.insertHomeScreenShortcut(getContentResolver(), aURI, true); + + // After shortcut is created, show the mobile desktop. + ActivityUtils.goToHomeScreen(this); + } + + private Bitmap getLauncherIcon(Bitmap aSource, int size) { + final float[] DEFAULT_LAUNCHER_ICON_HSV = { 32.0f, 1.0f, 1.0f }; + final int kOffset = 6; + final int kRadius = 5; + + int insetSize = aSource != null ? size * 2 / 3 : size; + + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + // draw a base color + Paint paint = new Paint(); + if (aSource == null) { + // If we aren't drawing a favicon, just use an orange color. + paint.setColor(Color.HSVToColor(DEFAULT_LAUNCHER_ICON_HSV)); + canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint); + } else if (aSource.getWidth() >= insetSize || aSource.getHeight() >= insetSize) { + // Otherwise, if the icon is large enough, just draw it. + Rect iconBounds = new Rect(0, 0, size, size); + canvas.drawBitmap(aSource, null, iconBounds, null); + return bitmap; + } else { + // otherwise use the dominant color from the icon + a layer of transparent white to lighten it somewhat + int color = BitmapUtils.getDominantColor(aSource); + paint.setColor(color); + canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint); + paint.setColor(Color.argb(100, 255, 255, 255)); + canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint); + } + + // draw the overlay + Bitmap overlay = BitmapUtils.decodeResource(this, R.drawable.home_bg); + canvas.drawBitmap(overlay, null, new Rect(0, 0, size, size), null); + + // draw the favicon + if (aSource == null) + aSource = BitmapUtils.decodeResource(this, R.drawable.home_star); + + // by default, we scale the icon to this size + int sWidth = insetSize / 2; + int sHeight = sWidth; + + int halfSize = size / 2; + canvas.drawBitmap(aSource, + null, + new Rect(halfSize - sWidth, + halfSize - sHeight, + halfSize + sWidth, + halfSize + sHeight), + null); + + return bitmap; + } + + @Override + protected void onNewIntent(Intent externalIntent) { + final SafeIntent intent = new SafeIntent(externalIntent); + + final boolean isFirstTab = !mWasFirstTabShownAfterActivityUnhidden; + mWasFirstTabShownAfterActivityUnhidden = true; // Reset since we'll be loading a tab. + + // if we were previously OOM killed, we can end up here when launching + // from external shortcuts, so set this as the intent for initialization + if (!mInitialized) { + setIntent(externalIntent); + return; + } + + final String action = intent.getAction(); + + final String uri = getURIFromIntent(intent); + final String passedUri; + if (!TextUtils.isEmpty(uri)) { + passedUri = uri; + } else { + passedUri = null; + } + + if (ACTION_LOAD.equals(action)) { + Tabs.getInstance().loadUrl(intent.getDataString()); + lastSelectedTabId = -1; + } else if (Intent.ACTION_VIEW.equals(action)) { + processActionViewIntent(new Runnable() { + @Override + public void run() { + final String url = intent.getDataString(); + int flags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_USER_ENTERED | Tabs.LOADURL_EXTERNAL; + if (isFirstTab) { + flags |= Tabs.LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN; + } + Tabs.getInstance().loadUrlWithIntentExtras(url, intent, flags); + } + }); + lastSelectedTabId = -1; + } else if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) { + mLayerView.loadUri(uri, GeckoView.LOAD_SWITCH_TAB); + } else if (Intent.ACTION_SEARCH.equals(action)) { + mLayerView.loadUri(uri, GeckoView.LOAD_NEW_TAB); + } else if (NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) { + NotificationHelper.getInstance(getApplicationContext()).handleNotificationIntent(intent); + } else if (ACTION_LAUNCH_SETTINGS.equals(action)) { + // Check if launched from data reporting notification. + Intent settingsIntent = new Intent(GeckoApp.this, GeckoPreferences.class); + // Copy extras. + settingsIntent.putExtras(intent.getUnsafe()); + startActivity(settingsIntent); + } else if (ACTION_SWITCH_TAB.equals(action)) { + final int tabId = intent.getIntExtra("TabId", -1); + Tabs.getInstance().selectTab(tabId); + lastSelectedTabId = -1; + } + + recordStartupActionTelemetry(passedUri, action); + } + + /** + * Handles getting a URI from an intent in a way that is backwards- + * compatible with our previous implementations. + */ + protected String getURIFromIntent(SafeIntent intent) { + final String action = intent.getAction(); + if (ACTION_ALERT_CALLBACK.equals(action) || + NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) { + return null; + } + + return intent.getDataString(); + } + + protected int getOrientation() { + return GeckoScreenOrientation.getInstance().getAndroidOrientation(); + } + + @Override + public void onResume() + { + // After an onPause, the activity is back in the foreground. + // Undo whatever we did in onPause. + super.onResume(); + if (mIsAbortingAppLaunch) { + return; + } + + GeckoAppShell.setGeckoInterface(this); + + if (lastSelectedTabId >= 0 && (lastActiveGeckoApp == null || lastActiveGeckoApp.get() != this)) { + Tabs.getInstance().selectTab(lastSelectedTabId); + } + + int newOrientation = getResources().getConfiguration().orientation; + if (GeckoScreenOrientation.getInstance().update(newOrientation)) { + refreshChrome(); + } + + if (mAppStateListeners != null) { + for (GeckoAppShell.AppStateListener listener : mAppStateListeners) { + listener.onResume(); + } + } + + // We use two times: a pseudo-unique wall-clock time to identify the + // current session across power cycles, and the elapsed realtime to + // track the duration of the session. + final long now = System.currentTimeMillis(); + final long realTime = android.os.SystemClock.elapsedRealtime(); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // Now construct the new session on HealthRecorder's behalf. We do this here + // so it can benefit from a single near-startup prefs commit. + SessionInformation currentSession = new SessionInformation(now, realTime); + + SharedPreferences prefs = GeckoApp.this.getSharedPreferences(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); + + if (!mLastSessionCrashed) { + // The last session terminated normally, + // so we can reset the count of successive crashes. + editor.putInt(GeckoApp.PREFS_CRASHED_COUNT, 0); + } + + currentSession.recordBegin(editor); + editor.apply(); + + final HealthRecorder rec = mHealthRecorder; + if (rec != null) { + rec.setCurrentSession(currentSession); + rec.processDelayed(); + } else { + Log.w(LOGTAG, "Can't record session: rec is null."); + } + } + }); + + Restrictions.update(this); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + + if (!mWindowFocusInitialized && hasFocus) { + mWindowFocusInitialized = true; + // XXX our editor tests require the GeckoView to have focus to pass, so we have to + // manually shift focus to the GeckoView. requestFocus apparently doesn't work at + // this stage of starting up, so we have to unset and reset the focusability. + mLayerView.setFocusable(false); + mLayerView.setFocusable(true); + mLayerView.setFocusableInTouchMode(true); + getWindow().setBackgroundDrawable(null); + } + } + + @Override + public void onPause() + { + if (mIsAbortingAppLaunch) { + super.onPause(); + return; + } + + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + lastSelectedTabId = selectedTab.getId(); + } + lastActiveGeckoApp = new WeakReference<GeckoApp>(this); + + final HealthRecorder rec = mHealthRecorder; + final Context context = this; + + // In some way it's sad that Android will trigger StrictMode warnings + // here as the whole point is to save to disk while the activity is not + // interacting with the user. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + SharedPreferences prefs = GeckoApp.this.getSharedPreferences(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, true); + if (rec != null) { + rec.recordSessionEnd("P", editor); + } + + // onPause might in fact be called even after a crash, but in that case the + // crash reporter will record this fact for us and we'll pick it up in onCreate. + mLastSessionCrashed = false; + + // If we haven't done it before, cleanup any old files in our old temp dir + if (prefs.getBoolean(GeckoApp.PREFS_CLEANUP_TEMP_FILES, true)) { + File tempDir = GeckoLoader.getGREDir(GeckoApp.this); + FileUtils.delTree(tempDir, new FileUtils.NameAndAgeFilter(null, ONE_DAY_MS), false); + + editor.putBoolean(GeckoApp.PREFS_CLEANUP_TEMP_FILES, false); + } + + editor.apply(); + } + }); + + if (mAppStateListeners != null) { + for (GeckoAppShell.AppStateListener listener : mAppStateListeners) { + listener.onPause(); + } + } + + super.onPause(); + } + + @Override + public void onRestart() { + if (mIsAbortingAppLaunch) { + super.onRestart(); + return; + } + + // Faster on main thread with an async apply(). + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + try { + SharedPreferences.Editor editor = GeckoApp.this.getSharedPreferences().edit(); + editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false); + editor.apply(); + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + + super.onRestart(); + } + + @Override + public void onDestroy() { + if (mIsAbortingAppLaunch) { + // This build does not support the Android version of the device: + // We did not initialize anything, so skip cleaning up. + super.onDestroy(); + return; + } + + getAppEventDispatcher().unregisterGeckoThreadListener((GeckoEventListener)this, + "Gecko:Ready", + "Gecko:Exited", + "Accessibility:Event"); + + getAppEventDispatcher().unregisterGeckoThreadListener((NativeEventListener)this, + "Accessibility:Ready", + "Bookmark:Insert", + "Contact:Add", + "DevToolsAuth:Scan", + "DOMFullScreen:Start", + "DOMFullScreen:Stop", + "Image:SetAs", + "Locale:Set", + "Permissions:Data", + "PrivateBrowsing:Data", + "RuntimePermissions:Prompt", + "Sanitize:Finished", + "Session:StatePurged", + "Share:Text", + "Snackbar:Show", + "SystemUI:Visibility", + "ToggleChrome:Focus", + "ToggleChrome:Hide", + "ToggleChrome:Show", + "Update:Check", + "Update:Download", + "Update:Install"); + + if (mPromptService != null) + mPromptService.destroy(); + + final HealthRecorder rec = mHealthRecorder; + mHealthRecorder = null; + if (rec != null && rec.isEnabled()) { + // Closing a HealthRecorder could incur a write. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + rec.close(GeckoApp.this); + } + }); + } + + super.onDestroy(); + + Tabs.unregisterOnTabsChangedListener(this); + } + + public void showSDKVersionError() { + final String message = getString(R.string.unsupported_sdk_version, Build.CPU_ABI, Build.VERSION.SDK_INT); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } + + // Get a temporary directory, may return null + public static File getTempDirectory() { + File dir = GeckoApplication.get().getExternalFilesDir("temp"); + return dir; + } + + // Delete any files in our temporary directory + public static void deleteTempFiles() { + File dir = getTempDirectory(); + if (dir == null) + return; + File[] files = dir.listFiles(); + if (files == null) + return; + for (File file : files) { + file.delete(); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale); + + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + final Locale changed = localeManager.onSystemConfigurationChanged(this, getResources(), newConfig, mLastLocale); + if (changed != null) { + onLocaleChanged(Locales.getLanguageTag(changed)); + } + + // onConfigurationChanged is not called for 180 degree orientation changes, + // we will miss such rotations and the screen orientation will not be + // updated. + if (GeckoScreenOrientation.getInstance().update(newConfig.orientation)) { + if (mFormAssistPopup != null) + mFormAssistPopup.hide(); + refreshChrome(); + } + super.onConfigurationChanged(newConfig); + } + + public String getContentProcessName() { + return AppConstants.MOZ_CHILD_PROCESS_NAME; + } + + public void addEnvToIntent(Intent intent) { + Map<String, String> envMap = System.getenv(); + Set<Map.Entry<String, String>> envSet = envMap.entrySet(); + Iterator<Map.Entry<String, String>> envIter = envSet.iterator(); + int c = 0; + while (envIter.hasNext()) { + Map.Entry<String, String> entry = envIter.next(); + intent.putExtra("env" + c, entry.getKey() + "=" + + entry.getValue()); + c++; + } + } + + @Override + public void doRestart() { + doRestart(null, null); + } + + public void doRestart(String args) { + doRestart(args, null); + } + + public void doRestart(Intent intent) { + doRestart(null, intent); + } + + public void doRestart(String args, Intent restartIntent) { + if (restartIntent == null) { + restartIntent = new Intent(Intent.ACTION_MAIN); + } + + if (args != null) { + restartIntent.putExtra("args", args); + } + + mRestartIntent = restartIntent; + Log.d(LOGTAG, "doRestart(\"" + restartIntent + "\")"); + + doShutdown(); + } + + private void doShutdown() { + // Shut down GeckoApp activity. + runOnUiThread(new Runnable() { + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + @Override public void run() { + if (!isFinishing() && (Versions.preJBMR1 || !isDestroyed())) { + finish(); + } + } + }); + } + + private void checkMigrateProfile() { + final File profileDir = getProfile().getDir(); + + if (profileDir != null) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + Handler handler = new Handler(); + handler.postDelayed(new DeferredCleanupTask(), CLEANUP_DEFERRAL_SECONDS * 1000); + } + }); + } + } + + private static class DeferredCleanupTask implements Runnable { + // The cleanup-version setting is recorded to avoid repeating the same + // tasks on subsequent startups; CURRENT_CLEANUP_VERSION may be updated + // if we need to do additional cleanup for future Gecko versions. + + private static final String CLEANUP_VERSION = "cleanup-version"; + private static final int CURRENT_CLEANUP_VERSION = 1; + + @Override + public void run() { + final Context context = GeckoAppShell.getApplicationContext(); + long cleanupVersion = GeckoSharedPrefs.forApp(context).getInt(CLEANUP_VERSION, 0); + + if (cleanupVersion < 1) { + // Reduce device storage footprint by removing .ttf files from + // the res/fonts directory: we no longer need to copy our + // bundled fonts out of the APK in order to use them. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=878674. + File dir = new File("res/fonts"); + if (dir.exists() && dir.isDirectory()) { + for (File file : dir.listFiles()) { + if (file.isFile() && file.getName().endsWith(".ttf")) { + file.delete(); + } + } + if (!dir.delete()) { + Log.w(LOGTAG, "unable to delete res/fonts directory (not empty?)"); + } + } + } + + // Additional cleanup needed for future versions would go here + + if (cleanupVersion != CURRENT_CLEANUP_VERSION) { + SharedPreferences.Editor editor = GeckoSharedPrefs.forApp(context).edit(); + editor.putInt(CLEANUP_VERSION, CURRENT_CLEANUP_VERSION); + editor.apply(); + } + } + } + + protected void onDone() { + moveTaskToBack(true); + } + + @Override + public void onBackPressed() { + if (getSupportFragmentManager().getBackStackEntryCount() > 0) { + super.onBackPressed(); + return; + } + + if (autoHideTabs()) { + return; + } + + if (mDoorHangerPopup != null && mDoorHangerPopup.isShowing()) { + mDoorHangerPopup.dismiss(); + return; + } + + if (mFullScreenPluginView != null) { + GeckoAppShell.onFullScreenPluginHidden(mFullScreenPluginView); + removeFullScreenPluginView(mFullScreenPluginView); + return; + } + + if (mLayerView != null && mLayerView.isFullScreen()) { + GeckoAppShell.notifyObservers("FullScreen:Exit", null); + return; + } + + final Tabs tabs = Tabs.getInstance(); + final Tab tab = tabs.getSelectedTab(); + if (tab == null) { + onDone(); + return; + } + + // Give Gecko a chance to handle the back press first, then fallback to the Java UI. + GeckoAppShell.sendRequestToGecko(new GeckoRequest("Browser:OnBackPressed", null) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + if (!nativeJSObject.getBoolean("handled")) { + // Default behavior is Gecko didn't prevent. + onDefault(); + } + } + + @Override + public void onError(NativeJSObject error) { + // Default behavior is Gecko didn't prevent, via failure. + onDefault(); + } + + // Return from Gecko thread, then back-press through the Java UI. + private void onDefault() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (tab.doBack()) { + return; + } + + if (tab.isExternal()) { + onDone(); + Tab nextSelectedTab = Tabs.getInstance().getNextTab(tab); + if (nextSelectedTab != null) { + int nextSelectedTabId = nextSelectedTab.getId(); + GeckoAppShell.notifyObservers("Tab:KeepZombified", Integer.toString(nextSelectedTabId)); + } + tabs.closeTab(tab); + return; + } + + final int parentId = tab.getParentId(); + final Tab parent = tabs.getTab(parentId); + if (parent != null) { + // The back button should always return to the parent (not a sibling). + tabs.closeTab(tab, parent); + return; + } + + onDone(); + } + }); + } + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (!ActivityHandlerHelper.handleActivityResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + Permissions.onRequestPermissionsResult(this, permissions, grantResults); + } + + @Override + public AbsoluteLayout getPluginContainer() { return mPluginContainer; } + + private static final String CPU = "cpu"; + private static final String SCREEN = "screen"; + + // Called when a Gecko Hal WakeLock is changed + @Override + // We keep the wake lock independent from the function scope, so we need to + // suppress the linter warning. + @SuppressLint("Wakelock") + public void notifyWakeLockChanged(String topic, String state) { + PowerManager.WakeLock wl = mWakeLocks.get(topic); + if (state.equals("locked-foreground") && wl == null) { + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + + if (CPU.equals(topic)) { + wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, topic); + } else if (SCREEN.equals(topic)) { + // ON_AFTER_RELEASE is set, the user activity timer will be reset when the + // WakeLock is released, causing the illumination to remain on a bit longer. + wl = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, topic); + } + + if (wl != null) { + wl.acquire(); + mWakeLocks.put(topic, wl); + } + } else if (!state.equals("locked-foreground") && wl != null) { + wl.release(); + mWakeLocks.remove(topic); + } + } + + @Override + public void notifyCheckUpdateResult(String result) { + GeckoAppShell.notifyObservers("Update:CheckResult", result); + } + + private void geckoConnected() { + mLayerView.setOverScrollMode(View.OVER_SCROLL_NEVER); + } + + @Override + public void setAccessibilityEnabled(boolean enabled) { + } + + @Override + public boolean openUriExternal(String targetURI, String mimeType, String packageName, String className, String action, String title) { + // Default to showing prompt in private browsing to be safe. + return IntentHelper.openUriExternal(targetURI, mimeType, packageName, className, action, title, true); + } + + public static class MainLayout extends RelativeLayout { + private TouchEventInterceptor mTouchEventInterceptor; + private MotionEventInterceptor mMotionEventInterceptor; + + public MainLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + } + + public void setTouchEventInterceptor(TouchEventInterceptor interceptor) { + mTouchEventInterceptor = interceptor; + } + + public void setMotionEventInterceptor(MotionEventInterceptor interceptor) { + mMotionEventInterceptor = interceptor; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (mTouchEventInterceptor != null && mTouchEventInterceptor.onInterceptTouchEvent(this, event)) { + return true; + } + return super.onInterceptTouchEvent(event); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (mTouchEventInterceptor != null && mTouchEventInterceptor.onTouch(this, event)) { + return true; + } + return super.onTouchEvent(event); + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (mMotionEventInterceptor != null && mMotionEventInterceptor.onInterceptMotionEvent(this, event)) { + return true; + } + return super.onGenericMotionEvent(event); + } + + @Override + public void setDrawingCacheEnabled(boolean enabled) { + // Instead of setting drawing cache in the view itself, we simply + // enable drawing caching on its children. This is mainly used in + // animations (see PropertyAnimator) + super.setChildrenDrawnWithCacheEnabled(enabled); + } + } + + private class FullScreenHolder extends FrameLayout { + + public FullScreenHolder(Context ctx) { + super(ctx); + setBackgroundColor(0xff000000); + } + + @Override + public void addView(View view, int index) { + /** + * This normally gets called when Flash adds a separate SurfaceView + * for the video. It is unhappy if we have the LayerView underneath + * it for some reason so we need to hide that. Hiding the LayerView causes + * its surface to be destroyed, which causes a pause composition + * event to be sent to Gecko. We synchronously wait for that to be + * processed. Simultaneously, however, Flash is waiting on a mutex so + * the post() below is an attempt to avoid a deadlock. + */ + super.addView(view, index); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mLayerView.hideSurface(); + } + }); + } + + /** + * The methods below are simply copied from what Android WebKit does. + * It wasn't ever called in my testing, but might as well + * keep it in case it is for some reason. The methods + * all return true because we don't want any events + * leaking out from the fullscreen view. + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (event.isSystem()) { + return super.onKeyDown(keyCode, event); + } + mFullScreenPluginView.onKeyDown(keyCode, event); + return true; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (event.isSystem()) { + return super.onKeyUp(keyCode, event); + } + mFullScreenPluginView.onKeyUp(keyCode, event); + return true; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + return true; + } + + @Override + public boolean onTrackballEvent(MotionEvent event) { + mFullScreenPluginView.onTrackballEvent(event); + return true; + } + } + + private int getVersionCode() { + int versionCode = 0; + try { + versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode; + } catch (NameNotFoundException e) { + Log.wtf(LOGTAG, getPackageName() + " not found", e); + } + return versionCode; + } + + // FHR reason code for a session end prior to a restart for a + // locale change. + private static final String SESSION_END_LOCALE_CHANGED = "L"; + + /** + * This exists so that a locale can be applied in two places: when saved + * in a nested activity, and then again when we get back up to GeckoApp. + * + * GeckoApp needs to do a bunch more stuff than, say, GeckoPreferences. + */ + protected void onLocaleChanged(final String locale) { + final boolean startNewSession = true; + final boolean shouldRestart = false; + + // If the HealthRecorder is not yet initialized (unlikely), the locale change won't + // trigger a session transition and subsequent events will be recorded in an environment + // with the wrong locale. + final HealthRecorder rec = mHealthRecorder; + if (rec != null) { + rec.onAppLocaleChanged(locale); + rec.onEnvironmentChanged(startNewSession, SESSION_END_LOCALE_CHANGED); + } + + if (!shouldRestart) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + GeckoApp.this.onLocaleReady(locale); + } + }); + return; + } + + // Do this in the background so that the health recorder has its + // time to finish. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GeckoApp.this.doRestart(); + } + }); + } + + /** + * Use BrowserLocaleManager to change our persisted and current locales, + * and poke the system to tell it of our changed state. + */ + protected void setLocale(final String locale) { + if (locale == null) { + return; + } + + final String resultant = BrowserLocaleManager.getInstance().setSelectedLocale(this, locale); + if (resultant == null) { + return; + } + + onLocaleChanged(resultant); + } + + private void setSystemUiVisible(final boolean visible) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (visible) { + mMainLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); + } else { + mMainLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE); + } + } + }); + } + + protected HealthRecorder createHealthRecorder(final Context context, + final String profilePath, + final EventDispatcher dispatcher, + final String osLocale, + final String appLocale, + final SessionInformation previousSession) { + // GeckoApp does not need to record any health information - return a stub. + return new StubbedHealthRecorder(); + } + + protected void recordStartupActionTelemetry(final String passedURL, final String action) { + } + + @Override + public void checkUriVisited(String uri) { + GlobalHistory.getInstance().checkUriVisited(uri); + } + + @Override + public void markUriVisited(final String uri) { + final Context context = getApplicationContext(); + final BrowserDB db = BrowserDB.from(context); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GlobalHistory.getInstance().add(context, db, uri); + } + }); + } + + @Override + public void setUriTitle(final String uri, final String title) { + final Context context = getApplicationContext(); + final BrowserDB db = BrowserDB.from(context); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GlobalHistory.getInstance().update(context.getContentResolver(), db, uri, title); + } + }); + } + + @Override + public String[] getHandlersForMimeType(String mimeType, String action) { + Intent intent = IntentHelper.getIntentForActionString(action); + if (mimeType != null && mimeType.length() > 0) + intent.setType(mimeType); + return IntentHelper.getHandlersForIntent(intent); + } + + @Override + public String[] getHandlersForURL(String url, String action) { + // May contain the whole URL or just the protocol. + Uri uri = url.indexOf(':') >= 0 ? Uri.parse(url) : new Uri.Builder().scheme(url).build(); + + Intent intent = IntentHelper.getOpenURIIntent(getApplicationContext(), uri.toString(), "", + TextUtils.isEmpty(action) ? Intent.ACTION_VIEW : action, ""); + + return IntentHelper.getHandlersForIntent(intent); + } + + @Override + public String getDefaultChromeURI() { + // Use the chrome URI specified by Gecko's defaultChromeURI pref. + return null; + } + + public GeckoView getGeckoView() { + return mLayerView; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java new file mode 100644 index 000000000..18a6e6535 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java @@ -0,0 +1,314 @@ +/* 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; + +import android.app.Application; +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.os.Bundle; +import android.os.SystemClock; +import android.util.Log; + +import com.squareup.leakcanary.LeakCanary; +import com.squareup.leakcanary.RefWatcher; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.LocalBrowserDB; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.dlc.DownloadContentService; +import org.mozilla.gecko.home.HomePanelsManager; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.mdns.MulticastDNSManager; +import org.mozilla.gecko.media.AudioFocusAgent; +import org.mozilla.gecko.notifications.NotificationClient; +import org.mozilla.gecko.notifications.NotificationHelper; +import org.mozilla.gecko.preferences.DistroSharedPrefsImport; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.File; +import java.lang.reflect.Method; + +public class GeckoApplication extends Application + implements ContextGetter { + private static final String LOG_TAG = "GeckoApplication"; + + private static volatile GeckoApplication instance; + + private boolean mInBackground; + private boolean mPausedGecko; + + private LightweightTheme mLightweightTheme; + + private RefWatcher mRefWatcher; + + public GeckoApplication() { + super(); + instance = this; + } + + public static GeckoApplication get() { + return instance; + } + + public static RefWatcher getRefWatcher(Context context) { + GeckoApplication app = (GeckoApplication) context.getApplicationContext(); + return app.mRefWatcher; + } + + public static void watchReference(Context context, Object object) { + if (context == null) { + return; + } + + getRefWatcher(context).watch(object); + } + + @Override + public Context getContext() { + return this; + } + + @Override + public SharedPreferences getSharedPreferences() { + return GeckoSharedPrefs.forApp(this); + } + + /** + * We need to do locale work here, because we need to intercept + * each hit to onConfigurationChanged. + */ + @Override + public void onConfigurationChanged(Configuration config) { + Log.d(LOG_TAG, "onConfigurationChanged: " + config.locale + + ", background: " + mInBackground); + + // Do nothing if we're in the background. It'll simply cause a loop + // (Bug 936756 Comment 11), and it's not necessary. + if (mInBackground) { + super.onConfigurationChanged(config); + return; + } + + // Otherwise, correct the locale. This catches some cases that GeckoApp + // doesn't get a chance to. + try { + BrowserLocaleManager.getInstance().correctLocale(this, getResources(), config); + } catch (IllegalStateException ex) { + // GeckoApp hasn't started, so we have no ContextGetter in BrowserLocaleManager. + Log.w(LOG_TAG, "Couldn't correct locale.", ex); + } + + super.onConfigurationChanged(config); + } + + public void onActivityPause(GeckoActivityStatus activity) { + mInBackground = true; + + if ((activity.isFinishing() == false) && + (activity.isGeckoActivityOpened() == false)) { + // Notify Gecko that we are pausing; the cache service will be + // shutdown, closing the disk cache cleanly. If the android + // low memory killer subsequently kills us, the disk cache will + // be left in a consistent state, avoiding costly cleanup and + // re-creation. + GeckoThread.onPause(); + mPausedGecko = true; + + final BrowserDB db = BrowserDB.from(this); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.expireHistory(getContentResolver(), BrowserContract.ExpirePriority.NORMAL); + } + }); + } + GeckoNetworkManager.getInstance().stop(); + } + + public void onActivityResume(GeckoActivityStatus activity) { + if (mPausedGecko) { + GeckoThread.onResume(); + mPausedGecko = false; + } + + GeckoBatteryManager.getInstance().start(this); + GeckoNetworkManager.getInstance().start(this); + + mInBackground = false; + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + AppConstants.maybeInstallMultiDex(base); + } + + @Override + public void onCreate() { + Log.i(LOG_TAG, "zerdatime " + SystemClock.uptimeMillis() + " - Fennec application start"); + + mRefWatcher = LeakCanary.install(this); + + final Context context = getApplicationContext(); + GeckoAppShell.setApplicationContext(context); + HardwareUtils.init(context); + Clipboard.init(context); + FilePicker.init(context); + DownloadsIntegration.init(); + HomePanelsManager.getInstance().init(context); + + GlobalPageMetadata.getInstance().init(); + + // We need to set the notification client before launching Gecko, since Gecko could start + // sending notifications immediately after startup, which we don't want to lose/crash on. + GeckoAppShell.setNotificationListener(new NotificationClient(context)); + // This getInstance call will force initialization of the NotificationHelper, but does nothing with the result + NotificationHelper.getInstance(context).init(); + + MulticastDNSManager.getInstance(context).init(); + + GeckoService.register(); + + EventDispatcher.getInstance().registerBackgroundThreadListener(new EventListener(), + "Profile:Create"); + + super.onCreate(); + } + + public void onDelayedStartup() { + if (AppConstants.MOZ_ANDROID_GCM) { + // TODO: only run in main process. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // It's fine to throw GCM initialization onto a background thread; the registration process requires + // network access, so is naturally asynchronous. This, of course, races against Gecko page load of + // content requiring GCM-backed services, like Web Push. There's nothing to be done here. + try { + final Class<?> clazz = Class.forName("org.mozilla.gecko.push.PushService"); + final Method onCreate = clazz.getMethod("onCreate", Context.class); + onCreate.invoke(null, getApplicationContext()); // Method is static. + } catch (Exception e) { + Log.e(LOG_TAG, "Got exception during startup; ignoring.", e); + return; + } + } + }); + } + + if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) { + DownloadContentService.startStudy(this); + } + + GeckoAccessibility.setAccessibilityManagerListeners(this); + + AudioFocusAgent.getInstance().attachToContext(this); + } + + private class EventListener implements BundleEventListener + { + private void onProfileCreate(final String name, final String path) { + // Add everything when we're done loading the distribution. + final Context context = GeckoApplication.this; + final GeckoProfile profile = GeckoProfile.get(context, name); + final Distribution distribution = Distribution.getInstance(context); + + distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() { + @Override + public void distributionNotFound() { + this.distributionFound(null); + } + + @Override + public void distributionFound(final Distribution distribution) { + Log.d(LOG_TAG, "Running post-distribution task: bookmarks."); + // Because we are running in the background, we want to synchronize on the + // GeckoProfile instance so that we don't race with main thread operations + // such as locking/unlocking/removing the profile. + synchronized (profile.getLock()) { + distributionFoundLocked(distribution); + } + } + + @Override + public void distributionArrivedLate(final Distribution distribution) { + Log.d(LOG_TAG, "Running late distribution task: bookmarks."); + // Recover as best we can. + synchronized (profile.getLock()) { + distributionArrivedLateLocked(distribution); + } + } + + private void distributionFoundLocked(final Distribution distribution) { + // Skip initialization if the profile directory has been removed. + if (!(new File(path)).exists()) { + return; + } + + final ContentResolver cr = context.getContentResolver(); + final LocalBrowserDB db = new LocalBrowserDB(profile.getName()); + + // We pass the number of added bookmarks to ensure that the + // indices of the distribution and default bookmarks are + // contiguous. Because there are always at least as many + // bookmarks as there are favicons, we can also guarantee that + // the favicon IDs won't overlap. + final int offset = distribution == null ? 0 : + db.addDistributionBookmarks(cr, distribution, 0); + db.addDefaultBookmarks(context, cr, offset); + + Log.d(LOG_TAG, "Running post-distribution task: android preferences."); + DistroSharedPrefsImport.importPreferences(context, distribution); + } + + private void distributionArrivedLateLocked(final Distribution distribution) { + // Skip initialization if the profile directory has been removed. + if (!(new File(path)).exists()) { + return; + } + + final ContentResolver cr = context.getContentResolver(); + final LocalBrowserDB db = new LocalBrowserDB(profile.getName()); + + // We assume we've been called very soon after startup, and so our offset + // into "Mobile Bookmarks" is the number of bookmarks in the DB. + final int offset = db.getCount(cr, "bookmarks"); + db.addDistributionBookmarks(cr, distribution, offset); + + Log.d(LOG_TAG, "Running late distribution task: android preferences."); + DistroSharedPrefsImport.importPreferences(context, distribution); + } + }); + } + + @Override // BundleEventListener + public void handleMessage(final String event, final Bundle message, + final EventCallback callback) { + if ("Profile:Create".equals(event)) { + onProfileCreate(message.getCharSequence("name").toString(), + message.getCharSequence("path").toString()); + } + } + } + + public boolean isApplicationInBackground() { + return mInBackground; + } + + public LightweightTheme getLightweightTheme() { + return mLightweightTheme; + } + + public void prepareLightweightTheme() { + mLightweightTheme = new LightweightTheme(this); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java new file mode 100644 index 000000000..319eccec1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java @@ -0,0 +1,211 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import android.os.SystemClock; +import android.util.Log; +import android.util.SparseArray; + +import org.mozilla.gecko.annotation.WrapForJNI; + +import java.lang.Thread; +import java.util.Set; + +public class GeckoJavaSampler { + private static final String LOGTAG = "JavaSampler"; + private static Thread sSamplingThread; + private static SamplingThread sSamplingRunnable; + private static Thread sMainThread; + + // Use the same timer primitive as the profiler + // to get a perfect sample syncing. + @WrapForJNI + private static native double getProfilerTime(); + + private static class Sample { + public Frame[] mFrames; + public double mTime; + public long mJavaTime; // non-zero if Android system time is used + public Sample(StackTraceElement[] aStack) { + mFrames = new Frame[aStack.length]; + if (GeckoThread.isStateAtLeast(GeckoThread.State.LIBS_READY)) { + mTime = getProfilerTime(); + } + if (mTime == 0.0d) { + // getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mJavaTime = SystemClock.elapsedRealtime(); + } + for (int i = 0; i < aStack.length; i++) { + mFrames[aStack.length - 1 - i] = new Frame(); + mFrames[aStack.length - 1 - i].fileName = aStack[i].getFileName(); + mFrames[aStack.length - 1 - i].lineNo = aStack[i].getLineNumber(); + mFrames[aStack.length - 1 - i].methodName = aStack[i].getMethodName(); + mFrames[aStack.length - 1 - i].className = aStack[i].getClassName(); + } + } + } + private static class Frame { + public String fileName; + public int lineNo; + public String methodName; + public String className; + } + + private static class SamplingThread implements Runnable { + private final int mInterval; + private final int mSampleCount; + + private boolean mPauseSampler; + private boolean mStopSampler; + + private final SparseArray<Sample[]> mSamples = new SparseArray<Sample[]>(); + private int mSamplePos; + + public SamplingThread(final int aInterval, final int aSampleCount) { + // If we sample faster then 10ms we get to many missed samples + mInterval = Math.max(10, aInterval); + mSampleCount = aSampleCount; + } + + @Override + public void run() { + synchronized (GeckoJavaSampler.class) { + mSamples.put(0, new Sample[mSampleCount]); + mSamplePos = 0; + + // Find the main thread + Set<Thread> threadSet = Thread.getAllStackTraces().keySet(); + for (Thread t : threadSet) { + if (t.getName().compareToIgnoreCase("main") == 0) { + sMainThread = t; + break; + } + } + + if (sMainThread == null) { + Log.e(LOGTAG, "Main thread not found"); + return; + } + } + + while (true) { + try { + Thread.sleep(mInterval); + } catch (InterruptedException e) { + e.printStackTrace(); + } + synchronized (GeckoJavaSampler.class) { + if (!mPauseSampler) { + StackTraceElement[] bt = sMainThread.getStackTrace(); + mSamples.get(0)[mSamplePos] = new Sample(bt); + mSamplePos = (mSamplePos + 1) % mSamples.get(0).length; + } + if (mStopSampler) { + break; + } + } + } + } + + private Sample getSample(int aThreadId, int aSampleId) { + if (aThreadId < mSamples.size() && aSampleId < mSamples.get(aThreadId).length && + mSamples.get(aThreadId)[aSampleId] != null) { + int startPos = 0; + if (mSamples.get(aThreadId)[mSamplePos] != null) { + startPos = mSamplePos; + } + int readPos = (startPos + aSampleId) % mSamples.get(aThreadId).length; + return mSamples.get(aThreadId)[readPos]; + } + return null; + } + } + + + @WrapForJNI + public synchronized static String getThreadName(int aThreadId) { + if (aThreadId == 0 && sMainThread != null) { + return sMainThread.getName(); + } + return null; + } + + private synchronized static Sample getSample(int aThreadId, int aSampleId) { + return sSamplingRunnable.getSample(aThreadId, aSampleId); + } + + @WrapForJNI + public synchronized static double getSampleTime(int aThreadId, int aSampleId) { + Sample sample = getSample(aThreadId, aSampleId); + if (sample != null) { + if (sample.mJavaTime != 0) { + return (sample.mJavaTime - + SystemClock.elapsedRealtime()) + getProfilerTime(); + } + System.out.println("Sample: " + sample.mTime); + return sample.mTime; + } + return 0; + } + + @WrapForJNI + public synchronized static String getFrameName(int aThreadId, int aSampleId, int aFrameId) { + Sample sample = getSample(aThreadId, aSampleId); + if (sample != null && aFrameId < sample.mFrames.length) { + Frame frame = sample.mFrames[aFrameId]; + if (frame == null) { + return null; + } + return frame.className + "." + frame.methodName + "()"; + } + return null; + } + + @WrapForJNI + public static void start(int aInterval, int aSamples) { + synchronized (GeckoJavaSampler.class) { + if (sSamplingRunnable != null) { + return; + } + sSamplingRunnable = new SamplingThread(aInterval, aSamples); + sSamplingThread = new Thread(sSamplingRunnable, "Java Sampler"); + sSamplingThread.start(); + } + } + + @WrapForJNI + public static void pause() { + synchronized (GeckoJavaSampler.class) { + sSamplingRunnable.mPauseSampler = true; + } + } + + @WrapForJNI + public static void unpause() { + synchronized (GeckoJavaSampler.class) { + sSamplingRunnable.mPauseSampler = false; + } + } + + @WrapForJNI + public static void stop() { + synchronized (GeckoJavaSampler.class) { + if (sSamplingThread == null) { + return; + } + + sSamplingRunnable.mStopSampler = true; + try { + sSamplingThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + sSamplingThread = null; + sSamplingRunnable = null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java b/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java new file mode 100644 index 000000000..c199aad55 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java @@ -0,0 +1,27 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.json.JSONObject; +import org.mozilla.gecko.util.EventCallback; + +/** + * Wrapper for MediaRouter types supported by Android, such as Chromecast, Miracast, etc. + */ +interface GeckoMediaPlayer { + /** + * Can return null. + */ + JSONObject toJSON(); + void load(String title, String url, String type, EventCallback callback); + void play(EventCallback callback); + void pause(EventCallback callback); + void stop(EventCallback callback); + void start(EventCallback callback); + void end(EventCallback callback); + void mirror(EventCallback callback); + void message(String message, EventCallback callback); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java b/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java new file mode 100644 index 000000000..b7f4870c2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java @@ -0,0 +1,19 @@ +/* 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; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public class GeckoMessageReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (GeckoApp.ACTION_INIT_PW.equals(action)) { + GeckoAppShell.notifyObservers("Passwords:Init", null); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java b/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java new file mode 100644 index 000000000..df9844d7b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java @@ -0,0 +1,22 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.json.JSONObject; +import org.mozilla.gecko.util.EventCallback; + +/** + * Wrapper for MediaRouter types supported by Android to use for + * Presentation API, such as Chromecast, Miracast, etc. + */ +interface GeckoPresentationDisplay { + /** + * Can return null. + */ + JSONObject toJSON(); + void start(EventCallback callback); + void stop(EventCallback callback); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java b/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java new file mode 100644 index 000000000..8a9c461c5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java @@ -0,0 +1,149 @@ +/* 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; + +import java.io.File; +import java.util.Map; +import java.util.Map.Entry; + +import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException; +import org.mozilla.gecko.db.BrowserContract; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.util.Log; + +/** + * This is not a per-profile provider. This provider allows read-only, + * restricted access to certain attributes of Fennec profiles. + */ +public class GeckoProfilesProvider extends ContentProvider { + private static final String LOG_TAG = "GeckoProfilesProvider"; + + private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + private static final int PROFILES = 100; + private static final int PROFILES_NAME = 101; + private static final int PROFILES_DEFAULT = 200; + + private static final String[] DEFAULT_ARGS = { + BrowserContract.Profiles.NAME, + BrowserContract.Profiles.PATH, + }; + + static { + URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "profiles", PROFILES); + URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "profiles/*", PROFILES_NAME); + URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "default", PROFILES_DEFAULT); + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public boolean onCreate() { + // Successfully loaded. + return true; + } + + private String[] profileValues(final String name, final String path, int len, int nameIndex, int pathIndex) { + final String[] values = new String[len]; + if (nameIndex >= 0) { + values[nameIndex] = name; + } + if (pathIndex >= 0) { + values[pathIndex] = path; + } + return values; + } + + protected void addRowForProfile(final MatrixCursor cursor, final int len, final int nameIndex, final int pathIndex, final String name, final String path) { + if (path == null || name == null) { + return; + } + + cursor.addRow(profileValues(name, path, len, nameIndex, pathIndex)); + } + + protected Cursor getCursorForProfiles(final String[] args, Map<String, String> profiles) { + // Compute the projection. + int nameIndex = -1; + int pathIndex = -1; + for (int i = 0; i < args.length; ++i) { + if (BrowserContract.Profiles.NAME.equals(args[i])) { + nameIndex = i; + } else if (BrowserContract.Profiles.PATH.equals(args[i])) { + pathIndex = i; + } + } + + final MatrixCursor cursor = new MatrixCursor(args); + for (Entry<String, String> entry : profiles.entrySet()) { + addRowForProfile(cursor, args.length, nameIndex, pathIndex, entry.getKey(), entry.getValue()); + } + return cursor; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + + final String[] args = (projection == null) ? DEFAULT_ARGS : projection; + + final File mozillaDir; + try { + mozillaDir = GeckoProfileDirectories.getMozillaDirectory(getContext()); + } catch (NoMozillaDirectoryException e) { + Log.d(LOG_TAG, "No Mozilla directory; cannot query for profiles. Assuming there are none."); + return new MatrixCursor(projection); + } + + final Map<String, String> matchingProfiles; + + final int match = URI_MATCHER.match(uri); + switch (match) { + case PROFILES: + // Return all profiles. + matchingProfiles = GeckoProfileDirectories.getAllProfiles(mozillaDir); + break; + case PROFILES_NAME: + // Return data about the specified profile. + final String name = uri.getLastPathSegment(); + matchingProfiles = GeckoProfileDirectories.getProfilesNamed(mozillaDir, + name); + break; + case PROFILES_DEFAULT: + matchingProfiles = GeckoProfileDirectories.getDefaultProfile(mozillaDir); + break; + default: + throw new UnsupportedOperationException("Unknown query URI " + uri); + } + + return getCursorForProfiles(args, matchingProfiles); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new IllegalStateException("Inserts not supported."); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new IllegalStateException("Deletes not supported."); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + throw new IllegalStateException("Updates not supported."); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoService.java b/mobile/android/base/java/org/mozilla/gecko/GeckoService.java new file mode 100644 index 000000000..3a99fd2a1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoService.java @@ -0,0 +1,236 @@ +/* -*- 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; + +import android.app.AlarmManager; +import android.app.Service; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.File; + +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.EventCallback; + +public class GeckoService extends Service { + + private static final String LOGTAG = "GeckoService"; + private static final boolean DEBUG = false; + + private static final String INTENT_PROFILE_NAME = "org.mozilla.gecko.intent.PROFILE_NAME"; + private static final String INTENT_PROFILE_DIR = "org.mozilla.gecko.intent.PROFILE_DIR"; + + private static final String INTENT_ACTION_UPDATE_ADDONS = "update-addons"; + private static final String INTENT_ACTION_CREATE_SERVICES = "create-services"; + + private static final String INTENT_SERVICE_CATEGORY = "category"; + private static final String INTENT_SERVICE_DATA = "data"; + + private static class EventListener implements NativeEventListener { + @Override // NativeEventListener + public void handleMessage(final String event, + final NativeJSObject message, + final EventCallback callback) { + final Context context = GeckoAppShell.getApplicationContext(); + switch (event) { + case "Gecko:ScheduleRun": + if (DEBUG) { + Log.d(LOGTAG, "Scheduling " + message.getString("action") + + " @ " + message.getInt("interval") + "ms"); + } + + final Intent intent = getIntentForAction(context, message.getString("action")); + final PendingIntent pendingIntent = PendingIntent.getService( + context, /* requestCode */ 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); + + final AlarmManager am = (AlarmManager) + context.getSystemService(Context.ALARM_SERVICE); + // Cancel any previous alarm and schedule a new one. + am.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, + message.getInt("trigger"), + message.getInt("interval"), + pendingIntent); + break; + + default: + throw new UnsupportedOperationException(event); + } + } + } + + private static final EventListener EVENT_LISTENER = new EventListener(); + + public static void register() { + if (DEBUG) { + Log.d(LOGTAG, "Registered listener"); + } + EventDispatcher.getInstance().registerGeckoThreadListener(EVENT_LISTENER, + "Gecko:ScheduleRun"); + } + + public static void unregister() { + if (DEBUG) { + Log.d(LOGTAG, "Unregistered listener"); + } + EventDispatcher.getInstance().unregisterGeckoThreadListener(EVENT_LISTENER, + "Gecko:ScheduleRun"); + } + + @Override // Service + public void onCreate() { + GeckoAppShell.ensureCrashHandling(); + GeckoThread.onResume(); + super.onCreate(); + + if (DEBUG) { + Log.d(LOGTAG, "Created"); + } + } + + @Override // Service + public void onDestroy() { + GeckoThread.onPause(); + + // We want to block here if we can, so we don't get killed when Gecko is in the + // middle of handling onPause(). + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + GeckoThread.waitOnGecko(); + } + + if (DEBUG) { + Log.d(LOGTAG, "Destroyed"); + } + super.onDestroy(); + } + + private static Intent getIntentForAction(final Context context, final String action) { + final Intent intent = new Intent(action, /* uri */ null, context, GeckoService.class); + final GeckoProfile profile = GeckoThread.getActiveProfile(); + if (profile != null) { + setIntentProfile(intent, profile.getName(), profile.getDir().getAbsolutePath()); + } + return intent; + } + + public static Intent getIntentToCreateServices(final Context context, final String category, final String data) { + final Intent intent = getIntentForAction(context, INTENT_ACTION_CREATE_SERVICES); + intent.putExtra(INTENT_SERVICE_CATEGORY, category); + intent.putExtra(INTENT_SERVICE_DATA, data); + return intent; + } + + public static Intent getIntentToCreateServices(final Context context, final String category) { + return getIntentToCreateServices(context, category, /* data */ null); + } + + public static void setIntentProfile(final Intent intent, final String profileName, + final String profileDir) { + intent.putExtra(INTENT_PROFILE_NAME, profileName); + intent.putExtra(INTENT_PROFILE_DIR, profileDir); + } + + private int handleIntent(final Intent intent, final int startId) { + if (DEBUG) { + Log.d(LOGTAG, "Handling " + intent.getAction()); + } + + final String profileName = intent.getStringExtra(INTENT_PROFILE_NAME); + final String profileDir = intent.getStringExtra(INTENT_PROFILE_DIR); + + if (profileName == null) { + throw new IllegalArgumentException("Intent must specify profile."); + } + + if (!GeckoThread.initWithProfile(profileName != null ? profileName : "", + profileDir != null ? new File(profileDir) : null)) { + Log.w(LOGTAG, "Ignoring due to profile mismatch: " + + profileName + " [" + profileDir + ']'); + + final GeckoProfile profile = GeckoThread.getActiveProfile(); + if (profile != null) { + Log.w(LOGTAG, "Current profile is " + profile.getName() + + " [" + profile.getDir().getAbsolutePath() + ']'); + } + stopSelf(startId); + return Service.START_NOT_STICKY; + } + + GeckoThread.launch(); + + switch (intent.getAction()) { + case INTENT_ACTION_UPDATE_ADDONS: + // Run the add-on update service. Because the service is automatically invoked + // when loading Gecko, we don't have to do anything else here. + break; + + case INTENT_ACTION_CREATE_SERVICES: + final String category = intent.getStringExtra(INTENT_SERVICE_CATEGORY); + final String data = intent.getStringExtra(INTENT_SERVICE_DATA); + + if (category == null) { + break; + } + GeckoThread.createServices(category, data); + break; + + default: + Log.w(LOGTAG, "Unknown request: " + intent); + } + + stopSelf(startId); + return Service.START_NOT_STICKY; + } + + @Override // Service + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (intent == null) { + return Service.START_NOT_STICKY; + } + try { + return handleIntent(intent, startId); + } catch (final Throwable e) { + Log.e(LOGTAG, "Cannot handle intent: " + intent, e); + return Service.START_NOT_STICKY; + } + } + + @Override // Service + public IBinder onBind(final Intent intent) { + return null; + } + + public static void startGecko(final GeckoProfile profile, final String args, final Context context) { + if (GeckoThread.isLaunched()) { + if (DEBUG) { + Log.v(LOGTAG, "already launched"); + } + return; + } + + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(new Runnable() { + @Override + public void run() { + GeckoAppShell.ensureCrashHandling(); + GeckoAppShell.setApplicationContext(context); + GeckoThread.onResume(); + + GeckoThread.init(profile, args, null, false); + GeckoThread.launch(); + + if (DEBUG) { + Log.v(LOGTAG, "warmed up (launched)"); + } + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java b/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java new file mode 100644 index 000000000..f73c42e40 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java @@ -0,0 +1,25 @@ +/* -*- 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; + +import org.mozilla.gecko.updater.UpdateServiceHelper; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +public class GeckoUpdateReceiver extends BroadcastReceiver +{ + @Override + public void onReceive(Context context, Intent intent) { + if (UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT.equals(intent.getAction())) { + String result = intent.getStringExtra("result"); + if (GeckoAppShell.getGeckoInterface() != null && result != null) { + GeckoAppShell.getGeckoInterface().notifyCheckUpdateResult(result); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java b/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java new file mode 100644 index 000000000..c1d9c4939 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java @@ -0,0 +1,178 @@ +/* -*- 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; + +import java.lang.ref.SoftReference; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; + +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.SystemClock; +import android.util.Log; + +class GlobalHistory { + private static final String LOGTAG = "GeckoGlobalHistory"; + + public static final String EVENT_URI_AVAILABLE_IN_HISTORY = "URI_INSERTED_TO_HISTORY"; + public static final String EVENT_PARAM_URI = "uri"; + + private static final String TELEMETRY_HISTOGRAM_ADD = "FENNEC_GLOBALHISTORY_ADD_MS"; + private static final String TELEMETRY_HISTOGRAM_UPDATE = "FENNEC_GLOBALHISTORY_UPDATE_MS"; + private static final String TELEMETRY_HISTOGRAM_BUILD_VISITED_LINK = "FENNEC_GLOBALHISTORY_VISITED_BUILD_MS"; + + private static final GlobalHistory sInstance = new GlobalHistory(); + + static GlobalHistory getInstance() { + return sInstance; + } + + // this is the delay between receiving a URI check request and processing it. + // this allows batching together multiple requests and processing them together, + // which is more efficient. + private static final long BATCHING_DELAY_MS = 100; + + private final Handler mHandler; // a background thread on which we can process requests + + // Note: These fields are accessed through the NotificationRunnable inner class. + final Queue<String> mPendingUris; // URIs that need to be checked + SoftReference<Set<String>> mVisitedCache; // cache of the visited URI list + boolean mProcessing; // = false // whether or not the runnable is queued/working + + private class NotifierRunnable implements Runnable { + private final ContentResolver mContentResolver; + private final BrowserDB mDB; + + public NotifierRunnable(final Context context) { + mContentResolver = context.getContentResolver(); + mDB = BrowserDB.from(context); + } + + @Override + public void run() { + Set<String> visitedSet = mVisitedCache.get(); + if (visitedSet == null) { + // The cache was wiped. Repopulate it. + Log.w(LOGTAG, "Rebuilding visited link set..."); + final long start = SystemClock.uptimeMillis(); + final Cursor c = mDB.getAllVisitedHistory(mContentResolver); + if (c == null) { + return; + } + + try { + visitedSet = new HashSet<String>(); + if (c.moveToFirst()) { + do { + visitedSet.add(c.getString(0)); + } while (c.moveToNext()); + } + mVisitedCache = new SoftReference<Set<String>>(visitedSet); + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_BUILD_VISITED_LINK, (int) Math.min(took, Integer.MAX_VALUE)); + } finally { + c.close(); + } + } + + // This runs on the same handler thread as the checkUriVisited code, + // so no synchronization is needed. + while (true) { + final String uri = mPendingUris.poll(); + if (uri == null) { + break; + } + + if (visitedSet.contains(uri)) { + GeckoAppShell.notifyUriVisited(uri); + } + } + + mProcessing = false; + } + }; + + private GlobalHistory() { + mHandler = ThreadUtils.getBackgroundHandler(); + mPendingUris = new LinkedList<String>(); + mVisitedCache = new SoftReference<Set<String>>(null); + } + + public void addToGeckoOnly(String uri) { + Set<String> visitedSet = mVisitedCache.get(); + if (visitedSet != null) { + visitedSet.add(uri); + } + GeckoAppShell.notifyUriVisited(uri); + } + + public void add(final Context context, final BrowserDB db, String uri) { + ThreadUtils.assertOnBackgroundThread(); + final long start = SystemClock.uptimeMillis(); + + // stripAboutReaderUrl only removes about:reader if present, in all other cases the original string is returned + final String uriToStore = ReaderModeUtils.stripAboutReaderUrl(uri); + + db.updateVisitedHistory(context.getContentResolver(), uriToStore); + + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_ADD, (int) Math.min(took, Integer.MAX_VALUE)); + addToGeckoOnly(uriToStore); + dispatchUriAvailableMessage(uri); + } + + @SuppressWarnings("static-method") + public void update(final ContentResolver cr, final BrowserDB db, String uri, String title) { + ThreadUtils.assertOnBackgroundThread(); + final long start = SystemClock.uptimeMillis(); + + final String uriToStore = ReaderModeUtils.stripAboutReaderUrl(uri); + + db.updateHistoryTitle(cr, uriToStore, title); + + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_UPDATE, (int) Math.min(took, Integer.MAX_VALUE)); + } + + public void checkUriVisited(final String uri) { + final String storedURI = ReaderModeUtils.stripAboutReaderUrl(uri); + + final NotifierRunnable runnable = new NotifierRunnable(GeckoAppShell.getContext()); + mHandler.post(new Runnable() { + @Override + public void run() { + // this runs on the same handler thread as the processing loop, + // so no synchronization needed + mPendingUris.add(storedURI); + if (mProcessing) { + // there's already a runnable queued up or working away, so + // no need to post another + return; + } + mProcessing = true; + mHandler.postDelayed(runnable, BATCHING_DELAY_MS); + } + }); + } + + private void dispatchUriAvailableMessage(String uri) { + final Bundle message = new Bundle(); + message.putString(EVENT_PARAM_URI, uri); + EventDispatcher.getInstance().dispatch(EVENT_URI_AVAILABLE_IN_HISTORY, message); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java new file mode 100644 index 000000000..d9d12962c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java @@ -0,0 +1,182 @@ +/* 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; + +import android.content.ContentProviderClient; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Provides access to metadata information about websites. + * + * While storing, in case of timing issues preventing us from looking up History GUID by a given uri, + * we queue up metadata and wait for GlobalHistory to let us know history record is now available. + * + * TODO Bug 1313515: selection of metadata for a given uri/history_GUID + * + * @author grisha + */ +/* package-local */ class GlobalPageMetadata implements BundleEventListener { + private static final String LOG_TAG = "GeckoGlobalPageMetadata"; + + private static final GlobalPageMetadata instance = new GlobalPageMetadata(); + + private static final String KEY_HAS_IMAGE = "hasImage"; + private static final String KEY_METADATA_JSON = "metadataJSON"; + + private static final int MAX_METADATA_QUEUE_SIZE = 15; + + private final Map<String, Bundle> queuedMetadata = Collections.synchronizedMap(new LimitedLinkedHashMap<String, Bundle>()); + + public static GlobalPageMetadata getInstance() { + return instance; + } + + private static class LimitedLinkedHashMap<K, V> extends LinkedHashMap<K, V> { + private static final long serialVersionUID = 6359725112736360244L; + + @Override + protected boolean removeEldestEntry(Entry<K, V> eldest) { + if (size() > MAX_METADATA_QUEUE_SIZE) { + Log.w(LOG_TAG, "Page metadata queue is full. Dropping oldest metadata."); + return true; + } + return false; + } + } + + private GlobalPageMetadata() {} + + public void init() { + EventDispatcher + .getInstance() + .registerBackgroundThreadListener(this, GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY); + } + + public void add(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) { + ThreadUtils.assertOnBackgroundThread(); + + // NB: Other than checking that JSON is valid and trimming it, + // we do not process metadataJSON in any way, trusting our source. + doAddOrQueue(db, contentProviderClient, uri, hasImage, metadataJSON); + } + + @VisibleForTesting + /*package-local */ void doAddOrQueue(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) { + final String preparedMetadataJSON; + try { + preparedMetadataJSON = prepareJSON(metadataJSON); + } catch (JSONException e) { + Log.e(LOG_TAG, "Couldn't process metadata JSON", e); + return; + } + + // Don't bother queuing this if deletions fails to find a corresponding history record. + // If we can't delete metadata because it didn't exist yet, that's OK. + if (preparedMetadataJSON.equals("{}")) { + final int deleted = db.deletePageMetadata(contentProviderClient, uri); + // We could delete none if history record for uri isn't present. + // We must delete one if history record for uri is present. + if (deleted != 0 && deleted != 1) { + throw new IllegalStateException("Deleted unexpected number of page metadata records: " + deleted); + } + return; + } + + // If we could insert page metadata, we're done. + if (db.insertPageMetadata(contentProviderClient, uri, hasImage, preparedMetadataJSON)) { + return; + } + + // Otherwise, we need to queue it for future insertion when history record is available. + Bundle bundledMetadata = new Bundle(); + bundledMetadata.putBoolean(KEY_HAS_IMAGE, hasImage); + bundledMetadata.putString(KEY_METADATA_JSON, preparedMetadataJSON); + queuedMetadata.put(uri, bundledMetadata); + } + + @VisibleForTesting + /* package-local */ int getMetadataQueueSize() { + return queuedMetadata.size(); + } + + @Override + public void handleMessage(String event, Bundle message, EventCallback callback) { + ThreadUtils.assertOnBackgroundThread(); + + if (!GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY.equals(event)) { + return; + } + + final String uri = message.getString(GlobalHistory.EVENT_PARAM_URI); + if (TextUtils.isEmpty(uri)) { + return; + } + + final Bundle bundledMetadata; + synchronized (queuedMetadata) { + if (!queuedMetadata.containsKey(uri)) { + return; + } + + bundledMetadata = queuedMetadata.get(uri); + queuedMetadata.remove(uri); + } + + insertMetadataBundleForUri(uri, bundledMetadata); + } + + private void insertMetadataBundleForUri(String uri, Bundle bundledMetadata) { + final boolean hasImage = bundledMetadata.getBoolean(KEY_HAS_IMAGE); + final String metadataJSON = bundledMetadata.getString(KEY_METADATA_JSON); + + // Acquire CPC, must be released in this function. + final ContentProviderClient contentProviderClient = GeckoAppShell.getApplicationContext() + .getContentResolver() + .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI); + + // Pre-conditions... + if (contentProviderClient == null) { + Log.e(LOG_TAG, "Couldn't acquire content provider client"); + return; + } + + if (TextUtils.isEmpty(metadataJSON)) { + Log.e(LOG_TAG, "Metadata bundle contained empty metadata json"); + return; + } + + // Insert! + try { + add( + BrowserDB.from(GeckoThread.getActiveProfile()), + contentProviderClient, + uri, hasImage, metadataJSON + ); + } finally { + contentProviderClient.release(); + } + } + + private String prepareJSON(String json) throws JSONException { + return (new JSONObject(json)).toString(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/GuestSession.java b/mobile/android/base/java/org/mozilla/gecko/GuestSession.java new file mode 100644 index 000000000..69502f44a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/GuestSession.java @@ -0,0 +1,51 @@ +/* -*- 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; + +import android.app.KeyguardManager; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.support.v4.app.NotificationCompat; +import android.view.Window; +import android.view.WindowManager; + +// Utility methods for entering/exiting guest mode. +public final class GuestSession { + private static final String LOGTAG = "GeckoGuestSession"; + + public static final String NOTIFICATION_INTENT = "org.mozilla.gecko.GUEST_SESSION_INPROGRESS"; + + private static PendingIntent getNotificationIntent(Context context) { + Intent intent = new Intent(NOTIFICATION_INTENT); + intent.setClassName(context, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public static void showNotification(Context context) { + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + final Resources res = context.getResources(); + builder.setContentTitle(res.getString(R.string.guest_browsing_notification_title)) + .setContentText(res.getString(R.string.guest_browsing_notification_text)) + .setSmallIcon(R.drawable.alert_guest) + .setOngoing(true) + .setContentIntent(getNotificationIntent(context)); + + final NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + manager.notify(R.id.guestNotification, builder.build()); + } + + public static void hideNotification(Context context) { + final NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + manager.cancel(R.id.guestNotification); + } + + public static void onNotificationIntentReceived(BrowserApp context) { + context.showGuestModeDialog(BrowserApp.GuestModeDialog.LEAVING); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java new file mode 100644 index 000000000..efe9576d7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java @@ -0,0 +1,593 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.overlays.ui.ShareDialog; +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.JSONUtils; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.widget.ExternalIntentDuringPrivateBrowsingPromptFragment; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.provider.Browser; +import android.support.annotation.Nullable; +import android.support.v4.app.FragmentActivity; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public final class IntentHelper implements GeckoEventListener, + NativeEventListener { + + private static final String LOGTAG = "GeckoIntentHelper"; + private static final String[] EVENTS = { + "Intent:GetHandlers", + "Intent:Open", + "Intent:OpenForResult", + }; + + private static final String[] NATIVE_EVENTS = { + "Intent:OpenNoHandler", + }; + + // via http://developer.android.com/distribute/tools/promote/linking.html + private static String MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id="; + private static String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"; + + /** A partial URI to an error page - the encoded error URI should be appended before loading. */ + private static String UNKNOWN_PROTOCOL_URI_PREFIX = "about:neterror?e=unknownProtocolFound&u="; + + private static IntentHelper instance; + + private final FragmentActivity activity; + + private IntentHelper(final FragmentActivity activity) { + this.activity = activity; + EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this, EVENTS); + EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener) this, NATIVE_EVENTS); + } + + public static IntentHelper init(final FragmentActivity activity) { + if (instance == null) { + instance = new IntentHelper(activity); + } else { + Log.w(LOGTAG, "IntentHelper.init() called twice, ignoring."); + } + + return instance; + } + + public static void destroy() { + if (instance != null) { + EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) instance, EVENTS); + EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) instance, NATIVE_EVENTS); + instance = null; + } + } + + /** + * Given the inputs to <code>getOpenURIIntent</code>, plus an optional + * package name and class name, create and fire an intent to open the + * provided URI. If a class name is specified but a package name is not, + * we will default to using the current fennec package. + * + * @param targetURI the string spec of the URI to open. + * @param mimeType an optional MIME type string. + * @param packageName an optional app package name. + * @param className an optional intent class name. + * @param action an Android action specifier, such as + * <code>Intent.ACTION_SEND</code>. + * @param title the title to use in <code>ACTION_SEND</code> intents. + * @param showPromptInPrivateBrowsing whether or not the user should be prompted when opening + * this uri from private browsing. This should be true + * when the user doesn't explicitly choose to open an an + * external app (e.g. just clicked a link). + * @return true if the activity started successfully or the user was prompted to open the + * application; false otherwise. + */ + public static boolean openUriExternal(String targetURI, + String mimeType, + String packageName, + String className, + String action, + String title, + final boolean showPromptInPrivateBrowsing) { + final GeckoAppShell.GeckoInterface gi = GeckoAppShell.getGeckoInterface(); + final Context activityContext = gi != null ? gi.getActivity() : null; + final Context context = activityContext != null ? activityContext : GeckoAppShell.getApplicationContext(); + final Intent intent = getOpenURIIntent(context, targetURI, + mimeType, action, title); + + if (intent == null) { + return false; + } + + if (!TextUtils.isEmpty(className)) { + if (!TextUtils.isEmpty(packageName)) { + intent.setClassName(packageName, className); + } else { + // Default to using the fennec app context. + intent.setClassName(context, className); + } + } + + if (!showPromptInPrivateBrowsing || activityContext == null) { + if (activityContext == null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, intent); + } else { + // Ideally we retrieve the Activity from the calling args, rather than + // statically, but since this method is called from Gecko and I'm + // unfamiliar with that code, this is a simpler solution. + final FragmentActivity fragmentActivity = (FragmentActivity) activityContext; + return ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser( + context, fragmentActivity.getSupportFragmentManager(), intent); + } + } + + public static boolean hasHandlersForIntent(Intent intent) { + try { + return !GeckoAppShell.queryIntentActivities(intent).isEmpty(); + } catch (Exception ex) { + Log.e(LOGTAG, "Exception in hasHandlersForIntent"); + return false; + } + } + + public static String[] getHandlersForIntent(Intent intent) { + final PackageManager pm = GeckoAppShell.getApplicationContext().getPackageManager(); + try { + final List<ResolveInfo> list = GeckoAppShell.queryIntentActivities(intent); + + int numAttr = 4; + final String[] ret = new String[list.size() * numAttr]; + for (int i = 0; i < list.size(); i++) { + ResolveInfo resolveInfo = list.get(i); + ret[i * numAttr] = resolveInfo.loadLabel(pm).toString(); + if (resolveInfo.isDefault) + ret[i * numAttr + 1] = "default"; + else + ret[i * numAttr + 1] = ""; + ret[i * numAttr + 2] = resolveInfo.activityInfo.applicationInfo.packageName; + ret[i * numAttr + 3] = resolveInfo.activityInfo.name; + } + return ret; + } catch (Exception ex) { + Log.e(LOGTAG, "Exception in getHandlersForIntent"); + return new String[0]; + } + } + + public static Intent getIntentForActionString(String aAction) { + // Default to the view action if no other action as been specified. + if (TextUtils.isEmpty(aAction)) { + return new Intent(Intent.ACTION_VIEW); + } + return new Intent(aAction); + } + + /** + * Given a URI, a MIME type, and a title, + * produce a share intent which can be used to query all activities + * than can open the specified URI. + * + * @param context a <code>Context</code> instance. + * @param targetURI the string spec of the URI to open. + * @param mimeType an optional MIME type string. + * @param title the title to use in <code>ACTION_SEND</code> intents. + * @return an <code>Intent</code>, or <code>null</code> if none could be + * produced. + */ + public static Intent getShareIntent(final Context context, + final String targetURI, + final String mimeType, + final String title) { + Intent shareIntent = getIntentForActionString(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_TEXT, targetURI); + shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); + shareIntent.putExtra(ShareDialog.INTENT_EXTRA_DEVICES_ONLY, true); + + // Note that EXTRA_TITLE is intended to be used for share dialog + // titles. Common usage (e.g., Pocket) suggests that it's sometimes + // interpreted as an alternate to EXTRA_SUBJECT, so we include it. + shareIntent.putExtra(Intent.EXTRA_TITLE, title); + + if (mimeType != null && mimeType.length() > 0) { + shareIntent.setType(mimeType); + } + + return shareIntent; + } + + /** + * Given a URI, a MIME type, an Android intent "action", and a title, + * produce an intent which can be used to start an activity to open + * the specified URI. + * + * @param context a <code>Context</code> instance. + * @param targetURI the string spec of the URI to open. + * @param mimeType an optional MIME type string. + * @param action an Android action specifier, such as + * <code>Intent.ACTION_SEND</code>. + * @param title the title to use in <code>ACTION_SEND</code> intents. + * @return an <code>Intent</code>, or <code>null</code> if none could be + * produced. + */ + static Intent getOpenURIIntent(final Context context, + final String targetURI, + final String mimeType, + final String action, + final String title) { + + // The resultant chooser can return non-exported activities in 4.1 and earlier. + // https://code.google.com/p/android/issues/detail?id=29535 + final Intent intent = getOpenURIIntentInner(context, targetURI, mimeType, action, title); + + if (intent != null) { + // Some applications use this field to return to the same browser after processing the + // Intent. While there is some danger (e.g. denial of service), other major browsers already + // use it and so it's the norm. + intent.putExtra(Browser.EXTRA_APPLICATION_ID, AppConstants.ANDROID_PACKAGE_NAME); + } + + return intent; + } + + private static Intent getOpenURIIntentInner(final Context context, final String targetURI, + final String mimeType, final String action, final String title) { + + if (action.equalsIgnoreCase(Intent.ACTION_SEND)) { + Intent shareIntent = getShareIntent(context, targetURI, mimeType, title); + return Intent.createChooser(shareIntent, + context.getResources().getString(R.string.share_title)); + } + + Uri uri = normalizeUriScheme(targetURI.indexOf(':') >= 0 ? Uri.parse(targetURI) : new Uri.Builder().scheme(targetURI).build()); + if (!TextUtils.isEmpty(mimeType)) { + Intent intent = getIntentForActionString(action); + intent.setDataAndType(uri, mimeType); + return intent; + } + + if (!GeckoAppShell.isUriSafeForScheme(uri)) { + return null; + } + + final String scheme = uri.getScheme(); + if ("intent".equals(scheme) || "android-app".equals(scheme)) { + final Intent intent; + try { + intent = Intent.parseUri(targetURI, 0); + } catch (final URISyntaxException e) { + Log.e(LOGTAG, "Unable to parse URI - " + e); + return null; + } + + // Only open applications which can accept arbitrary data from a browser. + intent.addCategory(Intent.CATEGORY_BROWSABLE); + + // Prevent site from explicitly opening our internal activities, which can leak data. + intent.setComponent(null); + nullIntentSelector(intent); + + return intent; + } + + // Compute our most likely intent, then check to see if there are any + // custom handlers that would apply. + // Start with the original URI. If we end up modifying it, we'll + // overwrite it. + final String extension = MimeTypeMap.getFileExtensionFromUrl(targetURI); + final Intent intent = getIntentForActionString(action); + intent.setData(uri); + + if ("file".equals(scheme)) { + // Only set explicit mimeTypes on file://. + final String mimeType2 = GeckoAppShell.getMimeTypeFromExtension(extension); + intent.setType(mimeType2); + return intent; + } + + // Have a special handling for SMS based schemes, as the query parameters + // are not extracted from the URI automatically. + if (!"sms".equals(scheme) && !"smsto".equals(scheme) && !"mms".equals(scheme) && !"mmsto".equals(scheme)) { + return intent; + } + + final String query = uri.getEncodedQuery(); + if (TextUtils.isEmpty(query)) { + return intent; + } + + // It is common to see sms*/mms* uris on the web without '//', it is W3C standard not to have the slashes, + // but android's Uri builder & Uri require the slashes and will interpret those without as malformed. + String currentUri = uri.toString(); + String correctlyFormattedDataURIScheme = scheme + "://"; + if (!currentUri.contains(correctlyFormattedDataURIScheme)) { + uri = Uri.parse(currentUri.replaceFirst(scheme + ":", correctlyFormattedDataURIScheme)); + } + + final String[] fields = query.split("&"); + boolean shouldUpdateIntent = false; + String resultQuery = ""; + for (String field : fields) { + if (field.startsWith("body=")) { + final String body = Uri.decode(field.substring(5)); + intent.putExtra("sms_body", body); + shouldUpdateIntent = true; + } else if (field.startsWith("subject=")) { + final String subject = Uri.decode(field.substring(8)); + intent.putExtra("subject", subject); + shouldUpdateIntent = true; + } else if (field.startsWith("cc=")) { + final String ccNumber = Uri.decode(field.substring(3)); + String phoneNumber = uri.getAuthority(); + if (phoneNumber != null) { + uri = uri.buildUpon().encodedAuthority(phoneNumber + ";" + ccNumber).build(); + } + shouldUpdateIntent = true; + } else { + resultQuery = resultQuery.concat(resultQuery.length() > 0 ? "&" + field : field); + } + } + + if (!shouldUpdateIntent) { + // No need to rewrite the URI, then. + return intent; + } + + // Form a new URI without the extracted fields in the query part, and + // push that into the new Intent. + final String newQuery = resultQuery.length() > 0 ? "?" + resultQuery : ""; + final Uri pruned = uri.buildUpon().encodedQuery(newQuery).build(); + intent.setData(pruned); + + return intent; + } + + // We create a separate method to better encapsulate the @TargetApi use. + @TargetApi(15) + private static void nullIntentSelector(final Intent intent) { + intent.setSelector(null); + } + + /** + * Return a <code>Uri</code> instance which is equivalent to <code>u</code>, + * but with a guaranteed-lowercase scheme as if the API level 16 method + * <code>u.normalizeScheme</code> had been called. + * + * @param u the <code>Uri</code> to normalize. + * @return a <code>Uri</code>, which might be <code>u</code>. + */ + private static Uri normalizeUriScheme(final Uri u) { + final String scheme = u.getScheme(); + final String lower = scheme.toLowerCase(Locale.US); + if (lower.equals(scheme)) { + return u; + } + + // Otherwise, return a new URI with a normalized scheme. + return u.buildUpon().scheme(lower).build(); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + if (event.equals("Intent:OpenNoHandler")) { + openNoHandler(message, callback); + } + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("Intent:GetHandlers")) { + getHandlers(message); + } else if (event.equals("Intent:Open")) { + open(message); + } else if (event.equals("Intent:OpenForResult")) { + openForResult(message); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + private void getHandlers(JSONObject message) throws JSONException { + final Intent intent = getOpenURIIntent(activity, + message.optString("url"), + message.optString("mime"), + message.optString("action"), + message.optString("title")); + final List<String> appList = Arrays.asList(getHandlersForIntent(intent)); + + final JSONObject response = new JSONObject(); + response.put("apps", new JSONArray(appList)); + EventDispatcher.sendResponse(message, response); + } + + private void open(JSONObject message) throws JSONException { + openUriExternal(message.optString("url"), + message.optString("mime"), + message.optString("packageName"), + message.optString("className"), + message.optString("action"), + message.optString("title"), false); + } + + private void openForResult(final JSONObject message) throws JSONException { + Intent intent = getOpenURIIntent(activity, + message.optString("url"), + message.optString("mime"), + message.optString("action"), + message.optString("title")); + intent.setClassName(message.optString("packageName"), message.optString("className")); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + final ResultHandler handler = new ResultHandler(message); + try { + ActivityHandlerHelper.startIntentForActivity(activity, intent, handler); + } catch (SecurityException e) { + Log.w(LOGTAG, "Forbidden to launch activity.", e); + } + } + + /** + * Opens a URI without any valid handlers on device. In the best case, a package is specified + * and we can bring the user directly to the application page in an app market. If a package is + * not specified and there is a fallback url in the intent extras, we open that url. If neither + * is present, we alert the user that we were unable to open the link. + * + * @param msg A message with the uri with no handlers as the value for the "uri" key + * @param callback A callback that will be called with success & no params if Java loads a page, or with error and + * the uri to load if Java does not load a page + */ + private void openNoHandler(final NativeJSObject msg, final EventCallback callback) { + final String uri = msg.getString("uri"); + + if (TextUtils.isEmpty(uri)) { + Log.w(LOGTAG, "Received empty URL - loading about:neterror"); + callback.sendError(getUnknownProtocolErrorPageUri("")); + return; + } + + final Intent intent; + try { + // TODO (bug 1173626): This will not handle android-app uris on non 5.1 devices. + intent = Intent.parseUri(uri, 0); + } catch (final URISyntaxException e) { + String errorUri; + try { + errorUri = getUnknownProtocolErrorPageUri(URLEncoder.encode(uri, "UTF-8")); + } catch (final UnsupportedEncodingException encodingE) { + errorUri = getUnknownProtocolErrorPageUri(""); + } + + // Don't log the exception to prevent leaking URIs. + Log.w(LOGTAG, "Unable to parse Intent URI - loading about:neterror"); + callback.sendError(errorUri); + return; + } + + // For this flow, we follow Chrome's lead: + // https://developer.chrome.com/multidevice/android/intents + final String fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL); + if (isFallbackUrlValid(fallbackUrl)) { + // Opens the page in JS. + callback.sendError(fallbackUrl); + + } else if (intent.getPackage() != null) { + // Note on alternative flows: we could get the intent package from a component, however, for + // security reasons, components are ignored when opening URIs (bug 1168998) so we should + // ignore it here too. + // + // Our old flow used to prompt the user to search for their app in the market by scheme and + // while this could help the user find a new app, there is not always a correlation in + // scheme to application name and we could end up steering the user wrong (potentially to + // malicious software). Better to leave that one alone. + final String marketUri = MARKET_INTENT_URI_PACKAGE_PREFIX + intent.getPackage(); + final Intent marketIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(marketUri)); + marketIntent.addCategory(Intent.CATEGORY_BROWSABLE); + marketIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // (Bug 1192436) We don't know if marketIntent matches any Activities (e.g. non-Play + // Store devices). If it doesn't, clicking the link will cause no action to occur. + ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser( + activity, activity.getSupportFragmentManager(), marketIntent); + callback.sendSuccess(null); + + } else { + // We return the error page here, but it will only be shown if we think the load did + // not come from clicking a link. Chrome does not show error pages in that case, and + // many websites have catered to this behavior. For example, the site might set a timeout and load a play + // store url for their app if the intent link fails to load, i.e. the app is not installed. + // These work-arounds would often end with our users seeing about:neterror instead of the intended experience. + // While I feel showing about:neterror is a better solution for users (when not hacked around), + // we should match the status quo for the good of our users. + // + // Don't log the URI to prevent leaking it. + Log.w(LOGTAG, "Unable to open URI, maybe showing neterror"); + callback.sendError(getUnknownProtocolErrorPageUri(intent.getData().toString())); + } + } + + private static boolean isFallbackUrlValid(@Nullable final String fallbackUrl) { + if (fallbackUrl == null) { + return false; + } + + try { + final String anyCaseScheme = new URI(fallbackUrl).getScheme(); + final String scheme = (anyCaseScheme == null) ? null : anyCaseScheme.toLowerCase(Locale.US); + if ("http".equals(scheme) || "https".equals(scheme)) { + return true; + } else { + Log.w(LOGTAG, "Fallback URI uses unsupported scheme: " + scheme + ". Try http or https."); + } + } catch (final URISyntaxException e) { + // Do not include Exception to avoid leaking uris. + Log.w(LOGTAG, "URISyntaxException parsing fallback URI"); + } + return false; + } + + /** + * Returns an about:neterror uri with the unknownProtocolFound text as a parameter. + * @param encodedUri The encoded uri. While the page does not open correctly without specifying + * a uri parameter, it happily accepts the empty String so this argument may + * be the empty String. + */ + private String getUnknownProtocolErrorPageUri(final String encodedUri) { + return UNKNOWN_PROTOCOL_URI_PREFIX + encodedUri; + } + + private static class ResultHandler implements ActivityResultHandler { + private final JSONObject message; + + public ResultHandler(JSONObject message) { + this.message = message; + } + + @Override + public void onActivityResult(int resultCode, Intent data) { + JSONObject response = new JSONObject(); + try { + if (data != null) { + if (data.getExtras() != null) { + response.put("extras", JSONUtils.bundleToJSON(data.getExtras())); + } + if (data.getData() != null) { + response.put("uri", data.getData().toString()); + } + } + response.put("resultCode", resultCode); + } catch (JSONException e) { + Log.w(LOGTAG, "Error building JSON response.", e); + } + EventDispatcher.sendResponse(message, response); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java b/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java new file mode 100644 index 000000000..4de8fa423 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java @@ -0,0 +1,110 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.customtabs.CustomTabsIntent; + +import org.mozilla.gecko.customtabs.CustomTabsActivity; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.tabqueue.TabQueueHelper; +import org.mozilla.gecko.tabqueue.TabQueueService; + +/** + * Activity that receives incoming Intents and dispatches them to the appropriate activities (e.g. browser, custom tabs, web app). + */ +public class LauncherActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + GeckoAppShell.ensureCrashHandling(); + + final SafeIntent safeIntent = new SafeIntent(getIntent()); + + // If it's not a view intent, it won't be a custom tabs intent either. Just launch! + if (!isViewIntentWithURL(safeIntent)) { + dispatchNormalIntent(); + + // Is this a custom tabs intent, and are custom tabs enabled? + } else if (AppConstants.MOZ_ANDROID_CUSTOM_TABS && isCustomTabsIntent(safeIntent) + && isCustomTabsEnabled()) { + dispatchCustomTabsIntent(); + + // Can we dispatch this VIEW action intent to the tab queue service? + } else if (!safeIntent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false) + && TabQueueHelper.TAB_QUEUE_ENABLED + && TabQueueHelper.isTabQueueEnabled(this)) { + dispatchTabQueueIntent(); + + // Dispatch this VIEW action intent to the browser. + } else { + dispatchNormalIntent(); + } + + finish(); + } + + /** + * Launch tab queue service to display overlay. + */ + private void dispatchTabQueueIntent() { + Intent intent = new Intent(getIntent()); + intent.setClass(getApplicationContext(), TabQueueService.class); + startService(intent); + } + + /** + * Launch the browser activity. + */ + private void dispatchNormalIntent() { + Intent intent = new Intent(getIntent()); + intent.setClassName(getApplicationContext(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + + filterFlags(intent); + + startActivity(intent); + } + + private void dispatchCustomTabsIntent() { + Intent intent = new Intent(getIntent()); + intent.setClassName(getApplicationContext(), CustomTabsActivity.class.getName()); + + filterFlags(intent); + + startActivity(intent); + } + + private static void filterFlags(Intent intent) { + // Explicitly remove the new task and clear task flags (Our browser activity is a single + // task activity and we never want to start a second task here). See bug 1280112. + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_CLEAR_TASK); + + // LauncherActivity is started with the "exclude from recents" flag (set in manifest). We do + // not want to propagate this flag from the launcher activity to the browser. + intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + } + + private static boolean isViewIntentWithURL(@NonNull final SafeIntent safeIntent) { + return Intent.ACTION_VIEW.equals(safeIntent.getAction()) + && safeIntent.getDataString() != null; + } + + private static boolean isCustomTabsIntent(@NonNull final SafeIntent safeIntent) { + return isViewIntentWithURL(safeIntent) + && safeIntent.hasExtra(CustomTabsIntent.EXTRA_SESSION); + } + + private boolean isCustomTabsEnabled() { + return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_CUSTOM_TABS, false); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java b/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java new file mode 100644 index 000000000..795caa925 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java @@ -0,0 +1,42 @@ +/* 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; + +import java.util.Locale; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; + +/** + * Implement this interface to provide Fennec's locale switching functionality. + * + * The LocaleManager is responsible for persisting and applying selected locales, + * and correcting configurations after Android has changed them. + */ +public interface LocaleManager { + void initialize(Context context); + + /** + * @return true if locale switching is enabled. + */ + boolean isEnabled(); + Locale getCurrentLocale(Context context); + String getAndApplyPersistedLocale(Context context); + void correctLocale(Context context, Resources resources, Configuration newConfig); + void updateConfiguration(Context context, Locale locale); + String setSelectedLocale(Context context, String localeCode); + boolean systemLocaleDidChange(); + void resetToSystemLocale(Context context); + + /** + * Call this in your onConfigurationChanged handler. This method is expected + * to do the appropriate thing: if the user has selected a locale, it + * corrects the incoming configuration; if not, it signals the new locale to + * use. + */ + Locale onSystemConfigurationChanged(Context context, Resources resources, Configuration configuration, Locale currentActivityLocale); + String getFallbackLocaleTag(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Locales.java b/mobile/android/base/java/org/mozilla/gecko/Locales.java new file mode 100644 index 000000000..e030b95e9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Locales.java @@ -0,0 +1,136 @@ +/* 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; + +import java.lang.reflect.Method; +import java.util.Locale; + +import org.mozilla.gecko.LocaleManager; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.os.StrictMode; +import android.support.v4.app.FragmentActivity; +import android.support.v7.app.AppCompatActivity; + +/** + * This is a helper class to do typical locale switching operations without + * hitting StrictMode errors or adding boilerplate to common activity + * subclasses. + * + * Either call {@link Locales#initializeLocale(Context)} in your + * <code>onCreate</code> method, or inherit from + * <code>LocaleAwareFragmentActivity</code> or <code>LocaleAwareActivity</code>. + */ +public class Locales { + public static LocaleManager getLocaleManager() { + try { + final Class<?> clazz = Class.forName("org.mozilla.gecko.BrowserLocaleManager"); + final Method getInstance = clazz.getMethod("getInstance"); + final LocaleManager localeManager = (LocaleManager) getInstance.invoke(null); + return localeManager; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static void initializeLocale(Context context) { + final LocaleManager localeManager = getLocaleManager(); + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + StrictMode.allowThreadDiskWrites(); + try { + localeManager.getAndApplyPersistedLocale(context); + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + public static abstract class LocaleAwareAppCompatActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + Locales.initializeLocale(getApplicationContext()); + super.onCreate(savedInstanceState); + } + + } + public static abstract class LocaleAwareFragmentActivity extends FragmentActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + Locales.initializeLocale(getApplicationContext()); + super.onCreate(savedInstanceState); + } + } + + public static abstract class LocaleAwareActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + Locales.initializeLocale(getApplicationContext()); + super.onCreate(savedInstanceState); + } + } + + /** + * Sometimes we want just the language for a locale, not the entire language + * tag. But Java's .getLanguage method is wrong. + * + * This method is equivalent to the first part of + * {@link Locales#getLanguageTag(Locale)}. + * + * @return a language string, such as "he" for the Hebrew locales. + */ + public static String getLanguage(final Locale locale) { + // Can, but should never be, an empty string. + final String language = locale.getLanguage(); + + // Modernize certain language codes. + if (language.equals("iw")) { + return "he"; + } + + if (language.equals("in")) { + return "id"; + } + + if (language.equals("ji")) { + return "yi"; + } + + return language; + } + + /** + * Gecko uses locale codes like "es-ES", whereas a Java {@link Locale} + * stringifies as "es_ES". + * + * This method approximates the Java 7 method + * <code>Locale#toLanguageTag()</code>. + * + * @return a locale string suitable for passing to Gecko. + */ + public static String getLanguageTag(final Locale locale) { + // If this were Java 7: + // return locale.toLanguageTag(); + + final String language = getLanguage(locale); + final String country = locale.getCountry(); // Can be an empty string. + if (country.equals("")) { + return language; + } + return language + "-" + country; + } + + public static Locale parseLocaleCode(final String localeCode) { + int index; + if ((index = localeCode.indexOf('-')) != -1 || + (index = localeCode.indexOf('_')) != -1) { + final String langCode = localeCode.substring(0, index); + final String countryCode = localeCode.substring(index + 1); + return new Locale(langCode, countryCode); + } + + return new Locale(localeCode); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java b/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java new file mode 100644 index 000000000..bd109058c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java @@ -0,0 +1,131 @@ +/* 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; + +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONObject; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.RelativeLayout; +import android.widget.TextView; + +public class MediaCastingBar extends RelativeLayout implements View.OnClickListener, GeckoEventListener { + private static final String LOGTAG = "GeckoMediaCastingBar"; + + private TextView mCastingTo; + private ImageButton mMediaPlay; + private ImageButton mMediaPause; + private ImageButton mMediaStop; + + private boolean mInflated; + + public MediaCastingBar(Context context, AttributeSet attrs) { + super(context, attrs); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "Casting:Started", + "Casting:Paused", + "Casting:Playing", + "Casting:Stopped"); + } + + public void inflateContent() { + LayoutInflater inflater = LayoutInflater.from(getContext()); + View content = inflater.inflate(R.layout.media_casting, this); + + mMediaPlay = (ImageButton) content.findViewById(R.id.media_play); + mMediaPlay.setOnClickListener(this); + mMediaPause = (ImageButton) content.findViewById(R.id.media_pause); + mMediaPause.setOnClickListener(this); + mMediaStop = (ImageButton) content.findViewById(R.id.media_stop); + mMediaStop.setOnClickListener(this); + + mCastingTo = (TextView) content.findViewById(R.id.media_sending_to); + + // Capture clicks on the rest of the view to prevent them from + // leaking into other views positioned below. + content.setOnClickListener(this); + + mInflated = true; + } + + public void show() { + if (!mInflated) + inflateContent(); + + setVisibility(VISIBLE); + } + + public void hide() { + setVisibility(GONE); + } + + public void onDestroy() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "Casting:Started", + "Casting:Paused", + "Casting:Playing", + "Casting:Stopped"); + } + + // View.OnClickListener implementation + @Override + public void onClick(View v) { + final int viewId = v.getId(); + + if (viewId == R.id.media_play) { + GeckoAppShell.notifyObservers("Casting:Play", ""); + mMediaPlay.setVisibility(GONE); + mMediaPause.setVisibility(VISIBLE); + } else if (viewId == R.id.media_pause) { + GeckoAppShell.notifyObservers("Casting:Pause", ""); + mMediaPause.setVisibility(GONE); + mMediaPlay.setVisibility(VISIBLE); + } else if (viewId == R.id.media_stop) { + GeckoAppShell.notifyObservers("Casting:Stop", ""); + } + } + + // GeckoEventListener implementation + @Override + public void handleMessage(final String event, final JSONObject message) { + final String device = message.optString("device"); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (event.equals("Casting:Started")) { + show(); + if (!TextUtils.isEmpty(device)) { + mCastingTo.setText(device); + } else { + // Should not happen + mCastingTo.setText(""); + Log.d(LOGTAG, "Device name is empty."); + } + mMediaPlay.setVisibility(GONE); + mMediaPause.setVisibility(VISIBLE); + } else if (event.equals("Casting:Paused")) { + mMediaPause.setVisibility(GONE); + mMediaPlay.setVisibility(VISIBLE); + } else if (event.equals("Casting:Playing")) { + mMediaPlay.setVisibility(GONE); + mMediaPause.setVisibility(VISIBLE); + } else if (event.equals("Casting:Stopped")) { + hide(); + } + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java b/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java new file mode 100644 index 000000000..fc0ce82cf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java @@ -0,0 +1,323 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.media.MediaControlIntent; +import android.support.v7.media.MediaRouteSelector; +import android.support.v7.media.MediaRouter; +import android.support.v7.media.MediaRouter.RouteInfo; +import android.util.Log; + +import com.google.android.gms.cast.CastMediaControlIntent; + +import org.json.JSONObject; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Manages a list of GeckoMediaPlayers methods (i.e. Chromecast/Miracast). Routes messages + * from Gecko to the correct caster based on the id of the display + */ +public class MediaPlayerManager extends Fragment implements NativeEventListener { + /** + * Create a new instance of DetailsFragment, initialized to + * show the text at 'index'. + */ + + private static MediaPlayerManager instance = null; + + @ReflectionTarget + public static MediaPlayerManager getInstance() { + if (instance != null) { + return instance; + } + if (Versions.feature17Plus) { + instance = (MediaPlayerManager) new PresentationMediaPlayerManager(); + } else { + instance = new MediaPlayerManager(); + } + return instance; + } + + private static final String LOGTAG = "GeckoMediaPlayerManager"; + protected boolean isPresentationMode = false; // Used to prevent mirroring when Presentation API is used. + + @ReflectionTarget + public static final String MEDIA_PLAYER_TAG = "MPManagerFragment"; + + private static final boolean SHOW_DEBUG = false; + // Simplified debugging interfaces + private static void debug(String msg, Exception e) { + if (SHOW_DEBUG) { + Log.e(LOGTAG, msg, e); + } + } + + private static void debug(String msg) { + if (SHOW_DEBUG) { + Log.d(LOGTAG, msg); + } + } + + protected MediaRouter mediaRouter = null; + protected final Map<String, GeckoMediaPlayer> players = new HashMap<String, GeckoMediaPlayer>(); + protected final Map<String, GeckoPresentationDisplay> displays = new HashMap<String, GeckoPresentationDisplay>(); // used for Presentation API + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "MediaPlayer:Load", + "MediaPlayer:Start", + "MediaPlayer:Stop", + "MediaPlayer:Play", + "MediaPlayer:Pause", + "MediaPlayer:End", + "MediaPlayer:Mirror", + "MediaPlayer:Message", + "AndroidCastDevice:Start", + "AndroidCastDevice:Stop", + "AndroidCastDevice:SyncDevice"); + } + + @Override + @JNITarget + public void onDestroy() { + super.onDestroy(); + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "MediaPlayer:Load", + "MediaPlayer:Start", + "MediaPlayer:Stop", + "MediaPlayer:Play", + "MediaPlayer:Pause", + "MediaPlayer:End", + "MediaPlayer:Mirror", + "MediaPlayer:Message", + "AndroidCastDevice:Start", + "AndroidCastDevice:Stop", + "AndroidCastDevice:SyncDevice"); + } + + // GeckoEventListener implementation + @Override + public void handleMessage(String event, final NativeJSObject message, final EventCallback callback) { + debug(event); + if (event.startsWith("MediaPlayer:")) { + final GeckoMediaPlayer player = players.get(message.getString("id")); + if (player == null) { + Log.e(LOGTAG, "Couldn't find a player for this id: " + message.getString("id") + " for message: " + event); + if (callback != null) { + callback.sendError(null); + } + return; + } + + if ("MediaPlayer:Play".equals(event)) { + player.play(callback); + } else if ("MediaPlayer:Start".equals(event)) { + player.start(callback); + } else if ("MediaPlayer:Stop".equals(event)) { + player.stop(callback); + } else if ("MediaPlayer:Pause".equals(event)) { + player.pause(callback); + } else if ("MediaPlayer:End".equals(event)) { + player.end(callback); + } else if ("MediaPlayer:Mirror".equals(event)) { + player.mirror(callback); + } else if ("MediaPlayer:Message".equals(event) && message.has("data")) { + player.message(message.getString("data"), callback); + } else if ("MediaPlayer:Load".equals(event)) { + final String url = message.optString("source", ""); + final String type = message.optString("type", "video/mp4"); + final String title = message.optString("title", ""); + player.load(title, url, type, callback); + } + } + + if (event.startsWith("AndroidCastDevice:")) { + if ("AndroidCastDevice:Start".equals(event)) { + final GeckoPresentationDisplay display = displays.get(message.getString("id")); + if (display == null) { + Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event); + return; + } + display.start(callback); + } else if ("AndroidCastDevice:Stop".equals(event)) { + final GeckoPresentationDisplay display = displays.get(message.getString("id")); + if (display == null) { + Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event); + return; + } + display.stop(callback); + } else if ("AndroidCastDevice:SyncDevice".equals(event)) { + for (Map.Entry<String, GeckoPresentationDisplay> entry : displays.entrySet()) { + GeckoPresentationDisplay display = entry.getValue(); + JSONObject json = display.toJSON(); + if (json == null) { + break; + } + GeckoAppShell.notifyObservers("AndroidCastDevice:Added", json.toString()); + } + } + } + } + + private final MediaRouter.Callback callback = + new MediaRouter.Callback() { + @Override + public void onRouteRemoved(MediaRouter router, RouteInfo route) { + debug("onRouteRemoved: route=" + route); + + // Remove from media player list. + players.remove(route.getId()); + GeckoAppShell.notifyObservers("MediaPlayer:Removed", route.getId()); + updatePresentation(); + + // Remove from presentation display list. + displays.remove(route.getId()); + GeckoAppShell.notifyObservers("AndroidCastDevice:Removed", route.getId()); + } + + @SuppressWarnings("unused") + public void onRouteSelected(MediaRouter router, int type, MediaRouter.RouteInfo route) { + updatePresentation(); + } + + // These methods aren't used by the support version Media Router + @SuppressWarnings("unused") + public void onRouteUnselected(MediaRouter router, int type, RouteInfo route) { + updatePresentation(); + } + + @Override + public void onRoutePresentationDisplayChanged(MediaRouter router, RouteInfo route) { + updatePresentation(); + } + + @Override + public void onRouteVolumeChanged(MediaRouter router, RouteInfo route) { + } + + @Override + public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo route) { + debug("onRouteAdded: route=" + route); + final GeckoMediaPlayer player = getMediaPlayerForRoute(route); + saveAndNotifyOfPlayer("MediaPlayer:Added", route, player); + updatePresentation(); + + final GeckoPresentationDisplay display = getPresentationDisplayForRoute(route); + saveAndNotifyOfDisplay("AndroidCastDevice:Added", route, display); + } + + @Override + public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) { + debug("onRouteChanged: route=" + route); + final GeckoMediaPlayer player = players.get(route.getId()); + saveAndNotifyOfPlayer("MediaPlayer:Changed", route, player); + updatePresentation(); + + final GeckoPresentationDisplay display = displays.get(route.getId()); + saveAndNotifyOfDisplay("AndroidCastDevice:Changed", route, display); + } + + private void saveAndNotifyOfPlayer(final String eventName, + MediaRouter.RouteInfo route, + final GeckoMediaPlayer player) { + if (player == null) { + return; + } + + final JSONObject json = player.toJSON(); + if (json == null) { + return; + } + + players.put(route.getId(), player); + GeckoAppShell.notifyObservers(eventName, json.toString()); + } + + private void saveAndNotifyOfDisplay(final String eventName, + MediaRouter.RouteInfo route, + final GeckoPresentationDisplay display) { + if (display == null) { + return; + } + + final JSONObject json = display.toJSON(); + if (json == null) { + return; + } + + displays.put(route.getId(), display); + GeckoAppShell.notifyObservers(eventName, json.toString()); + } + }; + + private GeckoMediaPlayer getMediaPlayerForRoute(MediaRouter.RouteInfo route) { + try { + if (route.supportsControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)) { + return new ChromeCastPlayer(getActivity(), route); + } + } catch (Exception ex) { + debug("Error handling presentation", ex); + } + + return null; + } + + private GeckoPresentationDisplay getPresentationDisplayForRoute(MediaRouter.RouteInfo route) { + try { + if (route.supportsControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastDisplay.REMOTE_DISPLAY_APP_ID))) { + return new ChromeCastDisplay(getActivity(), route); + } + } catch (Exception ex) { + debug("Error handling presentation", ex); + } + return null; + } + + @Override + public void onPause() { + super.onPause(); + mediaRouter.removeCallback(callback); + mediaRouter = null; + } + + @Override + public void onResume() { + super.onResume(); + + // The mediaRouter shouldn't exist here, but this is a nice safety check. + if (mediaRouter != null) { + return; + } + + mediaRouter = MediaRouter.getInstance(getActivity()); + final MediaRouteSelector selectorBuilder = new MediaRouteSelector.Builder() + .addControlCategory(MediaControlIntent.CATEGORY_LIVE_VIDEO) + .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) + .addControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastPlayer.MIRROR_RECEIVER_APP_ID)) + .addControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastDisplay.REMOTE_DISPLAY_APP_ID)) + .build(); + mediaRouter.addCallback(selectorBuilder, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY); + } + + public void setPresentationMode(boolean isPresentationMode) { + this.isPresentationMode = isPresentationMode; + } + + protected void updatePresentation() { /* Overridden in sub-classes. */ } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java b/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java new file mode 100644 index 000000000..94ca761b9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java @@ -0,0 +1,279 @@ +/* -*- 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; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserProvider; +import org.mozilla.gecko.home.ImageLoader; +import org.mozilla.gecko.icons.storage.MemoryStorage; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.BroadcastReceiver; +import android.content.ComponentCallbacks2; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; + +/** + * This is a utility class to keep track of how much memory and disk-space pressure + * the system is under. It receives input from GeckoActivity via the onLowMemory() and + * onTrimMemory() functions, and also listens for some system intents related to + * disk-space notifications. Internally it will track how much memory and disk pressure + * the system is under, and perform various actions to help alleviate the pressure. + * + * Note that since there is no notification for when the system has lots of free memory + * again, this class also assumes that, over time, the system will free up memory. This + * assumption is implemented using a timer that slowly lowers the internal memory + * pressure state if no new low-memory notifications are received. + * + * Synchronization note: MemoryMonitor contains an inner class PressureDecrementer. Both + * of these classes may be accessed from various threads, and have both been designed to + * be thread-safe. In terms of lock ordering, code holding the PressureDecrementer lock + * is allowed to pick up the MemoryMonitor lock, but not vice-versa. + */ +class MemoryMonitor extends BroadcastReceiver { + private static final String LOGTAG = "GeckoMemoryMonitor"; + private static final String ACTION_MEMORY_DUMP = "org.mozilla.gecko.MEMORY_DUMP"; + private static final String ACTION_FORCE_PRESSURE = "org.mozilla.gecko.FORCE_MEMORY_PRESSURE"; + + // Memory pressure levels. Keep these in sync with those in AndroidJavaWrappers.h + private static final int MEMORY_PRESSURE_NONE = 0; + private static final int MEMORY_PRESSURE_CLEANUP = 1; + private static final int MEMORY_PRESSURE_LOW = 2; + private static final int MEMORY_PRESSURE_MEDIUM = 3; + private static final int MEMORY_PRESSURE_HIGH = 4; + + private static final MemoryMonitor sInstance = new MemoryMonitor(); + + static MemoryMonitor getInstance() { + return sInstance; + } + + private Context mAppContext; + private final PressureDecrementer mPressureDecrementer; + private int mMemoryPressure; // Synchronized access only. + private volatile boolean mStoragePressure; // Accessed via UI thread intent, background runnables. + private boolean mInited; + + private MemoryMonitor() { + mPressureDecrementer = new PressureDecrementer(); + mMemoryPressure = MEMORY_PRESSURE_NONE; + } + + public void init(final Context context) { + if (mInited) { + return; + } + + mAppContext = context.getApplicationContext(); + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW); + filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK); + filter.addAction(ACTION_MEMORY_DUMP); + filter.addAction(ACTION_FORCE_PRESSURE); + mAppContext.registerReceiver(this, filter); + mInited = true; + } + + public void onLowMemory() { + Log.d(LOGTAG, "onLowMemory() notification received"); + if (increaseMemoryPressure(MEMORY_PRESSURE_HIGH)) { + // We need to wait on Gecko here, because if we haven't reduced + // memory usage enough when we return from this, Android will kill us. + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + GeckoThread.waitOnGecko(); + } + } + } + + public void onTrimMemory(int level) { + Log.d(LOGTAG, "onTrimMemory() notification received with level " + level); + if (level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE) { + // We seem to get this just by entering the task switcher or hitting the home button. + // Seems bogus, because we are the foreground app, or at least not at the end of the LRU list. + // Just ignore it, and if there is a real memory pressure event (CRITICAL, MODERATE, etc), + // we'll respond appropriately. + return; + } + + switch (level) { + case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL: + case ComponentCallbacks2.TRIM_MEMORY_MODERATE: + // TRIM_MEMORY_MODERATE is the highest level we'll respond to while backgrounded + increaseMemoryPressure(MEMORY_PRESSURE_HIGH); + break; + case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE: + increaseMemoryPressure(MEMORY_PRESSURE_MEDIUM); + break; + case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW: + increaseMemoryPressure(MEMORY_PRESSURE_LOW); + break; + case ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN: + case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND: + increaseMemoryPressure(MEMORY_PRESSURE_CLEANUP); + break; + default: + Log.d(LOGTAG, "Unhandled onTrimMemory() level " + level); + break; + } + } + + @Override + public void onReceive(Context context, Intent intent) { + if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(intent.getAction())) { + Log.d(LOGTAG, "Device storage is low"); + mStoragePressure = true; + ThreadUtils.postToBackgroundThread(new StorageReducer(context)); + } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(intent.getAction())) { + Log.d(LOGTAG, "Device storage is ok"); + mStoragePressure = false; + } else if (ACTION_MEMORY_DUMP.equals(intent.getAction())) { + String label = intent.getStringExtra("label"); + if (label == null) { + label = "default"; + } + GeckoAppShell.notifyObservers("Memory:Dump", label); + } else if (ACTION_FORCE_PRESSURE.equals(intent.getAction())) { + increaseMemoryPressure(MEMORY_PRESSURE_HIGH); + } + } + + @WrapForJNI(calledFrom = "ui") + private static native void dispatchMemoryPressure(); + + private boolean increaseMemoryPressure(int level) { + int oldLevel; + synchronized (this) { + // bump up our level if we're not already higher + if (mMemoryPressure > level) { + return false; + } + oldLevel = mMemoryPressure; + mMemoryPressure = level; + } + + Log.d(LOGTAG, "increasing memory pressure to " + level); + + // since we don't get notifications for when memory pressure is off, + // we schedule our own timer to slowly back off the memory pressure level. + // note that this will reset the time to next decrement if the decrementer + // is already running, which is the desired behaviour because we just got + // a new low-mem notification. + mPressureDecrementer.start(); + + if (oldLevel == level) { + // if we're not going to a higher level we probably don't + // need to run another round of the same memory reductions + // we did on the last memory pressure increase. + return false; + } + + // TODO hook in memory-reduction stuff for different levels here + if (level >= MEMORY_PRESSURE_MEDIUM) { + //Only send medium or higher events because that's all that is used right now + if (GeckoThread.isRunning()) { + dispatchMemoryPressure(); + } + + MemoryStorage.get().evictAll(); + ImageLoader.clearLruCache(); + LocalBroadcastManager.getInstance(mAppContext) + .sendBroadcast(new Intent(BrowserProvider.ACTION_SHRINK_MEMORY)); + } + return true; + } + + /** + * Thread-safe due to mStoragePressure's volatility. + */ + boolean isUnderStoragePressure() { + return mStoragePressure; + } + + private boolean decreaseMemoryPressure() { + int newLevel; + synchronized (this) { + if (mMemoryPressure <= 0) { + return false; + } + + newLevel = --mMemoryPressure; + } + Log.d(LOGTAG, "Decreased memory pressure to " + newLevel); + + return true; + } + + class PressureDecrementer implements Runnable { + private static final int DECREMENT_DELAY = 5 * 60 * 1000; // 5 minutes + + private boolean mPosted; + + synchronized void start() { + if (mPosted) { + // cancel the old one before scheduling a new one + ThreadUtils.getBackgroundHandler().removeCallbacks(this); + } + ThreadUtils.getBackgroundHandler().postDelayed(this, DECREMENT_DELAY); + mPosted = true; + } + + @Override + public synchronized void run() { + if (!decreaseMemoryPressure()) { + // done decrementing, bail out + mPosted = false; + return; + } + + // need to keep decrementing + ThreadUtils.getBackgroundHandler().postDelayed(this, DECREMENT_DELAY); + } + } + + private static class StorageReducer implements Runnable { + private final Context mContext; + private final BrowserDB mDB; + + public StorageReducer(final Context context) { + this.mContext = context; + // Since this may be called while Fennec is in the background, we don't want to risk accidentally + // using the wrong context. If the profile we get is a guest profile, use the default profile instead. + GeckoProfile profile = GeckoProfile.get(mContext); + if (profile.inGuestMode()) { + // If it was the guest profile, switch to the default one. + profile = GeckoProfile.get(mContext, GeckoProfile.DEFAULT_PROFILE); + } + + mDB = BrowserDB.from(profile); + } + + @Override + public void run() { + // this might get run right on startup, if so wait 10 seconds and try again + if (!GeckoThread.isRunning()) { + ThreadUtils.getBackgroundHandler().postDelayed(this, 10000); + return; + } + + if (!MemoryMonitor.getInstance().isUnderStoragePressure()) { + // Pressure is off, so we can abort. + return; + } + + final ContentResolver cr = mContext.getContentResolver(); + mDB.expireHistory(cr, BrowserContract.ExpirePriority.AGGRESSIVE); + mDB.removeThumbnails(cr); + + // TODO: drop or shrink disk caches + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java b/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java new file mode 100644 index 000000000..814c09995 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java @@ -0,0 +1,13 @@ +/* -*- 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; + +import android.view.MotionEvent; +import android.view.View; + +public interface MotionEventInterceptor { + public boolean onInterceptMotionEvent(View view, MotionEvent event); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java b/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java new file mode 100644 index 000000000..37dd8c304 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java @@ -0,0 +1,38 @@ +/* -*- 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; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.mozilla.gecko.mozglue.GeckoLoader; + +/** + * This broadcast receiver receives ACTION_MY_PACKAGE_REPLACED broadcasts and + * starts procedures that should run after the APK has been updated. + */ +public class PackageReplacedReceiver extends BroadcastReceiver { + public static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || !ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) { + // This is not the broadcast we are looking for. + return; + } + + // Extract Gecko libs to allow them to be loaded from cache on startup. + extractGeckoLibs(context); + } + + private static void extractGeckoLibs(final Context context) { + final String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadMozGlue(context); + GeckoLoader.extractGeckoLibs(context, resourcePath); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java b/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java new file mode 100644 index 000000000..e44096489 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java @@ -0,0 +1,149 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import android.annotation.TargetApi; +import android.app.Presentation; +import android.content.Context; +import android.os.Bundle; +import android.support.v7.media.MediaRouter; +import android.util.Log; +import android.view.Display; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.ViewGroup; +import android.view.WindowManager; + +import org.mozilla.gecko.AppConstants.Versions; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * A MediaPlayerManager with API 17+ Presentation support. + */ +@TargetApi(17) +public class PresentationMediaPlayerManager extends MediaPlayerManager { + + private static final String LOGTAG = "Gecko" + PresentationMediaPlayerManager.class.getSimpleName(); + + private GeckoPresentation presentation; + + public PresentationMediaPlayerManager() { + if (!Versions.feature17Plus) { + throw new IllegalStateException(PresentationMediaPlayerManager.class.getSimpleName() + + " does not support < API 17"); + } + } + + @Override + public void onStop() { + super.onStop(); + if (presentation != null) { + presentation.dismiss(); + presentation = null; + } + } + + @Override + protected void updatePresentation() { + if (mediaRouter == null) { + return; + } + + if (isPresentationMode) { + return; + } + + MediaRouter.RouteInfo route = mediaRouter.getSelectedRoute(); + Display display = route != null ? route.getPresentationDisplay() : null; + + if (display != null) { + if ((presentation != null) && (presentation.getDisplay() != display)) { + presentation.dismiss(); + presentation = null; + } + + if (presentation == null) { + final GeckoView geckoView = (GeckoView) getActivity().findViewById(R.id.layer_view); + presentation = new GeckoPresentation(getActivity(), display, geckoView); + + try { + presentation.show(); + } catch (WindowManager.InvalidDisplayException ex) { + Log.w(LOGTAG, "Couldn't show presentation! Display was removed in " + + "the meantime.", ex); + presentation = null; + } + } + } else if (presentation != null) { + presentation.dismiss(); + presentation = null; + } + } + + @WrapForJNI(calledFrom = "ui") + /* protected */ static native void invalidateAndScheduleComposite(GeckoView geckoView); + + @WrapForJNI(calledFrom = "ui") + /* protected */ static native void addPresentationSurface(GeckoView geckoView, Surface surface); + + @WrapForJNI(calledFrom = "ui") + /* protected */ static native void removePresentationSurface(); + + private static final class GeckoPresentation extends Presentation { + private SurfaceView mView; + private GeckoView mGeckoView; + + public GeckoPresentation(Context context, Display display, GeckoView geckoView) { + super(context, display); + + mGeckoView = geckoView; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mView = new SurfaceView(getContext()); + setContentView(mView, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + mView.getHolder().addCallback(new SurfaceListener(mGeckoView)); + } + } + + private static final class SurfaceListener implements SurfaceHolder.Callback { + private GeckoView mGeckoView; + + public SurfaceListener(GeckoView geckoView) { + mGeckoView = geckoView; + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, + int height) { + // Surface changed so force a composite + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + invalidateAndScheduleComposite(mGeckoView); + } + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + addPresentationSurface(mGeckoView, holder.getSurface()); + } + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + removePresentationSurface(); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/PresentationView.java b/mobile/android/base/java/org/mozilla/gecko/PresentationView.java new file mode 100644 index 000000000..3e5b5ffb3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/PresentationView.java @@ -0,0 +1,27 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.GeckoView; +import org.mozilla.gecko.ScreenManagerHelper; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.DisplayMetrics; + +public class PresentationView extends GeckoView { + private static final String LOGTAG = "PresentationView"; + private static final String presentationViewURI = "chrome://browser/content/PresentationView.xul"; + + public PresentationView(Context context, String deviceId, int screenId) { + super(context); + this.chromeURI = presentationViewURI + "#" + deviceId; + this.screenId = screenId; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java b/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java new file mode 100644 index 000000000..077b2d29b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java @@ -0,0 +1,124 @@ +/* -*- 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; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.IOUtils; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; + +import android.content.Context; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.print.PrintDocumentAdapter.LayoutResultCallback; +import android.print.PrintDocumentAdapter.WriteResultCallback; +import android.print.PrintDocumentInfo; +import android.print.PrintManager; +import android.print.PageRange; +import android.util.Log; + +public class PrintHelper { + private static final String LOGTAG = "GeckoPrintUtils"; + + public static void printPDF(final Context context) { + GeckoAppShell.sendRequestToGecko(new GeckoRequest("Print:PDF", new JSONObject()) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + final String filePath = nativeJSObject.getString("file"); + final String title = nativeJSObject.getString("title"); + finish(context, filePath, title); + } + + @Override + public void onError(NativeJSObject error) { + // Gecko didn't respond due to state change, javascript error, etc. + Log.d(LOGTAG, "No response from Gecko on request to generate a PDF"); + } + + private void finish(final Context context, final String filePath, final String title) { + PrintManager printManager = (PrintManager) context.getSystemService(Context.PRINT_SERVICE); + String jobName = title; + + // The adapter methods are all called on the UI thread by the PrintManager. Put the heavyweight code + // in onWrite on the background thread. + PrintDocumentAdapter pda = new PrintDocumentAdapter() { + @Override + public void onWrite(final PageRange[] pages, final ParcelFileDescriptor destination, final CancellationSignal cancellationSignal, final WriteResultCallback callback) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + InputStream input = null; + OutputStream output = null; + + try { + File pdfFile = new File(filePath); + input = new FileInputStream(pdfFile); + output = new FileOutputStream(destination.getFileDescriptor()); + + byte[] buf = new byte[8192]; + int bytesRead; + while ((bytesRead = input.read(buf)) > 0) { + output.write(buf, 0, bytesRead); + } + + callback.onWriteFinished(new PageRange[] { PageRange.ALL_PAGES }); + } catch (FileNotFoundException ee) { + Log.d(LOGTAG, "Unable to find the temporary PDF file."); + } catch (IOException ioe) { + Log.e(LOGTAG, "IOException while transferring temporary PDF file: ", ioe); + } finally { + IOUtils.safeStreamClose(input); + IOUtils.safeStreamClose(output); + } + } + }); + } + + @Override + public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes, CancellationSignal cancellationSignal, LayoutResultCallback callback, Bundle extras) { + if (cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + return; + } + + PrintDocumentInfo pdi = new PrintDocumentInfo.Builder(filePath).setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT).build(); + callback.onLayoutFinished(pdi, true); + } + + @Override + public void onFinish() { + // Remove the temporary file when the printing system is finished. + try { + File pdfFile = new File(filePath); + pdfFile.delete(); + } catch (NullPointerException npe) { + // Silence the exception. We only want to delete a real file. We don't + // care if the file doesn't exist. + } + } + }; + + printManager.print(jobName, pda, null); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java b/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java new file mode 100644 index 000000000..39b6899d3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java @@ -0,0 +1,28 @@ +/* -*- 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; + +import android.content.Context; + +import org.json.JSONObject; +import org.mozilla.gecko.db.BrowserDB; + +public class PrivateTab extends Tab { + public PrivateTab(Context context, int id, String url, boolean external, int parentId, String title) { + super(context, id, url, external, parentId, title); + } + + @Override + protected void saveThumbnailToDB(final BrowserDB db) {} + + @Override + public void setMetadata(JSONObject metadata) {} + + @Override + public boolean isPrivate() { + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java b/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java new file mode 100644 index 000000000..b4aee9370 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java @@ -0,0 +1,133 @@ +/* -*- 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; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.db.RemoteClient; + +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; +import android.util.SparseBooleanArray; + +/** + * A dialog fragment that displays a list of remote clients. + * <p> + * The dialog allows both single (one tap) and multiple (checkbox) selection. + * The dialog's results are communicated via the {@link RemoteClientsListener} + * interface. Either the dialog fragment's <i>target fragment</i> (see + * {@link Fragment#setTargetFragment(Fragment, int)}), or the containing + * <i>activity</i>, must implement that interface. See + * {@link #notifyListener(List)} for details. + */ +public class RemoteClientsDialogFragment extends DialogFragment { + private static final String KEY_TITLE = "title"; + private static final String KEY_CHOICE_MODE = "choice_mode"; + private static final String KEY_POSITIVE_BUTTON_TEXT = "positive_button_text"; + private static final String KEY_CLIENTS = "clients"; + + public interface RemoteClientsListener { + // Always called on the main UI thread. + public void onClients(List<RemoteClient> clients); + } + + public enum ChoiceMode { + SINGLE, + MULTIPLE, + } + + public static RemoteClientsDialogFragment newInstance(String title, String positiveButtonText, ChoiceMode choiceMode, ArrayList<RemoteClient> clients) { + final RemoteClientsDialogFragment dialog = new RemoteClientsDialogFragment(); + final Bundle args = new Bundle(); + args.putString(KEY_TITLE, title); + args.putString(KEY_POSITIVE_BUTTON_TEXT, positiveButtonText); + args.putInt(KEY_CHOICE_MODE, choiceMode.ordinal()); + args.putParcelableArrayList(KEY_CLIENTS, clients); + dialog.setArguments(args); + return dialog; + } + + public RemoteClientsDialogFragment() { + // Empty constructor is required for DialogFragment. + } + + @Override + public void onDestroy() { + super.onDestroy(); + + GeckoApplication.watchReference(getActivity(), this); + } + + protected void notifyListener(List<RemoteClient> clients) { + RemoteClientsListener listener; + try { + listener = (RemoteClientsListener) getTargetFragment(); + } catch (ClassCastException e) { + try { + listener = (RemoteClientsListener) getActivity(); + } catch (ClassCastException f) { + throw new ClassCastException(getTargetFragment() + " or " + getActivity() + + " must implement RemoteClientsListener"); + } + } + listener.onClients(clients); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final String title = getArguments().getString(KEY_TITLE); + final String positiveButtonText = getArguments().getString(KEY_POSITIVE_BUTTON_TEXT); + final ChoiceMode choiceMode = ChoiceMode.values()[getArguments().getInt(KEY_CHOICE_MODE)]; + final ArrayList<RemoteClient> clients = getArguments().getParcelableArrayList(KEY_CLIENTS); + + final Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(title); + + final String[] clientNames = new String[clients.size()]; + for (int i = 0; i < clients.size(); i++) { + clientNames[i] = clients.get(i).name; + } + + if (choiceMode == ChoiceMode.MULTIPLE) { + builder.setMultiChoiceItems(clientNames, null, null); + builder.setPositiveButton(positiveButtonText, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int which) { + if (which != Dialog.BUTTON_POSITIVE) { + return; + } + + final AlertDialog dialog = (AlertDialog) dialogInterface; + final SparseBooleanArray checkedItemPositions = dialog.getListView().getCheckedItemPositions(); + final ArrayList<RemoteClient> checked = new ArrayList<RemoteClient>(); + for (int i = 0; i < clients.size(); i++) { + if (checkedItemPositions.get(i)) { + checked.add(clients.get(i)); + } + } + notifyListener(checked); + } + }); + } else { + builder.setItems(clientNames, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int index) { + final ArrayList<RemoteClient> checked = new ArrayList<RemoteClient>(); + checked.add(clients.get(index)); + notifyListener(checked); + } + }); + } + + return builder.create(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java b/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java new file mode 100644 index 000000000..b5a5527c9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java @@ -0,0 +1,150 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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; + +import org.json.JSONObject; +import org.json.JSONException; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.PresentationView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.ScreenManagerHelper; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; + +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.CastPresentation; +import com.google.android.gms.cast.CastRemoteDisplayLocalService; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.media.MediaControlIntent; +import android.support.v7.media.MediaRouteSelector; +import android.support.v7.media.MediaRouter.RouteInfo; +import android.support.v7.media.MediaRouter; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Display; +import android.view.ViewGroup.LayoutParams; +import android.view.WindowManager; +import android.widget.RelativeLayout; + +import java.util.HashMap; +import java.util.Map; + +/* + * Service to keep the remote display running even when the app goes into the background + */ +public class RemotePresentationService extends CastRemoteDisplayLocalService { + + private static final String LOGTAG = "RemotePresentationService"; + private CastPresentation presentation; + private String deviceId; + private int screenId; + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceId() { + return deviceId; + } + + @Override + public void onCreatePresentation(Display display) { + createPresentation(); + } + + @Override + public void onDismissPresentation() { + dismissPresentation(); + } + + private void dismissPresentation() { + if (presentation != null) { + presentation.dismiss(); + presentation = null; + ScreenManagerHelper.removeDisplay(screenId); + MediaPlayerManager.getInstance().setPresentationMode(false); + } + } + + private void createPresentation() { + dismissPresentation(); + + MediaPlayerManager.getInstance().setPresentationMode(true); + + DisplayMetrics metrics = new DisplayMetrics(); + getDisplay().getMetrics(metrics); + screenId = ScreenManagerHelper.addDisplay(ScreenManagerHelper.DISPLAY_VIRTUAL, + metrics.widthPixels, + metrics.heightPixels, + metrics.density); + + VirtualPresentation virtualPresentation = new VirtualPresentation(this, getDisplay()); + virtualPresentation.setDeviceId(deviceId); + virtualPresentation.setScreenId(screenId); + presentation = (CastPresentation) virtualPresentation; + + try { + presentation.show(); + } catch (WindowManager.InvalidDisplayException ex) { + Log.e(LOGTAG, "Unable to show presentation, display was removed.", ex); + dismissPresentation(); + } + } +} + +class VirtualPresentation extends CastPresentation { + private final String LOGTAG = "VirtualPresentation"; + private RelativeLayout layout; + private PresentationView view; + private String deviceId; + private int screenId; + + public VirtualPresentation(Context context, Display display) { + super(context, display); + } + + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + public void setScreenId(int screenId) { this.screenId = screenId; } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + /* + * NOTICE: The context get from getContext() is different to the context + * of the application. Presentaion has its own context to get correct + * resources. + */ + + // Create new PresentationView + view = new PresentationView(getContext(), deviceId, screenId); + view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + + // Create new layout to put the GeckoView + layout = new RelativeLayout(getContext()); + layout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + layout.addView(view); + + setContentView(layout); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Restarter.java b/mobile/android/base/java/org/mozilla/gecko/Restarter.java new file mode 100644 index 000000000..b049f7627 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Restarter.java @@ -0,0 +1,50 @@ +/* -*- 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; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.os.Process; +import android.util.Log; + +public class Restarter extends Service { + private static final String LOGTAG = "GeckoRestarter"; + + private void doRestart(Intent intent) { + final int oldProc = intent.getIntExtra("pid", -1); + if (oldProc < 0) { + return; + } + + Process.killProcess(oldProc); + Log.d(LOGTAG, "Killed " + oldProc); + try { + Thread.sleep(100); + } catch (final InterruptedException e) { + } + + final Intent restartIntent = (Intent)intent.getParcelableExtra(Intent.EXTRA_INTENT); + restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra("didRestart", true) + .setClassName(getApplicationContext(), + AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + startActivity(restartIntent); + Log.d(LOGTAG, "Launched " + restartIntent); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + doRestart(intent); + stopSelf(startId); + return Service.START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java new file mode 100644 index 000000000..5cb404ce8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java @@ -0,0 +1,43 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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; + +import org.mozilla.gecko.annotation.WrapForJNI; + +class ScreenManagerHelper { + + /** + * The following display types use the same definition in nsIScreen.idl + */ + final static int DISPLAY_PRIMARY = 0; // primary screen + final static int DISPLAY_EXTERNAL = 1; // wired displays, such as HDMI, DisplayPort, etc. + final static int DISPLAY_VIRTUAL = 2; // wireless displays, such as Chromecast, WiFi-Display, etc. + + /** + * Add a new nsScreen when a new display in Android is available. + * + * @param displayType the display type of the nsScreen would be added + * @param width the width of the new nsScreen + * @param height the height of the new nsScreen + * @param density the density of the new nsScreen + * + * @return return the ID of the added nsScreen + */ + @WrapForJNI + public native static int addDisplay(int displayType, + int width, + int height, + float density); + + /** + * Remove the nsScreen by the specific screen ID. + * + * @param screenId the ID of the screen would be removed. + */ + @WrapForJNI + public native static void removeDisplay(int screenId); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java b/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java new file mode 100644 index 000000000..64f101e51 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java @@ -0,0 +1,146 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.util.ThreadUtils; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.Log; + +public class ScreenshotObserver { + private static final String LOGTAG = "GeckoScreenshotObserver"; + public Context context; + + /** + * Listener for screenshot changes. + */ + public interface OnScreenshotListener { + /** + * This callback is executed on the UI thread. + */ + public void onScreenshotTaken(String data, String title); + } + + private OnScreenshotListener listener; + + public ScreenshotObserver() { + } + + public void setListener(Context context, OnScreenshotListener listener) { + this.context = context; + this.listener = listener; + } + + private MediaObserver mediaObserver; + private String[] mediaProjections = new String[] { + MediaStore.Images.ImageColumns.DATA, + MediaStore.Images.ImageColumns.DISPLAY_NAME, + MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME, + MediaStore.Images.ImageColumns.DATE_TAKEN, + MediaStore.Images.ImageColumns.TITLE + }; + + /** + * Start ScreenshotObserver if this device is supported and all required runtime permissions + * have been granted by the user. Calling this method will not prompt for permissions. + */ + public void start() { + Permissions.from(context) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .doNotPrompt() + .run(startObserverRunnable()); + } + + private Runnable startObserverRunnable() { + return new Runnable() { + @Override + public void run() { + try { + if (mediaObserver == null) { + mediaObserver = new MediaObserver(); + context.getContentResolver().registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, false, mediaObserver); + } + } catch (Exception e) { + Log.e(LOGTAG, "Failure to start watching media: ", e); + } + } + }; + } + + public void stop() { + if (mediaObserver == null) { + return; + } + + try { + context.getContentResolver().unregisterContentObserver(mediaObserver); + mediaObserver = null; + } catch (Exception e) { + Log.e(LOGTAG, "Failure to stop watching media: ", e); + } + } + + public void onMediaChange(final Uri uri) { + // Make sure we are on not on the main thread. + final ContentResolver cr = context.getContentResolver(); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // Find the most recent image added to the MediaStore and see if it's a screenshot. + final Cursor cursor = cr.query(uri, mediaProjections, null, null, MediaStore.Images.ImageColumns.DATE_ADDED + " DESC LIMIT 1"); + try { + if (cursor == null) { + return; + } + + while (cursor.moveToNext()) { + String data = cursor.getString(0); + Log.i(LOGTAG, "data: " + data); + String display = cursor.getString(1); + Log.i(LOGTAG, "display: " + display); + String album = cursor.getString(2); + Log.i(LOGTAG, "album: " + album); + long date = cursor.getLong(3); + String title = cursor.getString(4); + Log.i(LOGTAG, "title: " + title); + if (album != null && album.toLowerCase().contains("screenshot")) { + if (listener != null) { + listener.onScreenshotTaken(data, title); + break; + } + } + } + } catch (Exception e) { + Log.e(LOGTAG, "Failure to process media change: ", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + }); + } + + private class MediaObserver extends ContentObserver { + public MediaObserver() { + super(null); + } + + @Override + public void onChange(boolean selfChange) { + super.onChange(selfChange); + onMediaChange(MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/SessionParser.java b/mobile/android/base/java/org/mozilla/gecko/SessionParser.java new file mode 100644 index 000000000..d29aaadc7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/SessionParser.java @@ -0,0 +1,140 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * ***** BEGIN LICENSE BLOCK ***** + * + * 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/. + * + * ***** END LICENSE BLOCK ***** */ + +package org.mozilla.gecko; + +import java.util.LinkedList; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.util.Log; + +public abstract class SessionParser { + private static final String LOGTAG = "GeckoSessionParser"; + + public class SessionTab { + final private String mTitle; + final private String mUrl; + final private JSONObject mTabObject; + private boolean mIsSelected; + + private SessionTab(String title, String url, boolean isSelected, JSONObject tabObject) { + mTitle = title; + mUrl = url; + mIsSelected = isSelected; + mTabObject = tabObject; + } + + public String getTitle() { + return mTitle; + } + + public String getUrl() { + return mUrl; + } + + public boolean isSelected() { + return mIsSelected; + } + + public JSONObject getTabObject() { + return mTabObject; + } + + /** + * Is this tab pointing to about:home and does not contain any other history? + */ + public boolean isAboutHomeWithoutHistory() { + JSONArray entries = mTabObject.optJSONArray("entries"); + return entries != null && entries.length() == 1 && AboutPages.isAboutHome(mUrl); + } + }; + + abstract public void onTabRead(SessionTab tab); + + /** + * Placeholder method that must be overloaded to handle closedTabs while parsing session data. + * + * @param closedTabs, JSONArray of recently closed tab entries. + * @throws JSONException + */ + public void onClosedTabsRead(final JSONArray closedTabs) throws JSONException { + } + + /** + * Parses the provided session store data and calls onTabRead for each tab that has been found. + * + * @param sessionStrings One or more strings containing session store data. + * @return False if any of the session strings provided didn't contain valid session store data. + */ + public boolean parse(String... sessionStrings) { + final LinkedList<SessionTab> sessionTabs = new LinkedList<SessionTab>(); + int totalCount = 0; + int selectedIndex = -1; + try { + for (String sessionString : sessionStrings) { + final JSONArray windowsArray = new JSONObject(sessionString).getJSONArray("windows"); + if (windowsArray.length() == 0) { + // Session json can be empty if the user has opted out of session restore. + Log.d(LOGTAG, "Session restore file is empty, no session entries found."); + continue; + } + + final JSONObject window = windowsArray.getJSONObject(0); + final JSONArray tabs = window.getJSONArray("tabs"); + final int optSelected = window.optInt("selected", -1); + final JSONArray closedTabs = window.optJSONArray("closedTabs"); + if (closedTabs != null) { + onClosedTabsRead(closedTabs); + } + + for (int i = 0; i < tabs.length(); i++) { + final JSONObject tab = tabs.getJSONObject(i); + final int index = tab.getInt("index"); + final JSONArray entries = tab.getJSONArray("entries"); + if (index < 1 || entries.length() < index) { + Log.w(LOGTAG, "Session entries and index don't agree."); + continue; + } + final JSONObject entry = entries.getJSONObject(index - 1); + final String url = entry.getString("url"); + + String title = entry.optString("title"); + if (title.length() == 0) { + title = url; + } + + totalCount++; + boolean selected = false; + if (optSelected == i + 1) { + selected = true; + selectedIndex = totalCount; + } + sessionTabs.add(new SessionTab(title, url, selected, tab)); + } + } + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + return false; + } + + // If no selected index was found, select the first tab. + if (selectedIndex == -1 && sessionTabs.size() > 0) { + sessionTabs.getFirst().mIsSelected = true; + } + + for (SessionTab tab : sessionTabs) { + onTabRead(tab); + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java b/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java new file mode 100644 index 000000000..1066da079 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java @@ -0,0 +1,311 @@ +/* -*- 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; + +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoEventListener; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.util.Log; + +import java.util.Map; +import java.util.HashMap; + +/** + * Helper class to get, set, and observe Android Shared Preferences. + */ +public final class SharedPreferencesHelper + implements GeckoEventListener +{ + public static final String LOGTAG = "GeckoAndSharedPrefs"; + + // Calculate this once, at initialization. isLoggable is too expensive to + // have in-line in each log call. + private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); + + private enum Scope { + APP("app"), + PROFILE("profile"), + GLOBAL("global"); + + public final String key; + + private Scope(String key) { + this.key = key; + } + + public static Scope forKey(String key) { + for (Scope scope : values()) { + if (scope.key.equals(key)) { + return scope; + } + } + + throw new IllegalStateException("SharedPreferences scope must be valid."); + } + } + + protected final Context mContext; + + // mListeners is not synchronized because it is only updated in + // handleObserve, which is called from Gecko serially. + protected final Map<String, SharedPreferences.OnSharedPreferenceChangeListener> mListeners; + + public SharedPreferencesHelper(Context context) { + mContext = context; + + mListeners = new HashMap<String, SharedPreferences.OnSharedPreferenceChangeListener>(); + + EventDispatcher dispatcher = GeckoApp.getEventDispatcher(); + if (dispatcher == null) { + Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException()); + return; + } + dispatcher.registerGeckoThreadListener(this, + "SharedPreferences:Set", + "SharedPreferences:Get", + "SharedPreferences:Observe"); + } + + public synchronized void uninit() { + EventDispatcher dispatcher = GeckoApp.getEventDispatcher(); + if (dispatcher == null) { + Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException()); + return; + } + dispatcher.unregisterGeckoThreadListener(this, + "SharedPreferences:Set", + "SharedPreferences:Get", + "SharedPreferences:Observe"); + } + + private SharedPreferences getSharedPreferences(JSONObject message) throws JSONException { + final Scope scope = Scope.forKey(message.getString("scope")); + switch (scope) { + case APP: + return GeckoSharedPrefs.forApp(mContext); + case PROFILE: + final String profileName = message.optString("profileName", null); + if (profileName == null) { + return GeckoSharedPrefs.forProfile(mContext); + } else { + return GeckoSharedPrefs.forProfileName(mContext, profileName); + } + case GLOBAL: + final String branch = message.optString("branch", null); + if (branch == null) { + return PreferenceManager.getDefaultSharedPreferences(mContext); + } else { + return mContext.getSharedPreferences(branch, Context.MODE_PRIVATE); + } + } + + return null; + } + + private String getBranch(Scope scope, String profileName, String branch) { + switch (scope) { + case APP: + return GeckoSharedPrefs.APP_PREFS_NAME; + case PROFILE: + if (profileName == null) { + profileName = GeckoProfile.get(mContext).getName(); + } + + return GeckoSharedPrefs.PROFILE_PREFS_NAME_PREFIX + profileName; + case GLOBAL: + return branch; + } + + return null; + } + + /** + * Set many SharedPreferences in Android. + * + * message.branch must exist, and should be a String SharedPreferences + * branch name, or null for the default branch. + * message.preferences should be an array of preferences. Each preference + * must include a String name, a String type in ["bool", "int", "string"], + * and an Object value. + */ + private void handleSet(JSONObject message) throws JSONException { + SharedPreferences.Editor editor = getSharedPreferences(message).edit(); + + JSONArray jsonPrefs = message.getJSONArray("preferences"); + + for (int i = 0; i < jsonPrefs.length(); i++) { + JSONObject pref = jsonPrefs.getJSONObject(i); + String name = pref.getString("name"); + String type = pref.getString("type"); + if ("bool".equals(type)) { + editor.putBoolean(name, pref.getBoolean("value")); + } else if ("int".equals(type)) { + editor.putInt(name, pref.getInt("value")); + } else if ("string".equals(type)) { + editor.putString(name, pref.getString("value")); + } else { + Log.w(LOGTAG, "Unknown pref value type [" + type + "] for pref [" + name + "]"); + } + editor.apply(); + } + } + + /** + * Get many SharedPreferences from Android. + * + * message.branch must exist, and should be a String SharedPreferences + * branch name, or null for the default branch. + * message.preferences should be an array of preferences. Each preference + * must include a String name, and a String type in ["bool", "int", + * "string"]. + */ + private JSONArray handleGet(JSONObject message) throws JSONException { + SharedPreferences prefs = getSharedPreferences(message); + JSONArray jsonPrefs = message.getJSONArray("preferences"); + JSONArray jsonValues = new JSONArray(); + + for (int i = 0; i < jsonPrefs.length(); i++) { + JSONObject pref = jsonPrefs.getJSONObject(i); + String name = pref.getString("name"); + String type = pref.getString("type"); + JSONObject jsonValue = new JSONObject(); + jsonValue.put("name", name); + jsonValue.put("type", type); + try { + if ("bool".equals(type)) { + boolean value = prefs.getBoolean(name, false); + jsonValue.put("value", value); + } else if ("int".equals(type)) { + int value = prefs.getInt(name, 0); + jsonValue.put("value", value); + } else if ("string".equals(type)) { + String value = prefs.getString(name, ""); + jsonValue.put("value", value); + } else { + Log.w(LOGTAG, "Unknown pref value type [" + type + "] for pref [" + name + "]"); + } + } catch (ClassCastException e) { + // Thrown if there is a preference with the given name that is + // not the right type. + Log.w(LOGTAG, "Wrong pref value type [" + type + "] for pref [" + name + "]"); + } + jsonValues.put(jsonValue); + } + + return jsonValues; + } + + private static class ChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { + public final Scope scope; + public final String branch; + public final String profileName; + + public ChangeListener(final Scope scope, final String branch, final String profileName) { + this.scope = scope; + this.branch = branch; + this.profileName = profileName; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (logVerbose) { + Log.v(LOGTAG, "Got onSharedPreferenceChanged"); + } + try { + final JSONObject msg = new JSONObject(); + msg.put("scope", this.scope.key); + msg.put("branch", this.branch); + msg.put("profileName", this.profileName); + msg.put("key", key); + + // Truly, this is awful, but the API impedance is strong: there + // is no way to get a single untyped value from a + // SharedPreferences instance. + msg.put("value", sharedPreferences.getAll().get(key)); + + GeckoAppShell.notifyObservers("SharedPreferences:Changed", msg.toString()); + } catch (JSONException e) { + Log.e(LOGTAG, "Got exception creating JSON object", e); + return; + } + } + } + + /** + * Register or unregister a SharedPreferences.OnSharedPreferenceChangeListener. + * + * message.branch must exist, and should be a String SharedPreferences + * branch name, or null for the default branch. + * message.enable should be a boolean: true to enable listening, false to + * disable listening. + */ + private void handleObserve(JSONObject message) throws JSONException { + final SharedPreferences prefs = getSharedPreferences(message); + final boolean enable = message.getBoolean("enable"); + + final Scope scope = Scope.forKey(message.getString("scope")); + final String profileName = message.optString("profileName", null); + final String branch = getBranch(scope, profileName, message.optString("branch", null)); + + if (branch == null) { + Log.e(LOGTAG, "No branch specified for SharedPreference:Observe; aborting."); + return; + } + + // mListeners is only modified in this one observer, which is called + // from Gecko serially. + if (enable && !this.mListeners.containsKey(branch)) { + SharedPreferences.OnSharedPreferenceChangeListener listener + = new ChangeListener(scope, branch, profileName); + this.mListeners.put(branch, listener); + prefs.registerOnSharedPreferenceChangeListener(listener); + } + if (!enable && this.mListeners.containsKey(branch)) { + SharedPreferences.OnSharedPreferenceChangeListener listener + = this.mListeners.remove(branch); + prefs.unregisterOnSharedPreferenceChangeListener(listener); + } + } + + @Override + public void handleMessage(String event, JSONObject message) { + // Everything here is synchronous and serial, so we need not worry about + // overwriting an in-progress response. + try { + if (event.equals("SharedPreferences:Set")) { + if (logVerbose) { + Log.v(LOGTAG, "Got SharedPreferences:Set message."); + } + handleSet(message); + } else if (event.equals("SharedPreferences:Get")) { + if (logVerbose) { + Log.v(LOGTAG, "Got SharedPreferences:Get message."); + } + JSONObject obj = new JSONObject(); + obj.put("values", handleGet(message)); + EventDispatcher.sendResponse(message, obj); + } else if (event.equals("SharedPreferences:Observe")) { + if (logVerbose) { + Log.v(LOGTAG, "Got SharedPreferences:Observe message."); + } + handleObserve(message); + } else { + Log.e(LOGTAG, "SharedPreferencesHelper got unexpected message " + event); + return; + } + } catch (JSONException e) { + Log.e(LOGTAG, "Got exception in handleMessage handling event " + event, e); + return; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java b/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java new file mode 100644 index 000000000..e39d25dd8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java @@ -0,0 +1,249 @@ +/* -*- 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; + +import org.json.JSONObject; + +import android.text.TextUtils; + +public class SiteIdentity { + private final String LOGTAG = "GeckoSiteIdentity"; + private SecurityMode mSecurityMode; + private boolean mSecure; + private MixedMode mMixedModeActive; + private MixedMode mMixedModeDisplay; + private TrackingMode mTrackingMode; + private String mHost; + private String mOwner; + private String mSupplemental; + private String mCountry; + private String mVerifier; + private String mOrigin; + + // The order of the items here relate to image levels in + // site_security_level.xml + public enum SecurityMode { + UNKNOWN("unknown"), + IDENTIFIED("identified"), + VERIFIED("verified"), + CHROMEUI("chromeUI"); + + private final String mId; + + private SecurityMode(String id) { + mId = id; + } + + public static SecurityMode fromString(String id) { + if (id == null) { + throw new IllegalArgumentException("Can't convert null String to SiteIdentity"); + } + + for (SecurityMode mode : SecurityMode.values()) { + if (TextUtils.equals(mode.mId, id)) { + return mode; + } + } + + throw new IllegalArgumentException("Could not convert String id to SiteIdentity"); + } + + @Override + public String toString() { + return mId; + } + } + + // The order of the items here relate to image levels in + // site_security_level.xml + public enum MixedMode { + UNKNOWN("unknown"), + MIXED_CONTENT_BLOCKED("blocked"), + MIXED_CONTENT_LOADED("loaded"); + + private final String mId; + + private MixedMode(String id) { + mId = id; + } + + public static MixedMode fromString(String id) { + if (id == null) { + throw new IllegalArgumentException("Can't convert null String to MixedMode"); + } + + for (MixedMode mode : MixedMode.values()) { + if (TextUtils.equals(mode.mId, id.toLowerCase())) { + return mode; + } + } + + throw new IllegalArgumentException("Could not convert String id to MixedMode"); + } + + @Override + public String toString() { + return mId; + } + } + + // The order of the items here relate to image levels in + // site_security_level.xml + public enum TrackingMode { + UNKNOWN("unknown"), + TRACKING_CONTENT_BLOCKED("tracking_content_blocked"), + TRACKING_CONTENT_LOADED("tracking_content_loaded"); + + private final String mId; + + private TrackingMode(String id) { + mId = id; + } + + public static TrackingMode fromString(String id) { + if (id == null) { + throw new IllegalArgumentException("Can't convert null String to TrackingMode"); + } + + for (TrackingMode mode : TrackingMode.values()) { + if (TextUtils.equals(mode.mId, id.toLowerCase())) { + return mode; + } + } + + throw new IllegalArgumentException("Could not convert String id to TrackingMode"); + } + + @Override + public String toString() { + return mId; + } + } + + public SiteIdentity() { + reset(); + } + + public void resetIdentity() { + mSecurityMode = SecurityMode.UNKNOWN; + mOrigin = null; + mHost = null; + mOwner = null; + mSupplemental = null; + mCountry = null; + mVerifier = null; + mSecure = false; + } + + public void reset() { + resetIdentity(); + mMixedModeActive = MixedMode.UNKNOWN; + mMixedModeDisplay = MixedMode.UNKNOWN; + mTrackingMode = TrackingMode.UNKNOWN; + } + + void update(JSONObject identityData) { + if (identityData == null) { + reset(); + return; + } + + try { + JSONObject mode = identityData.getJSONObject("mode"); + + try { + mMixedModeDisplay = MixedMode.fromString(mode.getString("mixed_display")); + } catch (Exception e) { + mMixedModeDisplay = MixedMode.UNKNOWN; + } + + try { + mMixedModeActive = MixedMode.fromString(mode.getString("mixed_active")); + } catch (Exception e) { + mMixedModeActive = MixedMode.UNKNOWN; + } + + try { + mTrackingMode = TrackingMode.fromString(mode.getString("tracking")); + } catch (Exception e) { + mTrackingMode = TrackingMode.UNKNOWN; + } + + try { + mSecurityMode = SecurityMode.fromString(mode.getString("identity")); + } catch (Exception e) { + resetIdentity(); + return; + } + + try { + mOrigin = identityData.getString("origin"); + mHost = identityData.optString("host", null); + mOwner = identityData.optString("owner", null); + mSupplemental = identityData.optString("supplemental", null); + mCountry = identityData.optString("country", null); + mVerifier = identityData.optString("verifier", null); + mSecure = identityData.optBoolean("secure", false); + } catch (Exception e) { + resetIdentity(); + } + } catch (Exception e) { + reset(); + } + } + + public SecurityMode getSecurityMode() { + return mSecurityMode; + } + + public String getOrigin() { + return mOrigin; + } + + public String getHost() { + return mHost; + } + + public String getOwner() { + return mOwner; + } + + public boolean hasOwner() { + return !TextUtils.isEmpty(mOwner); + } + + public String getSupplemental() { + return mSupplemental; + } + + public String getCountry() { + return mCountry; + } + + public boolean hasCountry() { + return !TextUtils.isEmpty(mCountry); + } + + public String getVerifier() { + return mVerifier; + } + + public boolean isSecure() { + return mSecure; + } + + public MixedMode getMixedModeActive() { + return mMixedModeActive; + } + + public MixedMode getMixedModeDisplay() { + return mMixedModeDisplay; + } + + public TrackingMode getTrackingMode() { + return mTrackingMode; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java b/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java new file mode 100644 index 000000000..3283e7c37 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java @@ -0,0 +1,257 @@ +/* -*- 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; + +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeJSObject; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.support.annotation.StringRes; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; +import android.util.Log; +import android.util.TypedValue; +import android.view.View; +import android.widget.TextView; + +import java.lang.ref.WeakReference; + +/** + * Helper class for creating and dismissing snackbars. Use this class to guarantee a consistent style and behavior + * across the app. + */ +public class SnackbarBuilder { + /** + * Combined interface for handling all callbacks from a snackbar because anonymous classes can only extend one + * interface or class. + */ + public static abstract class SnackbarCallback extends Snackbar.Callback implements View.OnClickListener {} + public static final String LOGTAG = "GeckoSnackbarBuilder"; + + /** + * SnackbarCallback implementation for delegating snackbar events to an EventCallback. + */ + private static class SnackbarEventCallback extends SnackbarCallback { + private EventCallback callback; + + public SnackbarEventCallback(EventCallback callback) { + this.callback = callback; + } + + @Override + public synchronized void onClick(View view) { + if (callback == null) { + return; + } + + callback.sendSuccess(null); + callback = null; // Releasing reference. We only want to execute the callback once. + } + + @Override + public synchronized void onDismissed(Snackbar snackbar, int event) { + if (callback == null || event == Snackbar.Callback.DISMISS_EVENT_ACTION) { + return; + } + + callback.sendError(null); + callback = null; // Releasing reference. We only want to execute the callback once. + } + } + + private static final Object currentSnackbarLock = new Object(); + private static WeakReference<Snackbar> currentSnackbar = new WeakReference<>(null); // Guarded by 'currentSnackbarLock' + + private final Activity activity; + private String message; + private int duration; + private String action; + private SnackbarCallback callback; + private Drawable icon; + private Integer backgroundColor; + private Integer actionColor; + + /** + * @param activity Activity to show the snackbar in. + */ + private SnackbarBuilder(final Activity activity) { + this.activity = activity; + } + + public static SnackbarBuilder builder(final Activity activity) { + return new SnackbarBuilder(activity); + } + + /** + * @param message The text to show. Can be formatted text. + */ + public SnackbarBuilder message(final String message) { + this.message = message; + return this; + } + + /** + * @param id The id of the string resource to show. Can be formatted text. + */ + public SnackbarBuilder message(@StringRes final int id) { + message = activity.getResources().getString(id); + return this; + } + + /** + * @param duration How long to display the message. + */ + public SnackbarBuilder duration(final int duration) { + this.duration = duration; + return this; + } + + /** + * @param action Action text to display. + */ + public SnackbarBuilder action(final String action) { + this.action = action; + return this; + } + + /** + * @param id The id of the string resource for the action text to display. + */ + public SnackbarBuilder action(@StringRes final int id) { + action = activity.getResources().getString(id); + return this; + } + + /** + * @param callback Callback to be invoked when the action is clicked or the snackbar is dismissed. + */ + public SnackbarBuilder callback(final SnackbarCallback callback) { + this.callback = callback; + return this; + } + + /** + * @param callback Callback to be invoked when the action is clicked or the snackbar is dismissed. + */ + public SnackbarBuilder callback(final EventCallback callback) { + this.callback = new SnackbarEventCallback(callback); + return this; + } + + /** + * @param icon Icon to be displayed with the snackbar text. + */ + public SnackbarBuilder icon(final Drawable icon) { + this.icon = icon; + return this; + } + + /** + * @param backgroundColor Snackbar background color. + */ + public SnackbarBuilder backgroundColor(final Integer backgroundColor) { + this.backgroundColor = backgroundColor; + return this; + } + + /** + * @param actionColor Action text color. + */ + public SnackbarBuilder actionColor(final Integer actionColor) { + this.actionColor = actionColor; + return this; + } + + /** + * @param object Populate the builder with data from a Gecko Snackbar:Show event. + */ + public SnackbarBuilder fromEvent(final NativeJSObject object) { + message = object.getString("message"); + duration = object.getInt("duration"); + + if (object.has("backgroundColor")) { + final String providedColor = object.getString("backgroundColor"); + try { + backgroundColor = Color.parseColor(providedColor); + } catch (IllegalArgumentException e) { + Log.w(LOGTAG, "Failed to parse color string: " + providedColor); + } + } + + NativeJSObject actionObject = object.optObject("action", null); + if (actionObject != null) { + action = actionObject.optString("label", null); + } + return this; + } + + public void buildAndShow() { + final View parentView = findBestParentView(activity); + final Snackbar snackbar = Snackbar.make(parentView, message, duration); + + if (callback != null && !TextUtils.isEmpty(action)) { + snackbar.setAction(action, callback); + if (actionColor == null) { + snackbar.setActionTextColor(ContextCompat.getColor(activity, R.color.fennec_ui_orange)); + } else { + snackbar.setActionTextColor(actionColor); + } + snackbar.setCallback(callback); + } + + if (icon != null) { + int leftPadding = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, activity.getResources().getDisplayMetrics()); + + final InsetDrawable paddedIcon = new InsetDrawable(icon, 0, 0, leftPadding, 0); + + paddedIcon.setBounds(0, 0, leftPadding + icon.getIntrinsicWidth(), icon.getIntrinsicHeight()); + + TextView textView = (TextView) snackbar.getView().findViewById(android.support.design.R.id.snackbar_text); + textView.setCompoundDrawables(paddedIcon, null, null, null); + } + + if (backgroundColor != null) { + snackbar.getView().setBackgroundColor(backgroundColor); + } + + snackbar.show(); + + synchronized (currentSnackbarLock) { + currentSnackbar = new WeakReference<>(snackbar); + } + } + + /** + * Dismiss the currently visible snackbar. + */ + public static void dismissCurrentSnackbar() { + synchronized (currentSnackbarLock) { + final Snackbar snackbar = currentSnackbar.get(); + if (snackbar != null && snackbar.isShown()) { + snackbar.dismiss(); + } + } + } + + /** + * Find the best parent view to hold the Snackbar's view. The Snackbar implementation of the support + * library will use this view to walk up the view tree to find an actual suitable parent (if needed). + */ + private static View findBestParentView(Activity activity) { + if (activity instanceof GeckoApp) { + final View view = activity.findViewById(R.id.root_layout); + if (view != null) { + return view; + } + } + + return activity.findViewById(android.R.id.content); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java b/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java new file mode 100644 index 000000000..e43bbef1f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java @@ -0,0 +1,142 @@ +/* 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; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; + +import org.json.JSONArray; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.util.HardwareUtils; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.util.NetworkUtils; + +/** + * Use network-based search suggestions. + */ +public class SuggestClient { + private static final String LOGTAG = "GeckoSuggestClient"; + + // This should go through GeckoInterface to get the UA, but the search activity + // doesn't use a GeckoView yet. Until it does, get the UA directly. + private static final String USER_AGENT = HardwareUtils.isTablet() ? + AppConstants.USER_AGENT_FENNEC_TABLET : AppConstants.USER_AGENT_FENNEC_MOBILE; + + private final Context mContext; + private final int mTimeout; + + // should contain the string "__searchTerms__", which is replaced with the query + private final String mSuggestTemplate; + + // the maximum number of suggestions to return + private final int mMaxResults; + + // used by robocop for testing + private final boolean mCheckNetwork; + + // used to make suggestions appear instantly after opt-in + private String mPrevQuery; + private ArrayList<String> mPrevResults; + + @RobocopTarget + public SuggestClient(Context context, String suggestTemplate, int timeout, int maxResults, boolean checkNetwork) { + mContext = context; + mMaxResults = maxResults; + mSuggestTemplate = suggestTemplate; + mTimeout = timeout; + mCheckNetwork = checkNetwork; + } + + public String getSuggestTemplate() { + return mSuggestTemplate; + } + + /** + * Queries for a given search term and returns an ArrayList of suggestions. + */ + public ArrayList<String> query(String query) { + if (query.equals(mPrevQuery)) + return mPrevResults; + + ArrayList<String> suggestions = new ArrayList<String>(); + if (TextUtils.isEmpty(mSuggestTemplate) || TextUtils.isEmpty(query)) { + return suggestions; + } + + if (!NetworkUtils.isConnected(mContext) && mCheckNetwork) { + Log.i(LOGTAG, "Not connected to network"); + return suggestions; + } + + try { + String encoded = URLEncoder.encode(query, "UTF-8"); + String suggestUri = mSuggestTemplate.replace("__searchTerms__", encoded); + + URL url = new URL(suggestUri); + String json = null; + HttpURLConnection urlConnection = null; + InputStream in = null; + try { + urlConnection = (HttpURLConnection) url.openConnection(); + urlConnection.setConnectTimeout(mTimeout); + urlConnection.setRequestProperty("User-Agent", USER_AGENT); + in = new BufferedInputStream(urlConnection.getInputStream()); + json = convertStreamToString(in); + } finally { + if (urlConnection != null) + urlConnection.disconnect(); + if (in != null) { + try { + in.close(); + } catch (IOException e) { + Log.e(LOGTAG, "error", e); + } + } + } + + if (json != null) { + /* + * Sample result: + * ["foo",["food network","foothill college","foot locker",...]] + */ + JSONArray results = new JSONArray(json); + JSONArray jsonSuggestions = results.getJSONArray(1); + + int added = 0; + for (int i = 0; (i < jsonSuggestions.length()) && (added < mMaxResults); i++) { + String suggestion = jsonSuggestions.getString(i); + if (!suggestion.equalsIgnoreCase(query)) { + suggestions.add(suggestion); + added++; + } + } + } else { + Log.e(LOGTAG, "Suggestion query failed"); + } + } catch (Exception e) { + Log.e(LOGTAG, "Error", e); + } + + mPrevQuery = query; + mPrevResults = suggestions; + return suggestions; + } + + private String convertStreamToString(java.io.InputStream is) { + try { + return new java.util.Scanner(is).useDelimiter("\\A").next(); + } catch (java.util.NoSuchElementException e) { + return ""; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Tab.java b/mobile/android/base/java/org/mozilla/gecko/Tab.java new file mode 100644 index 000000000..6010a3dd9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Tab.java @@ -0,0 +1,843 @@ +/* -*- 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; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.URLMetadata; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequestBuilder; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.reader.ReadingListHelper; +import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.SiteLogins; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +public class Tab { + private static final String LOGTAG = "GeckoTab"; + + private static Pattern sColorPattern; + private final int mId; + private final BrowserDB mDB; + private long mLastUsed; + private String mUrl; + private String mBaseDomain; + private String mUserRequested; // The original url requested. May be typed by the user or sent by an extneral app for example. + private String mTitle; + private Bitmap mFavicon; + private String mFaviconUrl; + private String mApplicationId; // Intended to be null after explicit user action. + + private IconRequestBuilder mIconRequestBuilder; + private Future<IconResponse> mRunningIconRequest; + + private boolean mHasFeeds; + private boolean mHasOpenSearch; + private final SiteIdentity mSiteIdentity; + private SiteLogins mSiteLogins; + private BitmapDrawable mThumbnail; + private final int mParentId; + // Indicates the url was loaded from a source external to the app. This will be cleared + // when the user explicitly loads a new url (e.g. clicking a link is not explicit). + private final boolean mExternal; + private boolean mBookmark; + private int mFaviconLoadId; + private String mContentType; + private boolean mHasTouchListeners; + private final ArrayList<View> mPluginViews; + private int mState; + private Bitmap mThumbnailBitmap; + private boolean mDesktopMode; + private boolean mEnteringReaderMode; + private final Context mAppContext; + private ErrorType mErrorType = ErrorType.NONE; + private volatile int mLoadProgress; + private volatile int mRecordingCount; + private volatile boolean mIsAudioPlaying; + private volatile boolean mIsMediaPlaying; + private String mMostRecentHomePanel; + private boolean mShouldShowToolbarWithoutAnimationOnFirstSelection; + + /* + * Bundle containing restore data for the panel referenced in mMostRecentHomePanel. This can be + * e.g. the most recent folder for the bookmarks panel, or any other state that should be + * persisted. This is then used e.g. when returning to homepanels via history. + */ + private Bundle mMostRecentHomePanelData; + + private int mHistoryIndex; + private int mHistorySize; + private boolean mCanDoBack; + private boolean mCanDoForward; + + private boolean mIsEditing; + private final TabEditingState mEditingState = new TabEditingState(); + + // Will be true when tab is loaded from cache while device was offline. + private boolean mLoadedFromCache; + + public static final int STATE_DELAYED = 0; + public static final int STATE_LOADING = 1; + public static final int STATE_SUCCESS = 2; + public static final int STATE_ERROR = 3; + + public static final int LOAD_PROGRESS_INIT = 10; + public static final int LOAD_PROGRESS_START = 20; + public static final int LOAD_PROGRESS_LOCATION_CHANGE = 60; + public static final int LOAD_PROGRESS_LOADED = 80; + public static final int LOAD_PROGRESS_STOP = 100; + + public enum ErrorType { + CERT_ERROR, // Pages with certificate problems + BLOCKED, // Pages blocked for phishing or malware warnings + NET_ERROR, // All other types of error + NONE // Non error pages + } + + public Tab(Context context, int id, String url, boolean external, int parentId, String title) { + mAppContext = context.getApplicationContext(); + mDB = BrowserDB.from(context); + mId = id; + mUrl = url; + mBaseDomain = ""; + mUserRequested = ""; + mExternal = external; + mParentId = parentId; + mTitle = title == null ? "" : title; + mSiteIdentity = new SiteIdentity(); + mHistoryIndex = -1; + mContentType = ""; + mPluginViews = new ArrayList<View>(); + mState = shouldShowProgress(url) ? STATE_LOADING : STATE_SUCCESS; + mLoadProgress = LOAD_PROGRESS_INIT; + mIconRequestBuilder = Icons.with(mAppContext).pageUrl(mUrl); + + updateBookmark(); + } + + private ContentResolver getContentResolver() { + return mAppContext.getContentResolver(); + } + + public void onDestroy() { + Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.CLOSED); + } + + @RobocopTarget + public int getId() { + return mId; + } + + public synchronized void onChange() { + mLastUsed = System.currentTimeMillis(); + } + + public synchronized long getLastUsed() { + return mLastUsed; + } + + public int getParentId() { + return mParentId; + } + + // may be null if user-entered query hasn't yet been resolved to a URI + public synchronized String getURL() { + return mUrl; + } + + // mUserRequested should never be null, but it may be an empty string + public synchronized String getUserRequested() { + return mUserRequested; + } + + // mTitle should never be null, but it may be an empty string + public synchronized String getTitle() { + return mTitle; + } + + public String getDisplayTitle() { + if (mTitle != null && mTitle.length() > 0) { + return mTitle; + } + + return mUrl; + } + + /** + * Returns the base domain of the loaded uri. Note that if the page is + * a Reader mode uri, the base domain returned is that of the original uri. + */ + public String getBaseDomain() { + return mBaseDomain; + } + + public Bitmap getFavicon() { + return mFavicon; + } + + protected String getApplicationId() { + return mApplicationId; + } + + protected void setApplicationId(final String applicationId) { + mApplicationId = applicationId; + } + + public BitmapDrawable getThumbnail() { + return mThumbnail; + } + + public String getMostRecentHomePanel() { + return mMostRecentHomePanel; + } + + public Bundle getMostRecentHomePanelData() { + return mMostRecentHomePanelData; + } + + public void setMostRecentHomePanel(String panelId) { + mMostRecentHomePanel = panelId; + mMostRecentHomePanelData = null; + } + + public void setMostRecentHomePanelData(Bundle data) { + mMostRecentHomePanelData = data; + } + + public Bitmap getThumbnailBitmap(int width, int height) { + if (mThumbnailBitmap != null) { + // Bug 787318 - Honeycomb has a bug with bitmap caching, we can't + // reuse the bitmap there. + boolean honeycomb = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB + && Build.VERSION.SDK_INT <= Build.VERSION_CODES.HONEYCOMB_MR2); + boolean sizeChange = mThumbnailBitmap.getWidth() != width + || mThumbnailBitmap.getHeight() != height; + if (honeycomb || sizeChange) { + mThumbnailBitmap = null; + } + } + + if (mThumbnailBitmap == null) { + Bitmap.Config config = (GeckoAppShell.getScreenDepth() == 24) ? + Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565; + mThumbnailBitmap = Bitmap.createBitmap(width, height, config); + } + + return mThumbnailBitmap; + } + + public void updateThumbnail(final Bitmap b, final ThumbnailHelper.CachePolicy cachePolicy) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + if (b != null) { + try { + mThumbnail = new BitmapDrawable(mAppContext.getResources(), b); + if (mState == Tab.STATE_SUCCESS && cachePolicy == ThumbnailHelper.CachePolicy.STORE) { + saveThumbnailToDB(mDB); + } else { + // If the page failed to load, or requested that we not cache info about it, clear any previous + // thumbnails we've stored. + clearThumbnailFromDB(mDB); + } + } catch (OutOfMemoryError oom) { + Log.w(LOGTAG, "Unable to create/scale bitmap.", oom); + mThumbnail = null; + } + } else { + mThumbnail = null; + } + + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.THUMBNAIL); + } + }); + } + + public synchronized String getFaviconURL() { + return mFaviconUrl; + } + + public boolean hasFeeds() { + return mHasFeeds; + } + + public boolean hasOpenSearch() { + return mHasOpenSearch; + } + + public boolean hasLoadedFromCache() { + return mLoadedFromCache; + } + + public SiteIdentity getSiteIdentity() { + return mSiteIdentity; + } + + public void resetSiteIdentity() { + if (mSiteIdentity != null) { + mSiteIdentity.reset(); + Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.SECURITY_CHANGE); + } + } + + public SiteLogins getSiteLogins() { + return mSiteLogins; + } + + public boolean isBookmark() { + return mBookmark; + } + + public boolean isExternal() { + return mExternal; + } + + public synchronized void updateURL(String url) { + if (url != null && url.length() > 0) { + mUrl = url; + } + } + + public synchronized void updateUserRequested(String userRequested) { + mUserRequested = userRequested; + } + + public void setErrorType(String type) { + if ("blocked".equals(type)) + setErrorType(ErrorType.BLOCKED); + else if ("certerror".equals(type)) + setErrorType(ErrorType.CERT_ERROR); + else if ("neterror".equals(type)) + setErrorType(ErrorType.NET_ERROR); + else + setErrorType(ErrorType.NONE); + } + + public void setErrorType(ErrorType type) { + mErrorType = type; + } + + public void setMetadata(JSONObject metadata) { + if (metadata == null) { + return; + } + + final ContentResolver cr = mAppContext.getContentResolver(); + final URLMetadata urlMetadata = mDB.getURLMetadata(); + + final Map<String, Object> data = urlMetadata.fromJSON(metadata); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + urlMetadata.save(cr, data); + } + }); + } + + public ErrorType getErrorType() { + return mErrorType; + } + + public void setContentType(String contentType) { + mContentType = (contentType == null) ? "" : contentType; + } + + public String getContentType() { + return mContentType; + } + + public int getHistoryIndex() { + return mHistoryIndex; + } + + public int getHistorySize() { + return mHistorySize; + } + + public synchronized void updateTitle(String title) { + // Keep the title unchanged while entering reader mode. + if (mEnteringReaderMode) { + return; + } + + // If there was a title, but it hasn't changed, do nothing. + if (mTitle != null && + TextUtils.equals(mTitle, title)) { + return; + } + + mTitle = (title == null ? "" : title); + Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.TITLE); + } + + public void setState(int state) { + mState = state; + + if (mState != Tab.STATE_LOADING) + mEnteringReaderMode = false; + } + + public int getState() { + return mState; + } + + public void setHasTouchListeners(boolean aValue) { + mHasTouchListeners = aValue; + } + + public boolean getHasTouchListeners() { + return mHasTouchListeners; + } + + public synchronized void addFavicon(String faviconURL, int faviconSize, String mimeType) { + mIconRequestBuilder + .icon(IconDescriptor.createFavicon(faviconURL, faviconSize, mimeType)) + .deferBuild(); + } + + public synchronized void addTouchicon(String iconUrl, int faviconSize, String mimeType) { + mIconRequestBuilder + .icon(IconDescriptor.createTouchicon(iconUrl, faviconSize, mimeType)) + .deferBuild(); + } + + public void loadFavicon() { + // Static Favicons never change + if (AboutPages.isBuiltinIconPage(mUrl) && mFavicon != null) { + return; + } + + mRunningIconRequest = mIconRequestBuilder + .build() + .execute(new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + mFavicon = response.getBitmap(); + + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.FAVICON); + } + }); + } + + public synchronized void clearFavicon() { + // Cancel any ongoing favicon load (if we never finished downloading the old favicon before + // we changed page). + if (mRunningIconRequest != null) { + mRunningIconRequest.cancel(true); + } + + // Keep the favicon unchanged while entering reader mode + if (mEnteringReaderMode) + return; + + mFavicon = null; + mFaviconUrl = null; + } + + public void setHasFeeds(boolean hasFeeds) { + mHasFeeds = hasFeeds; + } + + public void setHasOpenSearch(boolean hasOpenSearch) { + mHasOpenSearch = hasOpenSearch; + } + + public void setLoadedFromCache(boolean loadedFromCache) { + mLoadedFromCache = loadedFromCache; + } + + public void updateIdentityData(JSONObject identityData) { + mSiteIdentity.update(identityData); + } + + public void setSiteLogins(SiteLogins siteLogins) { + mSiteLogins = siteLogins; + } + + void updateBookmark() { + if (getURL() == null) { + return; + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final String url = getURL(); + if (url == null) { + return; + } + final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(url); + + mBookmark = mDB.isBookmark(getContentResolver(), pageUrl); + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.MENU_UPDATED); + } + }); + } + + public void addBookmark() { + final String url = getURL(); + if (url == null) { + return; + } + + final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(getURL()); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + mDB.addBookmark(getContentResolver(), mTitle, pageUrl); + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.BOOKMARK_ADDED); + } + }); + + if (AboutPages.isAboutReader(url)) { + ReadingListHelper.cacheReaderItem(pageUrl, mId, mAppContext); + } + } + + public void removeBookmark() { + final String url = getURL(); + if (url == null) { + return; + } + + final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(getURL()); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + mDB.removeBookmarksWithURL(getContentResolver(), pageUrl); + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.BOOKMARK_REMOVED); + } + }); + + // We need to ensure we remove readercached items here - we could have switched out of readermode + // before unbookmarking, so we don't necessarily have an about:reader URL here. + ReadingListHelper.removeCachedReaderItem(pageUrl, mAppContext); + } + + public boolean isEnteringReaderMode() { + return mEnteringReaderMode; + } + + public void doReload(boolean bypassCache) { + GeckoAppShell.notifyObservers("Session:Reload", "{\"bypassCache\":" + String.valueOf(bypassCache) + "}"); + } + + // Our version of nsSHistory::GetCanGoBack + public boolean canDoBack() { + return mCanDoBack; + } + + public boolean doBack() { + if (!canDoBack()) + return false; + + GeckoAppShell.notifyObservers("Session:Back", ""); + return true; + } + + public void doStop() { + GeckoAppShell.notifyObservers("Session:Stop", ""); + } + + // Our version of nsSHistory::GetCanGoForward + public boolean canDoForward() { + return mCanDoForward; + } + + public boolean doForward() { + if (!canDoForward()) + return false; + + GeckoAppShell.notifyObservers("Session:Forward", ""); + return true; + } + + void handleLocationChange(JSONObject message) throws JSONException { + final String uri = message.getString("uri"); + final String oldUrl = getURL(); + final boolean sameDocument = message.getBoolean("sameDocument"); + mEnteringReaderMode = ReaderModeUtils.isEnteringReaderMode(oldUrl, uri); + mHistoryIndex = message.getInt("historyIndex"); + mHistorySize = message.getInt("historySize"); + mCanDoBack = message.getBoolean("canGoBack"); + mCanDoForward = message.getBoolean("canGoForward"); + + if (!TextUtils.equals(oldUrl, uri)) { + updateURL(uri); + updateBookmark(); + if (!sameDocument) { + // We can unconditionally clear the favicon and title here: we + // already filtered both cases in which this was a (pseudo-) + // spurious location change, so we're definitely loading a new + // page. + clearFavicon(); + + // Start to build a new request to load a favicon. + mIconRequestBuilder = Icons.with(mAppContext) + .pageUrl(uri); + + // Load local static Favicons immediately + if (AboutPages.isBuiltinIconPage(uri)) { + loadFavicon(); + } + + updateTitle(null); + } + } + + if (sameDocument) { + // We can get a location change event for the same document with an anchor tag + // Notify listeners so that buttons like back or forward will update themselves + Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.LOCATION_CHANGE, oldUrl); + return; + } + + setContentType(message.getString("contentType")); + updateUserRequested(message.getString("userRequested")); + mBaseDomain = message.optString("baseDomain"); + + setHasFeeds(false); + setHasOpenSearch(false); + mSiteIdentity.reset(); + setSiteLogins(null); + setHasTouchListeners(false); + setErrorType(ErrorType.NONE); + setLoadProgressIfLoading(LOAD_PROGRESS_LOCATION_CHANGE); + + Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.LOCATION_CHANGE, oldUrl); + } + + private static boolean shouldShowProgress(final String url) { + return !AboutPages.isAboutPage(url); + } + + void handleDocumentStart(boolean restoring, String url) { + setLoadProgress(LOAD_PROGRESS_START); + setState((!restoring && shouldShowProgress(url)) ? STATE_LOADING : STATE_SUCCESS); + mSiteIdentity.reset(); + } + + void handleDocumentStop(boolean success) { + setState(success ? STATE_SUCCESS : STATE_ERROR); + + final String oldURL = getURL(); + final Tab tab = this; + tab.setLoadProgress(LOAD_PROGRESS_STOP); + + ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() { + @Override + public void run() { + // tab.getURL() may return null + if (!TextUtils.equals(oldURL, getURL())) + return; + + ThumbnailHelper.getInstance().getAndProcessThumbnailFor(tab); + } + }, 500); + } + + void handleContentLoaded() { + setLoadProgressIfLoading(LOAD_PROGRESS_LOADED); + } + + protected void saveThumbnailToDB(final BrowserDB db) { + final BitmapDrawable thumbnail = mThumbnail; + if (thumbnail == null) { + return; + } + + try { + final String url = getURL(); + if (url == null) { + return; + } + + db.updateThumbnailForUrl(getContentResolver(), url, thumbnail); + } catch (Exception e) { + // ignore + } + } + + public void loadThumbnailFromDB(final BrowserDB db) { + try { + final String url = getURL(); + if (url == null) { + return; + } + + byte[] thumbnail = db.getThumbnailForUrl(getContentResolver(), url); + if (thumbnail == null) { + return; + } + + Bitmap bitmap = BitmapUtils.decodeByteArray(thumbnail); + mThumbnail = new BitmapDrawable(mAppContext.getResources(), bitmap); + + Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.THUMBNAIL); + } catch (Exception e) { + // ignore + } + } + + private void clearThumbnailFromDB(final BrowserDB db) { + try { + final String url = getURL(); + if (url == null) { + return; + } + + // Passing in a null thumbnail will delete the stored thumbnail for this url + db.updateThumbnailForUrl(getContentResolver(), url, null); + } catch (Exception e) { + // ignore + } + } + + public void addPluginView(View view) { + mPluginViews.add(view); + } + + public void removePluginView(View view) { + mPluginViews.remove(view); + } + + public View[] getPluginViews() { + return mPluginViews.toArray(new View[mPluginViews.size()]); + } + + public void setDesktopMode(boolean enabled) { + mDesktopMode = enabled; + } + + public boolean getDesktopMode() { + return mDesktopMode; + } + + public boolean isPrivate() { + return false; + } + + /** + * Sets the tab load progress to the given percentage. + * + * @param progressPercentage Percentage to set progress to (0-100) + */ + void setLoadProgress(int progressPercentage) { + mLoadProgress = progressPercentage; + } + + /** + * Sets the tab load progress to the given percentage only if the tab is + * currently loading. + * + * about:neterror can trigger a STOP before other page load events (bug + * 976426), so any post-START events should make sure the page is loading + * before updating progress. + * + * @param progressPercentage Percentage to set progress to (0-100) + */ + void setLoadProgressIfLoading(int progressPercentage) { + if (getState() == STATE_LOADING) { + setLoadProgress(progressPercentage); + } + } + + /** + * Gets the tab load progress percentage. + * + * @return Current progress percentage + */ + public int getLoadProgress() { + return mLoadProgress; + } + + public void setRecording(boolean isRecording) { + if (isRecording) { + mRecordingCount++; + } else { + mRecordingCount--; + } + } + + public boolean isRecording() { + return mRecordingCount > 0; + } + + /** + * The "MediaPlaying" is used for controling media control interface and + * means the tab has playing media. + * + * @param isMediaPlaying the tab has any playing media or not + */ + public void setIsMediaPlaying(boolean isMediaPlaying) { + mIsMediaPlaying = isMediaPlaying; + } + + public boolean isMediaPlaying() { + return mIsMediaPlaying; + } + + /** + * The "AudioPlaying" is used for showing the tab sound indicator and means + * the tab has playing media and the media is audible. + * + * @param isAudioPlaying the tab has any audible playing media or not + */ + public void setIsAudioPlaying(boolean isAudioPlaying) { + mIsAudioPlaying = isAudioPlaying; + } + + public boolean isAudioPlaying() { + return mIsAudioPlaying; + } + + public boolean isEditing() { + return mIsEditing; + } + + public void setIsEditing(final boolean isEditing) { + this.mIsEditing = isEditing; + } + + public TabEditingState getEditingState() { + return mEditingState; + } + + public void setShouldShowToolbarWithoutAnimationOnFirstSelection(final boolean shouldShowWithoutAnimation) { + mShouldShowToolbarWithoutAnimationOnFirstSelection = shouldShowWithoutAnimation; + } + + public boolean getShouldShowToolbarWithoutAnimationOnFirstSelection() { + return mShouldShowToolbarWithoutAnimationOnFirstSelection; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Tabs.java b/mobile/android/base/java/org/mozilla/gecko/Tabs.java new file mode 100644 index 000000000..c7e024fe0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Tabs.java @@ -0,0 +1,1021 @@ +/* -*- 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; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import android.support.annotation.Nullable; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.notifications.WhatsNewReceiver; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.OnAccountsUpdateListener; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.sqlite.SQLiteException; +import android.graphics.Color; +import android.net.Uri; +import android.os.Handler; +import android.provider.Browser; +import android.support.v4.content.ContextCompat; +import android.util.Log; + +public class Tabs implements GeckoEventListener { + private static final String LOGTAG = "GeckoTabs"; + + // mOrder and mTabs are always of the same cardinality, and contain the same values. + private final CopyOnWriteArrayList<Tab> mOrder = new CopyOnWriteArrayList<Tab>(); + + // All writes to mSelectedTab must be synchronized on the Tabs instance. + // In general, it's preferred to always use selectTab()). + private volatile Tab mSelectedTab; + + // All accesses to mTabs must be synchronized on the Tabs instance. + private final HashMap<Integer, Tab> mTabs = new HashMap<Integer, Tab>(); + + private AccountManager mAccountManager; + private OnAccountsUpdateListener mAccountListener; + + public static final int LOADURL_NONE = 0; + public static final int LOADURL_NEW_TAB = 1 << 0; + public static final int LOADURL_USER_ENTERED = 1 << 1; + public static final int LOADURL_PRIVATE = 1 << 2; + public static final int LOADURL_PINNED = 1 << 3; + public static final int LOADURL_DELAY_LOAD = 1 << 4; + public static final int LOADURL_DESKTOP = 1 << 5; + public static final int LOADURL_BACKGROUND = 1 << 6; + /** Indicates the url has been specified by a source external to the app. */ + public static final int LOADURL_EXTERNAL = 1 << 7; + /** Indicates the tab is the first shown after Firefox is hidden and restored. */ + public static final int LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN = 1 << 8; + + private static final long PERSIST_TABS_AFTER_MILLISECONDS = 1000 * 2; + + public static final int INVALID_TAB_ID = -1; + + private static final AtomicInteger sTabId = new AtomicInteger(0); + private volatile boolean mInitialTabsAdded; + + private Context mAppContext; + private LayerView mLayerView; + private ContentObserver mBookmarksContentObserver; + private PersistTabsRunnable mPersistTabsRunnable; + private int mPrivateClearColor; + + private static class PersistTabsRunnable implements Runnable { + private final BrowserDB db; + private final Context context; + private final Iterable<Tab> tabs; + + public PersistTabsRunnable(final Context context, Iterable<Tab> tabsInOrder) { + this.context = context; + this.db = BrowserDB.from(context); + this.tabs = tabsInOrder; + } + + @Override + public void run() { + try { + db.getTabsAccessor().persistLocalTabs(context.getContentResolver(), tabs); + } catch (SQLiteException e) { + Log.w(LOGTAG, "Error persisting local tabs", e); + } + } + }; + + private Tabs() { + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "Tab:Added", + "Tab:Close", + "Tab:Select", + "Content:LocationChange", + "Content:SecurityChange", + "Content:StateChange", + "Content:LoadError", + "Content:PageShow", + "DOMTitleChanged", + "Link:Favicon", + "Link:Touchicon", + "Link:Feed", + "Link:OpenSearch", + "DesktopMode:Changed", + "Tab:StreamStart", + "Tab:StreamStop", + "Tab:AudioPlayingChange", + "Tab:MediaPlaybackChange"); + + mPrivateClearColor = Color.RED; + + } + + public synchronized void attachToContext(Context context, LayerView layerView) { + final Context appContext = context.getApplicationContext(); + if (mAppContext == appContext) { + return; + } + + if (mAppContext != null) { + // This should never happen. + Log.w(LOGTAG, "The application context has changed!"); + } + + mAppContext = appContext; + mLayerView = layerView; + mPrivateClearColor = ContextCompat.getColor(context, R.color.tabs_tray_grey_pressed); + mAccountManager = AccountManager.get(appContext); + + mAccountListener = new OnAccountsUpdateListener() { + @Override + public void onAccountsUpdated(Account[] accounts) { + queuePersistAllTabs(); + } + }; + + // The listener will run on the background thread (see 2nd argument). + mAccountManager.addOnAccountsUpdatedListener(mAccountListener, ThreadUtils.getBackgroundHandler(), false); + + if (mBookmarksContentObserver != null) { + // It's safe to use the db here since we aren't doing any I/O. + final GeckoProfile profile = GeckoProfile.get(context); + BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver); + } + } + + /** + * Gets the tab count corresponding to the private state of the selected + * tab. + * + * If the selected tab is a non-private tab, this will return the number of + * non-private tabs; likewise, if this is a private tab, this will return + * the number of private tabs. + * + * @return the number of tabs in the current private state + */ + public synchronized int getDisplayCount() { + // Once mSelectedTab is non-null, it cannot be null for the remainder + // of the object's lifetime. + boolean getPrivate = mSelectedTab != null && mSelectedTab.isPrivate(); + int count = 0; + for (Tab tab : mOrder) { + if (tab.isPrivate() == getPrivate) { + count++; + } + } + return count; + } + + public int isOpen(String url) { + for (Tab tab : mOrder) { + if (tab.getURL().equals(url)) { + return tab.getId(); + } + } + return -1; + } + + // Must be synchronized to avoid racing on mBookmarksContentObserver. + private void lazyRegisterBookmarkObserver() { + if (mBookmarksContentObserver == null) { + mBookmarksContentObserver = new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + for (Tab tab : mOrder) { + tab.updateBookmark(); + } + } + }; + + // It's safe to use the db here since we aren't doing any I/O. + final GeckoProfile profile = GeckoProfile.get(mAppContext); + BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver); + } + } + + private Tab addTab(int id, String url, boolean external, int parentId, String title, boolean isPrivate, int tabIndex) { + final Tab tab = isPrivate ? new PrivateTab(mAppContext, id, url, external, parentId, title) : + new Tab(mAppContext, id, url, external, parentId, title); + synchronized (this) { + lazyRegisterBookmarkObserver(); + mTabs.put(id, tab); + + if (tabIndex > -1) { + mOrder.add(tabIndex, tab); + } else { + mOrder.add(tab); + } + } + + // Suppress the ADDED event to prevent animation of tabs created via session restore. + if (mInitialTabsAdded) { + notifyListeners(tab, TabEvents.ADDED, + Integer.toString(getPrivacySpecificTabIndex(tabIndex, isPrivate))); + } + + return tab; + } + + // Return the index, among those tabs whose privacy setting matches isPrivate, of the tab at + // position index in mOrder. Returns -1, for "new last tab", when index is -1. + private int getPrivacySpecificTabIndex(int index, boolean isPrivate) { + int privacySpecificIndex = -1; + for (int i = 0; i <= index; i++) { + final Tab tab = mOrder.get(i); + if (tab.isPrivate() == isPrivate) { + privacySpecificIndex++; + } + } + return privacySpecificIndex; + } + + public synchronized void removeTab(int id) { + if (mTabs.containsKey(id)) { + Tab tab = getTab(id); + mOrder.remove(tab); + mTabs.remove(id); + } + } + + public synchronized Tab selectTab(int id) { + if (!mTabs.containsKey(id)) + return null; + + final Tab oldTab = getSelectedTab(); + final Tab tab = mTabs.get(id); + + // This avoids a NPE below, but callers need to be careful to + // handle this case. + if (tab == null || oldTab == tab) { + return tab; + } + + mSelectedTab = tab; + notifyListeners(tab, TabEvents.SELECTED); + + if (mLayerView != null) { + mLayerView.setClearColor(getTabColor(tab)); + } + + if (oldTab != null) { + notifyListeners(oldTab, TabEvents.UNSELECTED); + } + + // Pass a message to Gecko to update tab state in BrowserApp. + GeckoAppShell.notifyObservers("Tab:Selected", String.valueOf(tab.getId())); + return tab; + } + + public synchronized boolean selectLastTab() { + if (mOrder.isEmpty()) { + return false; + } + + selectTab(mOrder.get(mOrder.size() - 1).getId()); + return true; + } + + private int getIndexOf(Tab tab) { + return mOrder.lastIndexOf(tab); + } + + private Tab getNextTabFrom(Tab tab, boolean getPrivate) { + int numTabs = mOrder.size(); + int index = getIndexOf(tab); + for (int i = index + 1; i < numTabs; i++) { + Tab next = mOrder.get(i); + if (next.isPrivate() == getPrivate) { + return next; + } + } + return null; + } + + private Tab getPreviousTabFrom(Tab tab, boolean getPrivate) { + int index = getIndexOf(tab); + for (int i = index - 1; i >= 0; i--) { + Tab prev = mOrder.get(i); + if (prev.isPrivate() == getPrivate) { + return prev; + } + } + return null; + } + + /** + * Gets the selected tab. + * + * The selected tab can be null if we're doing a session restore after a + * crash and Gecko isn't ready yet. + * + * @return the selected tab, or null if no tabs exist + */ + @Nullable + public Tab getSelectedTab() { + return mSelectedTab; + } + + public boolean isSelectedTab(Tab tab) { + return tab != null && tab == mSelectedTab; + } + + public boolean isSelectedTabId(int tabId) { + final Tab selected = mSelectedTab; + return selected != null && selected.getId() == tabId; + } + + @RobocopTarget + public synchronized Tab getTab(int id) { + if (id == -1) + return null; + + if (mTabs.size() == 0) + return null; + + if (!mTabs.containsKey(id)) + return null; + + return mTabs.get(id); + } + + public synchronized Tab getTabForApplicationId(final String applicationId) { + if (applicationId == null) { + return null; + } + + for (final Tab tab : mOrder) { + if (applicationId.equals(tab.getApplicationId())) { + return tab; + } + } + + return null; + } + + /** Close tab and then select the default next tab */ + @RobocopTarget + public synchronized void closeTab(Tab tab) { + closeTab(tab, getNextTab(tab)); + } + + public synchronized void closeTab(Tab tab, Tab nextTab) { + closeTab(tab, nextTab, false); + } + + public synchronized void closeTab(Tab tab, boolean showUndoToast) { + closeTab(tab, getNextTab(tab), showUndoToast); + } + + /** Close tab and then select nextTab */ + public synchronized void closeTab(final Tab tab, Tab nextTab, boolean showUndoToast) { + if (tab == null) + return; + + int tabId = tab.getId(); + removeTab(tabId); + + if (nextTab == null) { + nextTab = loadUrl(AboutPages.HOME, LOADURL_NEW_TAB); + } + + selectTab(nextTab.getId()); + + tab.onDestroy(); + + final JSONObject args = new JSONObject(); + try { + args.put("tabId", String.valueOf(tabId)); + args.put("showUndoToast", showUndoToast); + } catch (JSONException e) { + Log.e(LOGTAG, "Error building Tab:Closed arguments: " + e); + } + + // Pass a message to Gecko to update tab state in BrowserApp + GeckoAppShell.notifyObservers("Tab:Closed", args.toString()); + } + + /** Return the tab that will be selected by default after this one is closed */ + public Tab getNextTab(Tab tab) { + Tab selectedTab = getSelectedTab(); + if (selectedTab != tab) + return selectedTab; + + boolean getPrivate = tab.isPrivate(); + Tab nextTab = getNextTabFrom(tab, getPrivate); + if (nextTab == null) + nextTab = getPreviousTabFrom(tab, getPrivate); + if (nextTab == null && getPrivate) { + // If there are no private tabs remaining, get the last normal tab + Tab lastTab = mOrder.get(mOrder.size() - 1); + if (!lastTab.isPrivate()) { + nextTab = lastTab; + } else { + nextTab = getPreviousTabFrom(lastTab, false); + } + } + + Tab parent = getTab(tab.getParentId()); + if (parent != null) { + // If the next tab is a sibling, switch to it. Otherwise go back to the parent. + if (nextTab != null && nextTab.getParentId() == tab.getParentId()) + return nextTab; + else + return parent; + } + return nextTab; + } + + public Iterable<Tab> getTabsInOrder() { + return mOrder; + } + + /** + * @return the current GeckoApp instance, or throws if + * we aren't correctly initialized. + */ + private synchronized Context getAppContext() { + if (mAppContext == null) { + throw new IllegalStateException("Tabs not initialized with a GeckoApp instance."); + } + return mAppContext; + } + + public ContentResolver getContentResolver() { + return getAppContext().getContentResolver(); + } + + // Make Tabs a singleton class. + private static class TabsInstanceHolder { + private static final Tabs INSTANCE = new Tabs(); + } + + @RobocopTarget + public static Tabs getInstance() { + return Tabs.TabsInstanceHolder.INSTANCE; + } + + // GeckoEventListener implementation + @Override + public void handleMessage(String event, JSONObject message) { + Log.d(LOGTAG, "handleMessage: " + event); + try { + // All other events handled below should contain a tabID property + int id = message.getInt("tabID"); + Tab tab = getTab(id); + + // "Tab:Added" is a special case because tab will be null if the tab was just added + if (event.equals("Tab:Added")) { + String url = message.isNull("uri") ? null : message.getString("uri"); + + if (message.getBoolean("cancelEditMode")) { + final Tab oldTab = getSelectedTab(); + if (oldTab != null) { + oldTab.setIsEditing(false); + } + } + + if (message.getBoolean("stub")) { + if (tab == null) { + // Tab was already closed; abort + return; + } + } else { + tab = addTab(id, url, message.getBoolean("external"), + message.getInt("parentId"), + message.getString("title"), + message.getBoolean("isPrivate"), + message.getInt("tabIndex")); + // If we added the tab as a stub, we should have already + // selected it, so ignore this flag for stubbed tabs. + if (message.getBoolean("selected")) + selectTab(id); + } + + if (message.getBoolean("delayLoad")) + tab.setState(Tab.STATE_DELAYED); + if (message.getBoolean("desktopMode")) + tab.setDesktopMode(true); + return; + } + + // Tab was already closed; abort + if (tab == null) + return; + + if (event.equals("Tab:Close")) { + closeTab(tab); + } else if (event.equals("Tab:Select")) { + selectTab(tab.getId()); + } else if (event.equals("Content:LocationChange")) { + tab.handleLocationChange(message); + } else if (event.equals("Content:SecurityChange")) { + tab.updateIdentityData(message.getJSONObject("identity")); + notifyListeners(tab, TabEvents.SECURITY_CHANGE); + } else if (event.equals("Content:StateChange")) { + int state = message.getInt("state"); + if ((state & GeckoAppShell.WPL_STATE_IS_NETWORK) != 0) { + if ((state & GeckoAppShell.WPL_STATE_START) != 0) { + boolean restoring = message.getBoolean("restoring"); + tab.handleDocumentStart(restoring, message.getString("uri")); + notifyListeners(tab, Tabs.TabEvents.START); + } else if ((state & GeckoAppShell.WPL_STATE_STOP) != 0) { + tab.handleDocumentStop(message.getBoolean("success")); + notifyListeners(tab, Tabs.TabEvents.STOP); + } + } + } else if (event.equals("Content:LoadError")) { + tab.handleContentLoaded(); + notifyListeners(tab, Tabs.TabEvents.LOAD_ERROR); + } else if (event.equals("Content:PageShow")) { + tab.setLoadedFromCache(message.getBoolean("fromCache")); + tab.updateUserRequested(message.getString("userRequested")); + notifyListeners(tab, TabEvents.PAGE_SHOW); + } else if (event.equals("DOMTitleChanged")) { + tab.updateTitle(message.getString("title")); + } else if (event.equals("Link:Favicon")) { + // Add the favicon to the set of available icons for this tab. + + tab.addFavicon(message.getString("href"), message.getInt("size"), message.getString("mime")); + + // Load the favicon. If the tab is still loading, we actually do the load once the + // page has loaded, in an attempt to prevent the favicon load from having a + // detrimental effect on page load time. + if (tab.getState() != Tab.STATE_LOADING) { + tab.loadFavicon(); + } + } else if (event.equals("Link:Touchicon")) { + tab.addTouchicon(message.getString("href"), message.getInt("size"), message.getString("mime")); + } else if (event.equals("Link:Feed")) { + tab.setHasFeeds(true); + notifyListeners(tab, TabEvents.LINK_FEED); + } else if (event.equals("Link:OpenSearch")) { + boolean visible = message.getBoolean("visible"); + tab.setHasOpenSearch(visible); + } else if (event.equals("DesktopMode:Changed")) { + tab.setDesktopMode(message.getBoolean("desktopMode")); + notifyListeners(tab, TabEvents.DESKTOP_MODE_CHANGE); + } else if (event.equals("Tab:StreamStart")) { + tab.setRecording(true); + notifyListeners(tab, TabEvents.RECORDING_CHANGE); + } else if (event.equals("Tab:StreamStop")) { + tab.setRecording(false); + notifyListeners(tab, TabEvents.RECORDING_CHANGE); + } else if (event.equals("Tab:AudioPlayingChange")) { + tab.setIsAudioPlaying(message.getBoolean("isAudioPlaying")); + notifyListeners(tab, TabEvents.AUDIO_PLAYING_CHANGE); + } else if (event.equals("Tab:MediaPlaybackChange")) { + final String status = message.getString("status"); + if (status.equals("resume")) { + notifyListeners(tab, TabEvents.MEDIA_PLAYING_RESUME); + } else { + tab.setIsMediaPlaying(status.equals("start")); + notifyListeners(tab, TabEvents.MEDIA_PLAYING_CHANGE); + } + } + + } catch (Exception e) { + Log.w(LOGTAG, "handleMessage threw for " + event, e); + } + } + + public void refreshThumbnails() { + final BrowserDB db = BrowserDB.from(mAppContext); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + for (final Tab tab : mOrder) { + if (tab.getThumbnail() == null) { + tab.loadThumbnailFromDB(db); + } + } + } + }); + } + + public interface OnTabsChangedListener { + void onTabChanged(Tab tab, TabEvents msg, String data); + } + + private static final List<OnTabsChangedListener> TABS_CHANGED_LISTENERS = new CopyOnWriteArrayList<OnTabsChangedListener>(); + + public static void registerOnTabsChangedListener(OnTabsChangedListener listener) { + TABS_CHANGED_LISTENERS.add(listener); + } + + public static void unregisterOnTabsChangedListener(OnTabsChangedListener listener) { + TABS_CHANGED_LISTENERS.remove(listener); + } + + public enum TabEvents { + CLOSED, + START, + LOADED, + LOAD_ERROR, + STOP, + FAVICON, + THUMBNAIL, + TITLE, + SELECTED, + UNSELECTED, + ADDED, + RESTORED, + LOCATION_CHANGE, + MENU_UPDATED, + PAGE_SHOW, + LINK_FEED, + SECURITY_CHANGE, + DESKTOP_MODE_CHANGE, + RECORDING_CHANGE, + BOOKMARK_ADDED, + BOOKMARK_REMOVED, + AUDIO_PLAYING_CHANGE, + OPENED_FROM_TABS_TRAY, + MEDIA_PLAYING_CHANGE, + MEDIA_PLAYING_RESUME + } + + public void notifyListeners(Tab tab, TabEvents msg) { + notifyListeners(tab, msg, ""); + } + + public void notifyListeners(final Tab tab, final TabEvents msg, final String data) { + if (tab == null && + msg != TabEvents.RESTORED) { + throw new IllegalArgumentException("onTabChanged:" + msg + " must specify a tab."); + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + onTabChanged(tab, msg, data); + + if (TABS_CHANGED_LISTENERS.isEmpty()) { + return; + } + + Iterator<OnTabsChangedListener> items = TABS_CHANGED_LISTENERS.iterator(); + while (items.hasNext()) { + items.next().onTabChanged(tab, msg, data); + } + } + }); + } + + private void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) { + switch (msg) { + // We want the tab record to have an accurate favicon, so queue + // the persisting of tabs when it changes. + case FAVICON: + case LOCATION_CHANGE: + queuePersistAllTabs(); + break; + case RESTORED: + mInitialTabsAdded = true; + break; + + // When one tab is deselected, another one is always selected, so only + // queue a single persist operation. When tabs are added/closed, they + // are also selected/unselected, so it would be redundant to also listen + // for ADDED/CLOSED events. + case SELECTED: + if (mLayerView != null) { + mLayerView.setSurfaceBackgroundColor(getTabColor(tab)); + mLayerView.setPaintState(LayerView.PAINT_START); + } + queuePersistAllTabs(); + case UNSELECTED: + tab.onChange(); + break; + default: + break; + } + } + + /** + * Queues a request to persist tabs after PERSIST_TABS_AFTER_MILLISECONDS + * milliseconds have elapsed. If any existing requests are already queued then + * those requests are removed. + */ + private void queuePersistAllTabs() { + final Handler backgroundHandler = ThreadUtils.getBackgroundHandler(); + + // Note: Its safe to modify the runnable here because all of the callers are on the same thread. + if (mPersistTabsRunnable != null) { + backgroundHandler.removeCallbacks(mPersistTabsRunnable); + mPersistTabsRunnable = null; + } + + mPersistTabsRunnable = new PersistTabsRunnable(mAppContext, getTabsInOrder()); + backgroundHandler.postDelayed(mPersistTabsRunnable, PERSIST_TABS_AFTER_MILLISECONDS); + } + + /** + * Looks for an open tab with the given URL. + * @param url the URL of the tab we're looking for + * + * @return first Tab with the given URL, or null if there is no such tab. + */ + public Tab getFirstTabForUrl(String url) { + return getFirstTabForUrlHelper(url, null); + } + + /** + * Looks for an open tab with the given URL and private state. + * @param url the URL of the tab we're looking for + * @param isPrivate if true, only look for tabs that are private. if false, + * only look for tabs that are non-private. + * + * @return first Tab with the given URL, or null if there is no such tab. + */ + public Tab getFirstTabForUrl(String url, boolean isPrivate) { + return getFirstTabForUrlHelper(url, isPrivate); + } + + private Tab getFirstTabForUrlHelper(String url, Boolean isPrivate) { + if (url == null) { + return null; + } + + for (Tab tab : mOrder) { + if (isPrivate != null && isPrivate != tab.isPrivate()) { + continue; + } + if (url.equals(tab.getURL())) { + return tab; + } + } + + return null; + } + + /** + * Looks for a reader mode enabled open tab with the given URL and private + * state. + * + * @param url + * The URL of the tab we're looking for. The url parameter can be + * the actual article URL or the reader mode article URL. + * @param isPrivate + * If true, only look for tabs that are private. If false, only + * look for tabs that are not private. + * + * @return The first Tab with the given URL, or null if there is no such + * tab. + */ + public Tab getFirstReaderTabForUrl(String url, boolean isPrivate) { + if (url == null) { + return null; + } + + url = ReaderModeUtils.stripAboutReaderUrl(url); + + for (Tab tab : mOrder) { + if (isPrivate != tab.isPrivate()) { + continue; + } + String tabUrl = tab.getURL(); + if (AboutPages.isAboutReader(tabUrl)) { + tabUrl = ReaderModeUtils.stripAboutReaderUrl(tabUrl); + if (url.equals(tabUrl)) { + return tab; + } + } + } + + return null; + } + + /** + * Loads a tab with the given URL in the currently selected tab. + * + * @param url URL of page to load, or search term used if searchEngine is given + */ + @RobocopTarget + public Tab loadUrl(String url) { + return loadUrl(url, LOADURL_NONE); + } + + /** + * Loads a tab with the given URL. + * + * @param url URL of page to load, or search term used if searchEngine is given + * @param flags flags used to load tab + * + * @return the Tab if a new one was created; null otherwise + */ + @RobocopTarget + public Tab loadUrl(String url, int flags) { + return loadUrl(url, null, -1, null, flags); + } + + public Tab loadUrlWithIntentExtras(final String url, final SafeIntent intent, final int flags) { + // We can't directly create a listener to tell when the user taps on the "What's new" + // notification, so we use this intent handling as a signal that they tapped the notification. + if (intent.getBooleanExtra(WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION, false)) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, + WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION); + } + + // Note: we don't get the URL from the intent so the calling + // method has the opportunity to change the URL if applicable. + return loadUrl(url, null, -1, intent, flags); + } + + public Tab loadUrl(final String url, final String searchEngine, final int parentId, final int flags) { + return loadUrl(url, searchEngine, parentId, null, flags); + } + + /** + * Loads a tab with the given URL. + * + * @param url URL of page to load, or search term used if searchEngine is given + * @param searchEngine if given, the search engine with this name is used + * to search for the url string; if null, the URL is loaded directly + * @param parentId ID of this tab's parent, or -1 if it has no parent + * @param intent an intent whose extras are used to modify the request + * @param flags flags used to load tab + * + * @return the Tab if a new one was created; null otherwise + */ + public Tab loadUrl(final String url, final String searchEngine, final int parentId, + final SafeIntent intent, final int flags) { + JSONObject args = new JSONObject(); + Tab tabToSelect = null; + boolean delayLoad = (flags & LOADURL_DELAY_LOAD) != 0; + + // delayLoad implies background tab + boolean background = delayLoad || (flags & LOADURL_BACKGROUND) != 0; + + try { + boolean isPrivate = (flags & LOADURL_PRIVATE) != 0; + boolean userEntered = (flags & LOADURL_USER_ENTERED) != 0; + boolean desktopMode = (flags & LOADURL_DESKTOP) != 0; + boolean external = (flags & LOADURL_EXTERNAL) != 0; + final boolean isFirstShownAfterActivityUnhidden = (flags & LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN) != 0; + + args.put("url", url); + args.put("engine", searchEngine); + args.put("parentId", parentId); + args.put("userEntered", userEntered); + args.put("isPrivate", isPrivate); + args.put("pinned", (flags & LOADURL_PINNED) != 0); + args.put("desktopMode", desktopMode); + + final boolean needsNewTab; + final String applicationId = (intent == null) ? null : + intent.getStringExtra(Browser.EXTRA_APPLICATION_ID); + if (applicationId == null) { + needsNewTab = (flags & LOADURL_NEW_TAB) != 0; + } else { + // If you modify this code, be careful that intent != null. + final boolean extraCreateNewTab = intent.getBooleanExtra(Browser.EXTRA_CREATE_NEW_TAB, false); + final Tab applicationTab = getTabForApplicationId(applicationId); + if (applicationTab == null || extraCreateNewTab) { + needsNewTab = true; + } else { + needsNewTab = false; + delayLoad = false; + background = false; + + tabToSelect = applicationTab; + final int tabToSelectId = tabToSelect.getId(); + args.put("tabID", tabToSelectId); + + // This must be called before the "Tab:Load" event is sent. I think addTab gets + // away with it because having "newTab" == true causes the selected tab to be + // updated in JS for the "Tab:Load" event but "newTab" is false in our case. + // This makes me think the other selectTab is not necessary (bug 1160673). + // + // Note: that makes the later call redundant but selectTab exits early so I'm + // fine not adding the complex logic to avoid calling it again. + selectTab(tabToSelect.getId()); + } + } + + args.put("newTab", needsNewTab); + args.put("delayLoad", delayLoad); + args.put("selected", !background); + + if (needsNewTab) { + int tabId = getNextTabId(); + args.put("tabID", tabId); + + // The URL is updated for the tab once Gecko responds with the + // Tab:Added message. We can preliminarily set the tab's URL as + // long as it's a valid URI. + String tabUrl = (url != null && Uri.parse(url).getScheme() != null) ? url : null; + + // Add the new tab to the end of the tab order. + final int tabIndex = -1; + + tabToSelect = addTab(tabId, tabUrl, external, parentId, url, isPrivate, tabIndex); + tabToSelect.setDesktopMode(desktopMode); + tabToSelect.setApplicationId(applicationId); + if (isFirstShownAfterActivityUnhidden) { + // We just opened Firefox so we want to show + // the toolbar but not animate it to avoid jank. + tabToSelect.setShouldShowToolbarWithoutAnimationOnFirstSelection(true); + } + } + } catch (Exception e) { + Log.w(LOGTAG, "Error building JSON arguments for loadUrl.", e); + } + + GeckoAppShell.notifyObservers("Tab:Load", args.toString()); + + if (tabToSelect == null) { + return null; + } + + if (!delayLoad && !background) { + selectTab(tabToSelect.getId()); + } + + // Load favicon instantly for about:home page because it's already cached + if (AboutPages.isBuiltinIconPage(url)) { + tabToSelect.loadFavicon(); + } + + return tabToSelect; + } + + public Tab addTab() { + return loadUrl(AboutPages.HOME, Tabs.LOADURL_NEW_TAB); + } + + public Tab addPrivateTab() { + return loadUrl(AboutPages.PRIVATEBROWSING, Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_PRIVATE); + } + + /** + * Open the url as a new tab, and mark the selected tab as its "parent". + * + * If the url is already open in a tab, the existing tab is selected. + * Use this for tabs opened by the browser chrome, so users can press the + * "Back" button to return to the previous tab. + * + * This method will open a new private tab if the currently selected tab + * is also private. + * + * @param url URL of page to load + */ + public void loadUrlInTab(String url) { + Iterable<Tab> tabs = getTabsInOrder(); + for (Tab tab : tabs) { + if (url.equals(tab.getURL())) { + selectTab(tab.getId()); + return; + } + } + + // getSelectedTab() can return null if no tab has been created yet + // (i.e., we're restoring a session after a crash). In these cases, + // don't mark any tabs as a parent. + int parentId = -1; + int flags = LOADURL_NEW_TAB; + + final Tab selectedTab = getSelectedTab(); + if (selectedTab != null) { + parentId = selectedTab.getId(); + if (selectedTab.isPrivate()) { + flags = flags | LOADURL_PRIVATE; + } + } + + loadUrl(url, null, parentId, flags); + } + + /** + * Gets the next tab ID. + */ + @JNITarget + public static int getNextTabId() { + return sTabId.getAndIncrement(); + } + + private int getTabColor(Tab tab) { + if (tab != null) { + return tab.isPrivate() ? mPrivateClearColor : Color.WHITE; + } + + return Color.WHITE; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/Telemetry.java b/mobile/android/base/java/org/mozilla/gecko/Telemetry.java new file mode 100644 index 000000000..342445bf2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/Telemetry.java @@ -0,0 +1,246 @@ +/* -*- 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; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.TelemetryContract.Event; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.TelemetryContract.Reason; +import org.mozilla.gecko.TelemetryContract.Session; + +import android.os.SystemClock; +import android.util.Log; + +/** + * All telemetry times are relative to one of two clocks: + * + * * Real time since the device was booted, including deep sleep. Use this + * as a substitute for wall clock. + * * Uptime since the device was booted, excluding deep sleep. Use this to + * avoid timing a user activity when their phone is in their pocket! + * + * The majority of methods in this class are defined in terms of real time. + */ +@RobocopTarget +public class Telemetry { + private static final String LOGTAG = "Telemetry"; + + @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko") + private static native void nativeAddHistogram(String name, int value); + @WrapForJNI(stubName = "AddKeyedHistogram", dispatchTo = "gecko") + private static native void nativeAddKeyedHistogram(String name, String key, int value); + @WrapForJNI(stubName = "StartUISession", dispatchTo = "gecko") + private static native void nativeStartUiSession(String name, long timestamp); + @WrapForJNI(stubName = "StopUISession", dispatchTo = "gecko") + private static native void nativeStopUiSession(String name, String reason, long timestamp); + @WrapForJNI(stubName = "AddUIEvent", dispatchTo = "gecko") + private static native void nativeAddUiEvent(String action, String method, + long timestamp, String extras); + + public static long uptime() { + return SystemClock.uptimeMillis(); + } + + public static long realtime() { + return SystemClock.elapsedRealtime(); + } + + // Define new histograms in: + // toolkit/components/telemetry/Histograms.json + public static void addToHistogram(String name, int value) { + if (GeckoThread.isRunning()) { + nativeAddHistogram(name, value); + } else { + GeckoThread.queueNativeCall(Telemetry.class, "nativeAddHistogram", + String.class, name, value); + } + } + + public static void addToKeyedHistogram(String name, String key, int value) { + if (GeckoThread.isRunning()) { + nativeAddKeyedHistogram(name, key, value); + } else { + GeckoThread.queueNativeCall(Telemetry.class, "nativeAddKeyedHistogram", + String.class, name, String.class, key, value); + } + } + + public abstract static class Timer { + private final long mStartTime; + private final String mName; + + private volatile boolean mHasFinished; + private volatile long mElapsed = -1; + + protected abstract long now(); + + public Timer(String name) { + mName = name; + mStartTime = now(); + } + + public void cancel() { + mHasFinished = true; + } + + public long getElapsed() { + return mElapsed; + } + + public void stop() { + // Only the first stop counts. + if (mHasFinished) { + return; + } + + mHasFinished = true; + + final long elapsed = now() - mStartTime; + if (elapsed < 0) { + Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?"); + return; + } + + mElapsed = elapsed; + if (elapsed > Integer.MAX_VALUE) { + Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram."); + return; + } + + addToHistogram(mName, (int) (elapsed)); + } + } + + public static class RealtimeTimer extends Timer { + public RealtimeTimer(String name) { + super(name); + } + + @Override + protected long now() { + return Telemetry.realtime(); + } + } + + public static class UptimeTimer extends Timer { + public UptimeTimer(String name) { + super(name); + } + + @Override + protected long now() { + return Telemetry.uptime(); + } + } + + public static void startUISession(final Session session, final String sessionNameSuffix) { + final String sessionName = getSessionName(session, sessionNameSuffix); + + Log.d(LOGTAG, "StartUISession: " + sessionName); + if (GeckoThread.isRunning()) { + nativeStartUiSession(sessionName, realtime()); + } else { + GeckoThread.queueNativeCall(Telemetry.class, "nativeStartUiSession", + String.class, sessionName, realtime()); + } + } + + public static void startUISession(final Session session) { + startUISession(session, null); + } + + public static void stopUISession(final Session session, final String sessionNameSuffix, + final Reason reason) { + final String sessionName = getSessionName(session, sessionNameSuffix); + + Log.d(LOGTAG, "StopUISession: " + sessionName + ", reason=" + reason); + if (GeckoThread.isRunning()) { + nativeStopUiSession(sessionName, reason.toString(), realtime()); + } else { + GeckoThread.queueNativeCall(Telemetry.class, "nativeStopUiSession", + String.class, sessionName, + String.class, reason.toString(), realtime()); + } + } + + public static void stopUISession(final Session session, final Reason reason) { + stopUISession(session, null, reason); + } + + public static void stopUISession(final Session session, final String sessionNameSuffix) { + stopUISession(session, sessionNameSuffix, Reason.NONE); + } + + public static void stopUISession(final Session session) { + stopUISession(session, null, Reason.NONE); + } + + private static String getSessionName(final Session session, final String sessionNameSuffix) { + if (sessionNameSuffix != null) { + return session.toString() + ":" + sessionNameSuffix; + } else { + return session.toString(); + } + } + + /** + * @param method A non-null method (if null is desired, consider using Method.NONE) + */ + private static void sendUIEvent(final String eventName, final Method method, + final long timestamp, final String extras) { + if (method == null) { + throw new IllegalArgumentException("Expected non-null method - use Method.NONE?"); + } + + if (!AppConstants.RELEASE_OR_BETA) { + final String logString = "SendUIEvent: event = " + eventName + " method = " + method + " timestamp = " + + timestamp + " extras = " + extras; + Log.d(LOGTAG, logString); + } + if (GeckoThread.isRunning()) { + nativeAddUiEvent(eventName, method.toString(), timestamp, extras); + } else { + GeckoThread.queueNativeCall(Telemetry.class, "nativeAddUiEvent", + String.class, eventName, String.class, method.toString(), + timestamp, String.class, extras); + } + } + + public static void sendUIEvent(final Event event, final Method method, final long timestamp, + final String extras) { + sendUIEvent(event.toString(), method, timestamp, extras); + } + + public static void sendUIEvent(final Event event, final Method method, final long timestamp) { + sendUIEvent(event, method, timestamp, null); + } + + public static void sendUIEvent(final Event event, final Method method, final String extras) { + sendUIEvent(event, method, realtime(), extras); + } + + public static void sendUIEvent(final Event event, final Method method) { + sendUIEvent(event, method, realtime(), null); + } + + public static void sendUIEvent(final Event event) { + sendUIEvent(event, Method.NONE, realtime(), null); + } + + /** + * Sends a UIEvent with the given status appended to the event name. + * + * This method is a slight bend of the Telemetry framework so chances + * are that you don't want to use this: please think really hard before you do. + * + * Intended for use with data policy notifications. + */ + public static void sendUIEvent(final Event event, final boolean eventStatus) { + final String eventName = event + ":" + eventStatus; + sendUIEvent(eventName, Method.NONE, realtime(), null); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java b/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java new file mode 100644 index 000000000..0c2051a9d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java @@ -0,0 +1,307 @@ +/* -*- 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; + +import org.mozilla.gecko.annotation.RobocopTarget; + +/** + * Holds data definitions for our UI Telemetry implementation. + * + * Note that enum values of "_TEST*" are reserved for testing and + * should not be changed without changing the associated tests. + * + * See mobile/android/base/docs/index.rst for a full dictionary. + */ +@RobocopTarget +public interface TelemetryContract { + + /** + * Holds event names. Intended for use with + * Telemetry.sendUIEvent() as the "action" parameter. + * + * Please keep this list sorted. + */ + public enum Event { + // Generic action, usually for tracking menu and toolbar actions. + ACTION("action.1"), + + // Cancel a state, action, etc. + CANCEL("cancel.1"), + + // Start casting a video. + // Note: Only used in JavaScript for now, but here for completeness. + CAST("cast.1"), + + // Editing an item. + EDIT("edit.1"), + + // Launching (opening) an external application. + // Note: Only used in JavaScript for now, but here for completeness. + LAUNCH("launch.1"), + + // Loading a URL. + LOAD_URL("loadurl.1"), + + LOCALE_BROWSER_RESET("locale.browser.reset.1"), + LOCALE_BROWSER_SELECTED("locale.browser.selected.1"), + LOCALE_BROWSER_UNSELECTED("locale.browser.unselected.1"), + + // Hide a built-in home panel. + PANEL_HIDE("panel.hide.1"), + + // Move a home panel up or down. + PANEL_MOVE("panel.move.1"), + + // Remove a custom home panel. + PANEL_REMOVE("panel.remove.1"), + + // Set default home panel. + PANEL_SET_DEFAULT("panel.setdefault.1"), + + // Show a hidden built-in home panel. + PANEL_SHOW("panel.show.1"), + + // Pinning an item. + PIN("pin.1"), + + // Outcome of data policy notification: can be true or false. + POLICY_NOTIFICATION_SUCCESS("policynotification.success.1"), + + // Sanitizing private data. + SANITIZE("sanitize.1"), + + // Saving a resource (reader, bookmark, etc) for viewing later. + SAVE("save.1"), + + // Perform a search -- currently used when starting a search in the search activity. + SEARCH("search.1"), + + // Remove a search engine. + SEARCH_REMOVE("search.remove.1"), + + // Restore default search engines. + SEARCH_RESTORE_DEFAULTS("search.restoredefaults.1"), + + // Set default search engine. + SEARCH_SET_DEFAULT("search.setdefault.1"), + + // Sharing content. + SHARE("share.1"), + + // Show a UI element. + SHOW("show.1"), + + // Undoing a user action. + // Note: Only used in JavaScript for now, but here for completeness. + UNDO("undo.1"), + + // Unpinning an item. + UNPIN("unpin.1"), + + // Stop holding a resource (reader, bookmark, etc) for viewing later. + UNSAVE("unsave.1"), + + // When the user performs actions on the in-content network error page. + NETERROR("neterror.1"), + + // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING. + _TEST1("_test_event_1.1"), + _TEST2("_test_event_2.1"), + _TEST3("_test_event_3.1"), + _TEST4("_test_event_4.1"), + ; + + private final String string; + + Event(final String string) { + this.string = string; + } + + @Override + public String toString() { + return string; + } + } + + /** + * Holds event methods. Intended for use in + * Telemetry.sendUIEvent() as the "method" parameter. + * + * Please keep this list sorted. + */ + public enum Method { + // Action triggered from the action bar (including the toolbar). + ACTIONBAR("actionbar"), + + // Action triggered by hitting the Android back button. + BACK("back"), + + // Action triggered from a button. + BUTTON("button"), + + // Action taken from a content page -- for example, a search results web page. + CONTENT("content"), + + // Action occurred via a context menu. + CONTEXT_MENU("contextmenu"), + + // Action triggered from a dialog. + DIALOG("dialog"), + + // Action triggered from a doorhanger popup prompt. + DOORHANGER("doorhanger"), + + // Action triggered from a view grid item, like a thumbnail. + GRID_ITEM("griditem"), + + // Action occurred via an intent. + INTENT("intent"), + + // Action occurred via a homescreen launcher. + HOMESCREEN("homescreen"), + + // Action triggered from a list. + LIST("list"), + + // Action triggered from a view list item, like a row of a list. + LIST_ITEM("listitem"), + + // Action occurred via the main menu. + MENU("menu"), + + // No method is specified. + NONE(null), + + // Action triggered from a notification in the Android notification bar. + NOTIFICATION("notification"), + + // Action triggered from a pageaction in the URLBar. + // Note: Only used in JavaScript for now, but here for completeness. + PAGEACTION("pageaction"), + + // Action triggered from one of a series of views, such as ViewPager. + PANEL("panel"), + + // Action triggered by a background service / automatic system making a decision. + SERVICE("service"), + + // Action triggered from a settings screen. + SETTINGS("settings"), + + // Actions triggered from the share overlay. + SHARE_OVERLAY("shareoverlay"), + + // Action triggered from a suggestion provided to the user. + SUGGESTION("suggestion"), + + // Action triggered from an OS system action. + SYSTEM("system"), + + // Action triggered from a SuperToast. + // Note: Only used in JavaScript for now, but here for completeness. + TOAST("toast"), + + // Action triggerred by pressing a SearchWidget button + WIDGET("widget"), + + // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING. + _TEST1("_test_method_1"), + _TEST2("_test_method_2"), + ; + + private final String string; + + Method(final String string) { + this.string = string; + } + + @Override + public String toString() { + return string; + } + } + + /** + * Holds session names. Intended for use with + * Telemetry.startUISession() as the "sessionName" parameter. + * + * Please keep this list sorted. + */ + public enum Session { + // Awesomescreen (including frecency search) is active. + AWESOMESCREEN("awesomescreen.1"), + + // Used to tag experiments being run. + EXPERIMENT("experiment.1"), + + // Started the very first time we believe the application has been launched. + FIRSTRUN("firstrun.1"), + + // Awesomescreen frecency search is active. + FRECENCY("frecency.1"), + + // Started when a user enters a given home panel. + // Session name is dynamic, encoded as "homepanel.1:<panel_id>" + HOME_PANEL("homepanel.1"), + + // Started when a Reader viewer becomes active in the foreground. + // Note: Only used in JavaScript for now, but here for completeness. + READER("reader.1"), + + // Started when the search activity launches. + SEARCH_ACTIVITY("searchactivity.1"), + + // Settings activity is active. + SETTINGS("settings.1"), + + // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING. + _TEST_STARTED_TWICE("_test_session_started_twice.1"), + _TEST_STOPPED_TWICE("_test_session_stopped_twice.1"), + ; + + private final String string; + + Session(final String string) { + this.string = string; + } + + @Override + public String toString() { + return string; + } + } + + /** + * Holds reasons for stopping a session. Intended for use in + * Telemetry.stopUISession() as the "reason" parameter. + * + * Please keep this list sorted. + */ + public enum Reason { + // Changes were committed. + COMMIT("commit"), + + // No reason is specified. + NONE(null), + + // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING. + _TEST1("_test_reason_1"), + _TEST2("_test_reason_2"), + _TEST_IGNORED("_test_reason_ignored"), + ; + + private final String string; + + Reason(final String string) { + this.string = string; + } + + @Override + public String toString() { + return string; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java b/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java new file mode 100644 index 000000000..3a7012431 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java @@ -0,0 +1,246 @@ +/* -*- 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; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.mozglue.DirectBufferAllocator; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.util.Log; + +import java.nio.ByteBuffer; +import java.util.ArrayList; + +/** + * Helper class to generate thumbnails for tabs. + * Internally, a queue of pending thumbnails is maintained in mPendingThumbnails. + * The head of the queue is the thumbnail that is currently being processed; upon + * completion of the current thumbnail the next one is automatically processed. + * Changes to the thumbnail width are stashed in mPendingWidth and the change is + * applied between thumbnail processing. This allows a single thumbnail buffer to + * be used for all thumbnails. + */ +public final class ThumbnailHelper { + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoThumbnailHelper"; + + public static final float TABS_PANEL_THUMBNAIL_ASPECT_RATIO = 0.8333333f; + public static final float TOP_SITES_THUMBNAIL_ASPECT_RATIO = 0.571428571f; // this is a 4:7 ratio (as per UX decision) + public static final float THUMBNAIL_ASPECT_RATIO; + + static { + // As we only want to generate one thumbnail for each tab, we calculate the + // largest aspect ratio required and create the thumbnail based off that. + // Any views with a smaller aspect ratio will use a cropped version of the + // same image. + THUMBNAIL_ASPECT_RATIO = Math.max(TABS_PANEL_THUMBNAIL_ASPECT_RATIO, TOP_SITES_THUMBNAIL_ASPECT_RATIO); + } + + public enum CachePolicy { + STORE, + NO_STORE + } + + // static singleton stuff + + private static ThumbnailHelper sInstance; + + public static synchronized ThumbnailHelper getInstance() { + if (sInstance == null) { + sInstance = new ThumbnailHelper(); + } + return sInstance; + } + + // instance stuff + + private final ArrayList<Tab> mPendingThumbnails; // synchronized access only + private volatile int mPendingWidth; + private int mWidth; + private int mHeight; + private ByteBuffer mBuffer; + + private ThumbnailHelper() { + final Resources res = GeckoAppShell.getContext().getResources(); + + mPendingThumbnails = new ArrayList<>(); + try { + mPendingWidth = (int) res.getDimension(R.dimen.tab_thumbnail_width); + } catch (Resources.NotFoundException nfe) { + } + mWidth = -1; + mHeight = -1; + } + + public void getAndProcessThumbnailFor(final int tabId, final ResourceDrawableUtils.BitmapLoader loader) { + final Tab tab = Tabs.getInstance().getTab(tabId); + if (tab != null) { + getAndProcessThumbnailFor(tab, loader); + } + } + + public void getAndProcessThumbnailFor(final Tab tab, final ResourceDrawableUtils.BitmapLoader loader) { + ResourceDrawableUtils.runOnBitmapFoundOnUiThread(loader, tab.getThumbnail()); + + Tabs.registerOnTabsChangedListener(new Tabs.OnTabsChangedListener() { + @Override + public void onTabChanged(final Tab t, final Tabs.TabEvents msg, final String data) { + if (tab != t || msg != Tabs.TabEvents.THUMBNAIL) { + return; + } + Tabs.unregisterOnTabsChangedListener(this); + ResourceDrawableUtils.runOnBitmapFoundOnUiThread(loader, t.getThumbnail()); + } + }); + getAndProcessThumbnailFor(tab); + } + + public void getAndProcessThumbnailFor(Tab tab) { + if (AboutPages.isAboutHome(tab.getURL()) || AboutPages.isAboutPrivateBrowsing(tab.getURL())) { + tab.updateThumbnail(null, CachePolicy.NO_STORE); + return; + } + + synchronized (mPendingThumbnails) { + if (mPendingThumbnails.lastIndexOf(tab) > 0) { + // This tab is already in the queue, so don't add it again. + // Note that if this tab is only at the *head* of the queue, + // (i.e. mPendingThumbnails.lastIndexOf(tab) == 0) then we do + // add it again because it may have already been thumbnailed + // and now we need to do it again. + return; + } + + mPendingThumbnails.add(tab); + if (mPendingThumbnails.size() > 1) { + // Some thumbnail was already being processed, so wait + // for that to be done. + return; + } + + requestThumbnailLocked(tab); + } + } + + public void setThumbnailWidth(int width) { + // Check inverted for safety: Bug 803299 Comment 34. + if (GeckoAppShell.getScreenDepth() == 24) { + mPendingWidth = width; + } else { + // Bug 776906: on 16-bit screens we need to ensure an even width. + mPendingWidth = (width + 1) & (~1); + } + } + + private void updateThumbnailSizeLocked() { + // Apply any pending width updates. + mWidth = mPendingWidth; + mHeight = Math.round(mWidth * THUMBNAIL_ASPECT_RATIO); + + int pixelSize = (GeckoAppShell.getScreenDepth() == 24) ? 4 : 2; + int capacity = mWidth * mHeight * pixelSize; + if (DEBUG) { + Log.d(LOGTAG, "Using new thumbnail size: " + capacity + + " (width " + mWidth + " - height " + mHeight + ")"); + } + if (mBuffer == null || mBuffer.capacity() != capacity) { + if (mBuffer != null) { + mBuffer = DirectBufferAllocator.free(mBuffer); + } + try { + mBuffer = DirectBufferAllocator.allocate(capacity); + } catch (IllegalArgumentException iae) { + Log.w(LOGTAG, iae.toString()); + } catch (OutOfMemoryError oom) { + Log.w(LOGTAG, "Unable to allocate thumbnail buffer of capacity " + capacity); + } + // If we hit an error above, mBuffer will be pointing to null, so we are in a sane state. + } + } + + private void requestThumbnailLocked(Tab tab) { + updateThumbnailSizeLocked(); + + if (mBuffer == null) { + // Buffer allocation may have failed. In this case we can't send the + // event requesting the screenshot which means we won't get back a response + // and so our queue will grow unboundedly. Handle this scenario by clearing + // the queue (no point trying more thumbnailing right now since we're likely + // low on memory). We will try again normally on the next call to + // getAndProcessThumbnailFor which will hopefully be when we have more free memory. + mPendingThumbnails.clear(); + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "Sending thumbnail event: " + mWidth + ", " + mHeight); + } + requestThumbnailLocked(mBuffer, tab, tab.getId(), mWidth, mHeight); + } + + @WrapForJNI(stubName = "RequestThumbnail", dispatchTo = "proxy") + private static native void requestThumbnailLocked(ByteBuffer data, Tab tab, int tabId, + int width, int height); + + /* This method is invoked by JNI once the thumbnail data is ready. */ + @WrapForJNI(calledFrom = "gecko") + private static void notifyThumbnail(final ByteBuffer data, final Tab tab, + final boolean success, final boolean shouldStore) { + final ThumbnailHelper helper = ThumbnailHelper.getInstance(); + if (success) { + helper.handleThumbnailData( + tab, data, shouldStore ? CachePolicy.STORE : CachePolicy.NO_STORE); + } + helper.processNextThumbnail(); + } + + private void processNextThumbnail() { + synchronized (mPendingThumbnails) { + if (mPendingThumbnails.isEmpty()) { + return; + } + + mPendingThumbnails.remove(0); + + if (!mPendingThumbnails.isEmpty()) { + requestThumbnailLocked(mPendingThumbnails.get(0)); + } + } + } + + private void handleThumbnailData(Tab tab, ByteBuffer data, CachePolicy cachePolicy) { + if (DEBUG) { + Log.d(LOGTAG, "handleThumbnailData: " + data.capacity()); + } + if (data != mBuffer) { + // This should never happen, but log it and recover gracefully + Log.e(LOGTAG, "handleThumbnailData called with an unexpected ByteBuffer!"); + } + + processThumbnailData(tab, data, cachePolicy); + } + + private void processThumbnailData(Tab tab, ByteBuffer data, CachePolicy cachePolicy) { + Bitmap b = tab.getThumbnailBitmap(mWidth, mHeight); + data.position(0); + b.copyPixelsFromBuffer(data); + setTabThumbnail(tab, b, null, cachePolicy); + } + + private void setTabThumbnail(Tab tab, Bitmap bitmap, byte[] compressed, CachePolicy cachePolicy) { + if (bitmap == null) { + if (compressed == null) { + Log.w(LOGTAG, "setTabThumbnail: one of bitmap or compressed must be non-null!"); + return; + } + bitmap = BitmapUtils.decodeByteArray(compressed); + } + tab.updateThumbnail(bitmap, cachePolicy); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java b/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java new file mode 100644 index 000000000..c0c9307dc --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java @@ -0,0 +1,838 @@ +/* -*- 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; + +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.gfx.PanZoomController; +import org.mozilla.gecko.gfx.PointUtils; +import org.mozilla.gecko.mozglue.DirectBufferAllocator; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.OvershootInterpolator; +import android.view.animation.ScaleAnimation; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import java.nio.ByteBuffer; +import java.text.DecimalFormat; + +public class ZoomedView extends FrameLayout implements LayerView.DynamicToolbarListener, + LayerView.ZoomedViewListener, GeckoEventListener { + private static final String LOGTAG = "Gecko" + ZoomedView.class.getSimpleName(); + + private static final float[] ZOOM_FACTORS_LIST = {2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f, 1.5f}; + private static final int W_CAPTURED_VIEW_IN_PERCENT = 50; + private static final int H_CAPTURED_VIEW_IN_PERCENT = 50; + private static final int MINIMUM_DELAY_BETWEEN_TWO_RENDER_CALLS_NS = 1000000; + private static final int DELAY_BEFORE_NEXT_RENDER_REQUEST_MS = 2000; + private static final int OPENING_ANIMATION_DURATION_MS = 250; + private static final int CLOSING_ANIMATION_DURATION_MS = 150; + private static final float OVERSHOOT_INTERPOLATOR_TENSION = 1.5f; + + private float zoomFactor; + private int currentZoomFactorIndex; + private boolean isSimplifiedUI; + private int defaultZoomFactor; + private PrefsHelper.PrefHandler prefObserver; + + private ImageView zoomedImageView; + private LayerView layerView; + private int viewWidth; + private int viewHeight; // Only the zoomed view height, no toolbar, no shadow ... + private int viewContainerWidth; + private int viewContainerHeight; // Zoomed view height with toolbar and other elements like shadow, ... + private int containterSize; // shadow, margin, ... + private Point lastPosition; + private boolean shouldSetVisibleOnUpdate; + private boolean isBlockedFromAppearing; // Prevent the display of the zoomedview while FormAssistantPopup is visible + private PointF returnValue; + private final PointF animationStart; + private ImageView closeButton; + private TextView changeZoomFactorButton; + private boolean toolbarOnTop; + private float offsetDueToToolBarPosition; + private int toolbarHeight; + private int cornerRadius; + private float dynamicToolbarOverlap; + + private boolean stopUpdateView; + + private int lastOrientation; + + private ByteBuffer buffer; + private Runnable requestRenderRunnable; + private long startTimeReRender; + private long lastStartTimeReRender; + + private ZoomedViewTouchListener touchListener; + + private enum StartPointUpdate { + GECKO_POSITION, CENTER, NO_CHANGE + } + + private class RoundedBitmapDrawable extends BitmapDrawable { + private Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG); + final float cornerRadius; + final boolean squareOnTopOfDrawable; + + RoundedBitmapDrawable(Resources res, Bitmap bitmap, boolean squareOnTop, int radius) { + super(res, bitmap); + squareOnTopOfDrawable = squareOnTop; + final BitmapShader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, + Shader.TileMode.CLAMP); + paint.setAntiAlias(true); + paint.setShader(shader); + cornerRadius = radius; + } + + @Override + public void draw(Canvas canvas) { + int height = getBounds().height(); + int width = getBounds().width(); + RectF rect = new RectF(0.0f, 0.0f, width, height); + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint); + + //draw rectangles over the corners we want to be square + if (squareOnTopOfDrawable) { + canvas.drawRect(0, 0, cornerRadius, cornerRadius, paint); + canvas.drawRect(width - cornerRadius, 0, width, cornerRadius, paint); + } else { + canvas.drawRect(0, height - cornerRadius, cornerRadius, height, paint); + canvas.drawRect(width - cornerRadius, height - cornerRadius, width, height, paint); + } + } + } + + private class ZoomedViewTouchListener implements View.OnTouchListener { + private float originRawX; + private float originRawY; + private boolean dragged; + private MotionEvent actionDownEvent; + + @Override + public boolean onTouch(View view, MotionEvent event) { + if (layerView == null) { + return false; + } + + switch (event.getAction()) { + case MotionEvent.ACTION_MOVE: + if (moveZoomedView(event)) { + dragged = true; + } + break; + + case MotionEvent.ACTION_UP: + if (dragged) { + dragged = false; + } else { + if (isClickInZoomedView(event.getY())) { + GeckoAppShell.notifyObservers("Gesture:ClickInZoomedView", ""); + layerView.dispatchTouchEvent(actionDownEvent); + actionDownEvent.recycle(); + PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(event.getX(), event.getY()); + // the LayerView expects the coordinates relative to the window, not the surface, so we need + // to adjust that here. + convertedPosition.y += layerView.getSurfaceTranslation(); + MotionEvent e = MotionEvent.obtain(event.getDownTime(), event.getEventTime(), + MotionEvent.ACTION_UP, convertedPosition.x, convertedPosition.y, + event.getMetaState()); + layerView.dispatchTouchEvent(e); + e.recycle(); + } + } + break; + + case MotionEvent.ACTION_DOWN: + dragged = false; + originRawX = event.getRawX(); + originRawY = event.getRawY(); + PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(event.getX(), event.getY()); + // the LayerView expects the coordinates relative to the window, not the surface, so we need + // to adjust that here. + convertedPosition.y += layerView.getSurfaceTranslation(); + actionDownEvent = MotionEvent.obtain(event.getDownTime(), event.getEventTime(), + MotionEvent.ACTION_DOWN, convertedPosition.x, convertedPosition.y, + event.getMetaState()); + break; + } + return true; + } + + private boolean isClickInZoomedView(float y) { + return ((toolbarOnTop && y > toolbarHeight) || + (!toolbarOnTop && y < ZoomedView.this.viewHeight)); + } + + private boolean moveZoomedView(MotionEvent event) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) ZoomedView.this.getLayoutParams(); + if ((!dragged) && (Math.abs((int) (event.getRawX() - originRawX)) < PanZoomController.CLICK_THRESHOLD) + && (Math.abs((int) (event.getRawY() - originRawY)) < PanZoomController.CLICK_THRESHOLD)) { + // When the user just touches the screen ACTION_MOVE can be detected for a very small delta on position. + // In this case, the move is ignored if the delta is lower than 1 unit. + return false; + } + + float newLeftMargin = params.leftMargin + event.getRawX() - originRawX; + float newTopMargin = params.topMargin + event.getRawY() - originRawY; + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + ZoomedView.this.moveZoomedView(metrics, newLeftMargin, newTopMargin, StartPointUpdate.CENTER); + originRawX = event.getRawX(); + originRawY = event.getRawY(); + return true; + } + } + + public ZoomedView(Context context) { + this(context, null, 0); + } + + public ZoomedView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ZoomedView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + isSimplifiedUI = true; + isBlockedFromAppearing = false; + getPrefs(); + currentZoomFactorIndex = 0; + returnValue = new PointF(); + animationStart = new PointF(); + requestRenderRunnable = new Runnable() { + @Override + public void run() { + requestZoomedViewRender(); + } + }; + touchListener = new ZoomedViewTouchListener(); + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "Gesture:clusteredLinksClicked", "Window:Resize", "Content:LocationChange", + "Gesture:CloseZoomedView", "Browser:ZoomToPageWidth", "Browser:ZoomToRect", + "FormAssist:AutoComplete", "FormAssist:Hide"); + } + + void destroy() { + if (prefObserver != null) { + PrefsHelper.removeObserver(prefObserver); + prefObserver = null; + } + ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable); + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "Gesture:clusteredLinksClicked", "Window:Resize", "Content:LocationChange", + "Gesture:CloseZoomedView", "Browser:ZoomToPageWidth", "Browser:ZoomToRect", + "FormAssist:AutoComplete", "FormAssist:Hide"); + } + + // This method (onFinishInflate) is called only when the zoomed view class is used inside + // an xml structure <org.mozilla.gecko.ZoomedView ... + // It won't be called if the class is used from java code like "new ZoomedView(context);" + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + closeButton = (ImageView) findViewById(R.id.dialog_close); + changeZoomFactorButton = (TextView) findViewById(R.id.change_zoom_factor); + zoomedImageView = (ImageView) findViewById(R.id.zoomed_image_view); + + updateUI(); + + toolbarHeight = getResources().getDimensionPixelSize(R.dimen.zoomed_view_toolbar_height); + containterSize = getResources().getDimensionPixelSize(R.dimen.drawable_dropshadow_size); + cornerRadius = getResources().getDimensionPixelSize(R.dimen.standard_corner_radius); + + moveToolbar(true); + } + + private void setListeners() { + closeButton.setOnClickListener(new View.OnClickListener() { + public void onClick(View view) { + stopZoomDisplay(true); + } + }); + + changeZoomFactorButton.setOnTouchListener(new OnTouchListener() { + public boolean onTouch(View v, MotionEvent event) { + + if (event.getAction() == MotionEvent.ACTION_UP) { + if (event.getX() >= (changeZoomFactorButton.getLeft() + changeZoomFactorButton.getWidth() / 2)) { + changeZoomFactor(true); + } else { + changeZoomFactor(false); + } + } + return true; + } + }); + + setOnTouchListener(touchListener); + } + + private void removeListeners() { + closeButton.setOnClickListener(null); + + changeZoomFactorButton.setOnTouchListener(null); + + setOnTouchListener(null); + } + /* + * Convert a click from ZoomedView. Return the position of the click in the + * LayerView + */ + private PointF getUnzoomedPositionFromPointInZoomedView(float x, float y) { + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + final float parentWidth = metrics.getWidth(); + final float parentHeight = metrics.getHeight(); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) getLayoutParams(); + + // The number of unzoomed content pixels that can be displayed in the + // zoomed area. + float visibleContentPixels = viewWidth / zoomFactor; + // The offset in content pixels of the leftmost zoomed pixel from the + // layerview's left edge when the zoomed view is moved to the right as + // far as it can go. + float maxContentOffset = parentWidth - visibleContentPixels; + // The maximum offset in screen pixels that the zoomed view can have + float maxZoomedViewOffset = parentWidth - viewContainerWidth; + + // The above values allow us to compute the term + // maxContentOffset / maxZoomedViewOffset + // which is the number of content pixels that we should move over by + // for every screen pixel that the zoomed view is moved over by. + // This allows a smooth transition from when the zoomed view is at the + // leftmost extent to when it is at the rightmost extent. + + // This is the offset in content pixels of the leftmost zoomed pixel + // visible in the zoomed view. This value is relative to the layerview + // edge. + float zoomedContentOffset = ((float)params.leftMargin) * maxContentOffset / maxZoomedViewOffset; + returnValue.x = (int)(zoomedContentOffset + (x / zoomFactor)); + + // Same comments here vertically + visibleContentPixels = viewHeight / zoomFactor; + maxContentOffset = parentHeight - visibleContentPixels; + maxZoomedViewOffset = parentHeight - (viewContainerHeight - toolbarHeight); + float zoomedAreaOffset = (float)params.topMargin + offsetDueToToolBarPosition - layerView.getSurfaceTranslation(); + zoomedContentOffset = zoomedAreaOffset * maxContentOffset / maxZoomedViewOffset; + returnValue.y = (int)(zoomedContentOffset + ((y - offsetDueToToolBarPosition) / zoomFactor)); + + return returnValue; + } + + /* + * A touch point (x,y) occurs in LayerView, this point should be displayed + * in the center of the zoomed view. The returned point is the position of + * the Top-Left zoomed view point on the screen device + */ + private PointF getZoomedViewTopLeftPositionFromTouchPosition(float x, float y) { + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + final float parentWidth = metrics.getWidth(); + final float parentHeight = metrics.getHeight(); + + // See comments in getUnzoomedPositionFromPointInZoomedView, but the + // transformations here are largely the reverse of that function. + + float visibleContentPixels = viewWidth / zoomFactor; + float maxContentOffset = parentWidth - visibleContentPixels; + float maxZoomedViewOffset = parentWidth - viewContainerWidth; + float contentPixelOffset = x - (visibleContentPixels / 2.0f); + returnValue.x = (int)(contentPixelOffset * (maxZoomedViewOffset / maxContentOffset)); + + visibleContentPixels = viewHeight / zoomFactor; + maxContentOffset = parentHeight - visibleContentPixels; + maxZoomedViewOffset = parentHeight - (viewContainerHeight - toolbarHeight); + contentPixelOffset = y - (visibleContentPixels / 2.0f); + float unscaledViewOffset = layerView.getSurfaceTranslation() - offsetDueToToolBarPosition; + returnValue.y = (int)((contentPixelOffset * (maxZoomedViewOffset / maxContentOffset)) + unscaledViewOffset); + + return returnValue; + } + + private void moveZoomedView(ImmutableViewportMetrics metrics, float newLeftMargin, float newTopMargin, + StartPointUpdate animateStartPoint) { + RelativeLayout.LayoutParams newLayoutParams = (RelativeLayout.LayoutParams) getLayoutParams(); + newLayoutParams.leftMargin = (int) newLeftMargin; + newLayoutParams.topMargin = (int) newTopMargin; + int topMarginMin = (int)(layerView.getSurfaceTranslation() + dynamicToolbarOverlap); + int topMarginMax = layerView.getHeight() - viewContainerHeight; + int leftMarginMin = 0; + int leftMarginMax = layerView.getWidth() - viewContainerWidth; + + if (newTopMargin < topMarginMin) { + newLayoutParams.topMargin = topMarginMin; + } else if (newTopMargin > topMarginMax) { + newLayoutParams.topMargin = topMarginMax; + } + + if (newLeftMargin < leftMarginMin) { + newLayoutParams.leftMargin = leftMarginMin; + } else if (newLeftMargin > leftMarginMax) { + newLayoutParams.leftMargin = leftMarginMax; + } + + if (newLayoutParams.topMargin < topMarginMin + 1) { + moveToolbar(false); + } else if (newLayoutParams.topMargin > topMarginMax - 1) { + moveToolbar(true); + } + + if (animateStartPoint == StartPointUpdate.GECKO_POSITION) { + // Before this point, the animationStart point is relative to the layerView. + // The value is initialized in startZoomDisplay using the click point position coming from Gecko. + // The position of the zoomed view is now calculated, so the position of the animation + // can now be correctly set relative to the zoomed view + animationStart.x = animationStart.x - newLayoutParams.leftMargin; + animationStart.y = animationStart.y - newLayoutParams.topMargin; + } else if (animateStartPoint == StartPointUpdate.CENTER) { + // At this point, the animationStart point is no more valid probably because + // the zoomed view has been moved by the user. + // In this case, the animationStart point is set to the center point of the zoomed view. + PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(viewContainerWidth / 2, viewContainerHeight / 2); + animationStart.x = convertedPosition.x - newLayoutParams.leftMargin; + animationStart.y = convertedPosition.y - newLayoutParams.topMargin; + } + + setLayoutParams(newLayoutParams); + PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(0, offsetDueToToolBarPosition); + lastPosition = PointUtils.round(convertedPosition); + requestZoomedViewRender(); + } + + private void moveToolbar(boolean moveTop) { + if (toolbarOnTop == moveTop) { + return; + } + toolbarOnTop = moveTop; + if (toolbarOnTop) { + offsetDueToToolBarPosition = toolbarHeight; + } else { + offsetDueToToolBarPosition = 0; + } + + RelativeLayout.LayoutParams p = (RelativeLayout.LayoutParams) zoomedImageView.getLayoutParams(); + RelativeLayout.LayoutParams pChangeZoomFactorButton = (RelativeLayout.LayoutParams) changeZoomFactorButton.getLayoutParams(); + RelativeLayout.LayoutParams pCloseButton = (RelativeLayout.LayoutParams) closeButton.getLayoutParams(); + + if (moveTop) { + p.addRule(RelativeLayout.BELOW, R.id.change_zoom_factor); + pChangeZoomFactorButton.addRule(RelativeLayout.BELOW, 0); + pCloseButton.addRule(RelativeLayout.BELOW, 0); + } else { + p.addRule(RelativeLayout.BELOW, 0); + pChangeZoomFactorButton.addRule(RelativeLayout.BELOW, R.id.zoomed_image_view); + pCloseButton.addRule(RelativeLayout.BELOW, R.id.zoomed_image_view); + } + pChangeZoomFactorButton.addRule(RelativeLayout.ALIGN_LEFT, R.id.zoomed_image_view); + pCloseButton.addRule(RelativeLayout.ALIGN_RIGHT, R.id.zoomed_image_view); + zoomedImageView.setLayoutParams(p); + changeZoomFactorButton.setLayoutParams(pChangeZoomFactorButton); + closeButton.setLayoutParams(pCloseButton); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // In case of orientation change, the zoomed view update is stopped until the orientation change + // is completed. At this time, the function onMetricsChanged is called and the + // zoomed view update is restarted again. + if (lastOrientation != newConfig.orientation) { + shouldBlockUpdate(true); + lastOrientation = newConfig.orientation; + } + } + + private void refreshZoomedViewSize(ImmutableViewportMetrics viewport) { + if (layerView == null) { + return; + } + + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) getLayoutParams(); + setCapturedSize(viewport); + moveZoomedView(viewport, params.leftMargin, params.topMargin, StartPointUpdate.NO_CHANGE); + } + + private void setCapturedSize(ImmutableViewportMetrics metrics) { + float parentMinSize = Math.min(metrics.getWidth(), metrics.getHeight()); + viewWidth = (int) ((parentMinSize * W_CAPTURED_VIEW_IN_PERCENT / (zoomFactor * 100.0)) * zoomFactor); + viewHeight = (int) ((parentMinSize * H_CAPTURED_VIEW_IN_PERCENT / (zoomFactor * 100.0)) * zoomFactor); + viewContainerHeight = viewHeight + toolbarHeight + + 2 * containterSize; // Top and bottom shadows + viewContainerWidth = viewWidth + + 2 * containterSize; // Right and left shadows + // Display in zoomedview is corrupted when width is an odd number + // More details about this issue here: bug 776906 comment 11 + viewWidth &= ~0x1; + } + + private void shouldBlockUpdate(boolean shouldBlockUpdate) { + stopUpdateView = shouldBlockUpdate; + } + + private Bitmap.Config getBitmapConfig() { + return (GeckoAppShell.getScreenDepth() == 24) ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565; + } + + private void updateUI() { + // onFinishInflate is not yet completed, the update of the UI will be done later + if (changeZoomFactorButton == null) { + return; + } + if (isSimplifiedUI) { + changeZoomFactorButton.setVisibility(View.INVISIBLE); + } else { + setTextInZoomFactorButton(zoomFactor); + changeZoomFactorButton.setVisibility(View.VISIBLE); + } + } + + private void getPrefs() { + prefObserver = new PrefsHelper.PrefHandlerBase() { + @Override + public void prefValue(String pref, boolean simplified) { + isSimplifiedUI = simplified; + if (simplified) { + zoomFactor = (float) defaultZoomFactor; + } else { + zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex]; + } + updateUI(); + } + + @Override + public void prefValue(String pref, int defaultZoomFactorFromSettings) { + defaultZoomFactor = defaultZoomFactorFromSettings; + if (isSimplifiedUI) { + zoomFactor = (float) defaultZoomFactor; + } else { + zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex]; + } + updateUI(); + } + }; + PrefsHelper.addObserver(new String[] { "ui.zoomedview.simplified", + "ui.zoomedview.defaultZoomFactor" }, + prefObserver); + } + + private void startZoomDisplay(LayerView aLayerView, final int leftFromGecko, final int topFromGecko) { + if (isBlockedFromAppearing) { + return; + } + if (layerView == null) { + layerView = aLayerView; + layerView.addZoomedViewListener(this); + layerView.getDynamicToolbarAnimator().addTranslationListener(this); + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + setCapturedSize(metrics); + } + startTimeReRender = 0; + shouldSetVisibleOnUpdate = true; + + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + // At this point, the start point is relative to the layerView. + // Later, it will be converted relative to the zoomed view as soon as + // the position of the zoomed view will be calculated. + animationStart.x = (float) leftFromGecko * metrics.zoomFactor; + animationStart.y = (float) topFromGecko * metrics.zoomFactor + layerView.getSurfaceTranslation(); + + moveUsingGeckoPosition(leftFromGecko, topFromGecko); + } + + public void stopZoomDisplay(boolean withAnimation) { + // If "startZoomDisplay" is running and not totally completed (Gecko thread is still + // running and "showZoomedView" has not yet been called), the zoomed view will be + // displayed after this call and it should not. + // Force the stop of the zoomed view, changing the shouldSetVisibleOnUpdate flag + // before the test of the visibility. + shouldSetVisibleOnUpdate = false; + if (getVisibility() == View.VISIBLE) { + hideZoomedView(withAnimation); + ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable); + if (layerView != null) { + layerView.getDynamicToolbarAnimator().removeTranslationListener(this); + layerView.removeZoomedViewListener(this); + layerView = null; + } + } + } + + private void changeZoomFactor(boolean zoomIn) { + if (zoomIn && currentZoomFactorIndex < ZOOM_FACTORS_LIST.length - 1) { + currentZoomFactorIndex++; + } else if (zoomIn && currentZoomFactorIndex >= ZOOM_FACTORS_LIST.length - 1) { + currentZoomFactorIndex = 0; + } else if (!zoomIn && currentZoomFactorIndex > 0) { + currentZoomFactorIndex--; + } else { + currentZoomFactorIndex = ZOOM_FACTORS_LIST.length - 1; + } + zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex]; + + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + refreshZoomedViewSize(metrics); + setTextInZoomFactorButton(zoomFactor); + } + + private void setTextInZoomFactorButton(float zoom) { + final String percentageValue = Integer.toString((int) (100 * zoom)); + changeZoomFactorButton.setText("- " + getResources().getString(R.string.percent, percentageValue) + " +"); + } + + @Override + public void handleMessage(final String event, final JSONObject message) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + try { + if (event.equals("Gesture:clusteredLinksClicked")) { + final JSONObject clickPosition = message.getJSONObject("clickPosition"); + int left = clickPosition.getInt("x"); + int top = clickPosition.getInt("y"); + // Start to display inside the zoomedView + LayerView geckoAppLayerView = GeckoAppShell.getLayerView(); + if (geckoAppLayerView != null) { + startZoomDisplay(geckoAppLayerView, left, top); + } + } else if (event.equals("Window:Resize")) { + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + refreshZoomedViewSize(metrics); + } else if (event.equals("Content:LocationChange")) { + stopZoomDisplay(false); + } else if (event.equals("Gesture:CloseZoomedView") || + event.equals("Browser:ZoomToPageWidth") || + event.equals("Browser:ZoomToRect")) { + stopZoomDisplay(true); + } else if (event.equals("FormAssist:AutoComplete")) { + isBlockedFromAppearing = true; + stopZoomDisplay(true); + } else if (event.equals("FormAssist:Hide")) { + isBlockedFromAppearing = false; + } + } catch (JSONException e) { + Log.e(LOGTAG, "JSON exception", e); + } + } + }); + } + + private void moveUsingGeckoPosition(int leftFromGecko, int topFromGecko) { + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + final float parentHeight = metrics.getHeight(); + // moveToolbar is called before getZoomedViewTopLeftPositionFromTouchPosition in order to + // correctly center vertically the zoomed area + moveToolbar((topFromGecko * metrics.zoomFactor > parentHeight / 2)); + PointF convertedPosition = getZoomedViewTopLeftPositionFromTouchPosition((leftFromGecko * metrics.zoomFactor), + (topFromGecko * metrics.zoomFactor)); + moveZoomedView(metrics, convertedPosition.x, convertedPosition.y, StartPointUpdate.GECKO_POSITION); + } + + @Override + public void onTranslationChanged(float aToolbarTranslation, float aLayerViewTranslation) { + ThreadUtils.assertOnUiThread(); + if (layerView != null) { + dynamicToolbarOverlap = aLayerViewTranslation - aToolbarTranslation; + refreshZoomedViewSize(layerView.getViewportMetrics()); + } + } + + @Override + public void onMetricsChanged(final ImmutableViewportMetrics viewport) { + // It can be called from a Gecko thread (forceViewportMetrics in GeckoLayerClient). + // Post to UI Thread to avoid Exception: + // "Only the original thread that created a view hierarchy can touch its views." + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + shouldBlockUpdate(false); + refreshZoomedViewSize(viewport); + } + }); + } + + @Override + public void onPanZoomStopped() { + } + + @Override + public void updateView(ByteBuffer data) { + final Bitmap sb3 = Bitmap.createBitmap(viewWidth, viewHeight, getBitmapConfig()); + if (sb3 != null) { + data.rewind(); + try { + sb3.copyPixelsFromBuffer(data); + } catch (Exception iae) { + Log.w(LOGTAG, iae.toString()); + } + if (zoomedImageView != null) { + RoundedBitmapDrawable ob3 = new RoundedBitmapDrawable(getResources(), sb3, toolbarOnTop, cornerRadius); + zoomedImageView.setImageDrawable(ob3); + } + } + if (shouldSetVisibleOnUpdate) { + this.showZoomedView(); + } + lastStartTimeReRender = startTimeReRender; + startTimeReRender = 0; + } + + private void showZoomedView() { + // no animation if the zoomed view is already visible + if (getVisibility() != View.VISIBLE) { + final Animation anim = new ScaleAnimation( + 0f, 1f, // Start and end values for the X axis scaling + 0f, 1f, // Start and end values for the Y axis scaling + Animation.ABSOLUTE, animationStart.x, // Pivot point of X scaling + Animation.ABSOLUTE, animationStart.y); // Pivot point of Y scaling + anim.setFillAfter(true); // Needed to keep the result of the animation + anim.setDuration(OPENING_ANIMATION_DURATION_MS); + anim.setInterpolator(new OvershootInterpolator(OVERSHOOT_INTERPOLATOR_TENSION)); + anim.setAnimationListener(new AnimationListener() { + public void onAnimationEnd(Animation animation) { + setListeners(); + } + public void onAnimationRepeat(Animation animation) { + } + public void onAnimationStart(Animation animation) { + removeListeners(); + } + }); + setAnimation(anim); + } + setVisibility(View.VISIBLE); + shouldSetVisibleOnUpdate = false; + } + + private void hideZoomedView(boolean withAnimation) { + if (withAnimation) { + final Animation anim = new ScaleAnimation( + 1f, 0f, // Start and end values for the X axis scaling + 1f, 0f, // Start and end values for the Y axis scaling + Animation.ABSOLUTE, animationStart.x, // Pivot point of X scaling + Animation.ABSOLUTE, animationStart.y); // Pivot point of Y scaling + anim.setFillAfter(true); // Needed to keep the result of the animation + anim.setDuration(CLOSING_ANIMATION_DURATION_MS); + anim.setAnimationListener(new AnimationListener() { + public void onAnimationEnd(Animation animation) { + } + public void onAnimationRepeat(Animation animation) { + } + public void onAnimationStart(Animation animation) { + removeListeners(); + } + }); + setAnimation(anim); + } else { + removeListeners(); + setAnimation(null); + } + setVisibility(View.GONE); + shouldSetVisibleOnUpdate = false; + } + + private void updateBufferSize() { + int pixelSize = (GeckoAppShell.getScreenDepth() == 24) ? 4 : 2; + int capacity = viewWidth * viewHeight * pixelSize; + if (buffer == null || buffer.capacity() != capacity) { + buffer = DirectBufferAllocator.free(buffer); + buffer = DirectBufferAllocator.allocate(capacity); + } + } + + private boolean isRendering() { + return (startTimeReRender != 0); + } + + private boolean renderFrequencyTooHigh() { + return ((System.nanoTime() - lastStartTimeReRender) < MINIMUM_DELAY_BETWEEN_TWO_RENDER_CALLS_NS); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void requestZoomedViewData(ByteBuffer buffer, int tabId, + int xPos, int yPos, int width, + int height, float scale); + + @Override + public void requestZoomedViewRender() { + if (stopUpdateView) { + return; + } + // remove pending runnable + ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable); + + // "requestZoomedViewRender" can be called very often by Gecko (endDrawing in LayerRender) without + // any thing changed in the zoomed area (useless calls from the "zoomed area" point of view). + // "requestZoomedViewRender" can take time to re-render the zoomed view, it depends of the complexity + // of the html on this area. + // To avoid to slow down the application, the 2 following cases are tested: + + // 1- Last render is still running, plan another render later. + if (isRendering()) { + // post a new runnable DELAY_BEFORE_NEXT_RENDER_REQUEST_MS later + // We need to post with a delay to be sure that the last call to requestZoomedViewRender will be done. + // For a static html page WITHOUT any animation/video, there is a last call to endDrawing and we need to make + // the zoomed render on this last call. + ThreadUtils.postDelayedToUiThread(requestRenderRunnable, DELAY_BEFORE_NEXT_RENDER_REQUEST_MS); + return; + } + + // 2- Current render occurs too early, plan another render later. + if (renderFrequencyTooHigh()) { + // post a new runnable DELAY_BEFORE_NEXT_RENDER_REQUEST_MS later + // We need to post with a delay to be sure that the last call to requestZoomedViewRender will be done. + // For a page WITH animation/video, the animation/video can be stopped, and we need to make + // the zoomed render on this last call. + ThreadUtils.postDelayedToUiThread(requestRenderRunnable, DELAY_BEFORE_NEXT_RENDER_REQUEST_MS); + return; + } + + startTimeReRender = System.nanoTime(); + // Allocate the buffer if it's the first call. + // Change the buffer size if it's not the right size. + updateBufferSize(); + + int tabId = Tabs.getInstance().getSelectedTab().getId(); + + ImmutableViewportMetrics metrics = layerView.getViewportMetrics(); + PointF origin = metrics.getOrigin(); + + final int xPos = (int)origin.x + lastPosition.x; + final int yPos = (int)origin.y + lastPosition.y; + + requestZoomedViewData(buffer, tabId, xPos, yPos, viewWidth, viewHeight, + zoomFactor * metrics.zoomFactor); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java new file mode 100644 index 000000000..d1c3f5916 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java @@ -0,0 +1,149 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.activitystream; + +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.text.TextUtils; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.publicsuffix.PublicSuffix; + +import java.util.Arrays; +import java.util.List; + +public class ActivityStream { + /** + * List of undesired prefixes for labels based on a URL. + * + * This list is by no means complete and is based on those sources: + * - https://gist.github.com/nchapman/36502ad115e8825d522a66549971a3f0 + * - https://github.com/mozilla/activity-stream/issues/1311 + */ + private static final List<String> UNDESIRED_LABEL_PREFIXES = Arrays.asList( + "index.", + "home." + ); + + /** + * Undesired labels for labels based on a URL. + * + * This list is by no means complete and is based on those sources: + * - https://gist.github.com/nchapman/36502ad115e8825d522a66549971a3f0 + * - https://github.com/mozilla/activity-stream/issues/1311 + */ + private static final List<String> UNDESIRED_LABELS = Arrays.asList( + "render", + "login", + "edit" + ); + + public static boolean isEnabled(Context context) { + if (!isUserEligible(context)) { + // If the user is not eligible then disable activity stream. Even if it has been + // enabled before. + return false; + } + + return GeckoSharedPrefs.forApp(context) + .getBoolean(GeckoPreferences.PREFS_ACTIVITY_STREAM, false); + } + + /** + * Is the user eligible to use activity stream or should we hide it from settings etc.? + */ + public static boolean isUserEligible(Context context) { + if (AppConstants.MOZ_ANDROID_ACTIVITY_STREAM) { + // If the build flag is enabled then just show the option to the user. + return true; + } + + if (AppConstants.NIGHTLY_BUILD && SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM)) { + // If this is a nightly build and the user is part of the activity stream experiment then + // the option should be visible too. The experiment is limited to Nightly too but I want + // to make really sure that this isn't riding the trains accidentally. + return true; + } + + // For everyone else activity stream is not available yet. + return false; + } + + /** + * Query whether we want to display Activity Stream as a Home Panel (within the HomePager), + * or as a HomePager replacement. + */ + public static boolean isHomePanel() { + return true; + } + + /** + * Extract a label from a URL to use in Activity Stream. + * + * This method implements the proposal from this desktop AS issue: + * https://github.com/mozilla/activity-stream/issues/1311 + * + * @param usePath Use the path of the URL to extract a label (if suitable) + */ + public static void extractLabel(final Context context, final String url, final boolean usePath, final LabelCallback callback) { + new AsyncTask<Void, Void, String>() { + @Override + protected String doInBackground(Void... params) { + if (TextUtils.isEmpty(url)) { + return ""; + } + + final Uri uri = Uri.parse(url); + + // Use last path segment if suitable + if (usePath) { + final String segment = uri.getLastPathSegment(); + if (!TextUtils.isEmpty(segment) + && !UNDESIRED_LABELS.contains(segment) + && !segment.matches("^[0-9]+$")) { + + boolean hasUndesiredPrefix = false; + for (int i = 0; i < UNDESIRED_LABEL_PREFIXES.size(); i++) { + if (segment.startsWith(UNDESIRED_LABEL_PREFIXES.get(i))) { + hasUndesiredPrefix = true; + break; + } + } + + if (!hasUndesiredPrefix) { + return segment; + } + } + } + + // If no usable path segment was found then use the host without public suffix and common subdomains + final String host = uri.getHost(); + if (TextUtils.isEmpty(host)) { + return url; + } + + return StringUtils.stripCommonSubdomains( + PublicSuffix.stripPublicSuffix(context, host)); + } + + @Override + protected void onPostExecute(String label) { + callback.onLabelExtracted(label); + } + }.execute(); + } + + public abstract static class LabelCallback { + public abstract void onLabelExtracted(String label); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java new file mode 100644 index 000000000..aee0bba63 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java @@ -0,0 +1,52 @@ +package org.mozilla.gecko.adjust; + +import android.content.SharedPreferences; +import android.os.Bundle; + +import org.mozilla.gecko.AdjustConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.delegates.BrowserAppDelegate; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.IntentUtils; + +public class AdjustBrowserAppDelegate extends BrowserAppDelegate { + private final AdjustHelperInterface adjustHelper; + private final AttributionHelperListener attributionHelperListener; + + public AdjustBrowserAppDelegate(AttributionHelperListener attributionHelperListener) { + this.adjustHelper = AdjustConstants.getAdjustHelper(); + this.attributionHelperListener = attributionHelperListener; + } + + @Override + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + adjustHelper.onCreate(browserApp, + AdjustConstants.MOZ_INSTALL_TRACKING_ADJUST_SDK_APP_TOKEN, + attributionHelperListener); + + final boolean isInAutomation = IntentUtils.getIsInAutomationFromEnvironment( + new SafeIntent(browserApp.getIntent())); + + final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp); + + // Adjust stores enabled state so this is only necessary because users may have set + // their data preferences before this feature was implemented and we need to respect + // those before upload can occur in Adjust.onResume. + adjustHelper.setEnabled(!isInAutomation + && prefs.getBoolean(GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true)); + } + + @Override + public void onResume(BrowserApp browserApp) { + // Needed for Adjust to get accurate session measurements + adjustHelper.onResume(); + } + + @Override + public void onPause(BrowserApp browserApp) { + // Needed for Adjust to get accurate session measurements + adjustHelper.onPause(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java new file mode 100644 index 000000000..19399e735 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java @@ -0,0 +1,75 @@ +/* -*- 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.adjust; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.adjust.sdk.Adjust; +import com.adjust.sdk.AdjustAttribution; +import com.adjust.sdk.AdjustConfig; +import com.adjust.sdk.AdjustReferrerReceiver; +import com.adjust.sdk.LogLevel; +import com.adjust.sdk.OnAttributionChangedListener; + +import org.mozilla.gecko.AppConstants; + +public class AdjustHelper implements AdjustHelperInterface, OnAttributionChangedListener { + + private static final String LOGTAG = AdjustHelper.class.getSimpleName(); + private AttributionHelperListener attributionListener; + + public void onCreate(final Context context, final String maybeAppToken, final AttributionHelperListener listener) { + final String environment; + final LogLevel logLevel; + if (AppConstants.MOZILLA_OFFICIAL) { + environment = AdjustConfig.ENVIRONMENT_PRODUCTION; + logLevel = LogLevel.WARN; + } else { + environment = AdjustConfig.ENVIRONMENT_SANDBOX; + logLevel = LogLevel.VERBOSE; + } + if (maybeAppToken == null) { + // We've got install tracking turned on -- we better have a token! + throw new IllegalArgumentException("maybeAppToken must not be null"); + } + attributionListener = listener; + AdjustConfig config = new AdjustConfig(context, maybeAppToken, environment); + config.setLogLevel(logLevel); + config.setOnAttributionChangedListener(this); + Adjust.onCreate(config); + } + + public void onPause() { + Adjust.onPause(); + } + + public void onResume() { + Adjust.onResume(); + } + + public void setEnabled(final boolean isEnabled) { + Adjust.setEnabled(isEnabled); + } + + public void onReceive(final Context context, final Intent intent) { + new AdjustReferrerReceiver().onReceive(context, intent); + } + + @Override + public void onAttributionChanged(AdjustAttribution attribution) { + if (attributionListener == null) { + throw new IllegalStateException("Expected non-null attribution listener."); + } + + if (attribution == null) { + Log.e(LOGTAG, "Adjust attribution is null; skipping campaign id retrieval."); + return; + } + attributionListener.onCampaignIdChanged(attribution.campaign); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java new file mode 100644 index 000000000..aeb7b4334 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java @@ -0,0 +1,22 @@ +/* -*- 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.adjust; + +import android.content.Context; +import android.content.Intent; + +public interface AdjustHelperInterface { + /** + * Register the Application with the Adjust SDK. + * @param appToken the (secret!) Adjust SDK per-application token to register with; may be null. + */ + void onCreate(final Context context, final String appToken, final AttributionHelperListener listener); + void onPause(); + void onResume(); + + void setEnabled(final boolean isEnabled); + void onReceive(final Context context, final Intent intent); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java new file mode 100644 index 000000000..6dadd2261 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java @@ -0,0 +1,17 @@ +/* -*- 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.adjust; + +/** + * Because of how our build module dependencies are structured, we aren't able to use + * the {@link com.adjust.sdk.OnAttributionChangedListener} directly outside of {@link AdjustHelper}. + * If the Adjust SDK is enabled, this listener should be notified when {@link com.adjust.sdk.OnAttributionChangedListener} + * is fired (i.e. this listener would be daisy-chained to the Adjust one). The listener also + * inherits thread-safety from GeckoSharedPrefs which is used to store the campaign ID. + */ +public interface AttributionHelperListener { + void onCampaignIdChanged(String campaignId); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java b/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java new file mode 100644 index 000000000..ddfed84bd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java @@ -0,0 +1,31 @@ +/* -*- 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.adjust; + +import android.content.Context; +import android.content.Intent; + +public class StubAdjustHelper implements AdjustHelperInterface { + public void onCreate(final Context context, final String appToken, final AttributionHelperListener listener) { + // Do nothing. + } + + public void onPause() { + // Do nothing. + } + + public void onResume() { + // Do nothing. + } + + public void setEnabled(final boolean isEnabled) { + // Do nothing. + } + + public void onReceive(final Context context, final Intent intent) { + // Do nothing. + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java b/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java new file mode 100644 index 000000000..63e8e168e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java @@ -0,0 +1,21 @@ +/* -*- 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.animation; + +import android.content.Context; + +public class AnimationUtils { + private static long mShortDuration = -1; + + public static long getShortDuration(Context context) { + if (mShortDuration < 0) { + mShortDuration = context.getResources().getInteger(android.R.integer.config_shortAnimTime); + } + return mShortDuration; + } +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java b/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java new file mode 100644 index 000000000..bf8007bbf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java @@ -0,0 +1,27 @@ +/* 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.animation; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +public class HeightChangeAnimation extends Animation { + int mFromHeight; + int mToHeight; + View mView; + + public HeightChangeAnimation(View view, int fromHeight, int toHeight) { + mView = view; + mFromHeight = fromHeight; + mToHeight = toHeight; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mView.getLayoutParams().height = Math.round((mFromHeight * (1 - interpolatedTime)) + (mToHeight * interpolatedTime)); + mView.requestLayout(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java b/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java new file mode 100644 index 000000000..dc2403bbd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java @@ -0,0 +1,342 @@ +/* -*- 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.animation; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.os.Handler; +import android.support.v4.view.ViewCompat; +import android.view.Choreographer; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.AnimationUtils; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; + +public class PropertyAnimator implements Runnable { + private static final String LOGTAG = "GeckoPropertyAnimator"; + + public static enum Property { + ALPHA, + TRANSLATION_X, + TRANSLATION_Y, + SCROLL_X, + SCROLL_Y, + WIDTH, + HEIGHT + } + + private class ElementHolder { + View view; + Property property; + float from; + float to; + } + + public static interface PropertyAnimationListener { + public void onPropertyAnimationStart(); + public void onPropertyAnimationEnd(); + } + + private final Interpolator mInterpolator; + private long mStartTime; + private final long mDuration; + private final float mDurationReciprocal; + private final List<ElementHolder> mElementsList; + private List<PropertyAnimationListener> mListeners; + FramePoster mFramePoster; + private boolean mUseHardwareLayer; + + public PropertyAnimator(long duration) { + this(duration, new DecelerateInterpolator()); + } + + public PropertyAnimator(long duration, Interpolator interpolator) { + mDuration = duration; + mDurationReciprocal = 1.0f / mDuration; + mInterpolator = interpolator; + mElementsList = new ArrayList<ElementHolder>(); + mFramePoster = FramePoster.create(this); + mUseHardwareLayer = true; + } + + public void setUseHardwareLayer(boolean useHardwareLayer) { + mUseHardwareLayer = useHardwareLayer; + } + + public void attach(View view, Property property, float to) { + ElementHolder element = new ElementHolder(); + + element.view = view; + element.property = property; + element.to = to; + + mElementsList.add(element); + } + + public void addPropertyAnimationListener(PropertyAnimationListener listener) { + if (mListeners == null) { + mListeners = new ArrayList<PropertyAnimationListener>(); + } + + mListeners.add(listener); + } + + public long getDuration() { + return mDuration; + } + + public long getRemainingTime() { + int timePassed = (int) (AnimationUtils.currentAnimationTimeMillis() - mStartTime); + return mDuration - timePassed; + } + + @Override + public void run() { + int timePassed = (int) (AnimationUtils.currentAnimationTimeMillis() - mStartTime); + if (timePassed >= mDuration) { + stop(); + return; + } + + float interpolation = mInterpolator.getInterpolation(timePassed * mDurationReciprocal); + + for (ElementHolder element : mElementsList) { + float delta = element.from + ((element.to - element.from) * interpolation); + invalidate(element, delta); + } + + mFramePoster.postNextAnimationFrame(); + } + + public void start() { + if (mDuration == 0) { + return; + } + + mStartTime = AnimationUtils.currentAnimationTimeMillis(); + + // Fix the from value based on current position and property + for (ElementHolder element : mElementsList) { + if (element.property == Property.ALPHA) + element.from = ViewHelper.getAlpha(element.view); + else if (element.property == Property.TRANSLATION_Y) + element.from = ViewHelper.getTranslationY(element.view); + else if (element.property == Property.TRANSLATION_X) + element.from = ViewHelper.getTranslationX(element.view); + else if (element.property == Property.SCROLL_Y) + element.from = ViewHelper.getScrollY(element.view); + else if (element.property == Property.SCROLL_X) + element.from = ViewHelper.getScrollX(element.view); + else if (element.property == Property.WIDTH) + element.from = ViewHelper.getWidth(element.view); + else if (element.property == Property.HEIGHT) + element.from = ViewHelper.getHeight(element.view); + + ViewCompat.setHasTransientState(element.view, true); + + if (shouldEnableHardwareLayer(element)) + element.view.setLayerType(View.LAYER_TYPE_HARDWARE, null); + else + element.view.setDrawingCacheEnabled(true); + } + + // Get ViewTreeObserver from any of the participant views + // in the animation. + final ViewTreeObserver treeObserver; + if (mElementsList.size() > 0) { + treeObserver = mElementsList.get(0).view.getViewTreeObserver(); + } else { + treeObserver = null; + } + + final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (treeObserver.isAlive()) { + treeObserver.removeOnPreDrawListener(this); + } + + mFramePoster.postFirstAnimationFrame(); + return true; + } + }; + + // Try to start animation after any on-going layout round + // in the current view tree. OnPreDrawListener seems broken + // on pre-Honeycomb devices, start animation immediatelly + // in this case. + if (treeObserver != null && treeObserver.isAlive()) { + treeObserver.addOnPreDrawListener(preDrawListener); + } else { + mFramePoster.postFirstAnimationFrame(); + } + + if (mListeners != null) { + for (PropertyAnimationListener listener : mListeners) { + listener.onPropertyAnimationStart(); + } + } + } + + /** + * Stop the animation, optionally snapping to the end position. + * onPropertyAnimationEnd is only called when snapping to the end position. + */ + public void stop(boolean snapToEndPosition) { + mFramePoster.cancelAnimationFrame(); + + // Make sure to snap to the end position. + for (ElementHolder element : mElementsList) { + if (snapToEndPosition) + invalidate(element, element.to); + + ViewCompat.setHasTransientState(element.view, false); + + if (shouldEnableHardwareLayer(element)) { + element.view.setLayerType(View.LAYER_TYPE_NONE, null); + } else { + element.view.setDrawingCacheEnabled(false); + } + } + + mElementsList.clear(); + + if (mListeners != null) { + if (snapToEndPosition) { + for (PropertyAnimationListener listener : mListeners) { + listener.onPropertyAnimationEnd(); + } + } + + mListeners.clear(); + mListeners = null; + } + } + + public void stop() { + stop(true); + } + + private boolean shouldEnableHardwareLayer(ElementHolder element) { + if (!mUseHardwareLayer) { + return false; + } + + if (!(element.view instanceof ViewGroup)) { + return false; + } + + if (element.property == Property.ALPHA || + element.property == Property.TRANSLATION_Y || + element.property == Property.TRANSLATION_X) { + return true; + } + + return false; + } + + private void invalidate(final ElementHolder element, final float delta) { + final View view = element.view; + + // check to see if the view was detached between the check above and this code + // getting run on the UI thread. + if (view.getHandler() == null) + return; + + if (element.property == Property.ALPHA) + ViewHelper.setAlpha(element.view, delta); + else if (element.property == Property.TRANSLATION_Y) + ViewHelper.setTranslationY(element.view, delta); + else if (element.property == Property.TRANSLATION_X) + ViewHelper.setTranslationX(element.view, delta); + else if (element.property == Property.SCROLL_Y) + ViewHelper.scrollTo(element.view, ViewHelper.getScrollX(element.view), (int) delta); + else if (element.property == Property.SCROLL_X) + ViewHelper.scrollTo(element.view, (int) delta, ViewHelper.getScrollY(element.view)); + else if (element.property == Property.WIDTH) + ViewHelper.setWidth(element.view, (int) delta); + else if (element.property == Property.HEIGHT) + ViewHelper.setHeight(element.view, (int) delta); + } + + private static abstract class FramePoster { + public static FramePoster create(Runnable r) { + if (Versions.feature16Plus) { + return new FramePosterPostJB(r); + } + + return new FramePosterPreJB(r); + } + + public abstract void postFirstAnimationFrame(); + public abstract void postNextAnimationFrame(); + public abstract void cancelAnimationFrame(); + } + + private static class FramePosterPreJB extends FramePoster { + // Default refresh rate in ms. + private static final int INTERVAL = 10; + + private final Handler mHandler; + private final Runnable mRunnable; + + public FramePosterPreJB(Runnable r) { + mHandler = new Handler(); + mRunnable = r; + } + + @Override + public void postFirstAnimationFrame() { + mHandler.post(mRunnable); + } + + @Override + public void postNextAnimationFrame() { + mHandler.postDelayed(mRunnable, INTERVAL); + } + + @Override + public void cancelAnimationFrame() { + mHandler.removeCallbacks(mRunnable); + } + } + + private static class FramePosterPostJB extends FramePoster { + private final Choreographer mChoreographer; + private final Choreographer.FrameCallback mCallback; + + public FramePosterPostJB(final Runnable r) { + mChoreographer = Choreographer.getInstance(); + + mCallback = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + r.run(); + } + }; + } + + @Override + public void postFirstAnimationFrame() { + postNextAnimationFrame(); + } + + @Override + public void postNextAnimationFrame() { + mChoreographer.postFrameCallback(mCallback); + } + + @Override + public void cancelAnimationFrame() { + mChoreographer.removeFrameCallback(mCallback); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java b/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java new file mode 100644 index 000000000..7e8377f55 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.animation; + +import android.view.animation.Animation; +import android.view.animation.Transformation; + +import android.graphics.Camera; +import android.graphics.Matrix; + +/** + * An animation that rotates the view on the Y axis between two specified angles. + * This animation also adds a translation on the Z axis (depth) to improve the effect. + */ +public class Rotate3DAnimation extends Animation { + private final float mFromDegrees; + private final float mToDegrees; + + private final float mCenterX; + private final float mCenterY; + + private final float mDepthZ; + private final boolean mReverse; + private Camera mCamera; + + private int mWidth = 1; + private int mHeight = 1; + + /** + * Creates a new 3D rotation on the Y axis. The rotation is defined by its + * start angle and its end angle. Both angles are in degrees. The rotation + * is performed around a center point on the 2D space, defined by a pair + * of X and Y coordinates, called centerX and centerY. When the animation + * starts, a translation on the Z axis (depth) is performed. The length + * of the translation can be specified, as well as whether the translation + * should be reversed in time. + * + * @param fromDegrees the start angle of the 3D rotation + * @param toDegrees the end angle of the 3D rotation + * @param centerX the X center of the 3D rotation + * @param centerY the Y center of the 3D rotation + * @param reverse true if the translation should be reversed, false otherwise + */ + public Rotate3DAnimation(float fromDegrees, float toDegrees, + float centerX, float centerY, float depthZ, boolean reverse) { + mFromDegrees = fromDegrees; + mToDegrees = toDegrees; + mCenterX = centerX; + mCenterY = centerY; + mDepthZ = depthZ; + mReverse = reverse; + } + + @Override + public void initialize(int width, int height, int parentWidth, int parentHeight) { + super.initialize(width, height, parentWidth, parentHeight); + mCamera = new Camera(); + mWidth = width; + mHeight = height; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + final float fromDegrees = mFromDegrees; + float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime); + + final Camera camera = mCamera; + final Matrix matrix = t.getMatrix(); + + camera.save(); + if (mReverse) { + camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime); + } else { + camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime)); + } + camera.rotateX(degrees); + camera.getMatrix(matrix); + camera.restore(); + + matrix.preTranslate(-mCenterX * mWidth, -mCenterY * mHeight); + matrix.postTranslate(mCenterX * mWidth, mCenterY * mHeight); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java b/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java new file mode 100644 index 000000000..3ea2e8437 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java @@ -0,0 +1,109 @@ +/* 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.animation; + +import android.view.View; +import android.view.ViewGroup; + +public final class ViewHelper { + private ViewHelper() { + } + + public static float getTranslationX(View view) { + if (view != null) { + return view.getTranslationX(); + } + + return 0; + } + + public static void setTranslationX(View view, float translationX) { + if (view != null) { + view.setTranslationX(translationX); + } + } + + public static float getTranslationY(View view) { + if (view != null) { + return view.getTranslationY(); + } + + return 0; + } + + public static void setTranslationY(View view, float translationY) { + if (view != null) { + view.setTranslationY(translationY); + } + } + + public static float getAlpha(View view) { + if (view != null) { + return view.getAlpha(); + } + + return 1; + } + + public static void setAlpha(View view, float alpha) { + if (view != null) { + view.setAlpha(alpha); + } + } + + public static int getWidth(View view) { + if (view != null) { + return view.getWidth(); + } + + return 0; + } + + public static void setWidth(View view, int width) { + if (view != null) { + ViewGroup.LayoutParams lp = view.getLayoutParams(); + lp.width = width; + view.setLayoutParams(lp); + } + } + + public static int getHeight(View view) { + if (view != null) { + return view.getHeight(); + } + + return 0; + } + + public static void setHeight(View view, int height) { + if (view != null) { + ViewGroup.LayoutParams lp = view.getLayoutParams(); + lp.height = height; + view.setLayoutParams(lp); + } + } + + public static int getScrollX(View view) { + if (view != null) { + return view.getScrollX(); + } + + return 0; + } + + public static int getScrollY(View view) { + if (view != null) { + return view.getScrollY(); + } + + return 0; + } + + public static void scrollTo(View view, int scrollX, int scrollY) { + if (view != null) { + view.scrollTo(scrollX, scrollY); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java new file mode 100644 index 000000000..447b837e8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java @@ -0,0 +1,81 @@ +/* + * 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.cleanup; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.support.annotation.VisibleForTesting; + +import java.io.File; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * Encapsulates the code to run the {@link FileCleanupService}. Call + * {@link #startIfReady(Context, SharedPreferences, String)} to start the clean-up. + * + * Note: for simplicity, the current implementation does not cache which + * files have been cleaned up and will attempt to delete the same files + * each time it is run. If the file deletion list grows large, consider + * keeping a cache. + */ +public class FileCleanupController { + + private static final long MILLIS_BETWEEN_CLEANUPS = TimeUnit.DAYS.toMillis(7); + @VisibleForTesting static final String PREF_LAST_CLEANUP_MILLIS = "cleanup.lastFileCleanupMillis"; + + // These will be prepended with the path of the profile we're cleaning up. + private static final String[] PROFILE_FILES_TO_CLEANUP = new String[] { + "health.db", + "health.db-journal", + "health.db-shm", + "health.db-wal", + }; + + /** + * Starts the clean-up if it's time to clean-up, otherwise returns. For simplicity, + * it does not schedule the cleanup for some point in the future - this method will + * have to be called again (i.e. polled) in order to run the clean-up service. + * + * @param context Context of the calling {@link android.app.Activity} + * @param sharedPrefs The {@link SharedPreferences} instance to store the controller state to + * @param profilePath The path to the profile the service should clean-up files from + */ + public static void startIfReady(final Context context, final SharedPreferences sharedPrefs, final String profilePath) { + if (!isCleanupReady(sharedPrefs)) { + return; + } + + recordCleanupScheduled(sharedPrefs); + + final Intent fileCleanupIntent = new Intent(context, FileCleanupService.class); + fileCleanupIntent.setAction(FileCleanupService.ACTION_DELETE_FILES); + fileCleanupIntent.putExtra(FileCleanupService.EXTRA_FILE_PATHS_TO_DELETE, getFilesToCleanup(profilePath + "/")); + context.startService(fileCleanupIntent); + } + + private static boolean isCleanupReady(final SharedPreferences sharedPrefs) { + final long lastCleanupMillis = sharedPrefs.getLong(PREF_LAST_CLEANUP_MILLIS, -1); + return lastCleanupMillis + MILLIS_BETWEEN_CLEANUPS < System.currentTimeMillis(); + } + + private static void recordCleanupScheduled(final SharedPreferences sharedPrefs) { + final SharedPreferences.Editor editor = sharedPrefs.edit(); + editor.putLong(PREF_LAST_CLEANUP_MILLIS, System.currentTimeMillis()).apply(); + } + + @VisibleForTesting + static ArrayList<String> getFilesToCleanup(final String profilePath) { + final ArrayList<String> out = new ArrayList<>(PROFILE_FILES_TO_CLEANUP.length); + for (final String path : PROFILE_FILES_TO_CLEANUP) { + // Append a file separator, just in-case the caller didn't include one. + out.add(profilePath + File.separator + path); + } + return out; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java new file mode 100644 index 000000000..76aff733a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java @@ -0,0 +1,80 @@ +/* + * 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.cleanup; + +import android.app.IntentService; +import android.content.Intent; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; + +/** + * An IntentService to delete files. + * + * It takes an {@link ArrayList} of String file paths to delete via the extra + * {@link #EXTRA_FILE_PATHS_TO_DELETE}. If these file paths are directories, they will + * not be traversed recursively and will only be deleted if empty. This is to avoid accidentally + * trashing a users' profile if a folder is accidentally listed. + * + * An IntentService was chosen because: + * * It generally won't be killed when the Activity is + * * (unlike HandlerThread) The system handles scheduling, prioritizing, + * and shutting down the underlying background thread + * * (unlike an existing background thread) We don't block our background operations + * for this, which doesn't directly affect the user. + * + * The major trade-off is that this Service is very dangerous if it's exported... so don't do that! + */ +public class FileCleanupService extends IntentService { + private static final String LOGTAG = "Gecko" + FileCleanupService.class.getSimpleName(); + private static final String WORKER_THREAD_NAME = LOGTAG + "Worker"; + + public static final String ACTION_DELETE_FILES = "org.mozilla.gecko.intent.action.DELETE_FILES"; + public static final String EXTRA_FILE_PATHS_TO_DELETE = "org.mozilla.gecko.file_paths_to_delete"; + + public FileCleanupService() { + super(WORKER_THREAD_NAME); + + // We're likely to get scheduled again - let's wait until then in order to avoid: + // * The coding complexity of re-running this + // * Consuming system resources: we were probably killed for resource conservation purposes + setIntentRedelivery(false); + } + + @Override + protected void onHandleIntent(final Intent intent) { + if (!isIntentValid(intent)) { + return; + } + + final ArrayList<String> filesToDelete = intent.getStringArrayListExtra(EXTRA_FILE_PATHS_TO_DELETE); + for (final String path : filesToDelete) { + final File file = new File(path); + file.delete(); + } + } + + private static boolean isIntentValid(final Intent intent) { + if (intent == null) { + Log.w(LOGTAG, "Received null intent"); + return false; + } + + if (!intent.getAction().equals(ACTION_DELETE_FILES)) { + Log.w(LOGTAG, "Received unknown intent action: " + intent.getAction()); + return false; + } + + if (!intent.hasExtra(EXTRA_FILE_PATHS_TO_DELETE)) { + Log.w(LOGTAG, "Received intent with no files extra"); + return false; + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java b/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java new file mode 100644 index 000000000..b1bf567b0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java @@ -0,0 +1,177 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.customtabs; + +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.text.TextUtils; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.TextView; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.util.ColorUtil; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import java.lang.reflect.Field; + +import static android.support.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR; + +public class CustomTabsActivity extends GeckoApp implements Tabs.OnTabsChangedListener { + private static final String LOGTAG = "CustomTabsActivity"; + private static final String SAVED_TOOLBAR_COLOR = "SavedToolbarColor"; + private static final String SAVED_TOOLBAR_TITLE = "SavedToolbarTitle"; + private static final int NO_COLOR = -1; + private Toolbar toolbar; + + private ActionBar actionBar; + private int tabId = -1; + private boolean useDomainTitle = true; + + private int toolbarColor; + private String toolbarTitle; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState != null) { + toolbarColor = savedInstanceState.getInt(SAVED_TOOLBAR_COLOR, NO_COLOR); + toolbarTitle = savedInstanceState.getString(SAVED_TOOLBAR_TITLE, AppConstants.MOZ_APP_BASENAME); + } else { + toolbarColor = NO_COLOR; + toolbarTitle = AppConstants.MOZ_APP_BASENAME; + } + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + updateActionBarWithToolbar(toolbar); + try { + // Since we don't create the Toolbar's TextView ourselves, this seems + // to be the only way of changing the ellipsize setting. + Field f = toolbar.getClass().getDeclaredField("mTitleTextView"); + f.setAccessible(true); + TextView textView = (TextView) f.get(toolbar); + textView.setEllipsize(TextUtils.TruncateAt.START); + } catch (Exception e) { + // If we can't ellipsize at the start of the title, we shouldn't display the host + // so as to avoid displaying a misleadingly truncated host. + Log.w(LOGTAG, "Failed to get Toolbar TextView, using default title."); + useDomainTitle = false; + } + actionBar = getSupportActionBar(); + actionBar.setTitle(toolbarTitle); + updateToolbarColor(toolbar); + + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onBackPressed(); + } + }); + + Tabs.registerOnTabsChangedListener(this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Tabs.unregisterOnTabsChangedListener(this); + } + + @Override + public int getLayout() { + return R.layout.customtabs_activity; + } + + @Override + protected void onDone() { + finish(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + if (tab == null) { + return; + } + + if (tabId >= 0 && tab.getId() != tabId) { + return; + } + + if (msg == Tabs.TabEvents.LOCATION_CHANGE) { + tabId = tab.getId(); + final Uri uri = Uri.parse(tab.getURL()); + String title = null; + if (uri != null) { + title = uri.getHost(); + } + if (!useDomainTitle || title == null || title.isEmpty()) { + toolbarTitle = AppConstants.MOZ_APP_BASENAME; + } else { + toolbarTitle = title; + } + actionBar.setTitle(toolbarTitle); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putInt(SAVED_TOOLBAR_COLOR, toolbarColor); + outState.putString(SAVED_TOOLBAR_TITLE, toolbarTitle); + } + + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void updateActionBarWithToolbar(final Toolbar toolbar) { + setSupportActionBar(toolbar); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + } + + private void updateToolbarColor(final Toolbar toolbar) { + if (toolbarColor == NO_COLOR) { + final int color = getIntent().getIntExtra(EXTRA_TOOLBAR_COLOR, NO_COLOR); + if (color == NO_COLOR) { + return; + } + toolbarColor = color; + } + + final int titleTextColor = ColorUtil.getReadableTextColor(toolbarColor); + + toolbar.setBackgroundColor(toolbarColor); + toolbar.setTitleTextColor(titleTextColor); + final Window window = getWindow(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.setStatusBarColor(ColorUtil.darken(toolbarColor, 0.25)); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java b/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java new file mode 100644 index 000000000..7960f7832 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java @@ -0,0 +1,65 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.customtabs; + +import android.net.Uri; +import android.os.Bundle; +import android.support.customtabs.CustomTabsService; +import android.support.customtabs.CustomTabsSessionToken; +import android.util.Log; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoService; + +import java.util.List; + +/** + * Custom tabs service external, third-party apps connect to. + */ +public class GeckoCustomTabsService extends CustomTabsService { + private static final String LOGTAG = "GeckoCustomTabsService"; + private static final boolean DEBUG = false; + + @Override + protected boolean updateVisuals(CustomTabsSessionToken sessionToken, Bundle bundle) { + Log.v(LOGTAG, "updateVisuals()"); + + return false; + } + + @Override + protected boolean warmup(long flags) { + if (DEBUG) { + Log.v(LOGTAG, "warming up..."); + } + + GeckoService.startGecko(GeckoProfile.initFromArgs(this, null), null, getApplicationContext()); + + return true; + } + + @Override + protected boolean newSession(CustomTabsSessionToken sessionToken) { + Log.v(LOGTAG, "newSession()"); + + // Pretend session has been started + return true; + } + + @Override + protected boolean mayLaunchUrl(CustomTabsSessionToken sessionToken, Uri uri, Bundle bundle, List<Bundle> list) { + Log.v(LOGTAG, "mayLaunchUrl()"); + + return false; + } + + @Override + protected Bundle extraCommand(String commandName, Bundle bundle) { + Log.v(LOGTAG, "extraCommand()"); + + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java new file mode 100644 index 000000000..2e056cc1e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java @@ -0,0 +1,79 @@ +/* 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 org.mozilla.gecko.annotation.RobocopTarget; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +/** + * The base class for ContentProviders that wish to use a different DB + * for each profile. + * + * This class has logic shared between ordinary per-profile CPs and + * those that wish to share DB connections between CPs. + */ +public abstract class AbstractPerProfileDatabaseProvider extends AbstractTransactionalProvider { + + /** + * Extend this to provide access to your own map of shared databases. This + * is a method so that your subclass doesn't collide with others! + */ + protected abstract PerProfileDatabases<? extends SQLiteOpenHelper> getDatabases(); + + /* + * Fetches a readable database based on the profile indicated in the + * passed URI. If the URI does not contain a profile param, the default profile + * is used. + * + * @param uri content URI optionally indicating the profile of the user + * @return instance of a readable SQLiteDatabase + */ + @Override + protected SQLiteDatabase getReadableDatabase(Uri uri) { + String profile = null; + if (uri != null) { + profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); + } + + return getDatabases().getDatabaseHelperForProfile(profile, isTest(uri)).getReadableDatabase(); + } + + /* + * Fetches a writable database based on the profile indicated in the + * passed URI. If the URI does not contain a profile param, the default profile + * is used + * + * @param uri content URI optionally indicating the profile of the user + * @return instance of a writable SQLiteDatabase + */ + @Override + protected SQLiteDatabase getWritableDatabase(Uri uri) { + String profile = null; + if (uri != null) { + profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); + } + + return getDatabases().getDatabaseHelperForProfile(profile, isTest(uri)).getWritableDatabase(); + } + + protected SQLiteDatabase getWritableDatabaseForProfile(String profile, boolean isTest) { + return getDatabases().getDatabaseHelperForProfile(profile, isTest).getWritableDatabase(); + } + + /** + * This method should ONLY be used for testing purposes. + * + * @param uri content URI optionally indicating the profile of the user + * @return instance of a writable SQLiteDatabase + */ + @Override + @RobocopTarget + public SQLiteDatabase getWritableDatabaseForTesting(Uri uri) { + return getWritableDatabase(uri); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java new file mode 100644 index 000000000..7e289b76f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java @@ -0,0 +1,328 @@ +/* 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 org.mozilla.gecko.AppConstants.Versions; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +/** + * This abstract class exists to capture some of the transaction-handling + * commonalities in Fennec's DB layer. + * + * In particular, this abstracts DB access, batching, and a particular + * transaction approach. + * + * That approach is: subclasses implement the abstract methods + * {@link #insertInTransaction(android.net.Uri, android.content.ContentValues)}, + * {@link #deleteInTransaction(android.net.Uri, String, String[])}, and + * {@link #updateInTransaction(android.net.Uri, android.content.ContentValues, String, String[])}. + * + * These are all called expecting a transaction to be established, so failed + * modifications can be rolled-back, and work batched. + * + * If no transaction is established, that's not a problem. Transaction nesting + * can be avoided by using {@link #beginWrite(SQLiteDatabase)}. + * + * The decision of when to begin a transaction is left to the subclasses, + * primarily to avoid the pattern of a transaction being begun, a read occurring, + * and then a write being necessary. This lock upgrade can result in SQLITE_BUSY, + * which we don't handle well. Better to avoid starting a transaction too soon! + * + * You are probably interested in some subclasses: + * + * * {@link AbstractPerProfileDatabaseProvider} provides a simple abstraction for + * querying databases that are stored in the user's profile directory. + * * {@link PerProfileDatabaseProvider} is a simple version that only allows a + * single ContentProvider to access each per-profile database. + * * {@link SharedBrowserDatabaseProvider} is an example of a per-profile provider + * that allows for multiple providers to safely work with the same databases. + */ +@SuppressWarnings("javadoc") +public abstract class AbstractTransactionalProvider extends ContentProvider { + private static final String LOGTAG = "GeckoTransProvider"; + + private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); + private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); + + protected abstract SQLiteDatabase getReadableDatabase(Uri uri); + protected abstract SQLiteDatabase getWritableDatabase(Uri uri); + + public abstract SQLiteDatabase getWritableDatabaseForTesting(Uri uri); + + protected abstract Uri insertInTransaction(Uri uri, ContentValues values); + protected abstract int deleteInTransaction(Uri uri, String selection, String[] selectionArgs); + protected abstract int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs); + + /** + * Track whether we're in a batch operation. + * + * When we're in a batch operation, individual write steps won't even try + * to start a transaction... and neither will they attempt to finish one. + * + * Set this to <code>Boolean.TRUE</code> when you're entering a batch -- + * a section of code in which {@link ContentProvider} methods will be + * called, but nested transactions should not be started. Callers are + * responsible for beginning and ending the enclosing transaction, and + * for setting this to <code>Boolean.FALSE</code> when done. + * + * This is a ThreadLocal separate from `db.inTransaction` because batched + * operations start transactions independent of individual ContentProvider + * operations. This doesn't work well with the entire concept of this + * abstract class -- that is, automatically beginning and ending transactions + * for each insert/delete/update operation -- and doing so without + * causing arbitrary nesting requires external tracking. + * + * Note that beginWrite takes a DB argument, but we don't differentiate + * between databases in this tracking flag. If your ContentProvider manages + * multiple database transactions within the same thread, you'll need to + * amend this scheme -- but then, you're already doing some serious wizardry, + * so rock on. + */ + final ThreadLocal<Boolean> isInBatchOperation = new ThreadLocal<Boolean>(); + + private boolean isInBatch() { + final Boolean isInBatch = isInBatchOperation.get(); + if (isInBatch == null) { + return false; + } + + return isInBatch; + } + + /** + * If we're not currently in a transaction, and we should be, start one. + */ + protected void beginWrite(final SQLiteDatabase db) { + if (isInBatch()) { + trace("Not bothering with an intermediate write transaction: inside batch operation."); + return; + } + + if (!db.inTransaction()) { + trace("beginWrite: beginning transaction."); + db.beginTransaction(); + } + } + + /** + * If we're not in a batch, but we are in a write transaction, mark it as + * successful. + */ + protected void markWriteSuccessful(final SQLiteDatabase db) { + if (isInBatch()) { + trace("Not marking write successful: inside batch operation."); + return; + } + + if (db.inTransaction()) { + trace("Marking write transaction successful."); + db.setTransactionSuccessful(); + } + } + + /** + * If we're not in a batch, but we are in a write transaction, + * end it. + * + * @see PerProfileDatabaseProvider#markWriteSuccessful(SQLiteDatabase) + */ + protected void endWrite(final SQLiteDatabase db) { + if (isInBatch()) { + trace("Not ending write: inside batch operation."); + return; + } + + if (db.inTransaction()) { + trace("endWrite: ending transaction."); + db.endTransaction(); + } + } + + protected void beginBatch(final SQLiteDatabase db) { + trace("Beginning batch."); + isInBatchOperation.set(Boolean.TRUE); + db.beginTransaction(); + } + + protected void markBatchSuccessful(final SQLiteDatabase db) { + if (isInBatch()) { + trace("Marking batch successful."); + db.setTransactionSuccessful(); + return; + } + Log.w(LOGTAG, "Unexpectedly asked to mark batch successful, but not in batch!"); + throw new IllegalStateException("Not in batch."); + } + + protected void endBatch(final SQLiteDatabase db) { + trace("Ending batch."); + db.endTransaction(); + isInBatchOperation.set(Boolean.FALSE); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + trace("Calling delete on URI: " + uri + ", " + selection + ", " + selectionArgs); + + final SQLiteDatabase db = getWritableDatabase(uri); + int deleted = 0; + + try { + deleted = deleteInTransaction(uri, selection, selectionArgs); + markWriteSuccessful(db); + } finally { + endWrite(db); + } + + if (deleted > 0) { + final boolean shouldSyncToNetwork = !isCallerSync(uri); + getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork); + } + + return deleted; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + trace("Calling insert on URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + Uri result = null; + try { + result = insertInTransaction(uri, values); + markWriteSuccessful(db); + } catch (SQLException sqle) { + Log.e(LOGTAG, "exception in DB operation", sqle); + } catch (UnsupportedOperationException uoe) { + Log.e(LOGTAG, "don't know how to perform that insert", uoe); + } finally { + endWrite(db); + } + + if (result != null) { + final boolean shouldSyncToNetwork = !isCallerSync(uri); + getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork); + } + + return result; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + trace("Calling update on URI: " + uri + ", " + selection + ", " + selectionArgs); + + final SQLiteDatabase db = getWritableDatabase(uri); + int updated = 0; + + try { + updated = updateInTransaction(uri, values, selection, + selectionArgs); + markWriteSuccessful(db); + } finally { + endWrite(db); + } + + if (updated > 0) { + final boolean shouldSyncToNetwork = !isCallerSync(uri); + getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork); + } + + return updated; + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) { + if (values == null) { + return 0; + } + + int numValues = values.length; + int successes = 0; + + final SQLiteDatabase db = getWritableDatabase(uri); + + debug("bulkInsert: explicitly starting transaction."); + beginBatch(db); + + try { + for (int i = 0; i < numValues; i++) { + insertInTransaction(uri, values[i]); + successes++; + } + trace("Flushing DB bulkinsert..."); + markBatchSuccessful(db); + } finally { + debug("bulkInsert: explicitly ending transaction."); + endBatch(db); + } + + if (successes > 0) { + final boolean shouldSyncToNetwork = !isCallerSync(uri); + getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork); + } + + return successes; + } + + /** + * Indicates whether a query should include deleted fields + * based on the URI. + * @param uri query URI + */ + protected static boolean shouldShowDeleted(Uri uri) { + String showDeleted = uri.getQueryParameter(BrowserContract.PARAM_SHOW_DELETED); + return !TextUtils.isEmpty(showDeleted); + } + + /** + * Indicates whether an insertion should be made if a record doesn't + * exist, based on the URI. + * @param uri query URI + */ + protected static boolean shouldUpdateOrInsert(Uri uri) { + String insertIfNeeded = uri.getQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED); + return Boolean.parseBoolean(insertIfNeeded); + } + + /** + * Indicates whether query is a test based on the URI. + * @param uri query URI + */ + protected static boolean isTest(Uri uri) { + if (uri == null) { + return false; + } + String isTest = uri.getQueryParameter(BrowserContract.PARAM_IS_TEST); + return !TextUtils.isEmpty(isTest); + } + + /** + * Return true of the query is from Firefox Sync. + * @param uri query URI + */ + protected static boolean isCallerSync(Uri uri) { + String isSync = uri.getQueryParameter(BrowserContract.PARAM_IS_SYNC); + return !TextUtils.isEmpty(isSync); + } + + protected static void trace(String message) { + if (logVerbose) { + Log.v(LOGTAG, message); + } + } + + protected static void debug(String message) { + if (logDebug) { + Log.d(LOGTAG, message); + } + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java b/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java new file mode 100644 index 000000000..418d547ed --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java @@ -0,0 +1,64 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.util.Log; + +// BaseTable provides a basic implementation of a Table for tables that don't require advanced operations during +// insert, delete, update, or query operations. Implementors must still provide onCreate and onUpgrade operations. +public abstract class BaseTable implements Table { + private static final String LOGTAG = "GeckoBaseTable"; + + private static final boolean DEBUG = false; + + protected static void log(String msg) { + if (DEBUG) { + Log.i(LOGTAG, msg); + } + } + + // Table implementation + @Override + public Table.ContentProviderInfo[] getContentProviderInfo() { + return new Table.ContentProviderInfo[0]; + } + + // Returns the name of the table to modify/query + protected abstract String getTable(); + + // Table implementation + @Override + public Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] columns, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit) { + Cursor c = db.query(getTable(), columns, selection, selectionArgs, groupBy, null, sortOrder, limit); + log("query " + columns + " in " + selection + " = " + c); + return c; + } + + @Override + public int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs) { + int updated = db.updateWithOnConflict(getTable(), values, selection, selectionArgs, SQLiteDatabase.CONFLICT_REPLACE); + log("update " + values + " in " + selection + " = " + updated); + return updated; + } + + @Override + public long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values) { + long inserted = db.insertOrThrow(getTable(), null, values); + log("insert " + values + " = " + inserted); + return inserted; + } + + @Override + public int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs) { + int deleted = db.delete(getTable(), selection, selectionArgs); + log("delete " + selection + " = " + deleted); + return deleted; + } +}; diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java new file mode 100644 index 000000000..51c8d964f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java @@ -0,0 +1,785 @@ +/* -*- 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 org.mozilla.gecko.AppConstants; + +import android.net.Uri; +import android.support.annotation.NonNull; + +import org.mozilla.gecko.annotation.RobocopTarget; + +@RobocopTarget +public class BrowserContract { + public static final String AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.browser"; + public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY); + + public static final String PASSWORDS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.passwords"; + public static final Uri PASSWORDS_AUTHORITY_URI = Uri.parse("content://" + PASSWORDS_AUTHORITY); + + public static final String FORM_HISTORY_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.formhistory"; + public static final Uri FORM_HISTORY_AUTHORITY_URI = Uri.parse("content://" + FORM_HISTORY_AUTHORITY); + + public static final String TABS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.tabs"; + public static final Uri TABS_AUTHORITY_URI = Uri.parse("content://" + TABS_AUTHORITY); + + public static final String HOME_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.home"; + public static final Uri HOME_AUTHORITY_URI = Uri.parse("content://" + HOME_AUTHORITY); + + public static final String PROFILES_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".profiles"; + public static final Uri PROFILES_AUTHORITY_URI = Uri.parse("content://" + PROFILES_AUTHORITY); + + public static final String READING_LIST_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.readinglist"; + public static final Uri READING_LIST_AUTHORITY_URI = Uri.parse("content://" + READING_LIST_AUTHORITY); + + public static final String SEARCH_HISTORY_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.searchhistory"; + public static final Uri SEARCH_HISTORY_AUTHORITY_URI = Uri.parse("content://" + SEARCH_HISTORY_AUTHORITY); + + public static final String LOGINS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.logins"; + public static final Uri LOGINS_AUTHORITY_URI = Uri.parse("content://" + LOGINS_AUTHORITY); + + public static final String PARAM_PROFILE = "profile"; + public static final String PARAM_PROFILE_PATH = "profilePath"; + public static final String PARAM_LIMIT = "limit"; + public static final String PARAM_SUGGESTEDSITES_LIMIT = "suggestedsites_limit"; + public static final String PARAM_TOPSITES_DISABLE_PINNED = "topsites_disable_pinned"; + public static final String PARAM_IS_SYNC = "sync"; + public static final String PARAM_SHOW_DELETED = "show_deleted"; + public static final String PARAM_IS_TEST = "test"; + public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed"; + public static final String PARAM_INCREMENT_VISITS = "increment_visits"; + public static final String PARAM_INCREMENT_REMOTE_AGGREGATES = "increment_remote_aggregates"; + public static final String PARAM_EXPIRE_PRIORITY = "priority"; + public static final String PARAM_DATASET_ID = "dataset_id"; + public static final String PARAM_GROUP_BY = "group_by"; + + static public enum ExpirePriority { + NORMAL, + AGGRESSIVE + } + + /** + * Produces a SQL expression used for sorting results of the "combined" view by frecency. + * Combines remote and local frecency calculations, weighting local visits much heavier. + * + * @param includesBookmarks When URL is bookmarked, should we give it bonus frecency points? + * @param ascending Indicates if sorting order ascending + * @return Combined frecency sorting expression + */ + static public String getCombinedFrecencySortOrder(boolean includesBookmarks, boolean ascending) { + final long now = System.currentTimeMillis(); + StringBuilder order = new StringBuilder(getRemoteFrecencySQL(now) + " + " + getLocalFrecencySQL(now)); + + if (includesBookmarks) { + order.insert(0, "(CASE WHEN " + Combined.BOOKMARK_ID + " > -1 THEN 100 ELSE 0 END) + "); + } + + order.append(ascending ? " ASC" : " DESC"); + return order.toString(); + } + + /** + * See Bug 1265525 for details (explanation + graphs) on how Remote frecency compares to Local frecency for different + * combinations of visits count and age. + * + * @param now Base time in milliseconds for age calculation + * @return remote frecency SQL calculation + */ + static public String getRemoteFrecencySQL(final long now) { + return getFrecencyCalculation(now, 1, 110, Combined.REMOTE_VISITS_COUNT, Combined.REMOTE_DATE_LAST_VISITED); + } + + /** + * Local frecency SQL calculation. Note higher scale factor and squared visit count which achieve + * visits generated locally being much preferred over remote visits. + * See Bug 1265525 for details (explanation + comparison graphs). + * + * @param now Base time in milliseconds for age calculation + * @return local frecency SQL calculation + */ + static public String getLocalFrecencySQL(final long now) { + String visitCountExpr = "(" + Combined.LOCAL_VISITS_COUNT + " + 2)"; + visitCountExpr = visitCountExpr + " * " + visitCountExpr; + + return getFrecencyCalculation(now, 2, 225, visitCountExpr, Combined.LOCAL_DATE_LAST_VISITED); + } + + /** + * Our version of frecency is computed by scaling the number of visits by a multiplier + * that approximates Gaussian decay, based on how long ago the entry was last visited. + * Since we're limited by the math we can do with sqlite, we're calculating this + * approximation using the Cauchy distribution: multiplier = scale_const / (age^2 + scale_const). + * For example, with 15 as our scale parameter, we get a scale constant 15^2 = 225. Then: + * frecencyScore = numVisits * max(1, 100 * 225 / (age*age + 225)). (See bug 704977) + * + * @param now Base time in milliseconds for age calculation + * @param minFrecency Minimum allowed frecency value + * @param multiplier Scale constant + * @param visitCountExpr Expression which will produce a visit count + * @param lastVisitExpr Expression which will produce "last-visited" timestamp + * @return Frecency SQL calculation + */ + static public String getFrecencyCalculation(final long now, final int minFrecency, final int multiplier, @NonNull final String visitCountExpr, @NonNull final String lastVisitExpr) { + final long nowInMicroseconds = now * 1000; + final long microsecondsPerDay = 86400000000L; + final String ageExpr = "(" + nowInMicroseconds + " - " + lastVisitExpr + ") / " + microsecondsPerDay; + + return visitCountExpr + " * MAX(" + minFrecency + ", 100 * " + multiplier + " / (" + ageExpr + " * " + ageExpr + " + " + multiplier + "))"; + } + + @RobocopTarget + public interface CommonColumns { + public static final String _ID = "_id"; + } + + @RobocopTarget + public interface DateSyncColumns { + public static final String DATE_CREATED = "created"; + public static final String DATE_MODIFIED = "modified"; + } + + @RobocopTarget + public interface SyncColumns extends DateSyncColumns { + public static final String GUID = "guid"; + public static final String IS_DELETED = "deleted"; + } + + @RobocopTarget + public interface URLColumns { + public static final String URL = "url"; + public static final String TITLE = "title"; + } + + @RobocopTarget + public interface FaviconColumns { + public static final String FAVICON = "favicon"; + public static final String FAVICON_ID = "favicon_id"; + public static final String FAVICON_URL = "favicon_url"; + } + + @RobocopTarget + public interface HistoryColumns { + public static final String DATE_LAST_VISITED = "date"; + public static final String VISITS = "visits"; + // Aggregates used to speed up top sites and search frecency-powered queries + public static final String LOCAL_VISITS = "visits_local"; + public static final String REMOTE_VISITS = "visits_remote"; + public static final String LOCAL_DATE_LAST_VISITED = "date_local"; + public static final String REMOTE_DATE_LAST_VISITED = "date_remote"; + } + + @RobocopTarget + public interface VisitsColumns { + public static final String HISTORY_GUID = "history_guid"; + public static final String VISIT_TYPE = "visit_type"; + public static final String DATE_VISITED = "date"; + // Used to distinguish between visits that were generated locally vs those that came in from Sync. + // Since we don't track "origin clientID" for visits, this is the best we can do for now. + public static final String IS_LOCAL = "is_local"; + } + + public interface PageMetadataColumns { + public static final String HISTORY_GUID = "history_guid"; + public static final String DATE_CREATED = "created"; + public static final String HAS_IMAGE = "has_image"; + public static final String JSON = "json"; + } + + public interface DeletedColumns { + public static final String ID = "id"; + public static final String GUID = "guid"; + public static final String TIME_DELETED = "timeDeleted"; + } + + @RobocopTarget + public static final class Favicons implements CommonColumns, DateSyncColumns { + private Favicons() {} + + public static final String TABLE_NAME = "favicons"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "favicons"); + + public static final String URL = "url"; + public static final String DATA = "data"; + public static final String PAGE_URL = "page_url"; + } + + @RobocopTarget + public static final class Thumbnails implements CommonColumns { + private Thumbnails() {} + + public static final String TABLE_NAME = "thumbnails"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "thumbnails"); + + public static final String URL = "url"; + public static final String DATA = "data"; + } + + public static final class Profiles { + private Profiles() {} + public static final String NAME = "name"; + public static final String PATH = "path"; + } + + @RobocopTarget + public static final class Bookmarks implements CommonColumns, URLColumns, FaviconColumns, SyncColumns { + private Bookmarks() {} + + public static final String TABLE_NAME = "bookmarks"; + + public static final String VIEW_WITH_FAVICONS = "bookmarks_with_favicons"; + + public static final String VIEW_WITH_ANNOTATIONS = "bookmarks_with_annotations"; + + public static final int FIXED_ROOT_ID = 0; + public static final int FAKE_DESKTOP_FOLDER_ID = -1; + public static final int FIXED_READING_LIST_ID = -2; + public static final int FIXED_PINNED_LIST_ID = -3; + public static final int FIXED_SCREENSHOT_FOLDER_ID = -4; + public static final int FAKE_READINGLIST_SMARTFOLDER_ID = -5; + + /** + * This ID and the following negative IDs are reserved for bookmarks from Android's partner + * bookmark provider. + */ + public static final long FAKE_PARTNER_BOOKMARKS_START = -1000; + + public static final String MOBILE_FOLDER_GUID = "mobile"; + public static final String PLACES_FOLDER_GUID = "places"; + public static final String MENU_FOLDER_GUID = "menu"; + public static final String TAGS_FOLDER_GUID = "tags"; + public static final String TOOLBAR_FOLDER_GUID = "toolbar"; + public static final String UNFILED_FOLDER_GUID = "unfiled"; + public static final String FAKE_DESKTOP_FOLDER_GUID = "desktop"; + public static final String PINNED_FOLDER_GUID = "pinned"; + public static final String SCREENSHOT_FOLDER_GUID = "screenshots"; + public static final String FAKE_READINGLIST_SMARTFOLDER_GUID = "readinglist"; + + public static final int TYPE_FOLDER = 0; + public static final int TYPE_BOOKMARK = 1; + public static final int TYPE_SEPARATOR = 2; + public static final int TYPE_LIVEMARK = 3; + public static final int TYPE_QUERY = 4; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "bookmarks"); + public static final Uri PARENTS_CONTENT_URI = Uri.withAppendedPath(CONTENT_URI, "parents"); + // Hacky API for bulk-updating positions. Bug 728783. + public static final Uri POSITIONS_CONTENT_URI = Uri.withAppendedPath(CONTENT_URI, "positions"); + public static final long DEFAULT_POSITION = Long.MIN_VALUE; + + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/bookmark"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/bookmark"; + public static final String TYPE = "type"; + public static final String PARENT = "parent"; + public static final String POSITION = "position"; + public static final String TAGS = "tags"; + public static final String DESCRIPTION = "description"; + public static final String KEYWORD = "keyword"; + + public static final String ANNOTATION_KEY = "annotation_key"; + public static final String ANNOTATION_VALUE = "annotation_value"; + } + + @RobocopTarget + public static final class History implements CommonColumns, URLColumns, HistoryColumns, FaviconColumns, SyncColumns { + private History() {} + + public static final String TABLE_NAME = "history"; + + public static final String VIEW_WITH_FAVICONS = "history_with_favicons"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "history"); + public static final Uri CONTENT_OLD_URI = Uri.withAppendedPath(AUTHORITY_URI, "history/old"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/browser-history"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/browser-history"; + } + + @RobocopTarget + public static final class Visits implements CommonColumns, VisitsColumns { + private Visits() {} + + public static final String TABLE_NAME = "visits"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "visits"); + + public static final int VISIT_IS_LOCAL = 1; + public static final int VISIT_IS_REMOTE = 0; + } + + // Combined bookmarks and history + @RobocopTarget + public static final class Combined implements CommonColumns, URLColumns, HistoryColumns, FaviconColumns { + private Combined() {} + + public static final String VIEW_NAME = "combined"; + + public static final String VIEW_WITH_FAVICONS = "combined_with_favicons"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "combined"); + + public static final String BOOKMARK_ID = "bookmark_id"; + public static final String HISTORY_ID = "history_id"; + + public static final String REMOTE_VISITS_COUNT = "remoteVisitCount"; + public static final String REMOTE_DATE_LAST_VISITED = "remoteDateLastVisited"; + + public static final String LOCAL_VISITS_COUNT = "localVisitCount"; + public static final String LOCAL_DATE_LAST_VISITED = "localDateLastVisited"; + } + + public static final class Schema { + private Schema() {} + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "schema"); + + public static final String VERSION = "version"; + } + + public static final class Passwords { + private Passwords() {} + public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "passwords"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/passwords"; + + public static final String ID = "id"; + public static final String HOSTNAME = "hostname"; + public static final String HTTP_REALM = "httpRealm"; + public static final String FORM_SUBMIT_URL = "formSubmitURL"; + public static final String USERNAME_FIELD = "usernameField"; + public static final String PASSWORD_FIELD = "passwordField"; + public static final String ENCRYPTED_USERNAME = "encryptedUsername"; + public static final String ENCRYPTED_PASSWORD = "encryptedPassword"; + public static final String ENC_TYPE = "encType"; + public static final String TIME_CREATED = "timeCreated"; + public static final String TIME_LAST_USED = "timeLastUsed"; + public static final String TIME_PASSWORD_CHANGED = "timePasswordChanged"; + public static final String TIMES_USED = "timesUsed"; + public static final String GUID = "guid"; + + // This needs to be kept in sync with the types defined in toolkit/components/passwordmgr/nsILoginManagerCrypto.idl#45 + public static final int ENCTYPE_SDR = 1; + } + + public static final class DeletedPasswords implements DeletedColumns { + private DeletedPasswords() {} + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-passwords"; + public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "deleted-passwords"); + } + + @RobocopTarget + public static final class GeckoDisabledHosts { + private GeckoDisabledHosts() {} + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/disabled-hosts"; + public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "disabled-hosts"); + + public static final String HOSTNAME = "hostname"; + } + + public static final class FormHistory { + private FormHistory() {} + public static final Uri CONTENT_URI = Uri.withAppendedPath(FORM_HISTORY_AUTHORITY_URI, "formhistory"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/formhistory"; + + public static final String ID = "id"; + public static final String FIELD_NAME = "fieldname"; + public static final String VALUE = "value"; + public static final String TIMES_USED = "timesUsed"; + public static final String FIRST_USED = "firstUsed"; + public static final String LAST_USED = "lastUsed"; + public static final String GUID = "guid"; + } + + public static final class DeletedFormHistory implements DeletedColumns { + private DeletedFormHistory() {} + public static final Uri CONTENT_URI = Uri.withAppendedPath(FORM_HISTORY_AUTHORITY_URI, "deleted-formhistory"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-formhistory"; + } + + @RobocopTarget + public static final class Tabs implements CommonColumns { + private Tabs() {} + public static final String TABLE_NAME = "tabs"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "tabs"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/tab"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/tab"; + + // Title of the tab. + public static final String TITLE = "title"; + + // Topmost URL from the history array. Allows processing of this tab without + // parsing that array. + public static final String URL = "url"; + + // Sync-assigned GUID for client device. NULL for local tabs. + public static final String CLIENT_GUID = "client_guid"; + + // JSON-encoded array of history URL strings, from most recent to least recent. + public static final String HISTORY = "history"; + + // Favicon URL for the tab's topmost history entry. + public static final String FAVICON = "favicon"; + + // Last used time of the tab. + public static final String LAST_USED = "last_used"; + + // Position of the tab. 0 represents foreground. + public static final String POSITION = "position"; + } + + public static final class Clients implements CommonColumns { + private Clients() {} + public static final Uri CONTENT_RECENCY_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "clients_recency"); + public static final Uri CONTENT_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "clients"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/client"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/client"; + + // Client-provided name string. Could conceivably be null. + public static final String NAME = "name"; + + // Sync-assigned GUID for client device. NULL for local tabs. + public static final String GUID = "guid"; + + // Last modified time for the client's tab record. For remote records, a server + // timestamp provided by Sync during insertion. + public static final String LAST_MODIFIED = "last_modified"; + + public static final String DEVICE_TYPE = "device_type"; + } + + // Data storage for dynamic panels on about:home + @RobocopTarget + public static final class HomeItems implements CommonColumns { + private HomeItems() {} + public static final Uri CONTENT_FAKE_URI = Uri.withAppendedPath(HOME_AUTHORITY_URI, "items/fake"); + public static final Uri CONTENT_URI = Uri.withAppendedPath(HOME_AUTHORITY_URI, "items"); + + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/homeitem"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/homeitem"; + + public static final String DATASET_ID = "dataset_id"; + public static final String URL = "url"; + public static final String TITLE = "title"; + public static final String DESCRIPTION = "description"; + public static final String IMAGE_URL = "image_url"; + public static final String BACKGROUND_COLOR = "background_color"; + public static final String BACKGROUND_URL = "background_url"; + public static final String CREATED = "created"; + public static final String FILTER = "filter"; + + public static final String[] DEFAULT_PROJECTION = + new String[] { _ID, DATASET_ID, URL, TITLE, DESCRIPTION, IMAGE_URL, BACKGROUND_COLOR, BACKGROUND_URL, FILTER }; + } + + @RobocopTarget + public static final class ReadingListItems implements CommonColumns, URLColumns { + public static final String EXCERPT = "excerpt"; + public static final String CLIENT_LAST_MODIFIED = "client_last_modified"; + public static final String GUID = "guid"; + public static final String SERVER_LAST_MODIFIED = "last_modified"; + public static final String SERVER_STORED_ON = "stored_on"; + public static final String ADDED_ON = "added_on"; + public static final String MARKED_READ_ON = "marked_read_on"; + public static final String IS_DELETED = "is_deleted"; + public static final String IS_ARCHIVED = "is_archived"; + public static final String IS_UNREAD = "is_unread"; + public static final String IS_ARTICLE = "is_article"; + public static final String IS_FAVORITE = "is_favorite"; + public static final String RESOLVED_URL = "resolved_url"; + public static final String RESOLVED_TITLE = "resolved_title"; + public static final String ADDED_BY = "added_by"; + public static final String MARKED_READ_BY = "marked_read_by"; + public static final String WORD_COUNT = "word_count"; + public static final String READ_POSITION = "read_position"; + public static final String CONTENT_STATUS = "content_status"; + + public static final String SYNC_STATUS = "sync_status"; + public static final String SYNC_CHANGE_FLAGS = "sync_change_flags"; + + private ReadingListItems() {} + public static final Uri CONTENT_URI = Uri.withAppendedPath(READING_LIST_AUTHORITY_URI, "items"); + + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/readinglistitem"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/readinglistitem"; + + // CONTENT_STATUS represents the result of an attempt to fetch content for the reading list item. + public static final int STATUS_UNFETCHED = 0; + public static final int STATUS_FETCH_FAILED_TEMPORARY = 1; + public static final int STATUS_FETCH_FAILED_PERMANENT = 2; + public static final int STATUS_FETCH_FAILED_UNSUPPORTED_FORMAT = 3; + public static final int STATUS_FETCHED_ARTICLE = 4; + + // See https://github.com/mozilla-services/readinglist/wiki/Client-phases for how this is expected to work. + // + // If an item is SYNCED, it doesn't need to be uploaded. + // + // If its status is NEW, the entire record should be uploaded. + // + // If DELETED, the record should be deleted. A record can only move into this state from SYNCED; NEW records + // are deleted immediately. + // + + public static final int SYNC_STATUS_SYNCED = 0; + public static final int SYNC_STATUS_NEW = 1; // Upload everything. + public static final int SYNC_STATUS_DELETED = 2; // Delete the record from the server. + public static final int SYNC_STATUS_MODIFIED = 3; // Consult SYNC_CHANGE_FLAGS. + + // SYNC_CHANGE_FLAG represents the sets of fields that need to be uploaded. + // If its status is only UNREAD_CHANGED (and maybe FAVORITE_CHANGED?), then it can easily be uploaded + // in a fire-and-forget manner. This change can never conflict. + // + // If its status is RESOLVED, then one or more of the content-oriented fields has changed, and a full + // upload of those fields should occur. These can result in conflicts. + // + // Note that these are flags; they should be considered together when deciding on a course of action. + // + // These flags are meaningless for records in any state other than SYNCED. They can be safely altered in + // other states (to avoid having to query to pre-fill a ContentValues), but should be ignored. + public static final int SYNC_CHANGE_NONE = 0; + public static final int SYNC_CHANGE_UNREAD_CHANGED = 1 << 0; // => marked_read_{on,by}, is_unread + public static final int SYNC_CHANGE_FAVORITE_CHANGED = 1 << 1; // => is_favorite + public static final int SYNC_CHANGE_RESOLVED = 1 << 2; // => is_article, resolved_{url,title}, excerpt, word_count + + + public static final String DEFAULT_SORT_ORDER = CLIENT_LAST_MODIFIED + " DESC"; + public static final String[] DEFAULT_PROJECTION = new String[] { _ID, URL, TITLE, EXCERPT, WORD_COUNT, IS_UNREAD }; + + // Minimum fields required to create a reading list item. + public static final String[] REQUIRED_FIELDS = { ReadingListItems.URL, ReadingListItems.TITLE }; + + // All fields that might be mapped from the DB into a record object. + public static final String[] ALL_FIELDS = { + CommonColumns._ID, + URLColumns.URL, + URLColumns.TITLE, + EXCERPT, + CLIENT_LAST_MODIFIED, + GUID, + SERVER_LAST_MODIFIED, + SERVER_STORED_ON, + ADDED_ON, + MARKED_READ_ON, + IS_DELETED, + IS_ARCHIVED, + IS_UNREAD, + IS_ARTICLE, + IS_FAVORITE, + RESOLVED_URL, + RESOLVED_TITLE, + ADDED_BY, + MARKED_READ_BY, + WORD_COUNT, + READ_POSITION, + CONTENT_STATUS, + + SYNC_STATUS, + SYNC_CHANGE_FLAGS, + }; + + public static final String TABLE_NAME = "reading_list"; + } + + @RobocopTarget + public static final class TopSites implements CommonColumns, URLColumns { + private TopSites() {} + + public static final int TYPE_BLANK = 0; + public static final int TYPE_TOP = 1; + public static final int TYPE_PINNED = 2; + public static final int TYPE_SUGGESTED = 3; + + public static final String BOOKMARK_ID = "bookmark_id"; + public static final String HISTORY_ID = "history_id"; + public static final String TYPE = "type"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "topsites"); + } + + public static final class Highlights { + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "highlights"); + + public static final String DATE = "date"; + } + + @RobocopTarget + public static final class SearchHistory implements CommonColumns, HistoryColumns { + private SearchHistory() {} + + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/searchhistory"; + public static final String QUERY = "query"; + public static final String DATE = "date"; + public static final String TABLE_NAME = "searchhistory"; + + public static final Uri CONTENT_URI = Uri.withAppendedPath(SEARCH_HISTORY_AUTHORITY_URI, "searchhistory"); + } + + @RobocopTarget + public static final class SuggestedSites implements CommonColumns, URLColumns { + private SuggestedSites() {} + + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "suggestedsites"); + } + + public static final class ActivityStreamBlocklist implements CommonColumns { + private ActivityStreamBlocklist() {} + + public static final String TABLE_NAME = "activity_stream_blocklist"; + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, TABLE_NAME); + + public static final String URL = "url"; + public static final String CREATED = "created"; + } + + @RobocopTarget + public static final class UrlAnnotations implements CommonColumns, DateSyncColumns { + private UrlAnnotations() {} + + public static final String TABLE_NAME = "urlannotations"; + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, TABLE_NAME); + + public static final String URL = "url"; + public static final String KEY = "key"; + public static final String VALUE = "value"; + public static final String SYNC_STATUS = "sync_status"; + + public enum Key { + // We use a parameter, rather than name(), as defensive coding: we can't let the + // enum name change because we've already stored values into the DB. + SCREENSHOT ("screenshot"), + + /** + * This key maps URLs to its feeds. + * + * Key: feed + * Value: URL of feed + */ + FEED("feed"), + + /** + * This key maps URLs of feeds to an object describing the feed. + * + * Key: feed_subscription + * Value: JSON object describing feed + */ + FEED_SUBSCRIPTION("feed_subscription"), + + /** + * Indicates that this URL (if stored as a bookmark) should be opened into reader view. + * + * Key: reader_view + * Value: String "true" to indicate that we would like to open into reader view. + */ + READER_VIEW("reader_view"), + + /** + * Indicator that the user interacted with the URL in regards to home screen shortcuts. + * + * Key: home_screen_shortcut + * Value: True: User created an home screen shortcut for this URL + * False: User declined to create a shortcut for this URL + */ + HOME_SCREEN_SHORTCUT("home_screen_shortcut"); + + private final String dbValue; + + Key(final String dbValue) { this.dbValue = dbValue; } + public String getDbValue() { return dbValue; } + } + + public enum SyncStatus { + // We use a parameter, rather than ordinal(), as defensive coding: we can't let the + // ordinal values change because we've already stored values into the DB. + NEW (0); + + // Value stored into the database for this column. + private final int dbValue; + + SyncStatus(final int dbValue) { + this.dbValue = dbValue; + } + + public int getDBValue() { return dbValue; } + } + + /** + * Value used to indicate that a reader view item is saved. We use the + */ + public static final String READER_VIEW_SAVED_VALUE = "true"; + } + + public static final class Numbers { + private Numbers() {} + + public static final String TABLE_NAME = "numbers"; + + public static final String POSITION = "position"; + + public static final int MAX_VALUE = 50; + } + + @RobocopTarget + public static final class Logins implements CommonColumns { + private Logins() {} + + public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins"; + public static final String TABLE_LOGINS = "logins"; + + public static final String HOSTNAME = "hostname"; + public static final String HTTP_REALM = "httpRealm"; + public static final String FORM_SUBMIT_URL = "formSubmitURL"; + public static final String USERNAME_FIELD = "usernameField"; + public static final String PASSWORD_FIELD = "passwordField"; + public static final String ENCRYPTED_USERNAME = "encryptedUsername"; + public static final String ENCRYPTED_PASSWORD = "encryptedPassword"; + public static final String ENC_TYPE = "encType"; + public static final String TIME_CREATED = "timeCreated"; + public static final String TIME_LAST_USED = "timeLastUsed"; + public static final String TIME_PASSWORD_CHANGED = "timePasswordChanged"; + public static final String TIMES_USED = "timesUsed"; + public static final String GUID = "guid"; + } + + @RobocopTarget + public static final class DeletedLogins implements CommonColumns { + private DeletedLogins() {} + + public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "deleted-logins"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-logins"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/deleted-logins"; + public static final String TABLE_DELETED_LOGINS = "deleted_logins"; + + public static final String GUID = "guid"; + public static final String TIME_DELETED = "timeDeleted"; + } + + @RobocopTarget + public static final class LoginsDisabledHosts implements CommonColumns { + private LoginsDisabledHosts() {} + + public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins-disabled-hosts"); + public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins-disabled-hosts"; + public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins-disabled-hosts"; + public static final String TABLE_DISABLED_HOSTS = "logins_disabled_hosts"; + + public static final String HOSTNAME = "hostname"; + } + + @RobocopTarget + public static final class PageMetadata implements CommonColumns, PageMetadataColumns { + private PageMetadata() {} + + public static final String TABLE_NAME = "page_metadata"; + public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "page_metadata"); + } + + // We refer to the service by name to decouple services from the rest of the code base. + public static final String TAB_RECEIVED_SERVICE_CLASS_NAME = "org.mozilla.gecko.tabqueue.TabReceivedService"; + + public static final String SKIP_TAB_QUEUE_FLAG = "skip_tab_queue"; + + public static final String EXTRA_CLIENT_GUID = "org.mozilla.gecko.extra.CLIENT_ID"; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java new file mode 100644 index 000000000..4219e45b1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java @@ -0,0 +1,205 @@ +/* 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 java.io.File; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserContract.ExpirePriority; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; + +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.graphics.drawable.BitmapDrawable; +import android.support.v4.content.CursorLoader; + +/** + * Interface for interactions with all databases. If you want an instance + * that implements this, you should go through GeckoProfile. E.g., + * <code>BrowserDB.from(context)</code>. + */ +public abstract class BrowserDB { + public static enum FilterFlags { + EXCLUDE_PINNED_SITES + } + + public abstract Searches getSearches(); + public abstract TabsAccessor getTabsAccessor(); + public abstract URLMetadata getURLMetadata(); + @RobocopTarget public abstract UrlAnnotations getUrlAnnotations(); + + /** + * Add default bookmarks to the database. + * Takes an offset; returns a new offset. + */ + public abstract int addDefaultBookmarks(Context context, ContentResolver cr, int offset); + + /** + * Add bookmarks from the provided distribution. + * Takes an offset; returns a new offset. + */ + public abstract int addDistributionBookmarks(ContentResolver cr, Distribution distribution, int offset); + + /** + * Invalidate cached data. + */ + public abstract void invalidate(); + + public abstract int getCount(ContentResolver cr, String database); + + /** + * @return a cursor representing the contents of the DB filtered according to the arguments. + * Can return <code>null</code>. <code>CursorLoader</code> will handle this correctly. + */ + public abstract Cursor filter(ContentResolver cr, CharSequence constraint, + int limit, EnumSet<BrowserDB.FilterFlags> flags); + + /** + * @return a cursor over top sites (high-ranking bookmarks and history). + * Can return <code>null</code>. + * Returns no more than <code>limit</code> results. + * Suggested sites will be limited to being within the first <code>suggestedRangeLimit</code> results. + */ + public abstract Cursor getTopSites(ContentResolver cr, int suggestedRangeLimit, int limit); + + public abstract CursorLoader getActivityStreamTopSites(Context context, int limit); + + public abstract void updateVisitedHistory(ContentResolver cr, String uri); + + public abstract void updateHistoryTitle(ContentResolver cr, String uri, String title); + + /** + * Can return <code>null</code>. + */ + public abstract Cursor getAllVisitedHistory(ContentResolver cr); + + /** + * Can return <code>null</code>. + */ + public abstract Cursor getRecentHistory(ContentResolver cr, int limit); + + public abstract Cursor getHistoryForURL(ContentResolver cr, String uri); + + public abstract Cursor getRecentHistoryBetweenTime(ContentResolver cr, int historyLimit, long start, long end); + + public abstract long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath); + + public abstract void expireHistory(ContentResolver cr, ExpirePriority priority); + + public abstract void removeHistoryEntry(ContentResolver cr, String url); + + public abstract void clearHistory(ContentResolver cr, boolean clearSearchHistory); + + + public abstract String getUrlForKeyword(ContentResolver cr, String keyword); + + public abstract boolean isBookmark(ContentResolver cr, String uri); + public abstract boolean addBookmark(ContentResolver cr, String title, String uri); + public abstract Cursor getBookmarkForUrl(ContentResolver cr, String url); + public abstract Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl); + public abstract void removeBookmarksWithURL(ContentResolver cr, String uri); + public abstract void registerBookmarkObserver(ContentResolver cr, ContentObserver observer); + public abstract void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword); + public abstract boolean hasBookmarkWithGuid(ContentResolver cr, String guid); + + public abstract boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON); + public abstract int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl); + /** + * Can return <code>null</code>. + */ + public abstract Cursor getBookmarksInFolder(ContentResolver cr, long folderId); + + public abstract int getBookmarkCountForFolder(ContentResolver cr, long folderId); + + /** + * Get the favicon from the database, if any, associated with the given favicon URL. (That is, + * the URL of the actual favicon image, not the URL of the page with which the favicon is associated.) + * @param cr The ContentResolver to use. + * @param faviconURL The URL of the favicon to fetch from the database. + * @return The decoded Bitmap from the database, if any. null if none is stored. + */ + public abstract LoadFaviconResult getFaviconForUrl(Context context, ContentResolver cr, String faviconURL); + + /** + * Try to find a usable favicon URL in the history or bookmarks table. + */ + public abstract String getFaviconURLFromPageURL(ContentResolver cr, String uri); + + public abstract byte[] getThumbnailForUrl(ContentResolver cr, String uri); + public abstract void updateThumbnailForUrl(ContentResolver cr, String uri, BitmapDrawable thumbnail); + + /** + * Query for non-null thumbnails matching the provided <code>urls</code>. + * The returned cursor will have no more than, but possibly fewer than, + * the requested number of thumbnails. + * + * Returns null if the provided list of URLs is empty or null. + */ + public abstract Cursor getThumbnailsForUrls(ContentResolver cr, + List<String> urls); + + public abstract void removeThumbnails(ContentResolver cr); + + // Utility function for updating existing history using batch operations + public abstract void updateHistoryInBatch(ContentResolver cr, + Collection<ContentProviderOperation> operations, String url, + String title, long date, int visits); + + public abstract void updateBookmarkInBatch(ContentResolver cr, + Collection<ContentProviderOperation> operations, String url, + String title, String guid, long parent, long added, long modified, + long position, String keyword, int type); + + public abstract void pinSite(ContentResolver cr, String url, String title, int position); + public abstract void unpinSite(ContentResolver cr, int position); + + public abstract boolean hideSuggestedSite(String url); + public abstract void setSuggestedSites(SuggestedSites suggestedSites); + public abstract SuggestedSites getSuggestedSites(); + public abstract boolean hasSuggestedImageUrl(String url); + public abstract String getSuggestedImageUrlForUrl(String url); + public abstract int getSuggestedBackgroundColorForUrl(String url); + + /** + * Obtain a set of links for highlights from bookmarks and history. + * + * @param context The context to load the cursor. + * @param limit Maximum number of results to return. + */ + public abstract CursorLoader getHighlights(Context context, int limit); + + /** + * Block a page from the highlights list. + * + * @param url The page URL. Only pages exactly matching this URL will be blocked. + */ + public abstract void blockActivityStreamSite(ContentResolver cr, String url); + + public static BrowserDB from(final Context context) { + return from(GeckoProfile.get(context)); + } + + public static BrowserDB from(final GeckoProfile profile) { + synchronized (profile.getLock()) { + BrowserDB db = (BrowserDB) profile.getData(); + if (db != null) { + return db; + } + + db = new LocalBrowserDB(profile.getName()); + profile.setData(db); + return db; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java new file mode 100644 index 000000000..f823d9060 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java @@ -0,0 +1,2237 @@ +/* -*- 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 java.io.File; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.mozilla.apache.commons.codec.binary.Base32; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.BrowserContract.Combined; +import org.mozilla.gecko.db.BrowserContract.Favicons; +import org.mozilla.gecko.db.BrowserContract.History; +import org.mozilla.gecko.db.BrowserContract.Visits; +import org.mozilla.gecko.db.BrowserContract.PageMetadata; +import org.mozilla.gecko.db.BrowserContract.Numbers; +import org.mozilla.gecko.db.BrowserContract.ReadingListItems; +import org.mozilla.gecko.db.BrowserContract.SearchHistory; +import org.mozilla.gecko.db.BrowserContract.Thumbnails; +import org.mozilla.gecko.db.BrowserContract.UrlAnnotations; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.reader.SavedReaderViewHelper; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.sync.repositories.android.RepoUtils; +import org.mozilla.gecko.util.FileUtils; + +import static org.mozilla.gecko.db.DBUtils.qualifyColumn; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.Build; +import android.util.Log; + + +// public for robocop testing +public final class BrowserDatabaseHelper extends SQLiteOpenHelper { + private static final String LOGTAG = "GeckoBrowserDBHelper"; + + // Replace the Bug number below with your Bug that is conducting a DB upgrade, as to force a merge conflict with any + // other patches that require a DB upgrade. + public static final int DATABASE_VERSION = 36; // Bug 1301717 + public static final String DATABASE_NAME = "browser.db"; + + final protected Context mContext; + + static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME; + static final String TABLE_HISTORY = History.TABLE_NAME; + static final String TABLE_VISITS = Visits.TABLE_NAME; + static final String TABLE_PAGE_METADATA = PageMetadata.TABLE_NAME; + static final String TABLE_FAVICONS = Favicons.TABLE_NAME; + static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME; + static final String TABLE_READING_LIST = ReadingListItems.TABLE_NAME; + static final String TABLE_TABS = TabsProvider.TABLE_TABS; + static final String TABLE_CLIENTS = TabsProvider.TABLE_CLIENTS; + static final String TABLE_LOGINS = BrowserContract.Logins.TABLE_LOGINS; + static final String TABLE_DELETED_LOGINS = BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS; + static final String TABLE_DISABLED_HOSTS = BrowserContract.LoginsDisabledHosts.TABLE_DISABLED_HOSTS; + static final String TABLE_ANNOTATIONS = UrlAnnotations.TABLE_NAME; + + static final String VIEW_COMBINED = Combined.VIEW_NAME; + static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS; + static final String VIEW_BOOKMARKS_WITH_ANNOTATIONS = Bookmarks.VIEW_WITH_ANNOTATIONS; + static final String VIEW_HISTORY_WITH_FAVICONS = History.VIEW_WITH_FAVICONS; + static final String VIEW_COMBINED_WITH_FAVICONS = Combined.VIEW_WITH_FAVICONS; + + static final String TABLE_BOOKMARKS_JOIN_FAVICONS = TABLE_BOOKMARKS + " LEFT OUTER JOIN " + + TABLE_FAVICONS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " = " + + qualifyColumn(TABLE_FAVICONS, Favicons._ID); + + static final String TABLE_BOOKMARKS_JOIN_ANNOTATIONS = TABLE_BOOKMARKS + " JOIN " + + TABLE_ANNOTATIONS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + + qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.URL); + + static final String TABLE_HISTORY_JOIN_FAVICONS = TABLE_HISTORY + " LEFT OUTER JOIN " + + TABLE_FAVICONS + " ON " + qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " = " + + qualifyColumn(TABLE_FAVICONS, Favicons._ID); + + static final String TABLE_BOOKMARKS_TMP = TABLE_BOOKMARKS + "_tmp"; + static final String TABLE_HISTORY_TMP = TABLE_HISTORY + "_tmp"; + + private static final String[] mobileIdColumns = new String[] { Bookmarks._ID }; + private static final String[] mobileIdSelectionArgs = new String[] { Bookmarks.MOBILE_FOLDER_GUID }; + + private boolean didCreateTabsTable = false; + private boolean didCreateCurrentReadingListTable = false; + + public BrowserDatabaseHelper(Context context, String databasePath) { + super(context, databasePath, null, DATABASE_VERSION); + mContext = context; + } + + private void createBookmarksTable(SQLiteDatabase db) { + debug("Creating " + TABLE_BOOKMARKS + " table"); + + db.execSQL("CREATE TABLE " + TABLE_BOOKMARKS + "(" + + Bookmarks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Bookmarks.TITLE + " TEXT," + + Bookmarks.URL + " TEXT," + + Bookmarks.TYPE + " INTEGER NOT NULL DEFAULT " + Bookmarks.TYPE_BOOKMARK + "," + + Bookmarks.PARENT + " INTEGER," + + Bookmarks.POSITION + " INTEGER NOT NULL," + + Bookmarks.KEYWORD + " TEXT," + + Bookmarks.DESCRIPTION + " TEXT," + + Bookmarks.TAGS + " TEXT," + + Bookmarks.FAVICON_ID + " INTEGER," + + Bookmarks.DATE_CREATED + " INTEGER," + + Bookmarks.DATE_MODIFIED + " INTEGER," + + Bookmarks.GUID + " TEXT NOT NULL," + + Bookmarks.IS_DELETED + " INTEGER NOT NULL DEFAULT 0, " + + "FOREIGN KEY (" + Bookmarks.PARENT + ") REFERENCES " + + TABLE_BOOKMARKS + "(" + Bookmarks._ID + ")" + + ");"); + + db.execSQL("CREATE INDEX bookmarks_url_index ON " + TABLE_BOOKMARKS + "(" + + Bookmarks.URL + ")"); + db.execSQL("CREATE INDEX bookmarks_type_deleted_index ON " + TABLE_BOOKMARKS + "(" + + Bookmarks.TYPE + ", " + Bookmarks.IS_DELETED + ")"); + db.execSQL("CREATE UNIQUE INDEX bookmarks_guid_index ON " + TABLE_BOOKMARKS + "(" + + Bookmarks.GUID + ")"); + db.execSQL("CREATE INDEX bookmarks_modified_index ON " + TABLE_BOOKMARKS + "(" + + Bookmarks.DATE_MODIFIED + ")"); + } + + private void createHistoryTable(SQLiteDatabase db) { + debug("Creating " + TABLE_HISTORY + " table"); + db.execSQL("CREATE TABLE " + TABLE_HISTORY + "(" + + History._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + History.TITLE + " TEXT," + + History.URL + " TEXT NOT NULL," + + // Can we drop VISITS count? Can we calculate it in the Combined view as a sum? + // See Bug 1277329. + History.VISITS + " INTEGER NOT NULL DEFAULT 0," + + History.LOCAL_VISITS + " INTEGER NOT NULL DEFAULT 0," + + History.REMOTE_VISITS + " INTEGER NOT NULL DEFAULT 0," + + History.FAVICON_ID + " INTEGER," + + History.DATE_LAST_VISITED + " INTEGER," + + History.LOCAL_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0," + + History.REMOTE_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0," + + History.DATE_CREATED + " INTEGER," + + History.DATE_MODIFIED + " INTEGER," + + History.GUID + " TEXT NOT NULL," + + History.IS_DELETED + " INTEGER NOT NULL DEFAULT 0" + + ");"); + + db.execSQL("CREATE INDEX history_url_index ON " + TABLE_HISTORY + '(' + + History.URL + ')'); + db.execSQL("CREATE UNIQUE INDEX history_guid_index ON " + TABLE_HISTORY + '(' + + History.GUID + ')'); + db.execSQL("CREATE INDEX history_modified_index ON " + TABLE_HISTORY + '(' + + History.DATE_MODIFIED + ')'); + db.execSQL("CREATE INDEX history_visited_index ON " + TABLE_HISTORY + '(' + + History.DATE_LAST_VISITED + ')'); + } + + private void createVisitsTable(SQLiteDatabase db) { + debug("Creating " + TABLE_VISITS + " table"); + db.execSQL("CREATE TABLE " + TABLE_VISITS + "(" + + Visits._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Visits.HISTORY_GUID + " TEXT NOT NULL," + + Visits.VISIT_TYPE + " TINYINT NOT NULL DEFAULT 1," + + Visits.DATE_VISITED + " INTEGER NOT NULL, " + + Visits.IS_LOCAL + " TINYINT NOT NULL DEFAULT 1, " + + + "FOREIGN KEY (" + Visits.HISTORY_GUID + ") REFERENCES " + + TABLE_HISTORY + "(" + History.GUID + ") ON DELETE CASCADE ON UPDATE CASCADE" + + ");"); + + db.execSQL("CREATE UNIQUE INDEX visits_history_guid_and_date_visited_index ON " + TABLE_VISITS + "(" + + Visits.HISTORY_GUID + "," + Visits.DATE_VISITED + ")"); + db.execSQL("CREATE INDEX visits_history_guid_index ON " + TABLE_VISITS + "(" + Visits.HISTORY_GUID + ")"); + } + + private void createFaviconsTable(SQLiteDatabase db) { + debug("Creating " + TABLE_FAVICONS + " table"); + db.execSQL("CREATE TABLE " + TABLE_FAVICONS + " (" + + Favicons._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Favicons.URL + " TEXT UNIQUE," + + Favicons.DATA + " BLOB," + + Favicons.DATE_CREATED + " INTEGER," + + Favicons.DATE_MODIFIED + " INTEGER" + + ");"); + + db.execSQL("CREATE INDEX favicons_modified_index ON " + TABLE_FAVICONS + "(" + + Favicons.DATE_MODIFIED + ")"); + } + + private void createThumbnailsTable(SQLiteDatabase db) { + debug("Creating " + TABLE_THUMBNAILS + " table"); + db.execSQL("CREATE TABLE " + TABLE_THUMBNAILS + " (" + + Thumbnails._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Thumbnails.URL + " TEXT UNIQUE," + + Thumbnails.DATA + " BLOB" + + ");"); + } + + private void createPageMetadataTable(SQLiteDatabase db) { + debug("Creating " + TABLE_PAGE_METADATA + " table"); + db.execSQL("CREATE TABLE " + TABLE_PAGE_METADATA + "(" + + PageMetadata._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + PageMetadata.HISTORY_GUID + " TEXT NOT NULL," + + PageMetadata.DATE_CREATED + " INTEGER NOT NULL, " + + PageMetadata.HAS_IMAGE + " TINYINT NOT NULL DEFAULT 0, " + + PageMetadata.JSON + " TEXT NOT NULL, " + + + "FOREIGN KEY (" + Visits.HISTORY_GUID + ") REFERENCES " + + TABLE_HISTORY + "(" + History.GUID + ") ON DELETE CASCADE ON UPDATE CASCADE" + + ");"); + + // Establish a 1-to-1 relationship with History table. + db.execSQL("CREATE UNIQUE INDEX page_metadata_history_guid ON " + TABLE_PAGE_METADATA + "(" + + PageMetadata.HISTORY_GUID + ")"); + // Improve performance of commonly occurring selections. + db.execSQL("CREATE INDEX page_metadata_history_guid_and_has_image ON " + TABLE_PAGE_METADATA + "(" + + PageMetadata.HISTORY_GUID + ", " + PageMetadata.HAS_IMAGE + ")"); + } + + private void createBookmarksWithFaviconsView(SQLiteDatabase db) { + debug("Creating " + VIEW_BOOKMARKS_WITH_FAVICONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_BOOKMARKS_WITH_FAVICONS + " AS " + + "SELECT " + qualifyColumn(TABLE_BOOKMARKS, "*") + + ", " + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Bookmarks.FAVICON + + ", " + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Bookmarks.FAVICON_URL + + " FROM " + TABLE_BOOKMARKS_JOIN_FAVICONS); + } + + private void createBookmarksWithAnnotationsView(SQLiteDatabase db) { + debug("Creating " + VIEW_BOOKMARKS_WITH_ANNOTATIONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_BOOKMARKS_WITH_ANNOTATIONS + " AS " + + "SELECT " + qualifyColumn(TABLE_BOOKMARKS, "*") + + ", " + qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.KEY) + " AS " + Bookmarks.ANNOTATION_KEY + + ", " + qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.VALUE) + " AS " + Bookmarks.ANNOTATION_VALUE + + " FROM " + TABLE_BOOKMARKS_JOIN_ANNOTATIONS); + } + + private void createHistoryWithFaviconsView(SQLiteDatabase db) { + debug("Creating " + VIEW_HISTORY_WITH_FAVICONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_HISTORY_WITH_FAVICONS + " AS " + + "SELECT " + qualifyColumn(TABLE_HISTORY, "*") + + ", " + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + History.FAVICON + + ", " + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + History.FAVICON_URL + + " FROM " + TABLE_HISTORY_JOIN_FAVICONS); + } + + private void createClientsTable(SQLiteDatabase db) { + debug("Creating " + TABLE_CLIENTS + " table"); + + // Table for client's name-guid mapping. + db.execSQL("CREATE TABLE " + TABLE_CLIENTS + "(" + + BrowserContract.Clients.GUID + " TEXT PRIMARY KEY," + + BrowserContract.Clients.NAME + " TEXT," + + BrowserContract.Clients.LAST_MODIFIED + " INTEGER," + + BrowserContract.Clients.DEVICE_TYPE + " TEXT" + + ");"); + } + + private void createTabsTable(SQLiteDatabase db, final String tableName) { + debug("Creating tabs.db: " + db.getPath()); + debug("Creating " + tableName + " table"); + + // Table for each tab on any client. + db.execSQL("CREATE TABLE " + tableName + "(" + + BrowserContract.Tabs._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + BrowserContract.Tabs.CLIENT_GUID + " TEXT," + + BrowserContract.Tabs.TITLE + " TEXT," + + BrowserContract.Tabs.URL + " TEXT," + + BrowserContract.Tabs.HISTORY + " TEXT," + + BrowserContract.Tabs.FAVICON + " TEXT," + + BrowserContract.Tabs.LAST_USED + " INTEGER," + + BrowserContract.Tabs.POSITION + " INTEGER, " + + "FOREIGN KEY (" + BrowserContract.Tabs.CLIENT_GUID + ") REFERENCES " + + TABLE_CLIENTS + "(" + BrowserContract.Clients.GUID + ") ON DELETE CASCADE" + + ");"); + + didCreateTabsTable = true; + } + + private void createTabsTableIndices(SQLiteDatabase db, final String tableName) { + // Indices on CLIENT_GUID and POSITION. + db.execSQL("CREATE INDEX " + TabsProvider.INDEX_TABS_GUID + + " ON " + tableName + "(" + BrowserContract.Tabs.CLIENT_GUID + ")"); + db.execSQL("CREATE INDEX " + TabsProvider.INDEX_TABS_POSITION + + " ON " + tableName + "(" + BrowserContract.Tabs.POSITION + ")"); + } + + // Insert a client row for our local Fennec client. + private void createLocalClient(SQLiteDatabase db) { + debug("Inserting local Fennec client into " + TABLE_CLIENTS + " table"); + + ContentValues values = new ContentValues(); + values.put(BrowserContract.Clients.LAST_MODIFIED, System.currentTimeMillis()); + db.insertOrThrow(TABLE_CLIENTS, null, values); + } + + private void createCombinedViewOn19(SQLiteDatabase db) { + /* + The v19 combined view removes the redundant subquery from the v16 + combined view and reorders the columns as necessary to prevent this + from breaking any code that might be referencing columns by index. + + The rows in the ensuing view are, in order: + + Combined.BOOKMARK_ID + Combined.HISTORY_ID + Combined._ID (always 0) + Combined.URL + Combined.TITLE + Combined.VISITS + Combined.DATE_LAST_VISITED + Combined.FAVICON_ID + + We need to return an _id column because CursorAdapter requires it for its + default implementation for the getItemId() method. However, since + we're not using this feature in the parts of the UI using this view, + we can just use 0 for all rows. + */ + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" + + + // Bookmarks without history. + " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," + + "-1 AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + + "-1 AS " + Combined.VISITS + ", " + + "-1 AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + + // Ignore pinned bookmarks. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + + " UNION ALL" + + + // History with and without bookmark. + " SELECT " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + + + // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't + // completely ignore them here because they're joined with history entries we care about. + " WHEN 0 THEN " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + + " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " + + "NULL " + + "ELSE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + + " END " + + "ELSE " + + "NULL " + + "END AS " + Combined.BOOKMARK_ID + "," + + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," + + + // Prioritize bookmark titles over history titles, since the user may have + // customized the title for a bookmark. + "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + + qualifyColumn(TABLE_HISTORY, History.TITLE) + + ") AS " + Combined.TITLE + "," + + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," + + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID + + + // We really shouldn't be selecting deleted bookmarks, but oh well. + " FROM " + TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_BOOKMARKS + + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + + " WHERE " + + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " + + "(" + + // The left outer join didn't match... + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + + + // ... or it's a bookmark. This is less efficient than filtering prior + // to the join if you have lots of folders. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + + ")" + ); + + debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" + + " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON + + " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS + + " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID)); + + } + + private void createCombinedViewOn33(final SQLiteDatabase db) { + /* + Builds on top of v19 combined view, and adds the following aggregates: + - Combined.LOCAL_DATE_LAST_VISITED - last date visited for all local visits + - Combined.REMOTE_DATE_LAST_VISITED - last date visited for all remote visits + - Combined.LOCAL_VISITS_COUNT - total number of local visits + - Combined.REMOTE_VISITS_COUNT - total number of remote visits + + Any code written prior to v33 referencing columns by index directly remains intact + (yet must die a fiery death), as new columns were added to the end of the list. + + The rows in the ensuing view are, in order: + Combined.BOOKMARK_ID + Combined.HISTORY_ID + Combined._ID (always 0) + Combined.URL + Combined.TITLE + Combined.VISITS + Combined.DATE_LAST_VISITED + Combined.FAVICON_ID + Combined.LOCAL_DATE_LAST_VISITED + Combined.REMOTE_DATE_LAST_VISITED + Combined.LOCAL_VISITS_COUNT + Combined.REMOTE_VISITS_COUNT + */ + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" + + + // Bookmarks without history. + " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," + + "-1 AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + + "-1 AS " + Combined.VISITS + ", " + + "-1 AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," + + "0 AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " + + "0 AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " + + "0 AS " + Combined.LOCAL_VISITS_COUNT + ", " + + "0 AS " + Combined.REMOTE_VISITS_COUNT + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + + // Ignore pinned bookmarks. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + + " UNION ALL" + + + // History with and without bookmark. + " SELECT " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + + + // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't + // completely ignore them here because they're joined with history entries we care about. + " WHEN 0 THEN " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + + " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " + + "NULL " + + "ELSE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + + " END " + + "ELSE " + + "NULL " + + "END AS " + Combined.BOOKMARK_ID + "," + + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," + + + // Prioritize bookmark titles over history titles, since the user may have + // customized the title for a bookmark. + "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + + qualifyColumn(TABLE_HISTORY, History.TITLE) + + ") AS " + Combined.TITLE + "," + + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," + + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," + + + // Figure out "last visited" days using MAX values for visit timestamps. + // We use CASE statements here to separate local from remote visits. + "COALESCE(MAX(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " " + + "WHEN 1 THEN " + qualifyColumn(TABLE_VISITS, Visits.DATE_VISITED) + " " + + "ELSE 0 END" + + "), 0) AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " + + + "COALESCE(MAX(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " " + + "WHEN 0 THEN " + qualifyColumn(TABLE_VISITS, Visits.DATE_VISITED) + " " + + "ELSE 0 END" + + "), 0) AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " + + + // Sum up visit counts for local and remote visit types. Again, use CASE to separate the two. + "COALESCE(SUM(" + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + "), 0) AS " + Combined.LOCAL_VISITS_COUNT + ", " + + "COALESCE(SUM(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " WHEN 0 THEN 1 ELSE 0 END), 0) AS " + Combined.REMOTE_VISITS_COUNT + + + // We need to JOIN on Visits in order to compute visit counts + " FROM " + TABLE_HISTORY + " " + + "LEFT OUTER JOIN " + TABLE_VISITS + + " ON " + qualifyColumn(TABLE_HISTORY, History.GUID) + " = " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " " + + + // We really shouldn't be selecting deleted bookmarks, but oh well. + "LEFT OUTER JOIN " + TABLE_BOOKMARKS + + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + + " WHERE " + + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " + + "(" + + // The left outer join didn't match... + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + + + // ... or it's a bookmark. This is less efficient than filtering prior + // to the join if you have lots of folders. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + + + ") GROUP BY " + qualifyColumn(TABLE_HISTORY, History.GUID) + ); + + debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" + + " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON + + " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS + + " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID)); + } + + private void createCombinedViewOn34(final SQLiteDatabase db) { + /* + Builds on top of v33 combined view, and instead of calculating the following aggregates, gets them + from the history table: + - Combined.LOCAL_DATE_LAST_VISITED - last date visited for all local visits + - Combined.REMOTE_DATE_LAST_VISITED - last date visited for all remote visits + - Combined.LOCAL_VISITS_COUNT - total number of local visits + - Combined.REMOTE_VISITS_COUNT - total number of remote visits + + Any code written prior to v33 referencing columns by index directly remains intact + (yet must die a fiery death), as new columns were added to the end of the list. + + The rows in the ensuing view are, in order: + Combined.BOOKMARK_ID + Combined.HISTORY_ID + Combined._ID (always 0) + Combined.URL + Combined.TITLE + Combined.VISITS + Combined.DATE_LAST_VISITED + Combined.FAVICON_ID + Combined.LOCAL_DATE_LAST_VISITED + Combined.REMOTE_DATE_LAST_VISITED + Combined.LOCAL_VISITS_COUNT + Combined.REMOTE_VISITS_COUNT + */ + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" + + + // Bookmarks without history. + " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," + + "-1 AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + + "-1 AS " + Combined.VISITS + ", " + + "-1 AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," + + "0 AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " + + "0 AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " + + "0 AS " + Combined.LOCAL_VISITS_COUNT + ", " + + "0 AS " + Combined.REMOTE_VISITS_COUNT + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + + // Ignore pinned bookmarks. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + + " UNION ALL" + + + // History with and without bookmark. + " SELECT " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + + + // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't + // completely ignore them here because they're joined with history entries we care about. + " WHEN 0 THEN " + + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + + " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " + + "NULL " + + "ELSE " + + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + + " END " + + "ELSE " + + "NULL " + + "END AS " + Combined.BOOKMARK_ID + "," + + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," + + "0 AS " + Combined._ID + "," + + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," + + + // Prioritize bookmark titles over history titles, since the user may have + // customized the title for a bookmark. + "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + + qualifyColumn(TABLE_HISTORY, History.TITLE) + + ") AS " + Combined.TITLE + "," + + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," + + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," + + + qualifyColumn(TABLE_HISTORY, History.LOCAL_DATE_LAST_VISITED) + " AS " + Combined.LOCAL_DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_HISTORY, History.REMOTE_DATE_LAST_VISITED) + " AS " + Combined.REMOTE_DATE_LAST_VISITED + "," + + qualifyColumn(TABLE_HISTORY, History.LOCAL_VISITS) + " AS " + Combined.LOCAL_VISITS_COUNT + "," + + qualifyColumn(TABLE_HISTORY, History.REMOTE_VISITS) + " AS " + Combined.REMOTE_VISITS_COUNT + + + // We need to JOIN on Visits in order to compute visit counts + " FROM " + TABLE_HISTORY + " " + + + // We really shouldn't be selecting deleted bookmarks, but oh well. + "LEFT OUTER JOIN " + TABLE_BOOKMARKS + + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + + " WHERE " + + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " + + "(" + + // The left outer join didn't match... + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + + + // ... or it's a bookmark. This is less efficient than filtering prior + // to the join if you have lots of folders. + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + ")" + ); + + debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view"); + + db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" + + " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " + + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON + + " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS + + " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID)); + } + + private void createLoginsTable(SQLiteDatabase db, final String tableName) { + debug("Creating logins.db: " + db.getPath()); + debug("Creating " + tableName + " table"); + + // Table for each login. + db.execSQL("CREATE TABLE " + tableName + "(" + + BrowserContract.Logins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + BrowserContract.Logins.HOSTNAME + " TEXT NOT NULL," + + BrowserContract.Logins.HTTP_REALM + " TEXT," + + BrowserContract.Logins.FORM_SUBMIT_URL + " TEXT," + + BrowserContract.Logins.USERNAME_FIELD + " TEXT NOT NULL," + + BrowserContract.Logins.PASSWORD_FIELD + " TEXT NOT NULL," + + BrowserContract.Logins.ENCRYPTED_USERNAME + " TEXT NOT NULL," + + BrowserContract.Logins.ENCRYPTED_PASSWORD + " TEXT NOT NULL," + + BrowserContract.Logins.GUID + " TEXT UNIQUE NOT NULL," + + BrowserContract.Logins.ENC_TYPE + " INTEGER NOT NULL, " + + BrowserContract.Logins.TIME_CREATED + " INTEGER," + + BrowserContract.Logins.TIME_LAST_USED + " INTEGER," + + BrowserContract.Logins.TIME_PASSWORD_CHANGED + " INTEGER," + + BrowserContract.Logins.TIMES_USED + " INTEGER" + + ");"); + } + + private void createLoginsTableIndices(SQLiteDatabase db, final String tableName) { + // No need to create an index on GUID, it is an unique column. + db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME + + " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + ")"); + db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL + + " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.FORM_SUBMIT_URL + ")"); + db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_HTTP_REALM + + " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.HTTP_REALM + ")"); + } + + private void createDeletedLoginsTable(SQLiteDatabase db, final String tableName) { + debug("Creating deleted_logins.db: " + db.getPath()); + debug("Creating " + tableName + " table"); + + // Table for each deleted login. + db.execSQL("CREATE TABLE " + tableName + "(" + + BrowserContract.DeletedLogins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + BrowserContract.DeletedLogins.GUID + " TEXT UNIQUE NOT NULL," + + BrowserContract.DeletedLogins.TIME_DELETED + " INTEGER NOT NULL" + + ");"); + } + + private void createDisabledHostsTable(SQLiteDatabase db, final String tableName) { + debug("Creating disabled_hosts.db: " + db.getPath()); + debug("Creating " + tableName + " table"); + + // Table for each disabled host. + db.execSQL("CREATE TABLE " + tableName + "(" + + BrowserContract.LoginsDisabledHosts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + BrowserContract.LoginsDisabledHosts.HOSTNAME + " TEXT UNIQUE NOT NULL ON CONFLICT REPLACE" + + ");"); + } + + @Override + public void onCreate(SQLiteDatabase db) { + debug("Creating browser.db: " + db.getPath()); + + for (Table table : BrowserProvider.sTables) { + table.onCreate(db); + } + + createBookmarksTable(db); + createHistoryTable(db); + createFaviconsTable(db); + createThumbnailsTable(db); + createClientsTable(db); + createLocalClient(db); + createTabsTable(db, TABLE_TABS); + createTabsTableIndices(db, TABLE_TABS); + + + createBookmarksWithFaviconsView(db); + createHistoryWithFaviconsView(db); + + createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID, + R.string.bookmarks_folder_places, 0); + + createOrUpdateAllSpecialFolders(db); + createSearchHistoryTable(db); + createUrlAnnotationsTable(db); + createNumbersTable(db); + + createDeletedLoginsTable(db, TABLE_DELETED_LOGINS); + createDisabledHostsTable(db, TABLE_DISABLED_HOSTS); + createLoginsTable(db, TABLE_LOGINS); + createLoginsTableIndices(db, TABLE_LOGINS); + + createBookmarksWithAnnotationsView(db); + + createVisitsTable(db); + createCombinedViewOn34(db); + + createActivityStreamBlocklistTable(db); + + createPageMetadataTable(db); + } + + /** + * Copies the tabs and clients tables out of the given tabs.db file and into the destinationDB. + * + * @param tabsDBFile Path to existing tabs.db. + * @param destinationDB The destination database. + */ + public void copyTabsDB(File tabsDBFile, SQLiteDatabase destinationDB) { + createClientsTable(destinationDB); + createTabsTable(destinationDB, TABLE_TABS); + createTabsTableIndices(destinationDB, TABLE_TABS); + + SQLiteDatabase oldTabsDB = null; + try { + oldTabsDB = SQLiteDatabase.openDatabase(tabsDBFile.getPath(), null, SQLiteDatabase.OPEN_READONLY); + + if (!DBUtils.copyTable(oldTabsDB, TABLE_CLIENTS, destinationDB, TABLE_CLIENTS)) { + Log.e(LOGTAG, "Failed to migrate table clients; ignoring."); + } + if (!DBUtils.copyTable(oldTabsDB, TABLE_TABS, destinationDB, TABLE_TABS)) { + Log.e(LOGTAG, "Failed to migrate table tabs; ignoring."); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception occurred while trying to copy from " + tabsDBFile.getPath() + + " to " + destinationDB.getPath() + "; ignoring.", e); + } finally { + if (oldTabsDB != null) { + oldTabsDB.close(); + } + } + } + + /** + * We used to have a separate history extensions database which was used by Sync to store arrays + * of visits for individual History GUIDs. It was only used by Sync. + * This function migrates contents of that database over to the Visits table. + * + * Warning to callers: this method might throw IllegalStateException if we fail to allocate a + * cursor to read HistoryExtensionsDB data for whatever reason. See Bug 1280409. + * + * @param historyExtensionDb Source History Extensions database + * @param db Destination database + */ + private void copyHistoryExtensionDataToVisitsTable(final SQLiteDatabase historyExtensionDb, final SQLiteDatabase db) { + final String historyExtensionTable = "HistoryExtension"; + final String columnGuid = "guid"; + final String columnVisits = "visits"; + + final Cursor historyExtensionCursor = historyExtensionDb.query(historyExtensionTable, + new String[] {columnGuid, columnVisits}, + null, null, null, null, null); + // Ignore null or empty cursor, we can't (or have nothing to) copy at this point. + if (historyExtensionCursor == null) { + return; + } + try { + if (!historyExtensionCursor.moveToFirst()) { + return; + } + + final int guidCol = historyExtensionCursor.getColumnIndexOrThrow(columnGuid); + + // Use prepared (aka "compiled") SQL statements because they are much faster when we're inserting + // lots of data. We avoid GC churn and recompilation of SQL statements on every insert. + // NB #1: OR IGNORE clause applies to UNIQUE, NOT NULL, CHECK, and PRIMARY KEY constraints. + // It does not apply to Foreign Key constraints, but in our case, at this point in time, foreign key + // constraints are disabled anyway. + // We care about OR IGNORE because we want to ensure that in case of (GUID,DATE) + // clash (the UNIQUE constraint), we will not fail the transaction, and just skip conflicting row. + // Clash might occur if visits array we got from Sync has duplicate (guid,date) records. + // NB #2: IS_LOCAL is always 0, since we consider all visits coming from Sync to be remote. + final String insertSqlStatement = "INSERT OR IGNORE INTO " + Visits.TABLE_NAME + " (" + + Visits.DATE_VISITED + "," + + Visits.VISIT_TYPE + "," + + Visits.HISTORY_GUID + "," + + Visits.IS_LOCAL + ") VALUES (?, ?, ?, " + Visits.VISIT_IS_REMOTE + ")"; + final SQLiteStatement compiledInsertStatement = db.compileStatement(insertSqlStatement); + + do { + final String guid = historyExtensionCursor.getString(guidCol); + + // Sanity check, let's not risk a bad incoming GUID. + if (guid == null || guid.isEmpty()) { + continue; + } + + // First, check if history with given GUID exists in the History table. + // We might have a lot of entries in the HistoryExtensionDatabase whose GUID doesn't + // match one in the History table. Let's avoid doing unnecessary work by first checking if + // GUID exists locally. + // Note that we don't have foreign key constraints enabled at this point. + // See Bug 1266232 for details. + if (!isGUIDPresentInHistoryTable(db, guid)) { + continue; + } + + final JSONArray visitsInHistoryExtensionDB = RepoUtils.getJSONArrayFromCursor(historyExtensionCursor, columnVisits); + + if (visitsInHistoryExtensionDB == null) { + continue; + } + + final int histExtVisitCount = visitsInHistoryExtensionDB.size(); + + debug("Inserting " + histExtVisitCount + " visits from history extension db for GUID: " + guid); + for (int i = 0; i < histExtVisitCount; i++) { + final JSONObject visit = (JSONObject) visitsInHistoryExtensionDB.get(i); + + // Sanity check. + if (visit == null) { + continue; + } + + // Let's not rely on underlying data being correct, and guard against casting failures. + // Since we can't recover from this (other than ignoring this visit), let's not fail user's migration. + final Long date; + final Long visitType; + try { + date = (Long) visit.get("date"); + visitType = (Long) visit.get("type"); + } catch (ClassCastException e) { + continue; + } + // Sanity check our incoming data. + if (date == null || visitType == null) { + continue; + } + + // Bind parameters use a 1-based index. + compiledInsertStatement.clearBindings(); + compiledInsertStatement.bindLong(1, date); + compiledInsertStatement.bindLong(2, visitType); + compiledInsertStatement.bindString(3, guid); + compiledInsertStatement.executeInsert(); + } + } while (historyExtensionCursor.moveToNext()); + } finally { + // We return on a null cursor, so don't have to check it here. + historyExtensionCursor.close(); + } + } + + private boolean isGUIDPresentInHistoryTable(final SQLiteDatabase db, String guid) { + final Cursor historyCursor = db.query( + History.TABLE_NAME, + new String[] {History.GUID}, History.GUID + " = ?", new String[] {guid}, + null, null, null); + if (historyCursor == null) { + return false; + } + try { + // No history record found for given GUID + if (!historyCursor.moveToFirst()) { + return false; + } + } finally { + historyCursor.close(); + } + + return true; + } + + private void createSearchHistoryTable(SQLiteDatabase db) { + debug("Creating " + SearchHistory.TABLE_NAME + " table"); + + db.execSQL("CREATE TABLE " + SearchHistory.TABLE_NAME + "(" + + SearchHistory._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + SearchHistory.QUERY + " TEXT UNIQUE NOT NULL, " + + SearchHistory.DATE_LAST_VISITED + " INTEGER, " + + SearchHistory.VISITS + " INTEGER ) "); + + db.execSQL("CREATE INDEX idx_search_history_last_visited ON " + + SearchHistory.TABLE_NAME + "(" + SearchHistory.DATE_LAST_VISITED + ")"); + } + + private void createActivityStreamBlocklistTable(final SQLiteDatabase db) { + debug("Creating " + ActivityStreamBlocklist.TABLE_NAME + " table"); + + db.execSQL("CREATE TABLE " + ActivityStreamBlocklist.TABLE_NAME + "(" + + ActivityStreamBlocklist._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + ActivityStreamBlocklist.URL + " TEXT UNIQUE NOT NULL, " + + ActivityStreamBlocklist.CREATED + " INTEGER NOT NULL)"); + } + + private void createReadingListTable(final SQLiteDatabase db, final String tableName) { + debug("Creating " + TABLE_READING_LIST + " table"); + + db.execSQL("CREATE TABLE " + tableName + "(" + + ReadingListItems._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + ReadingListItems.GUID + " TEXT UNIQUE, " + // Server-assigned. + + ReadingListItems.CONTENT_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.STATUS_UNFETCHED + ", " + + ReadingListItems.SYNC_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_STATUS_NEW + ", " + + ReadingListItems.SYNC_CHANGE_FLAGS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_CHANGE_NONE + ", " + + + ReadingListItems.CLIENT_LAST_MODIFIED + " INTEGER NOT NULL, " + // Client time. + ReadingListItems.SERVER_LAST_MODIFIED + " INTEGER, " + // Server-assigned. + + // Server-assigned. + ReadingListItems.SERVER_STORED_ON + " INTEGER, " + + ReadingListItems.ADDED_ON + " INTEGER, " + // Client time. Shouldn't be null, but not enforced. Formerly DATE_CREATED. + ReadingListItems.MARKED_READ_ON + " INTEGER, " + + + // These boolean flags represent the server 'status', 'unread', 'is_article', and 'favorite' fields. + ReadingListItems.IS_DELETED + " TINYINT NOT NULL DEFAULT 0, " + + ReadingListItems.IS_ARCHIVED + " TINYINT NOT NULL DEFAULT 0, " + + ReadingListItems.IS_UNREAD + " TINYINT NOT NULL DEFAULT 1, " + + ReadingListItems.IS_ARTICLE + " TINYINT NOT NULL DEFAULT 0, " + + ReadingListItems.IS_FAVORITE + " TINYINT NOT NULL DEFAULT 0, " + + + ReadingListItems.URL + " TEXT NOT NULL, " + + ReadingListItems.TITLE + " TEXT, " + + ReadingListItems.RESOLVED_URL + " TEXT, " + + ReadingListItems.RESOLVED_TITLE + " TEXT, " + + + ReadingListItems.EXCERPT + " TEXT, " + + + ReadingListItems.ADDED_BY + " TEXT, " + + ReadingListItems.MARKED_READ_BY + " TEXT, " + + + ReadingListItems.WORD_COUNT + " INTEGER DEFAULT 0, " + + ReadingListItems.READ_POSITION + " INTEGER DEFAULT 0 " + + "); "); + + didCreateCurrentReadingListTable = true; // Mostly correct, in the absence of transactions. + } + + private void createReadingListIndices(final SQLiteDatabase db, final String tableName) { + // No need to create an index on GUID; it's a UNIQUE column. + db.execSQL("CREATE INDEX reading_list_url ON " + tableName + "(" + + ReadingListItems.URL + ")"); + db.execSQL("CREATE INDEX reading_list_content_status ON " + tableName + "(" + + ReadingListItems.CONTENT_STATUS + ")"); + } + + private void createUrlAnnotationsTable(final SQLiteDatabase db) { + debug("Creating " + UrlAnnotations.TABLE_NAME + " table"); + + db.execSQL("CREATE TABLE " + UrlAnnotations.TABLE_NAME + "(" + + UrlAnnotations._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + UrlAnnotations.URL + " TEXT NOT NULL, " + + UrlAnnotations.KEY + " TEXT NOT NULL, " + + UrlAnnotations.VALUE + " TEXT, " + + UrlAnnotations.DATE_CREATED + " INTEGER NOT NULL, " + + UrlAnnotations.DATE_MODIFIED + " INTEGER NOT NULL, " + + UrlAnnotations.SYNC_STATUS + " TINYINT NOT NULL DEFAULT " + UrlAnnotations.SyncStatus.NEW.getDBValue() + + " );"); + + db.execSQL("CREATE INDEX idx_url_annotations_url_key ON " + + UrlAnnotations.TABLE_NAME + "(" + UrlAnnotations.URL + ", " + UrlAnnotations.KEY + ")"); + } + + private void createOrUpdateAllSpecialFolders(SQLiteDatabase db) { + createOrUpdateSpecialFolder(db, Bookmarks.MOBILE_FOLDER_GUID, + R.string.bookmarks_folder_mobile, 0); + createOrUpdateSpecialFolder(db, Bookmarks.TOOLBAR_FOLDER_GUID, + R.string.bookmarks_folder_toolbar, 1); + createOrUpdateSpecialFolder(db, Bookmarks.MENU_FOLDER_GUID, + R.string.bookmarks_folder_menu, 2); + createOrUpdateSpecialFolder(db, Bookmarks.TAGS_FOLDER_GUID, + R.string.bookmarks_folder_tags, 3); + createOrUpdateSpecialFolder(db, Bookmarks.UNFILED_FOLDER_GUID, + R.string.bookmarks_folder_unfiled, 4); + createOrUpdateSpecialFolder(db, Bookmarks.PINNED_FOLDER_GUID, + R.string.bookmarks_folder_pinned, 5); + } + + private void createOrUpdateSpecialFolder(SQLiteDatabase db, + String guid, int titleId, int position) { + ContentValues values = new ContentValues(); + values.put(Bookmarks.GUID, guid); + values.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER); + values.put(Bookmarks.POSITION, position); + + if (guid.equals(Bookmarks.PLACES_FOLDER_GUID)) { + values.put(Bookmarks._ID, Bookmarks.FIXED_ROOT_ID); + } else if (guid.equals(Bookmarks.PINNED_FOLDER_GUID)) { + values.put(Bookmarks._ID, Bookmarks.FIXED_PINNED_LIST_ID); + } + + // Set the parent to 0, which sync assumes is the root + values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID); + + String title = mContext.getResources().getString(titleId); + values.put(Bookmarks.TITLE, title); + + long now = System.currentTimeMillis(); + values.put(Bookmarks.DATE_CREATED, now); + values.put(Bookmarks.DATE_MODIFIED, now); + + int updated = db.update(TABLE_BOOKMARKS, values, + Bookmarks.GUID + " = ?", + new String[] { guid }); + + if (updated == 0) { + db.insert(TABLE_BOOKMARKS, Bookmarks.GUID, values); + debug("Inserted special folder: " + guid); + } else { + debug("Updated special folder: " + guid); + } + } + + private void createNumbersTable(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + " INTEGER PRIMARY KEY AUTOINCREMENT)"); + + if (db.getVersion() >= 3007011) { // SQLite 3.7.11 + // This is only available in SQLite >= 3.7.11, see release notes: + // "Enhance the INSERT syntax to allow multiple rows to be inserted via the VALUES clause" + final String numbers = "(0),(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)"; + + db.execSQL("INSERT INTO " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + ") VALUES " + numbers); + } else { + final SQLiteStatement statement = db.compileStatement("INSERT INTO " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + ") VALUES (?)"); + + for (int i = 0; i <= Numbers.MAX_VALUE; i++) { + statement.bindLong(1, i); + statement.executeInsert(); + } + } + } + + private boolean isSpecialFolder(ContentValues values) { + String guid = values.getAsString(Bookmarks.GUID); + if (guid == null) { + return false; + } + + return guid.equals(Bookmarks.MOBILE_FOLDER_GUID) || + guid.equals(Bookmarks.MENU_FOLDER_GUID) || + guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID) || + guid.equals(Bookmarks.UNFILED_FOLDER_GUID) || + guid.equals(Bookmarks.TAGS_FOLDER_GUID); + } + + private void migrateBookmarkFolder(SQLiteDatabase db, int folderId, + BookmarkMigrator migrator) { + Cursor c = null; + + debug("Migrating bookmark folder with id = " + folderId); + + String selection = Bookmarks.PARENT + " = " + folderId; + String[] selectionArgs = null; + + boolean isRootFolder = (folderId == Bookmarks.FIXED_ROOT_ID); + + // If we're loading the root folder, we have to account for + // any previously created special folder that was created without + // setting a parent id (e.g. mobile folder) and making sure we're + // not adding any infinite recursion as root's parent is root itself. + if (isRootFolder) { + selection = Bookmarks.GUID + " != ?" + " AND (" + + selection + " OR " + Bookmarks.PARENT + " = NULL)"; + selectionArgs = new String[] { Bookmarks.PLACES_FOLDER_GUID }; + } + + List<Integer> subFolders = new ArrayList<Integer>(); + List<ContentValues> invalidSpecialEntries = new ArrayList<ContentValues>(); + + try { + c = db.query(TABLE_BOOKMARKS_TMP, + null, + selection, + selectionArgs, + null, null, null); + + // The key point here is that bookmarks should be added in + // parent order to avoid any problems with the foreign key + // in Bookmarks.PARENT. + while (c.moveToNext()) { + ContentValues values = new ContentValues(); + + // We're using a null projection in the query which + // means we're getting all columns from the table. + // It's safe to simply transform the row into the + // values to be inserted on the new table. + DatabaseUtils.cursorRowToContentValues(c, values); + + boolean isSpecialFolder = isSpecialFolder(values); + + // The mobile folder used to be created with PARENT = NULL. + // We want fix that here. + if (values.getAsLong(Bookmarks.PARENT) == null && isSpecialFolder) + values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID); + + if (isRootFolder && !isSpecialFolder) { + invalidSpecialEntries.add(values); + continue; + } + + if (migrator != null) + migrator.updateForNewTable(values); + + debug("Migrating bookmark: " + values.getAsString(Bookmarks.TITLE)); + db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values); + + Integer type = values.getAsInteger(Bookmarks.TYPE); + if (type != null && type == Bookmarks.TYPE_FOLDER) + subFolders.add(values.getAsInteger(Bookmarks._ID)); + } + } finally { + if (c != null) + c.close(); + } + + // At this point is safe to assume that the mobile folder is + // in the new table given that we've always created it on + // database creation time. + final int nInvalidSpecialEntries = invalidSpecialEntries.size(); + if (nInvalidSpecialEntries > 0) { + Integer mobileFolderId = getMobileFolderId(db); + if (mobileFolderId == null) { + Log.e(LOGTAG, "Error migrating invalid special folder entries: mobile folder id is null"); + return; + } + + debug("Found " + nInvalidSpecialEntries + " invalid special folder entries"); + for (int i = 0; i < nInvalidSpecialEntries; i++) { + ContentValues values = invalidSpecialEntries.get(i); + values.put(Bookmarks.PARENT, mobileFolderId); + + db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values); + } + } + + final int nSubFolders = subFolders.size(); + for (int i = 0; i < nSubFolders; i++) { + int subFolderId = subFolders.get(i); + migrateBookmarkFolder(db, subFolderId, migrator); + } + } + + private void migrateBookmarksTable(SQLiteDatabase db) { + migrateBookmarksTable(db, null); + } + + private void migrateBookmarksTable(SQLiteDatabase db, BookmarkMigrator migrator) { + debug("Renaming bookmarks table to " + TABLE_BOOKMARKS_TMP); + db.execSQL("ALTER TABLE " + TABLE_BOOKMARKS + + " RENAME TO " + TABLE_BOOKMARKS_TMP); + + debug("Dropping views and indexes related to " + TABLE_BOOKMARKS); + + db.execSQL("DROP INDEX IF EXISTS bookmarks_url_index"); + db.execSQL("DROP INDEX IF EXISTS bookmarks_type_deleted_index"); + db.execSQL("DROP INDEX IF EXISTS bookmarks_guid_index"); + db.execSQL("DROP INDEX IF EXISTS bookmarks_modified_index"); + + createBookmarksTable(db); + + createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID, + R.string.bookmarks_folder_places, 0); + + migrateBookmarkFolder(db, Bookmarks.FIXED_ROOT_ID, migrator); + + // Ensure all special folders exist and have the + // right folder hierarchy. + createOrUpdateAllSpecialFolders(db); + + debug("Dropping bookmarks temporary table"); + db.execSQL("DROP TABLE IF EXISTS " + TABLE_BOOKMARKS_TMP); + } + + /** + * Migrate a history table from some old version to the newest one by creating the new table and + * copying all the data over. + */ + private void migrateHistoryTable(SQLiteDatabase db) { + debug("Renaming history table to " + TABLE_HISTORY_TMP); + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " RENAME TO " + TABLE_HISTORY_TMP); + + debug("Dropping views and indexes related to " + TABLE_HISTORY); + + db.execSQL("DROP INDEX IF EXISTS history_url_index"); + db.execSQL("DROP INDEX IF EXISTS history_guid_index"); + db.execSQL("DROP INDEX IF EXISTS history_modified_index"); + db.execSQL("DROP INDEX IF EXISTS history_visited_index"); + + createHistoryTable(db); + + db.execSQL("INSERT INTO " + TABLE_HISTORY + " SELECT * FROM " + TABLE_HISTORY_TMP); + + debug("Dropping history temporary table"); + db.execSQL("DROP TABLE IF EXISTS " + TABLE_HISTORY_TMP); + } + + private void upgradeDatabaseFrom3to4(SQLiteDatabase db) { + migrateBookmarksTable(db, new BookmarkMigrator3to4()); + } + + private void upgradeDatabaseFrom6to7(SQLiteDatabase db) { + debug("Removing history visits with NULL GUIDs"); + db.execSQL("DELETE FROM " + TABLE_HISTORY + " WHERE " + History.GUID + " IS NULL"); + + migrateBookmarksTable(db); + migrateHistoryTable(db); + } + + private void upgradeDatabaseFrom7to8(SQLiteDatabase db) { + debug("Combining history entries with the same URL"); + + final String TABLE_DUPES = "duped_urls"; + final String TOTAL = "total"; + final String LATEST = "latest"; + final String WINNER = "winner"; + + db.execSQL("CREATE TEMP TABLE " + TABLE_DUPES + " AS" + + " SELECT " + History.URL + ", " + + "SUM(" + History.VISITS + ") AS " + TOTAL + ", " + + "MAX(" + History.DATE_MODIFIED + ") AS " + LATEST + ", " + + "MAX(" + History._ID + ") AS " + WINNER + + " FROM " + TABLE_HISTORY + + " GROUP BY " + History.URL + + " HAVING count(" + History.URL + ") > 1"); + + db.execSQL("CREATE UNIQUE INDEX " + TABLE_DUPES + "_url_index ON " + + TABLE_DUPES + " (" + History.URL + ")"); + + final String fromClause = " FROM " + TABLE_DUPES + " WHERE " + + qualifyColumn(TABLE_DUPES, History.URL) + " = " + + qualifyColumn(TABLE_HISTORY, History.URL); + + db.execSQL("UPDATE " + TABLE_HISTORY + + " SET " + History.VISITS + " = (SELECT " + TOTAL + fromClause + "), " + + History.DATE_MODIFIED + " = (SELECT " + LATEST + fromClause + "), " + + History.IS_DELETED + " = " + + "(" + History._ID + " <> (SELECT " + WINNER + fromClause + "))" + + " WHERE " + History.URL + " IN (SELECT " + History.URL + " FROM " + TABLE_DUPES + ")"); + + db.execSQL("DROP TABLE " + TABLE_DUPES); + } + + private void upgradeDatabaseFrom10to11(SQLiteDatabase db) { + db.execSQL("CREATE INDEX bookmarks_type_deleted_index ON " + TABLE_BOOKMARKS + "(" + + Bookmarks.TYPE + ", " + Bookmarks.IS_DELETED + ")"); + } + + private void upgradeDatabaseFrom12to13(SQLiteDatabase db) { + createFaviconsTable(db); + + // Add favicon_id column to the history/bookmarks tables. We wrap this in a try-catch + // because the column *may* already exist at this point (depending on how many upgrade + // steps have been performed in this operation). In which case these queries will throw, + // but we don't care. + try { + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " ADD COLUMN " + History.FAVICON_ID + " INTEGER"); + db.execSQL("ALTER TABLE " + TABLE_BOOKMARKS + + " ADD COLUMN " + Bookmarks.FAVICON_ID + " INTEGER"); + } catch (SQLException e) { + // Don't care. + debug("Exception adding favicon_id column. We're probably fine." + e); + } + + createThumbnailsTable(db); + + db.execSQL("DROP VIEW IF EXISTS bookmarks_with_images"); + db.execSQL("DROP VIEW IF EXISTS history_with_images"); + db.execSQL("DROP VIEW IF EXISTS combined_with_images"); + + createBookmarksWithFaviconsView(db); + createHistoryWithFaviconsView(db); + + db.execSQL("DROP TABLE IF EXISTS images"); + } + + private void upgradeDatabaseFrom13to14(SQLiteDatabase db) { + createOrUpdateSpecialFolder(db, Bookmarks.PINNED_FOLDER_GUID, + R.string.bookmarks_folder_pinned, 6); + } + + private void upgradeDatabaseFrom14to15(SQLiteDatabase db) { + Cursor c = null; + try { + // Get all the pinned bookmarks + c = db.query(TABLE_BOOKMARKS, + new String[] { Bookmarks._ID, Bookmarks.URL }, + Bookmarks.PARENT + " = ?", + new String[] { Integer.toString(Bookmarks.FIXED_PINNED_LIST_ID) }, + null, null, null); + + while (c.moveToNext()) { + // Check if this URL can be parsed as a URI with a valid scheme. + String url = c.getString(c.getColumnIndexOrThrow(Bookmarks.URL)); + if (Uri.parse(url).getScheme() != null) { + continue; + } + + // If it can't, update the URL to be an encoded "user-entered" value. + ContentValues values = new ContentValues(1); + String newUrl = Uri.fromParts("user-entered", url, null).toString(); + values.put(Bookmarks.URL, newUrl); + db.update(TABLE_BOOKMARKS, values, Bookmarks._ID + " = ?", + new String[] { Integer.toString(c.getInt(c.getColumnIndexOrThrow(Bookmarks._ID))) }); + } + } finally { + if (c != null) { + c.close(); + } + } + } + + private void upgradeDatabaseFrom15to16(SQLiteDatabase db) { + // No harm in creating the v19 combined view here: means we don't need two almost-identical + // functions to define both the v16 and v19 ones. The upgrade path will redundantly drop + // and recreate the view again. *shrug* + createV19CombinedView(db); + } + + private void upgradeDatabaseFrom16to17(SQLiteDatabase db) { + // Purge any 0-byte favicons/thumbnails + try { + db.execSQL("DELETE FROM " + TABLE_FAVICONS + + " WHERE length(" + Favicons.DATA + ") = 0"); + db.execSQL("DELETE FROM " + TABLE_THUMBNAILS + + " WHERE length(" + Thumbnails.DATA + ") = 0"); + } catch (SQLException e) { + Log.e(LOGTAG, "Error purging invalid favicons or thumbnails", e); + } + } + + /* + * Moves reading list items from 'bookmarks' table to 'reading_list' table. + */ + private void upgradeDatabaseFrom17to18(SQLiteDatabase db) { + debug("Moving reading list items from 'bookmarks' table to 'reading_list' table"); + + final String selection = Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " = ? "; + final String[] selectionArgs = { String.valueOf(Bookmarks.FIXED_READING_LIST_ID), "0" }; + final String[] projection = { Bookmarks._ID, + Bookmarks.GUID, + Bookmarks.URL, + Bookmarks.DATE_MODIFIED, + Bookmarks.DATE_CREATED, + Bookmarks.TITLE }; + + try { + db.beginTransaction(); + + // Create 'reading_list' table. + createReadingListTable(db, TABLE_READING_LIST); + + // Get all the reading list items from bookmarks table. + final Cursor cursor = db.query(TABLE_BOOKMARKS, projection, selection, selectionArgs, null, null, null); + + if (cursor == null) { + // This should never happen. + db.setTransactionSuccessful(); + return; + } + + try { + // Insert reading list items into reading_list table. + while (cursor.moveToNext()) { + debug(DatabaseUtils.dumpCurrentRowToString(cursor)); + final ContentValues values = new ContentValues(); + + // We don't preserve bookmark GUIDs. + DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.URL, values, ReadingListItems.URL); + DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.TITLE, values, ReadingListItems.TITLE); + DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_CREATED, values, ReadingListItems.ADDED_ON); + DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_MODIFIED, values, ReadingListItems.CLIENT_LAST_MODIFIED); + + db.insertOrThrow(TABLE_READING_LIST, null, values); + } + } finally { + cursor.close(); + } + + // Delete reading list items from bookmarks table. + db.delete(TABLE_BOOKMARKS, + Bookmarks.PARENT + " = ? ", + new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) }); + + // Delete reading list special folder. + db.delete(TABLE_BOOKMARKS, + Bookmarks._ID + " = ? ", + new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) }); + + // Create indices. + createReadingListIndices(db, TABLE_READING_LIST); + + // Done. + db.setTransactionSuccessful(); + } catch (SQLException e) { + Log.e(LOGTAG, "Error migrating reading list items", e); + } finally { + db.endTransaction(); + } + } + + private void upgradeDatabaseFrom18to19(SQLiteDatabase db) { + // Redefine the "combined" view... + createV19CombinedView(db); + + // Kill any history entries with NULL URL. This ostensibly can't happen... + db.execSQL("DELETE FROM " + TABLE_HISTORY + " WHERE " + History.URL + " IS NULL"); + + // Similar for bookmark types. Replaces logic from the combined view, also shouldn't happen. + db.execSQL("UPDATE " + TABLE_BOOKMARKS + " SET " + + Bookmarks.TYPE + " = " + Bookmarks.TYPE_BOOKMARK + + " WHERE " + Bookmarks.TYPE + " IS NULL"); + } + + private void upgradeDatabaseFrom19to20(SQLiteDatabase db) { + createSearchHistoryTable(db); + } + + private void upgradeDatabaseFrom21to22(SQLiteDatabase db) { + if (didCreateCurrentReadingListTable) { + debug("No need to add CONTENT_STATUS to reading list; we just created with the current schema."); + return; + } + + debug("Adding CONTENT_STATUS column to reading list table."); + + try { + db.execSQL("ALTER TABLE " + TABLE_READING_LIST + + " ADD COLUMN " + ReadingListItems.CONTENT_STATUS + + " TINYINT DEFAULT " + ReadingListItems.STATUS_UNFETCHED); + + db.execSQL("CREATE INDEX reading_list_content_status ON " + TABLE_READING_LIST + "(" + + ReadingListItems.CONTENT_STATUS + ")"); + } catch (SQLiteException e) { + // We're betting that an error here means that the table already has the column, + // so we're failing due to the duplicate column name. + Log.e(LOGTAG, "Error upgrading database from 21 to 22", e); + } + } + + private void upgradeDatabaseFrom22to23(SQLiteDatabase db) { + if (didCreateCurrentReadingListTable) { + // If we just created this table it is already in the expected >= 23 schema. Trying + // to run this migration will crash because columns that were in the <= 22 schema + // no longer exist. + debug("No need to rev reading list schema; we just created with the current schema."); + return; + } + + debug("Rewriting reading list table."); + createReadingListTable(db, "tmp_rl"); + + // Remove indexes. We don't need them now, and we'll be throwing away the table. + db.execSQL("DROP INDEX IF EXISTS reading_list_url"); + db.execSQL("DROP INDEX IF EXISTS reading_list_guid"); + db.execSQL("DROP INDEX IF EXISTS reading_list_content_status"); + + // This used to be a part of the no longer existing ReadingListProvider, since we're deleting + // this table later in the second migration, and since sync for this table never existed, + // we don't care about the device name here. + final String thisDevice = "_fake_device_name_that_will_be_discarded_in_the_next_migration_"; + db.execSQL("INSERT INTO tmp_rl (" + + // Here are the columns we can preserve. + ReadingListItems._ID + ", " + + ReadingListItems.URL + ", " + + ReadingListItems.TITLE + ", " + + ReadingListItems.RESOLVED_TITLE + ", " + // = TITLE (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE) + ReadingListItems.RESOLVED_URL + ", " + // = URL (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE) + ReadingListItems.EXCERPT + ", " + + ReadingListItems.IS_UNREAD + ", " + // = !READ + ReadingListItems.IS_DELETED + ", " + // = 0 + ReadingListItems.GUID + ", " + // = NULL + ReadingListItems.CLIENT_LAST_MODIFIED + ", " + // = DATE_MODIFIED + ReadingListItems.ADDED_ON + ", " + // = DATE_CREATED + ReadingListItems.CONTENT_STATUS + ", " + + ReadingListItems.MARKED_READ_BY + ", " + // if READ + ", = this device + ReadingListItems.ADDED_BY + // = this device + ") " + + "SELECT " + + "_id, url, title, " + + "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN title ELSE NULL END, " + // RESOLVED_TITLE. + "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN url ELSE NULL END, " + // RESOLVED_URL. + "excerpt, " + + "CASE read WHEN 1 THEN 0 ELSE 1 END, " + // IS_UNREAD. + "0, " + // IS_DELETED. + "NULL, modified, created, content_status, " + + "CASE read WHEN 1 THEN ? ELSE NULL END, " + // MARKED_READ_BY. + "?" + // ADDED_BY. + " FROM " + TABLE_READING_LIST + + " WHERE deleted = 0", + new String[] {thisDevice, thisDevice}); + + // Now switch these tables over and recreate the indices. + db.execSQL("DROP TABLE " + TABLE_READING_LIST); + db.execSQL("ALTER TABLE tmp_rl RENAME TO " + TABLE_READING_LIST); + + createReadingListIndices(db, TABLE_READING_LIST); + } + + private void upgradeDatabaseFrom23to24(SQLiteDatabase db) { + // Version 24 consolidates the tabs and clients table into browser.db. Before, they lived in tabs.db. + // It's easier to copy the existing data than to arrange for Sync to re-populate it. + try { + final File oldTabsDBFile = new File(GeckoProfile.get(mContext).getDir(), "tabs.db"); + copyTabsDB(oldTabsDBFile, db); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception copying tabs and clients data from tabs.db to browser.db; ignoring.", e); + } + + // Delete the database, the shared memory, and the log. + for (String filename : new String[] { "tabs.db", "tabs.db-shm", "tabs.db-wal" }) { + final File file = new File(GeckoProfile.get(mContext).getDir(), filename); + try { + FileUtils.delete(file); + } catch (Exception e) { + Log.e(LOGTAG, "Exception occurred while trying to delete " + file.getPath() + "; ignoring.", e); + } + } + } + + private void upgradeDatabaseFrom24to25(SQLiteDatabase db) { + if (didCreateTabsTable) { + // This migration adds a foreign key constraint (the table scheme stays identical, except + // for the new constraint) - hence it is safe to run this migration on a newly created tabs + // table - but it's unnecessary hence we should avoid doing so. + debug("No need to rev tabs schema; foreign key constraint exists."); + return; + } + + debug("Rewriting tabs table."); + createTabsTable(db, "tmp_tabs"); + + // Remove indexes. We don't need them now, and we'll be throwing away the table. + db.execSQL("DROP INDEX IF EXISTS " + TabsProvider.INDEX_TABS_GUID); + db.execSQL("DROP INDEX IF EXISTS " + TabsProvider.INDEX_TABS_POSITION); + + db.execSQL("INSERT INTO tmp_tabs (" + + // Here are the columns we can preserve. + BrowserContract.Tabs._ID + ", " + + BrowserContract.Tabs.CLIENT_GUID + ", " + + BrowserContract.Tabs.TITLE + ", " + + BrowserContract.Tabs.URL + ", " + + BrowserContract.Tabs.HISTORY + ", " + + BrowserContract.Tabs.FAVICON + ", " + + BrowserContract.Tabs.LAST_USED + ", " + + BrowserContract.Tabs.POSITION + + ") " + + "SELECT " + + "_id, client_guid, title, url, history, favicon, last_used, position" + + " FROM " + TABLE_TABS); + + // Now switch these tables over and recreate the indices. + db.execSQL("DROP TABLE " + TABLE_TABS); + db.execSQL("ALTER TABLE tmp_tabs RENAME TO " + TABLE_TABS); + createTabsTableIndices(db, TABLE_TABS); + didCreateTabsTable = true; + } + + private void upgradeDatabaseFrom25to26(SQLiteDatabase db) { + debug("Dropping unnecessary indices"); + db.execSQL("DROP INDEX IF EXISTS clients_guid_index"); + db.execSQL("DROP INDEX IF EXISTS thumbnails_url_index"); + db.execSQL("DROP INDEX IF EXISTS favicons_url_index"); + } + + private void upgradeDatabaseFrom27to28(final SQLiteDatabase db) { + debug("Adding url annotations table"); + createUrlAnnotationsTable(db); + } + + private void upgradeDatabaseFrom28to29(SQLiteDatabase db) { + debug("Adding numbers table"); + createNumbersTable(db); + } + + private void upgradeDatabaseFrom29to30(final SQLiteDatabase db) { + debug("creating logins table"); + createDeletedLoginsTable(db, TABLE_DELETED_LOGINS); + createDisabledHostsTable(db, TABLE_DISABLED_HOSTS); + createLoginsTable(db, TABLE_LOGINS); + createLoginsTableIndices(db, TABLE_LOGINS); + } + + // Get the cache path for a URL, based on the storage format in place during the 27to28 transition. + // This is a reimplementation of _toHashedPath from ReaderMode.jsm - given that we're likely + // to migrate the SavedReaderViewHelper implementation at some point, it seems safest to have a local + // implementation here - moreover this is probably faster than calling into JS. + // This is public only to allow for testing. + @RobocopTarget + public static String getReaderCacheFileNameForURL(String url) { + try { + // On KitKat and above we can use java.nio.charset.StandardCharsets.UTF_8 in place of "UTF8" + // which avoids having to handle UnsupportedCodingException + byte[] utf8 = url.getBytes("UTF8"); + + final MessageDigest digester = MessageDigest.getInstance("MD5"); + byte[] hash = digester.digest(utf8); + + final String hashString = new Base32().encodeAsString(hash); + return hashString.substring(0, hashString.indexOf('=')) + ".json"; + } catch (UnsupportedEncodingException e) { + // This should never happen + throw new IllegalStateException("UTF8 encoding not available - can't process readercache filename"); + } catch (NoSuchAlgorithmException e) { + // This should also never happen + throw new IllegalStateException("MD5 digester unavailable - can't process readercache filename"); + } + } + + /* + * Moves reading list items from the 'reading_list' table back into the 'bookmarks' table. This time the + * reading list items are placed into a "Reading List" folder, which is a subfolder of the mobile-bookmarks table. + */ + private void upgradeDatabaseFrom30to31(SQLiteDatabase db) { + // We only need to do the migration if reading-list items already exist. We could do a query of count(*) on + // TABLE_READING_LIST, however if we are doing the migration, we'll need to query all items in the reading-list, + // hence we might as well just query all items, and proceed with the migration if cursor.count > 0. + + // We try to retain the original ordering below. Our LocalReadingListAccessor actually coalesced + // SERVER_STORED_ON with ADDED_ON to determine positioning, however reading list syncing was never + // implemented hence SERVER_STORED will have always been null. + final Cursor readingListCursor = db.query(TABLE_READING_LIST, + new String[] { + ReadingListItems.URL, + ReadingListItems.TITLE, + ReadingListItems.ADDED_ON, + ReadingListItems.CLIENT_LAST_MODIFIED + }, + ReadingListItems.IS_DELETED + " = 0", + null, + null, + null, + ReadingListItems.ADDED_ON + " DESC"); + + // We'll want to walk the cache directory, so that we can (A) bookkeep readercache items + // that we want and (B) delete unneeded readercache items. (B) shouldn't actually happen, but + // is possible if there were bugs in our reader-caching code. + // We need to construct this here since we populate this map while walking the DB cursor, + // and use the map later when walking the cache. + final Map<String, String> fileToURLMap = new HashMap<>(); + + + try { + if (!readingListCursor.moveToFirst()) { + return; + } + + final Integer mobileBookmarksID = getMobileFolderId(db); + + if (mobileBookmarksID == null) { + // This folder is created either on DB creation or during the 3-4 or 6-7 migrations. + throw new IllegalStateException("mobile bookmarks folder must already exist"); + } + + final long now = System.currentTimeMillis(); + + // We try to retain the same order as the reading-list would show. We should hopefully be reading the + // items in the order they are displayed on screen (final param of db.query above), by providing + // a position we should obtain the same ordering in the bookmark folder. + long position = 0; + + final int titleColumnID = readingListCursor.getColumnIndexOrThrow(ReadingListItems.TITLE); + final int createdColumnID = readingListCursor.getColumnIndexOrThrow(ReadingListItems.ADDED_ON); + + // This isn't the most efficient implementation, but the migration is one-off, and this + // also more maintainable than the SQL equivalent (generating the guids correctly is + // difficult in SQLite). + do { + final ContentValues readingListItemValues = new ContentValues(); + + final String url = readingListCursor.getString(readingListCursor.getColumnIndexOrThrow(ReadingListItems.URL)); + + readingListItemValues.put(Bookmarks.PARENT, mobileBookmarksID); + readingListItemValues.put(Bookmarks.GUID, Utils.generateGuid()); + readingListItemValues.put(Bookmarks.URL, url); + // Title may be null, however we're expecting a String - we can generate an empty string if needed: + if (!readingListCursor.isNull(titleColumnID)) { + readingListItemValues.put(Bookmarks.TITLE, readingListCursor.getString(titleColumnID)); + } else { + readingListItemValues.put(Bookmarks.TITLE, ""); + } + readingListItemValues.put(Bookmarks.DATE_CREATED, readingListCursor.getLong(createdColumnID)); + readingListItemValues.put(Bookmarks.DATE_MODIFIED, now); + readingListItemValues.put(Bookmarks.POSITION, position); + + db.insert(TABLE_BOOKMARKS, + null, + readingListItemValues); + + final String cacheFileName = getReaderCacheFileNameForURL(url); + fileToURLMap.put(cacheFileName, url); + + position++; + } while (readingListCursor.moveToNext()); + + } finally { + readingListCursor.close(); + // We need to do this work here since we might be returning (we return early if the + // reading-list table is empty). + db.execSQL("DROP TABLE IF EXISTS " + TABLE_READING_LIST); + createBookmarksWithAnnotationsView(db); + } + + final File profileDir = GeckoProfile.get(mContext).getDir(); + final File cacheDir = new File(profileDir, "readercache"); + + // At the time of this migration the SavedReaderViewHelper becomes a 1:1 mirror of reader view + // url-annotations. This may change in future implementations, however currently we only need to care + // about standard bookmarks (untouched during this migration) and bookmarks with a reader + // view annotation (which we're creating here, and which are guaranteed to be saved offline). + // + // This is why we have to migrate the cache items (instead of cleaning the cache + // and rebuilding it). We simply don't support uncached reader view bookmarks, and we would + // break existing reading list items (they would convert into plain bookmarks without + // reader view). This helps ensure that offline content isn't lost during the migration. + if (cacheDir.exists() && cacheDir.isDirectory()) { + SavedReaderViewHelper savedReaderViewHelper = SavedReaderViewHelper.getSavedReaderViewHelper(mContext); + + // Usually we initialise the helper during onOpen(). However onUpgrade() is run before + // onOpen() hence we need to manually initialise it at this stage. + savedReaderViewHelper.loadItems(); + + for (File cacheFile : cacheDir.listFiles()) { + if (fileToURLMap.containsKey(cacheFile.getName())) { + final String url = fileToURLMap.get(cacheFile.getName()); + final String path = cacheFile.getAbsolutePath(); + long size = cacheFile.length(); + + savedReaderViewHelper.put(url, path, size); + } else { + // This should never happen, but we don't actually know whether or not orphaned + // items happened in the wild. + boolean deleted = cacheFile.delete(); + + if (!deleted) { + Log.w(LOGTAG, "Failed to delete orphaned saved reader view file."); + } + } + } + } + } + + private void upgradeDatabaseFrom31to32(final SQLiteDatabase db) { + debug("Adding visits table"); + createVisitsTable(db); + + debug("Migrating visits from history extension db into visits table"); + String historyExtensionDbName = "history_extension_database"; + + SQLiteDatabase historyExtensionDb = null; + final File historyExtensionsDatabase = mContext.getDatabasePath(historyExtensionDbName); + + // Primary goal of this migration is to improve Top Sites experience by distinguishing between + // local and remote visits. If Sync is enabled, we rely on visit data from Sync and treat it as remote. + // However, if Sync is disabled but we detect evidence that it was enabled at some point (HistoryExtensionsDB is present) + // then we synthesize visits from the History table, but we mark them all as "remote". This will ensure + // that once user starts browsing around, their Top Sites will reflect their local browsing history. + // Otherwise, we risk overwhelming their Top Sites with remote history, just as we did before this migration. + try { + // If FxAccount exists (Sync is enabled) then port data over to the Visits table. + if (FirefoxAccounts.firefoxAccountsExist(mContext)) { + try { + historyExtensionDb = SQLiteDatabase.openDatabase(historyExtensionsDatabase.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + if (historyExtensionDb != null) { + copyHistoryExtensionDataToVisitsTable(historyExtensionDb, db); + } + + // If we fail to open HistoryExtensionDatabase, then synthesize visits marking them as remote + } catch (SQLiteException e) { + Log.w(LOGTAG, "Couldn't open history extension database; synthesizing visits instead", e); + synthesizeAndInsertVisits(db, false); + + // It's possible that we might fail to copy over visit data from the HistoryExtensionsDB, + // so let's synthesize visits marking them as remote. See Bug 1280409. + } catch (IllegalStateException e) { + Log.w(LOGTAG, "Couldn't copy over history extension data; synthesizing visits instead", e); + synthesizeAndInsertVisits(db, false); + } + + // FxAccount doesn't exist, but there's evidence Sync was enabled at some point. + // Synthesize visits from History table marking them all as remote. + } else if (historyExtensionsDatabase.exists()) { + synthesizeAndInsertVisits(db, false); + + // FxAccount doesn't exist and there's no evidence sync was ever enabled. + // Synthesize visits from History table marking them all as local. + } else { + synthesizeAndInsertVisits(db, true); + } + } finally { + if (historyExtensionDb != null) { + historyExtensionDb.close(); + } + } + + // Delete history extensions database if it's present. + if (historyExtensionsDatabase.exists()) { + if (!mContext.deleteDatabase(historyExtensionDbName)) { + Log.e(LOGTAG, "Couldn't remove history extension database"); + } + } + } + + private void synthesizeAndInsertVisits(final SQLiteDatabase db, boolean markAsLocal) { + final Cursor cursor = db.query( + History.TABLE_NAME, + new String[] {History.GUID, History.VISITS, History.DATE_LAST_VISITED}, + null, null, null, null, null); + if (cursor == null) { + Log.e(LOGTAG, "Null cursor while selecting all history records"); + return; + } + + try { + if (!cursor.moveToFirst()) { + Log.e(LOGTAG, "No history records to synthesize visits for."); + return; + } + + int guidCol = cursor.getColumnIndexOrThrow(History.GUID); + int visitsCol = cursor.getColumnIndexOrThrow(History.VISITS); + int dateCol = cursor.getColumnIndexOrThrow(History.DATE_LAST_VISITED); + + // Re-use compiled SQL statements for faster inserts. + // Visit Type is going to be 1, which is the column's default value. + final String insertSqlStatement = "INSERT OR IGNORE INTO " + Visits.TABLE_NAME + "(" + + Visits.DATE_VISITED + "," + + Visits.HISTORY_GUID + "," + + Visits.IS_LOCAL + + ") VALUES (?, ?, ?)"; + final SQLiteStatement compiledInsertStatement = db.compileStatement(insertSqlStatement); + + // For each history record, insert as many visits as there are recorded in the VISITS column. + do { + final int numberOfVisits = cursor.getInt(visitsCol); + final String guid = cursor.getString(guidCol); + final long lastVisitedDate = cursor.getLong(dateCol); + + // Sanity check. + if (guid == null) { + continue; + } + + // In a strange case that lastVisitedDate is a very low number, let's not introduce + // negative timestamps into our data. + if (lastVisitedDate - numberOfVisits < 0) { + continue; + } + + for (int i = 0; i < numberOfVisits; i++) { + final long offsetVisitedDate = lastVisitedDate - i; + compiledInsertStatement.clearBindings(); + compiledInsertStatement.bindLong(1, offsetVisitedDate); + compiledInsertStatement.bindString(2, guid); + // Very old school, 1 is true and 0 is false :) + if (markAsLocal) { + compiledInsertStatement.bindLong(3, Visits.VISIT_IS_LOCAL); + } else { + compiledInsertStatement.bindLong(3, Visits.VISIT_IS_REMOTE); + } + compiledInsertStatement.executeInsert(); + } + } while (cursor.moveToNext()); + } catch (Exception e) { + Log.e(LOGTAG, "Error while synthesizing visits for history record", e); + } finally { + cursor.close(); + } + } + + private void updateHistoryTableAddVisitAggregates(final SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " ADD COLUMN " + History.LOCAL_VISITS + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " ADD COLUMN " + History.REMOTE_VISITS + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " ADD COLUMN " + History.LOCAL_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE " + TABLE_HISTORY + + " ADD COLUMN " + History.REMOTE_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0"); + } + + private void calculateHistoryTableVisitAggregates(final SQLiteDatabase db) { + // Note that we convert from microseconds (timestamps in the visits table) to milliseconds + // (timestamps in the history table). Sync works in microseconds, so for visits Fennec stores + // timestamps in microseconds as well - but the rest of the timestamps are stored in milliseconds. + db.execSQL("UPDATE " + TABLE_HISTORY + " SET " + + History.LOCAL_VISITS + " = (" + + "SELECT COALESCE(SUM(" + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + "), 0)" + + " FROM " + TABLE_VISITS + + " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + + "), " + + History.REMOTE_VISITS + " = (" + + "SELECT COALESCE(SUM(CASE " + Visits.IS_LOCAL + " WHEN 0 THEN 1 ELSE 0 END), 0)" + + " FROM " + TABLE_VISITS + + " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + + "), " + + History.LOCAL_DATE_LAST_VISITED + " = (" + + "SELECT COALESCE(MAX(CASE " + Visits.IS_LOCAL + " WHEN 1 THEN " + Visits.DATE_VISITED + " ELSE 0 END), 0) / 1000" + + " FROM " + TABLE_VISITS + + " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + + "), " + + History.REMOTE_DATE_LAST_VISITED + " = (" + + "SELECT COALESCE(MAX(CASE " + Visits.IS_LOCAL + " WHEN 0 THEN " + Visits.DATE_VISITED + " ELSE 0 END), 0) / 1000" + + " FROM " + TABLE_VISITS + + " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + + ") " + + "WHERE EXISTS " + + "(SELECT " + Visits._ID + + " FROM " + TABLE_VISITS + + " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + ")" + ); + } + + private void upgradeDatabaseFrom32to33(final SQLiteDatabase db) { + createV33CombinedView(db); + } + + private void upgradeDatabaseFrom33to34(final SQLiteDatabase db) { + updateHistoryTableAddVisitAggregates(db); + calculateHistoryTableVisitAggregates(db); + createV34CombinedView(db); + } + + private void upgradeDatabaseFrom34to35(final SQLiteDatabase db) { + createActivityStreamBlocklistTable(db); + } + + private void upgradeDatabaseFrom35to36(final SQLiteDatabase db) { + createPageMetadataTable(db); + } + + private void createV33CombinedView(final SQLiteDatabase db) { + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED); + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS); + + createCombinedViewOn33(db); + } + + private void createV34CombinedView(final SQLiteDatabase db) { + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED); + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS); + + createCombinedViewOn34(db); + } + + private void createV19CombinedView(SQLiteDatabase db) { + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED); + db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS); + + createCombinedViewOn19(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + debug("Upgrading browser.db: " + db.getPath() + " from " + + oldVersion + " to " + newVersion); + + // We have to do incremental upgrades until we reach the current + // database schema version. + for (int v = oldVersion + 1; v <= newVersion; v++) { + switch (v) { + case 4: + upgradeDatabaseFrom3to4(db); + break; + + case 7: + upgradeDatabaseFrom6to7(db); + break; + + case 8: + upgradeDatabaseFrom7to8(db); + break; + + case 11: + upgradeDatabaseFrom10to11(db); + break; + + case 13: + upgradeDatabaseFrom12to13(db); + break; + + case 14: + upgradeDatabaseFrom13to14(db); + break; + + case 15: + upgradeDatabaseFrom14to15(db); + break; + + case 16: + upgradeDatabaseFrom15to16(db); + break; + + case 17: + upgradeDatabaseFrom16to17(db); + break; + + case 18: + upgradeDatabaseFrom17to18(db); + break; + + case 19: + upgradeDatabaseFrom18to19(db); + break; + + case 20: + upgradeDatabaseFrom19to20(db); + break; + + case 22: + upgradeDatabaseFrom21to22(db); + break; + + case 23: + upgradeDatabaseFrom22to23(db); + break; + + case 24: + upgradeDatabaseFrom23to24(db); + break; + + case 25: + upgradeDatabaseFrom24to25(db); + break; + + case 26: + upgradeDatabaseFrom25to26(db); + break; + + // case 27 occurs in UrlMetadataTable.onUpgrade + + case 28: + upgradeDatabaseFrom27to28(db); + break; + + case 29: + upgradeDatabaseFrom28to29(db); + break; + + case 30: + upgradeDatabaseFrom29to30(db); + break; + + case 31: + upgradeDatabaseFrom30to31(db); + break; + + case 32: + upgradeDatabaseFrom31to32(db); + break; + + case 33: + upgradeDatabaseFrom32to33(db); + break; + + case 34: + upgradeDatabaseFrom33to34(db); + break; + + case 35: + upgradeDatabaseFrom34to35(db); + break; + + case 36: + upgradeDatabaseFrom35to36(db); + break; + } + } + + for (Table table : BrowserProvider.sTables) { + table.onUpgrade(db, oldVersion, newVersion); + } + + // Delete the obsolete favicon database after all other upgrades complete. + // This can probably equivalently be moved into upgradeDatabaseFrom12to13. + if (oldVersion < 13 && newVersion >= 13) { + if (mContext.getDatabasePath("favicon_urls.db").exists()) { + mContext.deleteDatabase("favicon_urls.db"); + } + } + } + + @Override + public void onOpen(SQLiteDatabase db) { + debug("Opening browser.db: " + db.getPath()); + + // Force explicit readercache loading - we won't access readercache state for bookmarks + // until we actually know what our bookmarks are. Bookmarks are stored in the DB, hence + // it is sufficient to ensure that the readercache is loaded before the DB can be accessed. + // Note, this takes ~4-6ms to load on an N4 (compared to 20-50ms for most DB queries), and + // is only done once, hence this shouldn't have noticeable impact on performance. Moreover + // this is run on a background thread and therefore won't block UI code during startup. + SavedReaderViewHelper.getSavedReaderViewHelper(mContext).loadItems(); + + Cursor cursor = null; + try { + cursor = db.rawQuery("PRAGMA foreign_keys=ON", null); + } finally { + if (cursor != null) + cursor.close(); + } + cursor = null; + try { + cursor = db.rawQuery("PRAGMA synchronous=NORMAL", null); + } finally { + if (cursor != null) + cursor.close(); + } + + // From Honeycomb on, it's possible to run several db + // commands in parallel using multiple connections. + if (Build.VERSION.SDK_INT >= 11) { + // Modern Android allows WAL to be enabled through a mode flag. + if (Build.VERSION.SDK_INT < 16) { + db.enableWriteAheadLogging(); + + // This does nothing on 16+. + db.setLockingEnabled(false); + } + } else { + // Pre-Honeycomb, we can do some lesser optimizations. + cursor = null; + try { + cursor = db.rawQuery("PRAGMA journal_mode=PERSIST", null); + } finally { + if (cursor != null) + cursor.close(); + } + } + } + + // Calculate these once, at initialization. isLoggable is too expensive to + // have in-line in each log call. + private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); + private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); + protected static void trace(String message) { + if (logVerbose) { + Log.v(LOGTAG, message); + } + } + + protected static void debug(String message) { + if (logDebug) { + Log.d(LOGTAG, message); + } + } + + private Integer getMobileFolderId(SQLiteDatabase db) { + Cursor c = null; + + try { + c = db.query(TABLE_BOOKMARKS, + mobileIdColumns, + Bookmarks.GUID + " = ?", + mobileIdSelectionArgs, + null, null, null); + + if (c == null || !c.moveToFirst()) + return null; + + return c.getInt(c.getColumnIndex(Bookmarks._ID)); + } finally { + if (c != null) + c.close(); + } + } + + private interface BookmarkMigrator { + public void updateForNewTable(ContentValues bookmark); + } + + private class BookmarkMigrator3to4 implements BookmarkMigrator { + @Override + public void updateForNewTable(ContentValues bookmark) { + Integer isFolder = bookmark.getAsInteger("folder"); + if (isFolder == null || isFolder != 1) { + bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_BOOKMARK); + } else { + bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER); + } + + bookmark.remove("folder"); + } + } +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java new file mode 100644 index 000000000..eb75d0be9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java @@ -0,0 +1,2340 @@ +/* -*- 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 java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.BrowserContract.Combined; +import org.mozilla.gecko.db.BrowserContract.FaviconColumns; +import org.mozilla.gecko.db.BrowserContract.Favicons; +import org.mozilla.gecko.db.BrowserContract.Highlights; +import org.mozilla.gecko.db.BrowserContract.History; +import org.mozilla.gecko.db.BrowserContract.Visits; +import org.mozilla.gecko.db.BrowserContract.Schema; +import org.mozilla.gecko.db.BrowserContract.Tabs; +import org.mozilla.gecko.db.BrowserContract.Thumbnails; +import org.mozilla.gecko.db.BrowserContract.TopSites; +import org.mozilla.gecko.db.BrowserContract.UrlAnnotations; +import org.mozilla.gecko.db.BrowserContract.PageMetadata; +import org.mozilla.gecko.db.DBUtils.UpdateOperation; +import org.mozilla.gecko.icons.IconsHelper; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.BroadcastReceiver; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.OperationApplicationException; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteCursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +public class BrowserProvider extends SharedBrowserDatabaseProvider { + public static final String ACTION_SHRINK_MEMORY = "org.mozilla.gecko.db.intent.action.SHRINK_MEMORY"; + + private static final String LOGTAG = "GeckoBrowserProvider"; + + // How many records to reposition in a single query. + // This should be less than the SQLite maximum number of query variables + // (currently 999) divided by the number of variables used per positioning + // query (currently 3). + static final int MAX_POSITION_UPDATES_PER_QUERY = 100; + + // Minimum number of records to keep when expiring history. + static final int DEFAULT_EXPIRY_RETAIN_COUNT = 2000; + static final int AGGRESSIVE_EXPIRY_RETAIN_COUNT = 500; + + // Factor used to determine the minimum number of records to keep when expiring the activity stream blocklist + static final int ACTIVITYSTREAM_BLOCKLIST_EXPIRY_FACTOR = 5; + + // Minimum duration to keep when expiring. + static final long DEFAULT_EXPIRY_PRESERVE_WINDOW = 1000L * 60L * 60L * 24L * 28L; // Four weeks. + // Minimum number of thumbnails to keep around. + static final int DEFAULT_EXPIRY_THUMBNAIL_COUNT = 15; + + static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME; + static final String TABLE_HISTORY = History.TABLE_NAME; + static final String TABLE_VISITS = Visits.TABLE_NAME; + static final String TABLE_FAVICONS = Favicons.TABLE_NAME; + static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME; + static final String TABLE_TABS = Tabs.TABLE_NAME; + static final String TABLE_URL_ANNOTATIONS = UrlAnnotations.TABLE_NAME; + static final String TABLE_ACTIVITY_STREAM_BLOCKLIST = ActivityStreamBlocklist.TABLE_NAME; + static final String TABLE_PAGE_METADATA = PageMetadata.TABLE_NAME; + + static final String VIEW_COMBINED = Combined.VIEW_NAME; + static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS; + static final String VIEW_BOOKMARKS_WITH_ANNOTATIONS = Bookmarks.VIEW_WITH_ANNOTATIONS; + static final String VIEW_HISTORY_WITH_FAVICONS = History.VIEW_WITH_FAVICONS; + static final String VIEW_COMBINED_WITH_FAVICONS = Combined.VIEW_WITH_FAVICONS; + + // Bookmark matches + static final int BOOKMARKS = 100; + static final int BOOKMARKS_ID = 101; + static final int BOOKMARKS_FOLDER_ID = 102; + static final int BOOKMARKS_PARENT = 103; + static final int BOOKMARKS_POSITIONS = 104; + + // History matches + static final int HISTORY = 200; + static final int HISTORY_ID = 201; + static final int HISTORY_OLD = 202; + + // Favicon matches + static final int FAVICONS = 300; + static final int FAVICON_ID = 301; + + // Schema matches + static final int SCHEMA = 400; + + // Combined bookmarks and history matches + static final int COMBINED = 500; + + // Control matches + static final int CONTROL = 600; + + // Search Suggest matches. Obsolete. + static final int SEARCH_SUGGEST = 700; + + // Thumbnail matches + static final int THUMBNAILS = 800; + static final int THUMBNAIL_ID = 801; + + static final int URL_ANNOTATIONS = 900; + + static final int TOPSITES = 1000; + + static final int VISITS = 1100; + + static final int METADATA = 1200; + + static final int HIGHLIGHTS = 1300; + + static final int ACTIVITY_STREAM_BLOCKLIST = 1400; + + static final int PAGE_METADATA = 1500; + + static final String DEFAULT_BOOKMARKS_SORT_ORDER = Bookmarks.TYPE + + " ASC, " + Bookmarks.POSITION + " ASC, " + Bookmarks._ID + + " ASC"; + + static final String DEFAULT_HISTORY_SORT_ORDER = History.DATE_LAST_VISITED + " DESC"; + static final String DEFAULT_VISITS_SORT_ORDER = Visits.DATE_VISITED + " DESC"; + + static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + static final Map<String, String> BOOKMARKS_PROJECTION_MAP; + static final Map<String, String> HISTORY_PROJECTION_MAP; + static final Map<String, String> COMBINED_PROJECTION_MAP; + static final Map<String, String> SCHEMA_PROJECTION_MAP; + static final Map<String, String> FAVICONS_PROJECTION_MAP; + static final Map<String, String> THUMBNAILS_PROJECTION_MAP; + static final Map<String, String> URL_ANNOTATIONS_PROJECTION_MAP; + static final Map<String, String> VISIT_PROJECTION_MAP; + static final Map<String, String> PAGE_METADATA_PROJECTION_MAP; + static final Table[] sTables; + + static { + sTables = new Table[] { + // See awful shortcut assumption hack in getURLMetadataTable. + new URLMetadataTable() + }; + // We will reuse this. + HashMap<String, String> map; + + // Bookmarks + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks", BOOKMARKS); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/#", BOOKMARKS_ID); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/parents", BOOKMARKS_PARENT); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/positions", BOOKMARKS_POSITIONS); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/folder/#", BOOKMARKS_FOLDER_ID); + + map = new HashMap<String, String>(); + map.put(Bookmarks._ID, Bookmarks._ID); + map.put(Bookmarks.TITLE, Bookmarks.TITLE); + map.put(Bookmarks.URL, Bookmarks.URL); + map.put(Bookmarks.FAVICON, Bookmarks.FAVICON); + map.put(Bookmarks.FAVICON_ID, Bookmarks.FAVICON_ID); + map.put(Bookmarks.FAVICON_URL, Bookmarks.FAVICON_URL); + map.put(Bookmarks.TYPE, Bookmarks.TYPE); + map.put(Bookmarks.PARENT, Bookmarks.PARENT); + map.put(Bookmarks.POSITION, Bookmarks.POSITION); + map.put(Bookmarks.TAGS, Bookmarks.TAGS); + map.put(Bookmarks.DESCRIPTION, Bookmarks.DESCRIPTION); + map.put(Bookmarks.KEYWORD, Bookmarks.KEYWORD); + map.put(Bookmarks.DATE_CREATED, Bookmarks.DATE_CREATED); + map.put(Bookmarks.DATE_MODIFIED, Bookmarks.DATE_MODIFIED); + map.put(Bookmarks.GUID, Bookmarks.GUID); + map.put(Bookmarks.IS_DELETED, Bookmarks.IS_DELETED); + BOOKMARKS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // History + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history", HISTORY); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/#", HISTORY_ID); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/old", HISTORY_OLD); + + map = new HashMap<String, String>(); + map.put(History._ID, History._ID); + map.put(History.TITLE, History.TITLE); + map.put(History.URL, History.URL); + map.put(History.FAVICON, History.FAVICON); + map.put(History.FAVICON_ID, History.FAVICON_ID); + map.put(History.FAVICON_URL, History.FAVICON_URL); + map.put(History.VISITS, History.VISITS); + map.put(History.LOCAL_VISITS, History.LOCAL_VISITS); + map.put(History.REMOTE_VISITS, History.REMOTE_VISITS); + map.put(History.DATE_LAST_VISITED, History.DATE_LAST_VISITED); + map.put(History.LOCAL_DATE_LAST_VISITED, History.LOCAL_DATE_LAST_VISITED); + map.put(History.REMOTE_DATE_LAST_VISITED, History.REMOTE_DATE_LAST_VISITED); + map.put(History.DATE_CREATED, History.DATE_CREATED); + map.put(History.DATE_MODIFIED, History.DATE_MODIFIED); + map.put(History.GUID, History.GUID); + map.put(History.IS_DELETED, History.IS_DELETED); + HISTORY_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // Visits + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "visits", VISITS); + + map = new HashMap<String, String>(); + map.put(Visits._ID, Visits._ID); + map.put(Visits.HISTORY_GUID, Visits.HISTORY_GUID); + map.put(Visits.VISIT_TYPE, Visits.VISIT_TYPE); + map.put(Visits.DATE_VISITED, Visits.DATE_VISITED); + map.put(Visits.IS_LOCAL, Visits.IS_LOCAL); + VISIT_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // Favicons + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons", FAVICONS); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons/#", FAVICON_ID); + + map = new HashMap<String, String>(); + map.put(Favicons._ID, Favicons._ID); + map.put(Favicons.URL, Favicons.URL); + map.put(Favicons.DATA, Favicons.DATA); + map.put(Favicons.DATE_CREATED, Favicons.DATE_CREATED); + map.put(Favicons.DATE_MODIFIED, Favicons.DATE_MODIFIED); + FAVICONS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // Thumbnails + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "thumbnails", THUMBNAILS); + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "thumbnails/#", THUMBNAIL_ID); + + map = new HashMap<String, String>(); + map.put(Thumbnails._ID, Thumbnails._ID); + map.put(Thumbnails.URL, Thumbnails.URL); + map.put(Thumbnails.DATA, Thumbnails.DATA); + THUMBNAILS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // Url annotations + URI_MATCHER.addURI(BrowserContract.AUTHORITY, TABLE_URL_ANNOTATIONS, URL_ANNOTATIONS); + + map = new HashMap<>(); + map.put(UrlAnnotations._ID, UrlAnnotations._ID); + map.put(UrlAnnotations.URL, UrlAnnotations.URL); + map.put(UrlAnnotations.KEY, UrlAnnotations.KEY); + map.put(UrlAnnotations.VALUE, UrlAnnotations.VALUE); + map.put(UrlAnnotations.DATE_CREATED, UrlAnnotations.DATE_CREATED); + map.put(UrlAnnotations.DATE_MODIFIED, UrlAnnotations.DATE_MODIFIED); + map.put(UrlAnnotations.SYNC_STATUS, UrlAnnotations.SYNC_STATUS); + URL_ANNOTATIONS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + // Combined bookmarks and history + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "combined", COMBINED); + + map = new HashMap<String, String>(); + map.put(Combined._ID, Combined._ID); + map.put(Combined.BOOKMARK_ID, Combined.BOOKMARK_ID); + map.put(Combined.HISTORY_ID, Combined.HISTORY_ID); + map.put(Combined.URL, Combined.URL); + map.put(Combined.TITLE, Combined.TITLE); + map.put(Combined.VISITS, Combined.VISITS); + map.put(Combined.DATE_LAST_VISITED, Combined.DATE_LAST_VISITED); + map.put(Combined.FAVICON, Combined.FAVICON); + map.put(Combined.FAVICON_ID, Combined.FAVICON_ID); + map.put(Combined.FAVICON_URL, Combined.FAVICON_URL); + map.put(Combined.LOCAL_DATE_LAST_VISITED, Combined.LOCAL_DATE_LAST_VISITED); + map.put(Combined.REMOTE_DATE_LAST_VISITED, Combined.REMOTE_DATE_LAST_VISITED); + map.put(Combined.LOCAL_VISITS_COUNT, Combined.LOCAL_VISITS_COUNT); + map.put(Combined.REMOTE_VISITS_COUNT, Combined.REMOTE_VISITS_COUNT); + COMBINED_PROJECTION_MAP = Collections.unmodifiableMap(map); + + map = new HashMap<>(); + map.put(PageMetadata._ID, PageMetadata._ID); + map.put(PageMetadata.HISTORY_GUID, PageMetadata.HISTORY_GUID); + map.put(PageMetadata.DATE_CREATED, PageMetadata.DATE_CREATED); + map.put(PageMetadata.HAS_IMAGE, PageMetadata.HAS_IMAGE); + map.put(PageMetadata.JSON, PageMetadata.JSON); + PAGE_METADATA_PROJECTION_MAP = Collections.unmodifiableMap(map); + + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "page_metadata", PAGE_METADATA); + + // Schema + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "schema", SCHEMA); + + map = new HashMap<String, String>(); + map.put(Schema.VERSION, Schema.VERSION); + SCHEMA_PROJECTION_MAP = Collections.unmodifiableMap(map); + + + // Control + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "control", CONTROL); + + for (Table table : sTables) { + for (Table.ContentProviderInfo type : table.getContentProviderInfo()) { + URI_MATCHER.addURI(BrowserContract.AUTHORITY, type.name, type.id); + } + } + + // Combined pinned sites, top visited sites, and suggested sites + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "topsites", TOPSITES); + + URI_MATCHER.addURI(BrowserContract.AUTHORITY, "highlights", HIGHLIGHTS); + + URI_MATCHER.addURI(BrowserContract.AUTHORITY, ActivityStreamBlocklist.TABLE_NAME, ACTIVITY_STREAM_BLOCKLIST); + } + + private static class ShrinkMemoryReceiver extends BroadcastReceiver { + private final WeakReference<BrowserProvider> mBrowserProviderWeakReference; + + public ShrinkMemoryReceiver(final BrowserProvider browserProvider) { + mBrowserProviderWeakReference = new WeakReference<>(browserProvider); + } + + @Override + public void onReceive(Context context, Intent intent) { + final BrowserProvider browserProvider = mBrowserProviderWeakReference.get(); + if (browserProvider == null) { + return; + } + final PerProfileDatabases<BrowserDatabaseHelper> databases = browserProvider.getDatabases(); + if (databases == null) { + return; + } + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + databases.shrinkMemory(); + } + }); + } + } + + private final ShrinkMemoryReceiver mShrinkMemoryReceiver = new ShrinkMemoryReceiver(this); + + @Override + public boolean onCreate() { + if (!super.onCreate()) { + return false; + } + + LocalBroadcastManager.getInstance(getContext()).registerReceiver(mShrinkMemoryReceiver, + new IntentFilter(ACTION_SHRINK_MEMORY)); + + return true; + } + + @Override + public void shutdown() { + LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(mShrinkMemoryReceiver); + + super.shutdown(); + } + + // Convenience accessor. + // Assumes structure of sTables! + private URLMetadataTable getURLMetadataTable() { + return (URLMetadataTable) sTables[0]; + } + + private static boolean hasFaviconsInProjection(String[] projection) { + if (projection == null) return true; + for (int i = 0; i < projection.length; ++i) { + if (projection[i].equals(FaviconColumns.FAVICON) || + projection[i].equals(FaviconColumns.FAVICON_URL)) + return true; + } + + return false; + } + + // Calculate these once, at initialization. isLoggable is too expensive to + // have in-line in each log call. + private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); + private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); + protected static void trace(String message) { + if (logVerbose) { + Log.v(LOGTAG, message); + } + } + + protected static void debug(String message) { + if (logDebug) { + Log.d(LOGTAG, message); + } + } + + /** + * Remove enough activity stream blocklist items to bring the database count below <code>retain</code>. + * + * Items will be removed according to their creation date, oldest being removed first. + */ + private void expireActivityStreamBlocklist(final SQLiteDatabase db, final int retain) { + Log.d(LOGTAG, "Expiring highlights blocklist."); + final long rows = DatabaseUtils.queryNumEntries(db, TABLE_ACTIVITY_STREAM_BLOCKLIST); + + if (retain >= rows) { + debug("Not expiring highlights blocklist: only have " + rows + " rows."); + return; + } + + final long toRemove = rows - retain; + + final String statement = "DELETE FROM " + TABLE_ACTIVITY_STREAM_BLOCKLIST + " WHERE " + ActivityStreamBlocklist._ID + " IN " + + " ( SELECT " + ActivityStreamBlocklist._ID + " FROM " + TABLE_ACTIVITY_STREAM_BLOCKLIST + " " + + "ORDER BY " + ActivityStreamBlocklist.CREATED + " ASC LIMIT " + toRemove + ")"; + + beginWrite(db); + db.execSQL(statement); + } + + /** + * Remove enough history items to bring the database count below <code>retain</code>, + * removing no items with a modified time after <code>keepAfter</code>. + * + * Provide <code>keepAfter</code> less than or equal to zero to skip that check. + * + * Items will be removed according to last visited date. + */ + private void expireHistory(final SQLiteDatabase db, final int retain, final long keepAfter) { + Log.d(LOGTAG, "Expiring history."); + final long rows = DatabaseUtils.queryNumEntries(db, TABLE_HISTORY); + + if (retain >= rows) { + debug("Not expiring history: only have " + rows + " rows."); + return; + } + + final long toRemove = rows - retain; + debug("Expiring at most " + toRemove + " rows earlier than " + keepAfter + "."); + + final String sql; + if (keepAfter > 0) { + sql = "DELETE FROM " + TABLE_HISTORY + " " + + "WHERE MAX(" + History.DATE_LAST_VISITED + ", " + History.DATE_MODIFIED + ") < " + keepAfter + " " + + " AND " + History._ID + " IN ( SELECT " + + History._ID + " FROM " + TABLE_HISTORY + " " + + "ORDER BY " + History.DATE_LAST_VISITED + " ASC LIMIT " + toRemove + + ")"; + } else { + sql = "DELETE FROM " + TABLE_HISTORY + " WHERE " + History._ID + " " + + "IN ( SELECT " + History._ID + " FROM " + TABLE_HISTORY + " " + + "ORDER BY " + History.DATE_LAST_VISITED + " ASC LIMIT " + toRemove + ")"; + } + trace("Deleting using query: " + sql); + + beginWrite(db); + db.execSQL(sql); + } + + /** + * Remove any thumbnails that for sites that aren't likely to be ever shown. + * Items will be removed according to a frecency calculation and only if they are not pinned + * + * Call this method within a transaction. + */ + private void expireThumbnails(final SQLiteDatabase db) { + Log.d(LOGTAG, "Expiring thumbnails."); + final String sortOrder = BrowserContract.getCombinedFrecencySortOrder(true, false); + final String sql = "DELETE FROM " + TABLE_THUMBNAILS + + " WHERE " + Thumbnails.URL + " NOT IN ( " + + " SELECT " + Combined.URL + + " FROM " + Combined.VIEW_NAME + + " ORDER BY " + sortOrder + + " LIMIT " + DEFAULT_EXPIRY_THUMBNAIL_COUNT + + ") AND " + Thumbnails.URL + " NOT IN ( " + + " SELECT " + Bookmarks.URL + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + Bookmarks.PARENT + " = " + Bookmarks.FIXED_PINNED_LIST_ID + + ") AND " + Thumbnails.URL + " NOT IN ( " + + " SELECT " + Tabs.URL + + " FROM " + TABLE_TABS + + ")"; + trace("Clear thumbs using query: " + sql); + db.execSQL(sql); + } + + private boolean shouldIncrementVisits(Uri uri) { + String incrementVisits = uri.getQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS); + return Boolean.parseBoolean(incrementVisits); + } + + private boolean shouldIncrementRemoteAggregates(Uri uri) { + final String incrementRemoteAggregates = uri.getQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES); + return Boolean.parseBoolean(incrementRemoteAggregates); + } + + @Override + public String getType(Uri uri) { + final int match = URI_MATCHER.match(uri); + + trace("Getting URI type: " + uri); + + switch (match) { + case BOOKMARKS: + trace("URI is BOOKMARKS: " + uri); + return Bookmarks.CONTENT_TYPE; + case BOOKMARKS_ID: + trace("URI is BOOKMARKS_ID: " + uri); + return Bookmarks.CONTENT_ITEM_TYPE; + case HISTORY: + trace("URI is HISTORY: " + uri); + return History.CONTENT_TYPE; + case HISTORY_ID: + trace("URI is HISTORY_ID: " + uri); + return History.CONTENT_ITEM_TYPE; + default: + String type = getContentItemType(match); + if (type != null) { + trace("URI is " + type); + return type; + } + + debug("URI has unrecognized type: " + uri); + return null; + } + } + + @SuppressWarnings("fallthrough") + @Override + public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) { + trace("Calling delete in transaction on URI: " + uri); + final SQLiteDatabase db = getWritableDatabase(uri); + + final int match = URI_MATCHER.match(uri); + int deleted = 0; + + switch (match) { + case BOOKMARKS_ID: + trace("Delete on BOOKMARKS_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case BOOKMARKS: { + trace("Deleting bookmarks: " + uri); + deleted = deleteBookmarks(uri, selection, selectionArgs); + deleteUnusedImages(uri); + break; + } + + case HISTORY_ID: + trace("Delete on HISTORY_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case HISTORY: { + trace("Deleting history: " + uri); + beginWrite(db); + /** + * Deletes from Sync are actual DELETE statements, which will cascade delete relevant visits. + * Fennec's deletes mark records as deleted and wipe out all information (except for GUID). + * Eventually, Fennec will purge history records that were marked as deleted for longer than some + * period of time (e.g. 20 days). + * See {@link SharedBrowserDatabaseProvider#cleanUpSomeDeletedRecords(Uri, String)}. + */ + final ArrayList<String> historyGUIDs = getHistoryGUIDsFromSelection(db, uri, selection, selectionArgs); + + if (!isCallerSync(uri)) { + deleteVisitsForHistory(db, historyGUIDs); + } + deletePageMetadataForHistory(db, historyGUIDs); + deleted = deleteHistory(db, uri, selection, selectionArgs); + deleteUnusedImages(uri); + break; + } + + case VISITS: + trace("Deleting visits: " + uri); + beginWrite(db); + deleted = deleteVisits(uri, selection, selectionArgs); + break; + + case HISTORY_OLD: { + String priority = uri.getQueryParameter(BrowserContract.PARAM_EXPIRE_PRIORITY); + long keepAfter = System.currentTimeMillis() - DEFAULT_EXPIRY_PRESERVE_WINDOW; + int retainCount = DEFAULT_EXPIRY_RETAIN_COUNT; + + if (BrowserContract.ExpirePriority.AGGRESSIVE.toString().equals(priority)) { + keepAfter = 0; + retainCount = AGGRESSIVE_EXPIRY_RETAIN_COUNT; + } + expireHistory(db, retainCount, keepAfter); + expireActivityStreamBlocklist(db, retainCount / ACTIVITYSTREAM_BLOCKLIST_EXPIRY_FACTOR); + expireThumbnails(db); + deleteUnusedImages(uri); + break; + } + + case FAVICON_ID: + debug("Delete on FAVICON_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_FAVICONS + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case FAVICONS: { + trace("Deleting favicons: " + uri); + beginWrite(db); + deleted = deleteFavicons(uri, selection, selectionArgs); + break; + } + + case THUMBNAIL_ID: + debug("Delete on THUMBNAIL_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_THUMBNAILS + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case THUMBNAILS: { + trace("Deleting thumbnails: " + uri); + beginWrite(db); + deleted = deleteThumbnails(uri, selection, selectionArgs); + break; + } + + case URL_ANNOTATIONS: + trace("Delete on URL_ANNOTATIONS: " + uri); + deleteUrlAnnotation(uri, selection, selectionArgs); + break; + + case PAGE_METADATA: + trace("Delete on PAGE_METADATA: " + uri); + deleted = deletePageMetadata(uri, selection, selectionArgs); + break; + + default: { + Table table = findTableFor(match); + if (table == null) { + throw new UnsupportedOperationException("Unknown delete URI " + uri); + } + trace("Deleting TABLE: " + uri); + beginWrite(db); + deleted = table.delete(db, uri, match, selection, selectionArgs); + } + } + + debug("Deleted " + deleted + " rows for URI: " + uri); + + return deleted; + } + + @Override + public Uri insertInTransaction(Uri uri, ContentValues values) { + trace("Calling insert in transaction on URI: " + uri); + + int match = URI_MATCHER.match(uri); + long id = -1; + + switch (match) { + case BOOKMARKS: { + trace("Insert on BOOKMARKS: " + uri); + id = insertBookmark(uri, values); + break; + } + + case HISTORY: { + trace("Insert on HISTORY: " + uri); + id = insertHistory(uri, values); + break; + } + + case VISITS: { + trace("Insert on VISITS: " + uri); + id = insertVisit(uri, values); + break; + } + + case FAVICONS: { + trace("Insert on FAVICONS: " + uri); + id = insertFavicon(uri, values); + break; + } + + case THUMBNAILS: { + trace("Insert on THUMBNAILS: " + uri); + id = insertThumbnail(uri, values); + break; + } + + case URL_ANNOTATIONS: { + trace("Insert on URL_ANNOTATIONS: " + uri); + id = insertUrlAnnotation(uri, values); + break; + } + + case ACTIVITY_STREAM_BLOCKLIST: { + trace("Insert on ACTIVITY_STREAM_BLOCKLIST: " + uri); + id = insertActivityStreamBlocklistSite(uri, values); + break; + } + + case PAGE_METADATA: { + trace("Insert on PAGE_METADATA: " + uri); + id = insertPageMetadata(uri, values); + break; + } + + default: { + Table table = findTableFor(match); + if (table == null) { + throw new UnsupportedOperationException("Unknown insert URI " + uri); + } + + trace("Insert on TABLE: " + uri); + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + id = table.insert(db, uri, match, values); + } + } + + debug("Inserted ID in database: " + id); + + if (id >= 0) + return ContentUris.withAppendedId(uri, id); + + return null; + } + + @SuppressWarnings("fallthrough") + @Override + public int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + trace("Calling update in transaction on URI: " + uri); + + int match = URI_MATCHER.match(uri); + int updated = 0; + + final SQLiteDatabase db = getWritableDatabase(uri); + switch (match) { + // We provide a dedicated (hacky) API for callers to bulk-update the positions of + // folder children by passing an array of GUID strings as `selectionArgs`. + // Each child will have its position column set to its index in the provided array. + // + // This avoids callers having to issue a large number of UPDATE queries through + // the usual channels. See Bug 728783. + // + // Note that this is decidedly not a general-purpose API; use at your own risk. + // `values` and `selection` are ignored. + case BOOKMARKS_POSITIONS: { + debug("Update on BOOKMARKS_POSITIONS: " + uri); + + // This already starts and finishes its own transaction. + updated = updateBookmarkPositions(uri, selectionArgs); + break; + } + + case BOOKMARKS_PARENT: { + debug("Update on BOOKMARKS_PARENT: " + uri); + beginWrite(db); + updated = updateBookmarkParents(db, values, selection, selectionArgs); + break; + } + + case BOOKMARKS_ID: + debug("Update on BOOKMARKS_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case BOOKMARKS: { + debug("Updating bookmark: " + uri); + if (shouldUpdateOrInsert(uri)) { + updated = updateOrInsertBookmark(uri, values, selection, selectionArgs); + } else { + updated = updateBookmarks(uri, values, selection, selectionArgs); + } + break; + } + + case HISTORY_ID: + debug("Update on HISTORY_ID: " + uri); + + selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case HISTORY: { + debug("Updating history: " + uri); + if (shouldUpdateOrInsert(uri)) { + updated = updateOrInsertHistory(uri, values, selection, selectionArgs); + } else { + updated = updateHistory(uri, values, selection, selectionArgs); + } + if (shouldIncrementVisits(uri)) { + insertVisitForHistory(uri, values, selection, selectionArgs); + } + break; + } + + case FAVICONS: { + debug("Update on FAVICONS: " + uri); + + String url = values.getAsString(Favicons.URL); + String faviconSelection = null; + String[] faviconSelectionArgs = null; + + if (!TextUtils.isEmpty(url)) { + faviconSelection = Favicons.URL + " = ?"; + faviconSelectionArgs = new String[] { url }; + } + + if (shouldUpdateOrInsert(uri)) { + updated = updateOrInsertFavicon(uri, values, faviconSelection, faviconSelectionArgs); + } else { + updated = updateExistingFavicon(uri, values, faviconSelection, faviconSelectionArgs); + } + break; + } + + case THUMBNAILS: { + debug("Update on THUMBNAILS: " + uri); + + String url = values.getAsString(Thumbnails.URL); + + // if no URL is provided, update all of the entries + if (TextUtils.isEmpty(values.getAsString(Thumbnails.URL))) { + updated = updateExistingThumbnail(uri, values, null, null); + } else if (shouldUpdateOrInsert(uri)) { + updated = updateOrInsertThumbnail(uri, values, Thumbnails.URL + " = ?", + new String[] { url }); + } else { + updated = updateExistingThumbnail(uri, values, Thumbnails.URL + " = ?", + new String[] { url }); + } + break; + } + + case URL_ANNOTATIONS: + updateUrlAnnotation(uri, values, selection, selectionArgs); + break; + + default: { + Table table = findTableFor(match); + if (table == null) { + throw new UnsupportedOperationException("Unknown update URI " + uri); + } + trace("Update TABLE: " + uri); + + beginWrite(db); + updated = table.update(db, uri, match, values, selection, selectionArgs); + if (shouldUpdateOrInsert(uri) && updated == 0) { + trace("No update, inserting for URL: " + uri); + table.insert(db, uri, match, values); + updated = 1; + } + } + } + + debug("Updated " + updated + " rows for URI: " + uri); + return updated; + } + + /** + * Get topsites by themselves, without the inclusion of pinned sites. Suggested sites + * will be appended (if necessary) to the end of the list in order to provide up to PARAM_LIMIT items. + */ + private Cursor getPlainTopSites(final Uri uri) { + final SQLiteDatabase db = getReadableDatabase(uri); + + final String limitParam = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + final int limit; + if (limitParam != null) { + limit = Integer.parseInt(limitParam); + } else { + limit = 12; + } + + // Filter out: unvisited pages (history_id == -1) pinned (and other special) sites, deleted sites, + // and about: pages. + final String ignoreForTopSitesWhereClause = + "(" + Combined.HISTORY_ID + " IS NOT -1)" + + " AND " + + Combined.URL + " NOT IN (SELECT " + + Bookmarks.URL + " FROM " + TABLE_BOOKMARKS + " WHERE " + + DBUtils.qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " < " + Bookmarks.FIXED_ROOT_ID + " AND " + + DBUtils.qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " == 0)" + + " AND " + + "(" + Combined.URL + " NOT LIKE ?)"; + + final String[] ignoreForTopSitesArgs = new String[] { + AboutPages.URL_FILTER + }; + + final Cursor c = db.rawQuery("SELECT " + + Bookmarks._ID + ", " + + Combined.BOOKMARK_ID + ", " + + Combined.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + Combined.HISTORY_ID + ", " + + TopSites.TYPE_TOP + " AS " + TopSites.TYPE + + " FROM " + Combined.VIEW_NAME + + " WHERE " + ignoreForTopSitesWhereClause + + " ORDER BY " + BrowserContract.getCombinedFrecencySortOrder(true, false) + + " LIMIT " + limit, + ignoreForTopSitesArgs); + + c.setNotificationUri(getContext().getContentResolver(), + BrowserContract.AUTHORITY_URI); + + if (c.getCount() == limit) { + return c; + } + + // If we don't have enough data: get suggested sites too + final SuggestedSites suggestedSites = BrowserDB.from(GeckoProfile.get( + getContext(), uri.getQueryParameter(BrowserContract.PARAM_PROFILE))).getSuggestedSites(); + + final Cursor suggestedSitesCursor = suggestedSites.get(limit - c.getCount()); + + return new MergeCursor(new Cursor[]{ + c, + suggestedSitesCursor + }); + } + + private Cursor getTopSites(final Uri uri) { + // In order to correctly merge the top and pinned sites we: + // + // 1. Generate a list of free ids for topsites - this is the positions that are NOT used by pinned sites. + // We do this using a subquery with a self-join in order to generate rowids, that allow us to join with + // the list of topsites. + // 2. Generate the list of topsites in order of frecency. + // 3. Join these, so that each topsite is given its resulting position + // 4. UNION all with the pinned sites, and order by position + // + // Suggested sites are placed after the topsites, but might still be interspersed with the suggested sites, + // hence we append these to the topsite list, and treat these identically to topsites from this point on. + // + // We require rowids to join the two lists, however subqueries aren't given rowids - hence we use two different + // tricks to generate these: + // 1. The list of free ids is small, hence we can do a self-join to generate rowids. + // 2. The topsites list is larger, hence we use a temporary table, which automatically provides rowids. + + final SQLiteDatabase db = getWritableDatabase(uri); + + final String TABLE_TOPSITES = "topsites"; + + final String limitParam = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + final String gridLimitParam = uri.getQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT); + + final int totalLimit; + final int suggestedGridLimit; + + if (limitParam == null) { + totalLimit = 50; + } else { + totalLimit = Integer.parseInt(limitParam, 10); + } + + if (gridLimitParam == null) { + suggestedGridLimit = getContext().getResources().getInteger(R.integer.number_of_top_sites); + } else { + suggestedGridLimit = Integer.parseInt(gridLimitParam, 10); + } + + final String pinnedSitesFromClause = "FROM " + TABLE_BOOKMARKS + " WHERE " + + Bookmarks.PARENT + " == " + Bookmarks.FIXED_PINNED_LIST_ID + + " AND " + Bookmarks.IS_DELETED + " IS NOT 1"; + + // Ideally we'd use a recursive CTE to generate our sequence, e.g. something like this worked at one point: + // " WITH RECURSIVE" + + // " cnt(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM cnt WHERE x < 6)" + + // However that requires SQLite >= 3.8.3 (available on Android >= 5.0), so in the meantime + // we use a temporary numbers table. + // Note: SQLite rowids are 1-indexed, whereas we're expecting 0-indexed values for the position. Our numbers + // table starts at position = 0, which ensures the correct results here. + final String freeIDSubquery = + " SELECT count(free_ids.position) + 1 AS rowid, numbers.position AS " + Bookmarks.POSITION + + " FROM (SELECT position FROM numbers WHERE position NOT IN (SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + ")) AS numbers" + + " LEFT OUTER JOIN " + + " (SELECT position FROM numbers WHERE position NOT IN (SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + ")) AS free_ids" + + " ON numbers.position > free_ids.position" + + " GROUP BY numbers.position" + + " ORDER BY numbers.position ASC" + + " LIMIT " + suggestedGridLimit; + + // Filter out: unvisited pages (history_id == -1) pinned (and other special) sites, deleted sites, + // and about: pages. + final String ignoreForTopSitesWhereClause = + "(" + Combined.HISTORY_ID + " IS NOT -1)" + + " AND " + + Combined.URL + " NOT IN (SELECT " + + Bookmarks.URL + " FROM bookmarks WHERE " + + DBUtils.qualifyColumn("bookmarks", Bookmarks.PARENT) + " < " + Bookmarks.FIXED_ROOT_ID + " AND " + + DBUtils.qualifyColumn("bookmarks", Bookmarks.IS_DELETED) + " == 0)" + + " AND " + + "(" + Combined.URL + " NOT LIKE ?)"; + + final String[] ignoreForTopSitesArgs = new String[] { + AboutPages.URL_FILTER + }; + + // Stuff the suggested sites into SQL: this allows us to filter pinned and topsites out of the suggested + // sites list as part of the final query (as opposed to walking cursors in java) + final SuggestedSites suggestedSites = BrowserDB.from(GeckoProfile.get( + getContext(), uri.getQueryParameter(BrowserContract.PARAM_PROFILE))).getSuggestedSites(); + + StringBuilder suggestedSitesBuilder = new StringBuilder(); + // We could access the underlying data here, however SuggestedSites also performs filtering on the suggested + // sites list, which means we'd need to process the lists within SuggestedSites in any case. If we're doing + // that processing, there is little real between us using a MatrixCursor, or a Map (or List) instead of the + // MatrixCursor. + final Cursor suggestedSitesCursor = suggestedSites.get(suggestedGridLimit); + + String[] suggestedSiteArgs = new String[0]; + + boolean hasProcessedAnySuggestedSites = false; + + final int idColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks._ID); + final int urlColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks.URL); + final int titleColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks.TITLE); + + while (suggestedSitesCursor.moveToNext()) { + // We'll be using this as a subquery, hence we need to avoid the preceding UNION ALL + if (hasProcessedAnySuggestedSites) { + suggestedSitesBuilder.append(" UNION ALL"); + } else { + hasProcessedAnySuggestedSites = true; + } + suggestedSitesBuilder.append(" SELECT" + + " ? AS " + Bookmarks._ID + "," + + " ? AS " + Bookmarks.URL + "," + + " ? AS " + Bookmarks.TITLE); + + suggestedSiteArgs = DBUtils.appendSelectionArgs(suggestedSiteArgs, + new String[] { + suggestedSitesCursor.getString(idColumnIndex), + suggestedSitesCursor.getString(urlColumnIndex), + suggestedSitesCursor.getString(titleColumnIndex) + }); + } + suggestedSitesCursor.close(); + + boolean hasPreparedBlankTiles = false; + + // We can somewhat reduce the number of blanks we produce by eliminating suggested sites. + // We do the actual limit calculation in SQL (since we need to take into account the number + // of pinned sites too), but this might avoid producing 5 or so additional blank tiles + // that would then need to be filtered out. + final int maxBlanksNeeded = suggestedGridLimit - suggestedSitesCursor.getCount(); + + final StringBuilder blanksBuilder = new StringBuilder(); + for (int i = 0; i < maxBlanksNeeded; i++) { + if (hasPreparedBlankTiles) { + blanksBuilder.append(" UNION ALL"); + } else { + hasPreparedBlankTiles = true; + } + + blanksBuilder.append(" SELECT" + + " -1 AS " + Bookmarks._ID + "," + + " '' AS " + Bookmarks.URL + "," + + " '' AS " + Bookmarks.TITLE); + } + + + + // To restrict suggested sites to the grid, we simply subtract the number of topsites (which have already had + // the pinned sites filtered out), and the number of pinned sites. + // SQLite completely ignores negative limits, hence we need to manually limit to 0 in this case. + final String suggestedLimitClause = " LIMIT MAX(0, (" + suggestedGridLimit + " - (SELECT COUNT(*) FROM " + TABLE_TOPSITES + ") - (SELECT COUNT(*) " + pinnedSitesFromClause + "))) "; + + // Pinned site positions are zero indexed, but we need to get the maximum 1-indexed position. + // Hence to correctly calculate the largest pinned position (which should be 0 if there are + // no sites, or 1-6 if we have at least one pinned site), we coalesce the DB position (0-5) + // with -1 to represent no-sites, which allows us to directly add 1 to obtain the expected value + // regardless of whether a position was actually retrieved. + final String blanksLimitClause = " LIMIT MAX(0, " + + "COALESCE((SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + "), -1) + 1" + + " - (SELECT COUNT(*) " + pinnedSitesFromClause + ")" + + " - (SELECT COUNT(*) FROM " + TABLE_TOPSITES + ")" + + ")"; + + db.beginTransaction(); + try { + db.execSQL("DROP TABLE IF EXISTS " + TABLE_TOPSITES); + + db.execSQL("CREATE TEMP TABLE " + TABLE_TOPSITES + " AS" + + " SELECT " + + Bookmarks._ID + ", " + + Combined.BOOKMARK_ID + ", " + + Combined.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + Combined.HISTORY_ID + ", " + + TopSites.TYPE_TOP + " AS " + TopSites.TYPE + + " FROM " + Combined.VIEW_NAME + + " WHERE " + ignoreForTopSitesWhereClause + + " ORDER BY " + BrowserContract.getCombinedFrecencySortOrder(true, false) + + " LIMIT " + totalLimit, + + ignoreForTopSitesArgs); + + if (hasProcessedAnySuggestedSites) { + db.execSQL("INSERT INTO " + TABLE_TOPSITES + + // We need to LIMIT _after_ selecting the relevant suggested sites, which requires us to + // use an additional internal subquery, since we cannot LIMIT a subquery that is part of UNION ALL. + // Hence the weird SELECT * FROM (SELECT ...relevant suggested sites... LIMIT ?) + " SELECT * FROM (SELECT " + + Bookmarks._ID + ", " + + Bookmarks._ID + " AS " + Combined.BOOKMARK_ID + ", " + + " -1 AS " + Combined.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + "NULL AS " + Combined.HISTORY_ID + ", " + + TopSites.TYPE_SUGGESTED + " as " + TopSites.TYPE + + " FROM ( " + suggestedSitesBuilder.toString() + " )" + + " WHERE " + + Bookmarks.URL + " NOT IN (SELECT url FROM " + TABLE_TOPSITES + ")" + + " AND " + + Bookmarks.URL + " NOT IN (SELECT url " + pinnedSitesFromClause + ")" + + suggestedLimitClause + " )", + + suggestedSiteArgs); + } + + if (hasPreparedBlankTiles) { + db.execSQL("INSERT INTO " + TABLE_TOPSITES + + // We need to LIMIT _after_ selecting the relevant suggested sites, which requires us to + // use an additional internal subquery, since we cannot LIMIT a subquery that is part of UNION ALL. + // Hence the weird SELECT * FROM (SELECT ...relevant suggested sites... LIMIT ?) + " SELECT * FROM (SELECT " + + Bookmarks._ID + ", " + + Bookmarks._ID + " AS " + Combined.BOOKMARK_ID + ", " + + " -1 AS " + Combined.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + "NULL AS " + Combined.HISTORY_ID + ", " + + TopSites.TYPE_BLANK + " as " + TopSites.TYPE + + " FROM ( " + blanksBuilder.toString() + " )" + + blanksLimitClause + " )"); + } + + // If we retrieve more topsites than we have free positions for in the freeIdSubquery, + // we will have topsites that don't receive a position when joining TABLE_TOPSITES + // with freeIdSubquery. Hence we need to coalesce the position with a generated position. + // We know that the difference in positions will be at most suggestedGridLimit, hence we + // can add that to the rowid to generate a safe position. + // I.e. if we have 6 pinned sites then positions 0..5 are filled, the JOIN results in + // the first N rows having positions 6..(N+6), so row N+1 should receive a position that is at + // least N+1+6, which is equal to rowid + 6. + final SQLiteCursor c = (SQLiteCursor) db.rawQuery( + "SELECT " + + Bookmarks._ID + ", " + + TopSites.BOOKMARK_ID + ", " + + TopSites.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + "COALESCE(" + Bookmarks.POSITION + ", " + + DBUtils.qualifyColumn(TABLE_TOPSITES, "rowid") + " + " + suggestedGridLimit + + ")" + " AS " + Bookmarks.POSITION + ", " + + Combined.HISTORY_ID + ", " + + TopSites.TYPE + + " FROM " + TABLE_TOPSITES + + " LEFT OUTER JOIN " + // TABLE_IDS + + "(" + freeIDSubquery + ") AS id_results" + + " ON " + DBUtils.qualifyColumn(TABLE_TOPSITES, "rowid") + + " = " + DBUtils.qualifyColumn("id_results", "rowid") + + + " UNION ALL " + + + "SELECT " + + Bookmarks._ID + ", " + + Bookmarks._ID + " AS " + TopSites.BOOKMARK_ID + ", " + + " -1 AS " + TopSites.HISTORY_ID + ", " + + Bookmarks.URL + ", " + + Bookmarks.TITLE + ", " + + Bookmarks.POSITION + ", " + + "NULL AS " + Combined.HISTORY_ID + ", " + + TopSites.TYPE_PINNED + " as " + TopSites.TYPE + + " " + pinnedSitesFromClause + + + " ORDER BY " + Bookmarks.POSITION, + + null); + + c.setNotificationUri(getContext().getContentResolver(), + BrowserContract.AUTHORITY_URI); + + // Force the cursor to be compiled and the cursor-window filled now: + // (A) without compiling the cursor now we won't have access to the TEMP table which + // is removed as soon as we close our connection. + // (B) this might also mitigate the situation causing this crash where we're accessing + // a cursor and crashing in fillWindow. + c.moveToFirst(); + + db.setTransactionSuccessful(); + return c; + } finally { + db.endTransaction(); + } + } + + /** + * Obtain a set of links for highlights (from bookmarks and history). + * + * Based on the query for Activity^ Stream (desktop): + * https://github.com/mozilla/activity-stream/blob/9eb9f451b553bb62ae9b8d6b41a8ef94a2e020ea/addon/PlacesProvider.js#L578 + */ + public Cursor getHighlights(final SQLiteDatabase db, String limit) { + final int totalLimit = limit == null ? 20 : Integer.parseInt(limit); + + final long threeDaysAgo = System.currentTimeMillis() - (1000 * 60 * 60 * 24 * 3); + final long bookmarkLimit = 1; + + // Select recent bookmarks that have not been visited much + final String bookmarksQuery = "SELECT * FROM (SELECT " + + "-1 AS " + Combined.HISTORY_ID + ", " + + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + ", " + + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TITLE) + ", " + + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " AS " + Highlights.DATE + " " + + "FROM " + Bookmarks.TABLE_NAME + " " + + "LEFT JOIN " + History.TABLE_NAME + " ON " + + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " = " + + DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + " " + + "WHERE " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " > " + threeDaysAgo + " " + + "AND (" + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " <= 3 " + + "OR " + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " IS NULL) " + + "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.IS_DELETED) + " = 0 " + + "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " " + + "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" + + "ORDER BY " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " DESC " + + "LIMIT " + bookmarkLimit + ")"; + + final long last30Minutes = System.currentTimeMillis() - (1000 * 60 * 30); + final long historyLimit = totalLimit - bookmarkLimit; + + // Select recent history that has not been visited much. + final String historyQuery = "SELECT * FROM (SELECT " + + History._ID + " AS " + Combined.HISTORY_ID + ", " + + "-1 AS " + Combined.BOOKMARK_ID + ", " + + History.URL + ", " + + History.TITLE + ", " + + History.DATE_LAST_VISITED + " AS " + Highlights.DATE + " " + + "FROM " + History.TABLE_NAME + " " + + "WHERE " + History.DATE_LAST_VISITED + " < " + last30Minutes + " " + + "AND " + History.VISITS + " <= 3 " + + "AND " + History.TITLE + " NOT NULL AND " + History.TITLE + " != '' " + + "AND " + History.IS_DELETED + " = 0 " + + "AND " + History.URL + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" + + // TODO: Implement domain black list (bug 1298786) + // TODO: Group by host (bug 1298785) + "ORDER BY " + History.DATE_LAST_VISITED + " DESC " + + "LIMIT " + historyLimit + ")"; + + final String query = "SELECT DISTINCT * " + + "FROM (" + bookmarksQuery + " " + + "UNION ALL " + historyQuery + ") " + + "GROUP BY " + Combined.URL + ";"; + + final Cursor cursor = db.rawQuery(query, null); + + cursor.setNotificationUri(getContext().getContentResolver(), + BrowserContract.AUTHORITY_URI); + + return cursor; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + final int match = URI_MATCHER.match(uri); + + // Handle only queries requiring a writable DB connection here: most queries need only a readable + // connection, hence we can get a readable DB once, and then handle most queries within a switch. + // TopSites requires a writable connection (because of the temporary tables it uses), hence + // we handle that separately, i.e. before retrieving a readable connection. + if (match == TOPSITES) { + if (uri.getBooleanQueryParameter(BrowserContract.PARAM_TOPSITES_DISABLE_PINNED, false)) { + return getPlainTopSites(uri); + } else { + return getTopSites(uri); + } + } + + SQLiteDatabase db = getReadableDatabase(uri); + + SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + String groupBy = null; + + switch (match) { + case BOOKMARKS_FOLDER_ID: + case BOOKMARKS_ID: + case BOOKMARKS: { + debug("Query is on bookmarks: " + uri); + + if (match == BOOKMARKS_ID) { + selection = DBUtils.concatenateWhere(selection, Bookmarks._ID + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + } else if (match == BOOKMARKS_FOLDER_ID) { + selection = DBUtils.concatenateWhere(selection, Bookmarks.PARENT + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + } + + if (!shouldShowDeleted(uri)) + selection = DBUtils.concatenateWhere(Bookmarks.IS_DELETED + " = 0", selection); + + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_BOOKMARKS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(BOOKMARKS_PROJECTION_MAP); + + if (hasFaviconsInProjection(projection)) { + qb.setTables(VIEW_BOOKMARKS_WITH_FAVICONS); + } else if (selection != null && selection.contains(Bookmarks.ANNOTATION_KEY)) { + qb.setTables(VIEW_BOOKMARKS_WITH_ANNOTATIONS); + + groupBy = uri.getQueryParameter(BrowserContract.PARAM_GROUP_BY); + } else { + qb.setTables(TABLE_BOOKMARKS); + } + + break; + } + + case HISTORY_ID: + selection = DBUtils.concatenateWhere(selection, History._ID + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case HISTORY: { + debug("Query is on history: " + uri); + + if (!shouldShowDeleted(uri)) + selection = DBUtils.concatenateWhere(History.IS_DELETED + " = 0", selection); + + if (TextUtils.isEmpty(sortOrder)) + sortOrder = DEFAULT_HISTORY_SORT_ORDER; + + qb.setProjectionMap(HISTORY_PROJECTION_MAP); + + if (hasFaviconsInProjection(projection)) + qb.setTables(VIEW_HISTORY_WITH_FAVICONS); + else + qb.setTables(TABLE_HISTORY); + + break; + } + + case VISITS: + debug("Query is on visits: " + uri); + qb.setProjectionMap(VISIT_PROJECTION_MAP); + qb.setTables(TABLE_VISITS); + + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_VISITS_SORT_ORDER; + } + break; + + case FAVICON_ID: + selection = DBUtils.concatenateWhere(selection, Favicons._ID + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case FAVICONS: { + debug("Query is on favicons: " + uri); + + qb.setProjectionMap(FAVICONS_PROJECTION_MAP); + qb.setTables(TABLE_FAVICONS); + + break; + } + + case THUMBNAIL_ID: + selection = DBUtils.concatenateWhere(selection, Thumbnails._ID + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case THUMBNAILS: { + debug("Query is on thumbnails: " + uri); + + qb.setProjectionMap(THUMBNAILS_PROJECTION_MAP); + qb.setTables(TABLE_THUMBNAILS); + + break; + } + + case URL_ANNOTATIONS: + debug("Query is on url annotations: " + uri); + + qb.setProjectionMap(URL_ANNOTATIONS_PROJECTION_MAP); + qb.setTables(TABLE_URL_ANNOTATIONS); + break; + + case SCHEMA: { + debug("Query is on schema."); + MatrixCursor schemaCursor = new MatrixCursor(new String[] { Schema.VERSION }); + schemaCursor.newRow().add(BrowserDatabaseHelper.DATABASE_VERSION); + + return schemaCursor; + } + + case COMBINED: { + debug("Query is on combined: " + uri); + + if (TextUtils.isEmpty(sortOrder)) + sortOrder = DEFAULT_HISTORY_SORT_ORDER; + + // This will avoid duplicate entries in the awesomebar + // results when a history entry has multiple bookmarks. + groupBy = Combined.URL; + + qb.setProjectionMap(COMBINED_PROJECTION_MAP); + + if (hasFaviconsInProjection(projection)) + qb.setTables(VIEW_COMBINED_WITH_FAVICONS); + else + qb.setTables(Combined.VIEW_NAME); + + break; + } + + case HIGHLIGHTS: { + debug("Highlights query: " + uri); + + return getHighlights(db, limit); + } + + case PAGE_METADATA: { + debug("PageMetadata query: " + uri); + + qb.setProjectionMap(PAGE_METADATA_PROJECTION_MAP); + qb.setTables(TABLE_PAGE_METADATA); + break; + } + + default: { + Table table = findTableFor(match); + if (table == null) { + throw new UnsupportedOperationException("Unknown query URI " + uri); + } + trace("Update TABLE: " + uri); + return table.query(db, uri, match, projection, selection, selectionArgs, sortOrder, groupBy, limit); + } + } + + trace("Running built query."); + Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, + null, sortOrder, limit); + cursor.setNotificationUri(getContext().getContentResolver(), + BrowserContract.AUTHORITY_URI); + + return cursor; + } + + /** + * Update the positions of bookmarks in batches. + * + * Begins and ends its own transactions. + * + * @see #updateBookmarkPositionsInTransaction(SQLiteDatabase, String[], int, int) + */ + private int updateBookmarkPositions(Uri uri, String[] guids) { + if (guids == null) { + return 0; + } + + int guidsCount = guids.length; + if (guidsCount == 0) { + return 0; + } + + int offset = 0; + int updated = 0; + + final SQLiteDatabase db = getWritableDatabase(uri); + db.beginTransaction(); + + while (offset < guidsCount) { + try { + updated += updateBookmarkPositionsInTransaction(db, guids, offset, + MAX_POSITION_UPDATES_PER_QUERY); + } catch (SQLException e) { + Log.e(LOGTAG, "Got SQLite exception updating bookmark positions at offset " + offset, e); + + // Need to restart the transaction. + // The only way a caller knows that anything failed is that the + // returned update count will be smaller than the requested + // number of records. + db.setTransactionSuccessful(); + db.endTransaction(); + + db.beginTransaction(); + } + + offset += MAX_POSITION_UPDATES_PER_QUERY; + } + + db.setTransactionSuccessful(); + db.endTransaction(); + + return updated; + } + + /** + * Construct and execute an update expression that will modify the positions + * of records in-place. + */ + private static int updateBookmarkPositionsInTransaction(final SQLiteDatabase db, final String[] guids, + final int offset, final int max) { + int guidsCount = guids.length; + int processCount = Math.min(max, guidsCount - offset); + + // Each must appear twice: once in a CASE, and once in the IN clause. + String[] args = new String[processCount * 2]; + System.arraycopy(guids, offset, args, 0, processCount); + System.arraycopy(guids, offset, args, processCount, processCount); + + StringBuilder b = new StringBuilder("UPDATE " + TABLE_BOOKMARKS + + " SET " + Bookmarks.POSITION + + " = CASE guid"); + + // Build the CASE statement body for GUID/index pairs from offset up to + // the computed limit. + final int end = offset + processCount; + int i = offset; + for (; i < end; ++i) { + if (guids[i] == null) { + // We don't want to issue the query if not every GUID is specified. + debug("updateBookmarkPositions called with null GUID at index " + i); + return 0; + } + b.append(" WHEN ? THEN " + i); + } + + b.append(" END WHERE " + DBUtils.computeSQLInClause(processCount, Bookmarks.GUID)); + db.execSQL(b.toString(), args); + + // We can't easily get a modified count without calling something like changes(). + return processCount; + } + + /** + * Construct an update expression that will modify the parents of any records + * that match. + */ + private int updateBookmarkParents(SQLiteDatabase db, ContentValues values, String selection, String[] selectionArgs) { + trace("Updating bookmark parents of " + selection + " (" + selectionArgs[0] + ")"); + String where = Bookmarks._ID + " IN (" + + " SELECT DISTINCT " + Bookmarks.PARENT + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + selection + " )"; + return db.update(TABLE_BOOKMARKS, values, where, selectionArgs); + } + + private long insertBookmark(Uri uri, ContentValues values) { + // Generate values if not specified. Don't overwrite + // if specified by caller. + long now = System.currentTimeMillis(); + if (!values.containsKey(Bookmarks.DATE_CREATED)) { + values.put(Bookmarks.DATE_CREATED, now); + } + + if (!values.containsKey(Bookmarks.DATE_MODIFIED)) { + values.put(Bookmarks.DATE_MODIFIED, now); + } + + if (!values.containsKey(Bookmarks.GUID)) { + values.put(Bookmarks.GUID, Utils.generateGuid()); + } + + if (!values.containsKey(Bookmarks.POSITION)) { + debug("Inserting bookmark with no position for URI"); + values.put(Bookmarks.POSITION, + Long.toString(BrowserContract.Bookmarks.DEFAULT_POSITION)); + } + + if (!values.containsKey(Bookmarks.TITLE)) { + // Desktop Places barfs on insertion of a bookmark with no title, + // so we don't store them that way. + values.put(Bookmarks.TITLE, ""); + } + + String url = values.getAsString(Bookmarks.URL); + + debug("Inserting bookmark in database with URL: " + url); + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values); + } + + + private int updateOrInsertBookmark(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + int updated = updateBookmarks(uri, values, selection, selectionArgs); + if (updated > 0) { + return updated; + } + + // Transaction already begun by updateBookmarks. + if (0 <= insertBookmark(uri, values)) { + // We 'updated' one row. + return 1; + } + + // If something went wrong, then we updated zero rows. + return 0; + } + + private int updateBookmarks(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + trace("Updating bookmarks on URI: " + uri); + + final String[] bookmarksProjection = new String[] { + Bookmarks._ID, // 0 + }; + + if (!values.containsKey(Bookmarks.DATE_MODIFIED)) { + values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis()); + } + + trace("Querying bookmarks to update on URI: " + uri); + final SQLiteDatabase db = getWritableDatabase(uri); + + // Compute matching IDs. + final Cursor cursor = db.query(TABLE_BOOKMARKS, bookmarksProjection, + selection, selectionArgs, null, null, null); + + // Now that we're done reading, open a transaction. + final String inClause; + try { + inClause = DBUtils.computeSQLInClauseFromLongs(cursor, Bookmarks._ID); + } finally { + cursor.close(); + } + + beginWrite(db); + return db.update(TABLE_BOOKMARKS, values, inClause, null); + } + + private long insertHistory(Uri uri, ContentValues values) { + final long now = System.currentTimeMillis(); + values.put(History.DATE_CREATED, now); + values.put(History.DATE_MODIFIED, now); + + // Generate GUID for new history entry. Don't override specified GUIDs. + if (!values.containsKey(History.GUID)) { + values.put(History.GUID, Utils.generateGuid()); + } + + String url = values.getAsString(History.URL); + + debug("Inserting history in database with URL: " + url); + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.insertOrThrow(TABLE_HISTORY, History.VISITS, values); + } + + private int updateOrInsertHistory(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + final int updated = updateHistory(uri, values, selection, selectionArgs); + if (updated > 0) { + return updated; + } + + // Insert a new entry if necessary, setting visit and date aggregate values. + if (!values.containsKey(History.VISITS)) { + values.put(History.VISITS, 1); + values.put(History.LOCAL_VISITS, 1); + } else { + values.put(History.LOCAL_VISITS, values.getAsInteger(History.VISITS)); + } + if (values.containsKey(History.DATE_LAST_VISITED)) { + values.put(History.LOCAL_DATE_LAST_VISITED, values.getAsLong(History.DATE_LAST_VISITED)); + } + if (!values.containsKey(History.TITLE)) { + values.put(History.TITLE, values.getAsString(History.URL)); + } + + if (0 <= insertHistory(uri, values)) { + return 1; + } + + return 0; + } + + private int updateHistory(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + trace("Updating history on URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + + if (!values.containsKey(History.DATE_MODIFIED)) { + values.put(History.DATE_MODIFIED, System.currentTimeMillis()); + } + + // Use the simple code path for easy updates. + if (!shouldIncrementVisits(uri) && !shouldIncrementRemoteAggregates(uri)) { + trace("Updating history meta data only"); + return db.update(TABLE_HISTORY, values, selection, selectionArgs); + } + + trace("Updating history meta data and incrementing visits"); + + if (values.containsKey(History.DATE_LAST_VISITED)) { + values.put(History.LOCAL_DATE_LAST_VISITED, values.getAsLong(History.DATE_LAST_VISITED)); + } + + // Create a separate set of values that will be updated as an expression. + final ContentValues visits = new ContentValues(); + if (shouldIncrementVisits(uri)) { + // Update data and increment visits by 1. + final long incVisits = 1; + + visits.put(History.VISITS, History.VISITS + " + " + incVisits); + visits.put(History.LOCAL_VISITS, History.LOCAL_VISITS + " + " + incVisits); + } + + if (shouldIncrementRemoteAggregates(uri)) { + // Let's fail loudly instead of trying to assume what users of this API meant to do. + if (!values.containsKey(History.REMOTE_VISITS)) { + throw new IllegalArgumentException( + "Tried incrementing History.REMOTE_VISITS by unknown value"); + } + visits.put( + History.REMOTE_VISITS, + History.REMOTE_VISITS + " + " + values.getAsInteger(History.REMOTE_VISITS) + ); + // Need to remove passed in value, so that we increment REMOTE_VISITS, and not just set it. + values.remove(History.REMOTE_VISITS); + } + + final ContentValues[] valuesAndVisits = { values, visits }; + final UpdateOperation[] ops = { UpdateOperation.ASSIGN, UpdateOperation.EXPRESSION }; + + return DBUtils.updateArrays(db, TABLE_HISTORY, valuesAndVisits, ops, selection, selectionArgs); + } + + private long insertVisitForHistory(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + trace("Inserting visit for history on URI: " + uri); + + final SQLiteDatabase db = getReadableDatabase(uri); + + final Cursor cursor = db.query( + History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs, + null, null, null); + if (cursor == null) { + Log.e(LOGTAG, "Null cursor while trying to insert visit for history URI: " + uri); + return 0; + } + final ContentValues[] visitValues; + try { + visitValues = new ContentValues[cursor.getCount()]; + + if (!cursor.moveToFirst()) { + Log.e(LOGTAG, "No history records found while inserting visit(s) for history URI: " + uri); + return 0; + } + + // Sync works in microseconds, so we store visit timestamps in microseconds as well. + // History timestamps are in milliseconds. + // This is the conversion point for locally generated visits. + final long visitDate; + if (values.containsKey(History.DATE_LAST_VISITED)) { + visitDate = values.getAsLong(History.DATE_LAST_VISITED) * 1000; + } else { + visitDate = System.currentTimeMillis() * 1000; + } + + final int guidColumn = cursor.getColumnIndexOrThrow(History.GUID); + while (!cursor.isAfterLast()) { + final ContentValues visit = new ContentValues(); + visit.put(Visits.HISTORY_GUID, cursor.getString(guidColumn)); + visit.put(Visits.DATE_VISITED, visitDate); + visitValues[cursor.getPosition()] = visit; + cursor.moveToNext(); + } + } finally { + cursor.close(); + } + + if (visitValues.length == 1) { + return insertVisit(Visits.CONTENT_URI, visitValues[0]); + } else { + return bulkInsert(Visits.CONTENT_URI, visitValues); + } + } + + private long insertVisit(Uri uri, ContentValues values) { + final SQLiteDatabase db = getWritableDatabase(uri); + + debug("Inserting history in database with URL: " + uri); + beginWrite(db); + + // We ignore insert conflicts here to simplify inserting visits records coming in from Sync. + // Visits table has a unique index on (history_guid,date), so a conflict might arise when we're + // trying to insert history record visits coming in from sync which are already present locally + // as a result of previous sync operations. + // An alternative to doing this is to filter out already present records when we're doing history inserts + // from Sync, which is a costly operation to do en masse. + return db.insertWithOnConflict( + TABLE_VISITS, null, values, SQLiteDatabase.CONFLICT_IGNORE); + } + + private void updateFaviconIdsForUrl(SQLiteDatabase db, String pageUrl, Long faviconId) { + ContentValues updateValues = new ContentValues(1); + updateValues.put(FaviconColumns.FAVICON_ID, faviconId); + db.update(TABLE_HISTORY, + updateValues, + History.URL + " = ?", + new String[] { pageUrl }); + db.update(TABLE_BOOKMARKS, + updateValues, + Bookmarks.URL + " = ?", + new String[] { pageUrl }); + } + + private long insertFavicon(Uri uri, ContentValues values) { + return insertFavicon(getWritableDatabase(uri), values); + } + + private long insertFavicon(SQLiteDatabase db, ContentValues values) { + String faviconUrl = values.getAsString(Favicons.URL); + String pageUrl = null; + + trace("Inserting favicon for URL: " + faviconUrl); + + DBUtils.stripEmptyByteArray(values, Favicons.DATA); + + // Extract the page URL from the ContentValues + if (values.containsKey(Favicons.PAGE_URL)) { + pageUrl = values.getAsString(Favicons.PAGE_URL); + values.remove(Favicons.PAGE_URL); + } + + // If no URL is provided, insert using the default one. + if (TextUtils.isEmpty(faviconUrl) && !TextUtils.isEmpty(pageUrl)) { + values.put(Favicons.URL, IconsHelper.guessDefaultFaviconURL(pageUrl)); + } + + final long now = System.currentTimeMillis(); + values.put(Favicons.DATE_CREATED, now); + values.put(Favicons.DATE_MODIFIED, now); + + beginWrite(db); + final long faviconId = db.insertOrThrow(TABLE_FAVICONS, null, values); + + if (pageUrl != null) { + updateFaviconIdsForUrl(db, pageUrl, faviconId); + } + return faviconId; + } + + private int updateOrInsertFavicon(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + return updateFavicon(uri, values, selection, selectionArgs, + true /* insert if needed */); + } + + private int updateExistingFavicon(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + return updateFavicon(uri, values, selection, selectionArgs, + false /* only update, no insert */); + } + + private int updateFavicon(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean insertIfNeeded) { + String faviconUrl = values.getAsString(Favicons.URL); + String pageUrl = null; + int updated = 0; + Long faviconId = null; + long now = System.currentTimeMillis(); + + trace("Updating favicon for URL: " + faviconUrl); + + DBUtils.stripEmptyByteArray(values, Favicons.DATA); + + // Extract the page URL from the ContentValues + if (values.containsKey(Favicons.PAGE_URL)) { + pageUrl = values.getAsString(Favicons.PAGE_URL); + values.remove(Favicons.PAGE_URL); + } + + values.put(Favicons.DATE_MODIFIED, now); + + final SQLiteDatabase db = getWritableDatabase(uri); + + // If there's no favicon URL given and we're inserting if needed, skip + // the update and only do an insert (otherwise all rows would be + // updated). + if (!(insertIfNeeded && (faviconUrl == null))) { + updated = db.update(TABLE_FAVICONS, values, selection, selectionArgs); + } + + if (updated > 0) { + if ((faviconUrl != null) && (pageUrl != null)) { + final Cursor cursor = db.query(TABLE_FAVICONS, + new String[] { Favicons._ID }, + Favicons.URL + " = ?", + new String[] { faviconUrl }, + null, null, null); + try { + if (cursor.moveToFirst()) { + faviconId = cursor.getLong(cursor.getColumnIndexOrThrow(Favicons._ID)); + } + } finally { + cursor.close(); + } + } + if (pageUrl != null) { + beginWrite(db); + } + } else if (insertIfNeeded) { + values.put(Favicons.DATE_CREATED, now); + + trace("No update, inserting favicon for URL: " + faviconUrl); + beginWrite(db); + faviconId = db.insert(TABLE_FAVICONS, null, values); + updated = 1; + } + + if (pageUrl != null) { + updateFaviconIdsForUrl(db, pageUrl, faviconId); + } + + return updated; + } + + private long insertThumbnail(Uri uri, ContentValues values) { + final String url = values.getAsString(Thumbnails.URL); + + trace("Inserting thumbnail for URL: " + url); + + DBUtils.stripEmptyByteArray(values, Thumbnails.DATA); + + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.insertOrThrow(TABLE_THUMBNAILS, null, values); + } + + private long insertActivityStreamBlocklistSite(final Uri uri, final ContentValues values) { + final String url = values.getAsString(ActivityStreamBlocklist.URL); + trace("Inserting url into highlights blocklist, URL: " + url); + + final SQLiteDatabase db = getWritableDatabase(uri); + values.put(ActivityStreamBlocklist.CREATED, System.currentTimeMillis()); + + beginWrite(db); + return db.insertOrThrow(TABLE_ACTIVITY_STREAM_BLOCKLIST, null, values); + } + + private long insertPageMetadata(final Uri uri, final ContentValues values) { + final SQLiteDatabase db = getWritableDatabase(uri); + + if (!values.containsKey(PageMetadata.DATE_CREATED)) { + values.put(PageMetadata.DATE_CREATED, System.currentTimeMillis()); + } + + beginWrite(db); + + // Perform INSERT OR REPLACE, there might be page metadata present and we want to replace it. + // Depends on a conflict arising from unique foreign key (history_guid) constraint violation. + return db.insertWithOnConflict( + TABLE_PAGE_METADATA, null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + + private long insertUrlAnnotation(final Uri uri, final ContentValues values) { + final String url = values.getAsString(UrlAnnotations.URL); + trace("Inserting url annotations for URL: " + url); + + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.insertOrThrow(TABLE_URL_ANNOTATIONS, null, values); + } + + private void deleteUrlAnnotation(final Uri uri, final String selection, final String[] selectionArgs) { + trace("Deleting url annotation for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + db.delete(TABLE_URL_ANNOTATIONS, selection, selectionArgs); + } + + private int deletePageMetadata(final Uri uri, final String selection, final String[] selectionArgs) { + trace("Deleting page metadata for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + return db.delete(TABLE_PAGE_METADATA, selection, selectionArgs); + } + + private void updateUrlAnnotation(final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) { + trace("Updating url annotation for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + db.update(TABLE_URL_ANNOTATIONS, values, selection, selectionArgs); + } + + private int updateOrInsertThumbnail(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + return updateThumbnail(uri, values, selection, selectionArgs, + true /* insert if needed */); + } + + private int updateExistingThumbnail(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + return updateThumbnail(uri, values, selection, selectionArgs, + false /* only update, no insert */); + } + + private int updateThumbnail(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean insertIfNeeded) { + final String url = values.getAsString(Thumbnails.URL); + DBUtils.stripEmptyByteArray(values, Thumbnails.DATA); + + trace("Updating thumbnail for URL: " + url); + + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + int updated = db.update(TABLE_THUMBNAILS, values, selection, selectionArgs); + + if (updated == 0 && insertIfNeeded) { + trace("No update, inserting thumbnail for URL: " + url); + db.insert(TABLE_THUMBNAILS, null, values); + updated = 1; + } + + return updated; + } + + /** + * This method does not create a new transaction. Its first operation is + * guaranteed to be a write, which in the case of a new enclosing + * transaction will guarantee that a read does not need to be upgraded to + * a write. + */ + private int deleteHistory(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs) { + debug("Deleting history entry for URI: " + uri); + + if (isCallerSync(uri)) { + return db.delete(TABLE_HISTORY, selection, selectionArgs); + } + + debug("Marking history entry as deleted for URI: " + uri); + + ContentValues values = new ContentValues(); + values.put(History.IS_DELETED, 1); + + // Wipe sensitive data. + values.putNull(History.TITLE); + values.put(History.URL, ""); // Column is NOT NULL. + values.put(History.DATE_CREATED, 0); + values.put(History.DATE_LAST_VISITED, 0); + values.put(History.VISITS, 0); + values.put(History.DATE_MODIFIED, System.currentTimeMillis()); + + // Doing this UPDATE (or the DELETE above) first ensures that the + // first operation within a new enclosing transaction is a write. + // The cleanup call below will do a SELECT first, and thus would + // require the transaction to be upgraded from a reader to a writer. + // In some cases that upgrade can fail (SQLITE_BUSY), so we avoid + // it if we can. + final int updated = db.update(TABLE_HISTORY, values, selection, selectionArgs); + try { + cleanUpSomeDeletedRecords(uri, TABLE_HISTORY); + } catch (Exception e) { + // We don't care. + Log.e(LOGTAG, "Unable to clean up deleted history records: ", e); + } + return updated; + } + + private ArrayList<String> getHistoryGUIDsFromSelection(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs) { + final ArrayList<String> historyGUIDs = new ArrayList<>(); + + final Cursor cursor = db.query( + History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs, + null, null, null); + if (cursor == null) { + Log.e(LOGTAG, "Null cursor while trying to delete visits for history URI: " + uri); + return historyGUIDs; + } + + try { + if (!cursor.moveToFirst()) { + trace("No history items for which to remove visits matched for URI: " + uri); + return historyGUIDs; + } + final int historyColumn = cursor.getColumnIndexOrThrow(History.GUID); + while (!cursor.isAfterLast()) { + historyGUIDs.add(cursor.getString(historyColumn)); + cursor.moveToNext(); + } + } finally { + cursor.close(); + } + + return historyGUIDs; + } + + private int deletePageMetadataForHistory(SQLiteDatabase db, ArrayList<String> historyGUIDs) { + return bulkDeleteByHistoryGUID(db, historyGUIDs, PageMetadata.TABLE_NAME, PageMetadata.HISTORY_GUID); + } + + private int deleteVisitsForHistory(SQLiteDatabase db, ArrayList<String> historyGUIDs) { + return bulkDeleteByHistoryGUID(db, historyGUIDs, Visits.TABLE_NAME, Visits.HISTORY_GUID); + } + + private int bulkDeleteByHistoryGUID(SQLiteDatabase db, ArrayList<String> historyGUIDs, String table, String historyGUIDColumn) { + // Due to SQLite's maximum variable limitation, we need to chunk our delete statements. + // For example, if there were 1200 GUIDs, this will perform 2 delete statements. + int deleted = 0; + for (int chunk = 0; chunk <= historyGUIDs.size() / DBUtils.SQLITE_MAX_VARIABLE_NUMBER; chunk++) { + final int chunkStart = chunk * DBUtils.SQLITE_MAX_VARIABLE_NUMBER; + int chunkEnd = (chunk + 1) * DBUtils.SQLITE_MAX_VARIABLE_NUMBER; + if (chunkEnd > historyGUIDs.size()) { + chunkEnd = historyGUIDs.size(); + } + final List<String> chunkGUIDs = historyGUIDs.subList(chunkStart, chunkEnd); + deleted += db.delete( + table, + DBUtils.computeSQLInClause(chunkGUIDs.size(), historyGUIDColumn), + chunkGUIDs.toArray(new String[chunkGUIDs.size()]) + ); + } + + return deleted; + } + + private int deleteVisits(Uri uri, String selection, String[] selectionArgs) { + debug("Deleting visits for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + + beginWrite(db); + return db.delete(TABLE_VISITS, selection, selectionArgs); + } + + private int deleteBookmarks(Uri uri, String selection, String[] selectionArgs) { + debug("Deleting bookmarks for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + + if (isCallerSync(uri)) { + beginWrite(db); + return db.delete(TABLE_BOOKMARKS, selection, selectionArgs); + } + + debug("Marking bookmarks as deleted for URI: " + uri); + + ContentValues values = new ContentValues(); + values.put(Bookmarks.IS_DELETED, 1); + values.put(Bookmarks.POSITION, 0); + values.putNull(Bookmarks.PARENT); + values.putNull(Bookmarks.URL); + values.putNull(Bookmarks.TITLE); + values.putNull(Bookmarks.DESCRIPTION); + values.putNull(Bookmarks.KEYWORD); + values.putNull(Bookmarks.TAGS); + values.putNull(Bookmarks.FAVICON_ID); + + // Doing this UPDATE (or the DELETE above) first ensures that the + // first operation within this transaction is a write. + // The cleanup call below will do a SELECT first, and thus would + // require the transaction to be upgraded from a reader to a writer. + final int updated = updateBookmarks(uri, values, selection, selectionArgs); + try { + cleanUpSomeDeletedRecords(uri, TABLE_BOOKMARKS); + } catch (Exception e) { + // We don't care. + Log.e(LOGTAG, "Unable to clean up deleted bookmark records: ", e); + } + return updated; + } + + private int deleteFavicons(Uri uri, String selection, String[] selectionArgs) { + debug("Deleting favicons for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + + return db.delete(TABLE_FAVICONS, selection, selectionArgs); + } + + private int deleteThumbnails(Uri uri, String selection, String[] selectionArgs) { + debug("Deleting thumbnails for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + + return db.delete(TABLE_THUMBNAILS, selection, selectionArgs); + } + + private int deleteUnusedImages(Uri uri) { + debug("Deleting all unused favicons and thumbnails for URI: " + uri); + + String faviconSelection = Favicons._ID + " NOT IN " + + "(SELECT " + History.FAVICON_ID + + " FROM " + TABLE_HISTORY + + " WHERE " + History.IS_DELETED + " = 0" + + " AND " + History.FAVICON_ID + " IS NOT NULL" + + " UNION ALL SELECT " + Bookmarks.FAVICON_ID + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + Bookmarks.IS_DELETED + " = 0" + + " AND " + Bookmarks.FAVICON_ID + " IS NOT NULL)"; + + String thumbnailSelection = Thumbnails.URL + " NOT IN " + + "(SELECT " + History.URL + + " FROM " + TABLE_HISTORY + + " WHERE " + History.IS_DELETED + " = 0" + + " AND " + History.URL + " IS NOT NULL" + + " UNION ALL SELECT " + Bookmarks.URL + + " FROM " + TABLE_BOOKMARKS + + " WHERE " + Bookmarks.IS_DELETED + " = 0" + + " AND " + Bookmarks.URL + " IS NOT NULL)"; + + return deleteFavicons(uri, faviconSelection, null) + + deleteThumbnails(uri, thumbnailSelection, null) + + getURLMetadataTable().deleteUnused(getWritableDatabase(uri)); + } + + @Override + public ContentProviderResult[] applyBatch (ArrayList<ContentProviderOperation> operations) + throws OperationApplicationException { + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + + if (numOperations < 1) { + debug("applyBatch: no operations; returning immediately."); + // The original Android implementation returns a zero-length + // array in this case. We do the same. + return results; + } + + boolean failures = false; + + // We only have 1 database for all Uris that we can get. + SQLiteDatabase db = getWritableDatabase(operations.get(0).getUri()); + + // Note that the apply() call may cause us to generate + // additional transactions for the individual operations. + // But Android's wrapper for SQLite supports nested transactions, + // so this will do the right thing. + // + // Note further that in some circumstances this can result in + // exceptions: if this transaction is first involved in reading, + // and then (naturally) tries to perform writes, SQLITE_BUSY can + // be raised. See Bug 947939 and friends. + beginBatch(db); + + for (int i = 0; i < numOperations; i++) { + try { + final ContentProviderOperation operation = operations.get(i); + results[i] = operation.apply(this, results, i); + } catch (SQLException e) { + Log.w(LOGTAG, "SQLite Exception during applyBatch.", e); + // The Android API makes it implementation-defined whether + // the failure of a single operation makes all others abort + // or not. For our use cases, best-effort operation makes + // more sense. Rolling back and forcing the caller to retry + // after it figures out what went wrong isn't very convenient + // anyway. + // Signal failed operation back, so the caller knows what + // went through and what didn't. + results[i] = new ContentProviderResult(0); + failures = true; + // http://www.sqlite.org/lang_conflict.html + // Note that we need a new transaction, subsequent operations + // on this one will fail (we're in ABORT by default, which + // isn't IGNORE). We still need to set it as successful to let + // everything before the failed op go through. + // We can't set conflict resolution on API level < 8, and even + // above 8 it requires splitting the call per operation + // (insert/update/delete). + db.setTransactionSuccessful(); + db.endTransaction(); + db.beginTransaction(); + } catch (OperationApplicationException e) { + // Repeat of above. + results[i] = new ContentProviderResult(0); + failures = true; + db.setTransactionSuccessful(); + db.endTransaction(); + db.beginTransaction(); + } + } + + trace("Flushing DB applyBatch..."); + markBatchSuccessful(db); + endBatch(db); + + if (failures) { + throw new OperationApplicationException(); + } + + return results; + } + + private static Table findTableFor(int id) { + for (Table table : sTables) { + for (Table.ContentProviderInfo type : table.getContentProviderInfo()) { + if (type.id == id) { + return table; + } + } + } + return null; + } + + private static void addTablesToMatcher(Table[] tables, final UriMatcher matcher) { + } + + private static String getContentItemType(final int match) { + for (Table table : sTables) { + for (Table.ContentProviderInfo type : table.getContentProviderInfo()) { + if (type.id == match) { + return "vnd.android.cursor.item/" + type.name; + } + } + } + + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java new file mode 100644 index 000000000..cfa2f870f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java @@ -0,0 +1,450 @@ +/* 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.annotation.TargetApi; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.os.Build; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.Telemetry; + +import java.util.Map; + +public class DBUtils { + private static final String LOGTAG = "GeckoDBUtils"; + + public static final int SQLITE_MAX_VARIABLE_NUMBER = 999; + + public static final String qualifyColumn(String table, String column) { + return table + "." + column; + } + + // This is available in Android >= 11. Implemented locally to be + // compatible with older versions. + public static String concatenateWhere(String a, String b) { + if (TextUtils.isEmpty(a)) { + return b; + } + + if (TextUtils.isEmpty(b)) { + return a; + } + + return "(" + a + ") AND (" + b + ")"; + } + + // This is available in Android >= 11. Implemented locally to be + // compatible with older versions. + public static String[] appendSelectionArgs(String[] originalValues, String[] newValues) { + if (originalValues == null || originalValues.length == 0) { + return newValues; + } + + if (newValues == null || newValues.length == 0) { + return originalValues; + } + + String[] result = new String[originalValues.length + newValues.length]; + System.arraycopy(originalValues, 0, result, 0, originalValues.length); + System.arraycopy(newValues, 0, result, originalValues.length, newValues.length); + + return result; + } + + /** + * Concatenate multiple lists of selection arguments. <code>values</code> may be <code>null</code>. + */ + public static String[] concatenateSelectionArgs(String[]... values) { + // Since we're most likely to be concatenating a few arrays of many values, it is most + // efficient to iterate over the arrays once to obtain their lengths, allowing us to create one target array + // (as opposed to copying arrays on every iteration, which would result in many more copies). + int totalLength = 0; + for (String[] v : values) { + if (v != null) { + totalLength += v.length; + } + } + + String[] result = new String[totalLength]; + + int position = 0; + for (String[] v: values) { + if (v != null) { + int currentLength = v.length; + System.arraycopy(v, 0, result, position, currentLength); + position += currentLength; + } + } + + return result; + } + + public static void replaceKey(ContentValues aValues, String aOriginalKey, + String aNewKey, String aDefault) { + String value = aDefault; + if (aOriginalKey != null && aValues.containsKey(aOriginalKey)) { + value = aValues.get(aOriginalKey).toString(); + aValues.remove(aOriginalKey); + } + + if (!aValues.containsKey(aNewKey)) { + aValues.put(aNewKey, value); + } + } + + private static String HISTOGRAM_DATABASE_LOCKED = "DATABASE_LOCKED_EXCEPTION"; + private static String HISTOGRAM_DATABASE_UNLOCKED = "DATABASE_SUCCESSFUL_UNLOCK"; + public static void ensureDatabaseIsNotLocked(SQLiteOpenHelper dbHelper, String databasePath) { + final int maxAttempts = 5; + int attempt = 0; + SQLiteDatabase db = null; + for (; attempt < maxAttempts; attempt++) { + try { + // Try a simple test and exit the loop. + db = dbHelper.getWritableDatabase(); + break; + } catch (Exception e) { + // We assume that this is a android.database.sqlite.SQLiteDatabaseLockedException. + // That class is only available on API 11+. + Telemetry.addToHistogram(HISTOGRAM_DATABASE_LOCKED, attempt); + + // Things could get very bad if we don't find a way to unlock the DB. + Log.d(LOGTAG, "Database is locked, trying to kill any zombie processes: " + databasePath); + GeckoAppShell.killAnyZombies(); + try { + Thread.sleep(attempt * 100); + } catch (InterruptedException ie) { + } + } + } + + if (db == null) { + Log.w(LOGTAG, "Failed to unlock database."); + GeckoAppShell.listOfOpenFiles(); + return; + } + + // If we needed to retry, but we succeeded, report that in telemetry. + // Failures are indicated by a lower frequency of UNLOCKED than LOCKED. + if (attempt > 1) { + Telemetry.addToHistogram(HISTOGRAM_DATABASE_UNLOCKED, attempt - 1); + } + } + + /** + * Copies a table <b>between</b> database files. + * + * This method assumes that the source table and destination table already exist in the + * source and destination databases, respectively. + * + * The table is copied row-by-row in a single transaction. + * + * @param source The source database that the table will be copied from. + * @param sourceTableName The name of the source table. + * @param destination The destination database that the table will be copied to. + * @param destinationTableName The name of the destination table. + * @return true if all rows were copied; false otherwise. + */ + public static boolean copyTable(SQLiteDatabase source, String sourceTableName, + SQLiteDatabase destination, String destinationTableName) { + Cursor cursor = null; + try { + destination.beginTransaction(); + + cursor = source.query(sourceTableName, null, null, null, null, null, null); + Log.d(LOGTAG, "Trying to copy " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName); + + final ContentValues contentValues = new ContentValues(); + while (cursor.moveToNext()) { + contentValues.clear(); + DatabaseUtils.cursorRowToContentValues(cursor, contentValues); + destination.insert(destinationTableName, null, contentValues); + } + + destination.setTransactionSuccessful(); + Log.d(LOGTAG, "Successfully copied " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName); + return true; + } catch (Exception e) { + Log.w(LOGTAG, "Got exception copying rows from " + sourceTableName + " to " + destinationTableName + "; ignoring.", e); + return false; + } finally { + destination.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Verifies that 0-byte arrays aren't added as favicon or thumbnail data. + * @param values ContentValues of query + * @param columnName Name of data column to verify + */ + public static void stripEmptyByteArray(ContentValues values, String columnName) { + if (values.containsKey(columnName)) { + byte[] data = values.getAsByteArray(columnName); + if (data == null || data.length == 0) { + Log.w(LOGTAG, "Tried to insert an empty or non-byte-array image. Ignoring."); + values.putNull(columnName); + } + } + } + + /** + * Builds a selection string that searches for a list of arguments in a particular column. + * For example URL in (?,?,?). Callers should pass the actual arguments into their query + * as selection args. + * @para columnName The column to search in + * @para size The number of arguments to search for + */ + public static String computeSQLInClause(int items, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + int i = 0; + for (; i < items - 1; ++i) { + builder.append("?, "); + } + if (i < items) { + builder.append("?"); + } + builder.append(")"); + return builder.toString(); + } + + /** + * Turn a single-column cursor of longs into a single SQL "IN" clause. + * We can do this without using selection arguments because Long isn't + * vulnerable to injection. + */ + public static String computeSQLInClauseFromLongs(final Cursor cursor, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + final int commaLimit = cursor.getCount() - 1; + int i = 0; + while (cursor.moveToNext()) { + builder.append(cursor.getLong(0)); + if (i++ < commaLimit) { + builder.append(", "); + } + } + builder.append(")"); + return builder.toString(); + } + + public static Uri appendProfile(final String profile, final Uri uri) { + return uri.buildUpon().appendQueryParameter(BrowserContract.PARAM_PROFILE, profile).build(); + } + + public static Uri appendProfileWithDefault(final String profile, final Uri uri) { + if (profile == null) { + return appendProfile(GeckoProfile.DEFAULT_PROFILE, uri); + } + return appendProfile(profile, uri); + } + + /** + * Use the following when no conflict action is specified. + */ + private static final int CONFLICT_NONE = 0; + private static final String[] CONFLICT_VALUES = new String[] {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", " OR REPLACE "}; + + /** + * Convenience method for updating rows in the database. + * + * @param table the table to update in + * @param values a map from column names to new column values. null is a + * valid value that will be translated to NULL. + * @param whereClause the optional WHERE clause to apply when updating. + * Passing null will update all rows. + * @param whereArgs You may include ?s in the where clause, which + * will be replaced by the values from whereArgs. The values + * will be bound as Strings. + * @return the number of rows affected + */ + @RobocopTarget + public static int updateArrays(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) { + return updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, true); + } + + public static void updateArraysBlindly(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) { + updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, false); + } + + @RobocopTarget + public enum UpdateOperation { + /** + * ASSIGN is the usual update: replaces the value in the named column with the provided value. + * + * foo = ? + */ + ASSIGN, + + /** + * BITWISE_OR applies the provided value to the existing value with a bitwise OR. This is useful for adding to flags. + * + * foo |= ? + */ + BITWISE_OR, + + /** + * EXPRESSION is an end-run around the API: it allows callers to specify a fragment of SQL to splice into the + * SET part of the query. + * + * foo = $value + * + * Be very careful not to use user input in this. + */ + EXPRESSION, + } + + /** + * This is an evil reimplementation of SQLiteDatabase's methods to allow for + * smarter updating. + * + * Each ContentValues has an associated enum that describes how to unify input values with the existing column values. + */ + private static int updateArraysWithOnConflict(SQLiteDatabase db, String table, + ContentValues[] values, + UpdateOperation[] ops, + String whereClause, + String[] whereArgs, + int conflictAlgorithm, + boolean returnChangedRows) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("Empty values"); + } + + if (ops == null || ops.length != values.length) { + throw new IllegalArgumentException("ops and values don't match"); + } + + StringBuilder sql = new StringBuilder(120); + sql.append("UPDATE "); + sql.append(CONFLICT_VALUES[conflictAlgorithm]); + sql.append(table); + sql.append(" SET "); + + // move all bind args to one array + int setValuesSize = 0; + for (int i = 0; i < values.length; i++) { + // EXPRESSION types don't contribute any placeholders. + if (ops[i] != UpdateOperation.EXPRESSION) { + setValuesSize += values[i].size(); + } + } + + int bindArgsSize = (whereArgs == null) ? setValuesSize : (setValuesSize + whereArgs.length); + Object[] bindArgs = new Object[bindArgsSize]; + + int arg = 0; + for (int i = 0; i < values.length; i++) { + final ContentValues v = values[i]; + final UpdateOperation op = ops[i]; + + // Alas, code duplication. + switch (op) { + case ASSIGN: + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + bindArgs[arg++] = entry.getValue(); + sql.append("= ?"); + } + break; + case BITWISE_OR: + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + bindArgs[arg++] = entry.getValue(); + sql.append("= ? | "); + sql.append(colName); + } + break; + case EXPRESSION: + // Treat each value as a literal SQL string. + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + sql.append(" = "); + sql.append(entry.getValue()); + } + break; + } + } + + if (whereArgs != null) { + for (arg = setValuesSize; arg < bindArgsSize; arg++) { + bindArgs[arg] = whereArgs[arg - setValuesSize]; + } + } + if (!TextUtils.isEmpty(whereClause)) { + sql.append(" WHERE "); + sql.append(whereClause); + } + + // What a huge pain in the ass, all because SQLiteDatabase doesn't expose .executeSql, + // and we can't get a DB handle. Nor can we easily construct a statement with arguments + // already bound. + final SQLiteStatement statement = db.compileStatement(sql.toString()); + try { + bindAllArgs(statement, bindArgs); + if (!returnChangedRows) { + statement.execute(); + return 0; + } + // This is a separate method so we can annotate it with @TargetApi. + return executeStatementReturningChangedRows(statement); + } finally { + statement.close(); + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private static int executeStatementReturningChangedRows(SQLiteStatement statement) { + return statement.executeUpdateDelete(); + } + + // All because {@link SQLiteProgram#bind(integer, Object)} is private. + private static void bindAllArgs(SQLiteStatement statement, Object[] bindArgs) { + if (bindArgs == null) { + return; + } + for (int i = bindArgs.length; i != 0; i--) { + Object v = bindArgs[i - 1]; + if (v == null) { + statement.bindNull(i); + } else if (v instanceof String) { + statement.bindString(i, (String) v); + } else if (v instanceof Double) { + statement.bindDouble(i, (Double) v); + } else if (v instanceof Float) { + statement.bindDouble(i, (Float) v); + } else if (v instanceof Long) { + statement.bindLong(i, (Long) v); + } else if (v instanceof Integer) { + statement.bindLong(i, (Integer) v); + } else if (v instanceof Byte) { + statement.bindLong(i, (Byte) v); + } else if (v instanceof byte[]) { + statement.bindBlob(i, (byte[]) v); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java new file mode 100644 index 000000000..ff2f5238e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java @@ -0,0 +1,166 @@ +/* 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 java.lang.IllegalArgumentException; +import java.util.HashMap; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.db.BrowserContract.FormHistory; +import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.sqlite.SQLiteBridge; +import org.mozilla.gecko.sync.Utils; + +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; + +public class FormHistoryProvider extends SQLiteBridgeContentProvider { + static final String TABLE_FORM_HISTORY = "moz_formhistory"; + static final String TABLE_DELETED_FORM_HISTORY = "moz_deleted_formhistory"; + + private static final int FORM_HISTORY = 100; + private static final int DELETED_FORM_HISTORY = 101; + + private static final UriMatcher URI_MATCHER; + + + // This should be kept in sync with the db version in toolkit/components/satchel/nsFormHistory.js + private static final int DB_VERSION = 4; + private static final String DB_FILENAME = "formhistory.sqlite"; + private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_FORMS"; + + private static final String WHERE_GUID_IS_NULL = BrowserContract.DeletedFormHistory.GUID + " IS NULL"; + private static final String WHERE_GUID_IS_VALUE = BrowserContract.DeletedFormHistory.GUID + " = ?"; + + private static final String LOG_TAG = "FormHistoryProvider"; + + static { + URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + URI_MATCHER.addURI(BrowserContract.FORM_HISTORY_AUTHORITY, "formhistory", FORM_HISTORY); + URI_MATCHER.addURI(BrowserContract.FORM_HISTORY_AUTHORITY, "deleted-formhistory", DELETED_FORM_HISTORY); + } + + public FormHistoryProvider() { + super(LOG_TAG); + } + + + @Override + public String getType(Uri uri) { + final int match = URI_MATCHER.match(uri); + + switch (match) { + case FORM_HISTORY: + return FormHistory.CONTENT_TYPE; + + case DELETED_FORM_HISTORY: + return DeletedFormHistory.CONTENT_TYPE; + + default: + throw new UnsupportedOperationException("Unknown type " + uri); + } + } + + @Override + public String getTable(Uri uri) { + String table = null; + final int match = URI_MATCHER.match(uri); + switch (match) { + case DELETED_FORM_HISTORY: + table = TABLE_DELETED_FORM_HISTORY; + break; + + case FORM_HISTORY: + table = TABLE_FORM_HISTORY; + break; + + default: + throw new UnsupportedOperationException("Unknown table " + uri); + } + return table; + } + + @Override + public String getSortOrder(Uri uri, String aRequested) { + if (!TextUtils.isEmpty(aRequested)) { + return aRequested; + } + + return null; + } + + @Override + public void setupDefaults(Uri uri, ContentValues values) { + int match = URI_MATCHER.match(uri); + long now = System.currentTimeMillis(); + + switch (match) { + case DELETED_FORM_HISTORY: + values.put(DeletedFormHistory.TIME_DELETED, now); + + // Deleted entries must contain a guid + if (!values.containsKey(FormHistory.GUID)) { + throw new IllegalArgumentException("Must provide a GUID for a deleted form history"); + } + break; + + case FORM_HISTORY: + // Generate GUID for new entry. Don't override specified GUIDs. + if (!values.containsKey(FormHistory.GUID)) { + String guid = Utils.generateGuid(); + values.put(FormHistory.GUID, guid); + } + break; + + default: + throw new UnsupportedOperationException("Unknown insert URI " + uri); + } + } + + @Override + public void initGecko() { + GeckoAppShell.notifyObservers("FormHistory:Init", null); + } + + @Override + public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) { + if (!values.containsKey(FormHistory.GUID)) { + return; + } + + String guid = values.getAsString(FormHistory.GUID); + if (guid == null) { + db.delete(TABLE_DELETED_FORM_HISTORY, WHERE_GUID_IS_NULL, null); + return; + } + String[] args = new String[] { guid }; + db.delete(TABLE_DELETED_FORM_HISTORY, WHERE_GUID_IS_VALUE, args); + } + + @Override + public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) { } + + @Override + public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) { } + + @Override + protected String getDBName() { + return DB_FILENAME; + } + + @Override + protected String getTelemetryPrefix() { + return TELEMETRY_TAG; + } + + @Override + protected int getDBVersion() { + return DB_VERSION; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java new file mode 100644 index 000000000..1a241f9da --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java @@ -0,0 +1,194 @@ +/* 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 java.io.IOException; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.db.DBUtils; +import org.mozilla.gecko.sqlite.SQLiteBridge; +import org.mozilla.gecko.util.RawResource; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.util.Log; + +public class HomeProvider extends SQLiteBridgeContentProvider { + private static final String LOGTAG = "GeckoHomeProvider"; + + // This should be kept in sync with the db version in mobile/android/modules/HomeProvider.jsm + private static final int DB_VERSION = 3; + private static final String DB_FILENAME = "home.sqlite"; + private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_HOME"; + + private static final String TABLE_ITEMS = "items"; + + // Endpoint to return static fake data. + static final int ITEMS_FAKE = 100; + static final int ITEMS = 101; + static final int ITEMS_ID = 102; + + static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + static { + URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items/fake", ITEMS_FAKE); + URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items", ITEMS); + URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items/#", ITEMS_ID); + } + + public HomeProvider() { + super(LOGTAG); + } + + @Override + public String getType(Uri uri) { + final int match = URI_MATCHER.match(uri); + + switch (match) { + case ITEMS_FAKE: { + return HomeItems.CONTENT_TYPE; + } + case ITEMS: { + return HomeItems.CONTENT_TYPE; + } + default: { + throw new UnsupportedOperationException("Unknown type " + uri); + } + } + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + final int match = URI_MATCHER.match(uri); + + // If we're querying the fake items, don't try to get the database. + if (match == ITEMS_FAKE) { + return queryFakeItems(uri, projection, selection, selectionArgs, sortOrder); + } + + final String datasetId = uri.getQueryParameter(BrowserContract.PARAM_DATASET_ID); + if (datasetId == null) { + throw new IllegalArgumentException("All queries should contain a dataset ID parameter"); + } + + selection = DBUtils.concatenateWhere(selection, HomeItems.DATASET_ID + " = ?"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { datasetId }); + + // Otherwise, let the SQLiteContentProvider implementation take care of this query for us! + Cursor c = super.query(uri, projection, selection, selectionArgs, sortOrder); + + // SQLiteBridgeContentProvider may return a null Cursor if the database hasn't been created yet. + // However, we need a non-null cursor in order to listen for notifications. + if (c == null) { + c = new MatrixCursor(projection != null ? projection : HomeItems.DEFAULT_PROJECTION); + } + + final ContentResolver cr = getContext().getContentResolver(); + c.setNotificationUri(cr, getDatasetNotificationUri(datasetId)); + + return c; + } + + /** + * Returns a cursor populated with static fake data. + */ + private Cursor queryFakeItems(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + JSONArray items = null; + try { + final String jsonString = RawResource.getAsString(getContext(), R.raw.fake_home_items); + items = new JSONArray(jsonString); + } catch (IOException e) { + Log.e(LOGTAG, "Error getting fake home items", e); + return null; + } catch (JSONException e) { + Log.e(LOGTAG, "Error parsing fake_home_items.json", e); + return null; + } + + final MatrixCursor c = new MatrixCursor(HomeItems.DEFAULT_PROJECTION); + for (int i = 0; i < items.length(); i++) { + try { + final JSONObject item = items.getJSONObject(i); + c.addRow(new Object[] { + item.getInt("id"), + item.getString("dataset_id"), + item.getString("url"), + item.getString("title"), + item.getString("description"), + item.getString("image_url"), + item.getString("filter") + }); + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating cursor row for fake home item", e); + } + } + return c; + } + + /** + * SQLiteBridgeContentProvider implementation + */ + + @Override + protected String getDBName() { + return DB_FILENAME; + } + + @Override + protected String getTelemetryPrefix() { + return TELEMETRY_TAG; + } + + @Override + protected int getDBVersion() { + return DB_VERSION; + } + + @Override + public String getTable(Uri uri) { + final int match = URI_MATCHER.match(uri); + switch (match) { + case ITEMS: { + return TABLE_ITEMS; + } + default: { + throw new UnsupportedOperationException("Unknown table " + uri); + } + } + } + + @Override + public String getSortOrder(Uri uri, String aRequested) { + return null; + } + + @Override + public void setupDefaults(Uri uri, ContentValues values) { } + + @Override + public void initGecko() { } + + @Override + public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) { } + + @Override + public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) { } + + @Override + public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) { } + + public static Uri getDatasetNotificationUri(String datasetId) { + return Uri.withAppendedPath(HomeItems.CONTENT_URI, datasetId); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java new file mode 100644 index 000000000..8c219282f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java @@ -0,0 +1,1938 @@ +/* -*- 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 java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.IllegalAccessException; +import java.lang.NoSuchFieldException; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.BrowserContract.Combined; +import org.mozilla.gecko.db.BrowserContract.ExpirePriority; +import org.mozilla.gecko.db.BrowserContract.Favicons; +import org.mozilla.gecko.db.BrowserContract.History; +import org.mozilla.gecko.db.BrowserContract.SyncColumns; +import org.mozilla.gecko.db.BrowserContract.Thumbnails; +import org.mozilla.gecko.db.BrowserContract.TopSites; +import org.mozilla.gecko.db.BrowserContract.Highlights; +import org.mozilla.gecko.db.BrowserContract.PageMetadata; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.icons.decoders.FaviconDecoder; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.GeckoJarReader; +import org.mozilla.gecko.util.StringUtils; + +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; +import android.os.RemoteException; +import android.os.SystemClock; +import android.support.annotation.CheckResult; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.CursorLoader; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.util.IOUtils; + +import static org.mozilla.gecko.util.IOUtils.ConsumedInputStream; + +public class LocalBrowserDB extends BrowserDB { + // The default size of the buffer to use for downloading Favicons in the event no size is given + // by the server. + public static final int DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000; + + private static final String LOGTAG = "GeckoLocalBrowserDB"; + + // Calculate this once, at initialization. isLoggable is too expensive to + // have in-line in each log call. + private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); + protected static void debug(String message) { + if (logDebug) { + Log.d(LOGTAG, message); + } + } + + // Sentinel value used to indicate a failure to locate an ID for a default favicon. + private static final int FAVICON_ID_NOT_FOUND = Integer.MIN_VALUE; + + // Constant used to indicate that no folder was found for particular GUID. + private static final long FOLDER_NOT_FOUND = -1L; + + private final String mProfile; + + // Map of folder GUIDs to IDs. Used for caching. + private final HashMap<String, Long> mFolderIdMap; + + // Use wrapped Boolean so that we can have a null state + private volatile Boolean mDesktopBookmarksExist; + + private volatile SuggestedSites mSuggestedSites; + + // Constants used when importing history data from legacy browser. + public static String HISTORY_VISITS_DATE = "date"; + public static String HISTORY_VISITS_COUNT = "visits"; + public static String HISTORY_VISITS_URL = "url"; + + private static final String TELEMETRY_HISTOGRAM_ACITIVITY_STREAM_TOPSITES = "FENNEC_ACTIVITY_STREAM_TOPSITES_LOADER_TIME_MS"; + + private final Uri mBookmarksUriWithProfile; + private final Uri mParentsUriWithProfile; + private final Uri mHistoryUriWithProfile; + private final Uri mHistoryExpireUriWithProfile; + private final Uri mCombinedUriWithProfile; + private final Uri mUpdateHistoryUriWithProfile; + private final Uri mFaviconsUriWithProfile; + private final Uri mThumbnailsUriWithProfile; + private final Uri mTopSitesUriWithProfile; + private final Uri mHighlightsUriWithProfile; + private final Uri mSearchHistoryUri; + private final Uri mActivityStreamBlockedUriWithProfile; + private final Uri mPageMetadataWithProfile; + + private LocalSearches searches; + private LocalTabsAccessor tabsAccessor; + private LocalURLMetadata urlMetadata; + private LocalUrlAnnotations urlAnnotations; + + private static final String[] DEFAULT_BOOKMARK_COLUMNS = + new String[] { Bookmarks._ID, + Bookmarks.GUID, + Bookmarks.URL, + Bookmarks.TITLE, + Bookmarks.TYPE, + Bookmarks.PARENT }; + + public LocalBrowserDB(String profile) { + mProfile = profile; + mFolderIdMap = new HashMap<String, Long>(); + + mBookmarksUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.CONTENT_URI); + mParentsUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.PARENTS_CONTENT_URI); + mHistoryUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_URI); + mHistoryExpireUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_OLD_URI); + mCombinedUriWithProfile = DBUtils.appendProfile(profile, Combined.CONTENT_URI); + mFaviconsUriWithProfile = DBUtils.appendProfile(profile, Favicons.CONTENT_URI); + mTopSitesUriWithProfile = DBUtils.appendProfile(profile, TopSites.CONTENT_URI); + mHighlightsUriWithProfile = DBUtils.appendProfile(profile, Highlights.CONTENT_URI); + mThumbnailsUriWithProfile = DBUtils.appendProfile(profile, Thumbnails.CONTENT_URI); + mActivityStreamBlockedUriWithProfile = DBUtils.appendProfile(profile, ActivityStreamBlocklist.CONTENT_URI); + + mPageMetadataWithProfile = DBUtils.appendProfile(profile, PageMetadata.CONTENT_URI); + + mSearchHistoryUri = BrowserContract.SearchHistory.CONTENT_URI; + + mUpdateHistoryUriWithProfile = + mHistoryUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true") + .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true") + .build(); + + searches = new LocalSearches(mProfile); + tabsAccessor = new LocalTabsAccessor(mProfile); + urlMetadata = new LocalURLMetadata(mProfile); + urlAnnotations = new LocalUrlAnnotations(mProfile); + } + + @Override + public Searches getSearches() { + return searches; + } + + @Override + public TabsAccessor getTabsAccessor() { + return tabsAccessor; + } + + @Override + public URLMetadata getURLMetadata() { + return urlMetadata; + } + + @RobocopTarget + @Override + public UrlAnnotations getUrlAnnotations() { + return urlAnnotations; + } + + /** + * Not thread safe. A helper to allocate new IDs for arbitrary strings. + */ + private static class NameCounter { + private final HashMap<String, Integer> names = new HashMap<String, Integer>(); + private int counter; + private final int increment; + + public NameCounter(int start, int increment) { + this.counter = start; + this.increment = increment; + } + + public int get(final String name) { + Integer mapping = names.get(name); + if (mapping == null) { + int ours = counter; + counter += increment; + names.put(name, ours); + return ours; + } + + return mapping; + } + + public boolean has(final String name) { + return names.containsKey(name); + } + } + + /** + * Add default bookmarks to the database. + * Takes an offset; returns a new offset. + */ + @Override + public int addDefaultBookmarks(Context context, ContentResolver cr, final int offset) { + final long folderID = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID); + if (folderID == FOLDER_NOT_FOUND) { + Log.e(LOGTAG, "No mobile folder: cannot add default bookmarks."); + return offset; + } + + // Use reflection to walk the set of bookmark defaults. + // This is horrible. + final Class<?> stringsClass = R.string.class; + final Field[] fields = stringsClass.getFields(); + final Pattern p = Pattern.compile("^bookmarkdefaults_title_"); + + int pos = offset; + final long now = System.currentTimeMillis(); + + final ArrayList<ContentValues> bookmarkValues = new ArrayList<ContentValues>(); + final ArrayList<ContentValues> faviconValues = new ArrayList<ContentValues>(); + + // Count down from -offset into negative values to get new favicon IDs. + final NameCounter faviconIDs = new NameCounter((-1 - offset), -1); + + for (int i = 0; i < fields.length; i++) { + final String name = fields[i].getName(); + final Matcher m = p.matcher(name); + if (!m.find()) { + continue; + } + + try { + if (Restrictions.isRestrictedProfile(context)) { + // matching on variable name from strings.xml.in + final String addons = "bookmarkdefaults_title_addons"; + final String regularSumo = "bookmarkdefaults_title_support"; + if (name.equals(addons) || name.equals(regularSumo)) { + continue; + } + } + if (!Restrictions.isRestrictedProfile(context)) { + // if we're not in kidfox, skip the kidfox specific bookmark(s) + if (name.startsWith("bookmarkdefaults_title_restricted")) { + continue; + } + } + final int titleID = fields[i].getInt(null); + final String title = context.getString(titleID); + + final Field urlField = stringsClass.getField(name.replace("_title_", "_url_")); + final int urlID = urlField.getInt(null); + final String url = context.getString(urlID); + + final ContentValues bookmarkValue = createBookmark(now, title, url, pos++, folderID); + bookmarkValues.add(bookmarkValue); + + ConsumedInputStream faviconStream = getDefaultFaviconFromDrawable(context, name); + if (faviconStream == null) { + faviconStream = getDefaultFaviconFromPath(context, name); + } + + if (faviconStream == null) { + continue; + } + + // In the event that truncating the buffer fails, give up and move on. + byte[] icon; + try { + icon = faviconStream.getTruncatedData(); + } catch (OutOfMemoryError e) { + continue; + } + + final ContentValues iconValue = createFavicon(url, icon); + + // Assign a reserved negative _id to each new favicon. + // For now, each name is expected to be unique, and duplicate + // icons will be duplicated in the DB. See Bug 1040806 Comment 8. + if (iconValue != null) { + final int faviconID = faviconIDs.get(name); + iconValue.put("_id", faviconID); + bookmarkValue.put(Bookmarks.FAVICON_ID, faviconID); + faviconValues.add(iconValue); + } + } catch (IllegalAccessException | IllegalArgumentException | NoSuchFieldException e) { + Log.wtf(LOGTAG, "Reflection failure.", e); + } + } + + if (!faviconValues.isEmpty()) { + try { + cr.bulkInsert(mFaviconsUriWithProfile, faviconValues.toArray(new ContentValues[faviconValues.size()])); + } catch (Exception e) { + Log.e(LOGTAG, "Error bulk-inserting default favicons.", e); + } + } + + if (!bookmarkValues.isEmpty()) { + try { + final int inserted = cr.bulkInsert(mBookmarksUriWithProfile, bookmarkValues.toArray(new ContentValues[bookmarkValues.size()])); + return offset + inserted; + } catch (Exception e) { + Log.e(LOGTAG, "Error bulk-inserting default bookmarks.", e); + } + } + + return offset; + } + + /** + * Add bookmarks from the provided distribution. + * Takes an offset; returns a new offset. + */ + @Override + public int addDistributionBookmarks(ContentResolver cr, Distribution distribution, int offset) { + if (!distribution.exists()) { + Log.d(LOGTAG, "No distribution from which to add bookmarks."); + return offset; + } + + final JSONArray bookmarks = distribution.getBookmarks(); + if (bookmarks == null) { + Log.d(LOGTAG, "No distribution bookmarks."); + return offset; + } + + final long folderID = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID); + if (folderID == FOLDER_NOT_FOUND) { + Log.e(LOGTAG, "No mobile folder: cannot add distribution bookmarks."); + return offset; + } + + final Locale locale = Locale.getDefault(); + final long now = System.currentTimeMillis(); + int mobilePos = offset; + int pinnedPos = 0; // Assume nobody has pinned anything yet. + + final ArrayList<ContentValues> bookmarkValues = new ArrayList<ContentValues>(); + final ArrayList<ContentValues> faviconValues = new ArrayList<ContentValues>(); + + // Count down from -offset into negative values to get new favicon IDs. + final NameCounter faviconIDs = new NameCounter((-1 - offset), -1); + + for (int i = 0; i < bookmarks.length(); i++) { + try { + final JSONObject bookmark = bookmarks.getJSONObject(i); + + final String title = getLocalizedProperty(bookmark, "title", locale); + final String url = getLocalizedProperty(bookmark, "url", locale); + final long parent; + final int pos; + if (bookmark.has("pinned")) { + parent = Bookmarks.FIXED_PINNED_LIST_ID; + pos = pinnedPos++; + } else { + parent = folderID; + pos = mobilePos++; + } + + final ContentValues bookmarkValue = createBookmark(now, title, url, pos, parent); + bookmarkValues.add(bookmarkValue); + + // Return early if there is no icon for this bookmark. + if (!bookmark.has("icon")) { + continue; + } + + try { + final String iconData = bookmark.getString("icon"); + + byte[] icon = BitmapUtils.getBytesFromDataURI(iconData); + if (icon == null) { + continue; + } + + final ContentValues iconValue = createFavicon(url, icon); + if (iconValue == null) { + continue; + } + + /* + * Find out if this icon is a duplicate. If it is, don't try + * to insert it again, but reuse the shared ID. + * Otherwise, assign a new reserved negative _id. + * Duplicates won't be detected in default bookmarks, or + * those already in the database. + */ + final boolean seen = faviconIDs.has(iconData); + final int faviconID = faviconIDs.get(iconData); + + iconValue.put("_id", faviconID); + bookmarkValue.put(Bookmarks.FAVICON_ID, faviconID); + + if (!seen) { + faviconValues.add(iconValue); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating distribution bookmark icon.", e); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating distribution bookmark.", e); + } + } + + if (!faviconValues.isEmpty()) { + try { + cr.bulkInsert(mFaviconsUriWithProfile, faviconValues.toArray(new ContentValues[faviconValues.size()])); + } catch (Exception e) { + Log.e(LOGTAG, "Error bulk-inserting distribution favicons.", e); + } + } + + if (!bookmarkValues.isEmpty()) { + try { + final int inserted = cr.bulkInsert(mBookmarksUriWithProfile, bookmarkValues.toArray(new ContentValues[bookmarkValues.size()])); + return offset + inserted; + } catch (Exception e) { + Log.e(LOGTAG, "Error bulk-inserting distribution bookmarks.", e); + } + } + + return offset; + } + + private static ContentValues createBookmark(final long timestamp, final String title, final String url, final int pos, final long parent) { + final ContentValues v = new ContentValues(); + + v.put(Bookmarks.DATE_CREATED, timestamp); + v.put(Bookmarks.DATE_MODIFIED, timestamp); + v.put(Bookmarks.GUID, Utils.generateGuid()); + + v.put(Bookmarks.PARENT, parent); + v.put(Bookmarks.POSITION, pos); + v.put(Bookmarks.TITLE, title); + v.put(Bookmarks.URL, url); + return v; + } + + private static ContentValues createFavicon(final String url, final byte[] icon) { + ContentValues iconValues = new ContentValues(); + iconValues.put(Favicons.PAGE_URL, url); + iconValues.put(Favicons.DATA, icon); + + return iconValues; + } + + private static String getLocalizedProperty(final JSONObject bookmark, final String property, final Locale locale) throws JSONException { + // Try the full locale. + final String fullLocale = property + "." + locale.toString(); + if (bookmark.has(fullLocale)) { + return bookmark.getString(fullLocale); + } + + // Try without a variant. + if (!TextUtils.isEmpty(locale.getVariant())) { + String noVariant = fullLocale.substring(0, fullLocale.lastIndexOf("_")); + if (bookmark.has(noVariant)) { + return bookmark.getString(noVariant); + } + } + + // Try just the language. + String lang = property + "." + locale.getLanguage(); + if (bookmark.has(lang)) { + return bookmark.getString(lang); + } + + // Default to the non-localized property name. + return bookmark.getString(property); + } + + private static int getFaviconId(String name) { + try { + Class<?> drawablesClass = R.raw.class; + + // Look for a favicon with the id R.raw.bookmarkdefaults_favicon_*. + Field faviconField = drawablesClass.getField(name.replace("_title_", "_favicon_")); + faviconField.setAccessible(true); + + return faviconField.getInt(null); + } catch (IllegalAccessException | NoSuchFieldException e) { + // We'll end up here for any default bookmark that doesn't have a favicon in + // resources/raw/ (i.e., about:firefox). When this happens, the Favicons service will + // fall back to the default branding icon for about pages. Non-about pages should always + // specify an icon; otherwise, the placeholder globe favicon will be used. + Log.d(LOGTAG, "No raw favicon resource found for " + name); + } + + Log.e(LOGTAG, "Failed to find favicon resource ID for " + name); + return FAVICON_ID_NOT_FOUND; + } + + @Override + public boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON) { + final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl); + + if (historyGUID == null) { + return false; + } + + // We have the GUID, insert the metadata. + final ContentValues cv = new ContentValues(); + cv.put(PageMetadata.HISTORY_GUID, historyGUID); + cv.put(PageMetadata.HAS_IMAGE, hasImage); + cv.put(PageMetadata.JSON, metadataJSON); + + try { + contentProviderClient.insert(mPageMetadataWithProfile, cv); + } catch (RemoteException e) { + throw new IllegalStateException("Unexpected RemoteException", e); + } + + return true; + } + + @Override + public int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl) { + final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl); + + if (historyGUID == null) { + return 0; + } + + try { + return contentProviderClient.delete(mPageMetadataWithProfile, PageMetadata.HISTORY_GUID + " = ?", new String[]{historyGUID}); + } catch (RemoteException e) { + throw new IllegalStateException("Unexpected RemoteException", e); + } + } + + @Nullable + private String lookupHistoryGUIDByPageUri(ContentProviderClient contentProviderClient, String uri) { + // Unfortunately we might have duplicate history records for the same URL. + final Cursor cursor; + try { + cursor = contentProviderClient.query( + mHistoryUriWithProfile + .buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, "1") + .build(), + new String[]{ + History.GUID, + }, + History.URL + "= ?", + new String[]{uri}, History.DATE_LAST_VISITED + " DESC" + ); + } catch (RemoteException e) { + // Won't happen, we control the implementation. + throw new IllegalStateException("Unexpected RemoteException", e); + } + + if (cursor == null) { + return null; + } + + try { + if (!cursor.moveToFirst()) { + return null; + } + + final int historyGUIDCol = cursor.getColumnIndexOrThrow(History.GUID); + return cursor.getString(historyGUIDCol); + } finally { + cursor.close(); + } + } + + /** + * Load a favicon from the omnijar. + * @return A ConsumedInputStream containing the bytes loaded from omnijar. This must be a format + * compatible with the favicon decoder (most probably a PNG or ICO file). + */ + private static ConsumedInputStream getDefaultFaviconFromPath(Context context, String name) { + final int faviconId = getFaviconId(name); + if (faviconId == FAVICON_ID_NOT_FOUND) { + return null; + } + + final String bitmapPath = GeckoJarReader.getJarURL(context, context.getString(faviconId)); + final InputStream iStream = GeckoJarReader.getStream(context, bitmapPath); + + return IOUtils.readFully(iStream, DEFAULT_FAVICON_BUFFER_SIZE_BYTES); + } + + private static ConsumedInputStream getDefaultFaviconFromDrawable(Context context, String name) { + int faviconId = getFaviconId(name); + if (faviconId == FAVICON_ID_NOT_FOUND) { + return null; + } + + InputStream iStream = context.getResources().openRawResource(faviconId); + return IOUtils.readFully(iStream, DEFAULT_FAVICON_BUFFER_SIZE_BYTES); + } + + // Invalidate cached data + @Override + public void invalidate() { + mDesktopBookmarksExist = null; + } + + private Uri bookmarksUriWithLimit(int limit) { + return mBookmarksUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, + String.valueOf(limit)) + .build(); + } + + private Uri combinedUriWithLimit(int limit) { + return mCombinedUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, + String.valueOf(limit)) + .build(); + } + + private static Uri withDeleted(final Uri uri) { + return uri.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1") + .build(); + } + + private Cursor filterAllSites(ContentResolver cr, String[] projection, CharSequence constraint, + int limit, CharSequence urlFilter, String selection, String[] selectionArgs) { + // The combined history/bookmarks selection queries for sites with a URL or title containing + // the constraint string(s), treating space-separated words as separate constraints + if (!TextUtils.isEmpty(constraint)) { + final String[] constraintWords = constraint.toString().split(" "); + + // Only create a filter query with a maximum of 10 constraint words. + final int constraintCount = Math.min(constraintWords.length, 10); + for (int i = 0; i < constraintCount; i++) { + selection = DBUtils.concatenateWhere(selection, "(" + Combined.URL + " LIKE ? OR " + + Combined.TITLE + " LIKE ?)"); + String constraintWord = "%" + constraintWords[i] + "%"; + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { constraintWord, constraintWord }); + } + } + + if (urlFilter != null) { + selection = DBUtils.concatenateWhere(selection, "(" + Combined.URL + " NOT LIKE ?)"); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { urlFilter.toString() }); + } + + // Order by combined remote+local frecency score. + // Local visits are preferred, so they will by far outweigh remote visits. + // Bookmarked history items get extra frecency points. + final String sortOrder = BrowserContract.getCombinedFrecencySortOrder(true, false); + + return cr.query(combinedUriWithLimit(limit), + projection, + selection, + selectionArgs, + sortOrder); + } + + @Override + public int getCount(ContentResolver cr, String database) { + int count = 0; + String[] columns = null; + String constraint = null; + Uri uri = null; + + if ("history".equals(database)) { + uri = mHistoryUriWithProfile; + columns = new String[] { History._ID }; + constraint = Combined.VISITS + " > 0"; + } else if ("bookmarks".equals(database)) { + uri = mBookmarksUriWithProfile; + columns = new String[] { Bookmarks._ID }; + // ignore folders, tags, keywords, separators, etc. + constraint = Bookmarks.TYPE + " = " + Bookmarks.TYPE_BOOKMARK; + } else if ("thumbnails".equals(database)) { + uri = mThumbnailsUriWithProfile; + columns = new String[] { Thumbnails._ID }; + } else if ("favicons".equals(database)) { + uri = mFaviconsUriWithProfile; + columns = new String[] { Favicons._ID }; + } + + if (uri != null) { + final Cursor cursor = cr.query(uri, columns, constraint, null, null); + + try { + count = cursor.getCount(); + } finally { + cursor.close(); + } + } + + debug("Got count " + count + " for " + database); + return count; + } + + @Override + @RobocopTarget + public Cursor filter(ContentResolver cr, CharSequence constraint, int limit, + EnumSet<FilterFlags> flags) { + String selection = ""; + String[] selectionArgs = null; + + if (flags.contains(FilterFlags.EXCLUDE_PINNED_SITES)) { + selection = Combined.URL + " NOT IN (SELECT " + + Bookmarks.URL + " FROM bookmarks WHERE " + + DBUtils.qualifyColumn("bookmarks", Bookmarks.PARENT) + " = ? AND " + + DBUtils.qualifyColumn("bookmarks", Bookmarks.IS_DELETED) + " == 0)"; + selectionArgs = new String[] { String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) }; + } + + return filterAllSites(cr, + new String[] { Combined._ID, + Combined.URL, + Combined.TITLE, + Combined.BOOKMARK_ID, + Combined.HISTORY_ID }, + constraint, + limit, + null, + selection, selectionArgs); + } + + @Override + public void updateVisitedHistory(ContentResolver cr, String uri) { + ContentValues values = new ContentValues(); + + values.put(History.URL, uri); + values.put(History.DATE_LAST_VISITED, System.currentTimeMillis()); + values.put(History.IS_DELETED, 0); + + // This will insert a new history entry if one for this URL + // doesn't already exist + cr.update(mUpdateHistoryUriWithProfile, + values, + History.URL + " = ?", + new String[] { uri }); + } + + @Override + public void updateHistoryTitle(ContentResolver cr, String uri, String title) { + ContentValues values = new ContentValues(); + values.put(History.TITLE, title); + + cr.update(mHistoryUriWithProfile, + values, + History.URL + " = ?", + new String[] { uri }); + } + + @Override + @RobocopTarget + public Cursor getAllVisitedHistory(ContentResolver cr) { + return cr.query(mHistoryUriWithProfile, + new String[] { History.URL }, + History.VISITS + " > 0", + null, + null); + } + + @Override + public Cursor getRecentHistory(ContentResolver cr, int limit) { + return cr.query(combinedUriWithLimit(limit), + new String[] { Combined._ID, + Combined.BOOKMARK_ID, + Combined.HISTORY_ID, + Combined.URL, + Combined.TITLE, + Combined.DATE_LAST_VISITED, + Combined.VISITS }, + History.DATE_LAST_VISITED + " > 0", + null, + History.DATE_LAST_VISITED + " DESC"); + } + + @Override + public Cursor getRecentHistoryBetweenTime(ContentResolver cr, int limit, long start, long end) { + return cr.query(combinedUriWithLimit(limit), + new String[] { Combined._ID, + Combined.BOOKMARK_ID, + Combined.HISTORY_ID, + Combined.URL, + Combined.TITLE, + Combined.DATE_LAST_VISITED, + Combined.VISITS }, + History.DATE_LAST_VISITED + " >= " + start + " AND " + History.DATE_LAST_VISITED + " < " + end, + null, + History.DATE_LAST_VISITED + " DESC"); + } + + public Cursor getHistoryForURL(ContentResolver cr, String uri) { + return cr.query(mHistoryUriWithProfile, + new String[] { + History.VISITS, + History.DATE_LAST_VISITED + }, + History.URL + "= ?", + new String[] { uri }, + History.DATE_LAST_VISITED + " DESC" + ); + } + + @Override + public long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath) { + if (prePath == null) { + return 0; + } + // If we don't end with a trailing slash, then both https://foo.com and https://foo.company.biz will match. + if (!prePath.endsWith("/")) { + prePath = prePath + "/"; + } + final Cursor cursor = cr.query(BrowserContract.History.CONTENT_URI, + new String[] { "MAX(" + BrowserContract.HistoryColumns.DATE_LAST_VISITED + ") AS date" }, + BrowserContract.URLColumns.URL + " BETWEEN ? AND ?", new String[] { prePath, prePath + "\u007f" }, null); + try { + cursor.moveToFirst(); + if (cursor.isAfterLast()) { + return 0; + } + return cursor.getLong(0); + } finally { + cursor.close(); + } + } + + @Override + public void expireHistory(ContentResolver cr, ExpirePriority priority) { + Uri url = mHistoryExpireUriWithProfile; + url = url.buildUpon().appendQueryParameter(BrowserContract.PARAM_EXPIRE_PRIORITY, priority.toString()).build(); + cr.delete(url, null, null); + } + + @Override + @RobocopTarget + public void removeHistoryEntry(ContentResolver cr, String url) { + cr.delete(mHistoryUriWithProfile, + History.URL + " = ?", + new String[] { url }); + } + + @Override + public void clearHistory(ContentResolver cr, boolean clearSearchHistory) { + if (clearSearchHistory) { + cr.delete(mSearchHistoryUri, null, null); + } else { + cr.delete(mHistoryUriWithProfile, null, null); + } + } + + private void assertDefaultBookmarkColumnOrdering() { + // We need to insert MatrixCursor values in a specific order - in order to protect against changes + // in DEFAULT_BOOKMARK_COLUMNS we can just assert that we're using the correct ordering. + // Alternatively we could use RowBuilder.add(columnName, value) but that needs api >= 19, + // or we could iterate over DEFAULT_BOOKMARK_COLUMNS, but that gets messy once we need + // to add more than one artificial folder. + if (!((DEFAULT_BOOKMARK_COLUMNS[0].equals(Bookmarks._ID)) && + (DEFAULT_BOOKMARK_COLUMNS[1].equals(Bookmarks.GUID)) && + (DEFAULT_BOOKMARK_COLUMNS[2].equals(Bookmarks.URL)) && + (DEFAULT_BOOKMARK_COLUMNS[3].equals(Bookmarks.TITLE)) && + (DEFAULT_BOOKMARK_COLUMNS[4].equals(Bookmarks.TYPE)) && + (DEFAULT_BOOKMARK_COLUMNS[5].equals(Bookmarks.PARENT)) && + (DEFAULT_BOOKMARK_COLUMNS.length == 6))) { + // If DEFAULT_BOOKMARK_COLUMNS changes we need to update all the MatrixCursor rows + // to contain appropriate data. + throw new IllegalStateException("Fake folder MatrixCursor creation code must be updated to match DEFAULT_BOOKMARK_COLUMNS"); + } + } + + /** + * Retrieve the list of reader-view bookmarks, i.e. the equivalent of the former reading-list. + * This is the result of a join of bookmarks with reader-view annotations (as stored in + * UrlAnnotations). + */ + private Cursor getReadingListBookmarks(ContentResolver cr) { + // group by URL to avoid having duplicate bookmarks listed. It's possible to have multiple + // bookmarks pointing to the same URL (this would most commonly happen by manually + // copying bookmarks on desktop, followed by syncing with mobile), and we don't want + // to show the same URL multiple times in the reading list folder. + final Uri bookmarksGroupedByUri = mBookmarksUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_GROUP_BY, Bookmarks.URL) + .build(); + + return cr.query(bookmarksGroupedByUri, + DEFAULT_BOOKMARK_COLUMNS, + Bookmarks.ANNOTATION_KEY + " == ? AND " + + Bookmarks.ANNOTATION_VALUE + " == ? AND " + + "(" + Bookmarks.TYPE + " = ? AND " + Bookmarks.URL + " IS NOT NULL)", + new String[] { + BrowserContract.UrlAnnotations.Key.READER_VIEW.getDbValue(), + BrowserContract.UrlAnnotations.READER_VIEW_SAVED_VALUE, + String.valueOf(Bookmarks.TYPE_BOOKMARK) }, + null); + } + + @Override + @RobocopTarget + public Cursor getBookmarksInFolder(ContentResolver cr, long folderId) { + final boolean addDesktopFolder; + final boolean addScreenshotsFolder; + final boolean addReadingListFolder; + + // We always want to show mobile bookmarks in the root view. + if (folderId == Bookmarks.FIXED_ROOT_ID) { + folderId = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID); + + // We'll add a fake "Desktop Bookmarks" folder to the root view if desktop + // bookmarks exist, so that the user can still access non-mobile bookmarks. + addDesktopFolder = desktopBookmarksExist(cr); + addScreenshotsFolder = AppConstants.SCREENSHOTS_IN_BOOKMARKS_ENABLED; + + final int readingListItemCount = getBookmarkCountForFolder(cr, Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID); + addReadingListFolder = (readingListItemCount > 0); + } else { + addDesktopFolder = false; + addScreenshotsFolder = false; + addReadingListFolder = false; + } + + final Cursor c; + + // (You can't switch on a long in Java, hence the if statements) + if (folderId == Bookmarks.FAKE_DESKTOP_FOLDER_ID) { + // Since the "Desktop Bookmarks" folder doesn't actually exist, we + // just fake it by querying specifically certain known desktop folders. + c = cr.query(mBookmarksUriWithProfile, + DEFAULT_BOOKMARK_COLUMNS, + Bookmarks.GUID + " = ? OR " + + Bookmarks.GUID + " = ? OR " + + Bookmarks.GUID + " = ?", + new String[] { Bookmarks.TOOLBAR_FOLDER_GUID, + Bookmarks.MENU_FOLDER_GUID, + Bookmarks.UNFILED_FOLDER_GUID }, + null); + } else if (folderId == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) { + c = getUrlAnnotations().getScreenshots(cr); + } else if (folderId == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) { + c = getReadingListBookmarks(cr); + } else { + // Right now, we only support showing folder and bookmark type of + // entries. We should add support for other types though (bug 737024) + c = cr.query(mBookmarksUriWithProfile, + DEFAULT_BOOKMARK_COLUMNS, + Bookmarks.PARENT + " = ? AND " + + "(" + Bookmarks.TYPE + " = ? OR " + + "(" + Bookmarks.TYPE + " = ? AND " + Bookmarks.URL + " IS NOT NULL))", + new String[] { String.valueOf(folderId), + String.valueOf(Bookmarks.TYPE_FOLDER), + String.valueOf(Bookmarks.TYPE_BOOKMARK) }, + null); + } + + final List<Cursor> cursorsToMerge = getSpecialFoldersCursorList(addDesktopFolder, addScreenshotsFolder, addReadingListFolder); + if (cursorsToMerge.size() >= 1) { + cursorsToMerge.add(c); + final Cursor[] arr = (Cursor[]) Array.newInstance(Cursor.class, cursorsToMerge.size()); + return new MergeCursor(cursorsToMerge.toArray(arr)); + } else { + return c; + } + } + + @Override + public int getBookmarkCountForFolder(ContentResolver cr, long folderID) { + if (folderID == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) { + return getUrlAnnotations().getAnnotationCount(cr, BrowserContract.UrlAnnotations.Key.READER_VIEW); + } else { + throw new IllegalArgumentException("Retrieving bookmark count for folder with ID=" + folderID + " not supported yet"); + } + } + + @CheckResult + private ArrayList<Cursor> getSpecialFoldersCursorList(final boolean addDesktopFolder, + final boolean addScreenshotsFolder, final boolean addReadingListFolder) { + if (addDesktopFolder || addScreenshotsFolder || addReadingListFolder) { + // Avoid calling this twice. + assertDefaultBookmarkColumnOrdering(); + } + + // Capacity is number of cursors added below plus one for non-special data. + final ArrayList<Cursor> out = new ArrayList<>(4); + if (addDesktopFolder) { + out.add(getSpecialFolderCursor(Bookmarks.FAKE_DESKTOP_FOLDER_ID, Bookmarks.FAKE_DESKTOP_FOLDER_GUID)); + } + + if (addScreenshotsFolder) { + out.add(getSpecialFolderCursor(Bookmarks.FIXED_SCREENSHOT_FOLDER_ID, Bookmarks.SCREENSHOT_FOLDER_GUID)); + } + + if (addReadingListFolder) { + out.add(getSpecialFolderCursor(Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID, Bookmarks.FAKE_READINGLIST_SMARTFOLDER_GUID)); + } + + return out; + } + + @CheckResult + private MatrixCursor getSpecialFolderCursor(final int folderId, final String folderGuid) { + final MatrixCursor out = new MatrixCursor(DEFAULT_BOOKMARK_COLUMNS); + out.addRow(new Object[] { + folderId, + folderGuid, + "", + "", // Title localisation is done later, in the UI layer (BookmarksListAdapter) + Bookmarks.TYPE_FOLDER, + Bookmarks.FIXED_ROOT_ID + }); + return out; + } + + // Returns true if any desktop bookmarks exist, which will be true if the user + // has set up sync at one point, or done a profile migration from XUL fennec. + private boolean desktopBookmarksExist(ContentResolver cr) { + if (mDesktopBookmarksExist != null) { + return mDesktopBookmarksExist; + } + + // Check to see if there are any bookmarks in one of our three + // fixed "Desktop Bookmarks" folders. + final Cursor c = cr.query(bookmarksUriWithLimit(1), + new String[] { Bookmarks._ID }, + Bookmarks.PARENT + " = ? OR " + + Bookmarks.PARENT + " = ? OR " + + Bookmarks.PARENT + " = ?", + new String[] { String.valueOf(getFolderIdFromGuid(cr, Bookmarks.TOOLBAR_FOLDER_GUID)), + String.valueOf(getFolderIdFromGuid(cr, Bookmarks.MENU_FOLDER_GUID)), + String.valueOf(getFolderIdFromGuid(cr, Bookmarks.UNFILED_FOLDER_GUID)) }, + null); + + try { + // Don't read back out of the cache to avoid races with invalidation. + final boolean e = c.getCount() > 0; + mDesktopBookmarksExist = e; + return e; + } finally { + c.close(); + } + } + + @Override + @RobocopTarget + public boolean isBookmark(ContentResolver cr, String uri) { + final Cursor c = cr.query(bookmarksUriWithLimit(1), + new String[] { Bookmarks._ID }, + Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ?", + new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) }, + Bookmarks.URL); + + if (c == null) { + Log.e(LOGTAG, "Null cursor in isBookmark"); + return false; + } + + try { + return c.getCount() > 0; + } finally { + c.close(); + } + } + + @Override + public String getUrlForKeyword(ContentResolver cr, String keyword) { + final Cursor c = cr.query(mBookmarksUriWithProfile, + new String[] { Bookmarks.URL }, + Bookmarks.KEYWORD + " = ?", + new String[] { keyword }, + null); + try { + if (!c.moveToFirst()) { + return null; + } + + return c.getString(c.getColumnIndexOrThrow(Bookmarks.URL)); + } finally { + c.close(); + } + } + + private synchronized long getFolderIdFromGuid(final ContentResolver cr, final String guid) { + if (mFolderIdMap.containsKey(guid)) { + return mFolderIdMap.get(guid); + } + + final Cursor c = cr.query(mBookmarksUriWithProfile, + new String[] { Bookmarks._ID }, + Bookmarks.GUID + " = ?", + new String[] { guid }, + null); + try { + final int col = c.getColumnIndexOrThrow(Bookmarks._ID); + if (!c.moveToFirst() || c.isNull(col)) { + return FOLDER_NOT_FOUND; + } + + final long id = c.getLong(col); + mFolderIdMap.put(guid, id); + return id; + } finally { + c.close(); + } + } + + /** + * Find parents of records that match the provided criteria, and bump their + * modified timestamp. + */ + protected void bumpParents(ContentResolver cr, String param, String value) { + ContentValues values = new ContentValues(); + values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis()); + + String where = param + " = ?"; + String[] args = new String[] { value }; + int updated = cr.update(mParentsUriWithProfile, values, where, args); + debug("Updated " + updated + " rows to new modified time."); + } + + private void addBookmarkItem(ContentResolver cr, String title, String uri, long folderId) { + final long now = System.currentTimeMillis(); + ContentValues values = new ContentValues(); + if (title != null) { + values.put(Bookmarks.TITLE, title); + } + + values.put(Bookmarks.URL, uri); + values.put(Bookmarks.PARENT, folderId); + values.put(Bookmarks.DATE_MODIFIED, now); + + // Get the page's favicon ID from the history table + final Cursor c = cr.query(mHistoryUriWithProfile, + new String[] { History.FAVICON_ID }, + History.URL + " = ?", + new String[] { uri }, + null); + try { + if (c.moveToFirst()) { + int columnIndex = c.getColumnIndexOrThrow(History.FAVICON_ID); + if (!c.isNull(columnIndex)) { + values.put(Bookmarks.FAVICON_ID, c.getLong(columnIndex)); + } + } + } finally { + c.close(); + } + + // Restore deleted record if possible + values.put(Bookmarks.IS_DELETED, 0); + + final Uri bookmarksWithInsert = mBookmarksUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true") + .build(); + cr.update(bookmarksWithInsert, + values, + Bookmarks.URL + " = ? AND " + + Bookmarks.PARENT + " = " + folderId, + new String[] { uri }); + + // Bump parent modified time using its ID. + debug("Bumping parent modified time for addition to: " + folderId); + final String where = Bookmarks._ID + " = ?"; + final String[] args = new String[] { String.valueOf(folderId) }; + + ContentValues bumped = new ContentValues(); + bumped.put(Bookmarks.DATE_MODIFIED, now); + + final int updated = cr.update(mBookmarksUriWithProfile, bumped, where, args); + debug("Updated " + updated + " rows to new modified time."); + } + + @Override + @RobocopTarget + public boolean addBookmark(ContentResolver cr, String title, String uri) { + long folderId = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID); + if (isBookmarkForUrlInFolder(cr, uri, folderId)) { + // Bookmark added already. + return false; + } + + // Add a new bookmark. + addBookmarkItem(cr, title, uri, folderId); + return true; + } + + private boolean isBookmarkForUrlInFolder(ContentResolver cr, String uri, long folderId) { + final Cursor c = cr.query(bookmarksUriWithLimit(1), + new String[] { Bookmarks._ID }, + Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " == 0", + new String[] { uri, String.valueOf(folderId) }, + Bookmarks.URL); + + if (c == null) { + return false; + } + + try { + return c.getCount() > 0; + } finally { + c.close(); + } + } + + @Override + @RobocopTarget + public void removeBookmarksWithURL(ContentResolver cr, String uri) { + Uri contentUri = mBookmarksUriWithProfile; + + // Do this now so that the items still exist! + bumpParents(cr, Bookmarks.URL, uri); + + final String[] urlArgs = new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) }; + final String urlEquals = Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ? "; + + cr.delete(contentUri, urlEquals, urlArgs); + } + + @Override + public void registerBookmarkObserver(ContentResolver cr, ContentObserver observer) { + cr.registerContentObserver(mBookmarksUriWithProfile, false, observer); + } + + @Override + @RobocopTarget + public void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword) { + ContentValues values = new ContentValues(); + values.put(Bookmarks.TITLE, title); + values.put(Bookmarks.URL, uri); + values.put(Bookmarks.KEYWORD, keyword); + values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis()); + + cr.update(mBookmarksUriWithProfile, + values, + Bookmarks._ID + " = ?", + new String[] { String.valueOf(id) }); + } + + @Override + public boolean hasBookmarkWithGuid(ContentResolver cr, String guid) { + Cursor c = cr.query(bookmarksUriWithLimit(1), + new String[] { Bookmarks.GUID }, + Bookmarks.GUID + " = ?", + new String[] { guid }, + null); + + try { + return c != null && c.getCount() > 0; + } finally { + if (c != null) { + c.close(); + } + } + } + + /** + * Get the favicon from the database, if any, associated with the given favicon URL. (That is, + * the URL of the actual favicon image, not the URL of the page with which the favicon is associated.) + * @param cr The ContentResolver to use. + * @param faviconURL The URL of the favicon to fetch from the database. + * @return The decoded Bitmap from the database, if any. null if none is stored. + */ + @Override + public LoadFaviconResult getFaviconForUrl(Context context, ContentResolver cr, String faviconURL) { + final Cursor c = cr.query(mFaviconsUriWithProfile, + new String[] { Favicons.DATA }, + Favicons.URL + " = ? AND " + Favicons.DATA + " IS NOT NULL", + new String[] { faviconURL }, + null); + + boolean shouldDelete = false; + byte[] b = null; + try { + if (!c.moveToFirst()) { + return null; + } + + final int faviconIndex = c.getColumnIndexOrThrow(Favicons.DATA); + try { + b = c.getBlob(faviconIndex); + } catch (IllegalStateException e) { + // This happens when the blob is more than 1MB: Bug 1106347. + // Delete that row. + shouldDelete = true; + } + } finally { + c.close(); + } + + if (shouldDelete) { + try { + Log.d(LOGTAG, "Deleting invalid favicon."); + cr.delete(mFaviconsUriWithProfile, + Favicons.URL + " = ?", + new String[] { faviconURL }); + } catch (Exception e) { + // Do nothing. + } + } + + if (b == null) { + return null; + } + + return FaviconDecoder.decodeFavicon(context, b); + } + + /** + * Try to find a usable favicon URL in the history or bookmarks table. + */ + @Override + public String getFaviconURLFromPageURL(ContentResolver cr, String uri) { + // Check first in the history table. + Cursor c = cr.query(mHistoryUriWithProfile, + new String[] { History.FAVICON_URL }, + Combined.URL + " = ?", + new String[] { uri }, + null); + + try { + if (c.moveToFirst()) { + // Interrupted page loads can leave History items without a valid favicon_id. + final int columnIndex = c.getColumnIndexOrThrow(History.FAVICON_URL); + if (!c.isNull(columnIndex)) { + final String faviconURL = c.getString(columnIndex); + if (faviconURL != null) { + return faviconURL; + } + } + } + } finally { + c.close(); + } + + // If that fails, check in the bookmarks table. + c = cr.query(mBookmarksUriWithProfile, + new String[] { Bookmarks.FAVICON_URL }, + Bookmarks.URL + " = ?", + new String[] { uri }, + null); + + try { + if (c.moveToFirst()) { + return c.getString(c.getColumnIndexOrThrow(Bookmarks.FAVICON_URL)); + } + + return null; + } finally { + c.close(); + } + } + + @Override + public boolean hideSuggestedSite(String url) { + if (mSuggestedSites == null) { + return false; + } + + return mSuggestedSites.hideSite(url); + } + + @Override + public void updateThumbnailForUrl(ContentResolver cr, String uri, + BitmapDrawable thumbnail) { + // If a null thumbnail was passed in, delete the stored thumbnail for this url. + if (thumbnail == null) { + cr.delete(mThumbnailsUriWithProfile, Thumbnails.URL + " == ?", new String[] { uri }); + return; + } + + Bitmap bitmap = thumbnail.getBitmap(); + + byte[] data = null; + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)) { + data = stream.toByteArray(); + } else { + Log.w(LOGTAG, "Favicon compression failed."); + } + + ContentValues values = new ContentValues(); + values.put(Thumbnails.URL, uri); + values.put(Thumbnails.DATA, data); + + Uri thumbnailsUri = mThumbnailsUriWithProfile.buildUpon(). + appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(); + cr.update(thumbnailsUri, + values, + Thumbnails.URL + " = ?", + new String[] { uri }); + } + + @Override + @RobocopTarget + public byte[] getThumbnailForUrl(ContentResolver cr, String uri) { + final Cursor c = cr.query(mThumbnailsUriWithProfile, + new String[]{ Thumbnails.DATA }, + Thumbnails.URL + " = ? AND " + Thumbnails.DATA + " IS NOT NULL", + new String[]{ uri }, + null); + try { + if (!c.moveToFirst()) { + return null; + } + + int thumbnailIndex = c.getColumnIndexOrThrow(Thumbnails.DATA); + + return c.getBlob(thumbnailIndex); + } finally { + c.close(); + } + + } + + /** + * Query for non-null thumbnails matching the provided <code>urls</code>. + * The returned cursor will have no more than, but possibly fewer than, + * the requested number of thumbnails. + * + * Returns null if the provided list of URLs is empty or null. + */ + @Override + public Cursor getThumbnailsForUrls(ContentResolver cr, List<String> urls) { + final int urlCount = urls.size(); + if (urlCount == 0) { + return null; + } + + // Don't match against null thumbnails. + final String selection = Thumbnails.DATA + " IS NOT NULL AND " + + DBUtils.computeSQLInClause(urlCount, Thumbnails.URL); + final String[] selectionArgs = urls.toArray(new String[urlCount]); + + return cr.query(mThumbnailsUriWithProfile, + new String[] { Thumbnails.URL, Thumbnails.DATA }, + selection, + selectionArgs, + null); + } + + @Override + @RobocopTarget + public void removeThumbnails(ContentResolver cr) { + cr.delete(mThumbnailsUriWithProfile, null, null); + } + + /** + * Utility method used by AndroidImport for updating existing history record using batch operations. + * + * @param cr <code>ContentResolver</code> used for querying information about existing history records. + * @param operations Collection of operations for queueing record updates. + * @param url URL used for querying history records to update. + * @param title Optional new title. + * @param date New last visited date. Will be used if newer than current last visited date. + * @param visits Will increment existing visit counts by this number. + */ + @Override + public void updateHistoryInBatch(@NonNull ContentResolver cr, + @NonNull Collection<ContentProviderOperation> operations, + @NonNull String url, @Nullable String title, + long date, int visits) { + final String[] projection = { + History._ID, + History.VISITS, + History.LOCAL_VISITS, + History.DATE_LAST_VISITED, + History.LOCAL_DATE_LAST_VISITED + }; + + // We need to get the old visit and date aggregates. + final Cursor cursor = cr.query(withDeleted(mHistoryUriWithProfile), + projection, + History.URL + " = ?", + new String[] { url }, + null); + if (cursor == null) { + Log.w(LOGTAG, "Null cursor while querying for old visit and date aggregates"); + return; + } + + try { + final ContentValues values = new ContentValues(); + + // Restore deleted record if possible + values.put(History.IS_DELETED, 0); + + if (cursor.moveToFirst()) { + final int visitsCol = cursor.getColumnIndexOrThrow(History.VISITS); + final int localVisitsCol = cursor.getColumnIndexOrThrow(History.LOCAL_VISITS); + final int dateCol = cursor.getColumnIndexOrThrow(History.DATE_LAST_VISITED); + final int localDateCol = cursor.getColumnIndexOrThrow(History.LOCAL_DATE_LAST_VISITED); + + final int oldVisits = cursor.getInt(visitsCol); + final int oldLocalVisits = cursor.getInt(localVisitsCol); + final long oldDate = cursor.getLong(dateCol); + final long oldLocalDate = cursor.getLong(localDateCol); + + // NB: This will increment visit counts even if subsequent "insert visits" operations + // insert no new visits (see insertVisitsFromImportHistoryInBatch). + // So, we're doing a wrong thing here if user imports history more than once. + // See Bug 1277330. + values.put(History.VISITS, oldVisits + visits); + values.put(History.LOCAL_VISITS, oldLocalVisits + visits); + // Only update last visited if newer. + if (date > oldDate) { + values.put(History.DATE_LAST_VISITED, date); + } + if (date > oldLocalDate) { + values.put(History.LOCAL_DATE_LAST_VISITED, date); + } + } else { + values.put(History.VISITS, visits); + values.put(History.LOCAL_VISITS, visits); + values.put(History.DATE_LAST_VISITED, date); + values.put(History.LOCAL_DATE_LAST_VISITED, date); + } + if (title != null) { + values.put(History.TITLE, title); + } + values.put(History.URL, url); + + final Uri historyUri = withDeleted(mHistoryUriWithProfile).buildUpon(). + appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(); + + // Update or insert + final ContentProviderOperation.Builder builder = + ContentProviderOperation.newUpdate(historyUri); + builder.withSelection(History.URL + " = ?", new String[] { url }); + builder.withValues(values); + + // Queue the operation + operations.add(builder.build()); + } finally { + cursor.close(); + } + } + + /** + * Utility method used by AndroidImport to insert visit data for history records that were just imported. + * Uses batch operations. + * + * @param cr <code>ContentResolver</code> used to query history table and bulkInsert visit records + * @param operations Collection of operations for queueing inserts + * @param visitsToSynthesize List of ContentValues describing visit information for each history record: + * (History URL, LAST DATE VISITED, VISIT COUNT) + */ + public void insertVisitsFromImportHistoryInBatch(ContentResolver cr, + Collection<ContentProviderOperation> operations, + ArrayList<ContentValues> visitsToSynthesize) { + // If for any reason we fail to obtain history GUID for a tuple we're processing, + // let's just ignore it. It's possible that the "best-effort" history import + // did not fully succeed, so we could be missing some of the records. + int historyGUIDCol = -1; + for (ContentValues visitsInformation : visitsToSynthesize) { + final Cursor cursor = cr.query(mHistoryUriWithProfile, + new String[] {History.GUID}, + History.URL + " = ?", + new String[] {visitsInformation.getAsString(HISTORY_VISITS_URL)}, + null); + if (cursor == null) { + continue; + } + + final String historyGUID; + + try { + if (!cursor.moveToFirst()) { + continue; + } + if (historyGUIDCol == -1) { + historyGUIDCol = cursor.getColumnIndexOrThrow(History.GUID); + } + + historyGUID = cursor.getString(historyGUIDCol); + } finally { + // We "continue" on a null cursor above, so it's safe to act upon it without checking. + cursor.close(); + } + if (historyGUID == null) { + continue; + } + + // This fakes the individual visit records, using last visited date as the starting point. + for (int i = 0; i < visitsInformation.getAsInteger(HISTORY_VISITS_COUNT); i++) { + // We rely on database defaults for IS_LOCAL and VISIT_TYPE. + final ContentValues visitToInsert = new ContentValues(); + visitToInsert.put(BrowserContract.Visits.HISTORY_GUID, historyGUID); + + // Visit timestamps are stored in microseconds, while Android Browser visit timestmaps + // are in milliseconds. This is the conversion point for imports. + visitToInsert.put(BrowserContract.Visits.DATE_VISITED, + (visitsInformation.getAsLong(HISTORY_VISITS_DATE) - i) * 1000); + + final ContentProviderOperation.Builder builder = + ContentProviderOperation.newInsert(BrowserContract.Visits.CONTENT_URI); + builder.withValues(visitToInsert); + + // Queue the insert operation + operations.add(builder.build()); + } + } + } + + @Override + public void updateBookmarkInBatch(ContentResolver cr, + Collection<ContentProviderOperation> operations, + String url, String title, String guid, + long parent, long added, + long modified, long position, + String keyword, int type) { + ContentValues values = new ContentValues(); + if (title == null && url != null) { + title = url; + } + if (title != null) { + values.put(Bookmarks.TITLE, title); + } + if (url != null) { + values.put(Bookmarks.URL, url); + } + if (guid != null) { + values.put(SyncColumns.GUID, guid); + } + if (keyword != null) { + values.put(Bookmarks.KEYWORD, keyword); + } + if (added > 0) { + values.put(SyncColumns.DATE_CREATED, added); + } + if (modified > 0) { + values.put(SyncColumns.DATE_MODIFIED, modified); + } + values.put(Bookmarks.POSITION, position); + // Restore deleted record if possible + values.put(Bookmarks.IS_DELETED, 0); + + // This assumes no "real" folder has a negative ID. Only + // things like the reading list folder do. + if (parent < 0) { + parent = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID); + } + values.put(Bookmarks.PARENT, parent); + values.put(Bookmarks.TYPE, type); + + Uri bookmarkUri = withDeleted(mBookmarksUriWithProfile).buildUpon(). + appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(); + // Update or insert + ContentProviderOperation.Builder builder = + ContentProviderOperation.newUpdate(bookmarkUri); + if (url != null) { + // Bookmarks are defined by their URL and Folder. + builder.withSelection(Bookmarks.URL + " = ? AND " + + Bookmarks.PARENT + " = ?", + new String[] { url, + Long.toString(parent) + }); + } else if (title != null) { + // Or their title and parent folder. (Folders!) + builder.withSelection(Bookmarks.TITLE + " = ? AND " + + Bookmarks.PARENT + " = ?", + new String[]{ title, + Long.toString(parent) + }); + } else if (type == Bookmarks.TYPE_SEPARATOR) { + // Or their their position (separators) + builder.withSelection(Bookmarks.POSITION + " = ? AND " + + Bookmarks.PARENT + " = ?", + new String[] { Long.toString(position), + Long.toString(parent) + }); + } else { + Log.e(LOGTAG, "Bookmark entry without url or title and not a separator, not added."); + } + builder.withValues(values); + + // Queue the operation + operations.add(builder.build()); + } + + @Override + public void pinSite(ContentResolver cr, String url, String title, int position) { + ContentValues values = new ContentValues(); + final long now = System.currentTimeMillis(); + values.put(Bookmarks.TITLE, title); + values.put(Bookmarks.URL, url); + values.put(Bookmarks.PARENT, Bookmarks.FIXED_PINNED_LIST_ID); + values.put(Bookmarks.DATE_MODIFIED, now); + values.put(Bookmarks.POSITION, position); + values.put(Bookmarks.IS_DELETED, 0); + + // We do an update-and-replace here without deleting any existing pins for the given URL. + // That means if the user pins a URL, then edits another thumbnail to use the same URL, + // we'll end up with two pins for that site. This is the intended behavior, which + // incidentally saves us a delete query. + Uri uri = mBookmarksUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build(); + cr.update(uri, + values, + Bookmarks.POSITION + " = ? AND " + + Bookmarks.PARENT + " = ?", + new String[] { Integer.toString(position), + String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) }); + } + + @Override + public void unpinSite(ContentResolver cr, int position) { + cr.delete(mBookmarksUriWithProfile, + Bookmarks.PARENT + " == ? AND " + Bookmarks.POSITION + " = ?", + new String[] { + String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID), + Integer.toString(position) + }); + } + + @Override + @RobocopTarget + public Cursor getBookmarkForUrl(ContentResolver cr, String url) { + Cursor c = cr.query(bookmarksUriWithLimit(1), + new String[] { Bookmarks._ID, + Bookmarks.URL, + Bookmarks.TITLE, + Bookmarks.KEYWORD }, + Bookmarks.URL + " = ?", + new String[] { url }, + null); + + if (c != null && c.getCount() == 0) { + c.close(); + c = null; + } + + return c; + } + + @Override + public Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl) { + Cursor c = cr.query(mBookmarksUriWithProfile, + new String[] { Bookmarks.GUID, Bookmarks._ID, Bookmarks.URL }, + Bookmarks.URL + " LIKE '%" + partialUrl + "%'", // TODO: Escaping! + null, + null); + + if (c != null && c.getCount() == 0) { + c.close(); + c = null; + } + + return c; + } + + @Override + public void setSuggestedSites(SuggestedSites suggestedSites) { + mSuggestedSites = suggestedSites; + } + + @Override + public SuggestedSites getSuggestedSites() { + return mSuggestedSites; + } + + @Override + public boolean hasSuggestedImageUrl(String url) { + if (mSuggestedSites == null) { + return false; + } + return mSuggestedSites.contains(url); + } + + @Override + public String getSuggestedImageUrlForUrl(String url) { + if (mSuggestedSites == null) { + return null; + } + return mSuggestedSites.getImageUrlForUrl(url); + } + + @Override + public int getSuggestedBackgroundColorForUrl(String url) { + if (mSuggestedSites == null) { + return 0; + } + final String bgColor = mSuggestedSites.getBackgroundColorForUrl(url); + if (bgColor != null) { + return Color.parseColor(bgColor); + } + + return 0; + } + + private static void appendUrlsFromCursor(List<String> urls, Cursor c) { + if (!c.moveToFirst()) { + return; + } + + do { + String url = c.getString(c.getColumnIndex(History.URL)); + + // Do a simpler check before decoding to avoid parsing + // all URLs unnecessarily. + if (StringUtils.isUserEnteredUrl(url)) { + url = StringUtils.decodeUserEnteredUrl(url); + } + + urls.add(url); + } while (c.moveToNext()); + } + + + /** + * Internal CursorLoader that extends the framework CursorLoader in order to measure + * performance for telemetry purposes. + */ + private static final class TelemetrisedCursorLoader extends CursorLoader { + final String mHistogramName; + + public TelemetrisedCursorLoader(Context context, Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder, + final String histogramName) { + super(context, uri, projection, selection, selectionArgs, sortOrder); + mHistogramName = histogramName; + } + + @Override + public Cursor loadInBackground() { + final long start = SystemClock.uptimeMillis(); + + final Cursor cursor = super.loadInBackground(); + + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + + Telemetry.addToHistogram(mHistogramName, (int) Math.min(took, Integer.MAX_VALUE)); + return cursor; + } + } + + public CursorLoader getActivityStreamTopSites(Context context, int limit) { + final Uri uri = mTopSitesUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, + String.valueOf(limit)) + .appendQueryParameter(BrowserContract.PARAM_TOPSITES_DISABLE_PINNED, Boolean.TRUE.toString()) + .build(); + + return new TelemetrisedCursorLoader(context, + uri, + new String[]{ Combined._ID, + Combined.URL, + Combined.TITLE, + Combined.BOOKMARK_ID, + Combined.HISTORY_ID }, + null, + null, + null, + TELEMETRY_HISTOGRAM_ACITIVITY_STREAM_TOPSITES); + } + + @Override + public Cursor getTopSites(ContentResolver cr, int suggestedRangeLimit, int limit) { + final Uri uri = mTopSitesUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, + String.valueOf(limit)) + .appendQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT, + String.valueOf(suggestedRangeLimit)) + .build(); + + Cursor topSitesCursor = cr.query(uri, + new String[] { Combined._ID, + Combined.URL, + Combined.TITLE, + Combined.BOOKMARK_ID, + Combined.HISTORY_ID }, + null, + null, + null); + + // It's possible that we will retrieve fewer sites than are required to fill the top-sites panel - in this case + // we need to add "blank" tiles. It's much easier to add these here (as opposed to SQL), since we don't care + // about their ordering (they go after all the other sites), but we do care about their number (and calculating + // that inside out topsites SQL query would be difficult given the other processing we're already doing there). + final int blanksRequired = suggestedRangeLimit - topSitesCursor.getCount(); + + if (blanksRequired <= 0) { + return topSitesCursor; + } + + MatrixCursor blanksCursor = new MatrixCursor(new String[] { + TopSites._ID, + TopSites.BOOKMARK_ID, + TopSites.HISTORY_ID, + TopSites.URL, + TopSites.TITLE, + TopSites.TYPE}); + + final MatrixCursor.RowBuilder rb = blanksCursor.newRow(); + rb.add(-1); + rb.add(-1); + rb.add(-1); + rb.add(""); + rb.add(""); + rb.add(TopSites.TYPE_BLANK); + + return new MergeCursor(new Cursor[] {topSitesCursor, blanksCursor}); + } + + @Override + public CursorLoader getHighlights(Context context, int limit) { + final Uri uri = mHighlightsUriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(limit)) + .build(); + + return new CursorLoader(context, uri, null, null, null, null); + } + + @Override + public void blockActivityStreamSite(ContentResolver cr, String url) { + final ContentValues values = new ContentValues(); + values.put(ActivityStreamBlocklist.URL, url); + cr.insert(mActivityStreamBlockedUriWithProfile, values); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java new file mode 100644 index 000000000..a9a55e51d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java @@ -0,0 +1,28 @@ +/* -*- 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.ContentResolver; +import android.content.ContentValues; +import android.net.Uri; + +/** + * Helper class for dealing with the search provider inside Fennec. + */ +public class LocalSearches implements Searches { + private final Uri uriWithProfile; + + public LocalSearches(String mProfile) { + uriWithProfile = DBUtils.appendProfileWithDefault(mProfile, BrowserContract.SearchHistory.CONTENT_URI); + } + + @Override + public void insert(ContentResolver cr, String query) { + final ContentValues values = new ContentValues(); + values.put(BrowserContract.SearchHistory.QUERY, query); + cr.insert(uriWithProfile, values); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java new file mode 100644 index 000000000..c7bd9475c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java @@ -0,0 +1,320 @@ +/* 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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONException; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +public class LocalTabsAccessor implements TabsAccessor { + private static final String LOGTAG = "GeckoTabsAccessor"; + private static final long THREE_WEEKS_IN_MILLISECONDS = TimeUnit.MILLISECONDS.convert(21L, TimeUnit.DAYS); + + public static final String[] TABS_PROJECTION_COLUMNS = new String[] { + BrowserContract.Tabs.TITLE, + BrowserContract.Tabs.URL, + BrowserContract.Clients.GUID, + BrowserContract.Clients.NAME, + BrowserContract.Tabs.LAST_USED, + BrowserContract.Clients.LAST_MODIFIED, + BrowserContract.Clients.DEVICE_TYPE, + }; + + public static final String[] CLIENTS_PROJECTION_COLUMNS = new String[] { + BrowserContract.Clients.GUID, + BrowserContract.Clients.NAME, + BrowserContract.Clients.LAST_MODIFIED, + BrowserContract.Clients.DEVICE_TYPE + }; + + private static final String REMOTE_CLIENTS_SELECTION = BrowserContract.Clients.GUID + " IS NOT NULL"; + private static final String LOCAL_TABS_SELECTION = BrowserContract.Tabs.CLIENT_GUID + " IS NULL"; + private static final String REMOTE_TABS_SELECTION = BrowserContract.Tabs.CLIENT_GUID + " IS NOT NULL"; + private static final String REMOTE_TABS_SELECTION_CLIENT_RECENCY = REMOTE_TABS_SELECTION + + " AND " + BrowserContract.Clients.LAST_MODIFIED + " > ?"; + + private static final String REMOTE_TABS_SORT_ORDER = + // Most recently synced clients first. + BrowserContract.Clients.LAST_MODIFIED + " DESC, " + + // If two clients somehow had the same last modified time, this will + // group them (arbitrarily). + BrowserContract.Clients.GUID + " DESC, " + + // Within a single client, most recently used tabs first. + BrowserContract.Tabs.LAST_USED + " DESC"; + + private static final String LOCAL_CLIENT_SELECTION = BrowserContract.Clients.GUID + " IS NULL"; + + private static final Pattern FILTERED_URL_PATTERN = Pattern.compile("^(about|chrome|wyciwyg|file):"); + + private final Uri clientsRecencyUriWithProfile; + private final Uri tabsUriWithProfile; + private final Uri clientsUriWithProfile; + + public LocalTabsAccessor(String profileName) { + tabsUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Tabs.CONTENT_URI); + clientsUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Clients.CONTENT_URI); + clientsRecencyUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Clients.CONTENT_RECENCY_URI); + } + + /** + * Extracts a List of just RemoteClients from a cursor. + * The supplied cursor should be grouped by guid and sorted by most recently used. + */ + @Override + public List<RemoteClient> getClientsWithoutTabsByRecencyFromCursor(Cursor cursor) { + final ArrayList<RemoteClient> clients = new ArrayList<>(cursor.getCount()); + + final int originalPosition = cursor.getPosition(); + try { + if (!cursor.moveToFirst()) { + return clients; + } + + final int clientGuidIndex = cursor.getColumnIndex(BrowserContract.Clients.GUID); + final int clientNameIndex = cursor.getColumnIndex(BrowserContract.Clients.NAME); + final int clientLastModifiedIndex = cursor.getColumnIndex(BrowserContract.Clients.LAST_MODIFIED); + final int clientDeviceTypeIndex = cursor.getColumnIndex(BrowserContract.Clients.DEVICE_TYPE); + + while (!cursor.isAfterLast()) { + final String clientGuid = cursor.getString(clientGuidIndex); + final String clientName = cursor.getString(clientNameIndex); + final String deviceType = cursor.getString(clientDeviceTypeIndex); + final long lastModified = cursor.getLong(clientLastModifiedIndex); + + clients.add(new RemoteClient(clientGuid, clientName, lastModified, deviceType)); + + cursor.moveToNext(); + } + } finally { + cursor.moveToPosition(originalPosition); + } + return clients; + } + + /** + * Extract client and tab records from a cursor. + * <p> + * The position of the cursor is moved to before the first record before + * reading. The cursor is advanced until there are no more records to be + * read. The position of the cursor is restored before returning. + * + * @param cursor + * to extract records from. The records should already be grouped + * by client GUID. + * @return list of clients, each containing list of tabs. + */ + @Override + public List<RemoteClient> getClientsFromCursor(final Cursor cursor) { + final ArrayList<RemoteClient> clients = new ArrayList<RemoteClient>(); + + final int originalPosition = cursor.getPosition(); + try { + if (!cursor.moveToFirst()) { + return clients; + } + + final int tabTitleIndex = cursor.getColumnIndex(BrowserContract.Tabs.TITLE); + final int tabUrlIndex = cursor.getColumnIndex(BrowserContract.Tabs.URL); + final int tabLastUsedIndex = cursor.getColumnIndex(BrowserContract.Tabs.LAST_USED); + final int clientGuidIndex = cursor.getColumnIndex(BrowserContract.Clients.GUID); + final int clientNameIndex = cursor.getColumnIndex(BrowserContract.Clients.NAME); + final int clientLastModifiedIndex = cursor.getColumnIndex(BrowserContract.Clients.LAST_MODIFIED); + final int clientDeviceTypeIndex = cursor.getColumnIndex(BrowserContract.Clients.DEVICE_TYPE); + + // A walking partition, chunking by client GUID. We assume the + // cursor records are already grouped by client GUID; see the query + // sort order. + RemoteClient lastClient = null; + while (!cursor.isAfterLast()) { + final String clientGuid = cursor.getString(clientGuidIndex); + if (lastClient == null || !TextUtils.equals(lastClient.guid, clientGuid)) { + final String clientName = cursor.getString(clientNameIndex); + final long lastModified = cursor.getLong(clientLastModifiedIndex); + final String deviceType = cursor.getString(clientDeviceTypeIndex); + lastClient = new RemoteClient(clientGuid, clientName, lastModified, deviceType); + clients.add(lastClient); + } + + final String tabTitle = cursor.getString(tabTitleIndex); + final String tabUrl = cursor.getString(tabUrlIndex); + final long tabLastUsed = cursor.getLong(tabLastUsedIndex); + lastClient.tabs.add(new RemoteTab(tabTitle, tabUrl, tabLastUsed)); + + cursor.moveToNext(); + } + } finally { + cursor.moveToPosition(originalPosition); + } + + return clients; + } + + @Override + public Cursor getRemoteClientsByRecencyCursor(Context context) { + final Uri uri = clientsRecencyUriWithProfile; + return context.getContentResolver().query(uri, CLIENTS_PROJECTION_COLUMNS, + REMOTE_CLIENTS_SELECTION, null, null); + } + + @Override + public Cursor getRemoteTabsCursor(Context context) { + return getRemoteTabsCursor(context, -1); + } + + @Override + public Cursor getRemoteTabsCursor(Context context, int limit) { + Uri uri = tabsUriWithProfile; + + if (limit > 0) { + uri = uri.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(limit)) + .build(); + } + + final String threeWeeksAgoTimestampMillis = Long.valueOf( + System.currentTimeMillis() - THREE_WEEKS_IN_MILLISECONDS).toString(); + return context.getContentResolver().query(uri, + TABS_PROJECTION_COLUMNS, + REMOTE_TABS_SELECTION_CLIENT_RECENCY, + new String[] {threeWeeksAgoTimestampMillis}, + REMOTE_TABS_SORT_ORDER); + } + + // This method returns all tabs from all remote clients, + // ordered by most recent client first, most recent tab first + @Override + public void getTabs(final Context context, final OnQueryTabsCompleteListener listener) { + getTabs(context, 0, listener); + } + + // This method returns limited number of tabs from all remote clients, + // ordered by most recent client first, most recent tab first + @Override + public void getTabs(final Context context, final int limit, final OnQueryTabsCompleteListener listener) { + // If there is no listener, no point in doing work. + if (listener == null) + return; + + (new UIAsyncTask.WithoutParams<List<RemoteClient>>(ThreadUtils.getBackgroundHandler()) { + @Override + protected List<RemoteClient> doInBackground() { + final Cursor cursor = getRemoteTabsCursor(context, limit); + if (cursor == null) + return null; + + try { + return Collections.unmodifiableList(getClientsFromCursor(cursor)); + } finally { + cursor.close(); + } + } + + @Override + protected void onPostExecute(List<RemoteClient> clients) { + listener.onQueryTabsComplete(clients); + } + }).execute(); + } + + // Updates the modified time of the local client with the current time. + private void updateLocalClient(final ContentResolver cr) { + ContentValues values = new ContentValues(); + values.put(BrowserContract.Clients.LAST_MODIFIED, System.currentTimeMillis()); + + cr.update(clientsUriWithProfile, values, LOCAL_CLIENT_SELECTION, null); + } + + // Deletes all local tabs. + private void deleteLocalTabs(final ContentResolver cr) { + cr.delete(tabsUriWithProfile, LOCAL_TABS_SELECTION, null); + } + + /** + * Tabs are positioned in the DB in the same order that they appear in the tabs param. + * - URL should never empty or null. Skip this tab if there's no URL. + * - TITLE should always a string, either a page title or empty. + * - LAST_USED should always be numeric. + * - FAVICON should be a URL or null. + * - HISTORY should be serialized JSON array of URLs. + * - POSITION should always be numeric. + * - CLIENT_GUID should always be null to represent the local client. + */ + private void insertLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs) { + // Reuse this for serializing individual history URLs as JSON. + JSONArray history = new JSONArray(); + ArrayList<ContentValues> valuesToInsert = new ArrayList<ContentValues>(); + + int position = 0; + for (Tab tab : tabs) { + // Skip this tab if it has a null URL or is in private browsing mode, or is a filtered URL. + String url = tab.getURL(); + if (url == null || tab.isPrivate() || isFilteredURL(url)) + continue; + + ContentValues values = new ContentValues(); + values.put(BrowserContract.Tabs.URL, url); + values.put(BrowserContract.Tabs.TITLE, tab.getTitle()); + values.put(BrowserContract.Tabs.LAST_USED, tab.getLastUsed()); + + String favicon = tab.getFaviconURL(); + if (favicon != null) + values.put(BrowserContract.Tabs.FAVICON, favicon); + else + values.putNull(BrowserContract.Tabs.FAVICON); + + // We don't have access to session history in Java, so for now, we'll + // just use a JSONArray that holds most recent history item. + try { + history.put(0, tab.getURL()); + values.put(BrowserContract.Tabs.HISTORY, history.toString()); + } catch (JSONException e) { + Log.w(LOGTAG, "JSONException adding URL to tab history array.", e); + } + + values.put(BrowserContract.Tabs.POSITION, position++); + + // A null client guid corresponds to the local client. + values.putNull(BrowserContract.Tabs.CLIENT_GUID); + + valuesToInsert.add(values); + } + + ContentValues[] valuesToInsertArray = valuesToInsert.toArray(new ContentValues[valuesToInsert.size()]); + cr.bulkInsert(tabsUriWithProfile, valuesToInsertArray); + } + + // Deletes all local tabs and replaces them with a new list of tabs. + @Override + public synchronized void persistLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs) { + deleteLocalTabs(cr); + insertLocalTabs(cr, tabs); + updateLocalClient(cr); + } + + /** + * Matches the supplied URL string against the set of URLs to filter. + * + * @return true if the supplied URL should be skipped; false otherwise. + */ + private boolean isFilteredURL(String url) { + return FILTERED_URL_PATTERN.matcher(url).lookingAt(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java new file mode 100644 index 000000000..7f2c4a736 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java @@ -0,0 +1,240 @@ +/* -*- 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 java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.util.Log; +import android.util.LruCache; + +// Holds metadata info about URLs. Supports some helper functions for getting back a HashMap of key value data. +public class LocalURLMetadata implements URLMetadata { + private static final String LOGTAG = "GeckoURLMetadata"; + private final Uri uriWithProfile; + + public LocalURLMetadata(String mProfile) { + uriWithProfile = DBUtils.appendProfileWithDefault(mProfile, URLMetadataTable.CONTENT_URI); + } + + // A list of columns in the table. It's used to simplify some loops for reading/writing data. + private static final Set<String> COLUMNS; + static { + final HashSet<String> tempModel = new HashSet<>(4); + tempModel.add(URLMetadataTable.URL_COLUMN); + tempModel.add(URLMetadataTable.TILE_IMAGE_URL_COLUMN); + tempModel.add(URLMetadataTable.TILE_COLOR_COLUMN); + tempModel.add(URLMetadataTable.TOUCH_ICON_COLUMN); + COLUMNS = Collections.unmodifiableSet(tempModel); + } + + // Store a cache of recent results. This number is chosen to match the max number of tiles on about:home + private static final int CACHE_SIZE = 9; + // Note: Members of this cache are unmodifiable. + private final LruCache<String, Map<String, Object>> cache = new LruCache<String, Map<String, Object>>(CACHE_SIZE); + + /** + * Converts a JSON object into a unmodifiable Map of known metadata properties. + * Will throw away any properties that aren't stored in the database. + * + * Incoming data can include a list like: {touchIconList:{56:"http://x.com/56.png", 76:"http://x.com/76.png"}}. + * This will then be filtered to find the most appropriate touchIcon, i.e. the closest icon size that is larger + * than (or equal to) the preferred homescreen launcher icon size, which is then stored in the "touchIcon" property. + */ + @Override + public Map<String, Object> fromJSON(JSONObject obj) { + Map<String, Object> data = new HashMap<String, Object>(); + + for (String key : COLUMNS) { + if (obj.has(key)) { + data.put(key, obj.optString(key)); + } + } + + + try { + JSONObject icons; + if (obj.has("touchIconList") && + (icons = obj.getJSONObject("touchIconList")).length() > 0) { + int preferredSize = GeckoAppShell.getPreferredIconSize(); + + Iterator<String> keys = icons.keys(); + + ArrayList<Integer> sizes = new ArrayList<Integer>(icons.length()); + while (keys.hasNext()) { + sizes.add(new Integer(keys.next())); + } + + final int bestSize = LoadFaviconResult.selectBestSizeFromList(sizes, preferredSize); + final String iconURL = icons.getString(Integer.toString(bestSize)); + + data.put(URLMetadataTable.TOUCH_ICON_COLUMN, iconURL); + } + } catch (JSONException e) { + Log.w(LOGTAG, "Exception processing touchIconList for LocalURLMetadata; ignoring.", e); + } + + return Collections.unmodifiableMap(data); + } + + /** + * Converts a Cursor into a unmodifiable Map of known metadata properties. + * Will throw away any properties that aren't stored in the database. + * Will also not iterate through multiple rows in the cursor. + */ + private Map<String, Object> fromCursor(Cursor c) { + Map<String, Object> data = new HashMap<String, Object>(); + + String[] columns = c.getColumnNames(); + for (String column : columns) { + if (COLUMNS.contains(column)) { + try { + data.put(column, c.getString(c.getColumnIndexOrThrow(column))); + } catch (Exception ex) { + Log.i(LOGTAG, "Error getting data for " + column, ex); + } + } + } + + return Collections.unmodifiableMap(data); + } + + /** + * Returns an unmodifiable Map of url->Metadata (i.e. A second HashMap) for a list of urls. + * Must not be called from UI or Gecko threads. + */ + @Override + public Map<String, Map<String, Object>> getForURLs(final ContentResolver cr, + final Collection<String> urls, + final List<String> requestedColumns) { + ThreadUtils.assertNotOnUiThread(); + ThreadUtils.assertNotOnGeckoThread(); + + final Map<String, Map<String, Object>> data = new HashMap<String, Map<String, Object>>(); + + // Nothing to query for + if (urls.isEmpty() || requestedColumns.isEmpty()) { + Log.e(LOGTAG, "Queried metadata for nothing"); + return data; + } + + // Search the cache for any of these urls + List<String> urlsToQuery = new ArrayList<String>(); + for (String url : urls) { + final Map<String, Object> hit = cache.get(url); + if (hit != null) { + // Cache hit: we've found the URL in the cache, however we may not have cached the desired columns + // for that URL. Hence we need to check whether our cache hit contains those columns, and directly + // retrieve the desired data if not. (E.g. the top sites panel retrieves the tile, and tilecolor. If + // we later try to retrieve the touchIcon for a top-site the cache hit will only point to + // tile+tilecolor, and not the required touchIcon. In this case we don't want to use the cache.) + boolean useCache = true; + for (String c: requestedColumns) { + if (!hit.containsKey(c)) { + useCache = false; + } + } + if (useCache) { + data.put(url, hit); + } else { + urlsToQuery.add(url); + } + } else { + urlsToQuery.add(url); + } + } + + // If everything was in the cache, we're done! + if (urlsToQuery.size() == 0) { + return Collections.unmodifiableMap(data); + } + + final String selection = DBUtils.computeSQLInClause(urlsToQuery.size(), URLMetadataTable.URL_COLUMN); + List<String> columns = requestedColumns; + // We need the url to build our final HashMap, so we force it to be included in the query. + if (!columns.contains(URLMetadataTable.URL_COLUMN)) { + // The requestedColumns may be immutable (e.g. if the caller used Collections.singletonList), hence + // we have to create a copy. + columns = new ArrayList<String>(columns); + columns.add(URLMetadataTable.URL_COLUMN); + } + + final Cursor cursor = cr.query(uriWithProfile, + columns.toArray(new String[columns.size()]), // columns, + selection, // selection + urlsToQuery.toArray(new String[urlsToQuery.size()]), // selectionargs + null); + try { + if (!cursor.moveToFirst()) { + return Collections.unmodifiableMap(data); + } + + do { + final Map<String, Object> metadata = fromCursor(cursor); + final String url = cursor.getString(cursor.getColumnIndexOrThrow(URLMetadataTable.URL_COLUMN)); + + data.put(url, metadata); + cache.put(url, metadata); + } while (cursor.moveToNext()); + + } finally { + cursor.close(); + } + + return Collections.unmodifiableMap(data); + } + + /** + * Saves a HashMap of metadata into the database. Will iterate through columns + * in the Database and only save rows with matching keys in the HashMap. + * Must not be called from UI or Gecko threads. + */ + @Override + public void save(final ContentResolver cr, final Map<String, Object> data) { + ThreadUtils.assertNotOnUiThread(); + ThreadUtils.assertNotOnGeckoThread(); + + try { + ContentValues values = new ContentValues(); + + for (String key : COLUMNS) { + if (data.containsKey(key)) { + values.put(key, (String) data.get(key)); + } + } + + if (values.size() == 0) { + return; + } + + Uri uri = uriWithProfile.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true") + .build(); + cr.update(uri, values, URLMetadataTable.URL_COLUMN + "=?", new String[] { + (String) data.get(URLMetadataTable.URL_COLUMN) + }); + } catch (Exception ex) { + Log.e(LOGTAG, "error saving", ex); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java new file mode 100644 index 000000000..9df41a169 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java @@ -0,0 +1,253 @@ +/* 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.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + +import org.json.JSONException; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserContract.UrlAnnotations.Key; +import org.mozilla.gecko.feeds.subscriptions.FeedSubscription; + +public class LocalUrlAnnotations implements UrlAnnotations { + private static final String LOGTAG = "LocalUrlAnnotations"; + + private Uri urlAnnotationsTableWithProfile; + + public LocalUrlAnnotations(final String profile) { + urlAnnotationsTableWithProfile = DBUtils.appendProfile(profile, BrowserContract.UrlAnnotations.CONTENT_URI); + } + + /** + * Get all feed subscriptions. + */ + @Override + public Cursor getFeedSubscriptions(ContentResolver cr) { + return queryByKey(cr, + Key.FEED_SUBSCRIPTION, + new String[] { BrowserContract.UrlAnnotations.URL, BrowserContract.UrlAnnotations.VALUE }, + null); + } + + /** + * Insert mapping from website URL to URL of the feed. + */ + @Override + public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) { + insertAnnotation(cr, originUrl, Key.FEED, feedUrl); + } + + @Override + public boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url) { + return hasResultsForSelection(cr, + BrowserContract.UrlAnnotations.URL + " = ?", + new String[]{url}); + } + + @Override + public void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut) { + insertAnnotation(cr, url, Key.HOME_SCREEN_SHORTCUT, String.valueOf(hasCreatedShortCut)); + } + + /** + * Returns true if there's a mapping from the given website URL to a feed URL. False otherwise. + */ + @Override + public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) { + return hasResultsForSelection(cr, + BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?", + new String[]{websiteUrl, Key.FEED.getDbValue()}); + } + + /** + * Returns true if there's a website URL with this feed URL. False otherwise. + */ + @Override + public boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl) { + return hasResultsForSelection(cr, + BrowserContract.UrlAnnotations.VALUE + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?", + new String[]{feedUrl, Key.FEED.getDbValue()}); + } + + /** + * Delete the feed URL mapping for this website URL. + */ + @Override + public void deleteFeedUrl(ContentResolver cr, String websiteUrl) { + deleteAnnotation(cr, websiteUrl, Key.FEED); + } + + /** + * Get website URLs that are mapped to the given feed URL. + */ + @Override + public Cursor getWebsitesWithFeedUrl(ContentResolver cr) { + return cr.query(urlAnnotationsTableWithProfile, + new String[] { BrowserContract.UrlAnnotations.URL }, + BrowserContract.UrlAnnotations.KEY + " = ?", + new String[] { Key.FEED.getDbValue() }, + null); + } + + /** + * Returns true if there's a subscription for this feed URL. False otherwise. + */ + @Override + public boolean hasFeedSubscription(ContentResolver cr, String feedUrl) { + return hasResultsForSelection(cr, + BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?", + new String[]{feedUrl, Key.FEED_SUBSCRIPTION.getDbValue()}); + } + + /** + * Insert the given feed subscription (Mapping from feed URL to the subscription object). + */ + @Override + public void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription) { + try { + insertAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString()); + } catch (JSONException e) { + Log.w(LOGTAG, "Could not serialize subscription"); + } + } + + /** + * Update the feed subscription with new values. + */ + @Override + public void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription) { + try { + updateAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString()); + } catch (JSONException e) { + Log.w(LOGTAG, "Could not serialize subscription"); + } + } + + /** + * Delete the subscription for the feed URL. + */ + @Override + public void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription) { + deleteAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION); + } + + private int deleteAnnotation(final ContentResolver cr, final String url, final Key key) { + return cr.delete(urlAnnotationsTableWithProfile, + BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?", + new String[] { key.getDbValue(), url }); + } + + private int updateAnnotation(final ContentResolver cr, final String url, final Key key, final String value) { + ContentValues values = new ContentValues(); + values.put(BrowserContract.UrlAnnotations.VALUE, value); + values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, System.currentTimeMillis()); + + return cr.update(urlAnnotationsTableWithProfile, + values, + BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?", + new String[]{key.getDbValue(), url}); + } + + private void insertAnnotation(final ContentResolver cr, final String url, final Key key, final String value) { + insertAnnotation(cr, url, key.getDbValue(), value); + } + + @RobocopTarget + @Override + public void insertAnnotation(final ContentResolver cr, final String url, final String key, final String value) { + final long creationTime = System.currentTimeMillis(); + final ContentValues values = new ContentValues(5); + values.put(BrowserContract.UrlAnnotations.URL, url); + values.put(BrowserContract.UrlAnnotations.KEY, key); + values.put(BrowserContract.UrlAnnotations.VALUE, value); + values.put(BrowserContract.UrlAnnotations.DATE_CREATED, creationTime); + values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, creationTime); + cr.insert(urlAnnotationsTableWithProfile, values); + } + + /** + * @return true if the table contains rows for the given selection. + */ + private boolean hasResultsForSelection(ContentResolver cr, String selection, String[] selectionArgs) { + Cursor cursor = cr.query(urlAnnotationsTableWithProfile, + new String[] { BrowserContract.UrlAnnotations._ID }, + selection, + selectionArgs, + null); + if (cursor == null) { + return false; + } + + try { + return cursor.getCount() > 0; + } finally { + cursor.close(); + } + } + + private Cursor queryByKey(final ContentResolver cr, @NonNull final Key key, @Nullable final String[] projections, + @Nullable final String sortOrder) { + return cr.query(urlAnnotationsTableWithProfile, + projections, + BrowserContract.UrlAnnotations.KEY + " = ?", new String[] { key.getDbValue() }, + sortOrder); + } + + @Override + public Cursor getScreenshots(ContentResolver cr) { + return queryByKey(cr, + Key.SCREENSHOT, + new String[] { + BrowserContract.UrlAnnotations._ID, + BrowserContract.UrlAnnotations.URL, + BrowserContract.UrlAnnotations.KEY, + BrowserContract.UrlAnnotations.VALUE, + BrowserContract.UrlAnnotations.DATE_CREATED, + }, + BrowserContract.UrlAnnotations.DATE_CREATED + " DESC"); + } + + public void insertScreenshot(final ContentResolver cr, final String pageUrl, final String screenshotPath) { + insertAnnotation(cr, pageUrl, Key.SCREENSHOT.getDbValue(), screenshotPath); + } + + @Override + public void insertReaderViewUrl(final ContentResolver cr, final String pageUrl) { + insertAnnotation(cr, pageUrl, Key.READER_VIEW.getDbValue(), BrowserContract.UrlAnnotations.READER_VIEW_SAVED_VALUE); + } + + @Override + public void deleteReaderViewUrl(ContentResolver cr, String pageURL) { + deleteAnnotation(cr, pageURL, Key.READER_VIEW); + } + + public int getAnnotationCount(ContentResolver cr, Key key) { + final String countColumnname = "count"; + final Cursor c = queryByKey(cr, + key, + new String[] { + "COUNT(*) AS " + countColumnname + }, + null); + + try { + if (c != null && c.moveToFirst()) { + return c.getInt(c.getColumnIndexOrThrow(countColumnname)); + } else { + return 0; + } + } finally { + if (c != null) { + c.close(); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java new file mode 100644 index 000000000..d2d504851 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java @@ -0,0 +1,520 @@ +/* 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.ContentUris; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.Base64; + +import org.mozilla.gecko.db.BrowserContract.DeletedLogins; +import org.mozilla.gecko.db.BrowserContract.Logins; +import org.mozilla.gecko.db.BrowserContract.LoginsDisabledHosts; +import org.mozilla.gecko.sync.Utils; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.util.HashMap; + +import javax.crypto.Cipher; +import javax.crypto.NullCipher; + +import static org.mozilla.gecko.db.BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS; +import static org.mozilla.gecko.db.BrowserContract.Logins.TABLE_LOGINS; +import static org.mozilla.gecko.db.BrowserContract.LoginsDisabledHosts.TABLE_DISABLED_HOSTS; + +public class LoginsProvider extends SharedBrowserDatabaseProvider { + + private static final int LOGINS = 100; + private static final int LOGINS_ID = 101; + private static final int DELETED_LOGINS = 102; + private static final int DELETED_LOGINS_ID = 103; + private static final int DISABLED_HOSTS = 104; + private static final int DISABLED_HOSTS_HOSTNAME = 105; + private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + private static final HashMap<String, String> LOGIN_PROJECTION_MAP; + private static final HashMap<String, String> DELETED_LOGIN_PROJECTION_MAP; + private static final HashMap<String, String> DISABLED_HOSTS_PROJECTION_MAP; + + private static final String DEFAULT_LOGINS_SORT_ORDER = Logins.HOSTNAME + " ASC"; + private static final String DEFAULT_DELETED_LOGINS_SORT_ORDER = DeletedLogins.TIME_DELETED + " ASC"; + private static final String DEFAULT_DISABLED_HOSTS_SORT_ORDER = LoginsDisabledHosts.HOSTNAME + " ASC"; + private static final String WHERE_GUID_IS_NULL = DeletedLogins.GUID + " IS NULL"; + private static final String WHERE_GUID_IS_VALUE = DeletedLogins.GUID + " = ?"; + + protected static final String INDEX_LOGINS_HOSTNAME = "login_hostname_index"; + protected static final String INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL = "login_hostname_formSubmitURL_index"; + protected static final String INDEX_LOGINS_HOSTNAME_HTTP_REALM = "login_hostname_httpRealm_index"; + + static { + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins", LOGINS); + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins/#", LOGINS_ID); + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins", DELETED_LOGINS); + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins/#", DELETED_LOGINS_ID); + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins-disabled-hosts", DISABLED_HOSTS); + URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins-disabled-hosts/hostname/*", DISABLED_HOSTS_HOSTNAME); + + LOGIN_PROJECTION_MAP = new HashMap<>(); + LOGIN_PROJECTION_MAP.put(Logins._ID, Logins._ID); + LOGIN_PROJECTION_MAP.put(Logins.HOSTNAME, Logins.HOSTNAME); + LOGIN_PROJECTION_MAP.put(Logins.HTTP_REALM, Logins.HTTP_REALM); + LOGIN_PROJECTION_MAP.put(Logins.FORM_SUBMIT_URL, Logins.FORM_SUBMIT_URL); + LOGIN_PROJECTION_MAP.put(Logins.USERNAME_FIELD, Logins.USERNAME_FIELD); + LOGIN_PROJECTION_MAP.put(Logins.PASSWORD_FIELD, Logins.PASSWORD_FIELD); + LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_USERNAME, Logins.ENCRYPTED_USERNAME); + LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_PASSWORD, Logins.ENCRYPTED_PASSWORD); + LOGIN_PROJECTION_MAP.put(Logins.GUID, Logins.GUID); + LOGIN_PROJECTION_MAP.put(Logins.ENC_TYPE, Logins.ENC_TYPE); + LOGIN_PROJECTION_MAP.put(Logins.TIME_CREATED, Logins.TIME_CREATED); + LOGIN_PROJECTION_MAP.put(Logins.TIME_LAST_USED, Logins.TIME_LAST_USED); + LOGIN_PROJECTION_MAP.put(Logins.TIME_PASSWORD_CHANGED, Logins.TIME_PASSWORD_CHANGED); + LOGIN_PROJECTION_MAP.put(Logins.TIMES_USED, Logins.TIMES_USED); + + DELETED_LOGIN_PROJECTION_MAP = new HashMap<>(); + DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins._ID, DeletedLogins._ID); + DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.GUID, DeletedLogins.GUID); + DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.TIME_DELETED, DeletedLogins.TIME_DELETED); + + DISABLED_HOSTS_PROJECTION_MAP = new HashMap<>(); + DISABLED_HOSTS_PROJECTION_MAP.put(LoginsDisabledHosts._ID, LoginsDisabledHosts._ID); + DISABLED_HOSTS_PROJECTION_MAP.put(LoginsDisabledHosts.HOSTNAME, LoginsDisabledHosts.HOSTNAME); + } + + private static String projectColumn(String table, String column) { + return table + "." + column; + } + + private static String selectColumn(String table, String column) { + return projectColumn(table, column) + " = ?"; + } + + @Override + protected Uri insertInTransaction(Uri uri, ContentValues values) { + trace("Calling insert in transaction on URI: " + uri); + + final int match = URI_MATCHER.match(uri); + final SQLiteDatabase db = getWritableDatabase(uri); + final long id; + String guid; + + setupDefaultValues(values, uri); + switch (match) { + case LOGINS: + removeDeletedLoginsByGUIDInTransaction(values, db); + // Encrypt sensitive data. + encryptContentValueFields(values); + guid = values.getAsString(Logins.GUID); + debug("Inserting login in database with GUID: " + guid); + id = db.insertOrThrow(TABLE_LOGINS, Logins.GUID, values); + break; + + case DELETED_LOGINS: + guid = values.getAsString(DeletedLogins.GUID); + debug("Inserting deleted-login in database with GUID: " + guid); + id = db.insertOrThrow(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values); + break; + + case DISABLED_HOSTS: + String hostname = values.getAsString(LoginsDisabledHosts.HOSTNAME); + debug("Inserting disabled-host in database with hostname: " + hostname); + id = db.insertOrThrow(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME, values); + break; + + default: + throw new UnsupportedOperationException("Unknown insert URI " + uri); + } + + debug("Inserted ID in database: " + id); + + if (id >= 0) { + return ContentUris.withAppendedId(uri, id); + } + + return null; + } + + @Override + @SuppressWarnings("fallthrough") + protected int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) { + trace("Calling delete in transaction on URI: " + uri); + + final int match = URI_MATCHER.match(uri); + final String table; + final SQLiteDatabase db = getWritableDatabase(uri); + + beginWrite(db); + switch (match) { + case LOGINS_ID: + trace("Delete on LOGINS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[]{Long.toString(ContentUris.parseId(uri))}); + // Store the deleted client in deleted-logins table. + final String guid = getLoginGUIDByID(selection, selectionArgs, db); + if (guid == null) { + // No matching logins found for the id. + return 0; + } + boolean isInsertSuccessful = storeDeletedLoginForGUIDInTransaction(guid, db); + if (!isInsertSuccessful) { + // Failed to insert into deleted-logins, return early. + return 0; + } + // fall through + case LOGINS: + trace("Delete on LOGINS: " + uri); + table = TABLE_LOGINS; + break; + + case DELETED_LOGINS_ID: + trace("Delete on DELETED_LOGINS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[]{Long.toString(ContentUris.parseId(uri))}); + // fall through + case DELETED_LOGINS: + trace("Delete on DELETED_LOGINS_ID: " + uri); + table = TABLE_DELETED_LOGINS; + break; + + case DISABLED_HOSTS_HOSTNAME: + trace("Delete on DISABLED_HOSTS_HOSTNAME: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[]{uri.getLastPathSegment()}); + // fall through + case DISABLED_HOSTS: + trace("Delete on DISABLED_HOSTS: " + uri); + table = TABLE_DISABLED_HOSTS; + break; + + default: + throw new UnsupportedOperationException("Unknown delete URI " + uri); + } + + debug("Deleting " + table + " for URI: " + uri); + return db.delete(table, selection, selectionArgs); + } + + @Override + @SuppressWarnings("fallthrough") + protected int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + trace("Calling update in transaction on URI: " + uri); + + final int match = URI_MATCHER.match(uri); + final SQLiteDatabase db = getWritableDatabase(uri); + final String table; + + beginWrite(db); + switch (match) { + case LOGINS_ID: + trace("Update on LOGINS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[]{Long.toString(ContentUris.parseId(uri))}); + + case LOGINS: + trace("Update on LOGINS: " + uri); + table = TABLE_LOGINS; + // Encrypt sensitive data. + encryptContentValueFields(values); + break; + + default: + throw new UnsupportedOperationException("Unknown update URI " + uri); + } + + trace("Updating " + table + " on URI: " + uri); + return db.update(table, values, selection, selectionArgs); + + } + + @Override + @SuppressWarnings("fallthrough") + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + trace("Calling query on URI: " + uri); + + final SQLiteDatabase db = getReadableDatabase(uri); + final int match = URI_MATCHER.match(uri); + final String groupBy = null; + final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + final String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + + switch (match) { + case LOGINS_ID: + trace("Query is on LOGINS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + + // fall through + case LOGINS: + trace("Query is on LOGINS: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_LOGINS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(LOGIN_PROJECTION_MAP); + qb.setTables(TABLE_LOGINS); + break; + + case DELETED_LOGINS_ID: + trace("Query is on DELETED_LOGINS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + + // fall through + case DELETED_LOGINS: + trace("Query is on DELETED_LOGINS: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_DELETED_LOGINS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(DELETED_LOGIN_PROJECTION_MAP); + qb.setTables(TABLE_DELETED_LOGINS); + break; + + case DISABLED_HOSTS_HOSTNAME: + trace("Query is on DISABLED_HOSTS_HOSTNAME: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { uri.getLastPathSegment() }); + + // fall through + case DISABLED_HOSTS: + trace("Query is on DISABLED_HOSTS: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_DISABLED_HOSTS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(DISABLED_HOSTS_PROJECTION_MAP); + qb.setTables(TABLE_DISABLED_HOSTS); + break; + + default: + throw new UnsupportedOperationException("Unknown query URI " + uri); + } + + trace("Running built query."); + Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit); + // If decryptManyCursorRows does not return the original cursor, it closes it, so there's + // no need to close here. + cursor = decryptManyCursorRows(cursor); + cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.LOGINS_AUTHORITY_URI); + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + final int match = URI_MATCHER.match(uri); + + switch (match) { + case LOGINS: + return Logins.CONTENT_TYPE; + + case LOGINS_ID: + return Logins.CONTENT_ITEM_TYPE; + + case DELETED_LOGINS: + return DeletedLogins.CONTENT_TYPE; + + case DELETED_LOGINS_ID: + return DeletedLogins.CONTENT_ITEM_TYPE; + + case DISABLED_HOSTS: + return LoginsDisabledHosts.CONTENT_TYPE; + + case DISABLED_HOSTS_HOSTNAME: + return LoginsDisabledHosts.CONTENT_ITEM_TYPE; + + default: + throw new UnsupportedOperationException("Unknown type " + uri); + } + } + + /** + * Caller is responsible for invoking this method inside a transaction. + */ + private String getLoginGUIDByID(final String selection, final String[] selectionArgs, final SQLiteDatabase db) { + final Cursor cursor = db.query(Logins.TABLE_LOGINS, new String[]{Logins.GUID}, selection, selectionArgs, null, null, DEFAULT_LOGINS_SORT_ORDER); + try { + if (!cursor.moveToFirst()) { + return null; + } + return cursor.getString(cursor.getColumnIndexOrThrow(Logins.GUID)); + } finally { + cursor.close(); + } + } + + /** + * Caller is responsible for invoking this method inside a transaction. + */ + private boolean storeDeletedLoginForGUIDInTransaction(final String guid, final SQLiteDatabase db) { + if (guid == null) { + return false; + } + final ContentValues values = new ContentValues(); + values.put(DeletedLogins.GUID, guid); + values.put(DeletedLogins.TIME_DELETED, System.currentTimeMillis()); + return db.insert(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values) > 0; + } + + /** + * Caller is responsible for invoking this method inside a transaction. + */ + private void removeDeletedLoginsByGUIDInTransaction(ContentValues values, SQLiteDatabase db) { + if (values.containsKey(Logins.GUID)) { + final String guid = values.getAsString(Logins.GUID); + if (guid == null) { + db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_NULL, null); + } else { + String[] args = new String[]{guid}; + db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_VALUE, args); + } + } + } + + private void setupDefaultValues(ContentValues values, Uri uri) throws IllegalArgumentException { + final int match = URI_MATCHER.match(uri); + final long now = System.currentTimeMillis(); + switch (match) { + case DELETED_LOGINS: + values.put(DeletedLogins.TIME_DELETED, now); + // deleted-logins must contain a guid + if (!values.containsKey(DeletedLogins.GUID)) { + throw new IllegalArgumentException("Must provide GUID for deleted-login"); + } + break; + + case LOGINS: + values.put(Logins.TIME_CREATED, now); + // Generate GUID for new login. Don't override specified GUIDs. + if (!values.containsKey(Logins.GUID)) { + final String guid = Utils.generateGuid(); + values.put(Logins.GUID, guid); + } + // The database happily accepts strings for long values; this just lets us re-use + // the existing helper method. + String nowString = Long.toString(now); + DBUtils.replaceKey(values, null, Logins.HTTP_REALM, null); + DBUtils.replaceKey(values, null, Logins.FORM_SUBMIT_URL, null); + DBUtils.replaceKey(values, null, Logins.ENC_TYPE, "0"); + DBUtils.replaceKey(values, null, Logins.TIME_LAST_USED, nowString); + DBUtils.replaceKey(values, null, Logins.TIME_PASSWORD_CHANGED, nowString); + DBUtils.replaceKey(values, null, Logins.TIMES_USED, "0"); + break; + + case DISABLED_HOSTS: + if (!values.containsKey(LoginsDisabledHosts.HOSTNAME)) { + throw new IllegalArgumentException("Must provide hostname for disabled-host"); + } + break; + + default: + throw new UnsupportedOperationException("Unknown URI in setupDefaultValues " + uri); + } + } + + private void encryptContentValueFields(final ContentValues values) { + if (values.containsKey(Logins.ENCRYPTED_PASSWORD)) { + final String res = encrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD)); + values.put(Logins.ENCRYPTED_PASSWORD, res); + } + + if (values.containsKey(Logins.ENCRYPTED_USERNAME)) { + final String res = encrypt(values.getAsString(Logins.ENCRYPTED_USERNAME)); + values.put(Logins.ENCRYPTED_USERNAME, res); + } + } + + /** + * Replace each password and username encrypted ciphertext with its equivalent decrypted + * plaintext in the given cursor. + * <p/> + * The encryption algorithm used to protect logins is unspecified; and further, a consumer of + * consumers should never have access to encrypted ciphertext. + * + * @param cursor containing at least one of password and username encrypted ciphertexts. + * @return a new {@link Cursor} with password and username decrypted plaintexts. + */ + private Cursor decryptManyCursorRows(final Cursor cursor) { + final int passwordIndex = cursor.getColumnIndex(Logins.ENCRYPTED_PASSWORD); + final int usernameIndex = cursor.getColumnIndex(Logins.ENCRYPTED_USERNAME); + + if (passwordIndex == -1 && usernameIndex == -1) { + return cursor; + } + + // Special case, decrypt the encrypted username or password before returning the cursor. + final MatrixCursor newCursor = new MatrixCursor(cursor.getColumnNames(), cursor.getColumnCount()); + try { + for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { + final ContentValues values = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, values); + + if (passwordIndex > -1) { + String decrypted = decrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD)); + values.put(Logins.ENCRYPTED_PASSWORD, decrypted); + } + + if (usernameIndex > -1) { + String decrypted = decrypt(values.getAsString(Logins.ENCRYPTED_USERNAME)); + values.put(Logins.ENCRYPTED_USERNAME, decrypted); + } + + final MatrixCursor.RowBuilder rowBuilder = newCursor.newRow(); + for (String key : cursor.getColumnNames()) { + rowBuilder.add(values.get(key)); + } + } + } finally { + // Close the old cursor before returning the new one. + cursor.close(); + } + + return newCursor; + } + + private String encrypt(@NonNull String initialValue) { + try { + final Cipher cipher = getCipher(Cipher.ENCRYPT_MODE); + return Base64.encodeToString(cipher.doFinal(initialValue.getBytes("UTF-8")), Base64.URL_SAFE); + } catch (Exception e) { + debug("encryption failed : " + e); + throw new IllegalStateException("Logins encryption failed", e); + } + } + + private String decrypt(@NonNull String initialValue) { + try { + final Cipher cipher = getCipher(Cipher.DECRYPT_MODE); + return new String(cipher.doFinal(Base64.decode(initialValue.getBytes("UTF-8"), Base64.URL_SAFE))); + } catch (Exception e) { + debug("Decryption failed : " + e); + throw new IllegalStateException("Logins decryption failed", e); + } + } + + private Cipher getCipher(int mode) throws UnsupportedEncodingException, GeneralSecurityException { + return new NullCipher(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java new file mode 100644 index 000000000..2f5e11ed4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java @@ -0,0 +1,348 @@ +/* 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 java.util.HashMap; + +import org.mozilla.gecko.CrashHandler; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoMessageReceiver; +import org.mozilla.gecko.NSSBridge; +import org.mozilla.gecko.db.BrowserContract.DeletedPasswords; +import org.mozilla.gecko.db.BrowserContract.GeckoDisabledHosts; +import org.mozilla.gecko.db.BrowserContract.Passwords; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.sqlite.MatrixBlobCursor; +import org.mozilla.gecko.sqlite.SQLiteBridge; +import org.mozilla.gecko.sync.Utils; + +import android.content.ContentValues; +import android.content.Intent; +import android.content.UriMatcher; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +public class PasswordsProvider extends SQLiteBridgeContentProvider { + static final String TABLE_PASSWORDS = "moz_logins"; + static final String TABLE_DELETED_PASSWORDS = "moz_deleted_logins"; + static final String TABLE_DISABLED_HOSTS = "moz_disabledHosts"; + + private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_PASSWORDS"; + + private static final int PASSWORDS = 100; + private static final int DELETED_PASSWORDS = 101; + private static final int DISABLED_HOSTS = 102; + + static final String DEFAULT_PASSWORDS_SORT_ORDER = Passwords.HOSTNAME + " ASC"; + static final String DEFAULT_DELETED_PASSWORDS_SORT_ORDER = DeletedPasswords.TIME_DELETED + " ASC"; + + private static final UriMatcher URI_MATCHER; + + private static final HashMap<String, String> PASSWORDS_PROJECTION_MAP; + private static final HashMap<String, String> DELETED_PASSWORDS_PROJECTION_MAP; + private static final HashMap<String, String> DISABLED_HOSTS_PROJECTION_MAP; + + // this should be kept in sync with the version in toolkit/components/passwordmgr/storage-mozStorage.js + private static final int DB_VERSION = 6; + private static final String DB_FILENAME = "signons.sqlite"; + private static final String WHERE_GUID_IS_NULL = BrowserContract.DeletedPasswords.GUID + " IS NULL"; + private static final String WHERE_GUID_IS_VALUE = BrowserContract.DeletedPasswords.GUID + " = ?"; + + private static final String LOG_TAG = "GeckoPasswordsProvider"; + + private CrashHandler mCrashHandler; + + static { + URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + // content://org.mozilla.gecko.providers.browser/passwords/# + URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "passwords", PASSWORDS); + + PASSWORDS_PROJECTION_MAP = new HashMap<String, String>(); + PASSWORDS_PROJECTION_MAP.put(Passwords.ID, Passwords.ID); + PASSWORDS_PROJECTION_MAP.put(Passwords.HOSTNAME, Passwords.HOSTNAME); + PASSWORDS_PROJECTION_MAP.put(Passwords.HTTP_REALM, Passwords.HTTP_REALM); + PASSWORDS_PROJECTION_MAP.put(Passwords.FORM_SUBMIT_URL, Passwords.FORM_SUBMIT_URL); + PASSWORDS_PROJECTION_MAP.put(Passwords.USERNAME_FIELD, Passwords.USERNAME_FIELD); + PASSWORDS_PROJECTION_MAP.put(Passwords.PASSWORD_FIELD, Passwords.PASSWORD_FIELD); + PASSWORDS_PROJECTION_MAP.put(Passwords.ENCRYPTED_USERNAME, Passwords.ENCRYPTED_USERNAME); + PASSWORDS_PROJECTION_MAP.put(Passwords.ENCRYPTED_PASSWORD, Passwords.ENCRYPTED_PASSWORD); + PASSWORDS_PROJECTION_MAP.put(Passwords.GUID, Passwords.GUID); + PASSWORDS_PROJECTION_MAP.put(Passwords.ENC_TYPE, Passwords.ENC_TYPE); + PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_CREATED, Passwords.TIME_CREATED); + PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_LAST_USED, Passwords.TIME_LAST_USED); + PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_PASSWORD_CHANGED, Passwords.TIME_PASSWORD_CHANGED); + PASSWORDS_PROJECTION_MAP.put(Passwords.TIMES_USED, Passwords.TIMES_USED); + + URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "deleted-passwords", DELETED_PASSWORDS); + + DELETED_PASSWORDS_PROJECTION_MAP = new HashMap<String, String>(); + DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.ID, DeletedPasswords.ID); + DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.GUID, DeletedPasswords.GUID); + DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.TIME_DELETED, DeletedPasswords.TIME_DELETED); + + URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "disabled-hosts", DISABLED_HOSTS); + + DISABLED_HOSTS_PROJECTION_MAP = new HashMap<String, String>(); + DISABLED_HOSTS_PROJECTION_MAP.put(GeckoDisabledHosts.HOSTNAME, GeckoDisabledHosts.HOSTNAME); + } + + public PasswordsProvider() { + super(LOG_TAG); + } + + @Override + public boolean onCreate() { + mCrashHandler = CrashHandler.createDefaultCrashHandler(getContext()); + + // We don't use .loadMozGlue because we're in a different process, + // and we just want to reuse code rather than use the loader lock etc. + GeckoLoader.doLoadLibrary(getContext(), "mozglue"); + return super.onCreate(); + } + + @Override + public void shutdown() { + super.shutdown(); + + if (mCrashHandler != null) { + mCrashHandler.unregister(); + mCrashHandler = null; + } + } + + @Override + protected String getDBName() { + return DB_FILENAME; + } + + @Override + protected String getTelemetryPrefix() { + return TELEMETRY_TAG; + } + + @Override + protected int getDBVersion() { + return DB_VERSION; + } + + @Override + public String getType(Uri uri) { + final int match = URI_MATCHER.match(uri); + + switch (match) { + case PASSWORDS: + return Passwords.CONTENT_TYPE; + + case DELETED_PASSWORDS: + return DeletedPasswords.CONTENT_TYPE; + + case DISABLED_HOSTS: + return GeckoDisabledHosts.CONTENT_TYPE; + + default: + throw new UnsupportedOperationException("Unknown type " + uri); + } + } + + @Override + public String getTable(Uri uri) { + final int match = URI_MATCHER.match(uri); + switch (match) { + case DELETED_PASSWORDS: + return TABLE_DELETED_PASSWORDS; + + case PASSWORDS: + return TABLE_PASSWORDS; + + case DISABLED_HOSTS: + return TABLE_DISABLED_HOSTS; + + default: + throw new UnsupportedOperationException("Unknown table " + uri); + } + } + + @Override + public String getSortOrder(Uri uri, String aRequested) { + if (!TextUtils.isEmpty(aRequested)) { + return aRequested; + } + + final int match = URI_MATCHER.match(uri); + switch (match) { + case DELETED_PASSWORDS: + return DEFAULT_DELETED_PASSWORDS_SORT_ORDER; + + case PASSWORDS: + return DEFAULT_PASSWORDS_SORT_ORDER; + + case DISABLED_HOSTS: + return null; + + default: + throw new UnsupportedOperationException("Unknown URI " + uri); + } + } + + @Override + public void setupDefaults(Uri uri, ContentValues values) + throws IllegalArgumentException { + int match = URI_MATCHER.match(uri); + long now = System.currentTimeMillis(); + switch (match) { + case DELETED_PASSWORDS: + values.put(DeletedPasswords.TIME_DELETED, now); + + // Deleted passwords must contain a guid + if (!values.containsKey(Passwords.GUID)) { + throw new IllegalArgumentException("Must provide a GUID for a deleted password"); + } + break; + + case PASSWORDS: + values.put(Passwords.TIME_CREATED, now); + + // Generate GUID for new password. Don't override specified GUIDs. + if (!values.containsKey(Passwords.GUID)) { + String guid = Utils.generateGuid(); + values.put(Passwords.GUID, guid); + } + String nowString = Long.toString(now); + DBUtils.replaceKey(values, null, Passwords.HOSTNAME, ""); + DBUtils.replaceKey(values, null, Passwords.HTTP_REALM, ""); + DBUtils.replaceKey(values, null, Passwords.FORM_SUBMIT_URL, ""); + DBUtils.replaceKey(values, null, Passwords.USERNAME_FIELD, ""); + DBUtils.replaceKey(values, null, Passwords.PASSWORD_FIELD, ""); + DBUtils.replaceKey(values, null, Passwords.ENCRYPTED_USERNAME, ""); + DBUtils.replaceKey(values, null, Passwords.ENCRYPTED_PASSWORD, ""); + DBUtils.replaceKey(values, null, Passwords.ENC_TYPE, "0"); + DBUtils.replaceKey(values, null, Passwords.TIME_LAST_USED, nowString); + DBUtils.replaceKey(values, null, Passwords.TIME_PASSWORD_CHANGED, nowString); + DBUtils.replaceKey(values, null, Passwords.TIMES_USED, "0"); + break; + + case DISABLED_HOSTS: + if (!values.containsKey(GeckoDisabledHosts.HOSTNAME)) { + throw new IllegalArgumentException("Must provide a hostname for a disabled host"); + } + break; + + default: + throw new UnsupportedOperationException("Unknown URI " + uri); + } + } + + @Override + public void initGecko() { + // We're not in the main process. The receiver of this Intent can + // communicate with Gecko in the main process. + Intent initIntent = new Intent(getContext(), GeckoMessageReceiver.class); + initIntent.setAction(GeckoApp.ACTION_INIT_PW); + mContext.sendBroadcast(initIntent); + } + + private String doCrypto(String initialValue, Uri uri, Boolean encrypt) { + String profilePath = null; + if (uri != null) { + profilePath = uri.getQueryParameter(BrowserContract.PARAM_PROFILE_PATH); + } + + String result = ""; + try { + if (encrypt) { + if (profilePath != null) { + result = NSSBridge.encrypt(mContext, profilePath, initialValue); + } else { + result = NSSBridge.encrypt(mContext, initialValue); + } + } else { + if (profilePath != null) { + result = NSSBridge.decrypt(mContext, profilePath, initialValue); + } else { + result = NSSBridge.decrypt(mContext, initialValue); + } + } + } catch (Exception ex) { + Log.e(LOG_TAG, "Error in NSSBridge"); + throw new RuntimeException(ex); + } + return result; + } + + @Override + public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) { + if (values.containsKey(Passwords.GUID)) { + String guid = values.getAsString(Passwords.GUID); + if (guid == null) { + db.delete(TABLE_DELETED_PASSWORDS, WHERE_GUID_IS_NULL, null); + return; + } + String[] args = new String[] { guid }; + db.delete(TABLE_DELETED_PASSWORDS, WHERE_GUID_IS_VALUE, args); + } + + if (values.containsKey(Passwords.ENCRYPTED_PASSWORD)) { + String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_PASSWORD), uri, true); + values.put(Passwords.ENCRYPTED_PASSWORD, res); + values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR); + } + + if (values.containsKey(Passwords.ENCRYPTED_USERNAME)) { + String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_USERNAME), uri, true); + values.put(Passwords.ENCRYPTED_USERNAME, res); + values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR); + } + } + + @Override + public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) { + if (values.containsKey(Passwords.ENCRYPTED_PASSWORD)) { + String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_PASSWORD), uri, true); + values.put(Passwords.ENCRYPTED_PASSWORD, res); + values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR); + } + + if (values.containsKey(Passwords.ENCRYPTED_USERNAME)) { + String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_USERNAME), uri, true); + values.put(Passwords.ENCRYPTED_USERNAME, res); + values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR); + } + } + + @Override + public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) { + int passwordIndex = -1; + int usernameIndex = -1; + String profilePath = null; + + try { + passwordIndex = cursor.getColumnIndexOrThrow(Passwords.ENCRYPTED_PASSWORD); + } catch (Exception ex) { } + try { + usernameIndex = cursor.getColumnIndexOrThrow(Passwords.ENCRYPTED_USERNAME); + } catch (Exception ex) { } + + if (passwordIndex > -1 || usernameIndex > -1) { + MatrixBlobCursor m = (MatrixBlobCursor)cursor; + if (cursor.moveToFirst()) { + do { + if (passwordIndex > -1) { + String decrypted = doCrypto(cursor.getString(passwordIndex), uri, false);; + m.set(passwordIndex, decrypted); + } + + if (usernameIndex > -1) { + String decrypted = doCrypto(cursor.getString(usernameIndex), uri, false); + m.set(usernameIndex, decrypted); + } + } while (cursor.moveToNext()); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java new file mode 100644 index 000000000..7075c6e8a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java @@ -0,0 +1,55 @@ +/* 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 org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.db.PerProfileDatabases.DatabaseHelperFactory; + +import android.content.Context; +import android.database.sqlite.SQLiteOpenHelper; + +/** + * Abstract class containing methods needed to make a SQLite-based content + * provider with a database helper of type T, where one database helper is + * held per profile. + */ +public abstract class PerProfileDatabaseProvider<T extends SQLiteOpenHelper> extends AbstractPerProfileDatabaseProvider { + private PerProfileDatabases<T> databases; + + @Override + protected PerProfileDatabases<T> getDatabases() { + return databases; + } + + protected abstract String getDatabaseName(); + + /** + * Creates and returns an instance of the appropriate DB helper. + * + * @param context to use to create the database helper + * @param databasePath path to the DB file + * @return instance of the database helper + */ + protected abstract T createDatabaseHelper(Context context, String databasePath); + + @Override + public boolean onCreate() { + synchronized (this) { + databases = new PerProfileDatabases<T>( + getContext(), getDatabaseName(), new DatabaseHelperFactory<T>() { + @Override + public T makeDatabaseHelper(Context context, String databasePath) { + final T helper = createDatabaseHelper(context, databasePath); + if (Versions.feature16Plus) { + helper.setWriteAheadLoggingEnabled(true); + } + return helper; + } + }); + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java new file mode 100644 index 000000000..288d9cae7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java @@ -0,0 +1,94 @@ +/* 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 java.io.File; +import java.util.HashMap; + +import org.mozilla.gecko.GeckoProfile; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.text.TextUtils; + +/** + * Manages a set of per-profile database storage helpers. + */ +public class PerProfileDatabases<T extends SQLiteOpenHelper> { + + private final HashMap<String, T> mStorages = new HashMap<String, T>(); + + private final Context mContext; + private final String mDatabaseName; + private final DatabaseHelperFactory<T> mHelperFactory; + + // Only used during tests. + public void shutdown() { + synchronized (this) { + for (T t : mStorages.values()) { + try { + t.close(); + } catch (Throwable e) { + // Never mind. + } + } + } + } + + public interface DatabaseHelperFactory<T> { + public T makeDatabaseHelper(Context context, String databasePath); + } + + public PerProfileDatabases(final Context context, final String databaseName, final DatabaseHelperFactory<T> helperFactory) { + mContext = context; + mDatabaseName = databaseName; + mHelperFactory = helperFactory; + } + + public String getDatabasePathForProfile(String profile) { + final File profileDir = GeckoProfile.get(mContext, profile).getDir(); + if (profileDir == null) { + return null; + } + + return new File(profileDir, mDatabaseName).getAbsolutePath(); + } + + public T getDatabaseHelperForProfile(String profile) { + return getDatabaseHelperForProfile(profile, false); + } + + public T getDatabaseHelperForProfile(String profile, boolean isTest) { + // Always fall back to default profile if none has been provided. + if (profile == null) { + profile = GeckoProfile.get(mContext).getName(); + } + + synchronized (this) { + if (mStorages.containsKey(profile)) { + return mStorages.get(profile); + } + + final String databasePath = isTest ? mDatabaseName : getDatabasePathForProfile(profile); + if (databasePath == null) { + throw new IllegalStateException("Database path is null for profile: " + profile); + } + + final T helper = mHelperFactory.makeDatabaseHelper(mContext, databasePath); + DBUtils.ensureDatabaseIsNotLocked(helper, databasePath); + + mStorages.put(profile, helper); + return helper; + } + } + + public synchronized void shrinkMemory() { + for (T t : mStorages.values()) { + final SQLiteDatabase db = t.getWritableDatabase(); + db.execSQL("PRAGMA shrink_memory"); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java b/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java new file mode 100644 index 000000000..07f057c11 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java @@ -0,0 +1,69 @@ +/* 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 java.util.ArrayList; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * A thin representation of a remote client. + * <p> + * We use the hash of the client's GUID as the ID elsewhere. + */ +public class RemoteClient implements Parcelable { + public final String guid; + public final String name; + public final long lastModified; + public final String deviceType; + public final ArrayList<RemoteTab> tabs; + + public RemoteClient(String guid, String name, long lastModified, String deviceType) { + this.guid = guid; + this.name = name; + this.lastModified = lastModified; + this.deviceType = deviceType; + this.tabs = new ArrayList<>(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(guid); + parcel.writeString(name); + parcel.writeLong(lastModified); + parcel.writeString(deviceType); + parcel.writeTypedList(tabs); + } + + public static final Creator<RemoteClient> CREATOR = new Creator<RemoteClient>() { + @Override + public RemoteClient createFromParcel(final Parcel source) { + final String guid = source.readString(); + final String name = source.readString(); + final long lastModified = source.readLong(); + final String deviceType = source.readString(); + + final RemoteClient client = new RemoteClient(guid, name, lastModified, deviceType); + source.readTypedList(client.tabs, RemoteTab.CREATOR); + + return client; + } + + @Override + public RemoteClient[] newArray(final int size) { + return new RemoteClient[size]; + } + }; + + public boolean isDesktop() { + return "desktop".equals(deviceType); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java b/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java new file mode 100644 index 000000000..f7660c1f7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java @@ -0,0 +1,90 @@ +/* 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.os.Parcel; +import android.os.Parcelable; + +/** + * A thin representation of a remote tab. + * <p> + * These are generated functions. + */ +public class RemoteTab implements Parcelable { + public final String title; + public final String url; + public final long lastUsed; + + public RemoteTab(String title, String url, long lastUsed) { + this.title = title; + this.url = url; + this.lastUsed = lastUsed; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeString(title); + parcel.writeString(url); + parcel.writeLong(lastUsed); + } + + public static final Creator<RemoteTab> CREATOR = new Creator<RemoteTab>() { + @Override + public RemoteTab createFromParcel(final Parcel source) { + final String title = source.readString(); + final String url = source.readString(); + final long lastUsed = source.readLong(); + return new RemoteTab(title, url, lastUsed); + } + + @Override + public RemoteTab[] newArray(final int size) { + return new RemoteTab[size]; + } + }; + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((title == null) ? 0 : title.hashCode()); + result = prime * result + ((url == null) ? 0 : url.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + RemoteTab other = (RemoteTab) obj; + if (title == null) { + if (other.title != null) { + return false; + } + } else if (!title.equals(other.title)) { + return false; + } + if (url == null) { + if (other.url != null) { + return false; + } + } else if (!url.equals(other.url)) { + return false; + } + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java new file mode 100644 index 000000000..d48604f03 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java @@ -0,0 +1,471 @@ +/* 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 java.io.File; +import java.util.HashMap; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.sqlite.SQLiteBridge; +import org.mozilla.gecko.sqlite.SQLiteBridgeException; + +import android.content.ContentProvider; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +/* + * Provides a basic ContentProvider that sets up and sends queries through + * SQLiteBridge. Content providers should extend this by setting the appropriate + * table and version numbers in onCreate, and implementing the abstract methods: + * + * public abstract String getTable(Uri uri); + * public abstract String getSortOrder(Uri uri, String aRequested); + * public abstract void setupDefaults(Uri uri, ContentValues values); + * public abstract void initGecko(); + */ + +public abstract class SQLiteBridgeContentProvider extends ContentProvider { + private static final String ERROR_MESSAGE_DATABASE_IS_LOCKED = "Can't step statement: (5) database is locked"; + + private HashMap<String, SQLiteBridge> mDatabasePerProfile; + protected Context mContext; + private final String mLogTag; + + protected SQLiteBridgeContentProvider(String logTag) { + mLogTag = logTag; + } + + /** + * Subclasses must override this to allow error reporting code to compose + * the correct histogram name. + * + * Ensure that you define the new histograms if you define a new class! + */ + protected abstract String getTelemetryPrefix(); + + /** + * Errors are recorded in telemetry using an enumerated histogram. + * + * <https://developer.mozilla.org/en-US/docs/Mozilla/Performance/ + * Adding_a_new_Telemetry_probe#Choosing_a_Histogram_Type> + * + * These are the allowable enumeration values. Keep these in sync with the + * histogram definition! + * + */ + private static enum TelemetryErrorOp { + BULKINSERT (0), + DELETE (1), + INSERT (2), + QUERY (3), + UPDATE (4); + + private final int bucket; + + TelemetryErrorOp(final int bucket) { + this.bucket = bucket; + } + + public int getBucket() { + return bucket; + } + } + + @Override + public void shutdown() { + if (mDatabasePerProfile == null) { + return; + } + + synchronized (this) { + for (SQLiteBridge bridge : mDatabasePerProfile.values()) { + if (bridge != null) { + try { + bridge.close(); + } catch (Exception ex) { } + } + } + mDatabasePerProfile = null; + } + super.shutdown(); + } + + @Override + public void finalize() { + shutdown(); + } + + /** + * Return true of the query is from Firefox Sync. + * @param uri query URI + */ + public static boolean isCallerSync(Uri uri) { + String isSync = uri.getQueryParameter(BrowserContract.PARAM_IS_SYNC); + return !TextUtils.isEmpty(isSync); + } + + private SQLiteBridge getDB(Context context, final String databasePath) { + SQLiteBridge bridge = null; + + boolean dbNeedsSetup = true; + try { + String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadSQLiteLibs(context, resourcePath); + GeckoLoader.loadNSSLibs(context, resourcePath); + bridge = SQLiteBridge.openDatabase(databasePath, null, 0); + int version = bridge.getVersion(); + dbNeedsSetup = version != getDBVersion(); + } catch (SQLiteBridgeException ex) { + // close the database + if (bridge != null) { + bridge.close(); + } + + // this will throw if the database can't be found + // we should attempt to set it up if Gecko is running + dbNeedsSetup = true; + Log.e(mLogTag, "Error getting version ", ex); + + // if Gecko is not running, we should bail out. Otherwise we try to + // let Gecko build the database for us + if (!GeckoThread.isRunning()) { + Log.e(mLogTag, "Can not set up database. Gecko is not running"); + return null; + } + } + + // If the database is not set up yet, or is the wrong schema version, we send an initialize + // call to Gecko. Gecko will handle building the database file correctly, as well as any + // migrations that are necessary + if (dbNeedsSetup) { + bridge = null; + initGecko(); + } + return bridge; + } + + /** + * Returns the absolute path of a database file depending on the specified profile and dbName. + * @param profile + * the profile whose dbPath must be returned + * @param dbName + * the name of the db file whose absolute path must be returned + * @return the absolute path of the db file or <code>null</code> if it was not possible to retrieve a valid path + * + */ + private String getDatabasePathForProfile(String profile, String dbName) { + // Depends on the vagaries of GeckoProfile.get, so null check for safety. + File profileDir = GeckoProfile.get(mContext, profile).getDir(); + if (profileDir == null) { + return null; + } + + String databasePath = new File(profileDir, dbName).getAbsolutePath(); + return databasePath; + } + + /** + * Returns a SQLiteBridge object according to the specified profile id and to the name of db related to the + * current provider instance. + * @param profile + * the id of the profile to be used to retrieve the related SQLiteBridge + * @return the <code>SQLiteBridge</code> related to the specified profile id or <code>null</code> if it was + * not possible to retrieve a valid SQLiteBridge + */ + private SQLiteBridge getDatabaseForProfile(String profile) { + if (profile == null) { + profile = GeckoProfile.get(mContext).getName(); + Log.d(mLogTag, "No profile provided, using '" + profile + "'"); + } + + final String dbName = getDBName(); + String mapKey = profile + "/" + dbName; + + SQLiteBridge db = null; + synchronized (this) { + db = mDatabasePerProfile.get(mapKey); + if (db != null) { + return db; + } + final String dbPath = getDatabasePathForProfile(profile, dbName); + if (dbPath == null) { + Log.e(mLogTag, "Failed to get a valid db path for profile '" + profile + "'' dbName '" + dbName + "'"); + return null; + } + db = getDB(mContext, dbPath); + if (db != null) { + mDatabasePerProfile.put(mapKey, db); + } + } + return db; + } + + /** + * Returns a SQLiteBridge object according to the specified profile path and to the name of db related to the + * current provider instance. + * @param profilePath + * the profilePath to be used to retrieve the related SQLiteBridge + * @return the <code>SQLiteBridge</code> related to the specified profile path or <code>null</code> if it was + * not possible to retrieve a valid <code>SQLiteBridge</code> + */ + private SQLiteBridge getDatabaseForProfilePath(String profilePath) { + File profileDir = new File(profilePath, getDBName()); + final String dbPath = profileDir.getPath(); + return getDatabaseForDBPath(dbPath); + } + + /** + * Returns a SQLiteBridge object according to the specified file path. + * @param dbPath + * the path of the file to be used to retrieve the related SQLiteBridge + * @return the <code>SQLiteBridge</code> related to the specified file path or <code>null</code> if it was + * not possible to retrieve a valid <code>SQLiteBridge</code> + * + */ + private SQLiteBridge getDatabaseForDBPath(String dbPath) { + SQLiteBridge db = null; + synchronized (this) { + db = mDatabasePerProfile.get(dbPath); + if (db != null) { + return db; + } + db = getDB(mContext, dbPath); + if (db != null) { + mDatabasePerProfile.put(dbPath, db); + } + } + return db; + } + + /** + * Returns a SQLiteBridge object to be used to perform operations on the given <code>Uri</code>. + * @param uri + * the <code>Uri</code> to be used to retrieve the related SQLiteBridge + * @return a <code>SQLiteBridge</code> object to be used on the given uri or <code>null</code> if it was + * not possible to retrieve a valid <code>SQLiteBridge</code> + * + */ + private SQLiteBridge getDatabase(Uri uri) { + String profile = null; + String profilePath = null; + + profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); + profilePath = uri.getQueryParameter(BrowserContract.PARAM_PROFILE_PATH); + + // Testing will specify the absolute profile path + if (profilePath != null) { + return getDatabaseForProfilePath(profilePath); + } + return getDatabaseForProfile(profile); + } + + @Override + public boolean onCreate() { + mContext = getContext(); + synchronized (this) { + mDatabasePerProfile = new HashMap<String, SQLiteBridge>(); + } + return true; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + int deleted = 0; + final SQLiteBridge db = getDatabase(uri); + if (db == null) { + return deleted; + } + + try { + deleted = db.delete(getTable(uri), selection, selectionArgs); + } catch (SQLiteBridgeException ex) { + reportError(ex, TelemetryErrorOp.DELETE); + throw ex; + } + + return deleted; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + long id = -1; + final SQLiteBridge db = getDatabase(uri); + + // If we can not get a SQLiteBridge instance, its likely that the database + // has not been set up and Gecko is not running. We return null and expect + // callers to try again later + if (db == null) { + return null; + } + + setupDefaults(uri, values); + + boolean useTransaction = !db.inTransaction(); + try { + if (useTransaction) { + db.beginTransaction(); + } + + // onPreInsert does a check for the item in the deleted table in some cases + // so we put it inside this transaction + onPreInsert(values, uri, db); + id = db.insert(getTable(uri), null, values); + + if (useTransaction) { + db.setTransactionSuccessful(); + } + } catch (SQLiteBridgeException ex) { + reportError(ex, TelemetryErrorOp.INSERT); + throw ex; + } finally { + if (useTransaction) { + db.endTransaction(); + } + } + + return ContentUris.withAppendedId(uri, id); + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] allValues) { + final SQLiteBridge db = getDatabase(uri); + // If we can not get a SQLiteBridge instance, its likely that the database + // has not been set up and Gecko is not running. We return 0 and expect + // callers to try again later + if (db == null) { + return 0; + } + + int rowsAdded = 0; + + String table = getTable(uri); + + try { + db.beginTransaction(); + for (ContentValues initialValues : allValues) { + ContentValues values = new ContentValues(initialValues); + setupDefaults(uri, values); + onPreInsert(values, uri, db); + db.insert(table, null, values); + rowsAdded++; + } + db.setTransactionSuccessful(); + } catch (SQLiteBridgeException ex) { + reportError(ex, TelemetryErrorOp.BULKINSERT); + throw ex; + } finally { + db.endTransaction(); + } + + if (rowsAdded > 0) { + final boolean shouldSyncToNetwork = !isCallerSync(uri); + mContext.getContentResolver().notifyChange(uri, null, shouldSyncToNetwork); + } + + return rowsAdded; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + int updated = 0; + final SQLiteBridge db = getDatabase(uri); + + // If we can not get a SQLiteBridge instance, its likely that the database + // has not been set up and Gecko is not running. We return null and expect + // callers to try again later + if (db == null) { + return updated; + } + + onPreUpdate(values, uri, db); + + try { + updated = db.update(getTable(uri), values, selection, selectionArgs); + } catch (SQLiteBridgeException ex) { + reportError(ex, TelemetryErrorOp.UPDATE); + throw ex; + } + + return updated; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + Cursor cursor = null; + final SQLiteBridge db = getDatabase(uri); + + // If we can not get a SQLiteBridge instance, its likely that the database + // has not been set up and Gecko is not running. We return null and expect + // callers to try again later + if (db == null) { + return cursor; + } + + sortOrder = getSortOrder(uri, sortOrder); + + try { + cursor = db.query(getTable(uri), projection, selection, selectionArgs, null, null, sortOrder, null); + onPostQuery(cursor, uri, db); + } catch (SQLiteBridgeException ex) { + reportError(ex, TelemetryErrorOp.QUERY); + throw ex; + } + + return cursor; + } + + private String getHistogram(SQLiteBridgeException e) { + // If you add values here, make sure to update + // toolkit/components/telemetry/Histograms.json. + if (ERROR_MESSAGE_DATABASE_IS_LOCKED.equals(e.getMessage())) { + return getTelemetryPrefix() + "_LOCKED"; + } + return null; + } + + protected void reportError(SQLiteBridgeException e, TelemetryErrorOp op) { + Log.e(mLogTag, "Error in database " + op.name(), e); + final String histogram = getHistogram(e); + if (histogram == null) { + return; + } + + Telemetry.addToHistogram(histogram, op.getBucket()); + } + + protected abstract String getDBName(); + + protected abstract int getDBVersion(); + + protected abstract String getTable(Uri uri); + + protected abstract String getSortOrder(Uri uri, String aRequested); + + protected abstract void setupDefaults(Uri uri, ContentValues values); + + protected abstract void initGecko(); + + protected abstract void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db); + + protected abstract void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db); + + protected abstract void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java new file mode 100644 index 000000000..05d31fefd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java @@ -0,0 +1,127 @@ +/* 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 org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.SearchHistory; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +public class SearchHistoryProvider extends SharedBrowserDatabaseProvider { + private static final String LOG_TAG = "GeckoSearchProvider"; + private static final boolean DEBUG_ENABLED = false; + + /** + * Collapse whitespace. + */ + private String stripWhitespace(String query) { + if (TextUtils.isEmpty(query)) { + return ""; + } + + // Collapse whitespace + return query.trim().replaceAll("\\s+", " "); + } + + + @Override + public Uri insertInTransaction(Uri uri, ContentValues cv) { + final String query = stripWhitespace(cv.getAsString(SearchHistory.QUERY)); + + // We don't support inserting empty search queries. + if (TextUtils.isEmpty(query)) { + return null; + } + + final SQLiteDatabase db = getWritableDatabase(uri); + long id = -1; + + /* + * Attempt to insert the query. The catch block handles the case when + * the query already exists in the DB. + */ + try { + cv.put(SearchHistory.QUERY, query); + cv.put(SearchHistory.VISITS, 1); + cv.put(SearchHistory.DATE_LAST_VISITED, System.currentTimeMillis()); + + id = db.insertOrThrow(SearchHistory.TABLE_NAME, null, cv); + + if (id > 0) { + return ContentUris.withAppendedId(uri, id); + } + } catch (SQLException e) { + // This happens when the column already exists for this term. + if (DEBUG_ENABLED) { + Log.w(LOG_TAG, String.format("Query `%s` already in db", query)); + } + } + + /* + * Increment the VISITS counter and update the DATE_LAST_VISITED. + */ + final String sql = "UPDATE " + SearchHistory.TABLE_NAME + " SET " + + SearchHistory.VISITS + " = " + SearchHistory.VISITS + " + 1, " + + SearchHistory.DATE_LAST_VISITED + " = " + System.currentTimeMillis() + + " WHERE " + SearchHistory.QUERY + " = ?"; + + final Cursor c = db.rawQuery(sql, new String[] { query }); + + try { + if (c.getCount() > 1) { + // There is a UNIQUE constraint on the QUERY column, + // so there should only be one match. + return null; + } + if (c.moveToFirst()) { + return ContentUris.withAppendedId(uri, c.getInt(c.getColumnIndex(SearchHistory._ID))); + } + } finally { + c.close(); + } + + return null; + } + + @Override + public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) { + return getWritableDatabase(uri).delete(SearchHistory.TABLE_NAME, + selection, selectionArgs); + } + + /** + * Since we are managing counts and the full-text db, an update + * could mangle the internal state. So we disable it. + */ + @Override + public int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + throw new UnsupportedOperationException("This content provider does not support updating items"); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + final String groupBy = null; + final String having = null; + final String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + final Cursor cursor = getReadableDatabase(uri).query(SearchHistory.TABLE_NAME, projection, + selection, selectionArgs, groupBy, having, sortOrder, limit); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + @Override + public String getType(Uri uri) { + return SearchHistory.CONTENT_TYPE; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/Searches.java b/mobile/android/base/java/org/mozilla/gecko/db/Searches.java new file mode 100644 index 000000000..e050a4f93 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/Searches.java @@ -0,0 +1,12 @@ +/* -*- 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.ContentResolver; + +public interface Searches { + public void insert(ContentResolver cr, String query); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java new file mode 100644 index 000000000..8be18c089 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java @@ -0,0 +1,128 @@ +/* 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 org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.db.BrowserContract.CommonColumns; +import org.mozilla.gecko.db.BrowserContract.SyncColumns; +import org.mozilla.gecko.db.PerProfileDatabases.DatabaseHelperFactory; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.util.Log; + +/** + * A ContentProvider subclass that provides per-profile browser.db access + * that can be safely shared between multiple providers. + * + * If multiple ContentProvider classes wish to share a database, it's + * vitally important that they use the same SQLiteOpenHelpers for access. + * + * Failure to do so can cause accidental concurrent writes, with the result + * being unexpected SQLITE_BUSY errors. + * + * This class provides a static {@link PerProfileDatabases} instance, lazily + * initialized within {@link SharedBrowserDatabaseProvider#onCreate()}. + */ +public abstract class SharedBrowserDatabaseProvider extends AbstractPerProfileDatabaseProvider { + private static final String LOGTAG = SharedBrowserDatabaseProvider.class.getSimpleName(); + + private static PerProfileDatabases<BrowserDatabaseHelper> databases; + + @Override + protected PerProfileDatabases<BrowserDatabaseHelper> getDatabases() { + return databases; + } + + @Override + public void shutdown() { + synchronized (SharedBrowserDatabaseProvider.class) { + databases.shutdown(); + databases = null; + } + } + + @Override + public boolean onCreate() { + // If necessary, do the shared DB work. + synchronized (SharedBrowserDatabaseProvider.class) { + if (databases != null) { + return true; + } + + final DatabaseHelperFactory<BrowserDatabaseHelper> helperFactory = new DatabaseHelperFactory<BrowserDatabaseHelper>() { + @Override + public BrowserDatabaseHelper makeDatabaseHelper(Context context, String databasePath) { + final BrowserDatabaseHelper helper = new BrowserDatabaseHelper(context, databasePath); + if (Versions.feature16Plus) { + helper.setWriteAheadLoggingEnabled(true); + } + return helper; + } + }; + + databases = new PerProfileDatabases<BrowserDatabaseHelper>(getContext(), BrowserDatabaseHelper.DATABASE_NAME, helperFactory); + } + + return true; + } + + /** + * Clean up some deleted records from the specified table. + * + * If called in an existing transaction, it is the caller's responsibility + * to ensure that the transaction is already upgraded to a writer, because + * this method issues a read followed by a write, and thus is potentially + * vulnerable to an unhandled SQLITE_BUSY failure during the upgrade. + * + * If not called in an existing transaction, no new explicit transaction + * will be begun. + */ + protected void cleanUpSomeDeletedRecords(Uri fromUri, String tableName) { + Log.d(LOGTAG, "Cleaning up deleted records from " + tableName); + + // We clean up records marked as deleted that are older than a + // predefined max age. It's important not be too greedy here and + // remove only a few old deleted records at a time. + + // we cleanup records marked as deleted that are older than a + // predefined max age. It's important not be too greedy here and + // remove only a few old deleted records at a time. + + // Maximum age of deleted records to be cleaned up (20 days in ms) + final long MAX_AGE_OF_DELETED_RECORDS = 86400000 * 20; + + // Number of records marked as deleted to be removed + final long DELETED_RECORDS_PURGE_LIMIT = 5; + + // Android SQLite doesn't have LIMIT on DELETE. Instead, query for the + // IDs of matching rows, then delete them in one go. + final long now = System.currentTimeMillis(); + final String selection = getDeletedItemSelection(now - MAX_AGE_OF_DELETED_RECORDS); + + final String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE); + final SQLiteDatabase db = getWritableDatabaseForProfile(profile, isTest(fromUri)); + final String limit = Long.toString(DELETED_RECORDS_PURGE_LIMIT, 10); + final Cursor cursor = db.query(tableName, new String[] { CommonColumns._ID }, selection, null, null, null, null, limit); + final String inClause; + try { + inClause = DBUtils.computeSQLInClauseFromLongs(cursor, CommonColumns._ID); + } finally { + cursor.close(); + } + + db.delete(tableName, inClause, null); + } + + // Override this, or override cleanUpSomeDeletedRecords. + protected String getDeletedItemSelection(long earlierThan) { + if (earlierThan == -1L) { + return SyncColumns.IS_DELETED + " = 1"; + } + return SyncColumns.IS_DELETED + " = 1 AND " + SyncColumns.DATE_MODIFIED + " <= " + earlierThan; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java b/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java new file mode 100644 index 000000000..89b12904b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java @@ -0,0 +1,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(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/Table.java b/mobile/android/base/java/org/mozilla/gecko/db/Table.java new file mode 100644 index 000000000..37a605ee1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/Table.java @@ -0,0 +1,47 @@ +/* -*- 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.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +// Tables provide a basic wrapper around ContentProvider methods to make it simpler to add new tables into storage. +// If you create a new Table type, make sure to add it to the sTables list in BrowserProvider to ensure it is queried. +interface Table { + // Provides information to BrowserProvider about the type of URIs this Table can handle. + public static class ContentProviderInfo { + public final int id; // A number of ID for this table. Used by the UriMatcher in BrowserProvider + public final String name; // A name for this table. Will be appended onto uris querying this table + // This is also used to define the mimetype of data returned from this db, i.e. + // BrowserProvider will return "vnd.android.cursor.item/" + name + + public ContentProviderInfo(int id, String name) { + if (name == null) { + throw new IllegalArgumentException("Content provider info must specify a name"); + } + this.id = id; + this.name = name; + } + } + + // Return a list of Info about the ContentProvider URIs this will match + ContentProviderInfo[] getContentProviderInfo(); + + // Called by BrowserDBHelper whenever the database is created or upgraded. + // Order in which tables are created/upgraded isn't guaranteed (yet), so be careful if your Table depends on something in a + // separate table. + void onCreate(SQLiteDatabase db); + void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion); + + // Called by BrowserProvider when this database queried/modified + // The dbId here should match the dbId's you returned in your getContentProviderInfo() call + Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] projection, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit); + int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs); + long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values); + int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs); +}; diff --git a/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java b/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java new file mode 100644 index 000000000..1be004ca7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java @@ -0,0 +1,28 @@ +/* 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.ContentResolver; +import android.content.Context; +import android.database.Cursor; + +import org.mozilla.gecko.Tab; + +import java.util.List; + +public interface TabsAccessor { + public interface OnQueryTabsCompleteListener { + public void onQueryTabsComplete(List<RemoteClient> clients); + } + + public Cursor getRemoteClientsByRecencyCursor(Context context); + public Cursor getRemoteTabsCursor(Context context); + public Cursor getRemoteTabsCursor(Context context, int limit); + public List<RemoteClient> getClientsWithoutTabsByRecencyFromCursor(final Cursor cursor); + public List<RemoteClient> getClientsFromCursor(final Cursor cursor); + public void getTabs(final Context context, final OnQueryTabsCompleteListener listener); + public void getTabs(final Context context, final int limit, final OnQueryTabsCompleteListener listener); + public void persistLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java new file mode 100644 index 000000000..09e4d9cf5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java @@ -0,0 +1,361 @@ +/* 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 java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.mozilla.gecko.db.BrowserContract.Clients; +import org.mozilla.gecko.db.BrowserContract.Tabs; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.text.TextUtils; + +public class TabsProvider extends SharedBrowserDatabaseProvider { + private static final long ONE_DAY_IN_MILLISECONDS = 1000 * 60 * 60 * 24; + private static final long ONE_WEEK_IN_MILLISECONDS = 7 * ONE_DAY_IN_MILLISECONDS; + private static final long THREE_WEEKS_IN_MILLISECONDS = 3 * ONE_WEEK_IN_MILLISECONDS; + + static final String TABLE_TABS = "tabs"; + static final String TABLE_CLIENTS = "clients"; + + static final int TABS = 600; + static final int TABS_ID = 601; + static final int CLIENTS = 602; + static final int CLIENTS_ID = 603; + static final int CLIENTS_RECENCY = 604; + + // Exclude clients that are more than three weeks old and also any duplicates that are older than one week old. + static final String EXCLUDE_STALE_CLIENTS_SUBQUERY = + "(SELECT " + Clients.GUID + + ", " + Clients.NAME + + ", " + Clients.LAST_MODIFIED + + ", " + Clients.DEVICE_TYPE + + " FROM " + TABLE_CLIENTS + + " WHERE " + Clients.LAST_MODIFIED + " > %1$s " + + " GROUP BY " + Clients.NAME + + " UNION ALL " + + " SELECT c." + Clients.GUID + " AS " + Clients.GUID + + ", c." + Clients.NAME + " AS " + Clients.NAME + + ", c." + Clients.LAST_MODIFIED + " AS " + Clients.LAST_MODIFIED + + ", c." + Clients.DEVICE_TYPE + " AS " + Clients.DEVICE_TYPE + + " FROM " + TABLE_CLIENTS + " AS c " + + " JOIN (" + + " SELECT " + Clients.GUID + + ", " + "MAX( " + Clients.LAST_MODIFIED + ") AS " + Clients.LAST_MODIFIED + + " FROM " + TABLE_CLIENTS + + " WHERE (" + Clients.LAST_MODIFIED + " < %1$s" + " AND " + Clients.LAST_MODIFIED + " > %2$s) AND " + + Clients.NAME + " NOT IN " + "( SELECT " + Clients.NAME + " FROM " + TABLE_CLIENTS + " WHERE " + Clients.LAST_MODIFIED + " > %1$s)" + + " GROUP BY " + Clients.NAME + + ") AS c2" + + " ON c." + Clients.GUID + " = c2." + Clients.GUID + ")"; + + static final String DEFAULT_TABS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC, " + Tabs.LAST_USED + " DESC"; + static final String DEFAULT_CLIENTS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC"; + static final String DEFAULT_CLIENTS_RECENCY_SORT_ORDER = "COALESCE(MAX(" + Tabs.LAST_USED + "), " + Clients.LAST_MODIFIED + ") DESC"; + + static final String INDEX_TABS_GUID = "tabs_guid_index"; + static final String INDEX_TABS_POSITION = "tabs_position_index"; + + static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + + static final Map<String, String> TABS_PROJECTION_MAP; + static final Map<String, String> CLIENTS_PROJECTION_MAP; + static final Map<String, String> CLIENTS_RECENCY_PROJECTION_MAP; + + static { + URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs", TABS); + URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs/#", TABS_ID); + URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients", CLIENTS); + URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients/#", CLIENTS_ID); + URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients_recency", CLIENTS_RECENCY); + + HashMap<String, String> map; + + map = new HashMap<String, String>(); + map.put(Tabs._ID, Tabs._ID); + map.put(Tabs.TITLE, Tabs.TITLE); + map.put(Tabs.URL, Tabs.URL); + map.put(Tabs.HISTORY, Tabs.HISTORY); + map.put(Tabs.FAVICON, Tabs.FAVICON); + map.put(Tabs.LAST_USED, Tabs.LAST_USED); + map.put(Tabs.POSITION, Tabs.POSITION); + map.put(Clients.GUID, Clients.GUID); + map.put(Clients.NAME, Clients.NAME); + map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED); + map.put(Clients.DEVICE_TYPE, Clients.DEVICE_TYPE); + TABS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + map = new HashMap<String, String>(); + map.put(Clients.GUID, Clients.GUID); + map.put(Clients.NAME, Clients.NAME); + map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED); + map.put(Clients.DEVICE_TYPE, Clients.DEVICE_TYPE); + CLIENTS_PROJECTION_MAP = Collections.unmodifiableMap(map); + + map = new HashMap<>(); + map.put(Clients.GUID, projectColumn(TABLE_CLIENTS, Clients.GUID) + " AS guid"); + map.put(Clients.NAME, projectColumn(TABLE_CLIENTS, Clients.NAME) + " AS name"); + map.put(Clients.LAST_MODIFIED, projectColumn(TABLE_CLIENTS, Clients.LAST_MODIFIED) + " AS last_modified"); + map.put(Clients.DEVICE_TYPE, projectColumn(TABLE_CLIENTS, Clients.DEVICE_TYPE) + " AS device_type"); + // last_used is the max of the tab last_used times, or if there are no tabs, + // the client's last_modified time. + map.put(Tabs.LAST_USED, "COALESCE(MAX(" + projectColumn(TABLE_TABS, Tabs.LAST_USED) + "), " + projectColumn(TABLE_CLIENTS, Clients.LAST_MODIFIED) + ") AS last_used"); + CLIENTS_RECENCY_PROJECTION_MAP = Collections.unmodifiableMap(map); + } + + private static final String projectColumn(String table, String column) { + return table + "." + column; + } + + private static final String selectColumn(String table, String column) { + return projectColumn(table, column) + " = ?"; + } + + @Override + public String getType(Uri uri) { + final int match = URI_MATCHER.match(uri); + + trace("Getting URI type: " + uri); + + switch (match) { + case TABS: + trace("URI is TABS: " + uri); + return Tabs.CONTENT_TYPE; + + case TABS_ID: + trace("URI is TABS_ID: " + uri); + return Tabs.CONTENT_ITEM_TYPE; + + case CLIENTS: + trace("URI is CLIENTS: " + uri); + return Clients.CONTENT_TYPE; + + case CLIENTS_ID: + trace("URI is CLIENTS_ID: " + uri); + return Clients.CONTENT_ITEM_TYPE; + } + + debug("URI has unrecognized type: " + uri); + + return null; + } + + @Override + @SuppressWarnings("fallthrough") + public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) { + trace("Calling delete in transaction on URI: " + uri); + + final int match = URI_MATCHER.match(uri); + int deleted = 0; + + switch (match) { + case CLIENTS_ID: + trace("Delete on CLIENTS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case CLIENTS: + trace("Delete on CLIENTS: " + uri); + deleted = deleteValues(uri, selection, selectionArgs, TABLE_CLIENTS); + break; + + case TABS_ID: + trace("Delete on TABS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case TABS: + trace("Deleting on TABS: " + uri); + deleted = deleteValues(uri, selection, selectionArgs, TABLE_TABS); + break; + + default: + throw new UnsupportedOperationException("Unknown delete URI " + uri); + } + + debug("Deleted " + deleted + " rows for URI: " + uri); + + return deleted; + } + + @Override + public Uri insertInTransaction(Uri uri, ContentValues values) { + trace("Calling insert in transaction on URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + int match = URI_MATCHER.match(uri); + long id = -1; + + switch (match) { + case CLIENTS: + String guid = values.getAsString(Clients.GUID); + debug("Inserting client in database with GUID: " + guid); + id = db.insertOrThrow(TABLE_CLIENTS, Clients.GUID, values); + break; + + case TABS: + String url = values.getAsString(Tabs.URL); + debug("Inserting tab in database with URL: " + url); + id = db.insertOrThrow(TABLE_TABS, Tabs.TITLE, values); + break; + + default: + throw new UnsupportedOperationException("Unknown insert URI " + uri); + } + + debug("Inserted ID in database: " + id); + + if (id >= 0) + return ContentUris.withAppendedId(uri, id); + + return null; + } + + @Override + public int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + trace("Calling update in transaction on URI: " + uri); + + int match = URI_MATCHER.match(uri); + int updated = 0; + + switch (match) { + case CLIENTS_ID: + trace("Update on CLIENTS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case CLIENTS: + trace("Update on CLIENTS: " + uri); + updated = updateValues(uri, values, selection, selectionArgs, TABLE_CLIENTS); + break; + + case TABS_ID: + trace("Update on TABS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case TABS: + trace("Update on TABS: " + uri); + updated = updateValues(uri, values, selection, selectionArgs, TABLE_TABS); + break; + + default: + throw new UnsupportedOperationException("Unknown update URI " + uri); + } + + debug("Updated " + updated + " rows for URI: " + uri); + + return updated; + } + + @Override + @SuppressWarnings("fallthrough") + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + SQLiteDatabase db = getReadableDatabase(uri); + final int match = URI_MATCHER.match(uri); + + String groupBy = null; + SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); + + switch (match) { + case TABS_ID: + trace("Query is on TABS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case TABS: + trace("Query is on TABS: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_TABS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(TABS_PROJECTION_MAP); + qb.setTables(TABLE_TABS + " LEFT OUTER JOIN " + TABLE_CLIENTS + " ON (" + TABLE_TABS + "." + Tabs.CLIENT_GUID + " = " + TABLE_CLIENTS + "." + Clients.GUID + ")"); + break; + + case CLIENTS_ID: + trace("Query is on CLIENTS_ID: " + uri); + selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID)); + selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, + new String[] { Long.toString(ContentUris.parseId(uri)) }); + // fall through + case CLIENTS: + trace("Query is on CLIENTS: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_CLIENTS_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + qb.setProjectionMap(CLIENTS_PROJECTION_MAP); + qb.setTables(TABLE_CLIENTS); + break; + + case CLIENTS_RECENCY: + trace("Query is on CLIENTS_RECENCY: " + uri); + if (TextUtils.isEmpty(sortOrder)) { + sortOrder = DEFAULT_CLIENTS_RECENCY_SORT_ORDER; + } else { + debug("Using sort order " + sortOrder + "."); + } + + final long oneWeekAgo = System.currentTimeMillis() - ONE_WEEK_IN_MILLISECONDS; + final long threeWeeksAgo = System.currentTimeMillis() - THREE_WEEKS_IN_MILLISECONDS; + + final String excludeStaleClientsTable = String.format(EXCLUDE_STALE_CLIENTS_SUBQUERY, oneWeekAgo, threeWeeksAgo); + + qb.setProjectionMap(CLIENTS_RECENCY_PROJECTION_MAP); + + // Use a subquery to quietly exclude stale duplicate client records. + qb.setTables(excludeStaleClientsTable + " AS " + TABLE_CLIENTS + " LEFT OUTER JOIN " + TABLE_TABS + + " ON (" + projectColumn(TABLE_CLIENTS, Clients.GUID) + + " = " + projectColumn(TABLE_TABS, Tabs.CLIENT_GUID) + ")"); + groupBy = projectColumn(TABLE_CLIENTS, Clients.GUID); + break; + + default: + throw new UnsupportedOperationException("Unknown query URI " + uri); + } + + trace("Running built query."); + final Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit); + cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.TABS_AUTHORITY_URI); + + return cursor; + } + + int updateValues(Uri uri, ContentValues values, String selection, String[] selectionArgs, String table) { + trace("Updating tabs on URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.update(table, values, selection, selectionArgs); + } + + int deleteValues(Uri uri, String selection, String[] selectionArgs, String table) { + debug("Deleting tabs for URI: " + uri); + + final SQLiteDatabase db = getWritableDatabase(uri); + beginWrite(db); + return db.delete(table, selection, selectionArgs); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java new file mode 100644 index 000000000..7973839e2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java @@ -0,0 +1,25 @@ +/* -*- 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 java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.json.JSONObject; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.content.ContentResolver; + +@RobocopTarget +public interface URLMetadata { + public Map<String, Object> fromJSON(JSONObject obj); + public Map<String, Map<String, Object>> getForURLs(final ContentResolver cr, + final Collection<String> urls, + final List<String> columns); + public void save(final ContentResolver cr, final Map<String, Object> data); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java new file mode 100644 index 000000000..49bbb74e7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java @@ -0,0 +1,92 @@ +/* -*- 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 org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.BrowserContract.History; + +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +// Holds metadata info about urls. Supports some helper functions for getting back a HashMap of key value data. +public class URLMetadataTable extends BaseTable { + private static final String LOGTAG = "GeckoURLMetadataTable"; + + private static final String TABLE = "metadata"; // Name of the table in the db + private static final int TABLE_ID_NUMBER = BrowserProvider.METADATA; + + // Uri for querying this table + public static final Uri CONTENT_URI = Uri.withAppendedPath(BrowserContract.AUTHORITY_URI, "metadata"); + + // Columns in the table + public static final String ID_COLUMN = "id"; + public static final String URL_COLUMN = "url"; + public static final String TILE_IMAGE_URL_COLUMN = "tileImage"; + public static final String TILE_COLOR_COLUMN = "tileColor"; + public static final String TOUCH_ICON_COLUMN = "touchIcon"; + + URLMetadataTable() { } + + @Override + protected String getTable() { + return TABLE; + } + + @Override + public void onCreate(SQLiteDatabase db) { + String create = "CREATE TABLE " + TABLE + " (" + + ID_COLUMN + " INTEGER PRIMARY KEY, " + + URL_COLUMN + " TEXT NON NULL UNIQUE, " + + TILE_IMAGE_URL_COLUMN + " STRING, " + + TILE_COLOR_COLUMN + " STRING, " + + TOUCH_ICON_COLUMN + " STRING);"; + db.execSQL(create); + } + + private void upgradeDatabaseFrom26To27(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE + + " ADD COLUMN " + TOUCH_ICON_COLUMN + " STRING"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This table was added in v21 of the db. Force its creation if we're coming from an earlier version + if (newVersion >= 21 && oldVersion < 21) { + onCreate(db); + return; + } + + // Removed the redundant metadata_url_idx index in version 26 + if (newVersion >= 26 && oldVersion < 26) { + db.execSQL("DROP INDEX IF EXISTS metadata_url_idx"); + } + if (newVersion >= 27 && oldVersion < 27) { + upgradeDatabaseFrom26To27(db); + } + } + + @Override + public Table.ContentProviderInfo[] getContentProviderInfo() { + return new Table.ContentProviderInfo[] { + new Table.ContentProviderInfo(TABLE_ID_NUMBER, TABLE) + }; + } + + public int deleteUnused(final SQLiteDatabase db) { + final String selection = URL_COLUMN + " NOT IN " + + "(SELECT " + History.URL + + " FROM " + History.TABLE_NAME + + " WHERE " + History.IS_DELETED + " = 0" + + " UNION " + + " SELECT " + Bookmarks.URL + + " FROM " + Bookmarks.TABLE_NAME + + " WHERE " + Bookmarks.IS_DELETED + " = 0 " + + " AND " + Bookmarks.URL + " IS NOT NULL)"; + + return db.delete(getTable(), selection, null); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java new file mode 100644 index 000000000..dcae6ee79 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java @@ -0,0 +1,51 @@ +/* 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.ContentResolver; +import android.database.Cursor; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.feeds.subscriptions.FeedSubscription; + +public interface UrlAnnotations { + @RobocopTarget void insertAnnotation(ContentResolver cr, String url, String key, String value); + + Cursor getScreenshots(ContentResolver cr); + void insertScreenshot(ContentResolver cr, String pageUrl, String screenshotPath); + + Cursor getFeedSubscriptions(ContentResolver cr); + Cursor getWebsitesWithFeedUrl(ContentResolver cr); + void deleteFeedUrl(ContentResolver cr, String websiteUrl); + boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl); + void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription); + void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription); + boolean hasFeedSubscription(ContentResolver cr, String feedUrl); + void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription); + boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl); + void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl); + + void insertReaderViewUrl(ContentResolver cr, String pageURL); + void deleteReaderViewUrl(ContentResolver cr, String pageURL); + + /** + * Did the user ever interact with this URL in regards to home screen shortcuts? + * + * @return true if the user has created a home screen shortcut or declined to create one in the + * past. This method will still return true if the shortcut has been removed from the + * home screen by the user. + */ + boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url); + + /** + * Insert an indication that the user has interacted with this URL in regards to home screen + * shortcuts. + * + * @param hasCreatedShortCut True if a home screen shortcut has been created for this URL. False + * if the user has actively declined to create a shortcut for this URL. + */ + void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut); + + int getAnnotationCount(ContentResolver cr, BrowserContract.UrlAnnotations.Key key); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java new file mode 100644 index 000000000..a1c54d3c3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java @@ -0,0 +1,237 @@ +/* -*- 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.delegates; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.util.Log; +import android.view.View; +import android.widget.ListView; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.EditBookmarkDialog; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.home.HomeConfig; +import org.mozilla.gecko.promotion.SimpleHelperUI; +import org.mozilla.gecko.prompts.Prompt; +import org.mozilla.gecko.prompts.PromptListItem; +import org.mozilla.gecko.util.DrawableUtil; +import org.mozilla.gecko.util.ThreadUtils; + +import java.lang.ref.WeakReference; + +/** + * Delegate to watch for bookmark state changes. + * + * This is responsible for showing snackbars and helper UIs related to the addition/removal + * of bookmarks, or reader view bookmarks. + */ +public class BookmarkStateChangeDelegate extends BrowserAppDelegateWithReference implements Tabs.OnTabsChangedListener { + private static final String LOGTAG = "BookmarkDelegate"; + + @Override + public void onResume(BrowserApp browserApp) { + Tabs.registerOnTabsChangedListener(this); + } + + @Override + public void onPause(BrowserApp browserApp) { + Tabs.unregisterOnTabsChangedListener(this); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case BOOKMARK_ADDED: + // We always show the special offline snackbar whenever we bookmark a reader page. + // It's possible that the page is already stored offline, however this is highly + // unlikely, and even so it is probably nicer to show the same offline notification + // every time we bookmark an about:reader page. + if (!AboutPages.isAboutReader(tab.getURL())) { + showBookmarkAddedSnackbar(); + } else { + if (!promoteReaderViewBookmarkAdded()) { + showReaderModeBookmarkAddedSnackbar(); + } + } + break; + + case BOOKMARK_REMOVED: + showBookmarkRemovedSnackbar(); + break; + } + } + + @Override + public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) { + if (requestCode == BrowserApp.ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK) { + if (resultCode == BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS) { + browserApp.openUrlAndStopEditing("about:home?panel=" + HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS)); + } else if (resultCode == BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE) { + showReaderModeBookmarkAddedSnackbar(); + } + } + } + + private boolean promoteReaderViewBookmarkAdded() { + final BrowserApp browserApp = getBrowserApp(); + if (browserApp == null) { + return false; + } + + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(browserApp); + + final boolean hasFirstReaderViewPromptBeenShownBefore = prefs.getBoolean(SimpleHelperUI.PREF_FIRST_RVBP_SHOWN, false); + + if (hasFirstReaderViewPromptBeenShownBefore) { + return false; + } + + SimpleHelperUI.show(browserApp, + SimpleHelperUI.FIRST_RVBP_SHOWN_TELEMETRYEXTRA, + BrowserApp.ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK, + R.string.helper_first_offline_bookmark_title, R.string.helper_first_offline_bookmark_message, + R.drawable.helper_readerview_bookmark, R.string.helper_first_offline_bookmark_button, + BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS, + BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE); + + GeckoSharedPrefs.forProfile(browserApp) + .edit() + .putBoolean(SimpleHelperUI.PREF_FIRST_RVBP_SHOWN, true) + .apply(); + + return true; + } + + private void showBookmarkAddedSnackbar() { + final BrowserApp browserApp = getBrowserApp(); + if (browserApp == null) { + return; + } + + // This flow is from the option menu which has check to see if a bookmark was already added. + // So, it is safe here to show the snackbar that bookmark_added without any checks. + final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.TOAST, "bookmark_options"); + showBookmarkDialog(browserApp); + } + }; + + SnackbarBuilder.builder(browserApp) + .message(R.string.bookmark_added) + .duration(Snackbar.LENGTH_LONG) + .action(R.string.bookmark_options) + .callback(callback) + .buildAndShow(); + } + + private void showBookmarkRemovedSnackbar() { + final BrowserApp browserApp = getBrowserApp(); + if (browserApp == null) { + return; + } + + SnackbarBuilder.builder(browserApp) + .message(R.string.bookmark_removed) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + + private static void showBookmarkDialog(final BrowserApp browserApp) { + final Resources res = browserApp.getResources(); + final Tab tab = Tabs.getInstance().getSelectedTab(); + + final Prompt ps = new Prompt(browserApp, new Prompt.PromptCallback() { + @Override + public void onPromptFinished(String result) { + int itemId = -1; + try { + itemId = new JSONObject(result).getInt("button"); + } catch (JSONException ex) { + Log.e(LOGTAG, "Exception reading bookmark prompt result", ex); + } + + if (tab == null) { + return; + } + + if (itemId == 0) { + final String extrasId = res.getResourceEntryName(R.string.contextmenu_edit_bookmark); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, + TelemetryContract.Method.DIALOG, extrasId); + + new EditBookmarkDialog(browserApp).show(tab.getURL()); + } else if (itemId == 1) { + final String extrasId = res.getResourceEntryName(R.string.contextmenu_add_to_launcher); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, + TelemetryContract.Method.DIALOG, extrasId); + + final String url = tab.getURL(); + final String title = tab.getDisplayTitle(); + + if (url != null && title != null) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GeckoAppShell.createShortcut(title, url); + } + }); + } + } + } + }); + + final PromptListItem[] items = new PromptListItem[2]; + items[0] = new PromptListItem(res.getString(R.string.contextmenu_edit_bookmark)); + items[1] = new PromptListItem(res.getString(R.string.contextmenu_add_to_launcher)); + + ps.show("", "", items, ListView.CHOICE_MODE_NONE); + } + + private void showReaderModeBookmarkAddedSnackbar() { + final BrowserApp browserApp = getBrowserApp(); + if (browserApp == null) { + return; + } + + final Drawable iconDownloaded = DrawableUtil.tintDrawable(browserApp, R.drawable.status_icon_readercache, Color.WHITE); + + final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() { + @Override + public void onClick(View v) { + browserApp.openUrlAndStopEditing("about:home?panel=" + HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS)); + } + }; + + SnackbarBuilder.builder(browserApp) + .message(R.string.reader_saved_offline) + .duration(Snackbar.LENGTH_LONG) + .action(R.string.reader_switch_to_bookmarks) + .callback(callback) + .icon(iconDownloaded) + .backgroundColor(ContextCompat.getColor(browserApp, R.color.link_blue)) + .actionColor(Color.WHITE) + .buildAndShow(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java new file mode 100644 index 000000000..70b134992 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java @@ -0,0 +1,78 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.delegates; + +import android.content.Intent; +import android.os.Bundle; + +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.tabs.TabsPanel; + +/** + * Abstract class for extending the behavior of BrowserApp without adding additional code to the + * already huge class. + */ +public abstract class BrowserAppDelegate { + /** + * Called when the BrowserApp activity is first created. + */ + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {} + + /** + * Called after the BrowserApp activity has been stopped, prior to it being started again. + */ + public void onRestart(BrowserApp browserApp) {} + + /** + * Called when the BrowserApp activity is becoming visible to the user. + */ + public void onStart(BrowserApp browserApp) {} + + /** + * Called when the BrowserApp activity will start interacting with the user. + */ + public void onResume(BrowserApp browserApp) {} + + /** + * Called when the system is about to start resuming a previous activity. + */ + public void onPause(BrowserApp browserApp) {} + + /** + * Called when BrowserApp activity is no longer visible to the user. + */ + public void onStop(BrowserApp browserApp) {} + + /** + * The final call before the BrowserApp activity is destroyed. + */ + public void onDestroy(BrowserApp browserApp) {} + + /** + * Called when BrowserApp already exists and a new Intent to re-launch it was fired. + */ + public void onNewIntent(BrowserApp browserApp, SafeIntent intent) {} + + /** + * Called when the tabs tray is opened. + */ + public void onTabsTrayShown(BrowserApp browserApp, TabsPanel tabsPanel) {} + + /** + * Called when the tabs tray is closed. + */ + public void onTabsTrayHidden(BrowserApp browserApp, TabsPanel tabsPanel) {} + + /** + * Called when an activity started using startActivityForResult() returns. + * + * Delegates should only use request and result codes declared in BrowserApp itself (as opposed + * to declarations in the delegate), in order to avoid conflicts. + */ + public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) {} +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java new file mode 100644 index 000000000..c67b8a18a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java @@ -0,0 +1,29 @@ +package org.mozilla.gecko.delegates; + +import android.os.Bundle; +import android.support.annotation.CallSuper; + +import org.mozilla.gecko.BrowserApp; + +import java.lang.ref.WeakReference; + +/** + * BrowserAppDelegate that stores a reference to the parent BrowserApp. + */ +public abstract class BrowserAppDelegateWithReference extends BrowserAppDelegate { + private WeakReference<BrowserApp> browserApp; + + @Override + @CallSuper + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + this.browserApp = new WeakReference<>(browserApp); + } + + /** + * Obtain the referenced BrowserApp. May return <code>null</code> if the BrowserApp no longer + * exists. + */ + protected BrowserApp getBrowserApp() { + return browserApp.get(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java new file mode 100644 index 000000000..5f3aa9c59 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java @@ -0,0 +1,119 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.delegates; + +import android.app.Activity; +import android.os.Bundle; +import android.support.annotation.CallSuper; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.WeakHashMap; + +/** + * Displays "Showing offline version" message when tabs are loaded from cache while offline. + */ +public class OfflineTabStatusDelegate extends TabsTrayVisibilityAwareDelegate implements Tabs.OnTabsChangedListener { + private WeakReference<Activity> activityReference; + private WeakHashMap<Tab, Void> tabsQueuedForOfflineSnackbar = new WeakHashMap<>(); + + @CallSuper + @Override + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + super.onCreate(browserApp, savedInstanceState); + activityReference = new WeakReference<Activity>(browserApp); + } + + @Override + public void onResume(BrowserApp browserApp) { + Tabs.registerOnTabsChangedListener(this); + } + + @Override + public void onPause(BrowserApp browserApp) { + Tabs.unregisterOnTabsChangedListener(this); + } + + public void onTabChanged(final Tab tab, Tabs.TabEvents event, String data) { + if (tab == null) { + return; + } + + // Ignore tabs loaded regularly. + if (!tab.hasLoadedFromCache()) { + return; + } + + // Ignore tabs displaying about pages + if (AboutPages.isAboutPage(tab.getURL())) { + return; + } + + // We only want to show these notifications for tabs that were loaded successfully. + if (tab.getState() != Tab.STATE_SUCCESS) { + return; + } + + switch (event) { + // We listen specifically for the STOP event (as opposed to PAGE_SHOW), because we need + // to know if page load actually succeeded. When STOP is triggered, tab.getState() + // will return definitive STATE_SUCCESS or STATE_ERROR. When PAGE_SHOW is triggered, + // tab.getState() will return STATE_LOADING, which is ambiguous for our purposes. + // We don't want to show these notifications for 404 pages, for example. See Bug 1304914. + case STOP: + // Show offline notification if tab is visible, or queue it for display later. + if (!isTabsTrayVisible() && Tabs.getInstance().isSelectedTab(tab)) { + showLoadedOfflineSnackbar(activityReference.get()); + } else { + tabsQueuedForOfflineSnackbar.put(tab, null); + } + break; + // Fallthrough; see Bug 1278980 for details on why this event is here. + case OPENED_FROM_TABS_TRAY: + // When tab is selected and offline notification was queued, display it if possible. + // SELECTED event might also fire when we're on a TabStrip, so check first. + case SELECTED: + if (isTabsTrayVisible()) { + break; + } + if (tabsQueuedForOfflineSnackbar.containsKey(tab)) { + showLoadedOfflineSnackbar(activityReference.get()); + tabsQueuedForOfflineSnackbar.remove(tab); + } + break; + } + } + + /** + * Displays the notification snackbar and logs a telemetry event. + * + * @param activity which will be used for displaying the snackbar. + */ + private static void showLoadedOfflineSnackbar(final Activity activity) { + if (activity == null) { + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.NETERROR, TelemetryContract.Method.TOAST, "usecache"); + + SnackbarBuilder.builder(activity) + .message(R.string.tab_offline_version) + .duration(Snackbar.LENGTH_INDEFINITE) + .backgroundColor(ContextCompat.getColor(activity, R.color.link_blue)) + .buildAndShow(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java new file mode 100644 index 000000000..f048372f7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java @@ -0,0 +1,80 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.delegates; + +import android.app.Activity; +import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.ScreenshotObserver; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserDB; + +import java.lang.ref.WeakReference; + +/** + * Delegate for observing screenshots being taken. + */ +public class ScreenshotDelegate extends BrowserAppDelegateWithReference implements ScreenshotObserver.OnScreenshotListener { + private static final String LOGTAG = "GeckoScreenshotDelegate"; + + private final ScreenshotObserver mScreenshotObserver = new ScreenshotObserver(); + + @Override + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + super.onCreate(browserApp, savedInstanceState); + + mScreenshotObserver.setListener(browserApp, this); + } + + @Override + public void onScreenshotTaken(String screenshotPath, String title) { + // Treat screenshots as a sharing method. + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.BUTTON, "screenshot"); + + if (!AppConstants.SCREENSHOTS_IN_BOOKMARKS_ENABLED) { + return; + } + + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab == null) { + Log.w(LOGTAG, "Selected tab is null: could not page info to store screenshot."); + return; + } + + final Activity activity = getBrowserApp(); + if (activity == null) { + return; + } + + BrowserDB.from(activity).getUrlAnnotations().insertScreenshot( + activity.getContentResolver(), selectedTab.getURL(), screenshotPath); + + SnackbarBuilder.builder(activity) + .message(R.string.screenshot_added_to_bookmarks) + .duration(Snackbar.LENGTH_SHORT) + .buildAndShow(); + } + + @Override + public void onResume(BrowserApp browserApp) { + mScreenshotObserver.start(); + } + + @Override + public void onPause(BrowserApp browserApp) { + mScreenshotObserver.stop(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java new file mode 100644 index 000000000..ebd3991ea --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.delegates; + +import android.os.Bundle; +import android.support.annotation.CallSuper; + +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.tabs.TabsPanel; + +public abstract class TabsTrayVisibilityAwareDelegate extends BrowserAppDelegate { + private boolean tabsTrayVisible; + + @Override + @CallSuper + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + tabsTrayVisible = false; + } + + @Override + @CallSuper + public void onTabsTrayShown(BrowserApp browserApp, TabsPanel tabsPanel) { + tabsTrayVisible = true; + } + + @Override + @CallSuper + public void onTabsTrayHidden(BrowserApp browserApp, TabsPanel tabsPanel) { + tabsTrayVisible = false; + } + + protected boolean isTabsTrayVisible() { + return tabsTrayVisible; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java b/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java new file mode 100644 index 000000000..a7b0fe32d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java @@ -0,0 +1,1046 @@ +/* -*- 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.distribution; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import javax.net.ssl.SSLException; + +import ch.boye.httpclientandroidlib.protocol.HTTP; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.util.FileUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.SystemClock; +import android.support.annotation.WorkerThread; +import android.telephony.TelephonyManager; +import android.util.Log; + +/** + * Handles distribution file loading and fetching, + * and the corresponding hand-offs to Gecko. + */ +@RobocopTarget +public class Distribution { + private static final String LOGTAG = "GeckoDistribution"; + + private static final int STATE_UNKNOWN = 0; + private static final int STATE_NONE = 1; + private static final int STATE_SET = 2; + + private static final String FETCH_PROTOCOL = "https"; + private static final String FETCH_HOSTNAME = "mobile.cdn.mozilla.net"; + private static final String FETCH_PATH = "/distributions/1/"; + private static final String FETCH_EXTENSION = ".jar"; + + private static final String EXPECTED_CONTENT_TYPE = "application/java-archive"; + + private static final String DISTRIBUTION_PATH = "distribution/"; + + /** + * Telemetry constants. + */ + private static final String HISTOGRAM_REFERRER_INVALID = "FENNEC_DISTRIBUTION_REFERRER_INVALID"; + private static final String HISTOGRAM_DOWNLOAD_TIME_MS = "FENNEC_DISTRIBUTION_DOWNLOAD_TIME_MS"; + private static final String HISTOGRAM_CODE_CATEGORY = "FENNEC_DISTRIBUTION_CODE_CATEGORY"; + + /** + * Success/failure codes. Don't exceed the maximum listed in Histograms.json. + */ + private static final int CODE_CATEGORY_STATUS_OUT_OF_RANGE = 0; + // HTTP status 'codes' run from 1 to 5. + private static final int CODE_CATEGORY_OFFLINE = 6; + private static final int CODE_CATEGORY_FETCH_EXCEPTION = 7; + + // It's a post-fetch exception if we were able to download, but not + // able to extract. + private static final int CODE_CATEGORY_POST_FETCH_EXCEPTION = 8; + private static final int CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION = 9; + + // It's a malformed distribution if we could extract, but couldn't + // process the contents. + private static final int CODE_CATEGORY_MALFORMED_DISTRIBUTION = 10; + + // Specific fetch errors. + private static final int CODE_CATEGORY_FETCH_SOCKET_ERROR = 11; + private static final int CODE_CATEGORY_FETCH_SSL_ERROR = 12; + private static final int CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE = 13; + private static final int CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE = 14; + + // Corresponds to the high value in Histograms.json. + private static final long MAX_DOWNLOAD_TIME_MSEC = 40000; // 40 seconds. + + // If this is true, ready callbacks that arrive after our state is initially determined + // will be queued for delayed running. + // This should only be the case on first run, when we're in STATE_NONE. + // Implicitly accessed from any non-UI threads via Distribution.doInit, but in practice only one + // will actually perform initialization, and "non-UI thread" really means "background thread". + private volatile boolean shouldDelayLateCallbacks = false; + + /** + * These tasks can be queued to run when a distribution is available. + * + * If <code>distributionFound</code> is called, it will be the only call. + * If <code>distributionNotFound</code> is called, it might be followed by + * a call to <code>distributionArrivedLate</code>. + * + * When <code>distributionNotFound</code> is called, + * {@link org.mozilla.gecko.distribution.Distribution#exists()} will return + * false. In the other two callbacks, it will return true. + */ + public interface ReadyCallback { + @WorkerThread + void distributionNotFound(); + + @WorkerThread + void distributionFound(Distribution distribution); + + @WorkerThread + void distributionArrivedLate(Distribution distribution); + } + + /** + * Used as a drop-off point for ReferrerReceiver. Checked when we process + * first-run distribution. + * + * This is `protected` so that test code can clear it between runs. + */ + @RobocopTarget + protected static volatile ReferrerDescriptor referrer; + + private static Distribution instance; + + private final Context context; + private final String packagePath; + private final String prefsBranch; + + volatile int state = STATE_UNKNOWN; + private File distributionDir; + + private final Queue<ReadyCallback> onDistributionReady = new ConcurrentLinkedQueue<>(); + + // Callbacks in this queue have been invoked once as distributionNotFound. + // If they're invoked again, it'll be with distributionArrivedLate. + private final Queue<ReadyCallback> onLateReady = new ConcurrentLinkedQueue<>(); + + /** + * This is a little bit of a bad singleton, because in principle a Distribution + * can be created with arbitrary paths. So we only have one path to get here, and + * it uses the default arguments. Watch out if you're creating your own instances! + */ + public static synchronized Distribution getInstance(Context context) { + if (instance == null) { + instance = new Distribution(context); + } + return instance; + } + + @RobocopTarget + public static class DistributionDescriptor { + public final boolean valid; + public final String id; + public final String version; // Example uses a float, but that's a crazy idea. + + // Default UI-visible description of the distribution. + public final String about; + + // Each distribution file can include multiple localized versions of + // the 'about' string. These are represented as, e.g., "about.en-US" + // keys in the Global object. + // Here we map locale to description. + public final Map<String, String> localizedAbout; + + @SuppressWarnings("unchecked") + public DistributionDescriptor(JSONObject obj) { + this.id = obj.optString("id"); + this.version = obj.optString("version"); + this.about = obj.optString("about"); + Map<String, String> loc = new HashMap<String, String>(); + try { + Iterator<String> keys = obj.keys(); + while (keys.hasNext()) { + String key = keys.next(); + if (key.startsWith("about.")) { + String locale = key.substring(6); + if (!obj.isNull(locale)) { + loc.put(locale, obj.getString(key)); + } + } + } + } catch (JSONException ex) { + Log.w(LOGTAG, "Unable to completely process distribution JSON.", ex); + } + + this.localizedAbout = Collections.unmodifiableMap(loc); + this.valid = (null != this.id) && + (null != this.version) && + (null != this.about); + } + } + + private static Distribution init(final Distribution distribution) { + // Read/write preferences and files on the background thread. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + boolean distributionSet = distribution.doInit(); + if (distributionSet) { + String preferencesJSON = ""; + try { + final File descFile = distribution.getDistributionFile("preferences.json"); + preferencesJSON = FileUtils.readStringFromFile(descFile); + } catch (IOException e) { + Log.e(LOGTAG, "Error getting distribution descriptor file.", e); + } + GeckoAppShell.notifyObservers("Distribution:Set", preferencesJSON); + } + } + }); + + return distribution; + } + + /** + * Initializes distribution if it hasn't already been initialized. Sends + * messages to Gecko as appropriate. + * + * @param packagePath where to look for the distribution directory. + */ + @RobocopTarget + public static Distribution init(final Context context, final String packagePath, final String prefsPath) { + return init(new Distribution(context, packagePath, prefsPath)); + } + + /** + * Use <code>Context.getPackageResourcePath</code> to find an implicit + * package path. Reuses the existing Distribution if one exists. + */ + @RobocopTarget + public static Distribution init(final Context context) { + return init(Distribution.getInstance(context)); + } + + /** + * Returns parsed contents of bookmarks.json. + * This method should only be called from a background thread. + */ + public static JSONArray getBookmarks(final Context context) { + Distribution dist = new Distribution(context); + return dist.getBookmarks(); + } + + /** + * @param packagePath where to look for the distribution directory. + */ + public Distribution(final Context context, final String packagePath, final String prefsBranch) { + this.context = context; + this.packagePath = packagePath; + this.prefsBranch = prefsBranch; + } + + public Distribution(final Context context) { + this(context, context.getPackageResourcePath(), null); + } + + /** + * This method is called by ReferrerReceiver when we receive a post-install + * notification from Google Play. + * + * @param ref a parsed referrer value from the store-supplied intent. + */ + public static void onReceivedReferrer(final Context context, final ReferrerDescriptor ref) { + // Track the referrer object for distribution handling. + referrer = ref; + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final Distribution distribution = Distribution.getInstance(context); + + // This will bail if we aren't delayed, or we already have a distribution. + distribution.processDelayedReferrer(ref); + + // On Android 5+ we might receive the referrer intent + // and never actually launch the browser, which is the usual signal + // for the distribution init process to complete. + // Attempt to init here to handle that case. + // Profile setup that relies on the distribution will occur + // when the browser is eventually launched, via `addOnDistributionReadyCallback`. + distribution.doInit(); + } + }); + } + + /** + * Handle a referrer intent that arrives after first use of the distribution. + */ + private void processDelayedReferrer(final ReferrerDescriptor ref) { + ThreadUtils.assertOnBackgroundThread(); + if (state != STATE_NONE) { + return; + } + + Log.i(LOGTAG, "Processing delayed referrer."); + + if (!checkIntentDistribution(ref)) { + // Oh well. No sense keeping these tasks around. + this.onLateReady.clear(); + return; + } + + // Persist our new state. + this.state = STATE_SET; + getSharedPreferences().edit().putInt(getKeyName(), this.state).apply(); + + // Just in case this isn't empty but doInit has finished. + runReadyQueue(); + + // Now process any tasks that already ran while we were in STATE_NONE + // to tell them of our good news. + runLateReadyQueue(); + + // Make sure that changes to search defaults are applied immediately. + GeckoAppShell.notifyObservers("Distribution:Changed", ""); + } + + /** + * Helper to grab a file in the distribution directory. + * + * Returns null if there is no distribution directory or the file + * doesn't exist. Ensures init first. + */ + public File getDistributionFile(String name) { + Log.d(LOGTAG, "Getting file from distribution."); + + if (this.state == STATE_UNKNOWN) { + if (!this.doInit()) { + return null; + } + } + + File dist = ensureDistributionDir(); + if (dist == null) { + return null; + } + + File descFile = new File(dist, name); + if (!descFile.exists()) { + Log.e(LOGTAG, "Distribution directory exists, but no file named " + name); + return null; + } + + return descFile; + } + + public DistributionDescriptor getDescriptor() { + File descFile = getDistributionFile("preferences.json"); + if (descFile == null) { + // Logging and existence checks are handled in getDistributionFile. + return null; + } + + try { + JSONObject all = FileUtils.readJSONObjectFromFile(descFile); + + if (!all.has("Global")) { + Log.e(LOGTAG, "Distribution preferences.json has no Global entry!"); + return null; + } + + return new DistributionDescriptor(all.getJSONObject("Global")); + + } catch (IOException e) { + Log.e(LOGTAG, "Error getting distribution descriptor file.", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return null; + } catch (JSONException e) { + Log.e(LOGTAG, "Error parsing preferences.json", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return null; + } + } + + /** + * Get the Android preferences from the preferences.json file, if any exist. + * @return The preferences in a JSONObject, or an empty JSONObject if no preferences are defined. + */ + public JSONObject getAndroidPreferences() { + final File descFile = getDistributionFile("preferences.json"); + if (descFile == null) { + // Logging and existence checks are handled in getDistributionFile. + return new JSONObject(); + } + + try { + final JSONObject all = FileUtils.readJSONObjectFromFile(descFile); + + if (!all.has("AndroidPreferences")) { + return new JSONObject(); + } + + return all.getJSONObject("AndroidPreferences"); + + } catch (IOException e) { + Log.e(LOGTAG, "Error getting distribution descriptor file.", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return new JSONObject(); + } catch (JSONException e) { + Log.e(LOGTAG, "Error parsing preferences.json", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return new JSONObject(); + } + } + + public JSONArray getBookmarks() { + File bookmarks = getDistributionFile("bookmarks.json"); + if (bookmarks == null) { + // Logging and existence checks are handled in getDistributionFile. + return null; + } + + try { + return new JSONArray(FileUtils.readStringFromFile(bookmarks)); + } catch (IOException e) { + Log.e(LOGTAG, "Error getting bookmarks", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return null; + } catch (JSONException e) { + Log.e(LOGTAG, "Error parsing bookmarks.json", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); + return null; + } + } + + /** + * Don't call from the main thread. + * + * Postcondition: if this returns true, distributionDir will have been + * set and populated. + * + * This method is *only* protected for use from testDistribution. + * + * @return true if we've set a distribution. + */ + @RobocopTarget + protected boolean doInit() { + ThreadUtils.assertNotOnUiThread(); + + // Bail if we've already tried to initialize the distribution, and + // there wasn't one. + final SharedPreferences settings = getSharedPreferences(); + + final String keyName = getKeyName(); + this.state = settings.getInt(keyName, STATE_UNKNOWN); + + if (this.state == STATE_NONE) { + runReadyQueue(); + return false; + } + + // We've done the work once; don't do it again. + if (this.state == STATE_SET) { + // Note that we don't compute the distribution directory. + // Call `ensureDistributionDir` if you need it. + runReadyQueue(); + return true; + } + + // We try to find the install intent, then the APK, then the system directory, and finally + // an already copied distribution. Already copied might originate from the bouncer APK. + final boolean distributionSet = + checkIntentDistribution(referrer) || + copyAndCheckAPKDistribution() || + checkSystemDistribution() || + checkDataDistribution(); + + // If this is our first run -- and thus we weren't already in STATE_NONE or STATE_SET above -- + // and we didn't find a distribution already, then we should hold on to callbacks in case we + // get a late distribution. + this.shouldDelayLateCallbacks = !distributionSet; + this.state = distributionSet ? STATE_SET : STATE_NONE; + settings.edit().putInt(keyName, this.state).apply(); + + runReadyQueue(); + return distributionSet; + } + + /** + * If applicable, download and select the distribution specified in + * the referrer intent. + * + * @return true if a referrer-supplied distribution was selected. + */ + private boolean checkIntentDistribution(final ReferrerDescriptor referrer) { + if (referrer == null) { + return false; + } + + URI uri = getReferredDistribution(referrer); + if (uri == null) { + return false; + } + + long start = SystemClock.uptimeMillis(); + Log.v(LOGTAG, "Downloading referred distribution: " + uri); + + try { + final HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + + // If the Search Activity starts, and we handle the referrer intent, this'll return + // null. Recover gracefully in this case. + final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface(); + final String ua; + if (geckoInterface == null) { + // Fall back to GeckoApp's default implementation. + ua = HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET : + AppConstants.USER_AGENT_FENNEC_MOBILE; + } else { + ua = geckoInterface.getDefaultUAString(); + } + + connection.setRequestProperty(HTTP.USER_AGENT, ua); + connection.setRequestProperty("Accept", EXPECTED_CONTENT_TYPE); + + try { + final JarInputStream distro; + try { + distro = fetchDistribution(uri, connection); + } catch (Exception e) { + Log.e(LOGTAG, "Error fetching distribution from network.", e); + recordFetchTelemetry(e); + return false; + } + + long end = SystemClock.uptimeMillis(); + final long duration = end - start; + Log.d(LOGTAG, "Distro fetch took " + duration + "ms; result? " + (distro != null)); + Telemetry.addToHistogram(HISTOGRAM_DOWNLOAD_TIME_MS, clamp(MAX_DOWNLOAD_TIME_MSEC, duration)); + + if (distro == null) { + // Nothing to do. + return false; + } + + // Try to copy distribution files from the fetched stream. + try { + Log.d(LOGTAG, "Copying files from fetched zip."); + if (copyFilesFromStream(distro)) { + // We always copy to the data dir, and we only copy files from + // a 'distribution' subdirectory. Now determine our actual distribution directory. + return checkDataDistribution(); + } + } catch (SecurityException e) { + Log.e(LOGTAG, "Security exception copying files. Corrupt or malicious?", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION); + } catch (Exception e) { + Log.e(LOGTAG, "Error copying files from distribution.", e); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_EXCEPTION); + } finally { + distro.close(); + } + } finally { + connection.disconnect(); + } + } catch (IOException e) { + Log.e(LOGTAG, "Error copying distribution files from network.", e); + recordFetchTelemetry(e); + } + + return false; + } + + private static final int clamp(long v, long c) { + return (int) Math.min(c, v); + } + + /** + * Fetch the provided URI, returning a {@link JarInputStream} if the response body + * is appropriate. + * + * Protected to allow for mocking. + * + * @return the entity body as a stream, or null on failure. + */ + @SuppressWarnings("static-method") + @RobocopTarget + protected JarInputStream fetchDistribution(URI uri, HttpURLConnection connection) throws IOException { + final int status = connection.getResponseCode(); + + Log.d(LOGTAG, "Distribution fetch: " + status); + // We record HTTP statuses as 2xx, 3xx, 4xx, 5xx => 2, 3, 4, 5. + final int value; + if (status > 599 || status < 100) { + Log.wtf(LOGTAG, "Unexpected HTTP status code: " + status); + value = CODE_CATEGORY_STATUS_OUT_OF_RANGE; + } else { + value = status / 100; + } + + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, value); + + if (status != 200) { + Log.w(LOGTAG, "Got status " + status + " fetching distribution."); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE); + return null; + } + + final String contentType = connection.getContentType(); + if (contentType == null || !contentType.startsWith(EXPECTED_CONTENT_TYPE)) { + Log.w(LOGTAG, "Malformed response: invalid Content-Type."); + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE); + return null; + } + + return new JarInputStream(new BufferedInputStream(connection.getInputStream()), true); + } + + private static void recordFetchTelemetry(final Exception exception) { + if (exception == null) { + // Should never happen. + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION); + return; + } + + if (exception instanceof UnknownHostException) { + // Unknown host => we're offline. + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_OFFLINE); + return; + } + + if (exception instanceof SSLException) { + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SSL_ERROR); + return; + } + + if (exception instanceof ProtocolException || + exception instanceof SocketException) { + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SOCKET_ERROR); + return; + } + + Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION); + } + + /** + * @return true if we copied files out of the APK. Sets distributionDir in that case. + */ + private boolean copyAndCheckAPKDistribution() { + try { + // First, try copying distribution files out of the APK. + if (copyFilesFromPackagedAssets()) { + // We always copy to the data dir, and we only copy files from + // a 'distribution' subdirectory. Now determine our actual distribution directory. + return checkDataDistribution(); + } + } catch (IOException e) { + Log.e(LOGTAG, "Error copying distribution files from APK.", e); + } + return false; + } + + /** + * @return true if we found a data distribution (copied from APK or OTA). Sets distributionDir in that case. + */ + private boolean checkDataDistribution() { + return checkDirectories(getDataDistributionDirectories(context)); + } + + /** + * @return true if we found a system distribution. Sets distributionDir in that case. + */ + private boolean checkSystemDistribution() { + return checkDirectories(getSystemDistributionDirectories(context)); + } + + /** + * @return true if one of the specified distribution directories exists. Sets distributionDir in that case. + */ + private boolean checkDirectories(String[] directories) { + for (String path : directories) { + File directory = new File(path); + if (directory.exists()) { + distributionDir = directory; + return true; + } + } + return false; + } + + /** + * Unpack distribution files from a downloaded jar stream. + * + * The caller is responsible for closing the provided stream. + */ + private boolean copyFilesFromStream(JarInputStream jar) throws FileNotFoundException, IOException { + final byte[] buffer = new byte[1024]; + boolean distributionSet = false; + JarEntry entry; + while ((entry = jar.getNextJarEntry()) != null) { + final String name = entry.getName(); + + if (entry.isDirectory()) { + // We'll let getDataFile deal with creating the directory hierarchy. + // Yes, we can do better, but it can wait. + continue; + } + + if (!name.startsWith(DISTRIBUTION_PATH)) { + continue; + } + + File outFile = getDataFile(name); + if (outFile == null) { + continue; + } + + distributionSet = true; + + writeStream(jar, outFile, entry.getTime(), buffer); + } + + return distributionSet; + } + + /** + * Copies the /assets/distribution folder out of the APK and into the app's data directory. + * Returns true if distribution files were found and copied. + */ + private boolean copyFilesFromPackagedAssets() throws IOException { + final File applicationPackage = new File(packagePath); + final ZipFile zip = new ZipFile(applicationPackage); + + final String assetsPrefix = "assets/"; + final String fullPrefix = assetsPrefix + DISTRIBUTION_PATH; + + boolean distributionSet = false; + try { + final byte[] buffer = new byte[1024]; + + final Enumeration<? extends ZipEntry> zipEntries = zip.entries(); + while (zipEntries.hasMoreElements()) { + final ZipEntry fileEntry = zipEntries.nextElement(); + final String name = fileEntry.getName(); + + if (fileEntry.isDirectory()) { + // We'll let getDataFile deal with creating the directory hierarchy. + continue; + } + + // Read from "assets/distribution/**". + if (!name.startsWith(fullPrefix)) { + continue; + } + + // Write to "distribution/**". + final String nameWithoutPrefix = name.substring(assetsPrefix.length()); + final File outFile = getDataFile(nameWithoutPrefix); + if (outFile == null) { + continue; + } + + distributionSet = true; + + final InputStream fileStream = zip.getInputStream(fileEntry); + try { + writeStream(fileStream, outFile, fileEntry.getTime(), buffer); + } finally { + fileStream.close(); + } + } + } finally { + zip.close(); + } + + return distributionSet; + } + + private void writeStream(InputStream fileStream, File outFile, final long modifiedTime, byte[] buffer) + throws FileNotFoundException, IOException { + final OutputStream outStream = new FileOutputStream(outFile); + try { + int count; + while ((count = fileStream.read(buffer)) > 0) { + outStream.write(buffer, 0, count); + } + + outFile.setLastModified(modifiedTime); + } finally { + outStream.close(); + } + } + + /** + * Return a File instance in the data directory, ensuring + * that the parent exists. + * + * @return null if the parents could not be created. + */ + private File getDataFile(final String name) { + File outFile = new File(getDataDir(), name); + File dir = outFile.getParentFile(); + + if (!dir.exists()) { + Log.d(LOGTAG, "Creating " + dir.getAbsolutePath()); + if (!dir.mkdirs()) { + Log.e(LOGTAG, "Unable to create directories: " + dir.getAbsolutePath()); + return null; + } + } + + return outFile; + } + + private URI getReferredDistribution(ReferrerDescriptor descriptor) { + final String content = descriptor.content; + if (content == null) { + return null; + } + + // We restrict here to avoid injection attacks. After all, + // we're downloading a distribution payload based on intent input. + if (!content.matches("^[a-zA-Z0-9]+$")) { + Log.e(LOGTAG, "Invalid referrer content: " + content); + Telemetry.addToHistogram(HISTOGRAM_REFERRER_INVALID, 1); + return null; + } + + try { + return new URI(FETCH_PROTOCOL, FETCH_HOSTNAME, FETCH_PATH + content + FETCH_EXTENSION, null); + } catch (URISyntaxException e) { + // This should never occur. + Log.wtf(LOGTAG, "Invalid URI with content " + content + "!"); + return null; + } + } + + /** + * After calling this method, either <code>distributionDir</code> + * will be set, or there is no distribution in use. + * + * Only call after init. + */ + private File ensureDistributionDir() { + if (this.distributionDir != null) { + return this.distributionDir; + } + + if (this.state != STATE_SET) { + return null; + } + + // After init, we know that either we've copied a distribution out of + // the APK, or it exists in /system/. + // Look in each location in turn. + // (This could be optimized by caching the path in shared prefs.) + if (checkDataDistribution() || checkSystemDistribution()) { + return distributionDir; + } + + return null; + } + + private String getDataDir() { + return context.getApplicationInfo().dataDir; + } + + @JNITarget + public static String[] getDistributionDirectories() { + final Context context = GeckoAppShell.getApplicationContext(); + + final String[] dataDirectories = getDataDistributionDirectories(context); + final String[] systemDirectories = getSystemDistributionDirectories(context); + + final String[] directories = new String[dataDirectories.length + systemDirectories.length]; + + System.arraycopy(dataDirectories, 0, directories, 0, dataDirectories.length); + System.arraycopy(systemDirectories, 0, directories, dataDirectories.length, systemDirectories.length); + + return directories; + } + + /** + * Get a list of system distribution folder candidates. + * + * /system/<package>/distribution/<mcc>/<mnc> - For bundled distributions for specific network providers + * /system/<package>/distribution/<mcc> - For bundled distributions for specific countries + * /system/<package>/distribution/default - For bundled distributions with no matching mcc/mnc + * /system/<package>/distribution - Default non-bundled system distribution + */ + private static String[] getSystemDistributionDirectories(Context context) { + final String baseDirectory = "/system/" + context.getPackageName() + "/distribution"; + return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory); + } + + /** + * Get a list of data distribution folder candidates. + * + * <dataDir>/distribution/<mcc>/<mnc> - For bundled distributions for specific network providers + * <dataDir>/distribution/<mcc> - For bundled distributions for specific countries + * <dataDir>/distribution/default - For bundled distributions with no matching mcc/mnc + * <dataDir>/distribution - Default non-bundled system distribution + */ + private static String[] getDataDistributionDirectories(Context context) { + final String baseDirectory = new File(context.getApplicationInfo().dataDir, DISTRIBUTION_PATH).getAbsolutePath(); + return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory); + } + + /** + * Get a list of distribution folder candidates inside the specified base directory. + */ + private static String[] getDistributionDirectoriesFromBaseDirectory(Context context, String baseDirectory) { + final TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (telephonyManager != null && telephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY) { + final String simOperator = telephonyManager.getSimOperator(); + + if (simOperator != null && simOperator.length() >= 5) { + final String mcc = simOperator.substring(0, 3); + final String mnc = simOperator.substring(3); + + return new String[] { + baseDirectory + "/" + mcc + "/" + mnc, + baseDirectory + "/" + mcc, + baseDirectory + "/default", + baseDirectory + }; + } + } + + return new String[] { + baseDirectory + "/default", + baseDirectory + }; + } + + /** + * The provided <code>ReadyCallback</code> will be queued for execution after + * the distribution is ready, or queued for immediate execution if the + * distribution has already been processed. + * + * Each <code>ReadyCallback</code> will be executed on the background thread. + */ + public void addOnDistributionReadyCallback(final ReadyCallback callback) { + if (state == STATE_UNKNOWN) { + // Queue for later. + onDistributionReady.add(callback); + } else { + invokeCallbackDelayed(callback); + } + } + + /** + * Run our delayed queue, after a delayed distribution arrives. + */ + private void runLateReadyQueue() { + ReadyCallback task; + while ((task = onLateReady.poll()) != null) { + invokeLateCallbackDelayed(task); + } + } + + /** + * Execute tasks that wanted to run when we were done loading + * the distribution. + */ + private void runReadyQueue() { + ReadyCallback task; + while ((task = onDistributionReady.poll()) != null) { + invokeCallbackDelayed(task); + } + } + + private void invokeLateCallbackDelayed(final ReadyCallback callback) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // Sanity. + if (state != STATE_SET) { + Log.w(LOGTAG, "Refusing to invoke late distro callback in state " + state); + return; + } + callback.distributionArrivedLate(Distribution.this); + } + }); + } + + private void invokeCallbackDelayed(final ReadyCallback callback) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @WorkerThread + @Override + public void run() { + switch (state) { + case STATE_SET: + callback.distributionFound(Distribution.this); + break; + case STATE_NONE: + callback.distributionNotFound(); + if (shouldDelayLateCallbacks) { + onLateReady.add(callback); + } + break; + default: + throw new IllegalStateException("Expected STATE_NONE or STATE_SET, got " + state); + } + } + }); + } + + /** + * A safe way for callers to determine if this Distribution instance + * represents a real live distribution. + */ + public boolean exists() { + return state == STATE_SET; + } + + private String getKeyName() { + return context.getPackageName() + ".distribution_state"; + } + + private SharedPreferences getSharedPreferences() { + final SharedPreferences settings; + if (prefsBranch == null) { + settings = GeckoSharedPrefs.forApp(context); + } else { + settings = context.getSharedPreferences(prefsBranch, Activity.MODE_PRIVATE); + } + return settings; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java b/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java new file mode 100644 index 000000000..11ed4811f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java @@ -0,0 +1,61 @@ +/* + * 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.distribution; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import org.mozilla.gecko.GeckoSharedPrefs; + +import java.lang.ref.WeakReference; + +/** + * A distribution ready callback that will store the distribution ID to profile-specific shared preferences. + */ +public class DistributionStoreCallback implements Distribution.ReadyCallback { + private static final String LOGTAG = "Gecko" + DistributionStoreCallback.class.getSimpleName(); + + public static final String PREF_DISTRIBUTION_ID = "distribution.id"; + + private final WeakReference<Context> contextReference; + private final String profileName; + + public DistributionStoreCallback(final Context context, final String profileName) { + this.contextReference = new WeakReference<>(context); + this.profileName = profileName; + } + + public void distributionNotFound() { /* nothing to do here */ } + + @Override + public void distributionFound(final Distribution distribution) { + storeDistribution(distribution); + } + + @Override + public void distributionArrivedLate(final Distribution distribution) { + storeDistribution(distribution); + } + + private void storeDistribution(final Distribution distribution) { + final Context context = contextReference.get(); + if (context == null) { + Log.w(LOGTAG, "Context is no longer alive, could retrieve shared prefs to store distribution"); + return; + } + + // While the distribution preferences are per install and not per profile, it's okay to use the + // profile-specific prefs because: + // 1) We don't really support mulitple profiles for end-users + // 2) The TelemetryUploadService already accesses profile-specific shared prefs so this keeps things simple. + final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfileName(context, profileName); + final Distribution.DistributionDescriptor desc = distribution.getDescriptor(); + if (desc != null) { + sharedPrefs.edit().putString(PREF_DISTRIBUTION_ID, desc.id).apply(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java new file mode 100644 index 000000000..78a77221d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java @@ -0,0 +1,322 @@ +/* -*- 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.distribution; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.db.BrowserContract; + +import java.util.HashSet; +import java.util.Set; + +/** + * A proxy for the partner bookmarks provider. Bookmark and folder ids of the partner bookmarks providers + * will be transformed so that they do not overlap with the ids from the local database. + * + * Bookmarks in folder: + * content://{PACKAGE_ID}.partnerbookmarks/bookmarks/{folderId} + * Icon of bookmark: + * content://{PACKAGE_ID}.partnerbookmarks/icons/{bookmarkId} + */ +public class PartnerBookmarksProviderProxy extends ContentProvider { + /** + * The contract between the partner bookmarks provider and applications. Contains the definition + * for the supported URIs and columns. + */ + public static class PartnerContract { + public static final Uri CONTENT_URI = Uri.parse("content://com.android.partnerbookmarks/bookmarks"); + + public static final int TYPE_BOOKMARK = 1; + public static final int TYPE_FOLDER = 2; + + public static final int PARENT_ROOT_ID = 0; + + public static final String ID = "_id"; + public static final String TYPE = "type"; + public static final String URL = "url"; + public static final String TITLE = "title"; + public static final String FAVICON = "favicon"; + public static final String TOUCHICON = "touchicon"; + public static final String PARENT = "parent"; + } + + private static final String AUTHORITY_PREFIX = ".partnerbookmarks"; + + private static final int URI_MATCH_BOOKMARKS = 1000; + private static final int URI_MATCH_ICON = 1001; + private static final int URI_MATCH_BOOKMARK = 1002; + + private static final String PREF_DELETED_PARTNER_BOOKMARKS = "distribution.partner.bookmark.deleted"; + + /** + * Cursor wrapper for filtering empty folders. + */ + private static class FilteredCursor extends CursorWrapper { + private HashSet<Integer> emptyFolderPositions; + private int count; + + public FilteredCursor(PartnerBookmarksProviderProxy proxy, Cursor cursor) { + super(cursor); + + emptyFolderPositions = new HashSet<>(); + count = cursor.getCount(); + + for (int i = 0; i < cursor.getCount(); i++) { + cursor.moveToPosition(i); + + final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID)); + final int type = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TYPE)); + + if (type == BrowserContract.Bookmarks.TYPE_FOLDER && proxy.isFolderEmpty(id)) { + // We do not support deleting folders. So at least hide partner folders that are + // empty because all bookmarks inside it are deleted/hidden. + // Note that this will still show folders with empty folders in them. But multi-level + // partner bookmarks are very unlikely. + + count--; + emptyFolderPositions.add(i); + } + } + } + + @Override + public int getCount() { + return count; + } + + @Override + public boolean moveToPosition(int position) { + final Cursor cursor = getWrappedCursor(); + final int actualCount = cursor.getCount(); + + // Find the next position pointing to a bookmark or a non-empty folder + while (position < actualCount && emptyFolderPositions.contains(position)) { + position++; + } + + return position < actualCount && cursor.moveToPosition(position); + } + } + + private static String getAuthority(Context context) { + return context.getPackageName() + AUTHORITY_PREFIX; + } + + public static Uri getUriForBookmarks(Context context, long folderId) { + return new Uri.Builder() + .scheme("content") + .authority(getAuthority(context)) + .appendPath("bookmarks") + .appendPath(String.valueOf(folderId)) + .build(); + } + + public static Uri getUriForIcon(Context context, long bookmarkId) { + return new Uri.Builder() + .scheme("content") + .authority(getAuthority(context)) + .appendPath("icons") + .appendPath(String.valueOf(bookmarkId)) + .build(); + } + + public static Uri getUriForBookmark(Context context, long bookmarkId) { + return new Uri.Builder() + .scheme("content") + .authority(getAuthority(context)) + .appendPath("bookmark") + .appendPath(String.valueOf(bookmarkId)) + .build(); + } + + private final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + @Override + public boolean onCreate() { + String authority = getAuthority(assertAndGetContext()); + + uriMatcher.addURI(authority, "bookmarks/*", URI_MATCH_BOOKMARKS); + uriMatcher.addURI(authority, "icons/*", URI_MATCH_ICON); + uriMatcher.addURI(authority, "bookmark/*", URI_MATCH_BOOKMARK); + + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + final Context context = assertAndGetContext(); + final int match = uriMatcher.match(uri); + + final ContentResolver contentResolver = context.getContentResolver(); + + switch (match) { + case URI_MATCH_BOOKMARKS: + final long bookmarkId = ContentUris.parseId(uri); + if (bookmarkId == -1) { + throw new IllegalArgumentException("Bookmark id is not a number"); + } + final Cursor cursor = getBookmarksInFolder(contentResolver, bookmarkId); + cursor.setNotificationUri(context.getContentResolver(), uri); + return new FilteredCursor(this, cursor); + + case URI_MATCH_ICON: + return getIcon(contentResolver, ContentUris.parseId(uri)); + + default: + throw new UnsupportedOperationException("Unknown URI " + uri.toString()); + } + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + final int match = uriMatcher.match(uri); + + switch (match) { + case URI_MATCH_BOOKMARK: + rememberRemovedBookmark(ContentUris.parseId(uri)); + notifyBookmarkChange(); + return 1; + + default: + throw new UnsupportedOperationException("Unknown URI " + uri.toString()); + } + } + + private void notifyBookmarkChange() { + final Context context = assertAndGetContext(); + + context.getContentResolver().notifyChange( + new Uri.Builder() + .scheme("content") + .authority(getAuthority(context)) + .appendPath("bookmarks") + .build(), + null); + } + + private synchronized void rememberRemovedBookmark(long bookmarkId) { + Set<String> deletedIds = getRemovedBookmarkIds(); + + deletedIds.add(String.valueOf(bookmarkId)); + + GeckoSharedPrefs.forProfile(assertAndGetContext()) + .edit() + .putStringSet(PREF_DELETED_PARTNER_BOOKMARKS, deletedIds) + .apply(); + } + + private synchronized Set<String> getRemovedBookmarkIds() { + SharedPreferences preferences = GeckoSharedPrefs.forProfile(assertAndGetContext()); + return preferences.getStringSet(PREF_DELETED_PARTNER_BOOKMARKS, new HashSet<String>()); + } + + private Cursor getBookmarksInFolder(ContentResolver contentResolver, long folderId) { + // Use root folder id or transform negative id into actual (positive) folder id. + final long actualFolderId = folderId == BrowserContract.Bookmarks.FIXED_ROOT_ID + ? PartnerContract.PARENT_ROOT_ID + : BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START - folderId; + + final String removedBookmarkIds = TextUtils.join(",", getRemovedBookmarkIds()); + + return contentResolver.query( + PartnerContract.CONTENT_URI, + new String[] { + // Transform ids into negative values starting with FAKE_PARTNER_BOOKMARKS_START. + "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.ID + ") as " + BrowserContract.Bookmarks._ID, + "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.ID + ") as " + BrowserContract.Combined.BOOKMARK_ID, + PartnerContract.TITLE + " as " + BrowserContract.Bookmarks.TITLE, + PartnerContract.URL + " as " + BrowserContract.Bookmarks.URL, + // Transform parent ids to negative ids as well + "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.PARENT + ") as " + BrowserContract.Bookmarks.PARENT, + // Convert types (we use 0-1 and the partner provider 1-2) + "(2 - " + PartnerContract.TYPE + ") as " + BrowserContract.Bookmarks.TYPE, + // Use the ID of the entry as GUID + PartnerContract.ID + " as " + BrowserContract.Bookmarks.GUID + }, + PartnerContract.PARENT + " = ?" + // We only want to read bookmarks or folders from the content provider + + " AND " + BrowserContract.Bookmarks.TYPE + " IN (?,?)" + // Only select entries with non empty title + + " AND " + BrowserContract.Bookmarks.TITLE + " <> ''" + // Filter all "deleted" ids + + " AND " + BrowserContract.Combined.BOOKMARK_ID + " NOT IN (" + removedBookmarkIds + ")", + new String[] { + String.valueOf(actualFolderId), + String.valueOf(PartnerContract.TYPE_BOOKMARK), + String.valueOf(PartnerContract.TYPE_FOLDER) + }, + // Same order we use in our content provider (without position) + BrowserContract.Bookmarks.TYPE + " ASC, " + BrowserContract.Bookmarks._ID + " ASC"); + } + + private boolean isFolderEmpty(long folderId) { + final Context context = assertAndGetContext(); + final Cursor cursor = getBookmarksInFolder(context.getContentResolver(), folderId); + + if (cursor == null) { + return true; + } + + try { + return cursor.getCount() == 0; + } finally { + cursor.close(); + } + } + + private Cursor getIcon(ContentResolver contentResolver, long bookmarkId) { + final long actualId = BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START - bookmarkId; + + return contentResolver.query( + PartnerContract.CONTENT_URI, + new String[] { + PartnerContract.TOUCHICON, + PartnerContract.FAVICON + }, + PartnerContract.ID + " = ?", + new String[] { + String.valueOf(actualId) + }, + null); + } + + private Context assertAndGetContext() { + final Context context = super.getContext(); + + if (context == null) { + throw new AssertionError("Context is null"); + } + + return context; + } + + @Override + public String getType(@NonNull Uri uri) { + throw new UnsupportedOperationException(); + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java new file mode 100644 index 000000000..2dad21a48 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java @@ -0,0 +1,43 @@ +/* -*- 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.distribution; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +/** + * Client for accessing data from Android's "partner browser customizations" content provider. + */ +public class PartnerBrowserCustomizationsClient { + private static final Uri CONTENT_URI = Uri.parse("content://com.android.partnerbrowsercustomizations"); + + private static final Uri HOMEPAGE_URI = CONTENT_URI.buildUpon().path("homepage").build(); + + private static final String COLUMN_HOMEPAGE = "homepage"; + + /** + * Returns the partner homepage or null if it could not be read from the content provider. + */ + public static String getHomepage(Context context) { + Cursor cursor = context.getContentResolver().query( + HOMEPAGE_URI, new String[] { COLUMN_HOMEPAGE }, null, null, null); + + if (cursor == null) { + return null; + } + + try { + if (!cursor.moveToFirst()) { + return null; + } + + return cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_HOMEPAGE)); + } finally { + cursor.close(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java new file mode 100644 index 000000000..4a1be656b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java @@ -0,0 +1,64 @@ +/* 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.distribution; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.net.Uri; + +import java.net.URLDecoder; +import java.io.UnsupportedEncodingException; + +/** + * Encapsulates access to values encoded in the "referrer" extra of an install intent. + * + * This object is immutable. + * + * Example input: + * + * "utm_source=campsource&utm_medium=campmed&utm_term=term%2Bhere&utm_content=content&utm_campaign=name" + */ +@RobocopTarget +public class ReferrerDescriptor { + public final String source; + public final String medium; + public final String term; + public final String content; + public final String campaign; + + public ReferrerDescriptor(String referrer) { + if (referrer == null) { + source = null; + medium = null; + term = null; + content = null; + campaign = null; + return; + } + + try { + referrer = URLDecoder.decode(referrer, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF-8 is always supported + } + + final Uri u = new Uri.Builder() + .scheme("http") + .authority("local") + .path("/") + .encodedQuery(referrer).build(); + + source = u.getQueryParameter("utm_source"); + medium = u.getQueryParameter("utm_medium"); + term = u.getQueryParameter("utm_term"); + content = u.getQueryParameter("utm_content"); + campaign = u.getQueryParameter("utm_campaign"); + } + + @Override + public String toString() { + return "{s: " + source + ", m: " + medium + ", t: " + term + ", c: " + content + ", c: " + campaign + "}"; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java new file mode 100644 index 000000000..3651d6068 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java @@ -0,0 +1,107 @@ +/* -*- 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.distribution; + +import org.mozilla.gecko.AdjustConstants; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +public class ReferrerReceiver extends BroadcastReceiver { + private static final String LOGTAG = "GeckoReferrerReceiver"; + + private static final String ACTION_INSTALL_REFERRER = "com.android.vending.INSTALL_REFERRER"; + + // Sent when we're done. + @RobocopTarget + public static final String ACTION_REFERRER_RECEIVED = "org.mozilla.fennec.REFERRER_RECEIVED"; + + /** + * If the install intent has this source, it is a Mozilla specific or over + * the air distribution referral. We'll track the campaign ID using + * Mozilla's metrics systems. + * + * If the install intent has a source different than this one, it is a + * referral from an advertising network. We may track these campaigns using + * third-party tracking and metrics systems. + */ + private static final String MOZILLA_UTM_SOURCE = "mozilla"; + + /** + * If the install intent has this campaign, we'll load the specified distribution. + */ + private static final String DISTRIBUTION_UTM_CAMPAIGN = "distribution"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.v(LOGTAG, "Received intent " + intent); + if (!ACTION_INSTALL_REFERRER.equals(intent.getAction())) { + // This should never happen. + return; + } + + // Track the referrer object for distribution handling. + ReferrerDescriptor referrer = new ReferrerDescriptor(intent.getStringExtra("referrer")); + + if (!TextUtils.equals(referrer.source, MOZILLA_UTM_SOURCE)) { + // Allow the Adjust handler to process the intent. + try { + AdjustConstants.getAdjustHelper().onReceive(context, intent); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception in Adjust's onReceive; ignoring referrer intent.", e); + } + return; + } + + if (TextUtils.equals(referrer.campaign, DISTRIBUTION_UTM_CAMPAIGN)) { + Distribution.onReceivedReferrer(context, referrer); + // We want Adjust information for OTA distributions as well + try { + AdjustConstants.getAdjustHelper().onReceive(context, intent); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception in Adjust's onReceive for distribution.", e); + } + } else { + Log.d(LOGTAG, "Not downloading distribution: non-matching campaign."); + // If this is a Mozilla campaign, pass the campaign along to Gecko. + // It'll pretend to be a "playstore" distribution for BLP purposes. + propagateMozillaCampaign(referrer); + } + + // Broadcast a secondary, local intent to allow test code to respond. + final Intent receivedIntent = new Intent(ACTION_REFERRER_RECEIVED); + LocalBroadcastManager.getInstance(context).sendBroadcast(receivedIntent); + } + + + private void propagateMozillaCampaign(ReferrerDescriptor referrer) { + if (referrer.campaign == null) { + return; + } + + try { + final JSONObject data = new JSONObject(); + data.put("id", "playstore"); + data.put("version", referrer.campaign); + String payload = data.toString(); + + // Try to make sure the prefs are written as a group. + GeckoAppShell.notifyObservers("Campaign:Set", payload); + } catch (JSONException e) { + Log.e(LOGTAG, "Error propagating campaign identifier.", e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java new file mode 100644 index 000000000..28d6b238d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java @@ -0,0 +1,166 @@ +/* -*- 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.dlc; + +import android.content.Context; +import android.support.annotation.IntDef; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.background.nativecode.NativeCrypto; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.IOUtils; +import org.mozilla.gecko.util.ProxySelector; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; + +public abstract class BaseAction { + private static final String LOGTAG = "GeckoDLCBaseAction"; + + /** + * Exception indicating a recoverable error has happened. Download of the content will be retried later. + */ + /* package-private */ static class RecoverableDownloadContentException extends Exception { + private static final long serialVersionUID = -2246772819507370734L; + + @IntDef({MEMORY, DISK_IO, SERVER, NETWORK}) + public @interface ErrorType {} + public static final int MEMORY = 1; + public static final int DISK_IO = 2; + public static final int SERVER = 3; + public static final int NETWORK = 4; + + private int errorType; + + public RecoverableDownloadContentException(@ErrorType int errorType, String message) { + super(message); + this.errorType = errorType; + } + + public RecoverableDownloadContentException(@ErrorType int errorType, Throwable cause) { + super(cause); + this.errorType = errorType; + } + + @ErrorType + public int getErrorType() { + return errorType; + } + + /** + * Should this error be counted as failure? If this type of error will happen multiple times in a row then this + * error will be treated as permanently and the operation will not be tried again until the content changes. + */ + public boolean shouldBeCountedAsFailure() { + if (NETWORK == errorType) { + return false; // Always retry after network errors + } + + return true; + } + } + + /** + * If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try + * downloading it again - until a newer version of the content is available. + */ + /* package-private */ static class UnrecoverableDownloadContentException extends Exception { + private static final long serialVersionUID = 8956080754787367105L; + + public UnrecoverableDownloadContentException(String message) { + super(message); + } + + public UnrecoverableDownloadContentException(Throwable cause) { + super(cause); + } + } + + public abstract void perform(Context context, DownloadContentCatalog catalog); + + protected File getDestinationFile(Context context, DownloadContent content) + throws UnrecoverableDownloadContentException, RecoverableDownloadContentException { + if (content.isFont()) { + File destinationDirectory = new File(context.getApplicationInfo().dataDir, "fonts"); + + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, + "Destination directory does not exist and cannot be created"); + } + + return new File(destinationDirectory, content.getFilename()); + } + + // Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen) + throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind()); + } + + protected boolean verify(File file, String expectedChecksum) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + + try { + inputStream = new BufferedInputStream(new FileInputStream(file)); + + byte[] ctx = NativeCrypto.sha256init(); + if (ctx == null) { + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.MEMORY, + "Could not create SHA-256 context"); + } + + byte[] buffer = new byte[4096]; + int read; + + while ((read = inputStream.read(buffer)) != -1) { + NativeCrypto.sha256update(ctx, buffer, read); + } + + String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx)); + + if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) { + Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum); + return false; + } + + return true; + } catch (IOException e) { + // Recoverable: Just I/O discontinuation + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e); + } finally { + IOUtils.safeStreamClose(inputStream); + } + } + + protected HttpURLConnection buildHttpURLConnection(String url) + throws UnrecoverableDownloadContentException, IOException { + try { + System.setProperty("http.keepAlive", "true"); + + HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(new URI(url)); + connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ? + AppConstants.USER_AGENT_FENNEC_TABLET : + AppConstants.USER_AGENT_FENNEC_MOBILE); + connection.setRequestMethod("GET"); + connection.setInstanceFollowRedirects(true); + return connection; + } catch (MalformedURLException e) { + throw new UnrecoverableDownloadContentException(e); + } catch (URISyntaxException e) { + throw new UnrecoverableDownloadContentException(e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java new file mode 100644 index 000000000..e44704c6c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java @@ -0,0 +1,49 @@ +/* -*- 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.dlc; + +import android.content.Context; + +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; + +import java.io.File; + +/** + * CleanupAction: Remove content that is no longer needed. + */ +public class CleanupAction extends BaseAction { + @Override + public void perform(Context context, DownloadContentCatalog catalog) { + for (DownloadContent content : catalog.getContentToDelete()) { + if (!content.isAssetArchive()) { + continue; // We do not know how to clean up this content. But this means we didn't + // download it anyways. + } + + try { + File file = getDestinationFile(context, content); + + if (!file.exists()) { + // File does not exist. As good as deleting. + catalog.remove(content); + return; + } + + if (file.delete()) { + // File has been deleted. Now remove it from the catalog. + catalog.remove(content); + } + } catch (UnrecoverableDownloadContentException e) { + // We can't recover. Pretend the content is removed. It probably never existed in + // the first place. + catalog.remove(content); + } catch (RecoverableDownloadContentException e) { + // Try again next time. + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java new file mode 100644 index 000000000..8618d4699 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java @@ -0,0 +1,325 @@ +/* -*- 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.dlc; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.v4.net.ConnectivityManagerCompat; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.IOUtils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.zip.GZIPInputStream; + +/** + * Download content that has been scheduled during "study" or "verify". + */ +public class DownloadAction extends BaseAction { + private static final String LOGTAG = "DLCDownloadAction"; + + private static final String CACHE_DIRECTORY = "downloadContent"; + + private static final String CDN_BASE_URL = "https://fennec-catalog.cdn.mozilla.net/"; + + public interface Callback { + void onContentDownloaded(DownloadContent content); + } + + private Callback callback; + + public DownloadAction(Callback callback) { + this.callback = callback; + } + + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Downloading content.."); + + if (!isConnectedToNetwork(context)) { + Log.d(LOGTAG, "No connected network available. Postponing download."); + // TODO: Reschedule download (bug 1209498) + return; + } + + if (isActiveNetworkMetered(context)) { + Log.d(LOGTAG, "Network is metered. Postponing download."); + // TODO: Reschedule download (bug 1209498) + return; + } + + for (DownloadContent content : catalog.getScheduledDownloads()) { + Log.d(LOGTAG, "Downloading: " + content); + + File temporaryFile = null; + + try { + File destinationFile = getDestinationFile(context, content); + if (destinationFile.exists() && verify(destinationFile, content.getChecksum())) { + Log.d(LOGTAG, "Content already exists and is up-to-date."); + catalog.markAsDownloaded(content); + continue; + } + + temporaryFile = createTemporaryFile(context, content); + + if (!hasEnoughDiskSpace(content, destinationFile, temporaryFile)) { + Log.d(LOGTAG, "Not enough disk space to save content. Skipping download."); + continue; + } + + // TODO: Check space on disk before downloading content (bug 1220145) + final String url = createDownloadURL(content); + + if (!temporaryFile.exists() || temporaryFile.length() < content.getSize()) { + download(url, temporaryFile); + } + + if (!verify(temporaryFile, content.getDownloadChecksum())) { + Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId()); + temporaryFile.delete(); + continue; + } + + if (!content.isAssetArchive()) { + Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType()); + temporaryFile.delete(); + continue; + } + + extract(temporaryFile, destinationFile, content.getChecksum()); + + catalog.markAsDownloaded(content); + + Log.d(LOGTAG, "Successfully downloaded: " + content); + + if (callback != null) { + callback.onContentDownloaded(content); + } + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } catch (RecoverableDownloadContentException e) { + Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e); + + if (e.shouldBeCountedAsFailure()) { + catalog.rememberFailure(content, e.getErrorType()); + } + + // TODO: Reschedule download (bug 1209498) + } catch (UnrecoverableDownloadContentException e) { + Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e); + + catalog.markAsPermanentlyFailed(content); + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } + } + + Log.v(LOGTAG, "Done"); + } + + protected void download(String source, File temporaryFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + + HttpURLConnection connection = null; + + try { + connection = buildHttpURLConnection(source); + + final long offset = temporaryFile.exists() ? temporaryFile.length() : 0; + if (offset > 0) { + connection.setRequestProperty("Range", "bytes=" + offset + "-"); + } + + final int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) { + // We are trying to be smart and only retry if this is an error that might resolve in the future. + // TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106). + if (status >= 500) { + // Recoverable: Server errors 5xx + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, + "(Recoverable) Download failed. Status code: " + status); + } else if (status >= 400) { + // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed. + throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status); + } else { + // HttpsUrlConnection: -1 (No valid response code) + // Informational 1xx: They have no meaning to us. + // Successful 2xx: We don't know how to handle anything but 200. + // Redirection 3xx: HttpClient should have followed redirects if possible. We should not see those errors here. + throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status); + } + } + + inputStream = new BufferedInputStream(connection.getInputStream()); + outputStream = openFile(temporaryFile, status == HttpURLConnection.HTTP_PARTIAL); + + IOUtils.copy(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + } catch (IOException e) { + // Recoverable: Just I/O discontinuation + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + + if (connection != null) { + connection.disconnect(); + } + } + } + + protected OutputStream openFile(File file, boolean append) throws FileNotFoundException { + return new BufferedOutputStream(new FileOutputStream(file, append)); + } + + protected void extract(File sourceFile, File destinationFile, String checksum) + throws UnrecoverableDownloadContentException, RecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + File temporaryFile = null; + + try { + File destinationDirectory = destinationFile.getParentFile(); + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new IOException("Destination directory does not exist and cannot be created"); + } + + temporaryFile = new File(destinationDirectory, destinationFile.getName() + ".tmp"); + + inputStream = new GZIPInputStream(new BufferedInputStream(new FileInputStream(sourceFile))); + outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile)); + + IOUtils.copy(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + + if (!verify(temporaryFile, checksum)) { + Log.w(LOGTAG, "Checksum of extracted file does not match."); + return; + } + + move(temporaryFile, destinationFile); + } catch (IOException e) { + // We could not extract to the destination: Keep temporary file and try again next time we run. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + + if (temporaryFile != null && temporaryFile.exists()) { + temporaryFile.delete(); + } + } + } + + protected boolean isConnectedToNetwork(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + + return networkInfo != null && networkInfo.isConnected(); + } + + protected boolean isActiveNetworkMetered(Context context) { + return ConnectivityManagerCompat.isActiveNetworkMetered( + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + } + + protected String createDownloadURL(DownloadContent content) { + final String location = content.getLocation(); + + return CDN_BASE_URL + content.getLocation(); + } + + protected File createTemporaryFile(Context context, DownloadContent content) + throws RecoverableDownloadContentException { + File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY); + + if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) { + // Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, + "Could not create cache directory: " + cacheDirectory); + } + + return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId()); + } + + protected void move(File temporaryFile, File destinationFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + if (!temporaryFile.renameTo(destinationFile)) { + Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy.."); + copy(temporaryFile, destinationFile); + temporaryFile.delete(); + } + } + + protected void copy(File temporaryFile, File destinationFile) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + InputStream inputStream = null; + OutputStream outputStream = null; + + try { + File destinationDirectory = destinationFile.getParentFile(); + if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) { + throw new IOException("Destination directory does not exist and cannot be created"); + } + + inputStream = new BufferedInputStream(new FileInputStream(temporaryFile)); + outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile)); + + IOUtils.copy(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + } catch (IOException e) { + // We could not copy the temporary file to its destination: Keep the temporary file and + // try again the next time we run. + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e); + } finally { + IOUtils.safeStreamClose(inputStream); + IOUtils.safeStreamClose(outputStream); + } + } + + protected boolean hasEnoughDiskSpace(DownloadContent content, File destinationFile, File temporaryFile) { + final File temporaryDirectory = temporaryFile.getParentFile(); + if (temporaryDirectory.getUsableSpace() < content.getSize()) { + return false; + } + + final File destinationDirectory = destinationFile.getParentFile(); + // We need some more space to extract the file (getSize() returns the uncompressed size) + if (destinationDirectory.getUsableSpace() < content.getSize() * 2) { + return false; + } + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java new file mode 100644 index 000000000..3729cf2e0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java @@ -0,0 +1,144 @@ +/* -*- 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.dlc; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.HardwareUtils; + +import android.app.IntentService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * Service to handle downloadable content that did not ship with the APK. + */ +public class DownloadContentService extends IntentService { + private static final String LOGTAG = "GeckoDLCService"; + + /** + * Study: Scan the catalog for "new" content available for download. + */ + private static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY"; + + /** + * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum? + */ + private static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY"; + + /** + * Download content that has been scheduled during "study" or "verify". + */ + private static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD"; + + /** + * Sync: Synchronize catalog from a Kinto instance. + */ + private static final String ACTION_SYNCHRONIZE_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.SYNC"; + + /** + * CleanupAction: Remove content that is no longer needed (e.g. Removed from the catalog after a sync). + */ + private static final String ACTION_CLEANUP_FILES = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.CLEANUP"; + + public static void startStudy(Context context) { + Intent intent = new Intent(ACTION_STUDY_CATALOG); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + public static void startVerification(Context context) { + Intent intent = new Intent(ACTION_VERIFY_CONTENT); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + public static void startDownloads(Context context) { + Intent intent = new Intent(ACTION_DOWNLOAD_CONTENT); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + public static void startSync(Context context) { + Intent intent = new Intent(ACTION_SYNCHRONIZE_CATALOG); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + public static void startCleanup(Context context) { + Intent intent = new Intent(ACTION_CLEANUP_FILES); + intent.setComponent(new ComponentName(context, DownloadContentService.class)); + context.startService(intent); + } + + private DownloadContentCatalog catalog; + + public DownloadContentService() { + super(LOGTAG); + } + + @Override + public void onCreate() { + super.onCreate(); + + catalog = new DownloadContentCatalog(this); + } + + protected void onHandleIntent(Intent intent) { + if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) { + Log.w(LOGTAG, "Download content is not enabled. Stop."); + return; + } + + if (!HardwareUtils.isSupportedSystem()) { + // This service is running very early before checks in BrowserApp can prevent us from running. + Log.w(LOGTAG, "System is not supported. Stop."); + return; + } + + if (intent == null) { + return; + } + + final BaseAction action; + + switch (intent.getAction()) { + case ACTION_STUDY_CATALOG: + action = new StudyAction(); + break; + + case ACTION_DOWNLOAD_CONTENT: + action = new DownloadAction(new DownloadAction.Callback() { + @Override + public void onContentDownloaded(DownloadContent content) { + if (content.isFont()) { + GeckoAppShell.notifyObservers("Fonts:Reload", ""); + } + } + }); + break; + + case ACTION_VERIFY_CONTENT: + action = new VerifyAction(); + break; + + case ACTION_SYNCHRONIZE_CATALOG: + action = new SyncAction(); + break; + + default: + Log.e(LOGTAG, "Unknown action: " + intent.getAction()); + return; + } + + action.perform(this, catalog); + catalog.persistChanges(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java new file mode 100644 index 000000000..e15a17bbe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java @@ -0,0 +1,81 @@ +/* -*- 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.dlc; + +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.util.ContextUtils; + +/** + * Study: Scan the catalog for "new" content available for download. + */ +public class StudyAction extends BaseAction { + private static final String LOGTAG = "DLCStudyAction"; + + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Studying catalog.."); + + for (DownloadContent content : catalog.getContentToStudy()) { + if (!isMatching(context, content)) { + // This content is not for this particular version of the application or system + continue; + } + + if (content.isAssetArchive() && content.isFont()) { + catalog.scheduleDownload(content); + + Log.d(LOGTAG, "Scheduled download: " + content); + } + } + + if (catalog.hasScheduledDownloads()) { + startDownloads(context); + } + + Log.v(LOGTAG, "Done"); + } + + protected boolean isMatching(Context context, DownloadContent content) { + final String androidApiPattern = content.getAndroidApiPattern(); + if (!TextUtils.isEmpty(androidApiPattern)) { + final String apiVersion = String.valueOf(Build.VERSION.SDK_INT); + if (apiVersion.matches(androidApiPattern)) { + Log.d(LOGTAG, String.format("Android API (%s) does not match pattern: %s", apiVersion, androidApiPattern)); + return false; + } + } + + final String appIdPattern = content.getAppIdPattern(); + if (!TextUtils.isEmpty(appIdPattern)) { + final String appId = context.getPackageName(); + if (!appId.matches(appIdPattern)) { + Log.d(LOGTAG, String.format("App ID (%s) does not match pattern: %s", appId, appIdPattern)); + return false; + } + } + + final String appVersionPattern = content.getAppVersionPattern(); + if (!TextUtils.isEmpty(appVersionPattern)) { + final String appVersion = ContextUtils.getCurrentPackageInfo(context).versionName; + if (!appVersion.matches(appVersionPattern)) { + Log.d(LOGTAG, String.format("App version (%s) does not match pattern: %s", appVersion, appVersionPattern)); + return false; + } + } + + // There are no patterns or all patterns have matched. + return true; + } + + protected void startDownloads(Context context) { + DownloadContentService.startDownloads(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java new file mode 100644 index 000000000..104bdad18 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java @@ -0,0 +1,263 @@ +/* -*- 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.dlc; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import com.keepsafe.switchboard.SwitchBoard; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.util.IOUtils; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; + +/** + * Sync: Synchronize catalog from a Kinto instance. + */ +public class SyncAction extends BaseAction { + private static final String LOGTAG = "DLCSyncAction"; + + private static final String KINTO_KEY_ID = "id"; + private static final String KINTO_KEY_DELETED = "deleted"; + private static final String KINTO_KEY_DATA = "data"; + private static final String KINTO_KEY_ATTACHMENT = "attachment"; + private static final String KINTO_KEY_ORIGINAL = "original"; + + private static final String KINTO_PARAMETER_SINCE = "_since"; + private static final String KINTO_PARAMETER_FIELDS = "_fields"; + private static final String KINTO_PARAMETER_SORT = "_sort"; + + /** + * Kinto endpoint with online version of downloadable content catalog + * + * Dev instance: + * https://kinto-ota.dev.mozaws.net/v1/buckets/dlc/collections/catalog/records + */ + private static final String CATALOG_ENDPOINT = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/catalog/records"; + + @Override + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Synchronizing catalog."); + + if (!isSyncEnabledForClient(context)) { + Log.d(LOGTAG, "Sync is not enabled for client. Skipping."); + return; + } + + boolean cleanupRequired = false; + boolean studyRequired = false; + + try { + long lastModified = catalog.getLastModified(); + + // TODO: Consider using ETag here (Bug 1257459) + JSONArray rawCatalog = fetchRawCatalog(lastModified); + + Log.d(LOGTAG, "Server returned " + rawCatalog.length() + " records (since " + lastModified + ")"); + + for (int i = 0; i < rawCatalog.length(); i++) { + JSONObject object = rawCatalog.getJSONObject(i); + String id = object.getString(KINTO_KEY_ID); + + final boolean isDeleted = object.optBoolean(KINTO_KEY_DELETED, false); + + if (!isDeleted) { + JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT); + if (attachment.isNull(KINTO_KEY_ORIGINAL)) + throw new JSONException(String.format("Old Attachment Format")); + } + + DownloadContent existingContent = catalog.getContentById(id); + + if (isDeleted) { + cleanupRequired |= deleteContent(catalog, id); + } else if (existingContent != null) { + studyRequired |= updateContent(catalog, object, existingContent); + } else { + studyRequired |= createContent(catalog, object); + } + } + } catch (UnrecoverableDownloadContentException e) { + Log.e(LOGTAG, "UnrecoverableDownloadContentException", e); + } catch (RecoverableDownloadContentException e) { + Log.e(LOGTAG, "RecoverableDownloadContentException"); + } catch (JSONException e) { + Log.e(LOGTAG, "JSONException", e); + } + + if (studyRequired) { + startStudyAction(context); + } + + if (cleanupRequired) { + startCleanupAction(context); + } + + Log.v(LOGTAG, "Done"); + } + + protected void startStudyAction(Context context) { + DownloadContentService.startStudy(context); + } + + protected void startCleanupAction(Context context) { + DownloadContentService.startCleanup(context); + } + + protected JSONArray fetchRawCatalog(long lastModified) + throws RecoverableDownloadContentException, UnrecoverableDownloadContentException { + HttpURLConnection connection = null; + + try { + Uri.Builder builder = Uri.parse(CATALOG_ENDPOINT).buildUpon(); + + if (lastModified > 0) { + builder.appendQueryParameter(KINTO_PARAMETER_SINCE, String.valueOf(lastModified)); + } + // Only select the fields we are actually going to read. + builder.appendQueryParameter(KINTO_PARAMETER_FIELDS, + "attachment.location,attachment.original.filename,attachment.original.hash,attachment.hash,type,kind,attachment.original.size,match"); + + // We want to process items in the order they have been modified. This is to ensure that + // our last_modified values are correct if we processing is interrupted and not all items + // have been processed. + builder.appendQueryParameter(KINTO_PARAMETER_SORT, "last_modified"); + + connection = buildHttpURLConnection(builder.build().toString()); + + // TODO: Read 'Alert' header and EOL message if existing (Bug 1249248) + + // TODO: Read and use 'Backoff' header if available (Bug 1249251) + + // TODO: Add support for Next-Page header (Bug 1257495) + + final int responseCode = connection.getResponseCode(); + + if (responseCode != HttpURLConnection.HTTP_OK) { + if (responseCode >= 500) { + // A Retry-After header will be added to error responses (>=500), telling the + // client how many seconds it should wait before trying again. + + // TODO: Read and obey value in "Retry-After" header (Bug 1249249) + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Server error (" + responseCode + ")"); + } else if (responseCode == 410) { + // A 410 Gone error response can be returned if the client version is too old, + // or the service had been replaced with a new and better service using a new + // protocol version. + + // TODO: The server is gone. Stop synchronizing the catalog from this server (Bug 1249248). + throw new UnrecoverableDownloadContentException("Server is gone (410)"); + } else if (responseCode >= 400) { + // If the HTTP status is >=400 the response contains a JSON response. + logErrorResponse(connection); + + // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed. + throw new UnrecoverableDownloadContentException("(Unrecoverable) Catalog sync failed. Status code: " + responseCode); + } else if (responseCode < 200) { + // If the HTTP status is <200 the response contains a JSON response. + logErrorResponse(connection); + + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Response code: " + responseCode); + } else { + // HttpsUrlConnection: -1 (No valid response code) + // Successful 2xx: We don't know how to handle anything but 200. + // Redirection 3xx: We should have followed redirects if possible. We should not see those errors here. + + throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Response code: " + responseCode); + } + } + + return fetchJSONResponse(connection).getJSONArray(KINTO_KEY_DATA); + } catch (JSONException | IOException e) { + throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private JSONObject fetchJSONResponse(HttpURLConnection connection) throws IOException, JSONException { + InputStream inputStream = null; + + try { + inputStream = new BufferedInputStream(connection.getInputStream()); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + IOUtils.copy(inputStream, outputStream); + return new JSONObject(outputStream.toString("UTF-8")); + } finally { + IOUtils.safeStreamClose(inputStream); + } + } + + protected boolean updateContent(DownloadContentCatalog catalog, JSONObject object, DownloadContent existingContent) + throws JSONException { + DownloadContent content = existingContent.buildUpon() + .updateFromKinto(object) + .build(); + + if (existingContent.getLastModified() >= content.getLastModified()) { + Log.d(LOGTAG, "Item has not changed: " + content); + return false; + } + + catalog.update(content); + + return true; + } + + protected boolean createContent(DownloadContentCatalog catalog, JSONObject object) throws JSONException { + DownloadContent content = new DownloadContentBuilder() + .updateFromKinto(object) + .build(); + + catalog.add(content); + + return true; + } + + protected boolean deleteContent(DownloadContentCatalog catalog, String id) { + DownloadContent content = catalog.getContentById(id); + if (content == null) { + return false; + } + + catalog.markAsDeleted(content); + + return true; + } + + protected boolean isSyncEnabledForClient(Context context) { + // Sync action is behind a switchboard flag for staged rollout. + return SwitchBoard.isInExperiment(context, Experiments.DOWNLOAD_CONTENT_CATALOG_SYNC); + } + + private void logErrorResponse(HttpURLConnection connection) { + try { + JSONObject error = fetchJSONResponse(connection); + + Log.w(LOGTAG, "Server returned error response:"); + Log.w(LOGTAG, "- Code: " + error.getInt("code")); + Log.w(LOGTAG, "- Errno: " + error.getInt("errno")); + Log.w(LOGTAG, "- Error: " + error.optString("error", "-")); + Log.w(LOGTAG, "- Message: " + error.optString("message", "-")); + Log.w(LOGTAG, "- Info: " + error.optString("info", "-")); + } catch (JSONException | IOException e) { + Log.w(LOGTAG, "Could not fetch error response", e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java new file mode 100644 index 000000000..e96a62eae --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java @@ -0,0 +1,63 @@ +/* -*- 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.dlc; + +import android.content.Context; +import android.util.Log; + +import org.mozilla.gecko.dlc.catalog.DownloadContent; +import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog; + +import java.io.File; + +/** + * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum? + */ +public class VerifyAction extends BaseAction { + private static final String LOGTAG = "DLCVerifyAction"; + + @Override + public void perform(Context context, DownloadContentCatalog catalog) { + Log.d(LOGTAG, "Verifying catalog.."); + + for (DownloadContent content : catalog.getDownloadedContent()) { + try { + File destinationFile = getDestinationFile(context, content); + + if (!destinationFile.exists()) { + Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content); + + // This file does not exist even though it is marked as downloaded in the catalog. Scheduling a + // download to fetch it again. + catalog.scheduleDownload(content); + continue; + } + + if (!verify(destinationFile, content.getChecksum())) { + catalog.scheduleDownload(content); + Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content); + continue; + } + + Log.v(LOGTAG, "Content okay: " + content); + } catch (UnrecoverableDownloadContentException e) { + Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e); + } catch (RecoverableDownloadContentException e) { + // That's okay, we are just verifying already existing content. No log. + } + } + + if (catalog.hasScheduledDownloads()) { + startDownloads(context); + } + + Log.v(LOGTAG, "Done"); + } + + protected void startDownloads(Context context) { + DownloadContentService.startDownloads(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java new file mode 100644 index 000000000..61f7992ca --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java @@ -0,0 +1,189 @@ +/* -*- 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.dlc.catalog; + +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringDef; + +public class DownloadContent { + @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_UPDATED, STATE_DELETED}) + public @interface State {} + public static final int STATE_NONE = 0; + public static final int STATE_SCHEDULED = 1; + public static final int STATE_DOWNLOADED = 2; + public static final int STATE_FAILED = 3; // Permanently failed for this version of the content + public static final int STATE_UPDATED = 4; + public static final int STATE_DELETED = 5; + + @StringDef({TYPE_ASSET_ARCHIVE}) + public @interface Type {} + public static final String TYPE_ASSET_ARCHIVE = "asset-archive"; + + @StringDef({KIND_FONT, KIND_HYPHENATION_DICTIONARY}) + public @interface Kind {} + public static final String KIND_FONT = "font"; + public static final String KIND_HYPHENATION_DICTIONARY = "hyphenation"; + + private final String id; + private final String location; + private final String filename; + private final String checksum; + private final String downloadChecksum; + private final long lastModified; + private final String type; + private final String kind; + private final long size; + private final String appVersionPattern; + private final String androidApiPattern; + private final String appIdPattern; + private int state; + private int failures; + private int lastFailureType; + + /* package-private */ DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename, + @NonNull String checksum, @NonNull String downloadChecksum, @NonNull long lastModified, + @NonNull String type, @NonNull String kind, long size, int failures, int lastFailureType, + @Nullable String appVersionPattern, @Nullable String androidApiPattern, @Nullable String appIdPattern) { + this.id = id; + this.location = location; + this.filename = filename; + this.checksum = checksum; + this.downloadChecksum = downloadChecksum; + this.lastModified = lastModified; + this.type = type; + this.kind = kind; + this.size = size; + this.state = STATE_NONE; + this.failures = failures; + this.lastFailureType = lastFailureType; + this.appVersionPattern = appVersionPattern; + this.androidApiPattern = androidApiPattern; + this.appIdPattern = appIdPattern; + } + + public String getId() { + return id; + } + + /* package-private */ void setState(@State int state) { + this.state = state; + } + + @State + public int getState() { + return state; + } + + public boolean isStateIn(@State int... states) { + for (int state : states) { + if (this.state == state) { + return true; + } + } + + return false; + } + + @Kind + public String getKind() { + return kind; + } + + @Type + public String getType() { + return type; + } + + public String getLocation() { + return location; + } + + public long getLastModified() { + return lastModified; + } + + public String getFilename() { + return filename; + } + + public String getChecksum() { + return checksum; + } + + public String getDownloadChecksum() { + return downloadChecksum; + } + + public long getSize() { + return size; + } + + public boolean isFont() { + return KIND_FONT.equals(kind); + } + + public boolean isHyphenationDictionary() { + return KIND_HYPHENATION_DICTIONARY.equals(kind); + } + + /** + *Checks whether the content to be downloaded is a known content. + *Currently it checks whether the type is "Asset Archive" and is of kind + *"Font" or "Hyphenation Dictionary". + */ + public boolean isKnownContent() { + return ((isFont() || isHyphenationDictionary()) && isAssetArchive()); + } + + public boolean isAssetArchive() { + return TYPE_ASSET_ARCHIVE.equals(type); + } + + /* package-private */ int getFailures() { + return failures; + } + + /* package-private */ int getLastFailureType() { + return lastFailureType; + } + + /* package-private */ void rememberFailure(int failureType) { + if (lastFailureType != failureType) { + lastFailureType = failureType; + failures = 1; + } else { + failures++; + } + } + + /* package-private */ void resetFailures() { + failures = 0; + lastFailureType = 0; + } + + public String getAppVersionPattern() { + return appVersionPattern; + } + + public String getAndroidApiPattern() { + return androidApiPattern; + } + + public String getAppIdPattern() { + return appIdPattern; + } + + public DownloadContentBuilder buildUpon() { + return DownloadContentBuilder.buildUpon(this); + } + + + public String toString() { + return String.format("[%s,%s] %s (%d bytes) %s", getType(), getKind(), getId(), getSize(), getChecksum()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java new file mode 100644 index 000000000..40c804573 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java @@ -0,0 +1,161 @@ +/* -*- 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.dlc.catalog; + +import android.support.v4.util.ArrayMap; + +import org.mozilla.gecko.AppConstants; + +import java.util.Arrays; +import java.util.List; + +/* package-private */ class DownloadContentBootstrap { + public static ArrayMap<String, DownloadContent> createInitialDownloadContentList() { + if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) { + // We are packaging fonts. There's nothing we want to download; + return new ArrayMap<>(); + } + + List<DownloadContent> initialList = Arrays.asList( + new DownloadContentBuilder() + .setId("c40929cf-7f4c-fa72-3dc9-12cadf56905d") + .setLocation("fennec/catalog/f63e5f92-793c-4574-a2d7-fbc50242b8cb.gz") + .setFilename("CharisSILCompact-B.ttf") + .setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d") + .setDownloadChecksum("a9f9b34fed353169a88cc159b8f298cb285cce0b8b0f979c22a7d85de46f0532") + .setSize(1676072) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("6d265876-85ed-0917-fdc8-baf583ca2cba") + .setLocation("fennec/catalog/19af6c88-09d9-4d6c-805e-cfebb8699a6c.gz") + .setFilename("CharisSILCompact-BI.ttf") + .setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b") + .setDownloadChecksum("2be26671039a5e2e4d0360a948b4fa42048171133076a3bb6173d93d4b9cd55b") + .setSize(1667812) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("8460dc6d-d129-fd1a-24b6-343dbf6531dd") + .setLocation("fennec/catalog/f35a384a-90ea-41c6-a957-bb1845de97eb.gz") + .setFilename("CharisSILCompact-I.ttf") + .setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767") + .setDownloadChecksum("38a6469041c02624d43dfd41d2dd745e3e3211655e616188f65789a90952a1e9") + .setSize(1693988) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("c906275c-3747-fe27-426f-6187526a6f06") + .setLocation("fennec/catalog/8c3bec92-d2df-4789-8c4a-0f523f026d96.gz") + .setFilename("CharisSILCompact-R.ttf") + .setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067") + .setDownloadChecksum("7c2ec1f550c2005b75383b878f737266b5f0b1c82679dd886c8bbe30c82e340e") + .setSize(1727656) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("ff5deecc-6ecc-d816-bb51-65face460119") + .setLocation("fennec/catalog/ea115d71-e2ac-4609-853e-c978780776b1.gz") + .setFilename("ClearSans-Bold.ttf") + .setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70") + .setDownloadChecksum("0d3c22bef90e7096f75b331bb7391de3aa43017e10d61041cd3085816db4919a") + .setSize(140136) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("a173d1db-373b-ce42-1335-6b3285cfdebd") + .setLocation("fennec/catalog/0838e513-2d99-4e53-b58f-6b970f6548c6.gz") + .setFilename("ClearSans-BoldItalic.ttf") + .setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198") + .setDownloadChecksum("de0903164dde1ad3768d0bd6dec949871d6ab7be08f573d9d70f38c138a22e37") + .setSize(156124) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("e65c66df-0088-940d-ca5c-207c22118c0e") + .setLocation("fennec/catalog/7550fa42-0947-478c-a5f0-5ea1bbb6ba27.gz") + .setFilename("ClearSans-Italic.ttf") + .setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68") + .setDownloadChecksum("6e323db3115005dd0e96d2422db87a520f9ae426de28a342cd6cc87b55601d87") + .setSize(155672) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("25610abb-5dc8-fd75-40e7-990507f010c4") + .setLocation("fennec/catalog/dd9bee7d-d784-476b-a3dd-69af8e516487.gz") + .setFilename("ClearSans-Light.ttf") + .setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a") + .setDownloadChecksum("19d4f7c67176e9e254c61420da9c7363d9fe5e6b4bb9d61afa4b3b574280714f") + .setSize(145976) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("ffe40339-a096-2262-c3f8-54af75c81fe6") + .setLocation("fennec/catalog/bc5ada8c-8cfc-443d-93d7-dc5f98138a07.gz") + .setFilename("ClearSans-Medium.ttf") + .setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1") + .setDownloadChecksum("edec86dab3ad2a97561cb41b584670262a48bed008c57bb587ee05ca47fb067f") + .setSize(148892) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("139a94be-ac69-0264-c9cc-8f2d071fd29d") + .setLocation("fennec/catalog/0490c768-6178-49c2-af88-9f8769ff3167.gz") + .setFilename("ClearSans-MediumItalic.ttf") + .setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7") + .setDownloadChecksum("34edbd1b325dbffe7791fba8dd2d19852eb3c2fe00cff517ea2161ddc424ee22") + .setSize(155228) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("b887012a-01e1-7c94-fdcb-ca44d5b974a2") + .setLocation("fennec/catalog/78205bf8-c668-41b1-b68f-afd54f98713b.gz") + .setFilename("ClearSans-Regular.ttf") + .setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b") + .setDownloadChecksum("a72f1420b4da1ba9e6797adac34f08e72f94128a85e56542d5e6a8080af5f08a") + .setSize(142572) + .setKind("font") + .setType("asset-archive") + .build(), + + new DownloadContentBuilder() + .setId("c8703652-d317-0356-0bf8-95441a5b2c9b") + .setLocation("fennec/catalog/3570f44f-9440-4aa0-abd0-642eaf2a1aa0.gz") + .setFilename("ClearSans-Thin.ttf") + .setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a") + .setDownloadChecksum("d9f23fd8687d6743f5c281c33539fb16f163304795039959b8caf159e6d62822") + .setSize(147004) + .setKind("font") + .setType("asset-archive") + .build()); + + ArrayMap<String, DownloadContent> content = new ArrayMap<>(); + for (DownloadContent currentContent : initialList) { + content.put(currentContent.getId(), currentContent); + } + return content; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java new file mode 100644 index 000000000..243e2d4eb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java @@ -0,0 +1,238 @@ +/* -*- 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.dlc.catalog; + +import android.text.TextUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +public class DownloadContentBuilder { + private static final String LOCAL_KEY_ID = "id"; + private static final String LOCAL_KEY_LOCATION = "location"; + private static final String LOCAL_KEY_FILENAME = "filename"; + private static final String LOCAL_KEY_CHECKSUM = "checksum"; + private static final String LOCAL_KEY_DOWNLOAD_CHECKSUM = "download_checksum"; + private static final String LOCAL_KEY_LAST_MODIFIED = "last_modified"; + private static final String LOCAL_KEY_TYPE = "type"; + private static final String LOCAL_KEY_KIND = "kind"; + private static final String LOCAL_KEY_SIZE = "size"; + private static final String LOCAL_KEY_STATE = "state"; + private static final String LOCAL_KEY_FAILURES = "failures"; + private static final String LOCAL_KEY_LAST_FAILURE_TYPE = "last_failure_type"; + private static final String LOCAL_KEY_PATTERN_APP_ID = "pattern_app_id"; + private static final String LOCAL_KEY_PATTERN_ANDROID_API = "pattern_android_api"; + private static final String LOCAL_KEY_PATTERN_APP_VERSION = "pattern_app_version"; + + private static final String KINTO_KEY_ID = "id"; + private static final String KINTO_KEY_ATTACHMENT = "attachment"; + private static final String KINTO_KEY_ORIGINAL = "original"; + private static final String KINTO_KEY_LOCATION = "location"; + private static final String KINTO_KEY_FILENAME = "filename"; + private static final String KINTO_KEY_HASH = "hash"; + private static final String KINTO_KEY_LAST_MODIFIED = "last_modified"; + private static final String KINTO_KEY_TYPE = "type"; + private static final String KINTO_KEY_KIND = "kind"; + private static final String KINTO_KEY_SIZE = "size"; + private static final String KINTO_KEY_MATCH = "match"; + private static final String KINTO_KEY_APP_ID = "appId"; + private static final String KINTO_KEY_ANDROID_API = "androidApi"; + private static final String KINTO_KEY_APP_VERSION = "appVersion"; + + private String id; + private String location; + private String filename; + private String checksum; + private String downloadChecksum; + private long lastModified; + private String type; + private String kind; + private long size; + private int state; + private int failures; + private int lastFailureType; + private String appVersionPattern; + private String androidApiPattern; + private String appIdPattern; + + public static DownloadContentBuilder buildUpon(DownloadContent content) { + DownloadContentBuilder builder = new DownloadContentBuilder(); + + builder.id = content.getId(); + builder.location = content.getLocation(); + builder.filename = content.getFilename(); + builder.checksum = content.getChecksum(); + builder.downloadChecksum = content.getDownloadChecksum(); + builder.lastModified = content.getLastModified(); + builder.type = content.getType(); + builder.kind = content.getKind(); + builder.size = content.getSize(); + builder.state = content.getState(); + builder.failures = content.getFailures(); + builder.lastFailureType = content.getLastFailureType(); + + return builder; + } + + public static DownloadContent fromJSON(JSONObject object) throws JSONException { + return new DownloadContentBuilder() + .setId(object.getString(LOCAL_KEY_ID)) + .setLocation(object.getString(LOCAL_KEY_LOCATION)) + .setFilename(object.getString(LOCAL_KEY_FILENAME)) + .setChecksum(object.getString(LOCAL_KEY_CHECKSUM)) + .setDownloadChecksum(object.getString(LOCAL_KEY_DOWNLOAD_CHECKSUM)) + .setLastModified(object.getLong(LOCAL_KEY_LAST_MODIFIED)) + .setType(object.getString(LOCAL_KEY_TYPE)) + .setKind(object.getString(LOCAL_KEY_KIND)) + .setSize(object.getLong(LOCAL_KEY_SIZE)) + .setState(object.getInt(LOCAL_KEY_STATE)) + .setFailures(object.optInt(LOCAL_KEY_FAILURES), object.optInt(LOCAL_KEY_LAST_FAILURE_TYPE)) + .setAppVersionPattern(object.optString(LOCAL_KEY_PATTERN_APP_VERSION)) + .setAppIdPattern(object.optString(LOCAL_KEY_PATTERN_APP_ID)) + .setAndroidApiPattern(object.optString(LOCAL_KEY_PATTERN_ANDROID_API)) + .build(); + } + + public static JSONObject toJSON(DownloadContent content) throws JSONException { + final JSONObject object = new JSONObject(); + object.put(LOCAL_KEY_ID, content.getId()); + object.put(LOCAL_KEY_LOCATION, content.getLocation()); + object.put(LOCAL_KEY_FILENAME, content.getFilename()); + object.put(LOCAL_KEY_CHECKSUM, content.getChecksum()); + object.put(LOCAL_KEY_DOWNLOAD_CHECKSUM, content.getDownloadChecksum()); + object.put(LOCAL_KEY_LAST_MODIFIED, content.getLastModified()); + object.put(LOCAL_KEY_TYPE, content.getType()); + object.put(LOCAL_KEY_KIND, content.getKind()); + object.put(LOCAL_KEY_SIZE, content.getSize()); + object.put(LOCAL_KEY_STATE, content.getState()); + object.put(LOCAL_KEY_PATTERN_APP_VERSION, content.getAppVersionPattern()); + object.put(LOCAL_KEY_PATTERN_APP_ID, content.getAppIdPattern()); + object.put(LOCAL_KEY_PATTERN_ANDROID_API, content.getAndroidApiPattern()); + + final int failures = content.getFailures(); + if (failures > 0) { + object.put(LOCAL_KEY_FAILURES, failures); + object.put(LOCAL_KEY_LAST_FAILURE_TYPE, content.getLastFailureType()); + } + + return object; + } + + public DownloadContent build() { + DownloadContent content = new DownloadContent(id, location, filename, checksum, + downloadChecksum, lastModified, type, kind, size, failures, lastFailureType, + appVersionPattern, androidApiPattern, appIdPattern); + content.setState(state); + + return content; + } + + public DownloadContentBuilder setId(String id) { + this.id = id; + return this; + } + + public DownloadContentBuilder setLocation(String location) { + this.location = location; + return this; + } + + public DownloadContentBuilder setFilename(String filename) { + this.filename = filename; + return this; + } + + public DownloadContentBuilder setChecksum(String checksum) { + this.checksum = checksum; + return this; + } + + public DownloadContentBuilder setDownloadChecksum(String downloadChecksum) { + this.downloadChecksum = downloadChecksum; + return this; + } + + public DownloadContentBuilder setLastModified(long lastModified) { + this.lastModified = lastModified; + return this; + } + + public DownloadContentBuilder setType(String type) { + this.type = type; + return this; + } + + public DownloadContentBuilder setKind(String kind) { + this.kind = kind; + return this; + } + + public DownloadContentBuilder setSize(long size) { + this.size = size; + return this; + } + + public DownloadContentBuilder setState(int state) { + this.state = state; + return this; + } + + /* package-private */ DownloadContentBuilder setFailures(int failures, int lastFailureType) { + this.failures = failures; + this.lastFailureType = lastFailureType; + + return this; + } + + public DownloadContentBuilder setAppVersionPattern(String appVersionPattern) { + this.appVersionPattern = appVersionPattern; + return this; + } + + public DownloadContentBuilder setAndroidApiPattern(String androidApiPattern) { + this.androidApiPattern = androidApiPattern; + return this; + } + + public DownloadContentBuilder setAppIdPattern(String appIdPattern) { + this.appIdPattern = appIdPattern; + return this; + } + + public DownloadContentBuilder updateFromKinto(JSONObject object) throws JSONException { + final String objectId = object.getString(KINTO_KEY_ID); + + if (TextUtils.isEmpty(id)) { + // New object without an id yet + id = objectId; + } else if (!id.equals(objectId)) { + throw new JSONException(String.format("Record ids do not match: Expected=%s, Actual=%s", id, objectId)); + } + + setType(object.getString(KINTO_KEY_TYPE)); + setKind(object.getString(KINTO_KEY_KIND)); + setLastModified(object.getLong(KINTO_KEY_LAST_MODIFIED)); + + JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT); + JSONObject original = attachment.getJSONObject(KINTO_KEY_ORIGINAL); + + setFilename(original.getString(KINTO_KEY_FILENAME)); + setChecksum(original.getString(KINTO_KEY_HASH)); + setSize(original.getLong(KINTO_KEY_SIZE)); + + setLocation(attachment.getString(KINTO_KEY_LOCATION)); + setDownloadChecksum(attachment.getString(KINTO_KEY_HASH)); + + JSONObject match = object.optJSONObject(KINTO_KEY_MATCH); + if (match != null) { + setAndroidApiPattern(match.optString(KINTO_KEY_ANDROID_API)); + setAppIdPattern(match.optString(KINTO_KEY_APP_ID)); + setAppVersionPattern(match.optString(KINTO_KEY_APP_VERSION)); + } + + return this; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java new file mode 100644 index 000000000..43ba4e82e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java @@ -0,0 +1,303 @@ +/* -*- 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.dlc.catalog; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.v4.util.ArrayMap; +import android.support.v4.util.AtomicFile; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; + +/** + * Catalog of downloadable content (DLC). + * + * Changing elements returned by the catalog should be guarded by the catalog instance to guarantee visibility when + * persisting changes. + */ +public class DownloadContentCatalog { + private static final String LOGTAG = "GeckoDLCCatalog"; + private static final String FILE_NAME = "download_content_catalog"; + + private static final String JSON_KEY_CONTENT = "content"; + + private static final int MAX_FAILURES_UNTIL_PERMANENTLY_FAILED = 10; + + private final AtomicFile file; // Guarded by 'file' + + private ArrayMap<String, DownloadContent> content; // Guarded by 'this' + private boolean hasLoadedCatalog; // Guarded by 'this + private boolean hasCatalogChanged; // Guarded by 'this' + + public DownloadContentCatalog(Context context) { + this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME))); + + startLoadFromDisk(); + } + + // For injecting mocked AtomicFile objects during test + protected DownloadContentCatalog(AtomicFile file) { + this.content = new ArrayMap<>(); + this.file = file; + } + + public List<DownloadContent> getContentToStudy() { + return filterByState(DownloadContent.STATE_NONE, DownloadContent.STATE_UPDATED); + } + + public List<DownloadContent> getContentToDelete() { + return filterByState(DownloadContent.STATE_DELETED); + } + + public List<DownloadContent> getDownloadedContent() { + return filterByState(DownloadContent.STATE_DOWNLOADED); + } + + public List<DownloadContent> getScheduledDownloads() { + return filterByState(DownloadContent.STATE_SCHEDULED); + } + + private synchronized List<DownloadContent> filterByState(@DownloadContent.State int... filterStates) { + awaitLoadingCatalogLocked(); + + List<DownloadContent> filteredContent = new ArrayList<>(); + + for (DownloadContent currentContent : content.values()) { + if (currentContent.isStateIn(filterStates)) { + filteredContent.add(currentContent); + } + } + + return filteredContent; + } + + public boolean hasScheduledDownloads() { + return !filterByState(DownloadContent.STATE_SCHEDULED).isEmpty(); + } + + public synchronized void add(DownloadContent newContent) { + awaitLoadingCatalogLocked(); + + content.put(newContent.getId(), newContent); + hasCatalogChanged = true; + } + + public synchronized void update(DownloadContent changedContent) { + awaitLoadingCatalogLocked(); + + if (!content.containsKey(changedContent.getId())) { + Log.w(LOGTAG, "Did not find content with matching id (" + changedContent.getId() + ") to update"); + return; + } + + changedContent.setState(DownloadContent.STATE_UPDATED); + changedContent.resetFailures(); + + content.put(changedContent.getId(), changedContent); + hasCatalogChanged = true; + } + + public synchronized void remove(DownloadContent removedContent) { + awaitLoadingCatalogLocked(); + + if (!content.containsKey(removedContent.getId())) { + Log.w(LOGTAG, "Did not find content with matching id (" + removedContent.getId() + ") to remove"); + return; + } + + content.remove(removedContent.getId()); + } + + @Nullable + public synchronized DownloadContent getContentById(String id) { + return content.get(id); + } + + public synchronized long getLastModified() { + awaitLoadingCatalogLocked(); + + long lastModified = 0; + + for (DownloadContent currentContent : content.values()) { + if (currentContent.getLastModified() > lastModified) { + lastModified = currentContent.getLastModified(); + } + } + + return lastModified; + } + + public synchronized void scheduleDownload(DownloadContent content) { + content.setState(DownloadContent.STATE_SCHEDULED); + hasCatalogChanged = true; + } + + public synchronized void markAsDownloaded(DownloadContent content) { + content.setState(DownloadContent.STATE_DOWNLOADED); + content.resetFailures(); + hasCatalogChanged = true; + } + + public synchronized void markAsPermanentlyFailed(DownloadContent content) { + content.setState(DownloadContent.STATE_FAILED); + hasCatalogChanged = true; + } + + public synchronized void markAsDeleted(DownloadContent content) { + content.setState(DownloadContent.STATE_DELETED); + hasCatalogChanged = true; + } + + public synchronized void rememberFailure(DownloadContent content, int failureType) { + if (content.getFailures() >= MAX_FAILURES_UNTIL_PERMANENTLY_FAILED) { + Log.d(LOGTAG, "Maximum number of failures reached. Marking content has permanently failed."); + + markAsPermanentlyFailed(content); + } else { + content.rememberFailure(failureType); + hasCatalogChanged = true; + } + } + + public void persistChanges() { + new Thread(LOGTAG + "-Persist") { + public void run() { + writeToDisk(); + } + }.start(); + } + + private void startLoadFromDisk() { + new Thread(LOGTAG + "-Load") { + public void run() { + loadFromDisk(); + } + }.start(); + } + + private void awaitLoadingCatalogLocked() { + while (!hasLoadedCatalog) { + try { + Log.v(LOGTAG, "Waiting for catalog to be loaded"); + + wait(); + } catch (InterruptedException e) { + // Ignore + } + } + } + + protected synchronized boolean hasCatalogChanged() { + return hasCatalogChanged; + } + + protected synchronized void loadFromDisk() { + Log.d(LOGTAG, "Loading from disk"); + + if (hasLoadedCatalog) { + return; + } + + ArrayMap<String, DownloadContent> loadedContent = new ArrayMap<>(); + + try { + JSONObject catalog; + + synchronized (file) { + catalog = new JSONObject(new String(file.readFully(), "UTF-8")); + } + + JSONArray array = catalog.getJSONArray(JSON_KEY_CONTENT); + for (int i = 0; i < array.length(); i++) { + DownloadContent currentContent = DownloadContentBuilder.fromJSON(array.getJSONObject(i)); + loadedContent.put(currentContent.getId(), currentContent); + } + } catch (FileNotFoundException e) { + Log.d(LOGTAG, "Catalog file does not exist: Bootstrapping initial catalog"); + loadedContent = DownloadContentBootstrap.createInitialDownloadContentList(); + } catch (JSONException e) { + Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e); + // Catalog seems to be broken. Re-create catalog: + loadedContent = DownloadContentBootstrap.createInitialDownloadContentList(); + hasCatalogChanged = true; // Indicate that we want to persist the new catalog + } catch (NullPointerException e) { + // Bad content can produce an NPE in JSON code -- bug 1300139 + Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e); + // Catalog seems to be broken. Re-create catalog: + loadedContent = DownloadContentBootstrap.createInitialDownloadContentList(); + hasCatalogChanged = true; // Indicate that we want to persist the new catalog + } catch (UnsupportedEncodingException e) { + AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8"); + error.initCause(e); + throw error; + } catch (IOException e) { + Log.d(LOGTAG, "Can't read catalog due to IOException", e); + } + + onCatalogLoaded(loadedContent); + + notifyAll(); + + Log.d(LOGTAG, "Loaded " + content.size() + " elements"); + } + + protected void onCatalogLoaded(ArrayMap<String, DownloadContent> content) { + this.content = content; + this.hasLoadedCatalog = true; + } + + protected synchronized void writeToDisk() { + if (!hasCatalogChanged) { + Log.v(LOGTAG, "Not persisting: Catalog has not changed"); + return; + } + + Log.d(LOGTAG, "Writing to disk"); + + FileOutputStream outputStream = null; + + synchronized (file) { + try { + outputStream = file.startWrite(); + + JSONArray array = new JSONArray(); + for (DownloadContent currentContent : content.values()) { + array.put(DownloadContentBuilder.toJSON(currentContent)); + } + + JSONObject catalog = new JSONObject(); + catalog.put(JSON_KEY_CONTENT, array); + + outputStream.write(catalog.toString().getBytes("UTF-8")); + + file.finishWrite(outputStream); + + hasCatalogChanged = false; + } catch (UnsupportedEncodingException e) { + AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8"); + error.initCause(e); + throw error; + } catch (IOException | JSONException e) { + Log.e(LOGTAG, "IOException during writing catalog", e); + + if (outputStream != null) { + file.failWrite(outputStream); + } + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java b/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java new file mode 100644 index 000000000..d317a21ee --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java @@ -0,0 +1,89 @@ +/* -*- 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.feeds; + +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.NotificationManagerCompat; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.delegates.BrowserAppDelegate; +import org.mozilla.gecko.mozglue.SafeIntent; + +import java.util.List; + +/** + * BrowserAppDelegate implementation that takes care of handling intents from content notifications. + */ +public class ContentNotificationsDelegate extends BrowserAppDelegate { + // The application is opened from a content notification + public static final String ACTION_CONTENT_NOTIFICATION = AppConstants.ANDROID_PACKAGE_NAME + ".action.CONTENT_NOTIFICATION"; + + public static final String EXTRA_READ_BUTTON = "read_button"; + public static final String EXTRA_URLS = "urls"; + + private static final String TELEMETRY_EXTRA_CONTENT_UPDATE = "content_update"; + private static final String TELEMETRY_EXTRA_READ_NOW_BUTTON = TELEMETRY_EXTRA_CONTENT_UPDATE + "_read_now"; + + @Override + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + if (savedInstanceState != null) { + // This activity is getting restored: We do not want to handle the URLs in the Intent again. The browser + // will take care of restoring the tabs we already created. + return; + } + + + final Intent unsafeIntent = browserApp.getIntent(); + + // Nothing to do. + if (unsafeIntent == null) { + return; + } + + final SafeIntent intent = new SafeIntent(unsafeIntent); + + if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) { + openURLsFromIntent(browserApp, intent); + } + } + + @Override + public void onNewIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) { + if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) { + openURLsFromIntent(browserApp, intent); + } + } + + private void openURLsFromIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) { + final List<String> urls = intent.getStringArrayListExtra(EXTRA_URLS); + if (urls != null) { + browserApp.openUrls(urls); + } + + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp)); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, TELEMETRY_EXTRA_CONTENT_UPDATE); + + if (intent.getBooleanExtra(EXTRA_READ_BUTTON, false)) { + // "READ NOW" button in notification was clicked + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_READ_NOW_BUTTON); + + // Android's "auto cancel" won't remove the notification when an action button is pressed. So we do it ourselves here. + NotificationManagerCompat.from(browserApp).cancel(R.id.websiteContentNotification); + } else { + // Notification was clicked + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_CONTENT_UPDATE); + } + + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java new file mode 100644 index 000000000..d943b4f81 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java @@ -0,0 +1,31 @@ +/* -*- 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.feeds; + +import android.content.Context; +import android.content.Intent; +import android.support.v4.content.WakefulBroadcastReceiver; +import android.util.Log; + +/** + * Broadcast receiver that will receive broadcasts from the AlarmManager and start the FeedService + * with the given action. + */ +public class FeedAlarmReceiver extends WakefulBroadcastReceiver { + private static final String LOGTAG = "FeedCheckAction"; + + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + + Log.d(LOGTAG, "Received alarm with action: " + action); + + final Intent serviceIntent = new Intent(context, FeedService.class); + serviceIntent.setAction(action); + + startWakefulService(context, serviceIntent); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java new file mode 100644 index 000000000..76c1b7e30 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java @@ -0,0 +1,110 @@ +/* -*- 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.feeds; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.mozilla.gecko.feeds.parser.Feed; +import org.mozilla.gecko.feeds.parser.SimpleFeedParser; +import org.mozilla.gecko.util.IOUtils; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +/** + * Helper class for fetching and parsing a feed. + */ +public class FeedFetcher { + private static final int CONNECT_TIMEOUT = 15000; + private static final int READ_TIMEOUT = 15000; + + public static class FeedResponse { + public final Feed feed; + public final String etag; + public final String lastModified; + + public FeedResponse(Feed feed, String etag, String lastModified) { + this.feed = feed; + this.etag = etag; + this.lastModified = lastModified; + } + } + + /** + * Fetch and parse a feed from the given URL. Will return null if fetching or parsing failed. + */ + public static FeedResponse fetchAndParseFeed(String url) { + return fetchAndParseFeedIfModified(url, null, null); + } + + /** + * Fetch and parse a feed from the given URL using the given ETag and "Last modified" value. + * + * Will return null if fetching or parsing failed. Will also return null if the feed has not + * changed (ETag / Last-Modified-Since). + * + * @param eTag The ETag from the last fetch or null if no ETag is available (will always fetch feed) + * @param lastModified The "Last modified" header from the last time the feed has been fetch or + * null if no value is available (will always fetch feed) + * @return A FeedResponse or null if no feed could be fetched (error or no new version available) + */ + @Nullable + public static FeedResponse fetchAndParseFeedIfModified(@NonNull String url, @Nullable String eTag, @Nullable String lastModified) { + HttpURLConnection connection = null; + InputStream stream = null; + + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + + if (!TextUtils.isEmpty(eTag)) { + connection.setRequestProperty("If-None-Match", eTag); + } + + if (!TextUtils.isEmpty(lastModified)) { + connection.setRequestProperty("If-Modified-Since", lastModified); + } + + final int statusCode = connection.getResponseCode(); + + if (statusCode != HttpURLConnection.HTTP_OK) { + return null; + } + + String responseEtag = connection.getHeaderField("ETag"); + if (!TextUtils.isEmpty(responseEtag) && responseEtag.startsWith("W/")) { + // Weak ETag, get actual ETag value + responseEtag = responseEtag.substring(2); + } + + final String updatedLastModified = connection.getHeaderField("Last-Modified"); + + stream = new BufferedInputStream(connection.getInputStream()); + + final SimpleFeedParser parser = new SimpleFeedParser(); + final Feed feed = parser.parse(stream); + + return new FeedResponse(feed, responseEtag, updatedLastModified); + } catch (IOException e) { + return null; + } catch (SimpleFeedParser.ParserException e) { + return null; + } finally { + if (connection != null) { + connection.disconnect(); + } + IOUtils.safeStreamClose(stream); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java new file mode 100644 index 000000000..374486215 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java @@ -0,0 +1,168 @@ +/* -*- 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.feeds; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.annotation.Nullable; +import android.support.v4.net.ConnectivityManagerCompat; +import android.util.Log; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.feeds.action.FeedAction; +import org.mozilla.gecko.feeds.action.CheckForUpdatesAction; +import org.mozilla.gecko.feeds.action.EnrollSubscriptionsAction; +import org.mozilla.gecko.feeds.action.SetupAlarmsAction; +import org.mozilla.gecko.feeds.action.SubscribeToFeedAction; +import org.mozilla.gecko.feeds.action.WithdrawSubscriptionsAction; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.Experiments; + +/** + * Background service for subscribing to and checking website feeds to notify the user about updates. + */ +public class FeedService extends IntentService { + private static final String LOGTAG = "GeckoFeedService"; + + public static final String ACTION_SETUP = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SETUP"; + public static final String ACTION_SUBSCRIBE = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SUBSCRIBE"; + public static final String ACTION_CHECK = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.CHECK"; + public static final String ACTION_ENROLL = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.ENROLL"; + public static final String ACTION_WITHDRAW = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.WITHDRAW"; + + public static void setup(Context context) { + Intent intent = new Intent(context, FeedService.class); + intent.setAction(ACTION_SETUP); + context.startService(intent); + } + + public static void subscribe(Context context, String feedUrl) { + Intent intent = new Intent(context, FeedService.class); + intent.setAction(ACTION_SUBSCRIBE); + intent.putExtra(SubscribeToFeedAction.EXTRA_FEED_URL, feedUrl); + context.startService(intent); + } + + public FeedService() { + super(LOGTAG); + } + + private BrowserDB browserDB; + + @Override + public void onCreate() { + super.onCreate(); + + browserDB = BrowserDB.from(this); + } + + @Override + protected void onHandleIntent(Intent intent) { + try { + if (intent == null) { + return; + } + + Log.d(LOGTAG, "Service started with action: " + intent.getAction()); + + if (!isInExperiment(this)) { + Log.d(LOGTAG, "Not in content notifications experiment. Skipping."); + return; + } + + FeedAction action = createActionForIntent(intent); + if (action == null) { + Log.d(LOGTAG, "No action to process"); + return; + } + + if (action.requiresPreferenceEnabled() && !isPreferenceEnabled()) { + Log.d(LOGTAG, "Preference is disabled. Skipping."); + return; + } + + if (action.requiresNetwork() && !isConnectedToUnmeteredNetwork()) { + // For now just skip if we are not connected or the network is metered. We do not want + // to use precious mobile traffic. + Log.d(LOGTAG, "Not connected to a network or network is metered. Skipping."); + return; + } + + action.perform(browserDB, intent); + } finally { + FeedAlarmReceiver.completeWakefulIntent(intent); + } + + Log.d(LOGTAG, "Done."); + } + + @Nullable + private FeedAction createActionForIntent(Intent intent) { + final Context context = getApplicationContext(); + + switch (intent.getAction()) { + case ACTION_SETUP: + return new SetupAlarmsAction(context); + + case ACTION_SUBSCRIBE: + return new SubscribeToFeedAction(context); + + case ACTION_CHECK: + return new CheckForUpdatesAction(context); + + case ACTION_ENROLL: + return new EnrollSubscriptionsAction(context); + + case ACTION_WITHDRAW: + return new WithdrawSubscriptionsAction(context); + + default: + throw new AssertionError("Unknown action: " + intent.getAction()); + } + } + + private boolean isConnectedToUnmeteredNetwork() { + ConnectivityManager manager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null || !networkInfo.isConnected()) { + return false; + } + + return !ConnectivityManagerCompat.isActiveNetworkMetered(manager); + } + + public static boolean isInExperiment(Context context) { + return SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS) || + SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM) || + SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM); + } + + public static String getEnabledExperiment(Context context) { + String experiment = null; + + if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) { + experiment = Experiments.CONTENT_NOTIFICATIONS_12HRS; + } else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) { + experiment = Experiments.CONTENT_NOTIFICATIONS_8AM; + } else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) { + experiment = Experiments.CONTENT_NOTIFICATIONS_5PM; + } + + return experiment; + } + + private boolean isPreferenceEnabled() { + return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_CONTENT, true); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java new file mode 100644 index 000000000..09a2b12b6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java @@ -0,0 +1,281 @@ +/* -*- 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.feeds.action; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.content.ContextCompat; +import android.text.format.DateFormat; + +import org.json.JSONException; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.feeds.ContentNotificationsDelegate; +import org.mozilla.gecko.feeds.FeedFetcher; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.feeds.parser.Feed; +import org.mozilla.gecko.feeds.subscriptions.FeedSubscription; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.StringUtils; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * CheckForUpdatesAction: Check if feeds we subscribed to have new content available. + */ +public class CheckForUpdatesAction extends FeedAction { + /** + * This extra will be added to Intents fired by the notification. + */ + public static final String EXTRA_CONTENT_NOTIFICATION = "content-notification"; + + private final Context context; + + public CheckForUpdatesAction(Context context) { + this.context = context; + } + + @Override + public void perform(BrowserDB browserDB, Intent intent) { + final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations(); + final ContentResolver resolver = context.getContentResolver(); + final List<Feed> updatedFeeds = new ArrayList<>(); + + log("Checking feeds for updates.."); + + Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver); + if (cursor == null) { + return; + } + + try { + while (cursor.moveToNext()) { + FeedSubscription subscription = FeedSubscription.fromCursor(cursor); + + FeedFetcher.FeedResponse response = checkFeedForUpdates(subscription); + if (response != null) { + final Feed feed = response.feed; + + if (!hasBeenVisited(browserDB, feed.getLastItem().getURL())) { + // Only notify about this update if the last item hasn't been visited yet. + updatedFeeds.add(feed); + } else { + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, + TelemetryContract.Method.SERVICE, + "content_update"); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + } + + urlAnnotations.updateFeedSubscription(resolver, subscription); + } + } + } catch (JSONException e) { + log("Could not deserialize subscription", e); + } finally { + cursor.close(); + } + + showNotification(updatedFeeds); + } + + private FeedFetcher.FeedResponse checkFeedForUpdates(FeedSubscription subscription) { + log("Checking feed: " + subscription.getFeedTitle()); + + FeedFetcher.FeedResponse response = fetchFeed(subscription); + if (response == null) { + return null; + } + + if (subscription.hasBeenUpdated(response)) { + log("* Feed has changed. New item: " + response.feed.getLastItem().getTitle()); + + subscription.update(response); + + return response; + + } + + return null; + } + + /** + * Returns true if this URL has been visited before. + * + * We do an exact match. So this can fail if the feed uses a different URL and redirects to + * content. But it's better than no checks at all. + */ + private boolean hasBeenVisited(final BrowserDB browserDB, final String url) { + final Cursor cursor = browserDB.getHistoryForURL(context.getContentResolver(), url); + if (cursor == null) { + return false; + } + + try { + if (cursor.moveToFirst()) { + return cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)) > 0; + } + } finally { + cursor.close(); + } + + return false; + } + + private void showNotification(List<Feed> updatedFeeds) { + final int feedCount = updatedFeeds.size(); + if (feedCount == 0) { + return; + } + + if (feedCount == 1) { + showNotificationForSingleUpdate(updatedFeeds.get(0)); + } else { + showNotificationForMultipleUpdates(updatedFeeds); + } + + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, "content_update"); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + } + + private void showNotificationForSingleUpdate(Feed feed) { + final String date = DateFormat.getMediumDateFormat(context).format(new Date(feed.getLastItem().getTimestamp())); + + final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle() + .bigText(feed.getLastItem().getTitle()) + .setBigContentTitle(feed.getTitle()) + .setSummaryText(context.getString(R.string.content_notification_updated_on, date)); + + final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feed), PendingIntent.FLAG_UPDATE_CURRENT); + + final Notification notification = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentTitle(feed.getTitle()) + .setContentText(feed.getLastItem().getTitle()) + .setStyle(style) + .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .addAction(createOpenAction(feed)) + .addAction(createNotificationSettingsAction()) + .build(); + + NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification); + } + + private void showNotificationForMultipleUpdates(List<Feed> feeds) { + final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + for (Feed feed : feeds) { + inboxStyle.addLine(StringUtils.stripScheme(feed.getLastItem().getURL(), StringUtils.UrlFlags.STRIP_HTTPS)); + } + inboxStyle.setSummaryText(context.getString(R.string.content_notification_summary)); + + final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feeds), PendingIntent.FLAG_UPDATE_CURRENT); + + Notification notification = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentTitle(context.getString(R.string.content_notification_title_plural, feeds.size())) + .setContentText(context.getString(R.string.content_notification_summary)) + .setStyle(inboxStyle) + .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .addAction(createOpenAction(feeds)) + .setNumber(feeds.size()) + .addAction(createNotificationSettingsAction()) + .build(); + + NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification); + } + + private Intent createOpenIntent(Feed feed) { + final List<Feed> feeds = new ArrayList<>(); + feeds.add(feed); + + return createOpenIntent(feeds); + } + + private Intent createOpenIntent(List<Feed> feeds) { + final ArrayList<String> urls = new ArrayList<>(); + for (Feed feed : feeds) { + urls.add(feed.getLastItem().getURL()); + } + + final Intent intent = new Intent(context, BrowserApp.class); + intent.setAction(ContentNotificationsDelegate.ACTION_CONTENT_NOTIFICATION); + intent.putStringArrayListExtra(ContentNotificationsDelegate.EXTRA_URLS, urls); + + return intent; + } + + private NotificationCompat.Action createOpenAction(Feed feed) { + final List<Feed> feeds = new ArrayList<>(); + feeds.add(feed); + + return createOpenAction(feeds); + } + + private NotificationCompat.Action createOpenAction(List<Feed> feeds) { + Intent intent = createOpenIntent(feeds); + intent.putExtra(ContentNotificationsDelegate.EXTRA_READ_BUTTON, true); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + return new NotificationCompat.Action( + R.drawable.open_in_browser, + context.getString(R.string.content_notification_action_read_now), + pendingIntent); + } + + private NotificationCompat.Action createNotificationSettingsAction() { + final Intent intent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS); + intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true); + + GeckoPreferences.setResourceToOpen(intent, "preferences_notifications"); + + PendingIntent settingsIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + return new NotificationCompat.Action( + R.drawable.settings_notifications, + context.getString(R.string.content_notification_action_settings), + settingsIntent); + } + + private FeedFetcher.FeedResponse fetchFeed(FeedSubscription subscription) { + return FeedFetcher.fetchAndParseFeedIfModified( + subscription.getFeedUrl(), + subscription.getETag(), + subscription.getLastModified() + ); + } + + @Override + public boolean requiresNetwork() { + return true; + } + + @Override + public boolean requiresPreferenceEnabled() { + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java new file mode 100644 index 000000000..b778938fd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java @@ -0,0 +1,101 @@ +/* -*- 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.feeds.action; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.text.TextUtils; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.feeds.knownsites.KnownSiteBlogger; +import org.mozilla.gecko.feeds.knownsites.KnownSite; +import org.mozilla.gecko.feeds.knownsites.KnownSiteMedium; +import org.mozilla.gecko.feeds.knownsites.KnownSiteTumblr; +import org.mozilla.gecko.feeds.knownsites.KnownSiteWordpress; + +/** + * EnrollSubscriptionsAction: Search for bookmarks of known sites we can subscribe to. + */ +public class EnrollSubscriptionsAction extends FeedAction { + private static final String LOGTAG = "FeedEnrollAction"; + + private static final KnownSite[] knownSites = { + new KnownSiteMedium(), + new KnownSiteBlogger(), + new KnownSiteWordpress(), + new KnownSiteTumblr(), + }; + + private Context context; + + public EnrollSubscriptionsAction(Context context) { + this.context = context; + } + + @Override + public void perform(BrowserDB db, Intent intent) { + log("Searching for bookmarks to enroll in updates"); + + final ContentResolver contentResolver = context.getContentResolver(); + + for (KnownSite knownSite : knownSites) { + searchFor(db, contentResolver, knownSite); + } + } + + @Override + public boolean requiresNetwork() { + return false; + } + + @Override + public boolean requiresPreferenceEnabled() { + return true; + } + + private void searchFor(BrowserDB db, ContentResolver contentResolver, KnownSite knownSite) { + final UrlAnnotations urlAnnotations = db.getUrlAnnotations(); + + final Cursor cursor = db.getBookmarksForPartialUrl(contentResolver, knownSite.getURLSearchString()); + if (cursor == null) { + log("Nothing found (" + knownSite.getClass().getSimpleName() + ")"); + return; + } + + try { + log("Found " + cursor.getCount() + " websites"); + + while (cursor.moveToNext()) { + + final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.Bookmarks.URL)); + + log(" URL: " + url); + + String feedUrl = knownSite.getFeedFromURL(url); + if (TextUtils.isEmpty(feedUrl)) { + log("Could not determine feed for URL: " + url); + return; + } + + if (!urlAnnotations.hasFeedUrlForWebsite(contentResolver, url)) { + urlAnnotations.insertFeedUrl(contentResolver, url, feedUrl); + } + + if (!urlAnnotations.hasFeedSubscription(contentResolver, feedUrl)) { + FeedService.subscribe(context, feedUrl); + } + } + } finally { + cursor.close(); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java new file mode 100644 index 000000000..acfaa8b4d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java @@ -0,0 +1,58 @@ +/* -*- 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.feeds.action; + +import android.content.Intent; +import android.util.Log; + +import org.mozilla.gecko.db.BrowserDB; + +/** + * Interface for actions run by FeedService. + */ +public abstract class FeedAction { + public static final boolean DEBUG_LOG = false; + + /** + * Perform this action. + * + * @param browserDB database instance to perform the action. + * @param intent used to start the service. + */ + public abstract void perform(BrowserDB browserDB, Intent intent); + + /** + * Does this action require an active network connection? + */ + public abstract boolean requiresNetwork(); + + /** + * Should this action only run if the preference is enabled? + */ + public abstract boolean requiresPreferenceEnabled(); + + /** + * This method will swallow all log messages to avoid logging potential personal information. + * + * For debugging purposes set {@code DEBUG_LOG} to true. + */ + public void log(String message) { + if (DEBUG_LOG) { + Log.d("Gecko" + getClass().getSimpleName(), message); + } + } + + /** + * This method will swallow all log messages to avoid logging potential personal information. + * + * For debugging purposes set {@code DEBUG_LOG} to true. + */ + public void log(String message, Throwable throwable) { + if (DEBUG_LOG) { + Log.d("Gecko" + getClass().getSimpleName(), message, throwable); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java new file mode 100644 index 000000000..f5bf39997 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java @@ -0,0 +1,146 @@ +/* -*- 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.feeds.action; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.SystemClock; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.feeds.FeedAlarmReceiver; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.Experiments; + +import java.text.DateFormat; +import java.util.Calendar; + +/** + * SetupAlarmsAction: Set up alarms to run various actions every now and then. + */ +public class SetupAlarmsAction extends FeedAction { + private static final String LOGTAG = "FeedSetupAction"; + + private Context context; + + public SetupAlarmsAction(Context context) { + this.context = context; + } + + @Override + public void perform(BrowserDB browserDB, Intent intent) { + final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + cancelPreviousAlarms(alarmManager); + scheduleAlarms(alarmManager); + } + + @Override + public boolean requiresNetwork() { + return false; + } + + @Override + public boolean requiresPreferenceEnabled() { + return false; + } + + private void cancelPreviousAlarms(AlarmManager alarmManager) { + final PendingIntent withdrawIntent = getWithdrawPendingIntent(); + alarmManager.cancel(withdrawIntent); + + final PendingIntent enrollIntent = getEnrollPendingIntent(); + alarmManager.cancel(enrollIntent); + + final PendingIntent checkIntent = getCheckPendingIntent(); + alarmManager.cancel(checkIntent); + + log("Cancelled previous alarms"); + } + + private void scheduleAlarms(AlarmManager alarmManager) { + alarmManager.setInexactRepeating( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_FIFTEEN_MINUTES, + AlarmManager.INTERVAL_DAY, + getWithdrawPendingIntent()); + + alarmManager.setInexactRepeating( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HALF_HOUR, + AlarmManager.INTERVAL_DAY, + getEnrollPendingIntent() + ); + + if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) { + scheduleUpdateCheckEvery12Hours(alarmManager); + } + + if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) { + scheduleUpdateAtFullHour(alarmManager, 8); + } + + if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) { + scheduleUpdateAtFullHour(alarmManager, 17); + } + + + log("Scheduled alarms"); + } + + private void scheduleUpdateCheckEvery12Hours(AlarmManager alarmManager) { + alarmManager.setInexactRepeating( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR, + AlarmManager.INTERVAL_HALF_DAY, + getCheckPendingIntent() + ); + } + + private void scheduleUpdateAtFullHour(AlarmManager alarmManager, int hourOfDay) { + final Calendar calendar = Calendar.getInstance(); + + if (calendar.get(Calendar.HOUR_OF_DAY) >= hourOfDay) { + // This time has already passed today. Try again tomorrow. + calendar.add(Calendar.DAY_OF_MONTH, 1); + } + + calendar.set(Calendar.HOUR_OF_DAY, hourOfDay); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + alarmManager.setInexactRepeating( + AlarmManager.RTC, + calendar.getTimeInMillis(), + AlarmManager.INTERVAL_DAY, + getCheckPendingIntent() + ); + + log("Scheduled update alarm at " + DateFormat.getDateTimeInstance().format(calendar.getTime())); + } + + private PendingIntent getWithdrawPendingIntent() { + Intent intent = new Intent(context, FeedAlarmReceiver.class); + intent.setAction(FeedService.ACTION_WITHDRAW); + return PendingIntent.getBroadcast(context, 0, intent, 0); + } + + private PendingIntent getEnrollPendingIntent() { + Intent intent = new Intent(context, FeedAlarmReceiver.class); + intent.setAction(FeedService.ACTION_ENROLL); + return PendingIntent.getBroadcast(context, 0, intent, 0); + } + + private PendingIntent getCheckPendingIntent() { + Intent intent = new Intent(context, FeedAlarmReceiver.class); + intent.setAction(FeedService.ACTION_CHECK); + return PendingIntent.getBroadcast(context, 0, intent, 0); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java new file mode 100644 index 000000000..fbfce1af2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java @@ -0,0 +1,79 @@ +/* -*- 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.feeds.action; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.feeds.FeedFetcher; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.feeds.subscriptions.FeedSubscription; + +/** + * SubscribeToFeedAction: Try to fetch a feed and create a subscription if successful. + */ +public class SubscribeToFeedAction extends FeedAction { + private static final String LOGTAG = "FeedSubscribeAction"; + + public static final String EXTRA_FEED_URL = "feed_url"; + + private Context context; + + public SubscribeToFeedAction(Context context) { + this.context = context; + } + + @Override + public void perform(BrowserDB browserDB, Intent intent) { + final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations(); + + final Bundle extras = intent.getExtras(); + final String feedUrl = extras.getString(EXTRA_FEED_URL); + + if (urlAnnotations.hasFeedSubscription(context.getContentResolver(), feedUrl)) { + log("Already subscribed to " + feedUrl + ". Skipping."); + return; + } + + log("Subscribing to feed: " + feedUrl); + + subscribe(urlAnnotations, feedUrl); + } + + @Override + public boolean requiresNetwork() { + return true; + } + + @Override + public boolean requiresPreferenceEnabled() { + return true; + } + + private void subscribe(UrlAnnotations urlAnnotations, String feedUrl) { + FeedFetcher.FeedResponse response = FeedFetcher.fetchAndParseFeed(feedUrl); + if (response == null) { + log(String.format("Could not fetch feed (%s). Not subscribing for now.", feedUrl)); + return; + } + + log("Subscribing to feed: " + response.feed.getTitle()); + log(" Last item: " + response.feed.getLastItem().getTitle()); + + final FeedSubscription subscription = FeedSubscription.create(feedUrl, response); + + urlAnnotations.insertFeedSubscription(context.getContentResolver(), subscription); + + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "content_update"); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java new file mode 100644 index 000000000..6f955c185 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java @@ -0,0 +1,109 @@ +/* -*- 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.feeds.action; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; + +import org.json.JSONException; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.feeds.subscriptions.FeedSubscription; + +/** + * WithdrawSubscriptionsAction: Look for feeds to unsubscribe from. + */ +public class WithdrawSubscriptionsAction extends FeedAction { + private static final String LOGTAG = "FeedWithdrawAction"; + + private Context context; + + public WithdrawSubscriptionsAction(Context context) { + this.context = context; + } + + @Override + public void perform(BrowserDB browserDB, Intent intent) { + log("Searching for subscriptions to remove.."); + + final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations(); + final ContentResolver resolver = context.getContentResolver(); + + removeFeedsOfUnknownUrls(browserDB, urlAnnotations, resolver); + removeSubscriptionsOfRemovedFeeds(urlAnnotations, resolver); + } + + /** + * Search for website URLs with a feed assigned. Remove entry if website URL is not known anymore: + * For now this means the website is not bookmarked. + */ + private void removeFeedsOfUnknownUrls(BrowserDB browserDB, UrlAnnotations urlAnnotations, ContentResolver resolver) { + Cursor cursor = urlAnnotations.getWebsitesWithFeedUrl(resolver); + if (cursor == null) { + return; + } + + try { + while (cursor.moveToNext()) { + final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL)); + + if (!browserDB.isBookmark(resolver, url)) { + log("Removing feed for unknown URL: " + url); + + urlAnnotations.deleteFeedUrl(resolver, url); + } + } + } finally { + cursor.close(); + } + } + + /** + * Remove subscriptions of feed URLs that are not assigned to a website URL (anymore). + */ + private void removeSubscriptionsOfRemovedFeeds(UrlAnnotations urlAnnotations, ContentResolver resolver) { + Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver); + if (cursor == null) { + return; + } + + try { + while (cursor.moveToNext()) { + final FeedSubscription subscription = FeedSubscription.fromCursor(cursor); + + if (!urlAnnotations.hasWebsiteForFeedUrl(resolver, subscription.getFeedUrl())) { + log("Removing subscription for feed: " + subscription.getFeedUrl()); + + urlAnnotations.deleteFeedSubscription(resolver, subscription); + + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "content_update"); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context)); + } + } + } catch (JSONException e) { + log("Could not deserialize subscription", e); + } finally { + cursor.close(); + } + } + + @Override + public boolean requiresNetwork() { + return false; + } + + @Override + public boolean requiresPreferenceEnabled() { + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java new file mode 100644 index 000000000..febfbb0c7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java @@ -0,0 +1,38 @@ +/* -*- 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.feeds.knownsites; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * A site we know and for which we can guess the feed URL from an arbitrary URL. + */ +public interface KnownSite { + /** + * Get a search string to find URLs of this site in our database. This search string is usually + * a partial domain / URL. + * + * For example we could return "medium.com" to find all URLs that contain this string. This could + * obviously find URLs that are not actually medium.com sites. This is acceptable as long as + * getFeedFromURL() can handle these inputs and either returns a feed for valid URLs or null for + * other matches that are not related to this site. + */ + @NonNull String getURLSearchString(); + + /** + * Get the Feed URL for this URL. For a known site we can "guess" the feed URL from an URL + * pointing to any page. The input URL will be a result from the database found with the value + * returned by getURLSearchString(). + * + * Example: + * - Input: https://medium.com/@antlam/ux-thoughts-for-2016-1fc1d6e515e8 + * - Output: https://medium.com/feed/@antlam + * + * @return the url representing a feed, or null if a feed could not be determined. + */ + @Nullable String getFeedFromURL(String url); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java new file mode 100644 index 000000000..6bb3629bf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java @@ -0,0 +1,29 @@ +/* -*- 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.feeds.knownsites; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Blogger.com + */ +public class KnownSiteBlogger implements KnownSite { + @Override + public String getURLSearchString() { + return ".blogspot.com"; + } + + @Override + public String getFeedFromURL(String url) { + Pattern pattern = Pattern.compile("https?://(www\\.)?(.*?)\\.blogspot\\.com(/.*)?"); + Matcher matcher = pattern.matcher(url); + if (matcher.matches()) { + return String.format("https://%s.blogspot.com/feeds/posts/default", matcher.group(2)); + } + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java new file mode 100644 index 000000000..a96e83fcd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java @@ -0,0 +1,29 @@ +/* -*- 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.feeds.knownsites; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Medium.com + */ +public class KnownSiteMedium implements KnownSite { + @Override + public String getURLSearchString() { + return "://medium.com/"; + } + + @Override + public String getFeedFromURL(String url) { + Pattern pattern = Pattern.compile("https?://medium.com/([^/]+)(/.*)?"); + Matcher matcher = pattern.matcher(url); + if (matcher.matches()) { + return String.format("https://medium.com/feed/%s", matcher.group(1)); + } + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java new file mode 100644 index 000000000..c9f480013 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java @@ -0,0 +1,33 @@ +/* -*- 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.feeds.knownsites; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Tumblr.com + */ +public class KnownSiteTumblr implements KnownSite { + @Override + public String getURLSearchString() { + return ".tumblr.com"; + } + + @Override + public String getFeedFromURL(String url) { + final Pattern pattern = Pattern.compile("https?://(.*?).tumblr.com(/.*)?"); + final Matcher matcher = pattern.matcher(url); + if (matcher.matches()) { + final String username = matcher.group(1); + if (username.equals("www")) { + return null; + } + return "http://" + username + ".tumblr.com/rss"; + } + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java new file mode 100644 index 000000000..a74b41a74 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java @@ -0,0 +1,26 @@ +package org.mozilla.gecko.feeds.knownsites; + +import android.support.annotation.NonNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Wordpress.com + */ +public class KnownSiteWordpress implements KnownSite { + @Override + public String getURLSearchString() { + return ".wordpress.com"; + } + + @Override + public String getFeedFromURL(String url) { + Pattern pattern = Pattern.compile("https?://(.*?).wordpress.com(/.*)?"); + Matcher matcher = pattern.matcher(url); + if (matcher.matches()) { + return "https://" + matcher.group(1) + ".wordpress.com/feed/"; + } + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java new file mode 100644 index 000000000..aefc72aa7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java @@ -0,0 +1,70 @@ +/* -*- 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.feeds.parser; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +public class Feed { + private String title; + private String websiteURL; + private String feedURL; + private Item lastItem; + + public static Feed create(String title, String websiteURL, String feedURL, Item lastItem) { + Feed feed = new Feed(); + + feed.setTitle(title); + feed.setWebsiteURL(websiteURL); + feed.setFeedURL(feedURL); + feed.setLastItem(lastItem); + + return feed; + } + + /* package-private */ Feed() {} + + /* package-private */ void setTitle(String title) { + this.title = title; + } + + /* package-private */ void setWebsiteURL(String websiteURL) { + this.websiteURL = websiteURL; + } + + /* package-private */ void setFeedURL(String feedURL) { + this.feedURL = feedURL; + } + + /* package-private */ void setLastItem(Item lastItem) { + this.lastItem = lastItem; + } + + /** + * Is this feed object sufficiently complete so that we can use it? + */ + /* package-private */ boolean isSufficientlyComplete() { + return !TextUtils.isEmpty(title) && + lastItem != null && + !TextUtils.isEmpty(lastItem.getURL()) && + !TextUtils.isEmpty(lastItem.getTitle()); + } + + public String getTitle() { + return title; + } + + public String getWebsiteURL() { + return websiteURL; + } + + public String getFeedURL() { + return feedURL; + } + + public Item getLastItem() { + return lastItem; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java new file mode 100644 index 000000000..8d8f6d44e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java @@ -0,0 +1,49 @@ +/* -*- 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.feeds.parser; + +public class Item { + private String title; + private String url; + private long timestamp; + + public static Item create(String title, String url, long timestamp) { + Item item = new Item(); + + item.setTitle(title); + item.setURL(url); + item.setTimestamp(timestamp); + + return item; + } + + /* package-private */ void setTitle(String title) { + this.title = title; + } + + /* package-private */ void setURL(String url) { + this.url = url; + } + + /* package-private */ void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public String getTitle() { + return title; + } + + public String getURL() { + return url; + } + + /** + * @return the number of milliseconds since Jan. 1, 1970, midnight GMT. + */ + public long getTimestamp() { + return timestamp; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java new file mode 100644 index 000000000..afb1b7cb2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java @@ -0,0 +1,367 @@ +/* -*- 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.feeds.parser; + +import android.util.Log; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +/** + * A super simple feed parser written for implementing "content notifications". This XML Pull Parser + * can read ATOM and RSS feeds and returns an object describing the feed and the latest entry. + */ +public class SimpleFeedParser { + /** + * Generic exception that's thrown by the parser whenever a stream cannot be parsed. + */ + public static class ParserException extends Exception { + private static final long serialVersionUID = -6119538440219805603L; + + public ParserException(Throwable cause) { + super(cause); + } + + public ParserException(String message) { + super(message); + } + } + + private static final String LOGTAG = "Gecko/FeedParser"; + + private static final String TAG_RSS = "rss"; + private static final String TAG_FEED = "feed"; + private static final String TAG_RDF = "RDF"; + private static final String TAG_TITLE = "title"; + private static final String TAG_ITEM = "item"; + private static final String TAG_LINK = "link"; + private static final String TAG_ENTRY = "entry"; + private static final String TAG_PUBDATE = "pubDate"; + private static final String TAG_UPDATED = "updated"; + private static final String TAG_DATE = "date"; + private static final String TAG_SOURCE = "source"; + private static final String TAG_IMAGE = "image"; + private static final String TAG_CONTENT = "content"; + + private class ParserState { + public Feed feed; + public Item currentItem; + public boolean isRSS; + public boolean isATOM; + public boolean inSource; + public boolean inImage; + public boolean inContent; + } + + public Feed parse(InputStream in) throws ParserException, IOException { + final ParserState state = new ParserState(); + + try { + final XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + + XmlPullParser parser = factory.newPullParser(); + parser.setInput(in, null); + + int eventType = parser.getEventType(); + + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case XmlPullParser.START_DOCUMENT: + handleStartDocument(state); + break; + + case XmlPullParser.START_TAG: + handleStartTag(parser, state); + break; + + case XmlPullParser.END_TAG: + handleEndTag(parser, state); + break; + } + + eventType = parser.next(); + } + } catch (XmlPullParserException e) { + throw new ParserException(e); + } + + if (!state.feed.isSufficientlyComplete()) { + throw new ParserException("Feed is not sufficiently complete"); + } + + return state.feed; + } + + private void handleStartDocument(ParserState state) { + state.feed = new Feed(); + } + + private void handleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + switch (parser.getName()) { + case TAG_RSS: + state.isRSS = true; + break; + + case TAG_FEED: + state.isATOM = true; + break; + + case TAG_RDF: + // This is a RSS 1.0 feed + state.isRSS = true; + break; + + case TAG_ITEM: + case TAG_ENTRY: + state.currentItem = new Item(); + break; + + case TAG_TITLE: + handleTitleStartTag(parser, state); + break; + + case TAG_LINK: + handleLinkStartTag(parser, state); + break; + + case TAG_PUBDATE: + handlePubDateStartTag(parser, state); + break; + + case TAG_UPDATED: + handleUpdatedStartTag(parser, state); + break; + + case TAG_DATE: + handleDateStartTag(parser, state); + break; + + case TAG_SOURCE: + state.inSource = true; + break; + + case TAG_IMAGE: + state.inImage = true; + break; + + case TAG_CONTENT: + state.inContent = true; + break; + } + } + + private void handleEndTag(XmlPullParser parser, ParserState state) { + switch (parser.getName()) { + case TAG_ITEM: + case TAG_ENTRY: + handleItemOrEntryREndTag(state); + break; + + case TAG_SOURCE: + state.inSource = false; + break; + + case TAG_IMAGE: + state.inImage = false; + break; + + case TAG_CONTENT: + state.inContent = false; + break; + } + } + + private void handleTitleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + if (state.inSource || state.inImage || state.inContent) { + // We do not care about titles in <source>, <image> or <media> tags. + return; + } + + String title = getTextUntilEndTag(parser, TAG_TITLE); + + title = title.replaceAll("[\r\n]", " "); + title = title.replaceAll(" +", " "); + + if (state.currentItem != null) { + state.currentItem.setTitle(title); + } else { + state.feed.setTitle(title); + } + } + + private void handleLinkStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + if (state.inSource || state.inImage) { + // We do not care about links in <source> or <image> tags. + return; + } + + Map<String, String> attributes = fetchAttributes(parser); + + if (attributes.size() > 0) { + String rel = attributes.get("rel"); + + if (state.currentItem == null && "self".equals(rel)) { + state.feed.setFeedURL(attributes.get("href")); + return; + } + + if (rel == null || "alternate".equals(rel)) { + String type = attributes.get("type"); + if (type == null || type.equals("text/html")) { + String link = attributes.get("href"); + if (TextUtils.isEmpty(link)) { + return; + } + + if (state.currentItem != null) { + state.currentItem.setURL(link); + } else { + state.feed.setWebsiteURL(link); + } + + return; + } + } + } + + if (state.isRSS) { + String link = getTextUntilEndTag(parser, TAG_LINK); + if (TextUtils.isEmpty(link)) { + return; + } + + if (state.currentItem != null) { + state.currentItem.setURL(link); + } else { + state.feed.setWebsiteURL(link); + } + } + } + + private void handleItemOrEntryREndTag(ParserState state) { + if (state.feed.getLastItem() == null || state.feed.getLastItem().getTimestamp() < state.currentItem.getTimestamp()) { + // Only set this item as "last item" if we do not have an item yet or this item is newer. + state.feed.setLastItem(state.currentItem); + } + + state.currentItem = null; + } + + private void handlePubDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + if (state.currentItem == null) { + return; + } + + String pubDate = getTextUntilEndTag(parser, TAG_PUBDATE); + if (TextUtils.isEmpty(pubDate)) { + return; + } + + // RFC-822 + SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); + + updateCurrentItemTimestamp(state, pubDate, format); + } + + private void handleUpdatedStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + if (state.inSource) { + // We do not care about stuff in <source> tags. + return; + } + + if (state.currentItem == null) { + // We are only interested in <updated> values of feed items. + return; + } + + String updated = getTextUntilEndTag(parser, TAG_UPDATED); + if (TextUtils.isEmpty(updated)) { + return; + } + + SimpleDateFormat[] formats = new SimpleDateFormat[] { + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US), + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US) + }; + + // Fix timezones SimpleDateFormat can't parse: + // 2016-01-26T18:56:54Z -> 2016-01-26T18:56:54+0000 (Timezone: Z -> +0000) + updated = updated.replaceFirst("Z$", "+0000"); + // 2016-01-26T18:56:54+01:00 -> 2016-01-26T18:56:54+0100 (Timezone: +01:00 -> +0100) + updated = updated.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4"); + + updateCurrentItemTimestamp(state, updated, formats); + } + + private void handleDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException { + if (state.currentItem == null) { + // We are only interested in <updated> values of feed items. + return; + } + + String text = getTextUntilEndTag(parser, TAG_DATE); + if (TextUtils.isEmpty(text)) { + return; + } + + // Fix timezones SimpleDateFormat can't parse: + // 2016-01-26T18:56:54+00:00 -> 2016-01-26T18:56:54+0000 + text = text.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4"); + + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + + updateCurrentItemTimestamp(state, text, format); + } + + private void updateCurrentItemTimestamp(ParserState state, String text, SimpleDateFormat... formats) { + for (SimpleDateFormat format : formats) { + try { + Date date = format.parse(text); + state.currentItem.setTimestamp(date.getTime()); + return; + } catch (ParseException e) { + Log.w(LOGTAG, "Could not parse 'updated': " + text); + } + } + } + + private Map<String, String> fetchAttributes(XmlPullParser parser) { + Map<String, String> attributes = new HashMap<>(); + + for (int i = 0; i < parser.getAttributeCount(); i++) { + attributes.put(parser.getAttributeName(i), parser.getAttributeValue(i)); + } + + return attributes; + } + + private String getTextUntilEndTag(XmlPullParser parser, String tag) throws IOException, XmlPullParserException { + StringBuilder builder = new StringBuilder(); + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.TEXT) { + builder.append(parser.getText()); + } else if (parser.getEventType() == XmlPullParser.END_TAG && tag.equals(parser.getName())) { + break; + } + } + + return builder.toString().trim(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java new file mode 100644 index 000000000..7ce7f193f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java @@ -0,0 +1,130 @@ +/* -*- 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.feeds.subscriptions; + +import android.database.Cursor; +import android.text.TextUtils; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.feeds.FeedFetcher; +import org.mozilla.gecko.feeds.parser.Item; + +/** + * An object describing a subscription and containing some meta data about the last time we fetched + * the feed. + */ +public class FeedSubscription { + private static final String JSON_KEY_FEED_TITLE = "feed_title"; + private static final String JSON_KEY_LAST_ITEM_TITLE = "last_item_title"; + private static final String JSON_KEY_LAST_ITEM_URL = "last_item_url"; + private static final String JSON_KEY_LAST_ITEM_TIMESTAMP = "last_item_timestamp"; + private static final String JSON_KEY_ETAG = "etag"; + private static final String JSON_KEY_LAST_MODIFIED = "last_modified"; + + private String feedUrl; + private String feedTitle; + private String lastItemTitle; + private String lastItemUrl; + private long lastItemTimestamp; + private String etag; + private String lastModified; + + public static FeedSubscription create(String feedUrl, FeedFetcher.FeedResponse response) { + FeedSubscription subscription = new FeedSubscription(); + subscription.feedUrl = feedUrl; + + subscription.update(response); + + return subscription; + } + + public static FeedSubscription fromCursor(Cursor cursor) throws JSONException { + final FeedSubscription subscription = new FeedSubscription(); + subscription.feedUrl = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL)); + + final String value = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE)); + subscription.fromJSON(new JSONObject(value)); + + return subscription; + } + + private void fromJSON(JSONObject object) throws JSONException { + feedTitle = object.getString(JSON_KEY_FEED_TITLE); + lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE); + lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL); + lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP); + etag = object.optString(JSON_KEY_ETAG); + lastModified = object.optString(JSON_KEY_LAST_MODIFIED); + } + + public void update(FeedFetcher.FeedResponse response) { + feedTitle = response.feed.getTitle(); + lastItemTitle = response.feed.getLastItem().getTitle(); + lastItemUrl = response.feed.getLastItem().getURL(); + lastItemTimestamp = response.feed.getLastItem().getTimestamp(); + etag = response.etag; + lastModified = response.lastModified; + } + + /** + * Guesstimate if this response is a newer representation of the feed. + */ + public boolean hasBeenUpdated(FeedFetcher.FeedResponse response) { + final Item responseItem = response.feed.getLastItem(); + + if (responseItem.getTimestamp() > lastItemTimestamp) { + // The timestamp is from a newer date so we expect that this item is a new item. But this + // could also mean that the timestamp of an already existing item has been updated. We + // accept that and assume that the content will have changed too in this case. + return true; + } + + if (responseItem.getTimestamp() == lastItemTimestamp && responseItem.getTimestamp() != 0) { + // We have a timestamp that is not zero and this item has still the timestamp: It's very + // likely that we are looking at the same item. We assume this is not new content. + return false; + } + + if (!responseItem.getURL().equals(lastItemUrl)) { + // The URL changed: It is very likely that this is a new item. At least it has been updated + // in a way that we just treat it as new content here. + return true; + } + + return false; + } + + public String getFeedUrl() { + return feedUrl; + } + + public String getFeedTitle() { + return feedTitle; + } + + public String getETag() { + return etag; + } + + public String getLastModified() { + return lastModified; + } + + public JSONObject toJSON() throws JSONException { + JSONObject object = new JSONObject(); + + object.put(JSON_KEY_FEED_TITLE, feedTitle); + object.put(JSON_KEY_LAST_ITEM_TITLE, lastItemTitle); + object.put(JSON_KEY_LAST_ITEM_URL, lastItemUrl); + object.put(JSON_KEY_LAST_ITEM_TIMESTAMP, lastItemTimestamp); + object.put(JSON_KEY_ETAG, etag); + object.put(JSON_KEY_LAST_MODIFIED, lastModified); + + return object; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java new file mode 100644 index 000000000..d5940d758 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java @@ -0,0 +1,47 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +public class DataPanel extends FirstrunPanel { + private boolean isEnabled = false; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) { + final View root = super.onCreateView(inflater, container, savedInstance); + final ImageView clickableImage = (ImageView) root.findViewById(R.id.firstrun_image); + clickableImage.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + // Set new state. + isEnabled = !isEnabled; + int newResource = isEnabled ? R.drawable.firstrun_data_on : R.drawable.firstrun_data_off; + ((ImageView) view).setImageResource(newResource); + if (isEnabled) { + // Always block images. + PrefsHelper.setPref("browser.image_blocking", 0); + } else { + // Default: always load images. + PrefsHelper.setPref("browser.image_blocking", 1); + } + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-datasaving-" + isEnabled); + } + }); + + return root; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java new file mode 100644 index 000000000..93dd0c254 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java @@ -0,0 +1,94 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.content.Context; +import android.support.v4.app.FragmentManager; +import android.util.AttributeSet; + +import android.view.View; +import android.widget.LinearLayout; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.Experiments; + +/** + * A container for the pager and the entire first run experience. + * This is used for animation purposes. + */ +public class FirstrunAnimationContainer extends LinearLayout { + public static final String PREF_FIRSTRUN_ENABLED = "startpane_enabled"; + + public static interface OnFinishListener { + public void onFinish(); + } + + private FirstrunPager pager; + private boolean visible; + private OnFinishListener onFinishListener; + + public FirstrunAnimationContainer(Context context) { + this(context, null); + } + public FirstrunAnimationContainer(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void load(Context appContext, FragmentManager fm) { + visible = true; + pager = (FirstrunPager) findViewById(R.id.firstrun_pager); + pager.load(appContext, fm, new OnFinishListener() { + @Override + public void onFinish() { + hide(); + } + }); + } + + public boolean isVisible() { + return visible; + } + + public void hide() { + visible = false; + if (onFinishListener != null) { + onFinishListener.onFinish(); + } + animateHide(); + + // Stop all versions of firstrun A/B sessions. + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_B); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_C); + } + + private void animateHide() { + final Animator alphaAnimator = ObjectAnimator.ofFloat(this, "alpha", 0); + alphaAnimator.setDuration(150); + alphaAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + FirstrunAnimationContainer.this.setVisibility(View.GONE); + } + }); + + alphaAnimator.start(); + } + + public boolean showBrowserHint() { + final int currentPage = pager.getCurrentItem(); + FirstrunPanel currentPanel = (FirstrunPanel) ((FirstrunPager.ViewPagerAdapter) pager.getAdapter()).getItem(currentPage); + pager.cleanup(); + return currentPanel.shouldShowBrowserHint(); + } + + public void registerOnFinishListener(OnFinishListener listener) { + this.onFinishListener = listener; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java new file mode 100644 index 000000000..c2838ee3e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java @@ -0,0 +1,174 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.content.Context; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.home.HomePager.Decor; +import org.mozilla.gecko.home.TabMenuStrip; +import org.mozilla.gecko.restrictions.Restrictions; + +import java.util.List; + +/** + * ViewPager containing for our first run pages. + * + * @see FirstrunPanel for the first run pages that are used in this pager. + */ +public class FirstrunPager extends ViewPager { + + private Context context; + protected FirstrunPanel.PagerNavigation pagerNavigation; + private Decor mDecor; + + public FirstrunPager(Context context) { + this(context, null); + } + + public FirstrunPager(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (child instanceof Decor) { + ((ViewPager.LayoutParams) params).isDecor = true; + mDecor = (Decor) child; + mDecor.setOnTitleClickListener(new TabMenuStrip.OnTitleClickListener() { + @Override + public void onTitleClicked(int index) { + setCurrentItem(index, true); + } + }); + } + + super.addView(child, index, params); + } + + public void load(Context appContext, FragmentManager fm, final FirstrunAnimationContainer.OnFinishListener onFinishListener) { + final List<FirstrunPagerConfig.FirstrunPanelConfig> panels; + + if (Restrictions.isRestrictedProfile(context)) { + panels = FirstrunPagerConfig.getRestricted(); + } else { + panels = FirstrunPagerConfig.getDefault(appContext); + } + + setAdapter(new ViewPagerAdapter(fm, panels)); + this.pagerNavigation = new FirstrunPanel.PagerNavigation() { + @Override + public void next() { + final int currentPage = FirstrunPager.this.getCurrentItem(); + if (currentPage < FirstrunPager.this.getAdapter().getCount() - 1) { + FirstrunPager.this.setCurrentItem(currentPage + 1); + } + } + + @Override + public void finish() { + if (onFinishListener != null) { + onFinishListener.onFinish(); + } + } + }; + addOnPageChangeListener(new OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mDecor.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int i) { + mDecor.onPageSelected(i); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.PANEL, "onboarding." + i); + } + + @Override + public void onPageScrollStateChanged(int i) {} + }); + + animateLoad(); + + // Record telemetry for first onboarding panel, for baseline. + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.PANEL, "onboarding.0"); + } + + public void cleanup() { + setAdapter(null); + } + + private void animateLoad() { + setTranslationY(500); + setAlpha(0); + + final Animator translateAnimator = ObjectAnimator.ofFloat(this, "translationY", 0); + translateAnimator.setDuration(400); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(this, "alpha", 1); + alphaAnimator.setStartDelay(200); + alphaAnimator.setDuration(600); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(alphaAnimator, translateAnimator); + set.setStartDelay(400); + + set.start(); + } + + protected class ViewPagerAdapter extends FragmentPagerAdapter { + private final List<FirstrunPagerConfig.FirstrunPanelConfig> panels; + private final Fragment[] fragments; + + public ViewPagerAdapter(FragmentManager fm, List<FirstrunPagerConfig.FirstrunPanelConfig> panels) { + super(fm); + this.panels = panels; + this.fragments = new Fragment[panels.size()]; + for (FirstrunPagerConfig.FirstrunPanelConfig panel : panels) { + mDecor.onAddPagerView(context.getString(panel.getTitleRes())); + } + + if (panels.size() > 0) { + mDecor.onPageSelected(0); + } + } + + @Override + public Fragment getItem(int i) { + Fragment fragment = this.fragments[i]; + if (fragment == null) { + FirstrunPagerConfig.FirstrunPanelConfig panelConfig = panels.get(i); + fragment = Fragment.instantiate(context, panelConfig.getClassname(), panelConfig.getArgs()); + ((FirstrunPanel) fragment).setPagerNavigation(pagerNavigation); + fragments[i] = fragment; + } + return fragment; + } + + @Override + public int getCount() { + return panels.size(); + } + + @Override + public CharSequence getPageTitle(int i) { + // Unused now that we use TabMenuStrip. + return context.getString(panels.get(i).getTitleRes()).toUpperCase(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java new file mode 100644 index 000000000..3f901d07b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java @@ -0,0 +1,107 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.Experiments; + +import java.util.LinkedList; +import java.util.List; + +public class FirstrunPagerConfig { + public static final String LOGTAG = "FirstrunPagerConfig"; + + public static final String KEY_IMAGE = "imageRes"; + public static final String KEY_TEXT = "textRes"; + public static final String KEY_SUBTEXT = "subtextRes"; + + public static List<FirstrunPanelConfig> getDefault(Context context) { + final List<FirstrunPanelConfig> panels = new LinkedList<>(); + + if (Experiments.isInExperimentLocal(context, Experiments.ONBOARDING3_B)) { + panels.add(SimplePanelConfigs.urlbarPanelConfig); + panels.add(SimplePanelConfigs.bookmarksPanelConfig); + panels.add(SimplePanelConfigs.dataPanelConfig); + panels.add(SimplePanelConfigs.syncPanelConfig); + panels.add(SimplePanelConfigs.signInPanelConfig); + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_B); + GeckoSharedPrefs.forProfile(context).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_B).apply(); + } else if (Experiments.isInExperimentLocal(context, Experiments.ONBOARDING3_C)) { + panels.add(SimplePanelConfigs.tabqueuePanelConfig); + panels.add(SimplePanelConfigs.readerviewPanelConfig); + panels.add(SimplePanelConfigs.accountPanelConfig); + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_C); + GeckoSharedPrefs.forProfile(context).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_C).apply(); + } else { + Log.e(LOGTAG, "Not in an experiment!"); + panels.add(SimplePanelConfigs.signInPanelConfig); + } + return panels; + } + + public static List<FirstrunPanelConfig> getRestricted() { + final List<FirstrunPanelConfig> panels = new LinkedList<>(); + panels.add(new FirstrunPanelConfig(RestrictedWelcomePanel.class.getName(), RestrictedWelcomePanel.TITLE_RES)); + return panels; + } + + public static class FirstrunPanelConfig { + + private String classname; + private int titleRes; + private Bundle args; + + public FirstrunPanelConfig(String resource, int titleRes) { + this(resource, titleRes, -1, -1, -1, true); + } + + public FirstrunPanelConfig(String classname, int titleRes, int imageRes, int textRes, int subtextRes) { + this(classname, titleRes, imageRes, textRes, subtextRes, false); + } + + private FirstrunPanelConfig(String classname, int titleRes, int imageRes, int textRes, int subtextRes, boolean isCustom) { + this.classname = classname; + this.titleRes = titleRes; + + if (!isCustom) { + this.args = new Bundle(); + this.args.putInt(KEY_IMAGE, imageRes); + this.args.putInt(KEY_TEXT, textRes); + this.args.putInt(KEY_SUBTEXT, subtextRes); + } + } + + public String getClassname() { + return this.classname; + } + + public int getTitleRes() { + return this.titleRes; + } + + public Bundle getArgs() { + return args; + } + } + + private static class SimplePanelConfigs { + public static final FirstrunPanelConfig urlbarPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_panel_title_welcome, R.drawable.firstrun_urlbar, R.string.firstrun_urlbar_message, R.string.firstrun_urlbar_subtext); + public static final FirstrunPanelConfig bookmarksPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_bookmarks_title, R.drawable.firstrun_bookmarks, R.string.firstrun_bookmarks_message, R.string.firstrun_bookmarks_subtext); + public static final FirstrunPanelConfig dataPanelConfig = new FirstrunPanelConfig(DataPanel.class.getName(), R.string.firstrun_data_title, R.drawable.firstrun_data_off, R.string.firstrun_data_message, R.string.firstrun_data_subtext); + public static final FirstrunPanelConfig syncPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_sync_title, R.drawable.firstrun_sync, R.string.firstrun_sync_message, R.string.firstrun_sync_subtext); + public static final FirstrunPanelConfig signInPanelConfig = new FirstrunPanelConfig(SyncPanel.class.getName(), R.string.pref_sync, R.drawable.firstrun_signin, R.string.firstrun_signin_message, R.string.firstrun_welcome_button_browser); + + public static final FirstrunPanelConfig tabqueuePanelConfig = new FirstrunPanelConfig(TabQueuePanel.class.getName(), R.string.firstrun_tabqueue_title, R.drawable.firstrun_tabqueue_off, R.string.firstrun_tabqueue_message_off, R.string.firstrun_tabqueue_subtext_off); + public static final FirstrunPanelConfig readerviewPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_readerview_title, R.drawable.firstrun_readerview, R.string.firstrun_readerview_message, R.string.firstrun_readerview_subtext); + public static final FirstrunPanelConfig accountPanelConfig = new FirstrunPanelConfig(SyncPanel.class.getName(), R.string.firstrun_account_title, R.drawable.firstrun_account, R.string.firstrun_account_message, R.string.firstrun_button_notnow); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java new file mode 100644 index 000000000..4b27dbc73 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java @@ -0,0 +1,80 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +/** + * Base class for our first run pages. We call these FirstrunPanel for consistency + * with HomePager/HomePanel. + * + * @see FirstrunPager for the containing pager. + */ +public class FirstrunPanel extends Fragment { + + public static final int TITLE_RES = -1; + protected boolean showBrowserHint = true; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) { + final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.firstrun_basepanel_checkable_fragment, container, false); + Bundle args = getArguments(); + if (args != null) { + final int imageRes = args.getInt(FirstrunPagerConfig.KEY_IMAGE); + final int textRes = args.getInt(FirstrunPagerConfig.KEY_TEXT); + final int subtextRes = args.getInt(FirstrunPagerConfig.KEY_SUBTEXT); + + ((ImageView) root.findViewById(R.id.firstrun_image)).setImageResource(imageRes); + ((TextView) root.findViewById(R.id.firstrun_text)).setText(textRes); + ((TextView) root.findViewById(R.id.firstrun_subtext)).setText(subtextRes); + } + + root.findViewById(R.id.firstrun_link).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-next"); + pagerNavigation.next(); + } + }); + + return root; + } + + public interface PagerNavigation { + void next(); + void finish(); + } + protected PagerNavigation pagerNavigation; + + public void setPagerNavigation(PagerNavigation listener) { + this.pagerNavigation = listener; + } + + protected void next() { + if (pagerNavigation != null) { + pagerNavigation.next(); + } + } + + protected void close() { + if (pagerNavigation != null) { + pagerNavigation.finish(); + } + } + + protected boolean shouldShowBrowserHint() { + return showBrowserHint; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java new file mode 100644 index 000000000..efc91d20f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java @@ -0,0 +1,61 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.home.HomePager; + +import android.app.Activity; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import java.util.EnumSet; + +public class RestrictedWelcomePanel extends FirstrunPanel { + public static final int TITLE_RES = R.string.firstrun_panel_title_welcome; + + private static final String LEARN_MORE_URL = "https://support.mozilla.org/kb/controlledaccess"; + + private HomePager.OnUrlOpenListener onUrlOpenListener; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + onUrlOpenListener = (HomePager.OnUrlOpenListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement HomePager.OnUrlOpenListener"); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) { + final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.restricted_firstrun_welcome_fragment, container, false); + + root.findViewById(R.id.welcome_browse).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + close(); + } + }); + + root.findViewById(R.id.learn_more_link).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onUrlOpenListener.onUrlOpen(LEARN_MORE_URL, EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + + close(); + } + }); + + return root; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java new file mode 100644 index 000000000..2f489c84e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java @@ -0,0 +1,61 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity; + +public class SyncPanel extends FirstrunPanel { + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) { + final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.firstrun_sync_fragment, container, false); + final Bundle args = getArguments(); + if (args != null) { + final int imageRes = args.getInt(FirstrunPagerConfig.KEY_IMAGE); + final int textRes = args.getInt(FirstrunPagerConfig.KEY_TEXT); + final int subtextRes = args.getInt(FirstrunPagerConfig.KEY_SUBTEXT); + + ((ImageView) root.findViewById(R.id.firstrun_image)).setImageResource(imageRes); + ((TextView) root.findViewById(R.id.firstrun_text)).setText(textRes); + ((TextView) root.findViewById(R.id.welcome_browse)).setText(subtextRes); + } + + root.findViewById(R.id.welcome_account).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-sync"); + showBrowserHint = false; + + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_FIRSTRUN); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(intent); + + close(); + } + }); + + root.findViewById(R.id.welcome_browse).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-browser"); + close(); + } + }); + + return root; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java new file mode 100644 index 000000000..3c2ed8312 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java @@ -0,0 +1,92 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.firstrun; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Typeface; +import android.os.Bundle; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.SwitchCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.TextView; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.tabqueue.TabQueueHelper; +import org.mozilla.gecko.tabqueue.TabQueuePrompt; + +public class TabQueuePanel extends FirstrunPanel { + private static final int REQUEST_CODE_TAB_QUEUE = 1; + private SwitchCompat toggleSwitch; + private ImageView imageView; + private TextView messageTextView; + private TextView subtextTextView; + private Context context; + + @Override + public View onCreateView(LayoutInflater inflater, final ViewGroup container, Bundle savedInstance) { + context = getContext(); + final View root = super.onCreateView(inflater, container, savedInstance); + + imageView = (ImageView) root.findViewById(R.id.firstrun_image); + messageTextView = (TextView) root.findViewById(R.id.firstrun_text); + subtextTextView = (TextView) root.findViewById(R.id.firstrun_subtext); + + toggleSwitch = (SwitchCompat) root.findViewById(R.id.firstrun_switch); + toggleSwitch.setVisibility(View.VISIBLE); + toggleSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions"); + if (b && !TabQueueHelper.canDrawOverlays(context)) { + Intent promptIntent = new Intent(context, TabQueuePrompt.class); + startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE); + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-tabqueue-" + b); + + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(GeckoPreferences.PREFS_TAB_QUEUE, b).apply(); + + // Set image, text, and typeface changes. + imageView.setImageResource(b ? R.drawable.firstrun_tabqueue_on : R.drawable.firstrun_tabqueue_off); + messageTextView.setText(b ? R.string.firstrun_tabqueue_message_on : R.string.firstrun_tabqueue_message_off); + messageTextView.setTypeface(b ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT); + subtextTextView.setText(b ? R.string.firstrun_tabqueue_subtext_on : R.string.firstrun_tabqueue_subtext_off); + subtextTextView.setTypeface(b ? Typeface.defaultFromStyle(Typeface.ITALIC) : Typeface.DEFAULT); + subtextTextView.setTextColor(b ? ContextCompat.getColor(context, R.color.fennec_ui_orange) : ContextCompat.getColor(context, R.color.placeholder_grey)); + } + }); + + return root; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_CODE_TAB_QUEUE: + final boolean accepted = TabQueueHelper.processTabQueuePromptResponse(resultCode, context); + if (accepted) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions-yes"); + toggleSwitch.setChecked(true); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-tabqueue-true"); + } + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions-" + (accepted ? "accepted" : "rejected")); + break; + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java new file mode 100644 index 000000000..0616cd229 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java @@ -0,0 +1,35 @@ +/* -*- 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.gcm; + +import android.util.Log; + +import com.google.android.gms.iid.InstanceIDListenerService; + +import org.mozilla.gecko.push.PushService; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * This service is notified by the on-device Google Play Services library if an + * in-use token needs to be updated. We simply pass through to AndroidPushService. + */ +public class GcmInstanceIDListenerService extends InstanceIDListenerService { + /** + * Called if InstanceID token is updated. This may occur if the security of + * the previous token had been compromised. This call is initiated by the + * InstanceID provider. + */ + @Override + public void onTokenRefresh() { + Log.d("GeckoPushGCM", "Token refresh request received. Processing on background thread."); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + PushService.getInstance(GcmInstanceIDListenerService.this).onRefresh(); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java new file mode 100644 index 000000000..7962d7dc3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java @@ -0,0 +1,38 @@ +/* -*- 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.gcm; + +import android.os.Bundle; +import android.util.Log; + +import com.google.android.gms.gcm.GcmListenerService; + +import org.mozilla.gecko.push.PushService; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * This service actually handles messages directed from the on-device Google + * Play Services package. We simply route them to the AndroidPushService. + */ +public class GcmMessageListenerService extends GcmListenerService { + /** + * Called when message is received. + * + * @param from SenderID of the sender. + * @param bundle Data bundle containing message data as key/value pairs. + */ + @Override + public void onMessageReceived(final String from, final Bundle bundle) { + Log.d("GeckoPushGCM", "Message received. Processing on background thread."); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + PushService.getInstance(GcmMessageListenerService.this).onMessageReceived( + GcmMessageListenerService.this, bundle); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java new file mode 100644 index 000000000..024905eb0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.gcm; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.gcm.GoogleCloudMessaging; +import com.google.android.gms.iid.InstanceID; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.push.Fetched; + +import java.io.IOException; + +/** + * Fetch and cache GCM tokens. + * <p/> + * GCM tokens are stable and long lived. Google Play Services will periodically request that + * they are rotated, however: see + * <a href="https://developers.google.com/instance-id/guides/android-implementation">https://developers.google.com/instance-id/guides/android-implementation</a>. + * <p/> + * The GCM token is cached in the App-wide shared preferences. There's no particular harm in + * requesting new tokens, so if the user clears the App data, that's fine -- we'll get a fresh + * token and Push will react accordingly. + */ +public class GcmTokenClient { + private static final String LOG_TAG = "GeckoPushGCM"; + + private static final String KEY_GCM_TOKEN = "gcm_token"; + private static final String KEY_GCM_TOKEN_TIMESTAMP = "gcm_token_timestamp"; + + private final Context context; + + public GcmTokenClient(Context context) { + this.context = context; + } + + /** + * Check the device to make sure it has the Google Play Services APK. + * @param context Android context. + */ + protected void ensurePlayServices(Context context) throws NeedsGooglePlayServicesException { + final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + int resultCode = apiAvailability.isGooglePlayServicesAvailable(context); + if (resultCode != ConnectionResult.SUCCESS) { + Log.w(LOG_TAG, "This device does not support GCM! isGooglePlayServicesAvailable returned: " + resultCode); + Log.w(LOG_TAG, "isGooglePlayServicesAvailable message: " + apiAvailability.getErrorString(resultCode)); + throw new NeedsGooglePlayServicesException(resultCode); + } + } + + /** + * Get a GCM token (possibly cached). + * + * @param senderID to request token for. + * @param debug whether to log debug details. + * @return token and timestamp. + * @throws NeedsGooglePlayServicesException if user action is needed to use Google Play Services. + * @throws IOException if the token fetch failed. + */ + public @NonNull Fetched getToken(@NonNull String senderID, boolean debug) throws NeedsGooglePlayServicesException, IOException { + ensurePlayServices(this.context); + + final SharedPreferences sharedPrefs = GeckoSharedPrefs.forApp(context); + String token = sharedPrefs.getString(KEY_GCM_TOKEN, null); + long timestamp = sharedPrefs.getLong(KEY_GCM_TOKEN_TIMESTAMP, 0L); + if (token != null && timestamp > 0L) { + if (debug) { + Log.i(LOG_TAG, "Cached GCM token exists: " + token); + } else { + Log.i(LOG_TAG, "Cached GCM token exists."); + } + return new Fetched(token, timestamp); + } + + Log.i(LOG_TAG, "Cached GCM token does not exist; requesting new token with sender ID: " + senderID); + + final InstanceID instanceID = InstanceID.getInstance(context); + token = instanceID.getToken(senderID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null); + timestamp = System.currentTimeMillis(); + + if (debug) { + Log.i(LOG_TAG, "Got fresh GCM token; caching: " + token); + } else { + Log.i(LOG_TAG, "Got fresh GCM token; caching."); + } + sharedPrefs + .edit() + .putString(KEY_GCM_TOKEN, token) + .putLong(KEY_GCM_TOKEN_TIMESTAMP, timestamp) + .apply(); + + return new Fetched(token, timestamp); + } + + /** + * Remove any cached GCM token. + */ + public void invalidateToken() { + final SharedPreferences sharedPrefs = GeckoSharedPrefs.forApp(context); + sharedPrefs + .edit() + .remove(KEY_GCM_TOKEN) + .remove(KEY_GCM_TOKEN_TIMESTAMP) + .apply(); + } + + public class NeedsGooglePlayServicesException extends Exception { + private static final long serialVersionUID = 4132853166L; + + private final int resultCode; + + NeedsGooglePlayServicesException(int resultCode) { + super(); + this.resultCode = resultCode; + } + + public void showErrorNotification() { + final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance(); + apiAvailability.showErrorNotification(context, resultCode); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java b/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java new file mode 100644 index 000000000..a9f5b72f3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java @@ -0,0 +1,40 @@ +/* -*- 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.health; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONObject; + +/** + * HealthRecorder is an interface into the Firefox Health Report storage system. + */ +public interface HealthRecorder { + /** + * Returns whether the Health Recorder is actively recording events. + */ + public boolean isEnabled(); + + public void setCurrentSession(SessionInformation session); + public void checkForOrphanSessions(); + + public void recordGeckoStartupTime(long duration); + public void recordJavaStartupTime(long duration); + public void recordSearch(final String engineID, final String location); + public void recordSessionEnd(String reason, SharedPreferences.Editor editor); + public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment); + + public void onAppLocaleChanged(String to); + public void onAddonChanged(String id, JSONObject json); + public void onAddonUninstalling(String id); + public void onEnvironmentChanged(); + public void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason); + + public void close(final Context context); + + public void processDelayed(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java b/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java new file mode 100644 index 000000000..ad65918e1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java @@ -0,0 +1,138 @@ +/* -*- 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.health; + +import android.content.SharedPreferences; +import android.util.Log; + +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; + +import org.json.JSONException; +import org.json.JSONObject; + +public class SessionInformation { + private static final String LOG_TAG = "GeckoSessInfo"; + + public static final String PREFS_SESSION_START = "sessionStart"; + + public final long wallStartTime; // System wall clock. + public final long realStartTime; // Realtime clock. + + private final boolean wasOOM; + private final boolean wasStopped; + + private volatile long timedGeckoStartup = -1; + private volatile long timedJavaStartup = -1; + + // Current sessions don't (right now) care about wasOOM/wasStopped. + // Eventually we might want to lift that logic out of GeckoApp. + public SessionInformation(long wallTime, long realTime) { + this(wallTime, realTime, false, false); + } + + // Previous sessions do... + public SessionInformation(long wallTime, long realTime, boolean wasOOM, boolean wasStopped) { + this.wallStartTime = wallTime; + this.realStartTime = realTime; + this.wasOOM = wasOOM; + this.wasStopped = wasStopped; + } + + /** + * Initialize a new SessionInformation instance from the supplied prefs object. + * + * This includes retrieving OOM/crash data, as well as timings. + * + * If no wallStartTime was found, that implies that the previous + * session was correctly recorded, and an object with a zero + * wallStartTime is returned. + */ + public static SessionInformation fromSharedPrefs(SharedPreferences prefs) { + boolean wasOOM = prefs.getBoolean(GeckoAppShell.PREFS_OOM_EXCEPTION, false); + boolean wasStopped = prefs.getBoolean(GeckoApp.PREFS_WAS_STOPPED, true); + long wallStartTime = prefs.getLong(PREFS_SESSION_START, 0L); + long realStartTime = 0L; + Log.d(LOG_TAG, "Building SessionInformation from prefs: " + + wallStartTime + ", " + realStartTime + ", " + + wasStopped + ", " + wasOOM); + return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped); + } + + /** + * Initialize a new SessionInformation instance to 'split' the current + * session. + */ + public static SessionInformation forRuntimeTransition() { + final boolean wasOOM = false; + final boolean wasStopped = true; + final long wallStartTime = System.currentTimeMillis(); + final long realStartTime = android.os.SystemClock.elapsedRealtime(); + Log.v(LOG_TAG, "Recording runtime session transition: " + + wallStartTime + ", " + realStartTime); + return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped); + } + + public boolean wasKilled() { + return wasOOM || !wasStopped; + } + + /** + * Record the beginning of this session to SharedPreferences by + * recording our start time. If a session was already recorded, it is + * overwritten (there can only be one running session at a time). Does + * not commit the editor. + */ + public void recordBegin(SharedPreferences.Editor editor) { + Log.d(LOG_TAG, "Recording start of session: " + this.wallStartTime); + editor.putLong(PREFS_SESSION_START, this.wallStartTime); + } + + /** + * Record the completion of this session to SharedPreferences by + * deleting our start time. Does not commit the editor. + */ + public void recordCompletion(SharedPreferences.Editor editor) { + Log.d(LOG_TAG, "Recording session done: " + this.wallStartTime); + editor.remove(PREFS_SESSION_START); + } + + /** + * Return the JSON that we'll put in the DB for this session. + */ + public JSONObject getCompletionJSON(String reason, long realEndTime) throws JSONException { + long durationSecs = (realEndTime - this.realStartTime) / 1000; + JSONObject out = new JSONObject(); + out.put("r", reason); + out.put("d", durationSecs); + if (this.timedGeckoStartup > 0) { + out.put("sg", this.timedGeckoStartup); + } + if (this.timedJavaStartup > 0) { + out.put("sj", this.timedJavaStartup); + } + return out; + } + + public JSONObject getCrashedJSON() throws JSONException { + JSONObject out = new JSONObject(); + // We use ints here instead of booleans, because we're packing + // stuff into JSON, and saving bytes in the DB is a worthwhile + // goal. + out.put("oom", this.wasOOM ? 1 : 0); + out.put("stopped", this.wasStopped ? 1 : 0); + out.put("r", "A"); + return out; + } + + public void setTimedGeckoStartup(final long duration) { + timedGeckoStartup = duration; + } + + public void setTimedJavaStartup(final long duration) { + timedJavaStartup = duration; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java b/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java new file mode 100644 index 000000000..65a972985 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java @@ -0,0 +1,53 @@ +/* -*- 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.health; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONObject; + +/** + * StubbedHealthRecorder is an implementation of HealthRecorder that does (you guessed it!) + * nothing. + */ +public class StubbedHealthRecorder implements HealthRecorder { + @Override + public boolean isEnabled() { return false; } + + @Override + public void setCurrentSession(SessionInformation session) { } + @Override + public void checkForOrphanSessions() { } + + @Override + public void recordGeckoStartupTime(long duration) { } + @Override + public void recordJavaStartupTime(long duration) { } + @Override + public void recordSearch(final String engineID, final String location) { } + @Override + public void recordSessionEnd(String reason, SharedPreferences.Editor editor) { } + @Override + public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) { } + + @Override + public void onAppLocaleChanged(String to) { } + @Override + public void onAddonChanged(String id, JSONObject json) { } + @Override + public void onAddonUninstalling(String id) { } + @Override + public void onEnvironmentChanged() { } + @Override + public void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) { } + + @Override + public void close(final Context context) { } + + @Override + public void processDelayed() { } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java new file mode 100644 index 000000000..566422faf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java @@ -0,0 +1,147 @@ +/* -*- 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.home; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + +public class BookmarkFolderView extends LinearLayout { + private static final Set<Integer> FOLDERS_WITH_COUNT; + + static { + final Set<Integer> folders = new TreeSet<>(); + folders.add(BrowserContract.Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID); + + FOLDERS_WITH_COUNT = Collections.unmodifiableSet(folders); + } + + public enum FolderState { + /** + * A standard folder, i.e. a folder in a list of bookmarks and folders. + */ + FOLDER(R.drawable.folder_closed), + + /** + * The parent folder: this indicates that you are able to return to the previous + * folder ("Back to {name}"). + */ + PARENT(R.drawable.bookmark_folder_arrow_up), + + /** + * The reading list smartfolder: this displays a reading list icon instead of the + * normal folder icon. + */ + READING_LIST(R.drawable.reading_list_folder); + + public final int image; + + FolderState(final int image) { this.image = image; } + } + + private final TextView mTitle; + private final TextView mSubtitle; + + private final ImageView mIcon; + + public BookmarkFolderView(Context context) { + this(context, null); + } + + public BookmarkFolderView(Context context, AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.two_line_folder_row, this); + + mTitle = (TextView) findViewById(R.id.title); + mSubtitle = (TextView) findViewById(R.id.subtitle); + mIcon = (ImageView) findViewById(R.id.icon); + } + + public void update(String title, int folderID) { + setTitle(title); + setID(folderID); + } + + private void setTitle(String title) { + mTitle.setText(title); + } + + private static class ItemCountUpdateTask extends UIAsyncTask.WithoutParams<Integer> { + private final WeakReference<TextView> mTextViewReference; + private final int mFolderID; + + public ItemCountUpdateTask(final WeakReference<TextView> textViewReference, + final int folderID) { + super(ThreadUtils.getBackgroundHandler()); + + mTextViewReference = textViewReference; + mFolderID = folderID; + } + + @Override + protected Integer doInBackground() { + final TextView textView = mTextViewReference.get(); + + if (textView == null) { + return null; + } + + final BrowserDB db = BrowserDB.from(textView.getContext()); + return db.getBookmarkCountForFolder(textView.getContext().getContentResolver(), mFolderID); + } + + @Override + protected void onPostExecute(Integer count) { + final TextView textView = mTextViewReference.get(); + + if (textView == null) { + return; + } + + final String text; + if (count == 1) { + text = textView.getContext().getResources().getString(R.string.bookmark_folder_one_item); + } else { + text = textView.getContext().getResources().getString(R.string.bookmark_folder_items, count); + } + + textView.setText(text); + textView.setVisibility(View.VISIBLE); + } + } + + private void setID(final int folderID) { + if (FOLDERS_WITH_COUNT.contains(folderID)) { + final WeakReference<TextView> subTitleReference = new WeakReference<TextView>(mSubtitle); + + new ItemCountUpdateTask(subTitleReference, folderID).execute(); + } else { + mSubtitle.setVisibility(View.GONE); + } + } + + public void setState(@NonNull FolderState state) { + mIcon.setImageResource(state.image); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java new file mode 100644 index 000000000..a1efff049 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java @@ -0,0 +1,67 @@ +/* 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.home; + +import android.content.Context; +import android.database.Cursor; +import android.util.AttributeSet; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.UrlAnnotations; + +import java.text.DateFormat; +import java.text.FieldPosition; +import java.util.Date; + +/** + * An entry of the screenshot list in the bookmarks panel. + */ +class BookmarkScreenshotRow extends LinearLayout { + private TextView titleView; + private TextView dateView; + + // This DateFormat uses the current locale at instantiation time, which won't get updated if the locale is changed. + // Since it's just a date, it's probably not worth the code complexity to fix that. + private static final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG); + private static final DateFormat timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT); + + // This parameter to DateFormat.format has no impact on the result but rather gets mutated by the method to + // identify where a certain field starts and ends (by index). This is useful if you want to later modify the String; + // I'm not sure why this argument isn't optional. + private static final FieldPosition dummyFieldPosition = new FieldPosition(-1); + + public BookmarkScreenshotRow(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onFinishInflate() { + super.onFinishInflate(); + titleView = (TextView) findViewById(R.id.title); + dateView = (TextView) findViewById(R.id.date); + } + + public void updateFromCursor(final Cursor c) { + titleView.setText(getTitleFromCursor(c)); + dateView.setText(getDateFromCursor(c)); + } + + private static String getTitleFromCursor(final Cursor c) { + final int index = c.getColumnIndexOrThrow(UrlAnnotations.URL); + return c.getString(index); + } + + private static String getDateFromCursor(final Cursor c) { + final long timestamp = c.getLong(c.getColumnIndexOrThrow(UrlAnnotations.DATE_CREATED)); + final Date date = new Date(timestamp); + final StringBuffer sb = new StringBuffer(); + dateFormat.format(date, sb, dummyFieldPosition) + .append(" - "); + timeFormat.format(date, sb, dummyFieldPosition); + return sb.toString(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java new file mode 100644 index 000000000..b31116693 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java @@ -0,0 +1,352 @@ +/* -*- 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.home; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.home.BookmarkFolderView.FolderState; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; +import android.view.View; + +/** + * Adapter to back the BookmarksListView with a list of bookmarks. + */ +class BookmarksListAdapter extends MultiTypeCursorAdapter { + private static final int VIEW_TYPE_BOOKMARK_ITEM = 0; + private static final int VIEW_TYPE_FOLDER = 1; + private static final int VIEW_TYPE_SCREENSHOT = 2; + + private static final int[] VIEW_TYPES = new int[] { VIEW_TYPE_BOOKMARK_ITEM, VIEW_TYPE_FOLDER, VIEW_TYPE_SCREENSHOT }; + private static final int[] LAYOUT_TYPES = + new int[] { R.layout.bookmark_item_row, R.layout.bookmark_folder_row, R.layout.bookmark_screenshot_row }; + + public enum RefreshType implements Parcelable { + PARENT, + CHILD; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<RefreshType> CREATOR = new Creator<RefreshType>() { + @Override + public RefreshType createFromParcel(final Parcel source) { + return RefreshType.values()[source.readInt()]; + } + + @Override + public RefreshType[] newArray(final int size) { + return new RefreshType[size]; + } + }; + } + + public static class FolderInfo implements Parcelable { + public final int id; + public final String title; + + public FolderInfo(int id) { + this(id, ""); + } + + public FolderInfo(Parcel in) { + this(in.readInt(), in.readString()); + } + + public FolderInfo(int id, String title) { + this.id = id; + this.title = title; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(id); + dest.writeString(title); + } + + public static final Creator<FolderInfo> CREATOR = new Creator<FolderInfo>() { + @Override + public FolderInfo createFromParcel(Parcel in) { + return new FolderInfo(in); + } + + @Override + public FolderInfo[] newArray(int size) { + return new FolderInfo[size]; + } + }; + } + + // A listener that knows how to refresh the list for a given folder id. + // This is usually implemented by the enclosing fragment/activity. + public static interface OnRefreshFolderListener { + // The folder id to refresh the list with. + public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType); + } + + /** + * The type of data a bookmarks folder can display. This can be used to + * distinguish bookmark folders from "smart folders" that contain non-bookmark + * entries but still appear in the Bookmarks panel. + */ + public enum FolderType { + BOOKMARKS, + SCREENSHOTS, + } + + // mParentStack holds folder info instances (id + title) that allow + // us to navigate back up the folder hierarchy. + private LinkedList<FolderInfo> mParentStack; + + // Refresh folder listener. + private OnRefreshFolderListener mListener; + + private FolderType openFolderType = FolderType.BOOKMARKS; + + public BookmarksListAdapter(Context context, Cursor cursor, List<FolderInfo> parentStack) { + // Initializing with a null cursor. + super(context, cursor, VIEW_TYPES, LAYOUT_TYPES); + + if (parentStack == null) { + mParentStack = new LinkedList<FolderInfo>(); + } else { + mParentStack = new LinkedList<FolderInfo>(parentStack); + } + } + + public void restoreData(List<FolderInfo> parentStack) { + mParentStack = new LinkedList<FolderInfo>(parentStack); + notifyDataSetChanged(); + } + + public List<FolderInfo> getParentStack() { + return Collections.unmodifiableList(mParentStack); + } + + public FolderType getOpenFolderType() { + return openFolderType; + } + + /** + * Moves to parent folder, if one exists. + * + * @return Whether the adapter successfully moved to a parent folder. + */ + public boolean moveToParentFolder() { + // If we're already at the root, we can't move to a parent folder. + // An empty parent stack here means we're still waiting for the + // initial list of bookmarks and can't go to a parent folder. + if (mParentStack.size() <= 1) { + return false; + } + + if (mListener != null) { + // We pick the second folder in the stack as it represents + // the parent folder. + mListener.onRefreshFolder(mParentStack.get(1), RefreshType.PARENT); + } + + return true; + } + + /** + * Moves to child folder, given a folderId. + * + * @param folderId The id of the folder to show. + * @param folderTitle The title of the folder to show. + */ + public void moveToChildFolder(int folderId, String folderTitle) { + FolderInfo folderInfo = new FolderInfo(folderId, folderTitle); + + if (mListener != null) { + mListener.onRefreshFolder(folderInfo, RefreshType.CHILD); + } + } + + /** + * Set a listener that can refresh this adapter. + * + * @param listener The listener that can refresh the adapter. + */ + public void setOnRefreshFolderListener(OnRefreshFolderListener listener) { + mListener = listener; + } + + private boolean isCurrentFolder(FolderInfo folderInfo) { + return (mParentStack.size() > 0 && + mParentStack.peek().id == folderInfo.id); + } + + public void swapCursor(Cursor c, FolderInfo folderInfo, RefreshType refreshType) { + updateOpenFolderType(folderInfo); + switch (refreshType) { + case PARENT: + if (!isCurrentFolder(folderInfo)) { + mParentStack.removeFirst(); + } + break; + + case CHILD: + if (!isCurrentFolder(folderInfo)) { + mParentStack.addFirst(folderInfo); + } + break; + + default: + // Do nothing; + } + + swapCursor(c); + } + + private void updateOpenFolderType(final FolderInfo folderInfo) { + if (folderInfo.id == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) { + openFolderType = FolderType.SCREENSHOTS; + } else { + openFolderType = FolderType.BOOKMARKS; + } + } + + @Override + public int getItemViewType(int position) { + // The position also reflects the opened child folder row. + if (isShowingChildFolder()) { + if (position == 0) { + return VIEW_TYPE_FOLDER; + } + + // Accounting for the folder view. + position--; + } + + if (openFolderType == FolderType.SCREENSHOTS) { + return VIEW_TYPE_SCREENSHOT; + } + + final Cursor c = getCursor(position); + if (c.getInt(c.getColumnIndexOrThrow(Bookmarks.TYPE)) == Bookmarks.TYPE_FOLDER) { + return VIEW_TYPE_FOLDER; + } + + // Default to returning normal item type. + return VIEW_TYPE_BOOKMARK_ITEM; + } + + /** + * Get the title of the folder given a cursor moved to the position. + * + * @param context The context of the view. + * @param cursor A cursor moved to the required position. + * @return The title of the folder at the position. + */ + public String getFolderTitle(Context context, Cursor c) { + String guid = c.getString(c.getColumnIndexOrThrow(Bookmarks.GUID)); + + // If we don't have a special GUID, just return the folder title from the DB. + if (guid == null || guid.length() == 12) { + return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE)); + } + + Resources res = context.getResources(); + + // Use localized strings for special folder names. + if (guid.equals(Bookmarks.FAKE_DESKTOP_FOLDER_GUID)) { + return res.getString(R.string.bookmarks_folder_desktop); + } else if (guid.equals(Bookmarks.MENU_FOLDER_GUID)) { + return res.getString(R.string.bookmarks_folder_menu); + } else if (guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID)) { + return res.getString(R.string.bookmarks_folder_toolbar); + } else if (guid.equals(Bookmarks.UNFILED_FOLDER_GUID)) { + return res.getString(R.string.bookmarks_folder_unfiled); + } else if (guid.equals(Bookmarks.SCREENSHOT_FOLDER_GUID)) { + return res.getString(R.string.screenshot_folder_label_in_bookmarks); + } else if (guid.equals(Bookmarks.FAKE_READINGLIST_SMARTFOLDER_GUID)) { + return res.getString(R.string.readinglist_smartfolder_label_in_bookmarks); + } + + // If for some reason we have a folder with a special GUID, but it's not one of + // the special folders we expect in the UI, just return the title from the DB. + return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE)); + } + + /** + * @return true, if currently showing a child folder, false otherwise. + */ + public boolean isShowingChildFolder() { + if (mParentStack.size() == 0) { + return false; + } + + return (mParentStack.peek().id != Bookmarks.FIXED_ROOT_ID); + } + + @Override + public int getCount() { + return super.getCount() + (isShowingChildFolder() ? 1 : 0); + } + + @Override + public void bindView(View view, Context context, int position) { + final int viewType = getItemViewType(position); + + final Cursor cursor; + if (isShowingChildFolder()) { + if (position == 0) { + cursor = null; + } else { + // Accounting for the folder view. + position--; + cursor = getCursor(position); + } + } else { + cursor = getCursor(position); + } + + if (viewType == VIEW_TYPE_SCREENSHOT) { + ((BookmarkScreenshotRow) view).updateFromCursor(cursor); + } else if (viewType == VIEW_TYPE_BOOKMARK_ITEM) { + final TwoLinePageRow row = (TwoLinePageRow) view; + row.updateFromCursor(cursor); + } else { + final BookmarkFolderView row = (BookmarkFolderView) view; + if (cursor == null) { + final Resources res = context.getResources(); + row.update(res.getString(R.string.home_move_back_to_filter, mParentStack.get(1).title), -1); + row.setState(FolderState.PARENT); + } else { + int id = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID)); + + row.update(getFolderTitle(context, cursor), id); + + if (id == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) { + row.setState(FolderState.READING_LIST); + } else { + row.setState(FolderState.FOLDER); + } + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java new file mode 100644 index 000000000..94157be10 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java @@ -0,0 +1,218 @@ + /* -*- 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.home; + +import java.util.EnumSet; +import java.util.List; + +import android.util.Log; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; + +import android.content.Context; +import android.database.Cursor; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.View; +import android.widget.AdapterView; +import android.widget.HeaderViewListAdapter; +import android.widget.ListAdapter; + +import org.mozilla.gecko.reader.SavedReaderViewHelper; +import org.mozilla.gecko.util.NetworkUtils; + +/** + * A ListView of bookmarks. + */ +public class BookmarksListView extends HomeListView + implements AdapterView.OnItemClickListener { + public static final String LOGTAG = "GeckoBookmarksListView"; + + public BookmarksListView(Context context) { + this(context, null); + } + + public BookmarksListView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.bookmarksListViewStyle); + } + + public BookmarksListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + setOnItemClickListener(this); + + setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + final int action = event.getAction(); + + // If the user hit the BACK key, try to move to the parent folder. + if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + return getBookmarksListAdapter().moveToParentFolder(); + } + return false; + } + }); + } + + /** + * Get the appropriate telemetry extra for a given folder. + * + * baseFolderID is the ID of the first-level folder in the parent stack, i.e. the first folder + * that was selected from the root hierarchy (e.g. Desktop, Reading List, or any mobile first-level + * subfolder). If the current folder is a first-level folder, then the fixed root ID may be used + * instead. + * + * We use baseFolderID only to distinguish whether or not we're currently in a desktop subfolder. + * If it isn't equal to FAKE_DESKTOP_FOLDER_ID we know we're in a mobile subfolder, or one + * of the smartfolders. + */ + private String getTelemetryExtraForFolder(int folderID, int baseFolderID) { + if (folderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) { + return "folder_desktop"; + } else if (folderID == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) { + return "folder_screenshots"; + } else if (folderID == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) { + return "folder_reading_list"; + } else { + // The stack depth is 2 for either the fake desktop folder, or any subfolder of mobile + // bookmarks, we subtract these offsets so that any direct subfolder of mobile + // has a level equal to 1. (Desktop folders will be one level deeper due to the + // fake desktop folder, hence subtract 2.) + if (baseFolderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) { + return "folder_desktop_subfolder"; + } else { + return "folder_mobile_subfolder"; + } + } + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final BookmarksListAdapter adapter = getBookmarksListAdapter(); + if (adapter.isShowingChildFolder()) { + if (position == 0) { + // If we tap on an opened folder, move back to parent folder. + + final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack(); + if (parentStack.size() < 2) { + throw new IllegalStateException("Cannot move to parent folder if we are already in the root folder"); + } + + // The first item (top of stack) is the current folder, we're returning to the next one + BookmarksListAdapter.FolderInfo folder = parentStack.get(1); + final int parentID = folder.id; + final int baseFolderID; + if (parentStack.size() > 2) { + baseFolderID = parentStack.get(parentStack.size() - 2).id; + } else { + baseFolderID = Bookmarks.FIXED_ROOT_ID; + } + + final String extra = getTelemetryExtraForFolder(parentID, baseFolderID); + + // Move to parent _after_ retrieving stack information + adapter.moveToParentFolder(); + + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra); + return; + } + + // Accounting for the folder view. + position--; + } + + final Cursor cursor = adapter.getCursor(); + if (cursor == null) { + return; + } + + cursor.moveToPosition(position); + + if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "bookmarks-screenshot"); + + final String fileUrl = "file://" + cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE)); + getOnUrlOpenListener().onUrlOpen(fileUrl, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + return; + } + + int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE)); + if (type == Bookmarks.TYPE_FOLDER) { + // If we're clicking on a folder, update adapter to move to that folder + final int folderId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID)); + final String folderTitle = adapter.getFolderTitle(parent.getContext(), cursor); + adapter.moveToChildFolder(folderId, folderTitle); + + final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack(); + + final int baseFolderID; + if (parentStack.size() > 2) { + baseFolderID = parentStack.get(parentStack.size() - 2).id; + } else { + baseFolderID = Bookmarks.FIXED_ROOT_ID; + } + + final String extra = getTelemetryExtraForFolder(folderId, baseFolderID); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra); + } else { + // Otherwise, just open the URL + final String url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL)); + + final SavedReaderViewHelper rvh = SavedReaderViewHelper.getSavedReaderViewHelper(getContext()); + + final String extra; + if (rvh.isURLCached(url)) { + extra = "bookmarks-reader"; + } else { + extra = "bookmarks"; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, extra); + Telemetry.addToHistogram("FENNEC_LOAD_SAVED_PAGE", NetworkUtils.isConnected(getContext()) ? 2 : 3); + + // This item is a TwoLinePageRow, so we allow switch-to-tab. + getOnUrlOpenListener().onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + } + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + // Adjust the item position to account for the parent folder row that is inserted + // at the top of the list when viewing the contents of a folder. + final BookmarksListAdapter adapter = getBookmarksListAdapter(); + if (adapter.isShowingChildFolder()) { + position--; + } + + // Temporarily prevent crashes until we figure out what we actually want to do here (bug 1252316). + if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) { + return false; + } + + return super.onItemLongClick(parent, view, position, id); + } + + private BookmarksListAdapter getBookmarksListAdapter() { + BookmarksListAdapter adapter; + ListAdapter listAdapter = getAdapter(); + if (listAdapter instanceof HeaderViewListAdapter) { + adapter = (BookmarksListAdapter) ((HeaderViewListAdapter) listAdapter).getWrappedAdapter(); + } else { + adapter = (BookmarksListAdapter) listAdapter; + } + return adapter; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java new file mode 100644 index 000000000..4b4781996 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java @@ -0,0 +1,316 @@ +/* -*- 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.home; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy; +import org.mozilla.gecko.home.BookmarksListAdapter.FolderInfo; +import org.mozilla.gecko.home.BookmarksListAdapter.OnRefreshFolderListener; +import org.mozilla.gecko.home.BookmarksListAdapter.RefreshType; +import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.preferences.GeckoPreferences; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Configuration; +import android.database.Cursor; +import android.database.MergeCursor; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.ImageView; +import android.widget.TextView; + +/** + * A page in about:home that displays a ListView of bookmarks. + */ +public class BookmarksPanel extends HomeFragment { + public static final String LOGTAG = "GeckoBookmarksPanel"; + + // Cursor loader ID for list of bookmarks. + private static final int LOADER_ID_BOOKMARKS_LIST = 0; + + // Information about the target bookmarks folder. + private static final String BOOKMARKS_FOLDER_INFO = "folder_info"; + + // Refresh type for folder refreshing loader. + private static final String BOOKMARKS_REFRESH_TYPE = "refresh_type"; + + // List of bookmarks. + private BookmarksListView mList; + + // Adapter for list of bookmarks. + private BookmarksListAdapter mListAdapter; + + // Adapter's parent stack. + private List<FolderInfo> mSavedParentStack; + + // Reference to the View to display when there are no results. + private View mEmptyView; + + // Callback for cursor loaders. + private CursorLoaderCallbacks mLoaderCallbacks; + + @Override + public void restoreData(@NonNull Bundle data) { + final ArrayList<FolderInfo> stack = data.getParcelableArrayList("parentStack"); + if (stack == null) { + return; + } + + if (mListAdapter == null) { + mSavedParentStack = new LinkedList<FolderInfo>(stack); + } else { + mListAdapter.restoreData(stack); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.home_bookmarks_panel, container, false); + + mList = (BookmarksListView) view.findViewById(R.id.bookmarks_list); + + mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { + @Override + public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { + final int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE)); + if (type == Bookmarks.TYPE_FOLDER) { + // We don't show a context menu for folders + return null; + } + final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); + info.url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.TITLE)); + info.bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID)); + info.itemType = RemoveItemType.BOOKMARKS; + return info; + } + }); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + OnUrlOpenListener listener = null; + try { + listener = (OnUrlOpenListener) getActivity(); + } catch (ClassCastException e) { + throw new ClassCastException(getActivity().toString() + + " must implement HomePager.OnUrlOpenListener"); + } + + mList.setTag(HomePager.LIST_TAG_BOOKMARKS); + mList.setOnUrlOpenListener(listener); + + registerForContextMenu(mList); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final Activity activity = getActivity(); + + // Setup the list adapter. + mListAdapter = new BookmarksListAdapter(activity, null, mSavedParentStack); + mListAdapter.setOnRefreshFolderListener(new OnRefreshFolderListener() { + @Override + public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType) { + // Restart the loader with folder as the argument. + Bundle bundle = new Bundle(); + bundle.putParcelable(BOOKMARKS_FOLDER_INFO, folderInfo); + bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, refreshType); + getLoaderManager().restartLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks); + } + }); + mList.setAdapter(mListAdapter); + + // Create callbacks before the initial loader is started. + mLoaderCallbacks = new CursorLoaderCallbacks(); + loadIfVisible(); + } + + @Override + public void onDestroyView() { + mList = null; + mListAdapter = null; + mEmptyView = null; + super.onDestroyView(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (isVisible()) { + // The parent stack is saved just so that the folder state can be + // restored on rotation. + mSavedParentStack = mListAdapter.getParentStack(); + } + } + + @Override + protected void load() { + final Bundle bundle; + if (mSavedParentStack != null && mSavedParentStack.size() > 1) { + bundle = new Bundle(); + bundle.putParcelable(BOOKMARKS_FOLDER_INFO, mSavedParentStack.get(0)); + bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, RefreshType.CHILD); + } else { + bundle = null; + } + + getLoaderManager().initLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks); + } + + private void updateUiFromCursor(Cursor c) { + if ((c == null || c.getCount() == 0) && mEmptyView == null) { + // Set empty page view. We delay this so that the empty view won't flash. + final ViewStub emptyViewStub = (ViewStub) getView().findViewById(R.id.home_empty_view_stub); + mEmptyView = emptyViewStub.inflate(); + + final ImageView emptyIcon = (ImageView) mEmptyView.findViewById(R.id.home_empty_image); + emptyIcon.setImageResource(R.drawable.icon_bookmarks_empty); + + final TextView emptyText = (TextView) mEmptyView.findViewById(R.id.home_empty_text); + emptyText.setText(R.string.home_bookmarks_empty); + + mList.setEmptyView(mEmptyView); + } + } + + /** + * Loader for the list for bookmarks. + */ + private static class BookmarksLoader extends SimpleCursorLoader { + private final FolderInfo mFolderInfo; + private final RefreshType mRefreshType; + private final BrowserDB mDB; + + public BookmarksLoader(Context context) { + this(context, + new FolderInfo(Bookmarks.FIXED_ROOT_ID, context.getResources().getString(R.string.bookmarks_title)), + RefreshType.CHILD); + } + + public BookmarksLoader(Context context, FolderInfo folderInfo, RefreshType refreshType) { + super(context); + mFolderInfo = folderInfo; + mRefreshType = refreshType; + mDB = BrowserDB.from(context); + } + + @Override + public Cursor loadCursor() { + final boolean isRootFolder = mFolderInfo.id == BrowserContract.Bookmarks.FIXED_ROOT_ID; + + final ContentResolver contentResolver = getContext().getContentResolver(); + + Cursor partnerCursor = null; + Cursor userCursor = null; + + if (GeckoSharedPrefs.forProfile(getContext()).getBoolean(GeckoPreferences.PREFS_READ_PARTNER_BOOKMARKS_PROVIDER, false) + && (isRootFolder || mFolderInfo.id <= Bookmarks.FAKE_PARTNER_BOOKMARKS_START)) { + partnerCursor = contentResolver.query(PartnerBookmarksProviderProxy.getUriForBookmarks(getContext(), mFolderInfo.id), null, null, null, null, null); + } + + if (isRootFolder || mFolderInfo.id > Bookmarks.FAKE_PARTNER_BOOKMARKS_START) { + userCursor = mDB.getBookmarksInFolder(contentResolver, mFolderInfo.id); + } + + + if (partnerCursor == null && userCursor == null) { + return null; + } else if (partnerCursor == null) { + return userCursor; + } else if (userCursor == null) { + return partnerCursor; + } else { + return new MergeCursor(new Cursor[] { partnerCursor, userCursor }); + } + } + + @Override + public void onContentChanged() { + // Invalidate the cached value that keeps track of whether or + // not desktop bookmarks exist. + mDB.invalidate(); + super.onContentChanged(); + } + + public FolderInfo getFolderInfo() { + return mFolderInfo; + } + + public RefreshType getRefreshType() { + return mRefreshType; + } + } + + /** + * Loader callbacks for the LoaderManager of this fragment. + */ + private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (args == null) { + return new BookmarksLoader(getActivity()); + } else { + FolderInfo folderInfo = (FolderInfo) args.getParcelable(BOOKMARKS_FOLDER_INFO); + RefreshType refreshType = (RefreshType) args.getParcelable(BOOKMARKS_REFRESH_TYPE); + return new BookmarksLoader(getActivity(), folderInfo, refreshType); + } + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { + BookmarksLoader bl = (BookmarksLoader) loader; + mListAdapter.swapCursor(c, bl.getFolderInfo(), bl.getRefreshType()); + + if (mPanelStateChangeListener != null) { + final List<FolderInfo> parentStack = mListAdapter.getParentStack(); + final Bundle bundle = new Bundle(); + + // Bundle likes to store ArrayLists or Arrays, but we've got a generic List (which + // is actually an unmodifiable wrapper around a LinkedList). We'll need to do a + // LinkedList conversion at the other end, when saving we need to use this awkward + // syntax to create an Array. + bundle.putParcelableArrayList("parentStack", new ArrayList<FolderInfo>(parentStack)); + + mPanelStateChangeListener.onStateChanged(bundle); + } + updateUiFromCursor(c); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (mList != null) { + mListAdapter.swapCursor(null); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java new file mode 100644 index 000000000..7732932fe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java @@ -0,0 +1,1316 @@ +/* -*- 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.home; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; + +import android.content.SharedPreferences; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SuggestClient; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.History; +import org.mozilla.gecko.db.BrowserContract.URLColumns; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.toolbar.AutocompleteHandler; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.view.ContextMenu.ContextMenuInfo; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.AsyncTaskLoader; +import android.support.v4.content.Loader; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.WindowManager.LayoutParams; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.AdapterView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +/** + * Fragment that displays frecency search results in a ListView. + */ +public class BrowserSearch extends HomeFragment + implements GeckoEventListener, + SearchEngineBar.OnSearchBarClickListener { + + @RobocopTarget + public interface SuggestClientFactory { + public SuggestClient getSuggestClient(Context context, String template, int timeout, int max); + } + + @RobocopTarget + public static class DefaultSuggestClientFactory implements SuggestClientFactory { + @Override + public SuggestClient getSuggestClient(Context context, String template, int timeout, int max) { + return new SuggestClient(context, template, timeout, max, true); + } + } + + /** + * Set this to mock the suggestion mechanism. Public for access from tests. + */ + @RobocopTarget + public static volatile SuggestClientFactory sSuggestClientFactory = new DefaultSuggestClientFactory(); + + // Logging tag name + private static final String LOGTAG = "GeckoBrowserSearch"; + + // Cursor loader ID for search query + private static final int LOADER_ID_SEARCH = 0; + + // AsyncTask loader ID for suggestion query + private static final int LOADER_ID_SUGGESTION = 1; + private static final int LOADER_ID_SAVED_SUGGESTION = 2; + + // Timeout for the suggestion client to respond + private static final int SUGGESTION_TIMEOUT = 3000; + + // Maximum number of suggestions from the search engine's suggestion client. This impacts network traffic and device + // data consumption whereas R.integer.max_saved_suggestions controls how many suggestion to show in the UI. + private static final int NETWORK_SUGGESTION_MAX = 3; + + // The maximum number of rows deep in a search we'll dig + // for an autocomplete result + private static final int MAX_AUTOCOMPLETE_SEARCH = 20; + + // Length of https:// + 1 required to make autocomplete + // fill in the domain, for both http:// and https:// + private static final int HTTPS_PREFIX_LENGTH = 9; + + // Duration for fade-in animation + private static final int ANIMATION_DURATION = 250; + + // Holds the current search term to use in the query + private volatile String mSearchTerm; + + // Adapter for the list of search results + private SearchAdapter mAdapter; + + // The view shown by the fragment + private LinearLayout mView; + + // The list showing search results + private HomeListView mList; + + // The bar on the bottom of the screen displaying search engine options. + private SearchEngineBar mSearchEngineBar; + + // Client that performs search suggestion queries. + // Public for testing. + @RobocopTarget + public volatile SuggestClient mSuggestClient; + + // List of search engines from Gecko. + // Do not mutate this list. + // Access to this member must only occur from the UI thread. + private List<SearchEngine> mSearchEngines; + + // Search history suggestions + private ArrayList<String> mSearchHistorySuggestions; + + // Track the locale that was last in use when we filled mSearchEngines. + // Access to this member must only occur from the UI thread. + private Locale mLastLocale; + + // Whether search suggestions are enabled or not + private boolean mSuggestionsEnabled; + + // Whether history suggestions are enabled or not + private boolean mSavedSearchesEnabled; + + // Callbacks used for the search loader + private CursorLoaderCallbacks mCursorLoaderCallbacks; + + // Callbacks used for the search suggestion loader + private SearchEngineSuggestionLoaderCallbacks mSearchEngineSuggestionLoaderCallbacks; + private SearchHistorySuggestionLoaderCallbacks mSearchHistorySuggestionLoaderCallback; + + // Autocomplete handler used when filtering results + private AutocompleteHandler mAutocompleteHandler; + + // On search listener + private OnSearchListener mSearchListener; + + // On edit suggestion listener + private OnEditSuggestionListener mEditSuggestionListener; + + // Whether the suggestions will fade in when shown + private boolean mAnimateSuggestions; + + // Opt-in prompt view for search suggestions + private View mSuggestionsOptInPrompt; + + public interface OnSearchListener { + void onSearch(SearchEngine engine, String text, TelemetryContract.Method method); + } + + public interface OnEditSuggestionListener { + public void onEditSuggestion(String suggestion); + } + + public static BrowserSearch newInstance() { + BrowserSearch browserSearch = new BrowserSearch(); + + final Bundle args = new Bundle(); + args.putBoolean(HomePager.CAN_LOAD_ARG, true); + browserSearch.setArguments(args); + + return browserSearch; + } + + public BrowserSearch() { + mSearchTerm = ""; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mSearchListener = (OnSearchListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + + " must implement BrowserSearch.OnSearchListener"); + } + + try { + mEditSuggestionListener = (OnEditSuggestionListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + + " must implement BrowserSearch.OnEditSuggestionListener"); + } + } + + @Override + public void onDetach() { + super.onDetach(); + + mAutocompleteHandler = null; + mSearchListener = null; + mEditSuggestionListener = null; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mSearchEngines = new ArrayList<SearchEngine>(); + mSearchHistorySuggestions = new ArrayList<>(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + mSearchEngines = null; + } + + @Override + public void onHiddenChanged(boolean hidden) { + if (!hidden) { + final Tab tab = Tabs.getInstance().getSelectedTab(); + final boolean isPrivate = (tab != null && tab.isPrivate()); + + // Removes Search Suggestions Loader if in private browsing mode + // Loader may have been inserted when browsing in normal tab + if (isPrivate) { + getLoaderManager().destroyLoader(LOADER_ID_SUGGESTION); + } + + GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null); + } + super.onHiddenChanged(hidden); + } + + @Override + public void onResume() { + super.onResume(); + + final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext()); + mSavedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true); + + // Fetch engines if we need to. + if (mSearchEngines.isEmpty() || !Locale.getDefault().equals(mLastLocale)) { + GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null); + } else { + updateSearchEngineBar(); + } + + Telemetry.startUISession(TelemetryContract.Session.FRECENCY); + } + + @Override + public void onPause() { + super.onPause(); + + Telemetry.stopUISession(TelemetryContract.Session.FRECENCY); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + // All list views are styled to look the same with a global activity theme. + // If the style of the list changes, inflate it from an XML. + mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false); + mList = (HomeListView) mView.findViewById(R.id.home_list_view); + mSearchEngineBar = (SearchEngineBar) mView.findViewById(R.id.search_engine_bar); + + return mView; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "SearchEngines:Data"); + + mSearchEngineBar.setAdapter(null); + mSearchEngineBar = null; + + mList.setAdapter(null); + mList = null; + + mView = null; + mSuggestionsOptInPrompt = null; + mSuggestClient = null; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH); + + mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + // Perform the user-entered search if the user clicks on a search engine row. + // This row will be disabled if suggestions (in addition to the user-entered term) are showing. + if (view instanceof SearchEngineRow) { + ((SearchEngineRow) view).performUserEnteredSearch(); + return; + } + + // Account for the search engine rows. + position -= getPrimaryEngineCount(); + final Cursor c = mAdapter.getCursor(position); + final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL)); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "frecency"); + + // This item is a TwoLinePageRow, so we allow switch-to-tab. + mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + } + }); + + mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + // Don't do anything when the user long-clicks on a search engine row. + if (view instanceof SearchEngineRow) { + return true; + } + + // Account for the search engine rows. + position -= getPrimaryEngineCount(); + return mList.onItemLongClick(parent, view, position, id); + } + }); + + final ListSelectionListener listener = new ListSelectionListener(); + mList.setOnItemSelectedListener(listener); + mList.setOnFocusChangeListener(listener); + + mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { + @Override + public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { + final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); + info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE)); + + int bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID)); + info.bookmarkId = bookmarkId; + + int historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID)); + info.historyId = historyId; + + boolean isBookmark = bookmarkId != -1; + boolean isHistory = historyId != -1; + + if (isBookmark && isHistory) { + info.itemType = HomeContextMenuInfo.RemoveItemType.COMBINED; + } else if (isBookmark) { + info.itemType = HomeContextMenuInfo.RemoveItemType.BOOKMARKS; + } else if (isHistory) { + info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY; + } + + return info; + } + }); + + mList.setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, android.view.KeyEvent event) { + final View selected = mList.getSelectedView(); + + if (selected instanceof SearchEngineRow) { + return selected.onKeyDown(keyCode, event); + } + return false; + } + }); + + registerForContextMenu(mList); + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "SearchEngines:Data"); + + mSearchEngineBar.setOnSearchBarClickListener(this); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { + if (!(menuInfo instanceof HomeContextMenuInfo)) { + return; + } + + HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo; + + MenuInflater inflater = new MenuInflater(view.getContext()); + inflater.inflate(R.menu.browsersearch_contextmenu, menu); + + menu.setHeaderTitle(info.getDisplayTitle()); + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + ContextMenuInfo menuInfo = item.getMenuInfo(); + if (!(menuInfo instanceof HomeContextMenuInfo)) { + return false; + } + + final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo; + final Context context = getActivity(); + + final int itemId = item.getItemId(); + + if (itemId == R.id.browsersearch_remove) { + // Position for Top Sites grid items, but will always be -1 since this is only for BrowserSearch result + final int position = -1; + + new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute(); + return true; + } + + return false; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + // Initialize the search adapter + mAdapter = new SearchAdapter(getActivity()); + mList.setAdapter(mAdapter); + + // Only create an instance when we need it + mSearchEngineSuggestionLoaderCallbacks = null; + mSearchHistorySuggestionLoaderCallback = null; + + // Create callbacks before the initial loader is started + mCursorLoaderCallbacks = new CursorLoaderCallbacks(); + loadIfVisible(); + } + + @Override + public void handleMessage(String event, final JSONObject message) { + if (event.equals("SearchEngines:Data")) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + setSearchEngines(message); + } + }); + } + } + + @Override + protected void load() { + SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); + } + + private void handleAutocomplete(String searchTerm, Cursor c) { + if (c == null || + mAutocompleteHandler == null || + TextUtils.isEmpty(searchTerm)) { + return; + } + + // Avoid searching the path if we don't have to. Currently just + // decided by whether there is a '/' character in the string. + final boolean searchPath = searchTerm.indexOf('/') > 0; + final String autocompletion = findAutocompletion(searchTerm, c, searchPath); + + if (autocompletion == null || mAutocompleteHandler == null) { + return; + } + + // Prefetch auto-completed domain since it's a likely target + GeckoAppShell.notifyObservers("Session:Prefetch", "http://" + autocompletion); + + mAutocompleteHandler.onAutocomplete(autocompletion); + mAutocompleteHandler = null; + } + + /** + * Returns the substring of a provided URI, starting at the given offset, + * and extending up to the end of the path segment in which the provided + * index is found. + * + * For example, given + * + * "www.reddit.com/r/boop/abcdef", 0, ? + * + * this method returns + * + * ?=2: "www.reddit.com/" + * ?=17: "www.reddit.com/r/boop/" + * ?=21: "www.reddit.com/r/boop/" + * ?=22: "www.reddit.com/r/boop/abcdef" + * + */ + private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) { + final int afterEnd = url.length(); + + // We want to include the trailing slash, but not other characters. + int chop = url.indexOf('/', begin); + if (chop != -1) { + ++chop; + if (chop < offset) { + // This isn't supposed to happen. Fall back to returning the whole damn thing. + return url; + } + } else { + chop = url.indexOf('?', begin); + if (chop == -1) { + chop = url.indexOf('#', begin); + } + if (chop == -1) { + chop = afterEnd; + } + } + + return url.substring(offset, chop); + } + + LinkedHashSet<String> domains = null; + private LinkedHashSet<String> getDomains() { + if (domains == null) { + domains = new LinkedHashSet<String>(500); + BufferedReader buf = null; + try { + buf = new BufferedReader(new InputStreamReader(getResources().openRawResource(R.raw.topdomains))); + String res = null; + + do { + res = buf.readLine(); + if (res != null) { + domains.add(res); + } + } while (res != null); + } catch (IOException e) { + Log.e(LOGTAG, "Error reading domains", e); + } finally { + if (buf != null) { + try { + buf.close(); + } catch (IOException e) { } + } + } + } + return domains; + } + + private String searchDomains(String search) { + for (String domain : getDomains()) { + if (domain.startsWith(search)) { + return domain; + } + } + return null; + } + + private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) { + if (!c.moveToFirst()) { + // No cursor probably means no history, so let's try the fallback list. + return searchDomains(searchTerm); + } + + final int searchLength = searchTerm.length(); + final int urlIndex = c.getColumnIndexOrThrow(History.URL); + int searchCount = 0; + + do { + final String url = c.getString(urlIndex); + + if (searchCount == 0) { + // Prefetch the first item in the list since it's weighted the highest + GeckoAppShell.notifyObservers("Session:Prefetch", url); + } + + // Does the completion match against the whole URL? This will match + // about: pages, as well as user input including "http://...". + if (url.startsWith(searchTerm)) { + return uriSubstringUpToMatchedPath(url, 0, + (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH); + } + + final Uri uri = Uri.parse(url); + final String host = uri.getHost(); + + // Host may be null for about pages. + if (host == null) { + continue; + } + + if (host.startsWith(searchTerm)) { + return host + "/"; + } + + final String strippedHost = StringUtils.stripCommonSubdomains(host); + if (strippedHost.startsWith(searchTerm)) { + return strippedHost + "/"; + } + + ++searchCount; + + if (!searchPath) { + continue; + } + + // Otherwise, if we're matching paths, let's compare against the string itself. + final int hostOffset = url.indexOf(strippedHost); + if (hostOffset == -1) { + // This was a URL string that parsed to a different host (normalized?). + // Give up. + continue; + } + + // We already matched the non-stripped host, so now we're + // substring-searching in the part of the URL without the common + // subdomains. + if (url.startsWith(searchTerm, hostOffset)) { + // Great! Return including the rest of the path segment. + return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength); + } + } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext()); + + // If we can't find an autocompletion domain from history, let's try using the fallback list. + return searchDomains(searchTerm); + } + + public void resetScrollState() { + mSearchEngineBar.scrollToPosition(0); + } + + private void filterSuggestions() { + Tab tab = Tabs.getInstance().getSelectedTab(); + final boolean isPrivate = (tab != null && tab.isPrivate()); + + // mSuggestClient may be null if we haven't received our search engine list yet - hence + // we need to exit here in that case. + if (isPrivate || mSuggestClient == null || (!mSuggestionsEnabled && !mSavedSearchesEnabled)) { + mSearchHistorySuggestions.clear(); + return; + } + + // Suggestions from search engine + if (mSearchEngineSuggestionLoaderCallbacks == null) { + mSearchEngineSuggestionLoaderCallbacks = new SearchEngineSuggestionLoaderCallbacks(); + } + getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSearchEngineSuggestionLoaderCallbacks); + + // Saved suggestions + if (mSearchHistorySuggestionLoaderCallback == null) { + mSearchHistorySuggestionLoaderCallback = new SearchHistorySuggestionLoaderCallbacks(); + } + getLoaderManager().restartLoader(LOADER_ID_SAVED_SUGGESTION, null, mSearchHistorySuggestionLoaderCallback); + } + + private void setSuggestions(ArrayList<String> suggestions) { + ThreadUtils.assertOnUiThread(); + + // mSearchEngines may be null if the setSuggestions calls after onDestroy (bug 1310621). + // So drop the suggestions if search engines are not available + if (mSearchEngines != null && !mSearchEngines.isEmpty()) { + mSearchEngines.get(0).setSuggestions(suggestions); + mAdapter.notifyDataSetChanged(); + } + + } + + private void setSavedSuggestions(ArrayList<String> savedSuggestions) { + ThreadUtils.assertOnUiThread(); + + mSearchHistorySuggestions = savedSuggestions; + mAdapter.notifyDataSetChanged(); + } + + private boolean shouldUpdateSearchEngine(ArrayList<SearchEngine> searchEngines) { + if (searchEngines.size() != mSearchEngines.size()) { + return true; + } + + int size = searchEngines.size(); + + for (int i = 0; i < size; i++) { + if (!mSearchEngines.get(i).name.equals(searchEngines.get(i).name)) { + return true; + } + } + + return false; + } + + private void setSearchEngines(JSONObject data) { + ThreadUtils.assertOnUiThread(); + + // This method is called via a Runnable posted from the Gecko thread, so + // it's possible the fragment and/or its view has been destroyed by the + // time we get here. If so, just abort. + if (mView == null) { + return; + } + + try { + final JSONObject suggest = data.getJSONObject("suggest"); + final String suggestEngine = suggest.optString("engine", null); + final String suggestTemplate = suggest.optString("template", null); + final boolean suggestionsPrompted = suggest.getBoolean("prompted"); + final JSONArray engines = data.getJSONArray("searchEngines"); + + mSuggestionsEnabled = suggest.getBoolean("enabled"); + + ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>(); + for (int i = 0; i < engines.length(); i++) { + final JSONObject engineJSON = engines.getJSONObject(i); + final SearchEngine engine = new SearchEngine((Context) getActivity(), engineJSON); + + if (engine.name.equals(suggestEngine) && suggestTemplate != null) { + // Suggest engine should be at the front of the list. + // We're baking in an assumption here that the suggest engine + // is also the default engine. + searchEngines.add(0, engine); + + ensureSuggestClientIsSet(suggestTemplate); + } else { + searchEngines.add(engine); + } + } + + // checking if the new searchEngine is different from mSearchEngine, will have to re-layout if yes + boolean change = shouldUpdateSearchEngine(searchEngines); + + if (mAdapter != null && change) { + mSearchEngines = Collections.unmodifiableList(searchEngines); + mLastLocale = Locale.getDefault(); + updateSearchEngineBar(); + + mAdapter.notifyDataSetChanged(); + } + + final Tab tab = Tabs.getInstance().getSelectedTab(); + final boolean isPrivate = (tab != null && tab.isPrivate()); + + // Show suggestions opt-in prompt only if suggestions are not enabled yet, + // user hasn't been prompted and we're not on a private browsing tab. + // The prompt might have been inflated already when this view was previously called. + // Remove the opt-in prompt if it has been inflated in the view and dealt with by the user, + // or if we're on a private browsing tab + if (!mSuggestionsEnabled && !suggestionsPrompted && !isPrivate) { + showSuggestionsOptIn(); + } else { + removeSuggestionsOptIn(); + } + + } catch (JSONException e) { + Log.e(LOGTAG, "Error getting search engine JSON", e); + } + + filterSuggestions(); + } + + private void updateSearchEngineBar() { + final int primaryEngineCount = getPrimaryEngineCount(); + + if (primaryEngineCount < mSearchEngines.size()) { + mSearchEngineBar.setSearchEngines( + mSearchEngines.subList(primaryEngineCount, mSearchEngines.size()) + ); + mSearchEngineBar.setVisibility(View.VISIBLE); + } else { + mSearchEngineBar.setVisibility(View.GONE); + } + } + + @Override + public void onSearchBarClickListener(final SearchEngine searchEngine) { + final TelemetryContract.Method method = TelemetryContract.Method.LIST_ITEM; + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, "searchenginebar"); + mSearchListener.onSearch(searchEngine, mSearchTerm, method); + } + + private void ensureSuggestClientIsSet(final String suggestTemplate) { + // Don't update the suggestClient if we already have a client with the correct template + if (mSuggestClient != null && suggestTemplate.equals(mSuggestClient.getSuggestTemplate())) { + return; + } + + mSuggestClient = sSuggestClientFactory.getSuggestClient(getActivity(), suggestTemplate, SUGGESTION_TIMEOUT, NETWORK_SUGGESTION_MAX); + } + + private void showSuggestionsOptIn() { + // Only make the ViewStub visible again if it has already previously been shown. + // (An inflated ViewStub is removed from the View hierarchy so a second call to findViewById will return null, + // which also further necessitates handling this separately.) + if (mSuggestionsOptInPrompt != null) { + mSuggestionsOptInPrompt.setVisibility(View.VISIBLE); + return; + } + + mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate(); + + TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title); + promptText.setText(getResources().getString(R.string.suggestions_prompt)); + + final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes); + final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no); + + final OnClickListener listener = new OnClickListener() { + @Override + public void onClick(View v) { + // Prevent the buttons from being clicked multiple times (bug 816902) + yesButton.setOnClickListener(null); + noButton.setOnClickListener(null); + + setSuggestionsEnabled(v == yesButton); + } + }; + + yesButton.setOnClickListener(listener); + noButton.setOnClickListener(listener); + + // If the prompt gains focus, automatically pass focus to the + // yes button in the prompt. + final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); + prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + yesButton.requestFocus(); + } + } + }); + } + + private void removeSuggestionsOptIn() { + if (mSuggestionsOptInPrompt == null) { + return; + } + + mSuggestionsOptInPrompt.setVisibility(View.GONE); + } + + private void setSuggestionsEnabled(final boolean enabled) { + // Clicking the yes/no buttons quickly can cause the click events be + // queued before the listeners are removed above, so it's possible + // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt + // can be null if this happens (bug 828480). + if (mSuggestionsOptInPrompt == null) { + return; + } + + // Make suggestions appear immediately after the user opts in + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + SuggestClient client = mSuggestClient; + if (client != null) { + client.query(mSearchTerm); + } + } + }); + + PrefsHelper.setPref("browser.search.suggest.prompted", true); + PrefsHelper.setPref("browser.search.suggest.enabled", enabled); + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, (enabled ? "suggestions_optin_yes" : "suggestions_optin_no")); + + TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0); + slideAnimation.setDuration(ANIMATION_DURATION); + slideAnimation.setInterpolator(new AccelerateInterpolator()); + slideAnimation.setFillAfter(true); + final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt); + + TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight()); + shrinkAnimation.setDuration(ANIMATION_DURATION); + shrinkAnimation.setFillAfter(true); + shrinkAnimation.setStartOffset(slideAnimation.getDuration()); + shrinkAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation a) { + // Increase the height of the view so a gap isn't shown during animation + mView.getLayoutParams().height = mView.getHeight() + + mSuggestionsOptInPrompt.getHeight(); + mView.requestLayout(); + } + + @Override + public void onAnimationRepeat(Animation a) {} + + @Override + public void onAnimationEnd(Animation a) { + // Removing the view immediately results in a NPE in + // dispatchDraw(), possibly because this callback executes + // before drawing is finished. Posting this as a Runnable fixes + // the issue. + mView.post(new Runnable() { + @Override + public void run() { + mView.removeView(mSuggestionsOptInPrompt); + mList.clearAnimation(); + mSuggestionsOptInPrompt = null; + + // Reset the view height + mView.getLayoutParams().height = LayoutParams.MATCH_PARENT; + + // Show search suggestions and update them + if (enabled) { + mSuggestionsEnabled = enabled; + mAnimateSuggestions = true; + mAdapter.notifyDataSetChanged(); + filterSuggestions(); + } + } + }); + } + }); + + prompt.startAnimation(slideAnimation); + mSuggestionsOptInPrompt.startAnimation(shrinkAnimation); + mList.startAnimation(shrinkAnimation); + } + + private int getPrimaryEngineCount() { + return mSearchEngines.size() > 0 ? 1 : 0; + } + + private void restartSearchLoader() { + SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); + } + + private void initSearchLoader() { + SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm); + } + + public void filter(String searchTerm, AutocompleteHandler handler) { + if (TextUtils.isEmpty(searchTerm)) { + return; + } + + final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm); + + mSearchTerm = searchTerm; + mAutocompleteHandler = handler; + + if (mAdapter != null) { + if (isNewFilter) { + // The adapter depends on the search term to determine its number + // of items. Make it we notify the view about it. + mAdapter.notifyDataSetChanged(); + + // Restart loaders with the new search term + restartSearchLoader(); + filterSuggestions(); + } else { + // The search term hasn't changed, simply reuse any existing + // loader for the current search term. This will ensure autocompletion + // is consistently triggered (see bug 933739). + initSearchLoader(); + } + } + } + + abstract private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> { + protected final String mSearchTerm; + private ArrayList<String> mSuggestions; + + public SuggestionAsyncLoader(Context context, String searchTerm) { + super(context); + mSearchTerm = searchTerm; + } + + @Override + public void deliverResult(ArrayList<String> suggestions) { + mSuggestions = suggestions; + + if (isStarted()) { + super.deliverResult(mSuggestions); + } + } + + @Override + protected void onStartLoading() { + if (mSuggestions != null) { + deliverResult(mSuggestions); + } + + if (takeContentChanged() || mSuggestions == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + + onStopLoading(); + mSuggestions = null; + } + } + + private static class SearchEngineSuggestionAsyncLoader extends SuggestionAsyncLoader { + private final SuggestClient mSuggestClient; + + public SearchEngineSuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) { + super(context, searchTerm); + mSuggestClient = suggestClient; + } + + @Override + public ArrayList<String> loadInBackground() { + return mSuggestClient.query(mSearchTerm); + } + } + + private static class SearchHistorySuggestionAsyncLoader extends SuggestionAsyncLoader { + public SearchHistorySuggestionAsyncLoader(Context context, String searchTerm) { + super(context, searchTerm); + } + + @Override + public ArrayList<String> loadInBackground() { + final ContentResolver cr = getContext().getContentResolver(); + + String[] columns = new String[] { BrowserContract.SearchHistory.QUERY }; + String actualQuery = BrowserContract.SearchHistory.QUERY + " LIKE ?"; + String[] queryArgs = new String[] { '%' + mSearchTerm + '%' }; + + // For deduplication, the worst case is that all the first NETWORK_SUGGESTION_MAX history suggestions are duplicates + // of search engine suggestions, and the there is a duplicate for the search term itself. A duplicate of the + // search term can occur if the user has previously searched for the same thing. + final int maxSavedSuggestions = NETWORK_SUGGESTION_MAX + 1 + getContext().getResources().getInteger(R.integer.max_saved_suggestions); + + final String sortOrderAndLimit = BrowserContract.SearchHistory.DATE + " DESC LIMIT " + maxSavedSuggestions; + final Cursor result = cr.query(BrowserContract.SearchHistory.CONTENT_URI, columns, actualQuery, queryArgs, sortOrderAndLimit); + + if (result == null) { + return new ArrayList<>(); + } + + final ArrayList<String> savedSuggestions = new ArrayList<>(); + try { + if (result.moveToFirst()) { + final int searchColumn = result.getColumnIndexOrThrow(BrowserContract.SearchHistory.QUERY); + do { + final String savedSearch = result.getString(searchColumn); + savedSuggestions.add(savedSearch); + } while (result.moveToNext()); + } + } finally { + result.close(); + } + + return savedSuggestions; + } + } + + private class SearchAdapter extends MultiTypeCursorAdapter { + private static final int ROW_SEARCH = 0; + private static final int ROW_STANDARD = 1; + private static final int ROW_SUGGEST = 2; + + public SearchAdapter(Context context) { + super(context, null, new int[] { ROW_STANDARD, + ROW_SEARCH, + ROW_SUGGEST }, + new int[] { R.layout.home_item_row, + R.layout.home_search_item_row, + R.layout.home_search_item_row }); + } + + @Override + public int getItemViewType(int position) { + if (position < getPrimaryEngineCount()) { + if (mSuggestionsEnabled && mSearchEngines.get(position).hasSuggestions()) { + // Give suggestion views their own type to prevent them from + // sharing other recycled search result views. Using other + // recycled views for the suggestion row can break animations + // (bug 815937). + + return ROW_SUGGEST; + } else { + return ROW_SEARCH; + } + } + + return ROW_STANDARD; + } + + @Override + public boolean isEnabled(int position) { + // If we're using a gamepad or keyboard, allow the row to be + // focused so it can pass the focus to its child suggestion views. + if (!mList.isInTouchMode()) { + return true; + } + + // If the suggestion row only contains one item (the user-entered + // query), allow the entire row to be clickable; clicking the row + // has the same effect as clicking the single suggestion. If the + // row contains multiple items, clicking the row will do nothing. + + if (position < getPrimaryEngineCount()) { + return !mSearchEngines.get(position).hasSuggestions(); + } + + return true; + } + + // Add the search engines to the number of reported results. + @Override + public int getCount() { + final int resultCount = super.getCount(); + + // Don't show search engines or suggestions if search field is empty + if (TextUtils.isEmpty(mSearchTerm)) { + return resultCount; + } + + return resultCount + getPrimaryEngineCount(); + } + + @Override + public void bindView(View view, Context context, int position) { + final int type = getItemViewType(position); + + if (type == ROW_SEARCH || type == ROW_SUGGEST) { + final SearchEngineRow row = (SearchEngineRow) view; + row.setOnUrlOpenListener(mUrlOpenListener); + row.setOnSearchListener(mSearchListener); + row.setOnEditSuggestionListener(mEditSuggestionListener); + row.setSearchTerm(mSearchTerm); + + final SearchEngine engine = mSearchEngines.get(position); + final boolean haveSuggestions = (engine.hasSuggestions() || !mSearchHistorySuggestions.isEmpty()); + final boolean animate = (mAnimateSuggestions && haveSuggestions); + row.updateSuggestions(mSuggestionsEnabled, engine, mSearchHistorySuggestions, animate); + if (animate) { + // Only animate suggestions the first time they are shown + mAnimateSuggestions = false; + } + } else { + // Account for the search engines + position -= getPrimaryEngineCount(); + + final Cursor c = getCursor(position); + final TwoLinePageRow row = (TwoLinePageRow) view; + row.updateFromCursor(c); + } + } + } + + private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + return SearchLoader.createInstance(getActivity(), args); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { + if (mAdapter != null) { + mAdapter.swapCursor(c); + + // We should handle autocompletion based on the search term + // associated with the loader that has just provided + // the results. + SearchCursorLoader searchLoader = (SearchCursorLoader) loader; + handleAutocomplete(searchLoader.getSearchTerm(), c); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (mAdapter != null) { + mAdapter.swapCursor(null); + } + } + } + + private class SearchEngineSuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> { + @Override + public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) { + // mSuggestClient is set to null in onDestroyView(), so using it + // safely here relies on the fact that onCreateLoader() is called + // synchronously in restartLoader(). + return new SearchEngineSuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm); + } + + @Override + public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) { + setSuggestions(suggestions); + } + + @Override + public void onLoaderReset(Loader<ArrayList<String>> loader) { + setSuggestions(new ArrayList<String>()); + } + } + + private class SearchHistorySuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> { + @Override + public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) { + // mSuggestClient is set to null in onDestroyView(), so using it + // safely here relies on the fact that onCreateLoader() is called + // synchronously in restartLoader(). + return new SearchHistorySuggestionAsyncLoader(getActivity(), mSearchTerm); + } + + @Override + public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) { + setSavedSuggestions(suggestions); + } + + @Override + public void onLoaderReset(Loader<ArrayList<String>> loader) { + setSavedSuggestions(new ArrayList<String>()); + } + } + + private static class ListSelectionListener implements View.OnFocusChangeListener, + AdapterView.OnItemSelectedListener { + private SearchEngineRow mSelectedEngineRow; + + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + View selectedRow = ((ListView) v).getSelectedView(); + if (selectedRow != null) { + selectRow(selectedRow); + } + } else { + deselectRow(); + } + } + + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + deselectRow(); + selectRow(view); + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + deselectRow(); + } + + private void selectRow(View row) { + if (row instanceof SearchEngineRow) { + mSelectedEngineRow = (SearchEngineRow) row; + mSelectedEngineRow.onSelected(); + } + } + + private void deselectRow() { + if (mSelectedEngineRow != null) { + mSelectedEngineRow.onDeselected(); + mSelectedEngineRow = null; + } + } + } + + /** + * HomeSearchListView is a list view for displaying search engine results on the awesome screen. + */ + public static class HomeSearchListView extends HomeListView { + public HomeSearchListView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.homeListViewStyle); + } + + public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + // Dismiss the soft keyboard. + requestFocus(); + } + + return super.onTouchEvent(event); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java new file mode 100644 index 000000000..f288a2745 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java @@ -0,0 +1,373 @@ +/* -*- 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.home; + +import android.content.Context; +import android.support.annotation.UiThread; +import android.support.v4.util.Pair; +import android.support.v7.widget.RecyclerView; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.db.RemoteTab; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType.*; + +public class ClientsAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder { + public static final String LOGTAG = "GeckoClientsAdapter"; + + /** + * If a device claims to have synced before this date, we will assume it has never synced. + */ + public static final Date EARLIEST_VALID_SYNCED_DATE; + static { + final Calendar c = GregorianCalendar.getInstance(); + c.set(2000, Calendar.JANUARY, 1, 0, 0, 0); + EARLIEST_VALID_SYNCED_DATE = c.getTime(); + } + + List<Pair<String, Integer>> adapterList = new LinkedList<>(); + + // List of hidden remote clients. + // Only accessed from the UI thread. + protected final List<RemoteClient> hiddenClients = new ArrayList<>(); + private Map<String, RemoteClient> visibleClients = new HashMap<>(); + + // Maintain group collapsed and hidden state. Only accessed from the UI thread. + protected static RemoteTabsExpandableListState sState; + + private final Context context; + + public ClientsAdapter(Context context) { + this.context = context; + + // This races when multiple Fragments are created. That's okay: one + // will win, and thereafter, all will be okay. If we create and then + // drop an instance the shared SharedPreferences backing all the + // instances will maintain the state for us. Since everything happens on + // the UI thread, this doesn't even need to be volatile. + if (sState == null) { + sState = new RemoteTabsExpandableListState(GeckoSharedPrefs.forProfile(context)); + } + + this.setHasStableIds(true); + } + + @Override + public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final View view; + + final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType); + + switch (itemType) { + case NAVIGATION_BACK: + view = inflater.inflate(R.layout.home_combined_back_item, parent, false); + return new CombinedHistoryItem.HistoryItem(view); + + case CLIENT: + view = inflater.inflate(R.layout.home_remote_tabs_group, parent, false); + return new CombinedHistoryItem.ClientItem(view); + + case CHILD: + view = inflater.inflate(R.layout.home_item_row, parent, false); + return new CombinedHistoryItem.HistoryItem(view); + + case HIDDEN_DEVICES: + view = inflater.inflate(R.layout.home_remote_tabs_hidden_devices, parent, false); + return new CombinedHistoryItem.BasicItem(view); + } + return null; + } + + @Override + public void onBindViewHolder(CombinedHistoryItem holder, final int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + + switch (itemType) { + case CLIENT: + final CombinedHistoryItem.ClientItem clientItem = (CombinedHistoryItem.ClientItem) holder; + final String clientGuid = adapterList.get(position).first; + final RemoteClient client = visibleClients.get(clientGuid); + clientItem.bind(context, client, sState.isClientCollapsed(clientGuid)); + break; + + case CHILD: + final Pair<String, Integer> pair = adapterList.get(position); + RemoteTab remoteTab = visibleClients.get(pair.first).tabs.get(pair.second); + ((CombinedHistoryItem.HistoryItem) holder).bind(remoteTab); + break; + + case HIDDEN_DEVICES: + final String hiddenDevicesLabel = context.getResources().getString(R.string.home_remote_tabs_many_hidden_devices, hiddenClients.size()); + ((TextView) holder.itemView).setText(hiddenDevicesLabel); + break; + } + } + + @Override + public int getItemCount () { + return adapterList.size(); + } + + private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) { + if (position == 0) { + return NAVIGATION_BACK; + } + + final Pair<String, Integer> pair = adapterList.get(position); + if (pair == null) { + return HIDDEN_DEVICES; + } else if (pair.second == -1) { + return CLIENT; + } else { + return CHILD; + } + } + + @Override + public int getItemViewType(int position) { + return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position)); + } + + @Override + public long getItemId(int position) { + // RecyclerView.NO_ID is -1, so start our hard-coded IDs at -2. + final int NAVIGATION_BACK_ID = -2; + final int HIDDEN_DEVICES_ID = -3; + + final String clientGuid; + // adapterList is a list of tuples (clientGuid, tabId). + final Pair<String, Integer> pair = adapterList.get(position); + + switch (getItemTypeForPosition(position)) { + case NAVIGATION_BACK: + return NAVIGATION_BACK_ID; + + case HIDDEN_DEVICES: + return HIDDEN_DEVICES_ID; + + // For Clients, return hashCode of their GUIDs. + case CLIENT: + clientGuid = pair.first; + return clientGuid.hashCode(); + + // For Tabs, return hashCode of their URLs. + case CHILD: + clientGuid = pair.first; + final Integer tabId = pair.second; + + final RemoteClient remoteClient = visibleClients.get(clientGuid); + if (remoteClient == null) { + return RecyclerView.NO_ID; + } + + final RemoteTab remoteTab = remoteClient.tabs.get(tabId); + if (remoteTab == null) { + return RecyclerView.NO_ID; + } + + return remoteTab.url.hashCode(); + + default: + throw new IllegalStateException("Unexpected Home Panel item type"); + } + } + + public int getClientsCount() { + return hiddenClients.size() + visibleClients.size(); + } + + @UiThread + public void setClients(List<RemoteClient> clients) { + adapterList.clear(); + adapterList.add(null); + + hiddenClients.clear(); + visibleClients.clear(); + + for (RemoteClient client : clients) { + final String guid = client.guid; + if (sState.isClientHidden(guid)) { + hiddenClients.add(client); + } else { + visibleClients.put(guid, client); + adapterList.addAll(getVisibleItems(client)); + } + } + + // Add item for unhiding clients. + if (!hiddenClients.isEmpty()) { + adapterList.add(null); + } + + notifyDataSetChanged(); + } + + private static List<Pair<String, Integer>> getVisibleItems(RemoteClient client) { + List<Pair<String, Integer>> list = new LinkedList<>(); + final String guid = client.guid; + list.add(new Pair<>(guid, -1)); + if (!sState.isClientCollapsed(client.guid)) { + for (int i = 0; i < client.tabs.size(); i++) { + list.add(new Pair<>(guid, i)); + } + } + return list; + } + + public List<RemoteClient> getHiddenClients() { + return hiddenClients; + } + + public void toggleClient(int position) { + final Pair<String, Integer> pair = adapterList.get(position); + if (pair.second != -1) { + return; + } + + final String clientGuid = pair.first; + final RemoteClient client = visibleClients.get(clientGuid); + + final boolean isCollapsed = sState.isClientCollapsed(clientGuid); + + sState.setClientCollapsed(clientGuid, !isCollapsed); + notifyItemChanged(position); + + if (isCollapsed) { + for (int i = client.tabs.size() - 1; i > -1; i--) { + // Insert child tabs at the index right after the client item that was clicked. + adapterList.add(position + 1, new Pair<>(clientGuid, i)); + } + notifyItemRangeInserted(position + 1, client.tabs.size()); + } else { + int i = client.tabs.size(); + while (i > 0) { + adapterList.remove(position + 1); + i--; + } + notifyItemRangeRemoved(position + 1, client.tabs.size()); + } + } + + public void unhideClients(List<RemoteClient> selectedClients) { + final int numClients = selectedClients.size(); + if (numClients == 0) { + return; + } + + final int insertionIndex = adapterList.size() - 1; + int itemCount = numClients; + + for (RemoteClient client : selectedClients) { + final String clientGuid = client.guid; + + sState.setClientHidden(clientGuid, false); + hiddenClients.remove(client); + + visibleClients.put(clientGuid, client); + sState.setClientCollapsed(clientGuid, false); + adapterList.addAll(adapterList.size() - 1, getVisibleItems(client)); + + itemCount += client.tabs.size(); + } + + notifyItemRangeInserted(insertionIndex, itemCount); + + final int hiddenDevicesIndex = adapterList.size() - 1; + if (hiddenClients.isEmpty()) { + // No more hidden clients, remove "unhide" item. + adapterList.remove(hiddenDevicesIndex); + notifyItemRemoved(hiddenDevicesIndex); + } else { + // Update "hidden clients" item because number of hidden clients changed. + notifyItemChanged(hiddenDevicesIndex); + } + } + + public void removeItem(int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + switch (itemType) { + case CLIENT: + final String clientGuid = adapterList.get(position).first; + final RemoteClient client = visibleClients.remove(clientGuid); + final boolean hadHiddenClients = !hiddenClients.isEmpty(); + + int removeCount = sState.isClientCollapsed(clientGuid) ? 1 : client.tabs.size() + 1; + int c = removeCount; + while (c > 0) { + adapterList.remove(position); + c--; + } + notifyItemRangeRemoved(position, removeCount); + + sState.setClientHidden(clientGuid, true); + hiddenClients.add(client); + + if (!hadHiddenClients) { + // Add item for unhiding clients; + adapterList.add(null); + notifyItemInserted(adapterList.size() - 1); + } else { + // Update "hidden clients" item because number of hidden clients changed. + notifyItemChanged(adapterList.size() - 1); + } + break; + } + } + + @Override + public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + HomeContextMenuInfo info; + final Pair<String, Integer> pair = adapterList.get(position); + switch (itemType) { + case CHILD: + info = new HomeContextMenuInfo(view, position, -1); + return populateChildInfoFromTab(info, visibleClients.get(pair.first).tabs.get(pair.second)); + + case CLIENT: + info = new CombinedHistoryPanel.RemoteTabsClientContextMenuInfo(view, position, -1, visibleClients.get(pair.first)); + return info; + } + return null; + } + + protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, RemoteTab tab) { + info.url = tab.url; + info.title = tab.title; + return info; + } + + /** + * Return a relative "Last synced" time span for the given tab record. + * + * @param now local time. + * @param time to format string for. + * @return string describing time span + */ + public static String getLastSyncedString(Context context, long now, long time) { + if (new Date(time).before(EARLIEST_VALID_SYNCED_DATE)) { + return context.getString(R.string.remote_tabs_never_synced); + } + final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS); + return context.getResources().getString(R.string.remote_tabs_last_synced, relativeTimeSpanString); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java new file mode 100644 index 000000000..402ed26e7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java @@ -0,0 +1,433 @@ +/* -*- 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.home; + +import android.content.res.Resources; +import android.support.annotation.UiThread; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; + +import android.database.Cursor; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.util.ThreadUtils; + +public class CombinedHistoryAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder { + private static final int RECENT_TABS_SMARTFOLDER_INDEX = 0; + + // Array for the time ranges in milliseconds covered by each section. + static final HistorySectionsHelper.SectionDateRange[] sectionDateRangeArray = new HistorySectionsHelper.SectionDateRange[SectionHeader.values().length]; + + // Semantic names for the time covered by each section + public enum SectionHeader { + TODAY, + YESTERDAY, + WEEK, + THIS_MONTH, + MONTH_AGO, + TWO_MONTHS_AGO, + THREE_MONTHS_AGO, + FOUR_MONTHS_AGO, + FIVE_MONTHS_AGO, + OLDER_THAN_SIX_MONTHS + } + + private HomeFragment.PanelStateChangeListener panelStateChangeListener; + + private Cursor historyCursor; + private DevicesUpdateHandler devicesUpdateHandler; + private int deviceCount = 0; + private RecentTabsUpdateHandler recentTabsUpdateHandler; + private int recentTabsCount = 0; + + private LinearLayoutManager linearLayoutManager; // Only used on the UI thread, so no need to be volatile. + + // We use a sparse array to store each section header's position in the panel [more cheaply than a HashMap]. + private final SparseArray<SectionHeader> sectionHeaders; + + public CombinedHistoryAdapter(Resources resources, int cachedRecentTabsCount) { + super(); + recentTabsCount = cachedRecentTabsCount; + sectionHeaders = new SparseArray<>(); + HistorySectionsHelper.updateRecentSectionOffset(resources, sectionDateRangeArray); + this.setHasStableIds(true); + } + + public void setPanelStateChangeListener( + HomeFragment.PanelStateChangeListener panelStateChangeListener) { + this.panelStateChangeListener = panelStateChangeListener; + } + + @UiThread + public void setLinearLayoutManager(LinearLayoutManager linearLayoutManager) { + this.linearLayoutManager = linearLayoutManager; + } + + public void setHistory(Cursor history) { + historyCursor = history; + populateSectionHeaders(historyCursor, sectionHeaders); + notifyDataSetChanged(); + } + + public interface DevicesUpdateHandler { + void onDeviceCountUpdated(int count); + } + + public DevicesUpdateHandler getDeviceUpdateHandler() { + if (devicesUpdateHandler == null) { + devicesUpdateHandler = new DevicesUpdateHandler() { + @Override + public void onDeviceCountUpdated(int count) { + deviceCount = count; + notifyItemChanged(getSyncedDevicesSmartFolderIndex()); + } + }; + } + return devicesUpdateHandler; + } + + public interface RecentTabsUpdateHandler { + void onRecentTabsCountUpdated(int count, boolean countReliable); + } + + public RecentTabsUpdateHandler getRecentTabsUpdateHandler() { + if (recentTabsUpdateHandler != null) { + return recentTabsUpdateHandler; + } + + recentTabsUpdateHandler = new RecentTabsUpdateHandler() { + @Override + public void onRecentTabsCountUpdated(final int count, final boolean countReliable) { + // Now that other items can move around depending on the visibility of the + // Recent Tabs folder, only update the recentTabsCount on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @UiThread + @Override + public void run() { + if (!countReliable && count <= recentTabsCount) { + // The final tab count (where countReliable = true) is normally >= than + // previous values with countReliable = false. Hence we only want to + // update the displayed tab count with a preliminary value if it's larger + // than the previous count, so as to avoid the displayed count jumping + // downwards and then back up, as well as unnecessary folder animations. + return; + } + + final boolean prevFolderVisibility = isRecentTabsFolderVisible(); + recentTabsCount = count; + final boolean folderVisible = isRecentTabsFolderVisible(); + + if (prevFolderVisibility == folderVisible) { + if (prevFolderVisibility) { + notifyItemChanged(RECENT_TABS_SMARTFOLDER_INDEX); + } + return; + } + + // If the Recent Tabs smart folder has become hidden/unhidden, + // we need to recalculate the history section header positions. + populateSectionHeaders(historyCursor, sectionHeaders); + + if (folderVisible) { + int scrollPos = -1; + if (linearLayoutManager != null) { + scrollPos = linearLayoutManager.findFirstCompletelyVisibleItemPosition(); + } + + notifyItemInserted(RECENT_TABS_SMARTFOLDER_INDEX); + // If the list exceeds the display height and we want to show the new + // item inserted at position 0, we need to scroll up manually + // (see https://code.google.com/p/android/issues/detail?id=174227#c2). + // However we only do this if our current scroll position is at the + // top of the list. + if (linearLayoutManager != null && scrollPos == 0) { + linearLayoutManager.scrollToPosition(0); + } + } else { + notifyItemRemoved(RECENT_TABS_SMARTFOLDER_INDEX); + } + + if (countReliable && panelStateChangeListener != null) { + panelStateChangeListener.setCachedRecentTabsCount(recentTabsCount); + } + } + }); + } + }; + return recentTabsUpdateHandler; + } + + @UiThread + private boolean isRecentTabsFolderVisible() { + return recentTabsCount > 0; + } + + @UiThread + // Number of smart folders for determining practical empty state. + public int getNumVisibleSmartFolders() { + int visibleFolders = 1; // Synced devices folder is always visible. + + if (isRecentTabsFolderVisible()) { + visibleFolders += 1; + } + + return visibleFolders; + } + + @UiThread + private int getSyncedDevicesSmartFolderIndex() { + return isRecentTabsFolderVisible() ? + RECENT_TABS_SMARTFOLDER_INDEX + 1 : + RECENT_TABS_SMARTFOLDER_INDEX; + } + + @Override + public CombinedHistoryItem onCreateViewHolder(ViewGroup viewGroup, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); + final View view; + + final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType); + + switch (itemType) { + case RECENT_TABS: + case SYNCED_DEVICES: + view = inflater.inflate(R.layout.home_smartfolder, viewGroup, false); + return new CombinedHistoryItem.SmartFolder(view); + + case SECTION_HEADER: + view = inflater.inflate(R.layout.home_header_row, viewGroup, false); + return new CombinedHistoryItem.BasicItem(view); + + case HISTORY: + view = inflater.inflate(R.layout.home_item_row, viewGroup, false); + return new CombinedHistoryItem.HistoryItem(view); + default: + throw new IllegalArgumentException("Unexpected Home Panel item type"); + } + } + + @Override + public void onBindViewHolder(CombinedHistoryItem viewHolder, int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + final int localPosition = transformAdapterPositionForDataStructure(itemType, position); + + switch (itemType) { + case RECENT_TABS: + ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.icon_recent, R.string.home_closed_tabs_title2, R.string.home_closed_tabs_one, R.string.home_closed_tabs_number, recentTabsCount); + break; + + case SYNCED_DEVICES: + ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.cloud, R.string.home_synced_devices_smartfolder, R.string.home_synced_devices_one, R.string.home_synced_devices_number, deviceCount); + break; + + case SECTION_HEADER: + ((TextView) viewHolder.itemView).setText(getSectionHeaderTitle(sectionHeaders.get(localPosition))); + break; + + case HISTORY: + if (historyCursor == null || !historyCursor.moveToPosition(localPosition)) { + throw new IllegalStateException("Couldn't move cursor to position " + localPosition); + } + ((CombinedHistoryItem.HistoryItem) viewHolder).bind(historyCursor); + break; + } + } + + /** + * Transform an adapter position to the position for the data structure backing the item type. + * + * The type is not strictly necessary and could be fetched from <code>getItemTypeForPosition</code>, + * but is used for explicitness. + * + * @param type ItemType of the item + * @param position position in the adapter + * @return position of the item in the data structure + */ + @UiThread + private int transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType type, int position) { + if (type == CombinedHistoryItem.ItemType.SECTION_HEADER) { + return position; + } else if (type == CombinedHistoryItem.ItemType.HISTORY) { + return position - getHeadersBefore(position) - getNumVisibleSmartFolders(); + } else { + return position; + } + } + + @UiThread + private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) { + if (position == RECENT_TABS_SMARTFOLDER_INDEX && isRecentTabsFolderVisible()) { + return CombinedHistoryItem.ItemType.RECENT_TABS; + } + if (position == getSyncedDevicesSmartFolderIndex()) { + return CombinedHistoryItem.ItemType.SYNCED_DEVICES; + } + final int sectionPosition = transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.SECTION_HEADER, position); + if (sectionHeaders.get(sectionPosition) != null) { + return CombinedHistoryItem.ItemType.SECTION_HEADER; + } + return CombinedHistoryItem.ItemType.HISTORY; + } + + @UiThread + @Override + public int getItemViewType(int position) { + return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position)); + } + + @UiThread + @Override + public int getItemCount() { + final int historySize = historyCursor == null ? 0 : historyCursor.getCount(); + return historySize + sectionHeaders.size() + getNumVisibleSmartFolders(); + } + + /** + * Returns stable ID for each position. Data behind historyCursor is a sorted Combined view. + * + * @param position view item position for which to generate a stable ID + * @return stable ID for given position + */ + @UiThread + @Override + public long getItemId(int position) { + // Two randomly selected large primes used to generate non-clashing IDs. + final long PRIME_BOOKMARKS = 32416189867L; + final long PRIME_SECTION_HEADERS = 32416187737L; + + // RecyclerView.NO_ID is -1, so let's start from -2 for our hard-coded IDs. + final int RECENT_TABS_ID = -2; + final int SYNCED_DEVICES_ID = -3; + + switch (getItemTypeForPosition(position)) { + case RECENT_TABS: + return RECENT_TABS_ID; + case SYNCED_DEVICES: + return SYNCED_DEVICES_ID; + case SECTION_HEADER: + // We might have multiple section headers, so we try get unique IDs for them. + return position * PRIME_SECTION_HEADERS; + case HISTORY: + final int historyPosition = transformAdapterPositionForDataStructure( + CombinedHistoryItem.ItemType.HISTORY, position); + if (!historyCursor.moveToPosition(historyPosition)) { + return RecyclerView.NO_ID; + } + + final int historyIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID); + final long historyId = historyCursor.getLong(historyIdCol); + + if (historyId != -1) { + return historyId; + } + + final int bookmarkIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID); + final long bookmarkId = historyCursor.getLong(bookmarkIdCol); + + // Avoid clashing with historyId. + return bookmarkId * PRIME_BOOKMARKS; + default: + throw new IllegalStateException("Unexpected Home Panel item type"); + } + } + + /** + * Add only the SectionHeaders that have history items within their range to a SparseArray, where the + * array index is the position of the header in the history-only (no clients) ordering. + * @param c data Cursor + * @param sparseArray SparseArray to populate + */ + @UiThread + private void populateSectionHeaders(Cursor c, SparseArray<SectionHeader> sparseArray) { + ThreadUtils.assertOnUiThread(); + + sparseArray.clear(); + + if (c == null || !c.moveToFirst()) { + return; + } + + SectionHeader section = null; + + do { + final int historyPosition = c.getPosition(); + final long visitTime = c.getLong(c.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED)); + final SectionHeader itemSection = getSectionFromTime(visitTime); + + if (section != itemSection) { + section = itemSection; + sparseArray.append(historyPosition + sparseArray.size() + getNumVisibleSmartFolders(), section); + } + + if (section == SectionHeader.OLDER_THAN_SIX_MONTHS) { + break; + } + } while (c.moveToNext()); + } + + private static String getSectionHeaderTitle(SectionHeader section) { + return sectionDateRangeArray[section.ordinal()].displayName; + } + + private static SectionHeader getSectionFromTime(long time) { + for (int i = 0; i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) { + if (time > sectionDateRangeArray[i].start) { + return SectionHeader.values()[i]; + } + } + + return SectionHeader.OLDER_THAN_SIX_MONTHS; + } + + /** + * Returns the number of section headers before the given history item at the adapter position. + * @param position position in the adapter + */ + private int getHeadersBefore(int position) { + // Skip the first header case because there will always be a header. + for (int i = 1; i < sectionHeaders.size(); i++) { + // If the position of the header is greater than the history position, + // return the number of headers tested. + if (sectionHeaders.keyAt(i) > position) { + return i; + } + } + return sectionHeaders.size(); + } + + @Override + public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + if (itemType == CombinedHistoryItem.ItemType.HISTORY) { + final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, -1); + + historyCursor.moveToPosition(transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.HISTORY, position)); + return populateHistoryInfoFromCursor(info, historyCursor); + } + return null; + } + + protected static HomeContextMenuInfo populateHistoryInfoFromCursor(HomeContextMenuInfo info, Cursor cursor) { + info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE)); + info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID)); + info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY; + final int bookmarkIdCol = cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID); + if (cursor.isNull(bookmarkIdCol)) { + // If this is a combined cursor, we may get a history item without a + // bookmark, in which case the bookmarks ID column value will be null. + info.bookmarkId = -1; + } else { + info.bookmarkId = cursor.getInt(bookmarkIdCol); + } + return info; + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java new file mode 100644 index 000000000..a2c1b72c2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java @@ -0,0 +1,127 @@ +/* -*- 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.home; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.db.RemoteTab; +import org.mozilla.gecko.home.RecentTabsAdapter.ClosedTab; + +public abstract class CombinedHistoryItem extends RecyclerView.ViewHolder { + private static final String LOGTAG = "CombinedHistoryItem"; + + public CombinedHistoryItem(View view) { + super(view); + } + + public enum ItemType { + CLIENT, HIDDEN_DEVICES, SECTION_HEADER, HISTORY, NAVIGATION_BACK, CHILD, SYNCED_DEVICES, + RECENT_TABS, CLOSED_TAB; + + public static ItemType viewTypeToItemType(int viewType) { + if (viewType >= ItemType.values().length) { + Log.e(LOGTAG, "No corresponding ItemType!"); + } + return ItemType.values()[viewType]; + } + + public static int itemTypeToViewType(ItemType itemType) { + return itemType.ordinal(); + } + } + + public static class BasicItem extends CombinedHistoryItem { + public BasicItem(View view) { + super(view); + } + } + + public static class SmartFolder extends CombinedHistoryItem { + final Context context; + final ImageView icon; + final TextView title; + final TextView subtext; + + public SmartFolder(View view) { + super(view); + context = view.getContext(); + + icon = (ImageView) view.findViewById(R.id.device_type); + title = (TextView) view.findViewById(R.id.title); + subtext = (TextView) view.findViewById(R.id.subtext); + } + + public void bind(int drawableRes, int titleRes, int singleDeviceRes, int multiDeviceRes, int numDevices) { + icon.setImageResource(drawableRes); + title.setText(titleRes); + final String subtitle = numDevices == 1 ? context.getString(singleDeviceRes) : context.getString(multiDeviceRes, numDevices); + subtext.setText(subtitle); + } + } + + public static class HistoryItem extends CombinedHistoryItem { + public HistoryItem(View view) { + super(view); + } + + public void bind(Cursor historyCursor) { + final TwoLinePageRow pageRow = (TwoLinePageRow) this.itemView; + pageRow.setShowIcons(true); + pageRow.updateFromCursor(historyCursor); + } + + public void bind(RemoteTab remoteTab) { + final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView; + childPageRow.setShowIcons(true); + childPageRow.update(remoteTab.title, remoteTab.url); + } + + public void bind(ClosedTab closedTab) { + final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView; + childPageRow.setShowIcons(false); + childPageRow.update(closedTab.title, closedTab.url); + } + } + + public static class ClientItem extends CombinedHistoryItem { + final TextView nameView; + final ImageView deviceTypeView; + final TextView lastModifiedView; + final ImageView deviceExpanded; + + public ClientItem(View view) { + super(view); + nameView = (TextView) view.findViewById(R.id.client); + deviceTypeView = (ImageView) view.findViewById(R.id.device_type); + lastModifiedView = (TextView) view.findViewById(R.id.last_synced); + deviceExpanded = (ImageView) view.findViewById(R.id.device_expanded); + } + + public void bind(Context context, RemoteClient client, boolean isCollapsed) { + this.nameView.setText(client.name); + final long now = System.currentTimeMillis(); + this.lastModifiedView.setText(ClientsAdapter.getLastSyncedString(context, now, client.lastModified)); + + if (client.isDesktop()) { + deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_desktop_inactive : R.drawable.sync_desktop); + } else { + deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_mobile_inactive : R.drawable.sync_mobile); + } + + nameView.setTextColor(ContextCompat.getColor(context, isCollapsed ? R.color.tabs_tray_icon_grey : R.color.placeholder_active_grey)); + if (client.tabs.size() > 0) { + deviceExpanded.setImageResource(isCollapsed ? R.drawable.home_group_collapsed : R.drawable.arrow_down); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java new file mode 100644 index 000000000..c9afecd63 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java @@ -0,0 +1,697 @@ +/* -*- 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.home; + +import android.accounts.Account; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.UiThread; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.UnderlineSpan; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.RemoteClientsDialogFragment; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.SyncStatusListener; +import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.widget.HistoryDividerItemDecoration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT; +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC; +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS; + +public class CombinedHistoryPanel extends HomeFragment implements RemoteClientsDialogFragment.RemoteClientsListener { + private static final String LOGTAG = "GeckoCombinedHistoryPnl"; + + private static final String[] STAGES_TO_SYNC_ON_REFRESH = new String[] { "clients", "tabs" }; + private final int LOADER_ID_HISTORY = 0; + private final int LOADER_ID_REMOTE = 1; + + // String placeholders to mark formatting. + private final static String FORMAT_S1 = "%1$s"; + private final static String FORMAT_S2 = "%2$s"; + + private CombinedHistoryRecyclerView mRecyclerView; + private CombinedHistoryAdapter mHistoryAdapter; + private ClientsAdapter mClientsAdapter; + private RecentTabsAdapter mRecentTabsAdapter; + private CursorLoaderCallbacks mCursorLoaderCallbacks; + + private Bundle mSavedRestoreBundle; + + private PanelLevel mPanelLevel; + private Button mPanelFooterButton; + + private PanelStateUpdateHandler mPanelStateUpdateHandler; + + // Child refresh layout view. + protected SwipeRefreshLayout mRefreshLayout; + + // Sync listener that stops refreshing when a sync is completed. + protected RemoteTabsSyncListener mSyncStatusListener; + + // Reference to the View to display when there are no results. + private View mHistoryEmptyView; + private View mClientsEmptyView; + private View mRecentTabsEmptyView; + + public interface OnPanelLevelChangeListener { + enum PanelLevel { + PARENT, CHILD_SYNC, CHILD_RECENT_TABS + } + + /** + * Propagates level changes. + * @param level + * @return true if level changed, false otherwise. + */ + boolean changeLevel(PanelLevel level); + } + + @Override + public void onCreate(Bundle savedInstance) { + super.onCreate(savedInstance); + + int cachedRecentTabsCount = 0; + if (mPanelStateChangeListener != null ) { + cachedRecentTabsCount = mPanelStateChangeListener.getCachedRecentTabsCount(); + } + mHistoryAdapter = new CombinedHistoryAdapter(getResources(), cachedRecentTabsCount); + if (mPanelStateChangeListener != null) { + mHistoryAdapter.setPanelStateChangeListener(mPanelStateChangeListener); + } + + mClientsAdapter = new ClientsAdapter(getContext()); + // The RecentTabsAdapter doesn't use a cursor and therefore can't use the CursorLoader's + // onLoadFinished() callback for updating the panel state when the closed tab count changes. + // Instead, we provide it with independent callbacks as necessary. + mRecentTabsAdapter = new RecentTabsAdapter(getContext(), + mHistoryAdapter.getRecentTabsUpdateHandler(), getPanelStateUpdateHandler()); + + mSyncStatusListener = new RemoteTabsSyncListener(); + FirefoxAccounts.addSyncStatusListener(mSyncStatusListener); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.home_combined_history_panel, container, false); + } + + @UiThread + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mRecyclerView = (CombinedHistoryRecyclerView) view.findViewById(R.id.combined_recycler_view); + setUpRecyclerView(); + + mRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refresh_layout); + setUpRefreshLayout(); + + mClientsEmptyView = view.findViewById(R.id.home_clients_empty_view); + mHistoryEmptyView = view.findViewById(R.id.home_history_empty_view); + mRecentTabsEmptyView = view.findViewById(R.id.home_recent_tabs_empty_view); + setUpEmptyViews(); + + mPanelFooterButton = (Button) view.findViewById(R.id.history_panel_footer_button); + mPanelFooterButton.setText(R.string.home_clear_history_button); + mPanelFooterButton.setOnClickListener(new OnFooterButtonClickListener()); + + mRecentTabsAdapter.startListeningForClosedTabs(); + mRecentTabsAdapter.startListeningForHistorySanitize(); + + if (mSavedRestoreBundle != null) { + setPanelStateFromBundle(mSavedRestoreBundle); + mSavedRestoreBundle = null; + } + } + + @UiThread + private void setUpRecyclerView() { + if (mPanelLevel == null) { + mPanelLevel = PARENT; + } + + mRecyclerView.setAdapter(mPanelLevel == PARENT ? mHistoryAdapter : + mPanelLevel == CHILD_SYNC ? mClientsAdapter : mRecentTabsAdapter); + + final RecyclerView.ItemAnimator animator = new DefaultItemAnimator(); + animator.setAddDuration(100); + animator.setChangeDuration(100); + animator.setMoveDuration(100); + animator.setRemoveDuration(100); + mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + mHistoryAdapter.setLinearLayoutManager((LinearLayoutManager) mRecyclerView.getLayoutManager()); + mRecyclerView.setItemAnimator(animator); + mRecyclerView.addItemDecoration(new HistoryDividerItemDecoration(getContext())); + mRecyclerView.setOnHistoryClickedListener(mUrlOpenListener); + mRecyclerView.setOnPanelLevelChangeListener(new OnLevelChangeListener()); + mRecyclerView.setHiddenClientsDialogBuilder(new HiddenClientsHelper()); + mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + final LinearLayoutManager llm = (LinearLayoutManager) recyclerView.getLayoutManager(); + if ((mPanelLevel == PARENT) && (llm.findLastCompletelyVisibleItemPosition() == HistoryCursorLoader.HISTORY_LIMIT)) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.LIST, "history_scroll_max"); + } + + } + }); + registerForContextMenu(mRecyclerView); + } + + private void setUpRefreshLayout() { + mRefreshLayout.setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange); + mRefreshLayout.setOnRefreshListener(new RemoteTabsRefreshListener()); + mRefreshLayout.setEnabled(false); + } + + private void setUpEmptyViews() { + // Set up history empty view. + final ImageView historyIcon = (ImageView) mHistoryEmptyView.findViewById(R.id.home_empty_image); + historyIcon.setVisibility(View.GONE); + + final TextView historyText = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_text); + historyText.setText(R.string.home_most_recent_empty); + + final TextView historyHint = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_hint); + + if (!Restrictions.isAllowed(getActivity(), Restrictable.PRIVATE_BROWSING)) { + historyHint.setVisibility(View.GONE); + } else { + final String hintText = getResources().getString(R.string.home_most_recent_emptyhint); + final SpannableStringBuilder hintBuilder = formatHintText(hintText); + if (hintBuilder != null) { + historyHint.setText(hintBuilder); + historyHint.setMovementMethod(LinkMovementMethod.getInstance()); + historyHint.setVisibility(View.VISIBLE); + } + } + + // Set up Clients empty view. + final Button syncSetupButton = (Button) mClientsEmptyView.findViewById(R.id.sync_setup_button); + syncSetupButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "history_syncsetup"); + // This Activity will redirect to the correct Activity as needed. + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + startActivity(intent); + } + }); + + // Set up Recent Tabs empty view. + final ImageView recentTabsIcon = (ImageView) mRecentTabsEmptyView.findViewById(R.id.home_empty_image); + recentTabsIcon.setImageResource(R.drawable.icon_remote_tabs_empty); + + final TextView recentTabsText = (TextView) mRecentTabsEmptyView.findViewById(R.id.home_empty_text); + recentTabsText.setText(R.string.home_last_tabs_empty); + } + + @Override + public void setPanelStateChangeListener( + PanelStateChangeListener panelStateChangeListener) { + super.setPanelStateChangeListener(panelStateChangeListener); + if (mHistoryAdapter != null) { + mHistoryAdapter.setPanelStateChangeListener(panelStateChangeListener); + } + } + + @Override + public void restoreData(Bundle data) { + if (mRecyclerView != null) { + setPanelStateFromBundle(data); + } else { + mSavedRestoreBundle = data; + } + } + + private void setPanelStateFromBundle(Bundle data) { + if (data != null && data.getBoolean("goToRecentTabs", false) && mPanelLevel != CHILD_RECENT_TABS) { + mPanelLevel = CHILD_RECENT_TABS; + mRecyclerView.swapAdapter(mRecentTabsAdapter, true); + updateEmptyView(CHILD_RECENT_TABS); + updateButtonFromLevel(); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mCursorLoaderCallbacks = new CursorLoaderCallbacks(); + } + + @Override + protected void load() { + getLoaderManager().initLoader(LOADER_ID_HISTORY, null, mCursorLoaderCallbacks); + getLoaderManager().initLoader(LOADER_ID_REMOTE, null, mCursorLoaderCallbacks); + } + + private static class RemoteTabsCursorLoader extends SimpleCursorLoader { + private final GeckoProfile mProfile; + + public RemoteTabsCursorLoader(Context context) { + super(context); + mProfile = GeckoProfile.get(context); + } + + @Override + public Cursor loadCursor() { + return BrowserDB.from(mProfile).getTabsAccessor().getRemoteTabsCursor(getContext()); + } + } + + private static class HistoryCursorLoader extends SimpleCursorLoader { + // Max number of history results + public static final int HISTORY_LIMIT = 100; + private final BrowserDB mDB; + + public HistoryCursorLoader(Context context) { + super(context); + mDB = BrowserDB.from(context); + } + + @Override + public Cursor loadCursor() { + final ContentResolver cr = getContext().getContentResolver(); + return mDB.getRecentHistory(cr, HISTORY_LIMIT); + } + } + + private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + private BrowserDB mDB; // Pseudo-final: set in onCreateLoader. + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (mDB == null) { + mDB = BrowserDB.from(getActivity()); + } + + switch (id) { + case LOADER_ID_HISTORY: + return new HistoryCursorLoader(getContext()); + case LOADER_ID_REMOTE: + return new RemoteTabsCursorLoader(getContext()); + default: + Log.e(LOGTAG, "Unknown loader id!"); + return null; + } + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { + final int loaderId = loader.getId(); + switch (loaderId) { + case LOADER_ID_HISTORY: + mHistoryAdapter.setHistory(c); + updateEmptyView(PARENT); + break; + + case LOADER_ID_REMOTE: + final List<RemoteClient> clients = mDB.getTabsAccessor().getClientsFromCursor(c); + mHistoryAdapter.getDeviceUpdateHandler().onDeviceCountUpdated(clients.size()); + mClientsAdapter.setClients(clients); + updateEmptyView(CHILD_SYNC); + break; + } + + updateButtonFromLevel(); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + mClientsAdapter.setClients(Collections.<RemoteClient>emptyList()); + mHistoryAdapter.setHistory(null); + } + } + + public interface PanelStateUpdateHandler { + void onPanelStateUpdated(PanelLevel level); + } + + public PanelStateUpdateHandler getPanelStateUpdateHandler() { + if (mPanelStateUpdateHandler == null) { + mPanelStateUpdateHandler = new PanelStateUpdateHandler() { + @Override + public void onPanelStateUpdated(PanelLevel level) { + updateEmptyView(level); + updateButtonFromLevel(); + } + }; + } + return mPanelStateUpdateHandler; + } + + protected class OnLevelChangeListener implements OnPanelLevelChangeListener { + @Override + public boolean changeLevel(PanelLevel level) { + if (level == mPanelLevel) { + return false; + } + + mPanelLevel = level; + switch (level) { + case PARENT: + mRecyclerView.swapAdapter(mHistoryAdapter, true); + mRefreshLayout.setEnabled(false); + break; + case CHILD_SYNC: + mRecyclerView.swapAdapter(mClientsAdapter, true); + mRefreshLayout.setEnabled(mClientsAdapter.getClientsCount() > 0); + break; + case CHILD_RECENT_TABS: + mRecyclerView.swapAdapter(mRecentTabsAdapter, true); + break; + } + + updateEmptyView(level); + updateButtonFromLevel(); + return true; + } + } + + private void updateButtonFromLevel() { + switch (mPanelLevel) { + case PARENT: + final boolean historyRestricted = !Restrictions.isAllowed(getActivity(), Restrictable.CLEAR_HISTORY); + if (historyRestricted || mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders()) { + mPanelFooterButton.setVisibility(View.GONE); + } else { + mPanelFooterButton.setText(R.string.home_clear_history_button); + mPanelFooterButton.setVisibility(View.VISIBLE); + } + break; + case CHILD_RECENT_TABS: + if (mRecentTabsAdapter.getClosedTabsCount() > 1) { + mPanelFooterButton.setText(R.string.home_restore_all); + mPanelFooterButton.setVisibility(View.VISIBLE); + } else { + mPanelFooterButton.setVisibility(View.GONE); + } + break; + case CHILD_SYNC: + mPanelFooterButton.setVisibility(View.GONE); + break; + } + } + + private class OnFooterButtonClickListener implements View.OnClickListener { + @Override + public void onClick(View view) { + switch (mPanelLevel) { + case PARENT: + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); + dialogBuilder.setMessage(R.string.home_clear_history_confirm); + dialogBuilder.setNegativeButton(R.string.button_cancel, new AlertDialog.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + dialog.dismiss(); + } + }); + + dialogBuilder.setPositiveButton(R.string.button_ok, new AlertDialog.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + dialog.dismiss(); + + // Send message to Java to clear history. + final JSONObject json = new JSONObject(); + try { + json.put("history", true); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + + GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString()); + Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.BUTTON, "history"); + } + }); + + dialogBuilder.show(); + break; + case CHILD_RECENT_TABS: + final String telemetryExtra = mRecentTabsAdapter.restoreAllTabs(); + if (telemetryExtra != null) { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.BUTTON, telemetryExtra); + } + break; + } + } + } + + private void updateEmptyView(PanelLevel level) { + boolean showEmptyHistoryView = (mPanelLevel == PARENT && mHistoryEmptyView.isShown()); + boolean showEmptyClientsView = (mPanelLevel == CHILD_SYNC && mClientsEmptyView.isShown()); + boolean showEmptyRecentTabsView = (mPanelLevel == CHILD_RECENT_TABS && mRecentTabsEmptyView.isShown()); + + if (mPanelLevel == level) { + switch (mPanelLevel) { + case PARENT: + showEmptyHistoryView = mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders(); + break; + + case CHILD_SYNC: + showEmptyClientsView = mClientsAdapter.getItemCount() == 1; + break; + + case CHILD_RECENT_TABS: + showEmptyRecentTabsView = mRecentTabsAdapter.getClosedTabsCount() == 0; + break; + } + } + + final boolean showEmptyView = showEmptyClientsView || showEmptyHistoryView || showEmptyRecentTabsView; + mRecyclerView.setOverScrollMode(showEmptyView ? View.OVER_SCROLL_NEVER : View.OVER_SCROLL_IF_CONTENT_SCROLLS); + + mHistoryEmptyView.setVisibility(showEmptyHistoryView ? View.VISIBLE : View.GONE); + mClientsEmptyView.setVisibility(showEmptyClientsView ? View.VISIBLE : View.GONE); + mRecentTabsEmptyView.setVisibility(showEmptyRecentTabsView ? View.VISIBLE : View.GONE); + } + + /** + * Make Span that is clickable, and underlined + * between the string markers <code>FORMAT_S1</code> and + * <code>FORMAT_S2</code>. + * + * @param text String to format + * @return formatted SpannableStringBuilder, or null if there + * is not any text to format. + */ + private SpannableStringBuilder formatHintText(String text) { + // Set formatting as marked by string placeholders. + final int underlineStart = text.indexOf(FORMAT_S1); + final int underlineEnd = text.indexOf(FORMAT_S2); + + // Check that there is text to be formatted. + if (underlineStart >= underlineEnd) { + return null; + } + + final SpannableStringBuilder ssb = new SpannableStringBuilder(text); + + // Set clickable text. + final ClickableSpan clickableSpan = new ClickableSpan() { + @Override + public void onClick(View widget) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "hint_private_browsing"); + try { + final JSONObject json = new JSONObject(); + json.put("type", "Menu:Open"); + GeckoApp.getEventDispatcher().dispatchEvent(json, null); + EventDispatcher.getInstance().dispatchEvent(json, null); + } catch (JSONException e) { + Log.e(LOGTAG, "Error forming JSON for Private Browsing contextual hint", e); + } + } + }; + + ssb.setSpan(clickableSpan, 0, text.length(), 0); + + // Remove underlining set by ClickableSpan. + final UnderlineSpan noUnderlineSpan = new UnderlineSpan() { + @Override + public void updateDrawState(TextPaint textPaint) { + textPaint.setUnderlineText(false); + } + }; + + ssb.setSpan(noUnderlineSpan, 0, text.length(), 0); + + // Add underlining for "Private Browsing". + ssb.setSpan(new UnderlineSpan(), underlineStart, underlineEnd, 0); + + ssb.delete(underlineEnd, underlineEnd + FORMAT_S2.length()); + ssb.delete(underlineStart, underlineStart + FORMAT_S1.length()); + + return ssb; + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) { + if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) { + // Long pressed item was not a RemoteTabsGroup item. Superclass + // can handle this. + super.onCreateContextMenu(menu, view, menuInfo); + return; + } + + // Long pressed item was a remote client; provide the appropriate menu. + final MenuInflater inflater = new MenuInflater(view.getContext()); + inflater.inflate(R.menu.home_remote_tabs_client_contextmenu, menu); + + final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo; + menu.setHeaderTitle(info.client.name); + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + if (super.onContextItemSelected(item)) { + // HomeFragment was able to handle to selected item. + return true; + } + + final ContextMenu.ContextMenuInfo menuInfo = item.getMenuInfo(); + if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) { + return false; + } + + final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo; + + final int itemId = item.getItemId(); + if (itemId == R.id.home_remote_tabs_hide_client) { + mClientsAdapter.removeItem(info.position); + return true; + } + + return false; + } + + interface DialogBuilder<E> { + void createAndShowDialog(List<E> items); + } + + protected class HiddenClientsHelper implements DialogBuilder<RemoteClient> { + @Override + public void createAndShowDialog(List<RemoteClient> clientsList) { + final RemoteClientsDialogFragment dialog = RemoteClientsDialogFragment.newInstance( + getResources().getString(R.string.home_remote_tabs_hidden_devices_title), + getResources().getString(R.string.home_remote_tabs_unhide_selected_devices), + RemoteClientsDialogFragment.ChoiceMode.MULTIPLE, new ArrayList<>(clientsList)); + dialog.setTargetFragment(CombinedHistoryPanel.this, 0); + dialog.show(getActivity().getSupportFragmentManager(), "show-clients"); + } + } + + @Override + public void onClients(List<RemoteClient> clients) { + mClientsAdapter.unhideClients(clients); + } + + /** + * Stores information regarding the creation of the context menu for a remote client. + */ + protected static class RemoteTabsClientContextMenuInfo extends HomeContextMenuInfo { + protected final RemoteClient client; + + public RemoteTabsClientContextMenuInfo(View targetView, int position, long id, RemoteClient client) { + super(targetView, position, id); + this.client = client; + } + } + + protected class RemoteTabsRefreshListener implements SwipeRefreshLayout.OnRefreshListener { + @Override + public void onRefresh() { + if (FirefoxAccounts.firefoxAccountsExist(getActivity())) { + final Account account = FirefoxAccounts.getFirefoxAccount(getActivity()); + FirefoxAccounts.requestImmediateSync(account, STAGES_TO_SYNC_ON_REFRESH, null); + } else { + Log.wtf(LOGTAG, "No Firefox Account found; this should never happen. Ignoring."); + mRefreshLayout.setRefreshing(false); + } + } + } + + protected class RemoteTabsSyncListener implements SyncStatusListener { + @Override + public Context getContext() { + return getActivity(); + } + + @Override + public Account getAccount() { + return FirefoxAccounts.getFirefoxAccount(getContext()); + } + + @Override + public void onSyncStarted() { + } + + @Override + public void onSyncFinished() { + mRefreshLayout.setRefreshing(false); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + mRecentTabsAdapter.stopListeningForClosedTabs(); + mRecentTabsAdapter.stopListeningForHistorySanitize(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (mSyncStatusListener != null) { + FirefoxAccounts.removeSyncStatusListener(mSyncStatusListener); + mSyncStatusListener = null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java new file mode 100644 index 000000000..e813e4c44 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java @@ -0,0 +1,145 @@ +/* -*- 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.home; + +import android.content.Context; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.View; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import java.util.EnumSet; + +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS; +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC; +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT; + +public class CombinedHistoryRecyclerView extends RecyclerView + implements RecyclerViewClickSupport.OnItemClickListener, RecyclerViewClickSupport.OnItemLongClickListener { + public static String LOGTAG = "CombinedHistoryRecycView"; + + protected interface AdapterContextMenuBuilder { + HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position); + } + + protected HomePager.OnUrlOpenListener mOnUrlOpenListener; + protected OnPanelLevelChangeListener mOnPanelLevelChangeListener; + protected CombinedHistoryPanel.DialogBuilder<RemoteClient> mDialogBuilder; + protected HomeContextMenuInfo mContextMenuInfo; + + public CombinedHistoryRecyclerView(Context context) { + super(context); + init(context); + } + + public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + init(context); + } + + public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet, int defStyle) { + super(context, attributeSet, defStyle); + init(context); + } + + private void init(Context context) { + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + layoutManager.setOrientation(LinearLayoutManager.VERTICAL); + setLayoutManager(layoutManager); + + RecyclerViewClickSupport.addTo(this) + .setOnItemClickListener(this) + .setOnItemLongClickListener(this); + + setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + final int action = event.getAction(); + + // If the user hit the BACK key, try to move to the parent folder. + if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + return mOnPanelLevelChangeListener.changeLevel(PARENT); + } + return false; + } + }); + } + + public void setOnHistoryClickedListener(HomePager.OnUrlOpenListener listener) { + this.mOnUrlOpenListener = listener; + } + + public void setOnPanelLevelChangeListener(OnPanelLevelChangeListener listener) { + this.mOnPanelLevelChangeListener = listener; + } + + public void setHiddenClientsDialogBuilder(CombinedHistoryPanel.DialogBuilder<RemoteClient> builder) { + mDialogBuilder = builder; + } + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + final int viewType = getAdapter().getItemViewType(position); + final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType); + final String telemetryExtra; + + switch (itemType) { + case RECENT_TABS: + mOnPanelLevelChangeListener.changeLevel(CHILD_RECENT_TABS); + break; + + case SYNCED_DEVICES: + mOnPanelLevelChangeListener.changeLevel(CHILD_SYNC); + break; + + case CLIENT: + ((ClientsAdapter) getAdapter()).toggleClient(position); + break; + + case HIDDEN_DEVICES: + if (mDialogBuilder != null) { + mDialogBuilder.createAndShowDialog(((ClientsAdapter) getAdapter()).getHiddenClients()); + } + break; + + case NAVIGATION_BACK: + mOnPanelLevelChangeListener.changeLevel(PARENT); + break; + + case CHILD: + case HISTORY: + if (mOnUrlOpenListener != null) { + final TwoLinePageRow historyItem = (TwoLinePageRow) v; + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "history"); + mOnUrlOpenListener.onUrlOpen(historyItem.getUrl(), EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + } + break; + + case CLOSED_TAB: + telemetryExtra = ((RecentTabsAdapter) getAdapter()).restoreTabFromPosition(position); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, telemetryExtra); + break; + } + } + + @Override + public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) { + mContextMenuInfo = ((AdapterContextMenuBuilder) getAdapter()).makeContextMenuInfoFromPosition(v, position); + return showContextMenuForChild(this); + } + + @Override + public HomeContextMenuInfo getContextMenuInfo() { + return mContextMenuInfo; + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java new file mode 100644 index 000000000..d2c136219 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java @@ -0,0 +1,393 @@ +/* -*- 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.home; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.PanelLayout.ContextMenuRegistry; +import org.mozilla.gecko.home.PanelLayout.DatasetHandler; +import org.mozilla.gecko.home.PanelLayout.DatasetRequest; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +/** + * Fragment that displays dynamic content specified by a {@code PanelConfig}. + * The {@code DynamicPanel} UI is built based on the given {@code LayoutType} + * and its associated list of {@code ViewConfig}. + * + * {@code DynamicPanel} manages all necessary Loaders to load panel datasets + * from their respective content providers. Each panel dataset has its own + * associated Loader. This is enforced by defining the Loader IDs based on + * their associated dataset IDs. + * + * The {@code PanelLayout} can make load and reset requests on datasets via + * the provided {@code DatasetHandler}. This way it doesn't need to know the + * details of how datasets are loaded and reset. Each time a dataset is + * requested, {@code DynamicPanel} restarts a Loader with the respective ID (see + * {@code PanelDatasetHandler}). + * + * See {@code PanelLayout} for more details on how {@code DynamicPanel} + * receives dataset requests and delivers them back to the {@code PanelLayout}. + */ +public class DynamicPanel extends HomeFragment { + private static final String LOGTAG = "GeckoDynamicPanel"; + + // Dataset ID to be used by the loader + private static final String DATASET_REQUEST = "dataset_request"; + + // Max number of items to display in the panel + private static final int RESULT_LIMIT = 100; + + // The main view for this fragment. This contains the PanelLayout and PanelAuthLayout. + private FrameLayout mView; + + // The panel layout associated with this panel + private PanelLayout mPanelLayout; + + // The layout used to show authentication UI for this panel + private PanelAuthLayout mPanelAuthLayout; + + // Cache used to keep track of whether or not the user has been authenticated. + private PanelAuthCache mPanelAuthCache; + + // Hold a reference to the UiAsyncTask we use to check the state of the + // PanelAuthCache, so that we can cancel it if necessary. + private UIAsyncTask.WithoutParams<Boolean> mAuthStateTask; + + // The configuration associated with this panel + private PanelConfig mPanelConfig; + + // Callbacks used for the loader + private PanelLoaderCallbacks mLoaderCallbacks; + + // The current UI mode in the fragment + private UIMode mUIMode; + + /* + * Different UI modes to display depending on the authentication state. + * + * PANEL: Layout to display panel data. + * AUTH: Authentication UI. + */ + private enum UIMode { + PANEL, + AUTH + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle args = getArguments(); + if (args != null) { + mPanelConfig = (PanelConfig) args.getParcelable(HomePager.PANEL_CONFIG_ARG); + } + + if (mPanelConfig == null) { + throw new IllegalStateException("Can't create a DynamicPanel without a PanelConfig"); + } + + mPanelAuthCache = new PanelAuthCache(getActivity()); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + mView = new FrameLayout(getActivity()); + return mView; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // Restore whatever the UI mode the fragment had before + // a device rotation. + if (mUIMode != null) { + setUIMode(mUIMode); + } + + mPanelAuthCache.setOnChangeListener(new PanelAuthChangeListener()); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mView = null; + mPanelLayout = null; + mPanelAuthLayout = null; + + mPanelAuthCache.setOnChangeListener(null); + + if (mAuthStateTask != null) { + mAuthStateTask.cancel(); + mAuthStateTask = null; + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + // Create callbacks before the initial loader is started. + mLoaderCallbacks = new PanelLoaderCallbacks(); + loadIfVisible(); + } + + @Override + protected void load() { + Log.d(LOGTAG, "Loading layout"); + + if (requiresAuth()) { + mAuthStateTask = new UIAsyncTask.WithoutParams<Boolean>(ThreadUtils.getBackgroundHandler()) { + @Override + public synchronized Boolean doInBackground() { + return mPanelAuthCache.isAuthenticated(mPanelConfig.getId()); + } + + @Override + public void onPostExecute(Boolean isAuthenticated) { + mAuthStateTask = null; + setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH); + } + }; + mAuthStateTask.execute(); + } else { + setUIMode(UIMode.PANEL); + } + } + + /** + * @return true if this panel requires authentication. + */ + private boolean requiresAuth() { + return mPanelConfig.getAuthConfig() != null; + } + + /** + * Lazily creates layout for panel data. + */ + private void createPanelLayout() { + final ContextMenuRegistry contextMenuRegistry = new ContextMenuRegistry() { + @Override + public void register(View view) { + registerForContextMenu(view); + } + }; + + switch (mPanelConfig.getLayoutType()) { + case FRAME: + final PanelDatasetHandler datasetHandler = new PanelDatasetHandler(); + mPanelLayout = new FramePanelLayout(getActivity(), mPanelConfig, datasetHandler, + mUrlOpenListener, contextMenuRegistry); + break; + + default: + throw new IllegalStateException("Unrecognized layout type in DynamicPanel"); + } + + Log.d(LOGTAG, "Created layout of type: " + mPanelConfig.getLayoutType()); + mView.addView(mPanelLayout); + } + + /** + * Lazily creates layout for authentication UI. + */ + private void createPanelAuthLayout() { + mPanelAuthLayout = new PanelAuthLayout(getActivity(), mPanelConfig); + mView.addView(mPanelAuthLayout, 0); + } + + private void setUIMode(UIMode mode) { + switch (mode) { + case PANEL: + if (mPanelAuthLayout != null) { + mPanelAuthLayout.setVisibility(View.GONE); + } + if (mPanelLayout == null) { + createPanelLayout(); + } + mPanelLayout.setVisibility(View.VISIBLE); + + // Only trigger a reload if the UI mode has changed + // (e.g. auth cache changes) and the fragment is allowed + // to load its contents. Any loaders associated with the + // panel layout will be automatically re-bound after a + // device rotation, no need to explicitly load it again. + if (mUIMode != mode && canLoad()) { + mPanelLayout.load(); + } + break; + + case AUTH: + if (mPanelLayout != null) { + mPanelLayout.setVisibility(View.GONE); + } + if (mPanelAuthLayout == null) { + createPanelAuthLayout(); + } + mPanelAuthLayout.setVisibility(View.VISIBLE); + break; + + default: + throw new IllegalStateException("Unrecognized UIMode in DynamicPanel"); + } + + mUIMode = mode; + } + + /** + * Used by the PanelLayout to make load and reset requests to + * the holding fragment. + */ + private class PanelDatasetHandler implements DatasetHandler { + @Override + public void requestDataset(DatasetRequest request) { + Log.d(LOGTAG, "Requesting request: " + request); + + final Bundle bundle = new Bundle(); + bundle.putParcelable(DATASET_REQUEST, request); + + getLoaderManager().restartLoader(request.getViewIndex(), + bundle, mLoaderCallbacks); + } + + @Override + public void resetDataset(int viewIndex) { + Log.d(LOGTAG, "Resetting dataset: " + viewIndex); + + final LoaderManager lm = getLoaderManager(); + + // Release any resources associated with the dataset if + // it's currently loaded in memory. + final Loader<?> datasetLoader = lm.getLoader(viewIndex); + if (datasetLoader != null) { + datasetLoader.reset(); + } + } + } + + /** + * Cursor loader for the panel datasets. + */ + private static class PanelDatasetLoader extends SimpleCursorLoader { + private DatasetRequest mRequest; + + public PanelDatasetLoader(Context context, DatasetRequest request) { + super(context); + mRequest = request; + } + + public DatasetRequest getRequest() { + return mRequest; + } + + @Override + public void onContentChanged() { + // Ensure the refresh request doesn't affect the view's filter + // stack (i.e. use DATASET_LOAD type) but keep the current + // dataset ID and filter. + final DatasetRequest newRequest = + new DatasetRequest(mRequest.getViewIndex(), + DatasetRequest.Type.DATASET_LOAD, + mRequest.getDatasetId(), + mRequest.getFilterDetail()); + + mRequest = newRequest; + super.onContentChanged(); + } + + @Override + public Cursor loadCursor() { + final ContentResolver cr = getContext().getContentResolver(); + + final String selection; + final String[] selectionArgs; + + // Null represents the root filter + if (mRequest.getFilter() == null) { + selection = HomeItems.FILTER + " IS NULL"; + selectionArgs = null; + } else { + selection = HomeItems.FILTER + " = ?"; + selectionArgs = new String[] { mRequest.getFilter() }; + } + + final Uri queryUri = HomeItems.CONTENT_URI.buildUpon() + .appendQueryParameter(BrowserContract.PARAM_DATASET_ID, + mRequest.getDatasetId()) + .appendQueryParameter(BrowserContract.PARAM_LIMIT, + String.valueOf(RESULT_LIMIT)) + .build(); + + // XXX: You can use HomeItems.CONTENT_FAKE_URI for development + // to pull items from fake_home_items.json. + return cr.query(queryUri, null, selection, selectionArgs, null); + } + } + + /** + * LoaderCallbacks implementation that interacts with the LoaderManager. + */ + private class PanelLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final DatasetRequest request = (DatasetRequest) args.getParcelable(DATASET_REQUEST); + + Log.d(LOGTAG, "Creating loader for request: " + request); + return new PanelDatasetLoader(getActivity(), request); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { + final DatasetRequest request = getRequestFromLoader(loader); + Log.d(LOGTAG, "Finished loader for request: " + request); + + if (mPanelLayout != null) { + mPanelLayout.deliverDataset(request, cursor); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + final DatasetRequest request = getRequestFromLoader(loader); + Log.d(LOGTAG, "Resetting loader for request: " + request); + + if (mPanelLayout != null) { + mPanelLayout.releaseDataset(request.getViewIndex()); + } + } + + private DatasetRequest getRequestFromLoader(Loader<Cursor> loader) { + final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader; + return datasetLoader.getRequest(); + } + } + + private class PanelAuthChangeListener implements PanelAuthCache.OnChangeListener { + @Override + public void onChange(String panelId, boolean isAuthenticated) { + if (!mPanelConfig.getId().equals(panelId)) { + return; + } + + setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java new file mode 100644 index 000000000..7168c1576 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java @@ -0,0 +1,52 @@ +/* -*- 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.home; + +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.HomeConfig.ViewConfig; + +import android.content.Context; +import android.util.Log; +import android.view.View; + +class FramePanelLayout extends PanelLayout { + private static final String LOGTAG = "GeckoFramePanelLayout"; + + private final View mChildView; + private final ViewConfig mChildConfig; + + public FramePanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler, + OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) { + super(context, panelConfig, datasetHandler, urlOpenListener, contextMenuRegistry); + + // This layout can only hold one view so we simply + // take the first defined view from PanelConfig. + mChildConfig = panelConfig.getViewAt(0); + if (mChildConfig == null) { + throw new IllegalStateException("FramePanelLayout requires a view in PanelConfig"); + } + + mChildView = createPanelView(mChildConfig); + addView(mChildView); + } + + @Override + public void load() { + Log.d(LOGTAG, "Loading"); + + if (mChildView instanceof DatasetBacked) { + final FilterDetail filter = new FilterDetail(mChildConfig.getFilter(), null); + + final DatasetRequest request = new DatasetRequest(mChildConfig.getIndex(), + mChildConfig.getDatasetId(), + filter); + + Log.d(LOGTAG, "Requesting child request: " + request); + requestDataset(request); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java new file mode 100644 index 000000000..7a49559f6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java @@ -0,0 +1,80 @@ +/* -*- 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.home; + +import android.content.res.Resources; + +import org.mozilla.gecko.home.CombinedHistoryAdapter.SectionHeader; +import org.mozilla.gecko.R; + +import java.util.Calendar; +import java.util.Locale; + + +public class HistorySectionsHelper { + + // Constants for different time sections. + private static final long MS_PER_DAY = 86400000; + private static final long MS_PER_WEEK = MS_PER_DAY * 7; + + public static class SectionDateRange { + public final long start; + public final long end; + public final String displayName; + + private SectionDateRange(long start, long end, String displayName) { + this.start = start; + this.end = end; + this.displayName = displayName; + } + } + + /** + * Updates the time range in milliseconds covered by each section header and sets the title. + * @param res Resources for fetching string names + * @param sectionsArray Array of section bookkeeping objects + */ + public static void updateRecentSectionOffset(final Resources res, SectionDateRange[] sectionsArray) { + final long now = System.currentTimeMillis(); + final Calendar cal = Calendar.getInstance(); + + // Update calendar to this day. + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 1); + final long currentDayMS = cal.getTimeInMillis(); + + // Calculate the start and end time for each section header and set its display text. + sectionsArray[SectionHeader.TODAY.ordinal()] = + new SectionDateRange(currentDayMS, now, res.getString(R.string.history_today_section)); + + sectionsArray[SectionHeader.YESTERDAY.ordinal()] = + new SectionDateRange(currentDayMS - MS_PER_DAY, currentDayMS, res.getString(R.string.history_yesterday_section)); + + sectionsArray[SectionHeader.WEEK.ordinal()] = + new SectionDateRange(currentDayMS - MS_PER_WEEK, now, res.getString(R.string.history_week_section)); + + // Update the calendar to beginning of next month to avoid problems calculating the last day of this month. + cal.add(Calendar.MONTH, 1); + cal.set(Calendar.DAY_OF_MONTH, cal.getMinimum(Calendar.DAY_OF_MONTH)); + + // Iterate over the remaining history sections, moving backwards in time. + for (int i = SectionHeader.THIS_MONTH.ordinal(); i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) { + final long end = cal.getTimeInMillis(); + + cal.add(Calendar.MONTH, -1); + final long start = cal.getTimeInMillis(); + + final String displayName = cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()); + + sectionsArray[i] = new SectionDateRange(start, end, displayName); + } + + sectionsArray[SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal()] = + new SectionDateRange(0L, cal.getTimeInMillis(), res.getString(R.string.history_older_section)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java new file mode 100644 index 000000000..98d1ae6d8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java @@ -0,0 +1,224 @@ +/* -*- 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.home; + +import org.mozilla.gecko.activitystream.ActivityStream; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.HomeConfig.PanelType; +import org.mozilla.gecko.home.activitystream.ActivityStreamHomeFragment; + +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentStatePagerAdapter; +import android.view.ViewGroup; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class HomeAdapter extends FragmentStatePagerAdapter { + + private final Context mContext; + private final ArrayList<PanelInfo> mPanelInfos; + private final Map<String, HomeFragment> mPanels; + private final Map<String, Bundle> mRestoreBundles; + + private boolean mCanLoadHint; + + private OnAddPanelListener mAddPanelListener; + + private HomeFragment.PanelStateChangeListener mPanelStateChangeListener = null; + + public interface OnAddPanelListener { + void onAddPanel(String title); + } + + public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) { + mPanelStateChangeListener = listener; + + for (Fragment fragment : mPanels.values()) { + ((HomeFragment) fragment).setPanelStateChangeListener(listener); + } + } + + public HomeAdapter(Context context, FragmentManager fm) { + super(fm); + + mContext = context; + mCanLoadHint = HomeFragment.DEFAULT_CAN_LOAD_HINT; + + mPanelInfos = new ArrayList<>(); + mPanels = new HashMap<>(); + mRestoreBundles = new HashMap<>(); + } + + @Override + public int getCount() { + return mPanelInfos.size(); + } + + @Override + public Fragment getItem(int position) { + PanelInfo info = mPanelInfos.get(position); + return Fragment.instantiate(mContext, info.getClassName(mContext), info.getArgs()); + } + + @Override + public CharSequence getPageTitle(int position) { + if (mPanelInfos.size() > 0) { + PanelInfo info = mPanelInfos.get(position); + return info.getTitle().toUpperCase(); + } + + return null; + } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + final HomeFragment fragment = (HomeFragment) super.instantiateItem(container, position); + fragment.setPanelStateChangeListener(mPanelStateChangeListener); + + final String id = mPanelInfos.get(position).getId(); + mPanels.put(id, fragment); + + if (mRestoreBundles.containsKey(id)) { + fragment.restoreData(mRestoreBundles.get(id)); + mRestoreBundles.remove(id); + } + + return fragment; + } + + public void setRestoreData(int position, Bundle data) { + final String id = mPanelInfos.get(position).getId(); + final HomeFragment fragment = mPanels.get(id); + + // We have no guarantees as to whether our desired fragment is instantiated yet: therefore + // we might need to either pass data to the fragment, or store it for later. + if (fragment != null) { + fragment.restoreData(data); + } else { + mRestoreBundles.put(id, data); + } + + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + final String id = mPanelInfos.get(position).getId(); + + super.destroyItem(container, position, object); + mPanels.remove(id); + } + + public void setOnAddPanelListener(OnAddPanelListener listener) { + mAddPanelListener = listener; + } + + public int getItemPosition(String panelId) { + for (int i = 0; i < mPanelInfos.size(); i++) { + final String id = mPanelInfos.get(i).getId(); + if (id.equals(panelId)) { + return i; + } + } + + return -1; + } + + public String getPanelIdAtPosition(int position) { + // getPanelIdAtPosition() might be called before HomeAdapter + // has got its initial list of PanelConfigs. Just bail. + if (mPanelInfos.isEmpty()) { + return null; + } + + return mPanelInfos.get(position).getId(); + } + + private void addPanel(PanelInfo info) { + mPanelInfos.add(info); + + if (mAddPanelListener != null) { + mAddPanelListener.onAddPanel(info.getTitle()); + } + } + + public void update(List<PanelConfig> panelConfigs) { + mPanels.clear(); + mPanelInfos.clear(); + + if (panelConfigs != null) { + for (PanelConfig panelConfig : panelConfigs) { + final PanelInfo info = new PanelInfo(panelConfig); + addPanel(info); + } + } + + notifyDataSetChanged(); + } + + public boolean getCanLoadHint() { + return mCanLoadHint; + } + + public void setCanLoadHint(boolean canLoadHint) { + // We cache the last hint value so that we can use it when + // creating new panels. See PanelInfo.getArgs(). + mCanLoadHint = canLoadHint; + + // Enable/disable loading on all existing panels + for (Fragment panelFragment : mPanels.values()) { + final HomeFragment panel = (HomeFragment) panelFragment; + panel.setCanLoadHint(canLoadHint); + } + } + + private final class PanelInfo { + private final PanelConfig mPanelConfig; + + PanelInfo(PanelConfig panelConfig) { + mPanelConfig = panelConfig; + } + + public String getId() { + return mPanelConfig.getId(); + } + + public String getTitle() { + return mPanelConfig.getTitle(); + } + + public String getClassName(Context context) { + final PanelType type = mPanelConfig.getType(); + + // Override top_sites with ActivityStream panel when enabled + // PanelType.toString() returns the panel id + if (type.toString() == "top_sites" && + ActivityStream.isEnabled(context) && + ActivityStream.isHomePanel()) { + return ActivityStreamHomeFragment.class.getName(); + } + return type.getPanelClass().getName(); + } + + public Bundle getArgs() { + final Bundle args = new Bundle(); + + args.putBoolean(HomePager.CAN_LOAD_ARG, mCanLoadHint); + + // Only DynamicPanels need the PanelConfig argument + if (mPanelConfig.isDynamic()) { + args.putParcelable(HomePager.PANEL_CONFIG_ARG, mPanelConfig); + } + + return args; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java new file mode 100644 index 000000000..10f5db39e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java @@ -0,0 +1,315 @@ +/* -*- 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.home; + +import org.json.JSONObject; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.PropertyAnimator.Property; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.EllipsisTextView; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.Html; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; + +public class HomeBanner extends LinearLayout + implements GeckoEventListener { + private static final String LOGTAG = "GeckoHomeBanner"; + + // Used for tracking scroll length + private float mTouchY = -1; + + // Used to detect for upwards scroll to push banner all the way up + private boolean mSnapBannerToTop; + + // Tracks whether or not the banner should be shown on the current panel. + private boolean mActive; + + // The user is currently swiping between HomePager pages + private boolean mScrollingPages; + + // Tracks whether the user swiped the banner down, preventing us from autoshowing when the user + // switches back to the default page. + private boolean mUserSwipedDown; + + // We must use this custom TextView to address an issue on 2.3 and lower where ellipsized text + // will not wrap more than 2 lines. + private final EllipsisTextView mTextView; + private final ImageView mIconView; + + // The height of the banner view. + private final float mHeight; + + // Listener that gets called when the banner is dismissed from the close button. + private OnDismissListener mOnDismissListener; + + public interface OnDismissListener { + public void onDismiss(); + } + + public HomeBanner(Context context) { + this(context, null); + } + + public HomeBanner(Context context, AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.home_banner_content, this); + + mTextView = (EllipsisTextView) findViewById(R.id.text); + mIconView = (ImageView) findViewById(R.id.icon); + + mHeight = getResources().getDimensionPixelSize(R.dimen.home_banner_height); + + // Disable the banner until a message is set. + setEnabled(false); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + // Tapping on the close button will ensure that the banner is never + // showed again on this session. + final ImageButton closeButton = (ImageButton) findViewById(R.id.close); + + // The drawable should have 50% opacity. + closeButton.getDrawable().setAlpha(127); + + closeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + HomeBanner.this.dismiss(); + + // Send the current message id back to JS. + GeckoAppShell.notifyObservers("HomeBanner:Dismiss", (String) getTag()); + } + }); + + setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + HomeBanner.this.dismiss(); + + // Send the current message id back to JS. + GeckoAppShell.notifyObservers("HomeBanner:Click", (String) getTag()); + } + }); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, "HomeBanner:Data"); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, "HomeBanner:Data"); + } + + public void setScrollingPages(boolean scrollingPages) { + mScrollingPages = scrollingPages; + } + + public void setOnDismissListener(OnDismissListener listener) { + mOnDismissListener = listener; + } + + /** + * Hides and disables the banner. + */ + private void dismiss() { + setVisibility(View.GONE); + setEnabled(false); + + if (mOnDismissListener != null) { + mOnDismissListener.onDismiss(); + } + } + + /** + * Sends a message to gecko to request a new banner message. UI is updated in handleMessage. + */ + public void update() { + GeckoAppShell.notifyObservers("HomeBanner:Get", null); + } + + @Override + public void handleMessage(String event, JSONObject message) { + final String id = message.optString("id"); + final String text = message.optString("text"); + final String iconURI = message.optString("iconURI"); + + // Don't update the banner if the message doesn't have valid id and text. + if (TextUtils.isEmpty(id) || TextUtils.isEmpty(text)) { + return; + } + + // Update the banner message on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Store the current message id to pass back to JS in the view's OnClickListener. + setTag(id); + mTextView.setOriginalText(Html.fromHtml(text)); + + ResourceDrawableUtils.getDrawable(getContext(), iconURI, new ResourceDrawableUtils.BitmapLoader() { + @Override + public void onBitmapFound(final Drawable d) { + // Hide the image view if we don't have an icon to show. + if (d == null) { + mIconView.setVisibility(View.GONE); + } else { + mIconView.setImageDrawable(d); + mIconView.setVisibility(View.VISIBLE); + } + } + }); + + GeckoAppShell.notifyObservers("HomeBanner:Shown", id); + + // Enable the banner after a message is set. + setEnabled(true); + + // Animate the banner if it is currently active. + if (mActive) { + animateUp(); + } + } + }); + } + + public void setActive(boolean active) { + // No need to animate if not changing + if (mActive == active) { + return; + } + + mActive = active; + + // Don't animate if the banner isn't enabled. + if (!isEnabled()) { + return; + } + + if (active) { + animateUp(); + } else { + animateDown(); + } + } + + private void ensureVisible() { + // The banner visibility is set to GONE after it is animated off screen, + // so we need to make it visible again. + if (getVisibility() == View.GONE) { + // Translate the banner off screen before setting it to VISIBLE. + ViewHelper.setTranslationY(this, mHeight); + setVisibility(View.VISIBLE); + } + } + + private void animateUp() { + // Don't try to animate if the user swiped the banner down previously to hide it. + if (mUserSwipedDown) { + return; + } + + ensureVisible(); + + final PropertyAnimator animator = new PropertyAnimator(100); + animator.attach(this, Property.TRANSLATION_Y, 0); + animator.start(); + } + + private void animateDown() { + if (ViewHelper.getTranslationY(this) == mHeight) { + // Hide the banner to avoid intercepting clicks on pre-honeycomb devices. + setVisibility(View.GONE); + return; + } + + final PropertyAnimator animator = new PropertyAnimator(100); + animator.attach(this, Property.TRANSLATION_Y, mHeight); + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + // Hide the banner to avoid intercepting clicks on pre-honeycomb devices. + setVisibility(View.GONE); + } + }); + animator.start(); + } + + public void handleHomeTouch(MotionEvent event) { + if (!mActive || !isEnabled() || mScrollingPages) { + return; + } + + ensureVisible(); + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + // Track the beginning of the touch + mTouchY = event.getRawY(); + break; + } + + case MotionEvent.ACTION_MOVE: { + final float curY = event.getRawY(); + final float delta = mTouchY - curY; + mSnapBannerToTop = delta <= 0.0f; + + float newTranslationY = ViewHelper.getTranslationY(this) + delta; + + // Clamp the values to be between 0 and height. + if (newTranslationY < 0.0f) { + newTranslationY = 0.0f; + } else if (newTranslationY > mHeight) { + newTranslationY = mHeight; + } + + // Don't change this value if it wasn't a significant movement + if (delta >= 10 || delta <= -10) { + mUserSwipedDown = (newTranslationY == mHeight); + } + + ViewHelper.setTranslationY(this, newTranslationY); + mTouchY = curY; + break; + } + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + mTouchY = -1; + if (mSnapBannerToTop) { + animateUp(); + } else { + animateDown(); + mUserSwipedDown = true; + } + break; + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java new file mode 100644 index 000000000..08e79be3a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java @@ -0,0 +1,1694 @@ +/* -*- 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.home; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Pair; + +public final class HomeConfig { + public static final String PREF_KEY_BOOKMARKS_PANEL_ENABLED = "bookmarksPanelEnabled"; + public static final String PREF_KEY_HISTORY_PANEL_ENABLED = "combinedHistoryPanelEnabled"; + + /** + * Used to determine what type of HomeFragment subclass to use when creating + * a given panel. With the exception of DYNAMIC, all of these types correspond + * to a default set of built-in panels. The DYNAMIC panel type is used by + * third-party services to create panels with varying types of content. + */ + @RobocopTarget + public static enum PanelType implements Parcelable { + TOP_SITES("top_sites", TopSitesPanel.class), + BOOKMARKS("bookmarks", BookmarksPanel.class), + COMBINED_HISTORY("combined_history", CombinedHistoryPanel.class), + DYNAMIC("dynamic", DynamicPanel.class), + // Deprecated panels that should no longer exist but are kept around for + // migration code. Class references have been replaced with new version of the panel. + DEPRECATED_REMOTE_TABS("remote_tabs", CombinedHistoryPanel.class), + DEPRECATED_HISTORY("history", CombinedHistoryPanel.class), + DEPRECATED_READING_LIST("reading_list", BookmarksPanel.class), + DEPRECATED_RECENT_TABS("recent_tabs", CombinedHistoryPanel.class); + + private final String mId; + private final Class<?> mPanelClass; + + PanelType(String id, Class<?> panelClass) { + mId = id; + mPanelClass = panelClass; + } + + public static PanelType fromId(String id) { + if (id == null) { + throw new IllegalArgumentException("Could not convert null String to PanelType"); + } + + for (PanelType panelType : PanelType.values()) { + if (TextUtils.equals(panelType.mId, id.toLowerCase())) { + return panelType; + } + } + + throw new IllegalArgumentException("Could not convert String id to PanelType"); + } + + @Override + public String toString() { + return mId; + } + + public Class<?> getPanelClass() { + return mPanelClass; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<PanelType> CREATOR = new Creator<PanelType>() { + @Override + public PanelType createFromParcel(final Parcel source) { + return PanelType.values()[source.readInt()]; + } + + @Override + public PanelType[] newArray(final int size) { + return new PanelType[size]; + } + }; + } + + public static class PanelConfig implements Parcelable { + private final PanelType mType; + private final String mTitle; + private final String mId; + private final LayoutType mLayoutType; + private final List<ViewConfig> mViews; + private final AuthConfig mAuthConfig; + private final EnumSet<Flags> mFlags; + private final int mPosition; + + static final String JSON_KEY_TYPE = "type"; + static final String JSON_KEY_TITLE = "title"; + static final String JSON_KEY_ID = "id"; + static final String JSON_KEY_LAYOUT = "layout"; + static final String JSON_KEY_VIEWS = "views"; + static final String JSON_KEY_AUTH_CONFIG = "authConfig"; + static final String JSON_KEY_DEFAULT = "default"; + static final String JSON_KEY_DISABLED = "disabled"; + static final String JSON_KEY_POSITION = "position"; + + public enum Flags { + DEFAULT_PANEL, + DISABLED_PANEL + } + + public PanelConfig(JSONObject json) throws JSONException, IllegalArgumentException { + final String panelType = json.optString(JSON_KEY_TYPE, null); + if (TextUtils.isEmpty(panelType)) { + mType = PanelType.DYNAMIC; + } else { + mType = PanelType.fromId(panelType); + } + + mTitle = json.getString(JSON_KEY_TITLE); + mId = json.getString(JSON_KEY_ID); + + final String layoutTypeId = json.optString(JSON_KEY_LAYOUT, null); + if (layoutTypeId != null) { + mLayoutType = LayoutType.fromId(layoutTypeId); + } else { + mLayoutType = null; + } + + final JSONArray jsonViews = json.optJSONArray(JSON_KEY_VIEWS); + if (jsonViews != null) { + mViews = new ArrayList<ViewConfig>(); + + final int viewCount = jsonViews.length(); + for (int i = 0; i < viewCount; i++) { + final JSONObject jsonViewConfig = (JSONObject) jsonViews.get(i); + final ViewConfig viewConfig = new ViewConfig(i, jsonViewConfig); + mViews.add(viewConfig); + } + } else { + mViews = null; + } + + final JSONObject jsonAuthConfig = json.optJSONObject(JSON_KEY_AUTH_CONFIG); + if (jsonAuthConfig != null) { + mAuthConfig = new AuthConfig(jsonAuthConfig); + } else { + mAuthConfig = null; + } + + mFlags = EnumSet.noneOf(Flags.class); + + if (json.optBoolean(JSON_KEY_DEFAULT, false)) { + mFlags.add(Flags.DEFAULT_PANEL); + } + + if (json.optBoolean(JSON_KEY_DISABLED, false)) { + mFlags.add(Flags.DISABLED_PANEL); + } + + mPosition = json.optInt(JSON_KEY_POSITION, -1); + + validate(); + } + + @SuppressWarnings("unchecked") + public PanelConfig(Parcel in) { + mType = (PanelType) in.readParcelable(getClass().getClassLoader()); + mTitle = in.readString(); + mId = in.readString(); + mLayoutType = (LayoutType) in.readParcelable(getClass().getClassLoader()); + + mViews = new ArrayList<ViewConfig>(); + in.readTypedList(mViews, ViewConfig.CREATOR); + + mAuthConfig = (AuthConfig) in.readParcelable(getClass().getClassLoader()); + + mFlags = (EnumSet<Flags>) in.readSerializable(); + mPosition = in.readInt(); + + validate(); + } + + public PanelConfig(PanelConfig panelConfig) { + mType = panelConfig.mType; + mTitle = panelConfig.mTitle; + mId = panelConfig.mId; + mLayoutType = panelConfig.mLayoutType; + + mViews = new ArrayList<ViewConfig>(); + List<ViewConfig> viewConfigs = panelConfig.mViews; + if (viewConfigs != null) { + for (ViewConfig viewConfig : viewConfigs) { + mViews.add(new ViewConfig(viewConfig)); + } + } + + mAuthConfig = panelConfig.mAuthConfig; + mFlags = panelConfig.mFlags.clone(); + mPosition = panelConfig.mPosition; + + validate(); + } + + public PanelConfig(PanelType type, String title, String id) { + this(type, title, id, EnumSet.noneOf(Flags.class)); + } + + public PanelConfig(PanelType type, String title, String id, EnumSet<Flags> flags) { + this(type, title, id, null, null, null, flags, -1); + } + + public PanelConfig(PanelType type, String title, String id, LayoutType layoutType, + List<ViewConfig> views, AuthConfig authConfig, EnumSet<Flags> flags, int position) { + mType = type; + mTitle = title; + mId = id; + mLayoutType = layoutType; + mViews = views; + mAuthConfig = authConfig; + mFlags = flags; + mPosition = position; + + validate(); + } + + private void validate() { + if (mType == null) { + throw new IllegalArgumentException("Can't create PanelConfig with null type"); + } + + if (TextUtils.isEmpty(mTitle)) { + throw new IllegalArgumentException("Can't create PanelConfig with empty title"); + } + + if (TextUtils.isEmpty(mId)) { + throw new IllegalArgumentException("Can't create PanelConfig with empty id"); + } + + if (mLayoutType == null && mType == PanelType.DYNAMIC) { + throw new IllegalArgumentException("Can't create a dynamic PanelConfig with null layout type"); + } + + if ((mViews == null || mViews.size() == 0) && mType == PanelType.DYNAMIC) { + throw new IllegalArgumentException("Can't create a dynamic PanelConfig with no views"); + } + + if (mFlags == null) { + throw new IllegalArgumentException("Can't create PanelConfig with null flags"); + } + } + + public PanelType getType() { + return mType; + } + + public String getTitle() { + return mTitle; + } + + public String getId() { + return mId; + } + + public LayoutType getLayoutType() { + return mLayoutType; + } + + public int getViewCount() { + return (mViews != null ? mViews.size() : 0); + } + + public ViewConfig getViewAt(int index) { + return (mViews != null ? mViews.get(index) : null); + } + + public EnumSet<Flags> getFlags() { + return mFlags.clone(); + } + + public boolean isDynamic() { + return (mType == PanelType.DYNAMIC); + } + + public boolean isDefault() { + return mFlags.contains(Flags.DEFAULT_PANEL); + } + + private void setIsDefault(boolean isDefault) { + if (isDefault) { + mFlags.add(Flags.DEFAULT_PANEL); + } else { + mFlags.remove(Flags.DEFAULT_PANEL); + } + } + + public boolean isDisabled() { + return mFlags.contains(Flags.DISABLED_PANEL); + } + + private void setIsDisabled(boolean isDisabled) { + if (isDisabled) { + mFlags.add(Flags.DISABLED_PANEL); + } else { + mFlags.remove(Flags.DISABLED_PANEL); + } + } + + public AuthConfig getAuthConfig() { + return mAuthConfig; + } + + public int getPosition() { + return mPosition; + } + + public JSONObject toJSON() throws JSONException { + final JSONObject json = new JSONObject(); + + json.put(JSON_KEY_TYPE, mType.toString()); + json.put(JSON_KEY_TITLE, mTitle); + json.put(JSON_KEY_ID, mId); + + if (mLayoutType != null) { + json.put(JSON_KEY_LAYOUT, mLayoutType.toString()); + } + + if (mViews != null) { + final JSONArray jsonViews = new JSONArray(); + + final int viewCount = mViews.size(); + for (int i = 0; i < viewCount; i++) { + final ViewConfig viewConfig = mViews.get(i); + final JSONObject jsonViewConfig = viewConfig.toJSON(); + jsonViews.put(jsonViewConfig); + } + + json.put(JSON_KEY_VIEWS, jsonViews); + } + + if (mAuthConfig != null) { + json.put(JSON_KEY_AUTH_CONFIG, mAuthConfig.toJSON()); + } + + if (mFlags.contains(Flags.DEFAULT_PANEL)) { + json.put(JSON_KEY_DEFAULT, true); + } + + if (mFlags.contains(Flags.DISABLED_PANEL)) { + json.put(JSON_KEY_DISABLED, true); + } + + json.put(JSON_KEY_POSITION, mPosition); + + return json; + } + + @Override + public boolean equals(Object o) { + if (o == null) { + return false; + } + + if (this == o) { + return true; + } + + if (!(o instanceof PanelConfig)) { + return false; + } + + final PanelConfig other = (PanelConfig) o; + return mId.equals(other.mId); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mType, 0); + dest.writeString(mTitle); + dest.writeString(mId); + dest.writeParcelable(mLayoutType, 0); + dest.writeTypedList(mViews); + dest.writeParcelable(mAuthConfig, 0); + dest.writeSerializable(mFlags); + dest.writeInt(mPosition); + } + + public static final Creator<PanelConfig> CREATOR = new Creator<PanelConfig>() { + @Override + public PanelConfig createFromParcel(final Parcel in) { + return new PanelConfig(in); + } + + @Override + public PanelConfig[] newArray(final int size) { + return new PanelConfig[size]; + } + }; + } + + public static enum LayoutType implements Parcelable { + FRAME("frame"); + + private final String mId; + + LayoutType(String id) { + mId = id; + } + + public static LayoutType fromId(String id) { + if (id == null) { + throw new IllegalArgumentException("Could not convert null String to LayoutType"); + } + + for (LayoutType layoutType : LayoutType.values()) { + if (TextUtils.equals(layoutType.mId, id.toLowerCase())) { + return layoutType; + } + } + + throw new IllegalArgumentException("Could not convert String id to LayoutType"); + } + + @Override + public String toString() { + return mId; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<LayoutType> CREATOR = new Creator<LayoutType>() { + @Override + public LayoutType createFromParcel(final Parcel source) { + return LayoutType.values()[source.readInt()]; + } + + @Override + public LayoutType[] newArray(final int size) { + return new LayoutType[size]; + } + }; + } + + public static enum ViewType implements Parcelable { + LIST("list"), + GRID("grid"); + + private final String mId; + + ViewType(String id) { + mId = id; + } + + public static ViewType fromId(String id) { + if (id == null) { + throw new IllegalArgumentException("Could not convert null String to ViewType"); + } + + for (ViewType viewType : ViewType.values()) { + if (TextUtils.equals(viewType.mId, id.toLowerCase())) { + return viewType; + } + } + + throw new IllegalArgumentException("Could not convert String id to ViewType"); + } + + @Override + public String toString() { + return mId; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<ViewType> CREATOR = new Creator<ViewType>() { + @Override + public ViewType createFromParcel(final Parcel source) { + return ViewType.values()[source.readInt()]; + } + + @Override + public ViewType[] newArray(final int size) { + return new ViewType[size]; + } + }; + } + + public static enum ItemType implements Parcelable { + ARTICLE("article"), + IMAGE("image"), + ICON("icon"); + + private final String mId; + + ItemType(String id) { + mId = id; + } + + public static ItemType fromId(String id) { + if (id == null) { + throw new IllegalArgumentException("Could not convert null String to ItemType"); + } + + for (ItemType itemType : ItemType.values()) { + if (TextUtils.equals(itemType.mId, id.toLowerCase())) { + return itemType; + } + } + + throw new IllegalArgumentException("Could not convert String id to ItemType"); + } + + @Override + public String toString() { + return mId; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<ItemType> CREATOR = new Creator<ItemType>() { + @Override + public ItemType createFromParcel(final Parcel source) { + return ItemType.values()[source.readInt()]; + } + + @Override + public ItemType[] newArray(final int size) { + return new ItemType[size]; + } + }; + } + + public static enum ItemHandler implements Parcelable { + BROWSER("browser"), + INTENT("intent"); + + private final String mId; + + ItemHandler(String id) { + mId = id; + } + + public static ItemHandler fromId(String id) { + if (id == null) { + throw new IllegalArgumentException("Could not convert null String to ItemHandler"); + } + + for (ItemHandler itemHandler : ItemHandler.values()) { + if (TextUtils.equals(itemHandler.mId, id.toLowerCase())) { + return itemHandler; + } + } + + throw new IllegalArgumentException("Could not convert String id to ItemHandler"); + } + + @Override + public String toString() { + return mId; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<ItemHandler> CREATOR = new Creator<ItemHandler>() { + @Override + public ItemHandler createFromParcel(final Parcel source) { + return ItemHandler.values()[source.readInt()]; + } + + @Override + public ItemHandler[] newArray(final int size) { + return new ItemHandler[size]; + } + }; + } + + public static class ViewConfig implements Parcelable { + private final int mIndex; + private final ViewType mType; + private final String mDatasetId; + private final ItemType mItemType; + private final ItemHandler mItemHandler; + private final String mBackImageUrl; + private final String mFilter; + private final EmptyViewConfig mEmptyViewConfig; + private final HeaderConfig mHeaderConfig; + private final EnumSet<Flags> mFlags; + + static final String JSON_KEY_TYPE = "type"; + static final String JSON_KEY_DATASET = "dataset"; + static final String JSON_KEY_ITEM_TYPE = "itemType"; + static final String JSON_KEY_ITEM_HANDLER = "itemHandler"; + static final String JSON_KEY_BACK_IMAGE_URL = "backImageUrl"; + static final String JSON_KEY_FILTER = "filter"; + static final String JSON_KEY_EMPTY = "empty"; + static final String JSON_KEY_HEADER = "header"; + static final String JSON_KEY_REFRESH_ENABLED = "refreshEnabled"; + + public enum Flags { + REFRESH_ENABLED + } + + public ViewConfig(int index, JSONObject json) throws JSONException, IllegalArgumentException { + mIndex = index; + mType = ViewType.fromId(json.getString(JSON_KEY_TYPE)); + mDatasetId = json.getString(JSON_KEY_DATASET); + mItemType = ItemType.fromId(json.getString(JSON_KEY_ITEM_TYPE)); + mItemHandler = ItemHandler.fromId(json.getString(JSON_KEY_ITEM_HANDLER)); + mBackImageUrl = json.optString(JSON_KEY_BACK_IMAGE_URL, null); + mFilter = json.optString(JSON_KEY_FILTER, null); + + final JSONObject jsonEmptyViewConfig = json.optJSONObject(JSON_KEY_EMPTY); + if (jsonEmptyViewConfig != null) { + mEmptyViewConfig = new EmptyViewConfig(jsonEmptyViewConfig); + } else { + mEmptyViewConfig = null; + } + + final JSONObject jsonHeaderConfig = json.optJSONObject(JSON_KEY_HEADER); + mHeaderConfig = jsonHeaderConfig != null ? new HeaderConfig(jsonHeaderConfig) : null; + + mFlags = EnumSet.noneOf(Flags.class); + if (json.optBoolean(JSON_KEY_REFRESH_ENABLED, false)) { + mFlags.add(Flags.REFRESH_ENABLED); + } + + validate(); + } + + @SuppressWarnings("unchecked") + public ViewConfig(Parcel in) { + mIndex = in.readInt(); + mType = (ViewType) in.readParcelable(getClass().getClassLoader()); + mDatasetId = in.readString(); + mItemType = (ItemType) in.readParcelable(getClass().getClassLoader()); + mItemHandler = (ItemHandler) in.readParcelable(getClass().getClassLoader()); + mBackImageUrl = in.readString(); + mFilter = in.readString(); + mEmptyViewConfig = (EmptyViewConfig) in.readParcelable(getClass().getClassLoader()); + mHeaderConfig = (HeaderConfig) in.readParcelable(getClass().getClassLoader()); + mFlags = (EnumSet<Flags>) in.readSerializable(); + + validate(); + } + + public ViewConfig(ViewConfig viewConfig) { + mIndex = viewConfig.mIndex; + mType = viewConfig.mType; + mDatasetId = viewConfig.mDatasetId; + mItemType = viewConfig.mItemType; + mItemHandler = viewConfig.mItemHandler; + mBackImageUrl = viewConfig.mBackImageUrl; + mFilter = viewConfig.mFilter; + mEmptyViewConfig = viewConfig.mEmptyViewConfig; + mHeaderConfig = viewConfig.mHeaderConfig; + mFlags = viewConfig.mFlags.clone(); + + validate(); + } + + private void validate() { + if (mType == null) { + throw new IllegalArgumentException("Can't create ViewConfig with null type"); + } + + if (TextUtils.isEmpty(mDatasetId)) { + throw new IllegalArgumentException("Can't create ViewConfig with empty dataset ID"); + } + + if (mItemType == null) { + throw new IllegalArgumentException("Can't create ViewConfig with null item type"); + } + + if (mItemHandler == null) { + throw new IllegalArgumentException("Can't create ViewConfig with null item handler"); + } + + if (mFlags == null) { + throw new IllegalArgumentException("Can't create ViewConfig with null flags"); + } + } + + public int getIndex() { + return mIndex; + } + + public ViewType getType() { + return mType; + } + + public String getDatasetId() { + return mDatasetId; + } + + public ItemType getItemType() { + return mItemType; + } + + public ItemHandler getItemHandler() { + return mItemHandler; + } + + public String getBackImageUrl() { + return mBackImageUrl; + } + + public String getFilter() { + return mFilter; + } + + public EmptyViewConfig getEmptyViewConfig() { + return mEmptyViewConfig; + } + + public HeaderConfig getHeaderConfig() { + return mHeaderConfig; + } + + public boolean hasHeaderConfig() { + return mHeaderConfig != null; + } + + public boolean isRefreshEnabled() { + return mFlags.contains(Flags.REFRESH_ENABLED); + } + + public JSONObject toJSON() throws JSONException { + final JSONObject json = new JSONObject(); + + json.put(JSON_KEY_TYPE, mType.toString()); + json.put(JSON_KEY_DATASET, mDatasetId); + json.put(JSON_KEY_ITEM_TYPE, mItemType.toString()); + json.put(JSON_KEY_ITEM_HANDLER, mItemHandler.toString()); + + if (!TextUtils.isEmpty(mBackImageUrl)) { + json.put(JSON_KEY_BACK_IMAGE_URL, mBackImageUrl); + } + + if (!TextUtils.isEmpty(mFilter)) { + json.put(JSON_KEY_FILTER, mFilter); + } + + if (mEmptyViewConfig != null) { + json.put(JSON_KEY_EMPTY, mEmptyViewConfig.toJSON()); + } + + if (mHeaderConfig != null) { + json.put(JSON_KEY_HEADER, mHeaderConfig.toJSON()); + } + + if (mFlags.contains(Flags.REFRESH_ENABLED)) { + json.put(JSON_KEY_REFRESH_ENABLED, true); + } + + return json; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mIndex); + dest.writeParcelable(mType, 0); + dest.writeString(mDatasetId); + dest.writeParcelable(mItemType, 0); + dest.writeParcelable(mItemHandler, 0); + dest.writeString(mBackImageUrl); + dest.writeString(mFilter); + dest.writeParcelable(mEmptyViewConfig, 0); + dest.writeParcelable(mHeaderConfig, 0); + dest.writeSerializable(mFlags); + } + + public static final Creator<ViewConfig> CREATOR = new Creator<ViewConfig>() { + @Override + public ViewConfig createFromParcel(final Parcel in) { + return new ViewConfig(in); + } + + @Override + public ViewConfig[] newArray(final int size) { + return new ViewConfig[size]; + } + }; + } + + public static class EmptyViewConfig implements Parcelable { + private final String mText; + private final String mImageUrl; + + static final String JSON_KEY_TEXT = "text"; + static final String JSON_KEY_IMAGE_URL = "imageUrl"; + + public EmptyViewConfig(JSONObject json) throws JSONException, IllegalArgumentException { + mText = json.optString(JSON_KEY_TEXT, null); + mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null); + } + + public EmptyViewConfig(Parcel in) { + mText = in.readString(); + mImageUrl = in.readString(); + } + + public EmptyViewConfig(EmptyViewConfig emptyViewConfig) { + mText = emptyViewConfig.mText; + mImageUrl = emptyViewConfig.mImageUrl; + } + + public EmptyViewConfig(String text, String imageUrl) { + mText = text; + mImageUrl = imageUrl; + } + + public String getText() { + return mText; + } + + public String getImageUrl() { + return mImageUrl; + } + + public JSONObject toJSON() throws JSONException { + final JSONObject json = new JSONObject(); + + json.put(JSON_KEY_TEXT, mText); + json.put(JSON_KEY_IMAGE_URL, mImageUrl); + + return json; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mText); + dest.writeString(mImageUrl); + } + + public static final Creator<EmptyViewConfig> CREATOR = new Creator<EmptyViewConfig>() { + @Override + public EmptyViewConfig createFromParcel(final Parcel in) { + return new EmptyViewConfig(in); + } + + @Override + public EmptyViewConfig[] newArray(final int size) { + return new EmptyViewConfig[size]; + } + }; + } + + public static class HeaderConfig implements Parcelable { + static final String JSON_KEY_IMAGE_URL = "image_url"; + static final String JSON_KEY_URL = "url"; + + private final String mImageUrl; + private final String mUrl; + + public HeaderConfig(JSONObject json) { + mImageUrl = json.optString(JSON_KEY_IMAGE_URL); + mUrl = json.optString(JSON_KEY_URL); + } + + public HeaderConfig(Parcel in) { + mImageUrl = in.readString(); + mUrl = in.readString(); + } + + public String getImageUrl() { + return mImageUrl; + } + + public String getUrl() { + return mUrl; + } + + public JSONObject toJSON() throws JSONException { + JSONObject json = new JSONObject(); + + json.put(JSON_KEY_IMAGE_URL, mImageUrl); + json.put(JSON_KEY_URL, mUrl); + + return json; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mImageUrl); + dest.writeString(mUrl); + } + + public static final Creator<HeaderConfig> CREATOR = new Creator<HeaderConfig>() { + @Override + public HeaderConfig createFromParcel(Parcel source) { + return new HeaderConfig(source); + } + + @Override + public HeaderConfig[] newArray(int size) { + return new HeaderConfig[size]; + } + }; + } + + public static class AuthConfig implements Parcelable { + private final String mMessageText; + private final String mButtonText; + private final String mImageUrl; + + static final String JSON_KEY_MESSAGE_TEXT = "messageText"; + static final String JSON_KEY_BUTTON_TEXT = "buttonText"; + static final String JSON_KEY_IMAGE_URL = "imageUrl"; + + public AuthConfig(JSONObject json) throws JSONException, IllegalArgumentException { + mMessageText = json.optString(JSON_KEY_MESSAGE_TEXT); + mButtonText = json.optString(JSON_KEY_BUTTON_TEXT); + mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null); + } + + public AuthConfig(Parcel in) { + mMessageText = in.readString(); + mButtonText = in.readString(); + mImageUrl = in.readString(); + + validate(); + } + + public AuthConfig(AuthConfig authConfig) { + mMessageText = authConfig.mMessageText; + mButtonText = authConfig.mButtonText; + mImageUrl = authConfig.mImageUrl; + + validate(); + } + + public AuthConfig(String messageText, String buttonText, String imageUrl) { + mMessageText = messageText; + mButtonText = buttonText; + mImageUrl = imageUrl; + + validate(); + } + + private void validate() { + if (mMessageText == null) { + throw new IllegalArgumentException("Can't create AuthConfig with null message text"); + } + + if (mButtonText == null) { + throw new IllegalArgumentException("Can't create AuthConfig with null button text"); + } + } + + public String getMessageText() { + return mMessageText; + } + + public String getButtonText() { + return mButtonText; + } + + public String getImageUrl() { + return mImageUrl; + } + + public JSONObject toJSON() throws JSONException { + final JSONObject json = new JSONObject(); + + json.put(JSON_KEY_MESSAGE_TEXT, mMessageText); + json.put(JSON_KEY_BUTTON_TEXT, mButtonText); + json.put(JSON_KEY_IMAGE_URL, mImageUrl); + + return json; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mMessageText); + dest.writeString(mButtonText); + dest.writeString(mImageUrl); + } + + public static final Creator<AuthConfig> CREATOR = new Creator<AuthConfig>() { + @Override + public AuthConfig createFromParcel(final Parcel in) { + return new AuthConfig(in); + } + + @Override + public AuthConfig[] newArray(final int size) { + return new AuthConfig[size]; + } + }; + } + /** + * Immutable representation of the current state of {@code HomeConfig}. + * This is what HomeConfig returns from a load() call and takes as + * input to save a new state. + * + * Users of {@code State} should use an {@code Iterator} to iterate + * through the contained {@code PanelConfig} instances. + * + * {@code State} is immutable i.e. you can't add, remove, or update + * contained elements directly. You have to use an {@code Editor} to + * change the state, which can be created through the {@code edit()} + * method. + */ + public static class State implements Iterable<PanelConfig> { + private HomeConfig mHomeConfig; + private final List<PanelConfig> mPanelConfigs; + private final boolean mIsDefault; + + State(List<PanelConfig> panelConfigs, boolean isDefault) { + this(null, panelConfigs, isDefault); + } + + private State(HomeConfig homeConfig, List<PanelConfig> panelConfigs, boolean isDefault) { + mHomeConfig = homeConfig; + mPanelConfigs = Collections.unmodifiableList(panelConfigs); + mIsDefault = isDefault; + } + + private void setHomeConfig(HomeConfig homeConfig) { + if (mHomeConfig != null) { + throw new IllegalStateException("Can't set HomeConfig more than once"); + } + + mHomeConfig = homeConfig; + } + + @Override + public Iterator<PanelConfig> iterator() { + return mPanelConfigs.iterator(); + } + + /** + * Returns whether this {@code State} instance represents the default + * {@code HomeConfig} configuration or not. + */ + public boolean isDefault() { + return mIsDefault; + } + + /** + * Creates an {@code Editor} for this state. + */ + public Editor edit() { + return new Editor(mHomeConfig, this); + } + } + + /** + * {@code Editor} allows you to make changes to a {@code State}. You + * can create {@code Editor} by calling {@code edit()} on the target + * {@code State} instance. + * + * {@code Editor} works on a copy of the {@code State} that originated + * it. This means that adding, removing, or updating panels in an + * {@code Editor} will never change the {@code State} which you + * created the {@code Editor} from. Calling {@code commit()} or + * {@code apply()} will cause the new {@code State} instance to be + * created and saved using the {@code HomeConfig} instance that + * created the source {@code State}. + * + * {@code Editor} is *not* thread-safe. You can only make calls on it + * from the thread where it was originally created. It will throw an + * exception if you don't follow this invariant. + */ + public static class Editor implements Iterable<PanelConfig> { + private final HomeConfig mHomeConfig; + private final Map<String, PanelConfig> mConfigMap; + private final List<String> mConfigOrder; + private final Thread mOriginalThread; + + // Each Pair represents parameters to a GeckoAppShell.notifyObservers call; + // the first String is the observer topic and the second string is the notification data. + private List<Pair<String, String>> mNotificationQueue; + private PanelConfig mDefaultPanel; + private int mEnabledCount; + + private boolean mHasChanged; + private final boolean mIsFromDefault; + + private Editor(HomeConfig homeConfig, State configState) { + mHomeConfig = homeConfig; + mOriginalThread = Thread.currentThread(); + mConfigMap = new HashMap<String, PanelConfig>(); + mConfigOrder = new LinkedList<String>(); + mNotificationQueue = new ArrayList<>(); + + mIsFromDefault = configState.isDefault(); + + initFromState(configState); + } + + /** + * Initialize the initial state of the editor from the given + * {@sode State}. A HashMap is used to represent the list of + * panels as it provides fast access, and a LinkedList is used to + * keep track of order. We keep a reference to the default panel + * and the number of enabled panels to avoid iterating through the + * map every time we need those. + * + * @param configState The source State to load the editor from. + */ + private void initFromState(State configState) { + for (PanelConfig panelConfig : configState) { + final PanelConfig panelCopy = new PanelConfig(panelConfig); + + if (!panelCopy.isDisabled()) { + mEnabledCount++; + } + + if (panelCopy.isDefault()) { + if (mDefaultPanel == null) { + mDefaultPanel = panelCopy; + } else { + throw new IllegalStateException("Multiple default panels in HomeConfig state"); + } + } + + final String panelId = panelConfig.getId(); + mConfigOrder.add(panelId); + mConfigMap.put(panelId, panelCopy); + } + + // We should always have a defined default panel if there's + // at least one enabled panel around. + if (mEnabledCount > 0 && mDefaultPanel == null) { + throw new IllegalStateException("Default panel in HomeConfig state is undefined"); + } + } + + private PanelConfig getPanelOrThrow(String panelId) { + final PanelConfig panelConfig = mConfigMap.get(panelId); + if (panelConfig == null) { + throw new IllegalStateException("Tried to access non-existing panel: " + panelId); + } + + return panelConfig; + } + + private boolean isCurrentDefaultPanel(PanelConfig panelConfig) { + if (mDefaultPanel == null) { + return false; + } + + return mDefaultPanel.equals(panelConfig); + } + + private void findNewDefault() { + // Pick the first panel that is neither disabled nor currently + // set as default. + for (PanelConfig panelConfig : makeOrderedCopy(false)) { + if (!panelConfig.isDefault() && !panelConfig.isDisabled()) { + setDefault(panelConfig.getId()); + return; + } + } + + mDefaultPanel = null; + } + + /** + * Makes an ordered list of PanelConfigs that can be references + * or deep copied objects. + * + * @param deepCopy true to make deep-copied objects + * @return ordered List of PanelConfigs + */ + private List<PanelConfig> makeOrderedCopy(boolean deepCopy) { + final List<PanelConfig> copiedList = new ArrayList<PanelConfig>(mConfigOrder.size()); + for (String panelId : mConfigOrder) { + PanelConfig panelConfig = mConfigMap.get(panelId); + if (deepCopy) { + panelConfig = new PanelConfig(panelConfig); + } + copiedList.add(panelConfig); + } + + return copiedList; + } + + private void setPanelIsDisabled(PanelConfig panelConfig, boolean disabled) { + if (panelConfig.isDisabled() == disabled) { + return; + } + + panelConfig.setIsDisabled(disabled); + mEnabledCount += (disabled ? -1 : 1); + } + + /** + * Gets the ID of the current default panel. + */ + public String getDefaultPanelId() { + ThreadUtils.assertOnThread(mOriginalThread); + + if (mDefaultPanel == null) { + return null; + } + + return mDefaultPanel.getId(); + } + + /** + * Set a new default panel. + * + * @param panelId the ID of the new default panel. + */ + public void setDefault(String panelId) { + ThreadUtils.assertOnThread(mOriginalThread); + + final PanelConfig panelConfig = getPanelOrThrow(panelId); + if (isCurrentDefaultPanel(panelConfig)) { + return; + } + + if (mDefaultPanel != null) { + mDefaultPanel.setIsDefault(false); + } + + panelConfig.setIsDefault(true); + setPanelIsDisabled(panelConfig, false); + + mDefaultPanel = panelConfig; + mHasChanged = true; + } + + /** + * Toggles disabled state for a panel. + * + * @param panelId the ID of the target panel. + * @param disabled true to disable the panel. + */ + public void setDisabled(String panelId, boolean disabled) { + ThreadUtils.assertOnThread(mOriginalThread); + + final PanelConfig panelConfig = getPanelOrThrow(panelId); + if (panelConfig.isDisabled() == disabled) { + return; + } + + setPanelIsDisabled(panelConfig, disabled); + + if (disabled) { + if (isCurrentDefaultPanel(panelConfig)) { + panelConfig.setIsDefault(false); + findNewDefault(); + } + } else if (mEnabledCount == 1) { + setDefault(panelId); + } + + mHasChanged = true; + } + + /** + * Adds a new {@code PanelConfig}. It will do nothing if the + * {@code Editor} already contains a panel with the same ID. + * + * @param panelConfig the {@code PanelConfig} instance to be added. + * @return true if the item has been added. + */ + public boolean install(PanelConfig panelConfig) { + ThreadUtils.assertOnThread(mOriginalThread); + + if (panelConfig == null) { + throw new IllegalStateException("Can't install a null panel"); + } + + if (!panelConfig.isDynamic()) { + throw new IllegalStateException("Can't install a built-in panel: " + panelConfig.getId()); + } + + if (panelConfig.isDisabled()) { + throw new IllegalStateException("Can't install a disabled panel: " + panelConfig.getId()); + } + + boolean installed = false; + + final String id = panelConfig.getId(); + if (!mConfigMap.containsKey(id)) { + mConfigMap.put(id, panelConfig); + + final int position = panelConfig.getPosition(); + if (position < 0 || position >= mConfigOrder.size()) { + mConfigOrder.add(id); + } else { + mConfigOrder.add(position, id); + } + + mEnabledCount++; + if (mEnabledCount == 1 || panelConfig.isDefault()) { + setDefault(panelConfig.getId()); + } + + installed = true; + + // Add an event to the queue if a new panel is successfully installed. + mNotificationQueue.add(new Pair<String, String>( + "HomePanels:Installed", panelConfig.getId())); + } + + mHasChanged = true; + return installed; + } + + /** + * Removes an existing panel. + * + * @return true if the item has been removed. + */ + public boolean uninstall(String panelId) { + ThreadUtils.assertOnThread(mOriginalThread); + + final PanelConfig panelConfig = mConfigMap.get(panelId); + if (panelConfig == null) { + return false; + } + + if (!panelConfig.isDynamic()) { + throw new IllegalStateException("Can't uninstall a built-in panel: " + panelConfig.getId()); + } + + mConfigMap.remove(panelId); + mConfigOrder.remove(panelId); + + if (!panelConfig.isDisabled()) { + mEnabledCount--; + } + + if (isCurrentDefaultPanel(panelConfig)) { + findNewDefault(); + } + + // Add an event to the queue if a panel is successfully uninstalled. + mNotificationQueue.add(new Pair<String, String>("HomePanels:Uninstalled", panelId)); + + mHasChanged = true; + return true; + } + + /** + * Moves panel associated with panelId to the specified position. + * + * @param panelId Id of panel + * @param destIndex Destination position + * @return true if move succeeded + */ + public boolean moveTo(String panelId, int destIndex) { + ThreadUtils.assertOnThread(mOriginalThread); + + if (!mConfigOrder.contains(panelId)) { + return false; + } + + mConfigOrder.remove(panelId); + mConfigOrder.add(destIndex, panelId); + mHasChanged = true; + + return true; + } + + /** + * Replaces an existing panel with a new {@code PanelConfig} instance. + * + * @return true if the item has been updated. + */ + public boolean update(PanelConfig panelConfig) { + ThreadUtils.assertOnThread(mOriginalThread); + + if (panelConfig == null) { + throw new IllegalStateException("Can't update a null panel"); + } + + boolean updated = false; + + final String id = panelConfig.getId(); + if (mConfigMap.containsKey(id)) { + final PanelConfig oldPanelConfig = mConfigMap.put(id, panelConfig); + + // The disabled and default states can't never be + // changed by an update operation. + panelConfig.setIsDefault(oldPanelConfig.isDefault()); + panelConfig.setIsDisabled(oldPanelConfig.isDisabled()); + + updated = true; + } + + mHasChanged = true; + return updated; + } + + /** + * Saves the current {@code Editor} state asynchronously in the + * background thread. + * + * @return the resulting {@code State} instance. + */ + public State apply() { + ThreadUtils.assertOnThread(mOriginalThread); + + // We're about to save the current state in the background thread + // so we should use a deep copy of the PanelConfig instances to + // avoid saving corrupted state. + final State newConfigState = + new State(mHomeConfig, makeOrderedCopy(true), isDefault()); + + // Copy the event queue to a new list, so that we only modify mNotificationQueue on + // the original thread where it was created. + final List<Pair<String, String>> copiedQueue = mNotificationQueue; + mNotificationQueue = new ArrayList<>(); + + ThreadUtils.getBackgroundHandler().post(new Runnable() { + @Override + public void run() { + mHomeConfig.save(newConfigState); + + // Send pending events after the new config is saved. + sendNotificationsToGecko(copiedQueue); + } + }); + + return newConfigState; + } + + /** + * Saves the current {@code Editor} state synchronously in the + * current thread. + * + * @return the resulting {@code State} instance. + */ + public State commit() { + ThreadUtils.assertOnThread(mOriginalThread); + + final State newConfigState = + new State(mHomeConfig, makeOrderedCopy(false), isDefault()); + + // This is a synchronous blocking operation, hence no + // need to deep copy the current PanelConfig instances. + mHomeConfig.save(newConfigState); + + // Send pending events after the new config is saved. + sendNotificationsToGecko(mNotificationQueue); + mNotificationQueue.clear(); + + return newConfigState; + } + + /** + * Returns whether the {@code Editor} represents the default + * {@code HomeConfig} configuration without any unsaved changes. + */ + public boolean isDefault() { + ThreadUtils.assertOnThread(mOriginalThread); + + return (!mHasChanged && mIsFromDefault); + } + + public boolean isEmpty() { + return mConfigMap.isEmpty(); + } + + private void sendNotificationsToGecko(List<Pair<String, String>> notifications) { + for (Pair<String, String> p : notifications) { + GeckoAppShell.notifyObservers(p.first, p.second); + } + } + + private class EditorIterator implements Iterator<PanelConfig> { + private final Iterator<String> mOrderIterator; + + public EditorIterator() { + mOrderIterator = mConfigOrder.iterator(); + } + + @Override + public boolean hasNext() { + return mOrderIterator.hasNext(); + } + + @Override + public PanelConfig next() { + final String panelId = mOrderIterator.next(); + return mConfigMap.get(panelId); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("Can't 'remove' from on Editor iterator."); + } + } + + @Override + public Iterator<PanelConfig> iterator() { + ThreadUtils.assertOnThread(mOriginalThread); + + return new EditorIterator(); + } + } + + public interface OnReloadListener { + public void onReload(); + } + + public interface HomeConfigBackend { + public State load(); + public void save(State configState); + public String getLocale(); + public void setOnReloadListener(OnReloadListener listener); + } + + // UUIDs used to create PanelConfigs for default built-in panels. These are + // public because they can be used in "about:home?panel=UUID" query strings + // to open specific panels without querying the active Home Panel + // configuration. Because they don't consider the active configuration, it + // is only sensible to do this for built-in panels (and not for dynamic + // panels). + private static final String TOP_SITES_PANEL_ID = "4becc86b-41eb-429a-a042-88fe8b5a094e"; + private static final String BOOKMARKS_PANEL_ID = "7f6d419a-cd6c-4e34-b26f-f68b1b551907"; + private static final String HISTORY_PANEL_ID = "f134bf20-11f7-4867-ab8b-e8e705d7fbe8"; + private static final String COMBINED_HISTORY_PANEL_ID = "4d716ce2-e063-486d-9e7c-b190d7b04dc6"; + private static final String RECENT_TABS_PANEL_ID = "5c2601a5-eedc-4477-b297-ce4cef52adf8"; + private static final String REMOTE_TABS_PANEL_ID = "72429afd-8d8b-43d8-9189-14b779c563d0"; + private static final String DEPRECATED_READING_LIST_PANEL_ID = "20f4549a-64ad-4c32-93e4-1dcef792733b"; + + private final HomeConfigBackend mBackend; + + public HomeConfig(HomeConfigBackend backend) { + mBackend = backend; + } + + public State load() { + final State configState = mBackend.load(); + configState.setHomeConfig(this); + + return configState; + } + + public String getLocale() { + return mBackend.getLocale(); + } + + public void save(State configState) { + mBackend.save(configState); + } + + public void setOnReloadListener(OnReloadListener listener) { + mBackend.setOnReloadListener(listener); + } + + public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType) { + return createBuiltinPanelConfig(context, panelType, EnumSet.noneOf(PanelConfig.Flags.class)); + } + + public static int getTitleResourceIdForBuiltinPanelType(PanelType panelType) { + switch (panelType) { + case TOP_SITES: + return R.string.home_top_sites_title; + + case BOOKMARKS: + case DEPRECATED_READING_LIST: + return R.string.bookmarks_title; + + case DEPRECATED_HISTORY: + case DEPRECATED_REMOTE_TABS: + case DEPRECATED_RECENT_TABS: + case COMBINED_HISTORY: + return R.string.home_history_title; + + default: + throw new IllegalArgumentException("Only for built-in panel types: " + panelType); + } + } + + public static String getIdForBuiltinPanelType(PanelType panelType) { + switch (panelType) { + case TOP_SITES: + return TOP_SITES_PANEL_ID; + + case BOOKMARKS: + return BOOKMARKS_PANEL_ID; + + case DEPRECATED_HISTORY: + return HISTORY_PANEL_ID; + + case COMBINED_HISTORY: + return COMBINED_HISTORY_PANEL_ID; + + case DEPRECATED_REMOTE_TABS: + return REMOTE_TABS_PANEL_ID; + + case DEPRECATED_READING_LIST: + return DEPRECATED_READING_LIST_PANEL_ID; + + case DEPRECATED_RECENT_TABS: + return RECENT_TABS_PANEL_ID; + + default: + throw new IllegalArgumentException("Only for built-in panel types: " + panelType); + } + } + + public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType, EnumSet<PanelConfig.Flags> flags) { + final int titleId = getTitleResourceIdForBuiltinPanelType(panelType); + final String id = getIdForBuiltinPanelType(panelType); + + return new PanelConfig(panelType, context.getString(titleId), id, flags); + } + + public static HomeConfig getDefault(Context context) { + return new HomeConfig(new HomeConfigPrefsBackend(context)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java new file mode 100644 index 000000000..914d0fdd1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java @@ -0,0 +1,83 @@ +/* -*- 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.home; + +import org.mozilla.gecko.home.HomeConfig.OnReloadListener; + +import android.content.Context; +import android.support.v4.content.AsyncTaskLoader; + +public class HomeConfigLoader extends AsyncTaskLoader<HomeConfig.State> { + private final HomeConfig mConfig; + private HomeConfig.State mConfigState; + + private final Context mContext; + + public HomeConfigLoader(Context context, HomeConfig homeConfig) { + super(context); + mContext = context; + mConfig = homeConfig; + } + + @Override + public HomeConfig.State loadInBackground() { + return mConfig.load(); + } + + @Override + public void deliverResult(HomeConfig.State configState) { + if (isReset()) { + mConfigState = null; + return; + } + + mConfigState = configState; + mConfig.setOnReloadListener(new ForceReloadListener()); + + if (isStarted()) { + super.deliverResult(configState); + } + } + + @Override + protected void onStartLoading() { + if (mConfigState != null) { + deliverResult(mConfigState); + } + + if (takeContentChanged() || mConfigState == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public void onCanceled(HomeConfig.State configState) { + mConfigState = null; + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped. + onStopLoading(); + + mConfigState = null; + mConfig.setOnReloadListener(null); + } + + private class ForceReloadListener implements OnReloadListener { + @Override + public void onReload() { + onContentChanged(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java new file mode 100644 index 000000000..a2d80788c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java @@ -0,0 +1,663 @@ +/* -*- 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.home; + +import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.Locale; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.home.HomeConfig.HomeConfigBackend; +import org.mozilla.gecko.home.HomeConfig.OnReloadListener; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.HomeConfig.PanelType; +import org.mozilla.gecko.home.HomeConfig.State; +import org.mozilla.gecko.util.HardwareUtils; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.support.annotation.CheckResult; +import android.support.annotation.VisibleForTesting; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +public class HomeConfigPrefsBackend implements HomeConfigBackend { + private static final String LOGTAG = "GeckoHomeConfigBackend"; + + // Increment this to trigger a migration. + @VisibleForTesting + static final int VERSION = 8; + + // This key was originally used to store only an array of panel configs. + public static final String PREFS_CONFIG_KEY_OLD = "home_panels"; + + // This key is now used to store a version number with the array of panel configs. + public static final String PREFS_CONFIG_KEY = "home_panels_with_version"; + + // Keys used with JSON object stored in prefs. + private static final String JSON_KEY_PANELS = "panels"; + private static final String JSON_KEY_VERSION = "version"; + + private static final String PREFS_LOCALE_KEY = "home_locale"; + + private static final String RELOAD_BROADCAST = "HomeConfigPrefsBackend:Reload"; + + private final Context mContext; + private ReloadBroadcastReceiver mReloadBroadcastReceiver; + private OnReloadListener mReloadListener; + + private static boolean sMigrationDone; + + public HomeConfigPrefsBackend(Context context) { + mContext = context; + } + + private SharedPreferences getSharedPreferences() { + return GeckoSharedPrefs.forProfile(mContext); + } + + private State loadDefaultConfig() { + final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>(); + + panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.TOP_SITES, + EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL))); + + panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.BOOKMARKS)); + panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.COMBINED_HISTORY)); + + + return new State(panelConfigs, true); + } + + /** + * Iterate through the panels to check if they are all disabled. + */ + private static boolean allPanelsAreDisabled(JSONArray jsonPanels) throws JSONException { + final int count = jsonPanels.length(); + for (int i = 0; i < count; i++) { + final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i); + + if (!jsonPanelConfig.optBoolean(PanelConfig.JSON_KEY_DISABLED, false)) { + return false; + } + } + + return true; + } + + protected enum Position { + NONE, // Not present. + FRONT, // At the front of the list of panels. + BACK, // At the back of the list of panels. + } + + /** + * Create and insert a built-in panel configuration. + * + * @param context Android context. + * @param jsonPanels array of JSON panels to update in place. + * @param panelType to add. + * @param positionOnPhones where to place the new panel on phones. + * @param positionOnTablets where to place the new panel on tablets. + * @throws JSONException + */ + protected static void addBuiltinPanelConfig(Context context, JSONArray jsonPanels, + PanelType panelType, Position positionOnPhones, Position positionOnTablets) throws JSONException { + // Add the new panel. + final JSONObject jsonPanelConfig = + createBuiltinPanelConfig(context, panelType).toJSON(); + + // If any panel is enabled, then we should make the new panel enabled. + jsonPanelConfig.put(PanelConfig.JSON_KEY_DISABLED, + allPanelsAreDisabled(jsonPanels)); + + final boolean isTablet = HardwareUtils.isTablet(); + final boolean isPhone = !isTablet; + + // Maybe add the new panel to the front of the array. + if ((isPhone && positionOnPhones == Position.FRONT) || + (isTablet && positionOnTablets == Position.FRONT)) { + // This is an inefficient way to stretch [a, b, c] to [a, a, b, c]. + for (int i = jsonPanels.length(); i >= 1; i--) { + jsonPanels.put(i, jsonPanels.get(i - 1)); + } + // And this inserts [d, a, b, c]. + jsonPanels.put(0, jsonPanelConfig); + } + + // Maybe add the new panel to the back of the array. + if ((isPhone && positionOnPhones == Position.BACK) || + (isTablet && positionOnTablets == Position.BACK)) { + jsonPanels.put(jsonPanelConfig); + } + } + + /** + * Updates the panels to combine the History and Sync panels into the (Combined) History panel. + * + * Tries to replace the History panel with the Combined History panel if visible, or falls back to + * replacing the Sync panel if it's visible. That way, we minimize panel reordering during a migration. + * @param context Android context + * @param jsonPanels array of original JSON panels + * @return new array of updated JSON panels + * @throws JSONException + */ + private static JSONArray combineHistoryAndSyncPanels(Context context, JSONArray jsonPanels) throws JSONException { + EnumSet<PanelConfig.Flags> historyFlags = null; + EnumSet<PanelConfig.Flags> syncFlags = null; + + int historyIndex = -1; + int syncIndex = -1; + + // Determine state and location of History and Sync panels. + for (int i = 0; i < jsonPanels.length(); i++) { + JSONObject panelObj = jsonPanels.getJSONObject(i); + final PanelConfig panelConfig = new PanelConfig(panelObj); + final PanelType type = panelConfig.getType(); + if (type == PanelType.DEPRECATED_HISTORY) { + historyIndex = i; + historyFlags = panelConfig.getFlags(); + } else if (type == PanelType.DEPRECATED_REMOTE_TABS) { + syncIndex = i; + syncFlags = panelConfig.getFlags(); + } else if (type == PanelType.COMBINED_HISTORY) { + // Partial landing of bug 1220928 combined the History and Sync panels of users who didn't + // have home panel customizations (including new users), thus they don't this migration. + return jsonPanels; + } + } + + if (historyIndex == -1 || syncIndex == -1) { + throw new IllegalArgumentException("Expected both History and Sync panels to be present prior to Combined History."); + } + + PanelConfig newPanel; + int replaceIndex; + int removeIndex; + + if (historyFlags.contains(PanelConfig.Flags.DISABLED_PANEL) && !syncFlags.contains(PanelConfig.Flags.DISABLED_PANEL)) { + // Replace the Sync panel if it's visible and the History panel is disabled. + replaceIndex = syncIndex; + removeIndex = historyIndex; + newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, syncFlags); + } else { + // Otherwise, just replace the History panel. + replaceIndex = historyIndex; + removeIndex = syncIndex; + newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, historyFlags); + } + + // Copy the array with updated panel and removed panel. + final JSONArray newArray = new JSONArray(); + for (int i = 0; i < jsonPanels.length(); i++) { + if (i == replaceIndex) { + newArray.put(newPanel.toJSON()); + } else if (i == removeIndex) { + continue; + } else { + newArray.put(jsonPanels.get(i)); + } + } + + return newArray; + } + + /** + * Iterate over all homepanels to verify that there is at least one default panel. If there is + * no default panel, set History as the default panel. (This is only relevant for two botched + * migrations where the history panel should have been made the default panel, but wasn't.) + */ + private static void ensureDefaultPanelForV5orV8(Context context, JSONArray jsonPanels) throws JSONException { + int historyIndex = -1; + + // If all panels are disabled, there is no default panel - this is the only valid state + // that has no default. We can use this flag to track whether any visible panels have been + // found. + boolean enabledPanelsFound = false; + + for (int i = 0; i < jsonPanels.length(); i++) { + final PanelConfig panelConfig = new PanelConfig(jsonPanels.getJSONObject(i)); + if (panelConfig.isDefault()) { + return; + } + + if (!panelConfig.isDisabled()) { + enabledPanelsFound = true; + } + + if (panelConfig.getType() == PanelType.COMBINED_HISTORY) { + historyIndex = i; + } + } + + if (!enabledPanelsFound) { + // No panels are enabled, hence there can be no default (see noEnabledPanelsFound declaration + // for more information). + return; + } + + // Make the History panel default. We can't modify existing PanelConfigs, so make a new one. + final PanelConfig historyPanelConfig = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)); + jsonPanels.put(historyIndex, historyPanelConfig.toJSON()); + } + + /** + * Removes a panel from the home panel config. + * If the removed panel was set as the default home panel, we provide a replacement for it. + * + * @param context Android context + * @param jsonPanels array of original JSON panels + * @param panelToRemove The home panel to be removed. + * @param replacementPanel The panel which will replace it if the removed panel + * was the default home panel. + * @param alwaysUnhide If true, the replacement panel will always be unhidden, + * otherwise only if we turn it into the new default panel. + * @return new array of updated JSON panels + * @throws JSONException + */ + private static JSONArray removePanel(Context context, JSONArray jsonPanels, + PanelType panelToRemove, PanelType replacementPanel, boolean alwaysUnhide) throws JSONException { + boolean wasDefault = false; + boolean wasDisabled = false; + int replacementPanelIndex = -1; + boolean replacementWasDefault = false; + + // JSONArrary doesn't provide remove() for API < 19, therefore we need to manually copy all + // the items we don't want deleted into a new array. + final JSONArray newJSONPanels = new JSONArray(); + + for (int i = 0; i < jsonPanels.length(); i++) { + final JSONObject panelJSON = jsonPanels.getJSONObject(i); + final PanelConfig panelConfig = new PanelConfig(panelJSON); + + if (panelConfig.getType() == panelToRemove) { + // If this panel was the default we'll need to assign a new default: + wasDefault = panelConfig.isDefault(); + wasDisabled = panelConfig.isDisabled(); + } else { + if (panelConfig.getType() == replacementPanel) { + replacementPanelIndex = newJSONPanels.length(); + if (panelConfig.isDefault()) { + replacementWasDefault = true; + } + } + + newJSONPanels.put(panelJSON); + } + } + + // Unless alwaysUnhide is true, we make the replacement panel visible only if it is going + // to be the new default panel, since a hidden default panel doesn't make sense. + // This is to allow preserving the behaviour of the original reading list migration function. + if ((wasDefault || alwaysUnhide) && !wasDisabled) { + final JSONObject replacementPanelConfig; + if (wasDefault) { + // If the removed panel was the default, the replacement has to be made the new default + replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)).toJSON(); + } else { + final EnumSet<HomeConfig.PanelConfig.Flags> flags; + if (replacementWasDefault) { + // However if the replacement panel was already default, we need to preserve it's default status + // (By rewriting the PanelConfig, we lose all existing flags, so we need to make sure desired + // flags are retained - in this case there's only DEFAULT_PANEL, which is mutually + // exclusive with the DISABLE_PANEL case). + flags = EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL); + } else { + flags = EnumSet.noneOf(PanelConfig.Flags.class); + } + + // The panel is visible since we don't set Flags.DISABLED_PANEL. + replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, flags).toJSON(); + } + + if (replacementPanelIndex != -1) { + newJSONPanels.put(replacementPanelIndex, replacementPanelConfig); + } else { + newJSONPanels.put(replacementPanelConfig); + } + } + + return newJSONPanels; + } + + /** + * Checks to see if the reading list panel already exists. + * + * @param jsonPanels JSONArray array representing the curent set of panel configs. + * + * @return boolean Whether or not the reading list panel exists. + */ + private static boolean readingListPanelExists(JSONArray jsonPanels) { + final int count = jsonPanels.length(); + for (int i = 0; i < count; i++) { + try { + final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i); + final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig); + if (panelConfig.getType() == PanelType.DEPRECATED_READING_LIST) { + return true; + } + } catch (Exception e) { + // It's okay to ignore this exception, since an invalid reading list + // panel config is equivalent to no reading list panel. + Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e); + } + } + return false; + } + + @CheckResult + static synchronized JSONArray migratePrefsFromVersionToVersion(final Context context, final int currentVersion, final int newVersion, + final JSONArray jsonPanelsIn, final SharedPreferences.Editor prefsEditor) throws JSONException { + + JSONArray jsonPanels = jsonPanelsIn; + + for (int v = currentVersion + 1; v <= newVersion; v++) { + Log.d(LOGTAG, "Migrating to version = " + v); + + switch (v) { + case 1: + // Add "Recent Tabs" panel. + addBuiltinPanelConfig(context, jsonPanels, + PanelType.DEPRECATED_RECENT_TABS, Position.FRONT, Position.BACK); + + // Remove the old pref key. + prefsEditor.remove(PREFS_CONFIG_KEY_OLD); + break; + + case 2: + // Add "Remote Tabs"/"Synced Tabs" panel. + addBuiltinPanelConfig(context, jsonPanels, + PanelType.DEPRECATED_REMOTE_TABS, Position.FRONT, Position.BACK); + break; + + case 3: + // Add the "Reading List" panel if it does not exist. At one time, + // the Reading List panel was shown only to devices that were not + // considered "low memory". Now, we expose the panel to all devices. + // This migration should only occur for "low memory" devices. + // Note: This will not agree with the default configuration, which + // has DEPRECATED_REMOTE_TABS after DEPRECATED_READING_LIST on some devices. + if (!readingListPanelExists(jsonPanels)) { + addBuiltinPanelConfig(context, jsonPanels, + PanelType.DEPRECATED_READING_LIST, Position.BACK, Position.BACK); + } + break; + + case 4: + // Combine the History and Sync panels. In order to minimize an unexpected reordering + // of panels, we try to replace the History panel if it's visible, and fall back to + // the Sync panel if that's visible. + jsonPanels = combineHistoryAndSyncPanels(context, jsonPanels); + break; + + case 5: + // This is the fix for bug 1264136 where we lost track of the default panel during some migrations. + ensureDefaultPanelForV5orV8(context, jsonPanels); + break; + + case 6: + jsonPanels = removePanel(context, jsonPanels, + PanelType.DEPRECATED_READING_LIST, PanelType.BOOKMARKS, false); + break; + + case 7: + jsonPanels = removePanel(context, jsonPanels, + PanelType.DEPRECATED_RECENT_TABS, PanelType.COMBINED_HISTORY, true); + break; + + case 8: + // Similar to "case 5" above, this time 1304777 - once again we lost track + // of the history panel + ensureDefaultPanelForV5orV8(context, jsonPanels); + break; + } + } + + return jsonPanels; + } + + /** + * Migrates JSON config data storage. + * + * @param context Context used to get shared preferences and create built-in panel. + * @param jsonString String currently stored in preferences. + * + * @return JSONArray array representing new set of panel configs. + */ + private static synchronized JSONArray maybePerformMigration(Context context, String jsonString) throws JSONException { + // If the migration is already done, we're at the current version. + if (sMigrationDone) { + final JSONObject json = new JSONObject(jsonString); + return json.getJSONArray(JSON_KEY_PANELS); + } + + // Make sure we only do this version check once. + sMigrationDone = true; + + JSONArray jsonPanels; + final int version; + + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context); + if (prefs.contains(PREFS_CONFIG_KEY_OLD)) { + // Our original implementation did not contain versioning, so this is implicitly version 0. + jsonPanels = new JSONArray(jsonString); + version = 0; + } else { + final JSONObject json = new JSONObject(jsonString); + jsonPanels = json.getJSONArray(JSON_KEY_PANELS); + version = json.getInt(JSON_KEY_VERSION); + } + + if (version == VERSION) { + return jsonPanels; + } + + Log.d(LOGTAG, "Performing migration"); + + final SharedPreferences.Editor prefsEditor = prefs.edit(); + + jsonPanels = migratePrefsFromVersionToVersion(context, version, VERSION, jsonPanels, prefsEditor); + + // Save the new panel config and the new version number. + final JSONObject newJson = new JSONObject(); + newJson.put(JSON_KEY_PANELS, jsonPanels); + newJson.put(JSON_KEY_VERSION, VERSION); + + prefsEditor.putString(PREFS_CONFIG_KEY, newJson.toString()); + prefsEditor.apply(); + + return jsonPanels; + } + + private State loadConfigFromString(String jsonString) { + final JSONArray jsonPanelConfigs; + try { + jsonPanelConfigs = maybePerformMigration(mContext, jsonString); + updatePrefsFromConfig(jsonPanelConfigs); + } catch (JSONException e) { + Log.e(LOGTAG, "Error loading the list of home panels from JSON prefs", e); + + // Fallback to default config + return loadDefaultConfig(); + } + + final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>(); + + final int count = jsonPanelConfigs.length(); + for (int i = 0; i < count; i++) { + try { + final JSONObject jsonPanelConfig = jsonPanelConfigs.getJSONObject(i); + final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig); + panelConfigs.add(panelConfig); + } catch (Exception e) { + Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e); + } + } + + return new State(panelConfigs, false); + } + + @Override + public State load() { + final SharedPreferences prefs = getSharedPreferences(); + + final String key = (prefs.contains(PREFS_CONFIG_KEY_OLD) ? PREFS_CONFIG_KEY_OLD : PREFS_CONFIG_KEY); + final String jsonString = prefs.getString(key, null); + + final State configState; + if (TextUtils.isEmpty(jsonString)) { + configState = loadDefaultConfig(); + } else { + configState = loadConfigFromString(jsonString); + } + + return configState; + } + + @Override + public void save(State configState) { + final SharedPreferences prefs = getSharedPreferences(); + final SharedPreferences.Editor editor = prefs.edit(); + + // No need to save the state to disk if it represents the default + // HomeConfig configuration. Simply force all existing HomeConfigLoader + // instances to refresh their contents. + if (!configState.isDefault()) { + final JSONArray jsonPanelConfigs = new JSONArray(); + + for (PanelConfig panelConfig : configState) { + try { + final JSONObject jsonPanelConfig = panelConfig.toJSON(); + jsonPanelConfigs.put(jsonPanelConfig); + } catch (Exception e) { + Log.e(LOGTAG, "Exception converting PanelConfig to JSON", e); + } + } + + try { + final JSONObject json = new JSONObject(); + json.put(JSON_KEY_PANELS, jsonPanelConfigs); + json.put(JSON_KEY_VERSION, VERSION); + + editor.putString(PREFS_CONFIG_KEY, json.toString()); + } catch (JSONException e) { + Log.e(LOGTAG, "Exception saving PanelConfig state", e); + } + } + + editor.putString(PREFS_LOCALE_KEY, Locale.getDefault().toString()); + editor.apply(); + + // Trigger reload listeners on all live backend instances + sendReloadBroadcast(); + } + + @Override + public String getLocale() { + final SharedPreferences prefs = getSharedPreferences(); + + String locale = prefs.getString(PREFS_LOCALE_KEY, null); + if (locale == null) { + // Initialize config with the current locale + final String currentLocale = Locale.getDefault().toString(); + + final SharedPreferences.Editor editor = prefs.edit(); + editor.putString(PREFS_LOCALE_KEY, currentLocale); + editor.apply(); + + // If the user has saved HomeConfig before, return null this + // one time to trigger a refresh and ensure we use the + // correct locale for the saved state. For more context, + // see HomePanelsManager.onLocaleReady(). + if (!prefs.contains(PREFS_CONFIG_KEY)) { + locale = currentLocale; + } + } + + return locale; + } + + @Override + public void setOnReloadListener(OnReloadListener listener) { + if (mReloadListener != null) { + unregisterReloadReceiver(); + mReloadBroadcastReceiver = null; + } + + mReloadListener = listener; + + if (mReloadListener != null) { + mReloadBroadcastReceiver = new ReloadBroadcastReceiver(); + registerReloadReceiver(); + } + } + + /** + * Update prefs that depend on home panels state. + * + * This includes the prefs that keep track of whether bookmarks or history are enabled, which are + * used to control the visibility of the corresponding menu items. + */ + private void updatePrefsFromConfig(JSONArray panelsArray) { + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(mContext); + if (!prefs.contains(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED) + || !prefs.contains(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED)) { + + final String bookmarkType = PanelType.BOOKMARKS.toString(); + final String historyType = PanelType.COMBINED_HISTORY.toString(); + try { + for (int i = 0; i < panelsArray.length(); i++) { + final JSONObject panelObj = panelsArray.getJSONObject(i); + final String panelType = panelObj.optString(PanelConfig.JSON_KEY_TYPE, null); + if (panelType == null) { + break; + } + final boolean isDisabled = panelObj.optBoolean(PanelConfig.JSON_KEY_DISABLED, false); + if (bookmarkType.equals(panelType)) { + prefs.edit().putBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, !isDisabled).apply(); + } else if (historyType.equals(panelType)) { + prefs.edit().putBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, !isDisabled).apply(); + } + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error fetching panel from config to update prefs"); + } + } + } + + + private void sendReloadBroadcast() { + final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext); + final Intent reloadIntent = new Intent(RELOAD_BROADCAST); + lbm.sendBroadcast(reloadIntent); + } + + private void registerReloadReceiver() { + final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext); + lbm.registerReceiver(mReloadBroadcastReceiver, new IntentFilter(RELOAD_BROADCAST)); + } + + private void unregisterReloadReceiver() { + final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext); + lbm.unregisterReceiver(mReloadBroadcastReceiver); + } + + private class ReloadBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + mReloadListener.onReload(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java new file mode 100644 index 000000000..cefa0329d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java @@ -0,0 +1,82 @@ +/* -*- 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.home; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.util.StringUtils; + +import android.database.Cursor; +import android.text.TextUtils; +import android.view.View; +import android.widget.AdapterView.AdapterContextMenuInfo; +import android.widget.ExpandableListAdapter; +import android.widget.ListAdapter; + +/** + * A ContextMenuInfo for HomeListView + */ +public class HomeContextMenuInfo extends AdapterContextMenuInfo { + + public String url; + public String title; + public boolean isFolder; + public int historyId = -1; + public int bookmarkId = -1; + public RemoveItemType itemType = null; + + // Item type to be handled with "Remove" selection. + public static enum RemoveItemType { + BOOKMARKS, COMBINED, HISTORY + } + + public HomeContextMenuInfo(View targetView, int position, long id) { + super(targetView, position, id); + } + + public boolean hasBookmarkId() { + return bookmarkId > -1; + } + + public boolean hasHistoryId() { + return historyId > -1; + } + + public boolean hasPartnerBookmarkId() { + return bookmarkId <= BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START; + } + + public boolean canRemove() { + return hasBookmarkId() || hasHistoryId() || hasPartnerBookmarkId(); + } + + public String getDisplayTitle() { + if (!TextUtils.isEmpty(title)) { + return title; + } + return StringUtils.stripCommonSubdomains(StringUtils.stripScheme(url, StringUtils.UrlFlags.STRIP_HTTPS)); + } + + /** + * Interface for creating ContextMenuInfo instances from cursors. + */ + public interface Factory { + public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor); + } + + /** + * Interface for creating ContextMenuInfo instances from ListAdapters. + */ + public interface ListFactory extends Factory { + public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ListAdapter adapter); + } + + /** + * Interface for creating ContextMenuInfo instances from ExpandableListAdapters. + */ + public interface ExpandableFactory { + public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ExpandableListAdapter adapter); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java new file mode 100644 index 000000000..7badd6929 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java @@ -0,0 +1,68 @@ +/* -*- 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.home; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemLongClickListener; +import android.widget.ExpandableListView; + +/** + * <code>HomeExpandableListView</code> is a custom extension of + * <code>ExpandableListView<code>, that packs a <code>HomeContextMenuInfo</code> + * when any of its rows is long pressed. + * <p> + * This is the <code>ExpandableListView</code> equivalent of + * <code>HomeListView</code>. + */ +public class HomeExpandableListView extends ExpandableListView + implements OnItemLongClickListener { + + // ContextMenuInfo associated with the currently long pressed list item. + private HomeContextMenuInfo mContextMenuInfo; + + // ContextMenuInfo factory. + private HomeContextMenuInfo.ExpandableFactory mContextMenuInfoFactory; + + public HomeExpandableListView(Context context) { + this(context, null); + } + + public HomeExpandableListView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public HomeExpandableListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setOnItemLongClickListener(this); + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + if (mContextMenuInfoFactory == null) { + return false; + } + + // HomeExpandableListView items can correspond to groups and children. + // The factory can determine whether to add context menu for either, + // both, or none by unpacking the given position. + mContextMenuInfo = mContextMenuInfoFactory.makeInfoForAdapter(view, position, id, getExpandableListAdapter()); + return showContextMenuForChild(HomeExpandableListView.this); + } + + @Override + public ContextMenuInfo getContextMenuInfo() { + return mContextMenuInfo; + } + + public void setContextMenuInfoFactory(final HomeContextMenuInfo.ExpandableFactory factory) { + mContextMenuInfoFactory = factory; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java new file mode 100644 index 000000000..da6e9b703 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java @@ -0,0 +1,498 @@ +/* -*- 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.home; + +import java.util.EnumSet; + +import org.mozilla.gecko.EditBookmarkDialog; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.IntentHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.BrowserContract.SuggestedSites; +import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy; +import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType; +import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo; +import org.mozilla.gecko.reader.SavedReaderViewHelper; +import org.mozilla.gecko.reader.ReadingListHelper; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.os.Bundle; +import android.support.design.widget.Snackbar; +import android.support.v4.app.Fragment; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +/** + * HomeFragment is an empty fragment that can be added to the HomePager. + * Subclasses can add their own views. + * <p> + * The containing activity <b>must</b> implement {@link OnUrlOpenListener}. + */ +public abstract class HomeFragment extends Fragment { + // Log Tag. + private static final String LOGTAG = "GeckoHomeFragment"; + + // Share MIME type. + protected static final String SHARE_MIME_TYPE = "text/plain"; + + // Default value for "can load" hint + static final boolean DEFAULT_CAN_LOAD_HINT = false; + + // Whether the fragment can load its content or not + // This is used to defer data loading until the editing + // mode animation ends. + private boolean mCanLoadHint; + + // Whether the fragment has loaded its content + private boolean mIsLoaded; + + // On URL open listener + protected OnUrlOpenListener mUrlOpenListener; + + // Helper for opening a tab in the background. + protected OnUrlOpenInBackgroundListener mUrlOpenInBackgroundListener; + + protected PanelStateChangeListener mPanelStateChangeListener = null; + + /** + * Listener to notify when a home panels' state has changed in a way that needs to be stored + * for history/restoration. E.g. when a folder is opened/closed in bookmarks. + */ + public interface PanelStateChangeListener { + + /** + * @param bundle Data that should be persisted, and passed to this panel if restored at a later + * stage. + */ + void onStateChanged(Bundle bundle); + + void setCachedRecentTabsCount(int count); + + int getCachedRecentTabsCount(); + } + + public void restoreData(Bundle data) { + // Do nothing + } + + public void setPanelStateChangeListener( + PanelStateChangeListener mPanelStateChangeListener) { + this.mPanelStateChangeListener = mPanelStateChangeListener; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mUrlOpenListener = (OnUrlOpenListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + + " must implement HomePager.OnUrlOpenListener"); + } + + try { + mUrlOpenInBackgroundListener = (OnUrlOpenInBackgroundListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + + " must implement HomePager.OnUrlOpenInBackgroundListener"); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mUrlOpenListener = null; + mUrlOpenInBackgroundListener = null; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle args = getArguments(); + if (args != null) { + mCanLoadHint = args.getBoolean(HomePager.CAN_LOAD_ARG, DEFAULT_CAN_LOAD_HINT); + } else { + mCanLoadHint = DEFAULT_CAN_LOAD_HINT; + } + + mIsLoaded = false; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + GeckoApplication.watchReference(getActivity(), this); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { + if (!(menuInfo instanceof HomeContextMenuInfo)) { + return; + } + + HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo; + + // Don't show the context menu for folders. + if (info.isFolder) { + return; + } + + MenuInflater inflater = new MenuInflater(view.getContext()); + inflater.inflate(R.menu.home_contextmenu, menu); + + menu.setHeaderTitle(info.getDisplayTitle()); + + // Hide unused menu items. + menu.findItem(R.id.top_sites_edit).setVisible(false); + menu.findItem(R.id.top_sites_pin).setVisible(false); + menu.findItem(R.id.top_sites_unpin).setVisible(false); + + // Hide the "Edit" menuitem if this item isn't a bookmark, + // or if this is a reading list item. + if (!info.hasBookmarkId()) { + menu.findItem(R.id.home_edit_bookmark).setVisible(false); + } + + // Hide the "Remove" menuitem if this item not removable. + if (!info.canRemove()) { + menu.findItem(R.id.home_remove).setVisible(false); + } + + if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) { + menu.findItem(R.id.home_share).setVisible(false); + } + + if (!Restrictions.isAllowed(view.getContext(), Restrictable.PRIVATE_BROWSING)) { + menu.findItem(R.id.home_open_private_tab).setVisible(false); + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + // onContextItemSelected() is first dispatched to the activity and + // then dispatched to its fragments. Since fragments cannot "override" + // menu item selection handling, it's better to avoid menu id collisions + // between the activity and its fragments. + + ContextMenuInfo menuInfo = item.getMenuInfo(); + if (!(menuInfo instanceof HomeContextMenuInfo)) { + return false; + } + + final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo; + final Context context = getActivity(); + + final int itemId = item.getItemId(); + + // Track the menu action. We don't know much about the context, but we can use this to determine + // the frequency of use for various actions. + String extras = getResources().getResourceEntryName(itemId); + if (TextUtils.equals(extras, "home_open_private_tab")) { + // Mask private browsing + extras = "home_open_new_tab"; + } + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, extras); + + if (itemId == R.id.home_copyurl) { + if (info.url == null) { + Log.e(LOGTAG, "Can't copy address because URL is null"); + return false; + } + + Clipboard.setText(info.url); + return true; + } + + if (itemId == R.id.home_share) { + if (info.url == null) { + Log.e(LOGTAG, "Can't share because URL is null"); + return false; + } else { + IntentHelper.openUriExternal(info.url, SHARE_MIME_TYPE, "", "", + Intent.ACTION_SEND, info.getDisplayTitle(), false); + + // Context: Sharing via chrome homepage contextmenu list (home session should be active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "home_contextmenu"); + return true; + } + } + + if (itemId == R.id.home_add_to_launcher) { + if (info.url == null) { + Log.e(LOGTAG, "Can't add to home screen because URL is null"); + return false; + } + + // Fetch an icon big enough for use as a home screen icon. + final String displayTitle = info.getDisplayTitle(); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GeckoAppShell.createShortcut(displayTitle, info.url); + + } + }); + + return true; + } + + if (itemId == R.id.home_open_private_tab || itemId == R.id.home_open_new_tab) { + if (info.url == null) { + Log.e(LOGTAG, "Can't open in new tab because URL is null"); + return false; + } + + // Some pinned site items have "user-entered" urls. URLs entered in + // the PinSiteDialog are wrapped in a special URI until we can get a + // valid URL. If the url is a user-entered url, decode the URL + // before loading it. + final String url = StringUtils.decodeUserEnteredUrl(info.url); + + final EnumSet<OnUrlOpenInBackgroundListener.Flags> flags = EnumSet.noneOf(OnUrlOpenInBackgroundListener.Flags.class); + if (item.getItemId() == R.id.home_open_private_tab) { + flags.add(OnUrlOpenInBackgroundListener.Flags.PRIVATE); + } + + mUrlOpenInBackgroundListener.onUrlOpenInBackground(url, flags); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU); + + return true; + } + + if (itemId == R.id.home_edit_bookmark) { + // UI Dialog associates to the activity context, not the applications'. + new EditBookmarkDialog(context).show(info.url); + return true; + } + + if (itemId == R.id.home_remove) { + // For Top Sites grid items, position is required in case item is Pinned. + final int position = info instanceof TopSitesGridContextMenuInfo ? info.position : -1; + + if (info.hasPartnerBookmarkId()) { + new RemovePartnerBookmarkTask(context, info.bookmarkId).execute(); + } else { + new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute(); + } + return true; + } + + return false; + } + + @Override + public void setUserVisibleHint (boolean isVisibleToUser) { + if (isVisibleToUser == getUserVisibleHint()) { + return; + } + + super.setUserVisibleHint(isVisibleToUser); + loadIfVisible(); + } + + /** + * Handle a configuration change by detaching and re-attaching. + * <p> + * A HomeFragment only needs to handle onConfiguration change (i.e., + * re-attach) if its UI needs to change (i.e., re-inflate layouts, use + * different styles, etc) for different device orientations. Handling + * configuration changes in all HomeFragments will simply cause some + * redundant re-inflations on device rotation. This slight inefficiency + * avoids potentially not handling a needed onConfigurationChanged in a + * subclass. + */ + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Reattach the fragment, forcing a re-inflation of its view. + // We use commitAllowingStateLoss() instead of commit() here to avoid + // an IllegalStateException. If the phone is rotated while Fennec + // is in the background, onConfigurationChanged() is fired. + // onConfigurationChanged() is called before onResume(), so + // using commit() would throw an IllegalStateException since it can't + // be used between the Activity's onSaveInstanceState() and + // onResume(). + if (isVisible()) { + getFragmentManager().beginTransaction() + .detach(this) + .attach(this) + .commitAllowingStateLoss(); + } + } + + void setCanLoadHint(boolean canLoadHint) { + if (mCanLoadHint == canLoadHint) { + return; + } + + mCanLoadHint = canLoadHint; + loadIfVisible(); + } + + boolean getCanLoadHint() { + return mCanLoadHint; + } + + protected abstract void load(); + + protected boolean canLoad() { + return (mCanLoadHint && isVisible() && getUserVisibleHint()); + } + + protected void loadIfVisible() { + if (!canLoad() || mIsLoaded) { + return; + } + + load(); + mIsLoaded = true; + } + + protected static class RemoveItemByUrlTask extends UIAsyncTask.WithoutParams<Void> { + private final Context mContext; + private final String mUrl; + private final RemoveItemType mType; + private final int mPosition; + private final BrowserDB mDB; + + /** + * Remove bookmark/history/reading list type item by url, and also unpin the + * Top Sites grid item at index <code>position</code>. + */ + public RemoveItemByUrlTask(Context context, String url, RemoveItemType type, int position) { + super(ThreadUtils.getBackgroundHandler()); + + mContext = context; + mUrl = url; + mType = type; + mPosition = position; + mDB = BrowserDB.from(context); + } + + @Override + public Void doInBackground() { + ContentResolver cr = mContext.getContentResolver(); + + if (mPosition > -1) { + mDB.unpinSite(cr, mPosition); + if (mDB.hideSuggestedSite(mUrl)) { + cr.notifyChange(SuggestedSites.CONTENT_URI, null); + } + } + + switch (mType) { + case BOOKMARKS: + removeBookmark(cr); + break; + + case HISTORY: + removeHistory(cr); + break; + + case COMBINED: + removeBookmark(cr); + removeHistory(cr); + break; + + default: + Log.e(LOGTAG, "Can't remove item type " + mType.toString()); + break; + } + return null; + } + + @Override + public void onPostExecute(Void result) { + SnackbarBuilder.builder((Activity) mContext) + .message(R.string.page_removed) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + + private void removeBookmark(ContentResolver cr) { + SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(mContext); + final boolean isReaderViewPage = rch.isURLCached(mUrl); + + final String extra; + if (isReaderViewPage) { + extra = "bookmark_reader"; + } else { + extra = "bookmark"; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.CONTEXT_MENU, extra); + mDB.removeBookmarksWithURL(cr, mUrl); + + if (isReaderViewPage) { + ReadingListHelper.removeCachedReaderItem(mUrl, mContext); + } + } + + private void removeHistory(ContentResolver cr) { + mDB.removeHistoryEntry(cr, mUrl); + } + } + + private static class RemovePartnerBookmarkTask extends UIAsyncTask.WithoutParams<Void> { + private Context context; + private long bookmarkId; + + public RemovePartnerBookmarkTask(Context context, long bookmarkId) { + super(ThreadUtils.getBackgroundHandler()); + + this.context = context; + this.bookmarkId = bookmarkId; + } + + @Override + protected Void doInBackground() { + context.getContentResolver().delete( + PartnerBookmarksProviderProxy.getUriForBookmark(context, bookmarkId), + null, + null + ); + + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + SnackbarBuilder.builder((Activity) context) + .message(R.string.page_removed) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java new file mode 100644 index 000000000..d179a27ce --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java @@ -0,0 +1,138 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; + +import android.content.Context; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemLongClickListener; +import android.widget.ListView; + +/** + * HomeListView is a custom extension of ListView, that packs a HomeContextMenuInfo + * when any of its rows is long pressed. + */ +public class HomeListView extends ListView + implements OnItemLongClickListener { + + // ContextMenuInfo associated with the currently long pressed list item. + private HomeContextMenuInfo mContextMenuInfo; + + // On URL open listener + protected OnUrlOpenListener mUrlOpenListener; + + // Top divider + private final boolean mShowTopDivider; + + // ContextMenuInfo maker + private HomeContextMenuInfo.Factory mContextMenuInfoFactory; + + public HomeListView(Context context) { + this(context, null); + } + + public HomeListView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.homeListViewStyle); + } + + public HomeListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HomeListView, defStyle, 0); + mShowTopDivider = a.getBoolean(R.styleable.HomeListView_topDivider, false); + a.recycle(); + + setOnItemLongClickListener(this); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + final Drawable divider = getDivider(); + if (mShowTopDivider && divider != null) { + final int dividerHeight = getDividerHeight(); + final View view = new View(getContext()); + view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, dividerHeight)); + addHeaderView(view); + } + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + mUrlOpenListener = null; + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + Object item = parent.getItemAtPosition(position); + + // HomeListView could hold headers too. Add a context menu info only for its children. + if (item instanceof Cursor) { + Cursor cursor = (Cursor) item; + if (cursor == null || mContextMenuInfoFactory == null) { + mContextMenuInfo = null; + return false; + } + + mContextMenuInfo = mContextMenuInfoFactory.makeInfoForCursor(view, position, id, cursor); + return showContextMenuForChild(HomeListView.this); + + } else if (mContextMenuInfoFactory instanceof HomeContextMenuInfo.ListFactory) { + mContextMenuInfo = ((HomeContextMenuInfo.ListFactory) mContextMenuInfoFactory).makeInfoForAdapter(view, position, id, getAdapter()); + return showContextMenuForChild(HomeListView.this); + } else { + mContextMenuInfo = null; + return false; + } + } + + @Override + public ContextMenuInfo getContextMenuInfo() { + return mContextMenuInfo; + } + + @Override + public void setOnItemClickListener(final AdapterView.OnItemClickListener listener) { + if (listener == null) { + super.setOnItemClickListener(null); + return; + } + + super.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + if (mShowTopDivider) { + position--; + } + + listener.onItemClick(parent, view, position, id); + } + }); + } + + public void setContextMenuInfoFactory(final HomeContextMenuInfo.Factory factory) { + mContextMenuInfoFactory = factory; + } + + public OnUrlOpenListener getOnUrlOpenListener() { + return mUrlOpenListener; + } + + public void setOnUrlOpenListener(OnUrlOpenListener listener) { + mUrlOpenListener = listener; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java new file mode 100644 index 000000000..4915f0c91 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java @@ -0,0 +1,564 @@ +/* -*- 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.home; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.home.HomeAdapter.OnAddPanelListener; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.LoaderManager; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.Loader; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +public class HomePager extends ViewPager implements HomeScreen { + + @Override + public boolean requestFocus(int direction, Rect previouslyFocusedRect) { + return super.requestFocus(direction, previouslyFocusedRect); + } + + private static final int LOADER_ID_CONFIG = 0; + + private final Context mContext; + private volatile boolean mVisible; + private Decor mDecor; + private View mTabStrip; + private HomeBanner mHomeBanner; + private int mDefaultPageIndex = -1; + + private final OnAddPanelListener mAddPanelListener; + + private final HomeConfig mConfig; + private final ConfigLoaderCallbacks mConfigLoaderCallbacks; + + private String mInitialPanelId; + private Bundle mRestoreData; + + // Cached original ViewPager background. + private final Drawable mOriginalBackground; + + // Telemetry session for current panel. + private TelemetryContract.Session mCurrentPanelSession; + private String mCurrentPanelSessionSuffix; + + // Current load state of HomePager. + private LoadState mLoadState; + + // Listens for when the current panel changes. + private OnPanelChangeListener mPanelChangedListener; + + private HomeFragment.PanelStateChangeListener mPanelStateChangeListener; + + // This is mostly used by UI tests to easily fetch + // specific list views at runtime. + public static final String LIST_TAG_HISTORY = "history"; + public static final String LIST_TAG_BOOKMARKS = "bookmarks"; + public static final String LIST_TAG_TOP_SITES = "top_sites"; + public static final String LIST_TAG_RECENT_TABS = "recent_tabs"; + public static final String LIST_TAG_BROWSER_SEARCH = "browser_search"; + public static final String LIST_TAG_REMOTE_TABS = "remote_tabs"; + + public interface OnUrlOpenListener { + public enum Flags { + ALLOW_SWITCH_TO_TAB, + OPEN_WITH_INTENT, + /** + * Ensure that the raw URL is opened. If not set, then the reader view version of the page + * might be opened if the URL is stored as an offline reader-view bookmark. + */ + NO_READER_VIEW + } + + public void onUrlOpen(String url, EnumSet<Flags> flags); + } + + /** + * Interface for requesting a new tab be opened in the background. + * <p> + * This is the <code>HomeFragment</code> equivalent of opening a new tab by + * long clicking a link and selecting the "Open new [private] tab" context + * menu option. + */ + public interface OnUrlOpenInBackgroundListener { + public enum Flags { + PRIVATE, + } + + /** + * Open a new tab with the given URL + * + * @param url to open. + * @param flags to open new tab with. + */ + public void onUrlOpenInBackground(String url, EnumSet<Flags> flags); + } + + /** + * Special type of child views that could be added as pager decorations by default. + */ + public interface Decor { + void onAddPagerView(String title); + void removeAllPagerViews(); + void onPageSelected(int position); + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels); + void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener); + } + + /** + * State of HomePager with respect to loading its configuration. + */ + private enum LoadState { + UNLOADED, + LOADING, + LOADED + } + + public static final String CAN_LOAD_ARG = "canLoad"; + public static final String PANEL_CONFIG_ARG = "panelConfig"; + + public HomePager(Context context) { + this(context, null); + } + + public HomePager(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + + mConfig = HomeConfig.getDefault(mContext); + mConfigLoaderCallbacks = new ConfigLoaderCallbacks(); + + mAddPanelListener = new OnAddPanelListener() { + @Override + public void onAddPanel(String title) { + if (mDecor != null) { + mDecor.onAddPagerView(title); + } + } + }; + + // This is to keep all 4 panels in memory after they are + // selected in the pager. + setOffscreenPageLimit(3); + + // We can call HomePager.requestFocus to steal focus from the URL bar and drop the soft + // keyboard. However, if there are no focusable views (e.g. an empty reading list), the + // URL bar will be refocused. Therefore, we make the HomePager container focusable to + // ensure there is always a focusable view. This would ordinarily be done via an XML + // attribute, but it is not working properly. + setFocusableInTouchMode(true); + + mOriginalBackground = getBackground(); + setOnPageChangeListener(new PageChangeListener()); + + mLoadState = LoadState.UNLOADED; + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (child instanceof Decor) { + ((ViewPager.LayoutParams) params).isDecor = true; + mDecor = (Decor) child; + mTabStrip = child; + + mDecor.setOnTitleClickListener(new TabMenuStrip.OnTitleClickListener() { + @Override + public void onTitleClicked(int index) { + setCurrentItem(index, true); + } + }); + } + + super.addView(child, index, params); + } + + /** + * Loads and initializes the pager. + * + * @param fm FragmentManager for the adapter + */ + @Override + public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator) { + mLoadState = LoadState.LOADING; + + mVisible = true; + mInitialPanelId = panelId; + mRestoreData = restoreData; + + // Update the home banner message each time the HomePager is loaded. + if (mHomeBanner != null) { + mHomeBanner.update(); + } + + // Only animate on post-HC devices, when a non-null animator is given + final boolean shouldAnimate = animator != null; + + final HomeAdapter adapter = new HomeAdapter(mContext, fm); + adapter.setOnAddPanelListener(mAddPanelListener); + adapter.setPanelStateChangeListener(mPanelStateChangeListener); + adapter.setCanLoadHint(true); + setAdapter(adapter); + + // Don't show the tabs strip until we have the + // list of panels in place. + mTabStrip.setVisibility(View.INVISIBLE); + + // Load list of panels from configuration + lm.initLoader(LOADER_ID_CONFIG, null, mConfigLoaderCallbacks); + + if (shouldAnimate) { + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + setLayerType(View.LAYER_TYPE_HARDWARE, null); + } + + @Override + public void onPropertyAnimationEnd() { + setLayerType(View.LAYER_TYPE_NONE, null); + } + }); + + ViewHelper.setAlpha(this, 0.0f); + + animator.attach(this, + PropertyAnimator.Property.ALPHA, + 1.0f); + } + } + + /** + * Removes all child fragments to free memory. + */ + @Override + public void unload() { + mVisible = false; + setAdapter(null); + mLoadState = LoadState.UNLOADED; + + // Stop UI Telemetry sessions. + stopCurrentPanelTelemetrySession(); + } + + /** + * Determines whether the pager is visible. + * + * Unlike getVisibility(), this method does not need to be called on the UI + * thread. + * + * @return Whether the pager and its fragments are loaded + */ + public boolean isVisible() { + return mVisible; + } + + @Override + public void setCurrentItem(int item, boolean smoothScroll) { + super.setCurrentItem(item, smoothScroll); + + if (mDecor != null) { + mDecor.onPageSelected(item); + } + + if (mHomeBanner != null) { + mHomeBanner.setActive(item == mDefaultPageIndex); + } + } + + private void restorePanelData(int item, Bundle data) { + ((HomeAdapter) getAdapter()).setRestoreData(item, data); + } + + /** + * Shows a home panel. If the given panelId is null, + * the default panel will be shown. No action will be taken if: + * * HomePager has not loaded yet + * * Panel with the given panelId cannot be found + * + * If you're trying to open a built-in panel, consider loading the panel url directly with + * {@link org.mozilla.gecko.AboutPages#getURLForBuiltinPanelType(HomeConfig.PanelType)}. + * + * @param panelId of the home panel to be shown. + */ + @Override + public void showPanel(String panelId, Bundle restoreData) { + if (!mVisible) { + return; + } + + switch (mLoadState) { + case LOADING: + mInitialPanelId = panelId; + mRestoreData = restoreData; + break; + + case LOADED: + int position = mDefaultPageIndex; + if (panelId != null) { + position = ((HomeAdapter) getAdapter()).getItemPosition(panelId); + } + + if (position > -1) { + setCurrentItem(position); + if (restoreData != null) { + restorePanelData(position, restoreData); + } + } + break; + + default: + // Do nothing. + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + // Drop the soft keyboard by stealing focus from the URL bar. + requestFocus(); + } + + return super.onInterceptTouchEvent(event); + } + + public void setBanner(HomeBanner banner) { + mHomeBanner = banner; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (mHomeBanner != null) { + mHomeBanner.handleHomeTouch(event); + } + + return super.dispatchTouchEvent(event); + } + + @Override + public void onToolbarFocusChange(boolean hasFocus) { + if (mHomeBanner == null) { + return; + } + + // We should only make the banner active if the toolbar is not focused and we are on the default page + final boolean active = !hasFocus && getCurrentItem() == mDefaultPageIndex; + mHomeBanner.setActive(active); + } + + private void updateUiFromConfigState(HomeConfig.State configState) { + // We only care about the adapter if HomePager is currently + // loaded, which means it's visible in the activity. + if (!mVisible) { + return; + } + + if (mDecor != null) { + mDecor.removeAllPagerViews(); + } + + final HomeAdapter adapter = (HomeAdapter) getAdapter(); + + // Disable any fragment loading until we have the initial + // panel selection done. + adapter.setCanLoadHint(false); + + // Destroy any existing panels currently loaded + // in the pager. + setAdapter(null); + + // Only keep enabled panels. + final List<PanelConfig> enabledPanels = new ArrayList<PanelConfig>(); + + for (PanelConfig panelConfig : configState) { + if (!panelConfig.isDisabled()) { + enabledPanels.add(panelConfig); + } + } + + // Update the adapter with the new panel configs + adapter.update(enabledPanels); + + final int count = enabledPanels.size(); + if (count == 0) { + // Set firefox watermark as background. + setBackgroundResource(R.drawable.home_pager_empty_state); + // Hide the tab strip as there are no panels. + mTabStrip.setVisibility(View.INVISIBLE); + } else { + mTabStrip.setVisibility(View.VISIBLE); + // Restore original background. + setBackgroundDrawable(mOriginalBackground); + } + + // Re-install the adapter with the final state + // in the pager. + setAdapter(adapter); + + if (count == 0) { + mDefaultPageIndex = -1; + + // Hide the banner if there are no enabled panels. + if (mHomeBanner != null) { + mHomeBanner.setActive(false); + } + } else { + for (int i = 0; i < count; i++) { + if (enabledPanels.get(i).isDefault()) { + mDefaultPageIndex = i; + break; + } + } + + // Use the default panel if the initial panel wasn't explicitly set by the + // load() caller, or if the initial panel is not found in the adapter. + final int itemPosition = (mInitialPanelId == null) ? -1 : adapter.getItemPosition(mInitialPanelId); + if (itemPosition > -1) { + setCurrentItem(itemPosition, false); + if (mRestoreData != null) { + restorePanelData(itemPosition, mRestoreData); + mRestoreData = null; // Release data since it's no longer needed + } + mInitialPanelId = null; + } else { + setCurrentItem(mDefaultPageIndex, false); + } + } + + // The selection is updated asynchronously so we need to post to + // UI thread to give the pager time to commit the new page selection + // internally and load the right initial panel. + ThreadUtils.getUiHandler().post(new Runnable() { + @Override + public void run() { + adapter.setCanLoadHint(true); + } + }); + } + + @Override + public void setOnPanelChangeListener(OnPanelChangeListener listener) { + mPanelChangedListener = listener; + } + + @Override + public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) { + mPanelStateChangeListener = listener; + + HomeAdapter adapter = (HomeAdapter) getAdapter(); + if (adapter != null) { + adapter.setPanelStateChangeListener(listener); + } + } + + /** + * Notify listeners of newly selected panel. + * + * @param position of the newly selected panel + */ + private void notifyPanelSelected(int position) { + if (mDecor != null) { + mDecor.onPageSelected(position); + } + + if (mPanelChangedListener != null) { + final String panelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position); + mPanelChangedListener.onPanelSelected(panelId); + } + } + + private class ConfigLoaderCallbacks implements LoaderCallbacks<HomeConfig.State> { + @Override + public Loader<HomeConfig.State> onCreateLoader(int id, Bundle args) { + return new HomeConfigLoader(mContext, mConfig); + } + + @Override + public void onLoadFinished(Loader<HomeConfig.State> loader, HomeConfig.State configState) { + mLoadState = LoadState.LOADED; + updateUiFromConfigState(configState); + } + + @Override + public void onLoaderReset(Loader<HomeConfig.State> loader) { + mLoadState = LoadState.UNLOADED; + } + } + + private class PageChangeListener implements ViewPager.OnPageChangeListener { + @Override + public void onPageSelected(int position) { + notifyPanelSelected(position); + + if (mHomeBanner != null) { + mHomeBanner.setActive(position == mDefaultPageIndex); + } + + // Start a UI telemetry session for the newly selected panel. + final String newPanelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position); + startNewPanelTelemetrySession(newPanelId); + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (mDecor != null) { + mDecor.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + if (mHomeBanner != null) { + mHomeBanner.setScrollingPages(positionOffsetPixels != 0); + } + } + + @Override + public void onPageScrollStateChanged(int state) { } + } + + /** + * Start UI telemetry session for the a panel. + * If there is currently a session open for a panel, + * it will be stopped before a new one is started. + * + * @param panelId of panel to start a session for + */ + private void startNewPanelTelemetrySession(String panelId) { + // Stop the current panel's session if we have one. + stopCurrentPanelTelemetrySession(); + + mCurrentPanelSession = TelemetryContract.Session.HOME_PANEL; + mCurrentPanelSessionSuffix = panelId; + Telemetry.startUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix); + } + + /** + * Stop the current panel telemetry session if one exists. + */ + private void stopCurrentPanelTelemetrySession() { + if (mCurrentPanelSession != null) { + Telemetry.stopUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix); + mCurrentPanelSession = null; + mCurrentPanelSessionSuffix = null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java new file mode 100644 index 000000000..bfd6c5624 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java @@ -0,0 +1,368 @@ +/* -*- 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.home; + +import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.db.HomeProvider; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.PanelInfoManager.PanelInfo; +import org.mozilla.gecko.home.PanelInfoManager.RequestCallback; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.ContentResolver; +import android.content.Context; +import android.os.Handler; +import android.util.Log; + +public class HomePanelsManager implements GeckoEventListener { + public static final String LOGTAG = "HomePanelsManager"; + + private static final HomePanelsManager sInstance = new HomePanelsManager(); + + private static final int INVALIDATION_DELAY_MSEC = 500; + private static final int PANEL_INFO_TIMEOUT_MSEC = 1000; + + private static final String EVENT_HOMEPANELS_INSTALL = "HomePanels:Install"; + private static final String EVENT_HOMEPANELS_UNINSTALL = "HomePanels:Uninstall"; + private static final String EVENT_HOMEPANELS_UPDATE = "HomePanels:Update"; + private static final String EVENT_HOMEPANELS_REFRESH = "HomePanels:RefreshDataset"; + + private static final String JSON_KEY_PANEL = "panel"; + private static final String JSON_KEY_PANEL_ID = "id"; + + private enum ChangeType { + UNINSTALL, + INSTALL, + UPDATE, + REFRESH + } + + private enum InvalidationMode { + DELAYED, + IMMEDIATE + } + + private static class ConfigChange { + private final ChangeType type; + private final Object target; + + public ConfigChange(ChangeType type) { + this(type, null); + } + + public ConfigChange(ChangeType type, Object target) { + this.type = type; + this.target = target; + } + } + + private Context mContext; + private HomeConfig mHomeConfig; + private boolean mInitialized; + + private final Queue<ConfigChange> mPendingChanges = new ConcurrentLinkedQueue<ConfigChange>(); + private final Runnable mInvalidationRunnable = new InvalidationRunnable(); + + public static HomePanelsManager getInstance() { + return sInstance; + } + + public void init(Context context) { + if (mInitialized) { + return; + } + + mContext = context; + mHomeConfig = HomeConfig.getDefault(context); + + EventDispatcher.getInstance().registerGeckoThreadListener(this, + EVENT_HOMEPANELS_INSTALL, + EVENT_HOMEPANELS_UNINSTALL, + EVENT_HOMEPANELS_UPDATE, + EVENT_HOMEPANELS_REFRESH); + + mInitialized = true; + } + + public void onLocaleReady(final String locale) { + ThreadUtils.getBackgroundHandler().post(new Runnable() { + @Override + public void run() { + final String configLocale = mHomeConfig.getLocale(); + if (configLocale == null || !configLocale.equals(locale)) { + handleLocaleChange(); + } + } + }); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals(EVENT_HOMEPANELS_INSTALL)) { + Log.d(LOGTAG, EVENT_HOMEPANELS_INSTALL); + handlePanelInstall(createPanelConfigFromMessage(message), InvalidationMode.DELAYED); + } else if (event.equals(EVENT_HOMEPANELS_UNINSTALL)) { + Log.d(LOGTAG, EVENT_HOMEPANELS_UNINSTALL); + final String panelId = message.getString(JSON_KEY_PANEL_ID); + handlePanelUninstall(panelId); + } else if (event.equals(EVENT_HOMEPANELS_UPDATE)) { + Log.d(LOGTAG, EVENT_HOMEPANELS_UPDATE); + handlePanelUpdate(createPanelConfigFromMessage(message)); + } else if (event.equals(EVENT_HOMEPANELS_REFRESH)) { + Log.d(LOGTAG, EVENT_HOMEPANELS_REFRESH); + handleDatasetRefresh(message); + } + } catch (Exception e) { + Log.e(LOGTAG, "Failed to handle event " + event, e); + } + } + + private PanelConfig createPanelConfigFromMessage(JSONObject message) throws JSONException { + final JSONObject json = message.getJSONObject(JSON_KEY_PANEL); + return new PanelConfig(json); + } + + /** + * Adds a new PanelConfig to the HomeConfig. + * + * This posts the invalidation of HomeConfig immediately. + * + * @param panelConfig panel to add + */ + public void installPanel(PanelConfig panelConfig) { + Log.d(LOGTAG, "installPanel: " + panelConfig.getTitle()); + handlePanelInstall(panelConfig, InvalidationMode.IMMEDIATE); + } + + /** + * Runs in the gecko thread. + */ + private void handlePanelInstall(PanelConfig panelConfig, InvalidationMode mode) { + mPendingChanges.offer(new ConfigChange(ChangeType.INSTALL, panelConfig)); + Log.d(LOGTAG, "handlePanelInstall: " + mPendingChanges.size()); + + scheduleInvalidation(mode); + } + + /** + * Runs in the gecko thread. + */ + private void handlePanelUninstall(String panelId) { + mPendingChanges.offer(new ConfigChange(ChangeType.UNINSTALL, panelId)); + Log.d(LOGTAG, "handlePanelUninstall: " + mPendingChanges.size()); + + scheduleInvalidation(InvalidationMode.DELAYED); + } + + /** + * Runs in the gecko thread. + */ + private void handlePanelUpdate(PanelConfig panelConfig) { + mPendingChanges.offer(new ConfigChange(ChangeType.UPDATE, panelConfig)); + Log.d(LOGTAG, "handlePanelUpdate: " + mPendingChanges.size()); + + scheduleInvalidation(InvalidationMode.DELAYED); + } + + /** + * Runs in the background thread. + */ + private void handleLocaleChange() { + mPendingChanges.offer(new ConfigChange(ChangeType.REFRESH)); + Log.d(LOGTAG, "handleLocaleChange: " + mPendingChanges.size()); + + scheduleInvalidation(InvalidationMode.IMMEDIATE); + } + + + /** + * Handles a dataset refresh request from Gecko. This is usually + * triggered by a HomeStorage.save() call in an add-on. + * + * Runs in the gecko thread. + */ + private void handleDatasetRefresh(JSONObject message) { + final String datasetId; + try { + datasetId = message.getString("datasetId"); + } catch (JSONException e) { + Log.e(LOGTAG, "Failed to handle dataset refresh", e); + return; + } + + Log.d(LOGTAG, "Refresh request for dataset: " + datasetId); + + final ContentResolver cr = mContext.getContentResolver(); + cr.notifyChange(HomeProvider.getDatasetNotificationUri(datasetId), null); + } + + /** + * Runs in the gecko or main thread. + */ + private void scheduleInvalidation(InvalidationMode mode) { + final Handler handler = ThreadUtils.getBackgroundHandler(); + + handler.removeCallbacks(mInvalidationRunnable); + + if (mode == InvalidationMode.IMMEDIATE) { + handler.post(mInvalidationRunnable); + } else { + handler.postDelayed(mInvalidationRunnable, INVALIDATION_DELAY_MSEC); + } + + Log.d(LOGTAG, "scheduleInvalidation: scheduled new invalidation: " + mode); + } + + /** + * Runs in the background thread. + */ + private void executePendingChanges(HomeConfig.Editor editor) { + boolean shouldRefresh = false; + + while (!mPendingChanges.isEmpty()) { + final ConfigChange pendingChange = mPendingChanges.poll(); + + switch (pendingChange.type) { + case UNINSTALL: { + final String panelId = (String) pendingChange.target; + if (editor.uninstall(panelId)) { + Log.d(LOGTAG, "executePendingChanges: uninstalled panel " + panelId); + } + break; + } + + case INSTALL: { + final PanelConfig panelConfig = (PanelConfig) pendingChange.target; + if (editor.install(panelConfig)) { + Log.d(LOGTAG, "executePendingChanges: added panel " + panelConfig.getId()); + } + break; + } + + case UPDATE: { + final PanelConfig panelConfig = (PanelConfig) pendingChange.target; + if (editor.update(panelConfig)) { + Log.w(LOGTAG, "executePendingChanges: updated panel " + panelConfig.getId()); + } + break; + } + + case REFRESH: { + shouldRefresh = true; + } + } + } + + // The editor still represents the default HomeConfig + // configuration and hasn't been changed by any operation + // above. No need to refresh as the HomeConfig backend will + // take of forcing all existing HomeConfigLoader instances to + // refresh their contents. + if (shouldRefresh && !editor.isDefault()) { + executeRefresh(editor); + } + } + + /** + * Runs in the background thread. + */ + private void refreshFromPanelInfos(HomeConfig.Editor editor, List<PanelInfo> panelInfos) { + Log.d(LOGTAG, "refreshFromPanelInfos"); + + for (PanelConfig panelConfig : editor) { + PanelConfig refreshedPanelConfig = null; + + if (panelConfig.isDynamic()) { + for (PanelInfo panelInfo : panelInfos) { + if (panelInfo.getId().equals(panelConfig.getId())) { + refreshedPanelConfig = panelInfo.toPanelConfig(); + Log.d(LOGTAG, "refreshFromPanelInfos: refreshing from panel info: " + panelInfo.getId()); + break; + } + } + } else { + refreshedPanelConfig = createBuiltinPanelConfig(mContext, panelConfig.getType()); + Log.d(LOGTAG, "refreshFromPanelInfos: refreshing built-in panel: " + panelConfig.getId()); + } + + if (refreshedPanelConfig == null) { + Log.d(LOGTAG, "refreshFromPanelInfos: no refreshed panel, falling back: " + panelConfig.getId()); + continue; + } + + Log.d(LOGTAG, "refreshFromPanelInfos: refreshed panel " + refreshedPanelConfig.getId()); + editor.update(refreshedPanelConfig); + } + } + + /** + * Runs in the background thread. + */ + private void executeRefresh(HomeConfig.Editor editor) { + if (editor.isEmpty()) { + return; + } + + Log.d(LOGTAG, "executeRefresh"); + + final Set<String> ids = new HashSet<String>(); + for (PanelConfig panelConfig : editor) { + ids.add(panelConfig.getId()); + } + + final Object panelRequestLock = new Object(); + final List<PanelInfo> latestPanelInfos = new ArrayList<PanelInfo>(); + + final PanelInfoManager pm = new PanelInfoManager(); + pm.requestPanelsById(ids, new RequestCallback() { + @Override + public void onComplete(List<PanelInfo> panelInfos) { + synchronized (panelRequestLock) { + latestPanelInfos.addAll(panelInfos); + Log.d(LOGTAG, "executeRefresh: fetched panel infos: " + panelInfos.size()); + + panelRequestLock.notifyAll(); + } + } + }); + + try { + synchronized (panelRequestLock) { + panelRequestLock.wait(PANEL_INFO_TIMEOUT_MSEC); + + Log.d(LOGTAG, "executeRefresh: done fetching panel infos"); + refreshFromPanelInfos(editor, latestPanelInfos); + } + } catch (InterruptedException e) { + Log.e(LOGTAG, "Failed to fetch panels from gecko", e); + } + } + + /** + * Runs in the background thread. + */ + private class InvalidationRunnable implements Runnable { + @Override + public void run() { + final HomeConfig.Editor editor = mHomeConfig.load().edit(); + executePendingChanges(editor); + editor.apply(); + } + }; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java new file mode 100644 index 000000000..1525969a0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java @@ -0,0 +1,57 @@ +package org.mozilla.gecko.home; + +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.LoaderManager; +import android.view.View; + +import org.mozilla.gecko.animation.PropertyAnimator; + +/** + * Generic interface for any View that can be used as the homescreen. + * + * In the past we had the HomePager, which contained the usual homepanels (multiple panels: TopSites, + * bookmarks, history, etc.), which could be swiped between. + * + * This interface allows easily switching between different homepanel implementations. For example + * the prototype activity-stream panel (which will be a single panel combining the functionality + * of the previous panels). + */ +public interface HomeScreen { + /** + * Interface for listening into ViewPager panel changes + */ + public interface OnPanelChangeListener { + /** + * Called when a new panel is selected. + * + * @param panelId of the newly selected panel + */ + public void onPanelSelected(String panelId); + } + + // The following two methods are actually methods of View. Since there is no View interface + // we're forced to do this instead of "extending" View. Any class implementing HomeScreen + // will have to implement these and pass them through to the underlying View. + boolean isVisible(); + boolean requestFocus(); + + void onToolbarFocusChange(boolean hasFocus); + + // The following three methods are HomePager specific. The persistence framework might need + // refactoring/generalising at some point, but it isn't entirely clear what other panels + // might need so we can leave these as is for now. + void showPanel(String panelId, Bundle restoreData); + void setOnPanelChangeListener(OnPanelChangeListener listener); + void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener); + + /** + * Set a banner that may be displayed at the bottom of the HomeScreen. This can be used + * e.g. to show snippets. + */ + void setBanner(HomeBanner banner); + + void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator); + + void unload(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java new file mode 100644 index 000000000..2bbd82a8d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java @@ -0,0 +1,164 @@ +/* -*- 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.home; + +import android.content.Context; +import android.net.Uri; +import android.util.DisplayMetrics; +import android.util.Log; + +import com.squareup.picasso.LruCache; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Downloader.Response; +import com.squareup.picasso.UrlConnectionDownloader; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.EnumSet; +import java.util.Set; + +import org.mozilla.gecko.distribution.Distribution; + +public class ImageLoader { + private static final String LOGTAG = "GeckoImageLoader"; + + private static final String DISTRIBUTION_SCHEME = "gecko.distribution"; + private static final String SUGGESTED_SITES_AUTHORITY = "suggestedsites"; + + // The order of density factors to try when looking for an image resource + // in the distribution directory. It looks for an exact match first (1.0) then + // tries to find images with higher density (2.0 and 1.5). If no image is found, + // try a lower density (0.5). See loadDistributionImage(). + private static final float[] densityFactors = new float[] { 1.0f, 2.0f, 1.5f, 0.5f }; + + private static enum Density { + MDPI, + HDPI, + XHDPI, + XXHDPI; + + @Override + public String toString() { + return super.toString().toLowerCase(); + } + } + + // Picasso instance and LruCache lrucache are protected by synchronization. + private static Picasso instance; + private static LruCache lrucache; + + public static synchronized Picasso with(Context context) { + if (instance == null) { + lrucache = new LruCache(context); + Picasso.Builder builder = new Picasso.Builder(context).memoryCache(lrucache); + + final Distribution distribution = Distribution.getInstance(context.getApplicationContext()); + builder.downloader(new ImageDownloader(context, distribution)); + instance = builder.build(); + } + + return instance; + } + + public static synchronized void clearLruCache() { + if (lrucache != null) { + lrucache.evictAll(); + } + } + + /** + * Custom Downloader built on top of Picasso's UrlConnectionDownloader + * that supports loading images from custom URIs. + */ + public static class ImageDownloader extends UrlConnectionDownloader { + private final Context context; + private final Distribution distribution; + + public ImageDownloader(Context context, Distribution distribution) { + super(context); + this.context = context; + this.distribution = distribution; + } + + private Density getDensity(float factor) { + final DisplayMetrics dm = context.getResources().getDisplayMetrics(); + final float densityDpi = dm.densityDpi * factor; + + if (densityDpi >= DisplayMetrics.DENSITY_XXHIGH) { + return Density.XXHDPI; + } else if (densityDpi >= DisplayMetrics.DENSITY_XHIGH) { + return Density.XHDPI; + } else if (densityDpi >= DisplayMetrics.DENSITY_HIGH) { + return Density.HDPI; + } + + // Fallback to mdpi, no need to handle ldpi. + return Density.MDPI; + } + + @Override + public Response load(Uri uri, boolean localCacheOnly) throws IOException { + final String scheme = uri.getScheme(); + if (DISTRIBUTION_SCHEME.equals(scheme)) { + return loadDistributionImage(uri); + } + + return super.load(uri, localCacheOnly); + } + + private static String getPathForDensity(String basePath, Density density, + String filename) { + final File dir = new File(basePath, density.toString()); + return String.format("%s/%s.png", dir.toString(), filename); + } + + /** + * Handle distribution URIs in Picasso. The expected format is: + * + * gecko.distribution://<basepath>/<imagename> + * + * Which will look for the following file in the distribution: + * + * <distribution-root-dir>/<basepath>/<device-density>/<imagename>.png + */ + private Response loadDistributionImage(Uri uri) throws IOException { + // Eliminate the leading '//' + final String ssp = uri.getSchemeSpecificPart().substring(2); + + final String filename; + final String basePath; + + final int slashIndex = ssp.lastIndexOf('/'); + if (slashIndex == -1) { + filename = ssp; + basePath = ""; + } else { + filename = ssp.substring(slashIndex + 1); + basePath = ssp.substring(0, slashIndex); + } + + Set<Density> triedDensities = EnumSet.noneOf(Density.class); + + for (int i = 0; i < densityFactors.length; i++) { + final Density density = getDensity(densityFactors[i]); + if (!triedDensities.add(density)) { + continue; + } + + final String path = getPathForDensity(basePath, density, filename); + Log.d(LOGTAG, "Trying to load image from distribution " + path); + + final File f = distribution.getDistributionFile(path); + if (f != null) { + return new Response(new FileInputStream(f), true); + } + } + + throw new ResponseException("Couldn't find suggested site image in distribution"); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java new file mode 100644 index 000000000..26edf13ff --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java @@ -0,0 +1,100 @@ +/* -*- 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.home; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.widget.CursorAdapter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +/** + * MultiTypeCursorAdapter wraps a cursor and any meta data associated with it. + * A set of view types (corresponding to the cursor and its meta data) + * are mapped to a set of layouts. + */ +abstract class MultiTypeCursorAdapter extends CursorAdapter { + private final int[] mViewTypes; + private final int[] mLayouts; + + // Bind the view for the given position. + abstract public void bindView(View view, Context context, int position); + + public MultiTypeCursorAdapter(Context context, Cursor cursor, int[] viewTypes, int[] layouts) { + super(context, cursor, 0); + + if (viewTypes.length != layouts.length) { + throw new IllegalStateException("The view types and the layouts should be of same size"); + } + + mViewTypes = viewTypes; + mLayouts = layouts; + } + + @Override + public final int getViewTypeCount() { + return mViewTypes.length; + } + + /** + * @return Cursor for the given position. + */ + public final Cursor getCursor(int position) { + final Cursor cursor = getCursor(); + if (cursor == null || !cursor.moveToPosition(position)) { + throw new IllegalStateException("Couldn't move cursor to position " + position); + } + + return cursor; + } + + @Override + public final View getView(int position, View convertView, ViewGroup parent) { + final Context context = parent.getContext(); + if (convertView == null) { + convertView = newView(context, position, parent); + } + + bindView(convertView, context, position); + return convertView; + } + + @Override + public final void bindView(View view, Context context, Cursor cursor) { + // Do nothing. + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public final View newView(Context context, Cursor cursor, ViewGroup parent) { + return null; + } + + /** + * Inflate a new view from a set of view types and layouts based on the position. + * + * @param context Context for inflating the view. + * @param position Position of the view. + * @param parent Parent view group that will hold this view. + */ + private View newView(Context context, int position, ViewGroup parent) { + final int type = getItemViewType(position); + final int count = mViewTypes.length; + + for (int i = 0; i < count; i++) { + if (mViewTypes[i] == type) { + return LayoutInflater.from(context).inflate(mLayouts[i], parent, false); + } + } + + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java new file mode 100644 index 000000000..d66919344 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java @@ -0,0 +1,82 @@ +/* -*- 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.home; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.util.Log; + +import org.mozilla.gecko.GeckoSharedPrefs; + +/** + * Cache used to store authentication state of dynamic panels. The values + * in this cache are set in JS through the Home.panels API. + * + * {@code DynamicPanel} uses this cache to determine whether or not to + * show authentication UI for dynamic panels, including listening for + * changes in authentication state. + */ +class PanelAuthCache { + private static final String LOGTAG = "GeckoPanelAuthCache"; + + // Keep this in sync with the constant defined in Home.jsm + private static final String PREFS_PANEL_AUTH_PREFIX = "home_panels_auth_"; + + private final Context mContext; + private SharedPrefsListener mSharedPrefsListener; + private OnChangeListener mChangeListener; + + public interface OnChangeListener { + public void onChange(String panelId, boolean isAuthenticated); + } + + public PanelAuthCache(Context context) { + mContext = context; + } + + private SharedPreferences getSharedPreferences() { + return GeckoSharedPrefs.forProfile(mContext); + } + + private String getPanelAuthKey(String panelId) { + return PREFS_PANEL_AUTH_PREFIX + panelId; + } + + public boolean isAuthenticated(String panelId) { + final SharedPreferences prefs = getSharedPreferences(); + return prefs.getBoolean(getPanelAuthKey(panelId), false); + } + + public void setOnChangeListener(OnChangeListener listener) { + final SharedPreferences prefs = getSharedPreferences(); + + if (mChangeListener != null) { + prefs.unregisterOnSharedPreferenceChangeListener(mSharedPrefsListener); + mSharedPrefsListener = null; + } + + mChangeListener = listener; + + if (mChangeListener != null) { + mSharedPrefsListener = new SharedPrefsListener(); + prefs.registerOnSharedPreferenceChangeListener(mSharedPrefsListener); + } + } + + private class SharedPrefsListener implements OnSharedPreferenceChangeListener { + @Override + public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { + if (key.startsWith(PREFS_PANEL_AUTH_PREFIX)) { + final String panelId = key.substring(PREFS_PANEL_AUTH_PREFIX.length()); + final boolean isAuthenticated = prefs.getBoolean(key, false); + + Log.d(LOGTAG, "Auth state changed: panelId=" + panelId + ", isAuthenticated=" + isAuthenticated); + mChangeListener.onChange(panelId, isAuthenticated); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java new file mode 100644 index 000000000..1ad91b7ca --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java @@ -0,0 +1,63 @@ +/* -*- 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.home; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.HomeConfig.AuthConfig; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.squareup.picasso.Picasso; + +class PanelAuthLayout extends LinearLayout { + + public PanelAuthLayout(Context context, PanelConfig panelConfig) { + super(context); + + final AuthConfig authConfig = panelConfig.getAuthConfig(); + if (authConfig == null) { + throw new IllegalStateException("Can't create PanelAuthLayout without a valid AuthConfig"); + } + + setOrientation(LinearLayout.VERTICAL); + LayoutInflater.from(context).inflate(R.layout.panel_auth_layout, this); + + final TextView messageView = (TextView) findViewById(R.id.message); + messageView.setText(authConfig.getMessageText()); + + final Button buttonView = (Button) findViewById(R.id.button); + buttonView.setText(authConfig.getButtonText()); + + final String panelId = panelConfig.getId(); + buttonView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + GeckoAppShell.notifyObservers("HomePanels:Authenticate", panelId); + } + }); + + final ImageView imageView = (ImageView) findViewById(R.id.image); + final String imageUrl = authConfig.getImageUrl(); + + if (TextUtils.isEmpty(imageUrl)) { + // Use a default image if an image URL isn't specified. + imageView.setImageResource(R.drawable.icon_home_empty_firefox); + } else { + ImageLoader.with(getContext()) + .load(imageUrl) + .into(imageView); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java new file mode 100644 index 000000000..4772e08ab --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java @@ -0,0 +1,48 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.PanelLayout.FilterDetail; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.squareup.picasso.Picasso; + +class PanelBackItemView extends LinearLayout { + private final TextView title; + + public PanelBackItemView(Context context, String backImageUrl) { + super(context); + + LayoutInflater.from(context).inflate(R.layout.panel_back_item, this); + setOrientation(HORIZONTAL); + + title = (TextView) findViewById(R.id.title); + + final ImageView image = (ImageView) findViewById(R.id.image); + + if (TextUtils.isEmpty(backImageUrl)) { + image.setImageResource(R.drawable.arrow_up); + } else { + ImageLoader.with(getContext()) + .load(backImageUrl) + .placeholder(R.drawable.arrow_up) + .into(image); + } + } + + public void updateFromFilter(FilterDetail filter) { + final String backText = getResources() + .getString(R.string.home_move_back_to_filter, filter.title); + title.setText(backText); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java new file mode 100644 index 000000000..50c4dbc07 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java @@ -0,0 +1,28 @@ +package org.mozilla.gecko.home; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.widget.ImageView; + +@SuppressLint("ViewConstructor") // View is only created from code +public class PanelHeaderView extends ImageView { + public PanelHeaderView(Context context, HomeConfig.HeaderConfig config) { + super(context); + + setAdjustViewBounds(true); + + ImageLoader.with(context) + .load(config.getImageUrl()) + .into(this); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int width = MeasureSpec.getSize(widthMeasureSpec); + + // Always span the whole width and adjust height as needed. + widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java new file mode 100644 index 000000000..089e17837 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java @@ -0,0 +1,162 @@ +/* -*- 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.home; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import android.util.Log; +import android.util.SparseArray; + +public class PanelInfoManager implements GeckoEventListener { + private static final String LOGTAG = "GeckoPanelInfoManager"; + + public class PanelInfo { + private final String mId; + private final String mTitle; + private final JSONObject mJSONData; + + public PanelInfo(String id, String title, JSONObject jsonData) { + mId = id; + mTitle = title; + mJSONData = jsonData; + } + + public String getId() { + return mId; + } + + public String getTitle() { + return mTitle; + } + + public PanelConfig toPanelConfig() { + try { + return new PanelConfig(mJSONData); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to convert PanelInfo to PanelConfig", e); + return null; + } + } + } + + public interface RequestCallback { + public void onComplete(List<PanelInfo> panelInfos); + } + + private static final AtomicInteger sRequestId = new AtomicInteger(0); + + // Stores set of pending request callbacks. + private static final SparseArray<RequestCallback> sCallbacks = new SparseArray<RequestCallback>(); + + /** + * Asynchronously fetches list of available panels from Gecko + * for the given IDs. + * + * @param ids list of panel ids to be fetched. A null value will fetch all + * available panels. + * @param callback onComplete will be called on the UI thread. + */ + public void requestPanelsById(Set<String> ids, RequestCallback callback) { + final int requestId = sRequestId.getAndIncrement(); + + synchronized (sCallbacks) { + // If there are no pending callbacks, register the event listener. + if (sCallbacks.size() == 0) { + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "HomePanels:Data"); + } + sCallbacks.put(requestId, callback); + } + + final JSONObject message = new JSONObject(); + try { + message.put("requestId", requestId); + + if (ids != null && ids.size() > 0) { + JSONArray idsArray = new JSONArray(); + for (String id : ids) { + idsArray.put(id); + } + + message.put("ids", idsArray); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Failed to build event to request panels by id", e); + return; + } + + GeckoAppShell.notifyObservers("HomePanels:Get", message.toString()); + } + + /** + * Asynchronously fetches list of available panels from Gecko. + * + * @param callback onComplete will be called on the UI thread. + */ + public void requestAvailablePanels(RequestCallback callback) { + requestPanelsById(null, callback); + } + + /** + * Handles "HomePanels:Data" events. + */ + @Override + public void handleMessage(String event, JSONObject message) { + final ArrayList<PanelInfo> panelInfos = new ArrayList<PanelInfo>(); + + try { + final JSONArray panels = message.getJSONArray("panels"); + final int count = panels.length(); + for (int i = 0; i < count; i++) { + final PanelInfo panelInfo = getPanelInfoFromJSON(panels.getJSONObject(i)); + panelInfos.add(panelInfo); + } + + final RequestCallback callback; + final int requestId = message.getInt("requestId"); + + synchronized (sCallbacks) { + callback = sCallbacks.get(requestId); + sCallbacks.delete(requestId); + + // Unregister the event listener if there are no more pending callbacks. + if (sCallbacks.size() == 0) { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "HomePanels:Data"); + } + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + callback.onComplete(panelInfos); + } + }); + } catch (JSONException e) { + Log.e(LOGTAG, "Exception handling " + event + " message", e); + } + } + + private PanelInfo getPanelInfoFromJSON(JSONObject jsonPanelInfo) throws JSONException { + final String id = jsonPanelInfo.getString("id"); + final String title = jsonPanelInfo.getString("title"); + + return new PanelInfo(id, title, jsonPanelInfo); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java new file mode 100644 index 000000000..2a97d42bc --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java @@ -0,0 +1,136 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.home.HomeConfig.ItemType; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Color; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +class PanelItemView extends LinearLayout { + private final TextView titleView; + private final TextView descriptionView; + private final ImageView imageView; + private final LinearLayout titleDescContainerView; + private final ImageView backgroundView; + + private PanelItemView(Context context, int layoutId) { + super(context); + + LayoutInflater.from(context).inflate(layoutId, this); + titleView = (TextView) findViewById(R.id.title); + descriptionView = (TextView) findViewById(R.id.description); + imageView = (ImageView) findViewById(R.id.image); + backgroundView = (ImageView) findViewById(R.id.background); + titleDescContainerView = (LinearLayout) findViewById(R.id.title_desc_container); + } + + public void updateFromCursor(Cursor cursor) { + int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE); + final String titleText = cursor.getString(titleIndex); + + // Only show title if the item has one + final boolean hasTitle = !TextUtils.isEmpty(titleText); + titleView.setVisibility(hasTitle ? View.VISIBLE : View.GONE); + if (hasTitle) { + titleView.setText(titleText); + } + + int descriptionIndex = cursor.getColumnIndexOrThrow(HomeItems.DESCRIPTION); + final String descriptionText = cursor.getString(descriptionIndex); + + // Only show description if the item has one + // Descriptions are not supported for IconItemView objects (Bug 1157539) + final boolean hasDescription = !TextUtils.isEmpty(descriptionText); + if (descriptionView != null) { + descriptionView.setVisibility(hasDescription ? View.VISIBLE : View.GONE); + if (hasDescription) { + descriptionView.setText(descriptionText); + } + } + if (titleDescContainerView != null) { + titleDescContainerView.setVisibility(hasTitle || hasDescription ? View.VISIBLE : View.GONE); + } + + int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL); + final String imageUrl = cursor.getString(imageIndex); + + // Only try to load the image if the item has define image URL + final boolean hasImageUrl = !TextUtils.isEmpty(imageUrl); + imageView.setVisibility(hasImageUrl ? View.VISIBLE : View.GONE); + + if (hasImageUrl) { + ImageLoader.with(getContext()) + .load(imageUrl) + .into(imageView); + } + + final int columnIndexBackgroundColor = cursor.getColumnIndex(HomeItems.BACKGROUND_COLOR); + if (columnIndexBackgroundColor != -1) { + final String color = cursor.getString(columnIndexBackgroundColor); + if (!TextUtils.isEmpty(color)) { + setBackgroundColor(Color.parseColor(color)); + } + } + + // Backgrounds are only supported for IconItemView objects (Bug 1157539) + final int columnIndexBackgroundUrl = cursor.getColumnIndex(HomeItems.BACKGROUND_URL); + if (columnIndexBackgroundUrl != -1) { + final String backgroundUrl = cursor.getString(columnIndexBackgroundUrl); + if (backgroundView != null && !TextUtils.isEmpty(backgroundUrl)) { + ImageLoader.with(getContext()) + .load(backgroundUrl) + .fit() + .into(backgroundView); + } + } + } + + private static class ArticleItemView extends PanelItemView { + private ArticleItemView(Context context) { + super(context, R.layout.panel_article_item); + setOrientation(LinearLayout.HORIZONTAL); + } + } + + private static class ImageItemView extends PanelItemView { + private ImageItemView(Context context) { + super(context, R.layout.panel_image_item); + setOrientation(LinearLayout.VERTICAL); + } + } + + private static class IconItemView extends PanelItemView { + private IconItemView(Context context) { + super(context, R.layout.panel_icon_item); + } + } + + public static PanelItemView create(Context context, ItemType itemType) { + switch (itemType) { + case ARTICLE: + return new ArticleItemView(context); + + case IMAGE: + return new ImageItemView(context); + + case ICON: + return new IconItemView(context); + + default: + throw new IllegalArgumentException("Could not create panel item view from " + itemType); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java new file mode 100644 index 000000000..2c2d89ae0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java @@ -0,0 +1,747 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.HomeConfig.EmptyViewConfig; +import org.mozilla.gecko.home.HomeConfig.ItemHandler; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.HomeConfig.ViewConfig; +import org.mozilla.gecko.util.StringUtils; + +import android.content.Context; +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.lang.ref.SoftReference; +import java.util.EnumSet; +import java.util.LinkedList; +import java.util.Map; +import java.util.WeakHashMap; + +import com.squareup.picasso.Picasso; + +/** + * {@code PanelLayout} is the base class for custom layouts to be + * used in {@code DynamicPanel}. It provides the basic framework + * that enables custom layouts to request and reset datasets and + * create panel views. Furthermore, it automates most of the process + * of binding panel views with their respective datasets. + * + * {@code PanelLayout} abstracts the implemention details of how + * datasets are actually loaded through the {@DatasetHandler} interface. + * {@code DatasetHandler} provides two operations: request and reset. + * The results of the dataset requests done via the {@code DatasetHandler} + * are delivered to the {@code PanelLayout} with the {@code deliverDataset()} + * method. + * + * Subclasses of {@code PanelLayout} should simply use the utilities + * provided by {@code PanelLayout}. Namely: + * + * {@code requestDataset()} - To fetch datasets and auto-bind them to + * the existing panel views backed by them. + * + * {@code resetDataset()} - To release any resources associated with a + * previously loaded dataset. + * + * {@code createPanelView()} - To create a panel view for a ViewConfig + * associated with the panel. + * + * {@code disposePanelView()} - To dispose any dataset references associated + * with the given view. + * + * {@code PanelLayout} subclasses should always use {@code createPanelView()} + * to create the views dynamically created based on {@code ViewConfig}. This + * allows {@code PanelLayout} to auto-bind datasets with panel views. + * {@code PanelLayout} subclasses are free to have any type of views to arrange + * the panel views in different ways. + */ +abstract class PanelLayout extends FrameLayout { + private static final String LOGTAG = "GeckoPanelLayout"; + + protected final SparseArray<ViewState> mViewStates; + private final PanelConfig mPanelConfig; + private final DatasetHandler mDatasetHandler; + private final OnUrlOpenListener mUrlOpenListener; + private final ContextMenuRegistry mContextMenuRegistry; + + /** + * To be used by panel views to express that they are + * backed by datasets. + */ + public interface DatasetBacked { + public void setDataset(Cursor cursor); + public void setFilterManager(FilterManager manager); + } + + /** + * To be used by requests made to {@code DatasetHandler}s to couple dataset ID with current + * filter for queries on the database. + */ + public static class DatasetRequest implements Parcelable { + public enum Type implements Parcelable { + DATASET_LOAD, + FILTER_PUSH, + FILTER_POP; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<Type> CREATOR = new Creator<Type>() { + @Override + public Type createFromParcel(final Parcel source) { + return Type.values()[source.readInt()]; + } + + @Override + public Type[] newArray(final int size) { + return new Type[size]; + } + }; + } + + private final int mViewIndex; + private final Type mType; + private final String mDatasetId; + private final FilterDetail mFilterDetail; + + private DatasetRequest(Parcel in) { + this.mViewIndex = in.readInt(); + this.mType = (Type) in.readParcelable(getClass().getClassLoader()); + this.mDatasetId = in.readString(); + this.mFilterDetail = (FilterDetail) in.readParcelable(getClass().getClassLoader()); + } + + public DatasetRequest(int index, String datasetId, FilterDetail filterDetail) { + this(index, Type.DATASET_LOAD, datasetId, filterDetail); + } + + public DatasetRequest(int index, Type type, String datasetId, FilterDetail filterDetail) { + this.mViewIndex = index; + this.mType = type; + this.mDatasetId = datasetId; + this.mFilterDetail = filterDetail; + } + + public int getViewIndex() { + return mViewIndex; + } + + public Type getType() { + return mType; + } + + public String getDatasetId() { + return mDatasetId; + } + + public String getFilter() { + return (mFilterDetail != null ? mFilterDetail.filter : null); + } + + public FilterDetail getFilterDetail() { + return mFilterDetail; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mViewIndex); + dest.writeParcelable(mType, 0); + dest.writeString(mDatasetId); + dest.writeParcelable(mFilterDetail, 0); + } + + public String toString() { + return "{ index: " + mViewIndex + + ", type: " + mType + + ", dataset: " + mDatasetId + + ", filter: " + mFilterDetail + + " }"; + } + + public static final Creator<DatasetRequest> CREATOR = new Creator<DatasetRequest>() { + @Override + public DatasetRequest createFromParcel(Parcel in) { + return new DatasetRequest(in); + } + + @Override + public DatasetRequest[] newArray(int size) { + return new DatasetRequest[size]; + } + }; + } + + /** + * Defines the contract with the component that is responsible + * for handling datasets requests. + */ + public interface DatasetHandler { + /** + * Requests a dataset to be fetched and auto-bound to the + * panel views backed by it. + */ + public void requestDataset(DatasetRequest request); + + /** + * Releases any resources associated with a panel view. It will + * do nothing if the view with the given index been created + * before. + */ + public void resetDataset(int viewIndex); + } + + public interface PanelView { + public void setOnItemOpenListener(OnItemOpenListener listener); + public void setOnKeyListener(OnKeyListener listener); + public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory); + } + + public interface FilterManager { + public FilterDetail getPreviousFilter(); + public boolean canGoBack(); + public void goBack(); + } + + public interface ContextMenuRegistry { + public void register(View view); + } + + public PanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler, + OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) { + super(context); + mViewStates = new SparseArray<ViewState>(); + mPanelConfig = panelConfig; + mDatasetHandler = datasetHandler; + mUrlOpenListener = urlOpenListener; + mContextMenuRegistry = contextMenuRegistry; + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + final int count = mViewStates.size(); + for (int i = 0; i < count; i++) { + final ViewState viewState = mViewStates.valueAt(i); + + final View view = viewState.getView(); + if (view != null) { + maybeSetDataset(view, null); + } + } + mViewStates.clear(); + } + + /** + * Delivers the dataset as a {@code Cursor} to be bound to the + * panel view backed by it. This is used by the {@code DatasetHandler} + * in response to a dataset request. + */ + public final void deliverDataset(DatasetRequest request, Cursor cursor) { + Log.d(LOGTAG, "Delivering request: " + request); + final ViewState viewState = mViewStates.get(request.getViewIndex()); + if (viewState == null) { + return; + } + + switch (request.getType()) { + case FILTER_PUSH: + viewState.pushFilter(request.getFilterDetail()); + break; + case FILTER_POP: + viewState.popFilter(); + break; + } + + final View activeView = viewState.getActiveView(); + if (activeView == null) { + throw new IllegalStateException("No active view for view state: " + viewState.getIndex()); + } + + final ViewConfig viewConfig = viewState.getViewConfig(); + + final View newView; + if (cursor == null || cursor.getCount() == 0) { + newView = createEmptyView(viewConfig); + maybeSetDataset(activeView, null); + } else { + newView = createPanelView(viewConfig); + maybeSetDataset(newView, cursor); + } + + if (activeView != newView) { + replacePanelView(activeView, newView); + } + } + + /** + * Releases any references to the given dataset from all + * existing panel views. + */ + public final void releaseDataset(int viewIndex) { + Log.d(LOGTAG, "Releasing dataset: " + viewIndex); + final ViewState viewState = mViewStates.get(viewIndex); + if (viewState == null) { + return; + } + + final View view = viewState.getView(); + if (view != null) { + maybeSetDataset(view, null); + } + } + + /** + * Requests a dataset to be loaded and bound to any existing + * panel view backed by it. + */ + protected final void requestDataset(DatasetRequest request) { + Log.d(LOGTAG, "Requesting request: " + request); + if (mViewStates.get(request.getViewIndex()) == null) { + return; + } + + mDatasetHandler.requestDataset(request); + } + + /** + * Releases any resources associated with a panel view. + * e.g. close any associated {@code Cursor}. + */ + protected final void resetDataset(int viewIndex) { + Log.d(LOGTAG, "Resetting view with index: " + viewIndex); + if (mViewStates.get(viewIndex) == null) { + return; + } + + mDatasetHandler.resetDataset(viewIndex); + } + + /** + * Factory method to create instance of panels from a given + * {@code ViewConfig}. All panel views defined in {@code PanelConfig} + * should be created using this method so that {@PanelLayout} can + * keep track of panel views and their associated datasets. + */ + protected final View createPanelView(ViewConfig viewConfig) { + Log.d(LOGTAG, "Creating panel view: " + viewConfig.getType()); + + ViewState viewState = mViewStates.get(viewConfig.getIndex()); + if (viewState == null) { + viewState = new ViewState(viewConfig); + mViewStates.put(viewConfig.getIndex(), viewState); + } + + View view = viewState.getView(); + if (view == null) { + switch (viewConfig.getType()) { + case LIST: + view = new PanelListView(getContext(), viewConfig); + break; + + case GRID: + view = new PanelRecyclerView(getContext(), viewConfig); + break; + + default: + throw new IllegalStateException("Unrecognized view type in " + getClass().getSimpleName()); + } + + PanelView panelView = (PanelView) view; + panelView.setOnItemOpenListener(new PanelOnItemOpenListener(viewState)); + panelView.setOnKeyListener(new PanelKeyListener(viewState)); + panelView.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { + @Override + public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { + final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); + info.url = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.TITLE)); + return info; + } + }); + + mContextMenuRegistry.register(view); + + if (view instanceof DatasetBacked) { + DatasetBacked datasetBacked = (DatasetBacked) view; + datasetBacked.setFilterManager(new PanelFilterManager(viewState)); + + if (viewConfig.isRefreshEnabled()) { + view = new PanelRefreshLayout(getContext(), view, + mPanelConfig.getId(), viewConfig.getIndex()); + } + } + + viewState.setView(view); + } + + return view; + } + + /** + * Dispose any dataset references associated with the + * given view. + */ + protected final void disposePanelView(View view) { + Log.d(LOGTAG, "Disposing panel view"); + final int count = mViewStates.size(); + for (int i = 0; i < count; i++) { + final ViewState viewState = mViewStates.valueAt(i); + + if (viewState.getView() == view) { + maybeSetDataset(view, null); + mViewStates.remove(viewState.getIndex()); + break; + } + } + } + + private void maybeSetDataset(View view, Cursor cursor) { + if (view instanceof DatasetBacked) { + final DatasetBacked dsb = (DatasetBacked) view; + dsb.setDataset(cursor); + } + } + + private View createEmptyView(ViewConfig viewConfig) { + Log.d(LOGTAG, "Creating empty view: " + viewConfig.getType()); + + ViewState viewState = mViewStates.get(viewConfig.getIndex()); + if (viewState == null) { + throw new IllegalStateException("No view state found for view index: " + viewConfig.getIndex()); + } + + View view = viewState.getEmptyView(); + if (view == null) { + view = LayoutInflater.from(getContext()).inflate(R.layout.home_empty_panel, null); + + final EmptyViewConfig emptyViewConfig = viewConfig.getEmptyViewConfig(); + + // XXX: Refactor this into a custom view (bug 985134) + final String text = (emptyViewConfig == null) ? null : emptyViewConfig.getText(); + final TextView textView = (TextView) view.findViewById(R.id.home_empty_text); + if (TextUtils.isEmpty(text)) { + textView.setText(R.string.home_default_empty); + } else { + textView.setText(text); + } + + final String imageUrl = (emptyViewConfig == null) ? null : emptyViewConfig.getImageUrl(); + final ImageView imageView = (ImageView) view.findViewById(R.id.home_empty_image); + + if (TextUtils.isEmpty(imageUrl)) { + imageView.setImageResource(R.drawable.icon_home_empty_firefox); + } else { + ImageLoader.with(getContext()) + .load(imageUrl) + .error(R.drawable.icon_home_empty_firefox) + .into(imageView); + } + + viewState.setEmptyView(view); + } + + return view; + } + + private void replacePanelView(View currentView, View newView) { + final ViewGroup parent = (ViewGroup) currentView.getParent(); + parent.addView(newView, parent.indexOfChild(currentView), currentView.getLayoutParams()); + parent.removeView(currentView); + } + + /** + * Must be implemented by {@code PanelLayout} subclasses to define + * what happens then the layout is first loaded. Should set initial + * UI state and request any necessary datasets. + */ + public abstract void load(); + + /** + * Represents a 'live' instance of a panel view associated with + * the {@code PanelLayout}. Is responsible for tracking the history stack of filters. + */ + protected class ViewState { + private final ViewConfig mViewConfig; + private SoftReference<View> mView; + private SoftReference<View> mEmptyView; + private LinkedList<FilterDetail> mFilterStack; + + public ViewState(ViewConfig viewConfig) { + mViewConfig = viewConfig; + mView = new SoftReference<View>(null); + mEmptyView = new SoftReference<View>(null); + } + + public ViewConfig getViewConfig() { + return mViewConfig; + } + + public int getIndex() { + return mViewConfig.getIndex(); + } + + public View getView() { + return mView.get(); + } + + public void setView(View view) { + mView = new SoftReference<View>(view); + } + + public View getEmptyView() { + return mEmptyView.get(); + } + + public void setEmptyView(View view) { + mEmptyView = new SoftReference<View>(view); + } + + public View getActiveView() { + final View view = getView(); + if (view != null && view.getParent() != null) { + return view; + } + + final View emptyView = getEmptyView(); + if (emptyView != null && emptyView.getParent() != null) { + return emptyView; + } + + return null; + } + + public String getDatasetId() { + return mViewConfig.getDatasetId(); + } + + public ItemHandler getItemHandler() { + return mViewConfig.getItemHandler(); + } + + /** + * Get the current filter that this view is displaying, or null if none. + */ + public FilterDetail getCurrentFilter() { + if (mFilterStack == null) { + return null; + } else { + return mFilterStack.peek(); + } + } + + /** + * Get the previous filter that this view was displaying, or null if none. + */ + public FilterDetail getPreviousFilter() { + if (!canPopFilter()) { + return null; + } + + return mFilterStack.get(1); + } + + /** + * Adds a filter to the history stack for this view. + */ + public void pushFilter(FilterDetail filter) { + if (mFilterStack == null) { + mFilterStack = new LinkedList<FilterDetail>(); + + // Initialize with the initial filter. + mFilterStack.push(new FilterDetail(mViewConfig.getFilter(), + mPanelConfig.getTitle())); + } + + mFilterStack.push(filter); + } + + /** + * Remove the most recent filter from the stack. + * + * @return whether the filter was popped + */ + public boolean popFilter() { + if (!canPopFilter()) { + return false; + } + + mFilterStack.pop(); + return true; + } + + public boolean canPopFilter() { + return (mFilterStack != null && mFilterStack.size() > 1); + } + } + + static class FilterDetail implements Parcelable { + final String filter; + final String title; + + private FilterDetail(Parcel in) { + this.filter = in.readString(); + this.title = in.readString(); + } + + public FilterDetail(String filter, String title) { + this.filter = filter; + this.title = title; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(filter); + dest.writeString(title); + } + + public static final Creator<FilterDetail> CREATOR = new Creator<FilterDetail>() { + @Override + public FilterDetail createFromParcel(Parcel in) { + return new FilterDetail(in); + } + + @Override + public FilterDetail[] newArray(int size) { + return new FilterDetail[size]; + } + }; + } + + /** + * Pushes filter to {@code ViewState}'s stack and makes request for new filter value. + */ + private void pushFilterOnView(ViewState viewState, FilterDetail filterDetail) { + final int index = viewState.getIndex(); + final String datasetId = viewState.getDatasetId(); + + mDatasetHandler.requestDataset(new DatasetRequest(index, + DatasetRequest.Type.FILTER_PUSH, + datasetId, + filterDetail)); + } + + /** + * Pops filter from {@code ViewState}'s stack and makes request for previous filter value. + * + * @return whether the filter has changed + */ + private boolean popFilterOnView(ViewState viewState) { + if (viewState.canPopFilter()) { + final int index = viewState.getIndex(); + final String datasetId = viewState.getDatasetId(); + final FilterDetail filterDetail = viewState.getPreviousFilter(); + + mDatasetHandler.requestDataset(new DatasetRequest(index, + DatasetRequest.Type.FILTER_POP, + datasetId, + filterDetail)); + + return true; + } else { + return false; + } + } + + public interface OnItemOpenListener { + public void onItemOpen(String url, String title); + } + + private class PanelOnItemOpenListener implements OnItemOpenListener { + private final ViewState mViewState; + + public PanelOnItemOpenListener(ViewState viewState) { + mViewState = viewState; + } + + @Override + public void onItemOpen(String url, String title) { + if (StringUtils.isFilterUrl(url)) { + FilterDetail filterDetail = new FilterDetail(StringUtils.getFilterFromUrl(url), title); + pushFilterOnView(mViewState, filterDetail); + } else { + EnumSet<OnUrlOpenListener.Flags> flags = EnumSet.noneOf(OnUrlOpenListener.Flags.class); + if (mViewState.getItemHandler() == ItemHandler.INTENT) { + flags.add(OnUrlOpenListener.Flags.OPEN_WITH_INTENT); + } + + mUrlOpenListener.onUrlOpen(url, flags); + } + } + } + + private class PanelKeyListener implements View.OnKeyListener { + private final ViewState mViewState; + + public PanelKeyListener(ViewState viewState) { + mViewState = viewState; + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + return popFilterOnView(mViewState); + } + + return false; + } + } + + private class PanelFilterManager implements FilterManager { + private final ViewState mViewState; + + public PanelFilterManager(ViewState viewState) { + mViewState = viewState; + } + + @Override + public FilterDetail getPreviousFilter() { + return mViewState.getPreviousFilter(); + } + + @Override + public boolean canGoBack() { + return mViewState.canPopFilter(); + } + + @Override + public void goBack() { + popFilterOnView(mViewState); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java new file mode 100644 index 000000000..505fb9b0d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java @@ -0,0 +1,83 @@ +/* -*- 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.home; + +import java.util.EnumSet; + +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.home.HomeConfig.ItemHandler; +import org.mozilla.gecko.home.HomeConfig.ViewConfig; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.PanelLayout.DatasetBacked; +import org.mozilla.gecko.home.PanelLayout.FilterManager; +import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener; +import org.mozilla.gecko.home.PanelLayout.PanelView; + +import android.content.Context; +import android.database.Cursor; +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; + +public class PanelListView extends HomeListView + implements DatasetBacked, PanelView { + + private static final String LOGTAG = "GeckoPanelListView"; + + private final ViewConfig viewConfig; + private final PanelViewAdapter adapter; + private final PanelViewItemHandler itemHandler; + private OnItemOpenListener itemOpenListener; + + public PanelListView(Context context, ViewConfig viewConfig) { + super(context); + + this.viewConfig = viewConfig; + itemHandler = new PanelViewItemHandler(); + + adapter = new PanelViewAdapter(context, viewConfig); + setAdapter(adapter); + + setOnItemClickListener(new PanelListItemClickListener()); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + itemHandler.setOnItemOpenListener(itemOpenListener); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + itemHandler.setOnItemOpenListener(null); + } + + @Override + public void setDataset(Cursor cursor) { + Log.d(LOGTAG, "Setting dataset: " + viewConfig.getDatasetId()); + adapter.swapCursor(cursor); + } + + @Override + public void setOnItemOpenListener(OnItemOpenListener listener) { + itemHandler.setOnItemOpenListener(listener); + itemOpenListener = listener; + } + + @Override + public void setFilterManager(FilterManager filterManager) { + adapter.setFilterManager(filterManager); + itemHandler.setFilterManager(filterManager); + } + + private class PanelListItemClickListener implements AdapterView.OnItemClickListener { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + itemHandler.openItemAtPosition(adapter.getCursor(), position); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java new file mode 100644 index 000000000..9145ab1e1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java @@ -0,0 +1,178 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.PanelLayout.DatasetBacked; +import org.mozilla.gecko.home.PanelLayout.PanelView; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; +import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemClickListener; +import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemLongClickListener; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +/** + * RecyclerView implementation for grid home panels. + */ +@SuppressLint("ViewConstructor") // View is only created from code +public class PanelRecyclerView extends RecyclerView + implements DatasetBacked, PanelView, OnItemClickListener, OnItemLongClickListener { + private final PanelRecyclerViewAdapter adapter; + private final GridLayoutManager layoutManager; + private final PanelViewItemHandler itemHandler; + private final float columnWidth; + private final boolean autoFit; + private final HomeConfig.ViewConfig viewConfig; + + private PanelLayout.OnItemOpenListener itemOpenListener; + private HomeContextMenuInfo contextMenuInfo; + private HomeContextMenuInfo.Factory contextMenuInfoFactory; + + public PanelRecyclerView(Context context, HomeConfig.ViewConfig viewConfig) { + super(context); + + this.viewConfig = viewConfig; + + final Resources resources = context.getResources(); + + int spanCount; + if (viewConfig.getItemType() == HomeConfig.ItemType.ICON) { + autoFit = false; + spanCount = getResources().getInteger(R.integer.panel_icon_grid_view_columns); + } else { + autoFit = true; + spanCount = 1; + } + + columnWidth = resources.getDimension(R.dimen.panel_grid_view_column_width); + layoutManager = new GridLayoutManager(context, spanCount); + adapter = new PanelRecyclerViewAdapter(context, viewConfig); + itemHandler = new PanelViewItemHandler(); + + layoutManager.setSpanSizeLookup(new PanelSpanSizeLookup()); + + setLayoutManager(layoutManager); + setAdapter(adapter); + + int horizontalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_horizontal_spacing); + int verticalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_vertical_spacing); + int outerSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_outer_spacing); + + addItemDecoration(new SpacingDecoration(horizontalSpacing, verticalSpacing)); + + setPadding(outerSpacing, outerSpacing, outerSpacing, outerSpacing); + setClipToPadding(false); + + RecyclerViewClickSupport.addTo(this) + .setOnItemClickListener(this) + .setOnItemLongClickListener(this); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + super.onMeasure(widthSpec, heightSpec); + + if (autoFit) { + // Adjust span based on space available (What GridView does when you say numColumns="auto_fit") + final int spanCount = (int) Math.max(1, getMeasuredWidth() / columnWidth); + layoutManager.setSpanCount(spanCount); + } + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + itemHandler.setOnItemOpenListener(itemOpenListener); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + itemHandler.setOnItemOpenListener(null); + } + + @Override + public void setDataset(Cursor cursor) { + adapter.swapCursor(cursor); + } + + @Override + public void setFilterManager(PanelLayout.FilterManager manager) { + adapter.setFilterManager(manager); + itemHandler.setFilterManager(manager); + } + + @Override + public void setOnItemOpenListener(PanelLayout.OnItemOpenListener listener) { + itemOpenListener = listener; + itemHandler.setOnItemOpenListener(listener); + } + + @Override + public HomeContextMenuInfo getContextMenuInfo() { + return contextMenuInfo; + } + + @Override + public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory) { + contextMenuInfoFactory = factory; + } + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + if (viewConfig.hasHeaderConfig()) { + if (position == 0) { + itemOpenListener.onItemOpen(viewConfig.getHeaderConfig().getUrl(), null); + return; + } + + position--; + } + + itemHandler.openItemAtPosition(adapter.getCursor(), position); + } + + @Override + public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) { + if (viewConfig.hasHeaderConfig()) { + if (position == 0) { + final HomeConfig.HeaderConfig headerConfig = viewConfig.getHeaderConfig(); + + final HomeContextMenuInfo info = new HomeContextMenuInfo(v, position, -1); + info.url = headerConfig.getUrl(); + info.title = headerConfig.getUrl(); + + contextMenuInfo = info; + return showContextMenuForChild(this); + } + + position--; + } + + Cursor cursor = adapter.getCursor(); + cursor.moveToPosition(position); + + contextMenuInfo = contextMenuInfoFactory.makeInfoForCursor(recyclerView, position, -1, cursor); + return showContextMenuForChild(this); + } + + private class PanelSpanSizeLookup extends GridLayoutManager.SpanSizeLookup { + @Override + public int getSpanSize(int position) { + if (position == 0 && viewConfig.hasHeaderConfig()) { + return layoutManager.getSpanCount(); + } + + return 1; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java new file mode 100644 index 000000000..fa632bccd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java @@ -0,0 +1,137 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +public class PanelRecyclerViewAdapter extends RecyclerView.Adapter<PanelRecyclerViewAdapter.PanelViewHolder> { + private static final int VIEW_TYPE_ITEM = 0; + private static final int VIEW_TYPE_BACK = 1; + private static final int VIEW_TYPE_HEADER = 2; + + public static class PanelViewHolder extends RecyclerView.ViewHolder { + public static PanelViewHolder create(View itemView) { + + // Wrap in a FrameLayout that will handle the highlight on touch + FrameLayout frameLayout = (FrameLayout) LayoutInflater.from(itemView.getContext()) + .inflate(R.layout.panel_item_container, null); + + frameLayout.addView(itemView, 0, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + return new PanelViewHolder(frameLayout); + } + + private PanelViewHolder(View itemView) { + super(itemView); + } + } + + private final Context context; + private final HomeConfig.ViewConfig viewConfig; + private PanelLayout.FilterManager filterManager; + private Cursor cursor; + + public PanelRecyclerViewAdapter(Context context, HomeConfig.ViewConfig viewConfig) { + this.context = context; + this.viewConfig = viewConfig; + } + + public void setFilterManager(PanelLayout.FilterManager filterManager) { + this.filterManager = filterManager; + } + + private boolean isShowingBack() { + return filterManager != null && filterManager.canGoBack(); + } + + public void swapCursor(Cursor cursor) { + this.cursor = cursor; + + notifyDataSetChanged(); + } + + public Cursor getCursor() { + return cursor; + } + + @Override + public int getItemViewType(int position) { + if (viewConfig.hasHeaderConfig() && position == 0) { + return VIEW_TYPE_HEADER; + } else if (isShowingBack() && position == getBackPosition()) { + return VIEW_TYPE_BACK; + } else { + return VIEW_TYPE_ITEM; + } + } + + @Override + public PanelViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + switch (viewType) { + case VIEW_TYPE_HEADER: + return PanelViewHolder.create(new PanelHeaderView(context, viewConfig.getHeaderConfig())); + case VIEW_TYPE_BACK: + return PanelViewHolder.create(new PanelBackItemView(context, viewConfig.getBackImageUrl())); + case VIEW_TYPE_ITEM: + return PanelViewHolder.create(PanelItemView.create(context, viewConfig.getItemType())); + default: + throw new IllegalArgumentException("Unknown view type: " + viewType); + } + } + + @Override + public void onBindViewHolder(PanelViewHolder panelViewHolder, int position) { + final View view = ((FrameLayout) panelViewHolder.itemView).getChildAt(0); + + if (viewConfig.hasHeaderConfig()) { + if (position == 0) { + // Nothing to do here, the header is static + return; + } + } + + if (isShowingBack()) { + if (position == getBackPosition()) { + final PanelBackItemView item = (PanelBackItemView) view; + item.updateFromFilter(filterManager.getPreviousFilter()); + return; + } + } + + int actualPosition = position + - (isShowingBack() ? 1 : 0) + - (viewConfig.hasHeaderConfig() ? 1 : 0); + + cursor.moveToPosition(actualPosition); + + final PanelItemView panelItemView = (PanelItemView) view; + panelItemView.updateFromCursor(cursor); + } + + private int getBackPosition() { + return viewConfig.hasHeaderConfig() ? 1 : 0; + } + + @Override + public int getItemCount() { + if (cursor == null) { + return 0; + } + + return cursor.getCount() + + (isShowingBack() ? 1 : 0) + + (viewConfig.hasHeaderConfig() ? 1 : 0); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java new file mode 100644 index 000000000..d43a97f31 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java @@ -0,0 +1,90 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.home.PanelLayout.DatasetBacked; +import org.mozilla.gecko.home.PanelLayout.FilterManager; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.widget.SwipeRefreshLayout; +import android.util.Log; +import android.view.View; + +/** + * Used to wrap a {@code DatasetBacked} ListView or GridView to give the child view swipe-to-refresh + * capabilities. + * + * This view acts as a decorator to forward the {@code DatasetBacked} methods to the child view + * while providing the refresh gesture support on top of it. + */ +class PanelRefreshLayout extends SwipeRefreshLayout implements DatasetBacked { + private static final String LOGTAG = "GeckoPanelRefreshLayout"; + + private static final String JSON_KEY_PANEL_ID = "panelId"; + private static final String JSON_KEY_VIEW_INDEX = "viewIndex"; + + private final String panelId; + private final int viewIndex; + private final DatasetBacked datasetBacked; + + /** + * @param context Android context. + * @param childView ListView or GridView. Must implement {@code DatasetBacked}. + * @param panelId The ID from the {@code PanelConfig}. + * @param viewIndex The index from the {@code ViewConfig}. + */ + public PanelRefreshLayout(Context context, View childView, String panelId, int viewIndex) { + super(context); + + if (!(childView instanceof DatasetBacked)) { + throw new IllegalArgumentException("View must implement DatasetBacked to be refreshed"); + } + + this.panelId = panelId; + this.viewIndex = viewIndex; + this.datasetBacked = (DatasetBacked) childView; + + setOnRefreshListener(new RefreshListener()); + addView(childView); + + // Must be called after the child view has been added. + setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange); + } + + @Override + public void setDataset(Cursor cursor) { + datasetBacked.setDataset(cursor); + setRefreshing(false); + } + + @Override + public void setFilterManager(FilterManager manager) { + datasetBacked.setFilterManager(manager); + } + + private class RefreshListener implements OnRefreshListener { + @Override + public void onRefresh() { + final JSONObject response = new JSONObject(); + try { + response.put(JSON_KEY_PANEL_ID, panelId); + response.put(JSON_KEY_VIEW_INDEX, viewIndex); + } catch (JSONException e) { + Log.e(LOGTAG, "Could not create refresh message", e); + return; + } + + GeckoAppShell.notifyObservers("HomePanels:RefreshView", response.toString()); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java new file mode 100644 index 000000000..cf03c50c0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java @@ -0,0 +1,113 @@ +/* -*- 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.home; + +import org.mozilla.gecko.home.HomeConfig.ItemType; +import org.mozilla.gecko.home.HomeConfig.ViewConfig; +import org.mozilla.gecko.home.PanelLayout.FilterManager; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.widget.CursorAdapter; +import android.view.View; +import android.view.ViewGroup; + +class PanelViewAdapter extends CursorAdapter { + private static final int VIEW_TYPE_ITEM = 0; + private static final int VIEW_TYPE_BACK = 1; + + private final ViewConfig viewConfig; + private FilterManager filterManager; + private final Context context; + + public PanelViewAdapter(Context context, ViewConfig viewConfig) { + super(context, null, 0); + this.context = context; + this.viewConfig = viewConfig; + } + + public void setFilterManager(FilterManager manager) { + this.filterManager = manager; + } + + @Override + public final int getViewTypeCount() { + return 2; + } + + @Override + public int getCount() { + return super.getCount() + (isShowingBack() ? 1 : 0); + } + + @Override + public int getItemViewType(int position) { + if (isShowingBack() && position == 0) { + return VIEW_TYPE_BACK; + } else { + return VIEW_TYPE_ITEM; + } + } + + @Override + public final View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = newView(parent.getContext(), position, parent); + } + + bindView(convertView, position); + return convertView; + } + + private View newView(Context context, int position, ViewGroup parent) { + if (getItemViewType(position) == VIEW_TYPE_BACK) { + return new PanelBackItemView(context, viewConfig.getBackImageUrl()); + } else { + return PanelItemView.create(context, viewConfig.getItemType()); + } + } + + private void bindView(View view, int position) { + if (isShowingBack()) { + if (position == 0) { + final PanelBackItemView item = (PanelBackItemView) view; + item.updateFromFilter(filterManager.getPreviousFilter()); + return; + } + + position--; + } + + final Cursor cursor = getCursor(position); + final PanelItemView item = (PanelItemView) view; + item.updateFromCursor(cursor); + } + + private boolean isShowingBack() { + return filterManager != null && filterManager.canGoBack(); + } + + private final Cursor getCursor(int position) { + final Cursor cursor = getCursor(); + if (cursor == null || !cursor.moveToPosition(position)) { + throw new IllegalStateException("Couldn't move cursor to position " + position); + } + + return cursor; + } + + @Override + public final void bindView(View view, Context context, Cursor cursor) { + // Do nothing. + } + + @Override + public final View newView(Context context, Cursor cursor, ViewGroup parent) { + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java new file mode 100644 index 000000000..a69db0b41 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java @@ -0,0 +1,59 @@ +/* -*- 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.home; + +import org.mozilla.gecko.db.BrowserContract.HomeItems; +import org.mozilla.gecko.home.HomeConfig.ViewConfig; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.PanelLayout.FilterManager; +import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener; + +import android.database.Cursor; + +import java.util.EnumSet; + +class PanelViewItemHandler { + private OnItemOpenListener mItemOpenListener; + private FilterManager mFilterManager; + + public void setOnItemOpenListener(OnItemOpenListener listener) { + mItemOpenListener = listener; + } + + public void setFilterManager(FilterManager manager) { + mFilterManager = manager; + } + + /** + * If item at this position is a back item, perform the go back action via the + * {@code FilterManager}. Otherwise, prepare the url to be opened by the + * {@code OnUrlOpenListener}. + */ + public void openItemAtPosition(Cursor cursor, int position) { + if (mFilterManager != null && mFilterManager.canGoBack()) { + if (position == 0) { + mFilterManager.goBack(); + return; + } + + position--; + } + + if (cursor == null || !cursor.moveToPosition(position)) { + throw new IllegalStateException("Couldn't move cursor to position " + position); + } + + int urlIndex = cursor.getColumnIndexOrThrow(HomeItems.URL); + final String url = cursor.getString(urlIndex); + + int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE); + final String title = cursor.getString(titleIndex); + + if (mItemOpenListener != null) { + mItemOpenListener.onItemOpen(url, title); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java new file mode 100644 index 000000000..230b1d329 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java @@ -0,0 +1,256 @@ +/* -*- 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.home; + +import java.util.EnumSet; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.URLColumns; +import org.mozilla.gecko.db.BrowserDB.FilterFlags; +import org.mozilla.gecko.util.StringUtils; + +import android.app.Dialog; +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.Loader; +import android.support.v4.widget.CursorAdapter; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.ListView; + +/** + * Dialog fragment that displays frecency search results, for pinning a site, in a GridView. + */ +class PinSiteDialog extends DialogFragment { + // Listener for url selection + public static interface OnSiteSelectedListener { + public void onSiteSelected(String url, String title); + } + + // Cursor loader ID for search query + private static final int LOADER_ID_SEARCH = 0; + + // Holds the current search term to use in the query + private String mSearchTerm; + + // Adapter for the list of search results + private SearchAdapter mAdapter; + + // Search entry + private EditText mSearch; + + // Search results + private ListView mList; + + // Callbacks used for the search loader + private CursorLoaderCallbacks mLoaderCallbacks; + + // Bookmark selected listener + private OnSiteSelectedListener mOnSiteSelectedListener; + + public static PinSiteDialog newInstance() { + return new PinSiteDialog(); + } + + private PinSiteDialog() { + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(DialogFragment.STYLE_NO_TITLE, android.R.style.Theme_Holo_Light_Dialog); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + // All list views are styled to look the same with a global activity theme. + // If the style of the list changes, inflate it from an XML. + return inflater.inflate(R.layout.pin_site_dialog, container, false); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mSearch = (EditText) view.findViewById(R.id.search); + mSearch.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + setSearchTerm(mSearch.getText().toString()); + filter(mSearchTerm); + } + }); + + mSearch.setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (keyCode != KeyEvent.KEYCODE_ENTER || mOnSiteSelectedListener == null) { + return false; + } + + // If the user manually entered a search term or URL, wrap the value in + // a special URI until we can get a valid URL for this bookmark. + final String text = mSearch.getText().toString().trim(); + if (!TextUtils.isEmpty(text)) { + final String url = StringUtils.encodeUserEnteredUrl(text); + mOnSiteSelectedListener.onSiteSelected(url, text); + dismiss(); + } + + return true; + } + }); + + mSearch.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + // On rotation, the view gets destroyed and we could be in a race to get the dialog + // and window (see bug 1072959). + Dialog dialog = getDialog(); + if (dialog == null) { + return; + } + Window window = dialog.getWindow(); + if (window == null) { + return; + } + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } + } + }); + + mList = (HomeListView) view.findViewById(R.id.list); + mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + if (mOnSiteSelectedListener != null) { + final Cursor c = mAdapter.getCursor(); + if (c == null || !c.moveToPosition(position)) { + return; + } + + final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL)); + final String title = c.getString(c.getColumnIndexOrThrow(URLColumns.TITLE)); + mOnSiteSelectedListener.onSiteSelected(url, title); + } + + // Dismiss the fragment and the dialog. + dismiss(); + } + }); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final LoaderManager manager = getLoaderManager(); + + // Initialize the search adapter + mAdapter = new SearchAdapter(getActivity()); + mList.setAdapter(mAdapter); + + // Create callbacks before the initial loader is started + mLoaderCallbacks = new CursorLoaderCallbacks(); + + // Reconnect to the loader only if present + manager.initLoader(LOADER_ID_SEARCH, null, mLoaderCallbacks); + + // If there is a search term, put it in the text field + if (!TextUtils.isEmpty(mSearchTerm)) { + mSearch.setText(mSearchTerm); + mSearch.selectAll(); + } + + // Always start with an empty filter + filter(""); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + // Discard any additional site selection as the dialog + // is getting destroyed (see bug 935542). + setOnSiteSelectedListener(null); + } + + public void setSearchTerm(String searchTerm) { + mSearchTerm = searchTerm; + } + + private void filter(String searchTerm) { + // Restart loaders with the new search term + SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, + mLoaderCallbacks, searchTerm, + EnumSet.of(FilterFlags.EXCLUDE_PINNED_SITES)); + } + + public void setOnSiteSelectedListener(OnSiteSelectedListener listener) { + mOnSiteSelectedListener = listener; + } + + private static class SearchAdapter extends CursorAdapter { + private final LayoutInflater mInflater; + + public SearchAdapter(Context context) { + super(context, null, 0); + mInflater = LayoutInflater.from(context); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + TwoLinePageRow row = (TwoLinePageRow) view; + row.setShowIcons(false); + row.updateFromCursor(cursor); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return (TwoLinePageRow) mInflater.inflate(R.layout.home_item_row, parent, false); + } + } + + private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + return SearchLoader.createInstance(getActivity(), args); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { + mAdapter.swapCursor(c); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + mAdapter.swapCursor(null); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java new file mode 100755 index 000000000..3091f77da --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java @@ -0,0 +1,454 @@ +/* -*- 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.home; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SessionParser; +import org.mozilla.gecko.home.CombinedHistoryAdapter.RecentTabsUpdateHandler; +import org.mozilla.gecko.home.CombinedHistoryPanel.PanelStateUpdateHandler; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType; +import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS; + +public class RecentTabsAdapter extends RecyclerView.Adapter<CombinedHistoryItem> + implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder, NativeEventListener { + private static final String LOGTAG = "GeckoRecentTabsAdapter"; + + private static final int NAVIGATION_BACK_BUTTON_INDEX = 0; + + private static final String TELEMETRY_EXTRA_LAST_TIME = "recent_tabs_last_time"; + private static final String TELEMETRY_EXTRA_RECENTLY_CLOSED = "recent_closed_tabs"; + private static final String TELEMETRY_EXTRA_MIXED = "recent_tabs_mixed"; + + // Recently closed tabs from Gecko. + private ClosedTab[] recentlyClosedTabs; + private boolean recentlyClosedTabsReceived = false; + + // "Tabs from last time". + private ClosedTab[] lastSessionTabs; + + public static final class ClosedTab { + public final String url; + public final String title; + public final String data; + + public ClosedTab(String url, String title, String data) { + this.url = url; + this.title = title; + this.data = data; + } + } + + private final Context context; + private final RecentTabsUpdateHandler recentTabsUpdateHandler; + private final PanelStateUpdateHandler panelStateUpdateHandler; + + public RecentTabsAdapter(Context context, + RecentTabsUpdateHandler recentTabsUpdateHandler, + PanelStateUpdateHandler panelStateUpdateHandler) { + this.context = context; + this.recentTabsUpdateHandler = recentTabsUpdateHandler; + this.panelStateUpdateHandler = panelStateUpdateHandler; + recentlyClosedTabs = new ClosedTab[0]; + lastSessionTabs = new ClosedTab[0]; + + readPreviousSessionData(); + } + + public void startListeningForClosedTabs() { + EventDispatcher.getInstance().registerGeckoThreadListener(this, "ClosedTabs:Data"); + GeckoAppShell.notifyObservers("ClosedTabs:StartNotifications", null); + } + + public void stopListeningForClosedTabs() { + GeckoAppShell.notifyObservers("ClosedTabs:StopNotifications", null); + EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "ClosedTabs:Data"); + recentlyClosedTabsReceived = false; + } + + public void startListeningForHistorySanitize() { + EventDispatcher.getInstance().registerGeckoThreadListener(this, "Sanitize:Finished"); + } + + public void stopListeningForHistorySanitize() { + EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "Sanitize:Finished"); + } + + @Override + public void handleMessage(String event, NativeJSObject message, EventCallback callback) { + switch (event) { + case "ClosedTabs:Data": + updateRecentlyClosedTabs(message); + break; + case "Sanitize:Finished": + clearLastSessionData(); + break; + } + } + + private void updateRecentlyClosedTabs(NativeJSObject message) { + final NativeJSObject[] tabs = message.getObjectArray("tabs"); + final int length = tabs.length; + + final ClosedTab[] closedTabs = new ClosedTab[length]; + for (int i = 0; i < length; i++) { + final NativeJSObject tab = tabs[i]; + closedTabs[i] = new ClosedTab(tab.getString("url"), tab.getString("title"), tab.getObject("data").toString()); + } + + // Only modify recentlyClosedTabs on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Save some data about the old panel state, so we can be + // smarter about notifying the recycler view which bits changed. + int prevClosedTabsCount = recentlyClosedTabs.length; + boolean prevSectionHeaderVisibility = isSectionHeaderVisible(); + int prevSectionHeaderIndex = getSectionHeaderIndex(); + + recentlyClosedTabs = closedTabs; + recentlyClosedTabsReceived = true; + recentTabsUpdateHandler.onRecentTabsCountUpdated( + getClosedTabsCount(), recentlyClosedTabsReceived); + panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS); + + // Handle the section header hiding/unhiding. + updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex); + + // Update the "Recently closed" part of the tab list. + updateTabsList(prevClosedTabsCount, recentlyClosedTabs.length, getFirstRecentTabIndex(), getLastRecentTabIndex()); + } + }); + } + + private void readPreviousSessionData() { + // If we happen to initialise before GeckoApp, waiting on either the main or the background + // thread can lead to a deadlock, so we have to run on a separate thread instead. + final Thread parseThread = new Thread(new Runnable() { + @Override + public void run() { + // Make sure that the start up code has had a chance to update sessionstore.old as necessary. + GeckoProfile.get(context).waitForOldSessionDataProcessing(); + + final String jsonString = GeckoProfile.get(context).readPreviousSessionFile(); + if (jsonString == null) { + // No previous session data. + return; + } + + final List<ClosedTab> parsedTabs = new ArrayList<>(); + + new SessionParser() { + @Override + public void onTabRead(SessionTab tab) { + final String url = tab.getUrl(); + + // Don't show last tabs for about:home + if (AboutPages.isAboutHome(url)) { + return; + } + + parsedTabs.add(new ClosedTab(url, tab.getTitle(), tab.getTabObject().toString())); + } + }.parse(jsonString); + + final ClosedTab[] closedTabs = parsedTabs.toArray(new ClosedTab[parsedTabs.size()]); + + // Only modify lastSessionTabs on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Save some data about the old panel state, so we can be + // smarter about notifying the recycler view which bits changed. + int prevClosedTabsCount = lastSessionTabs.length; + boolean prevSectionHeaderVisibility = isSectionHeaderVisible(); + int prevSectionHeaderIndex = getSectionHeaderIndex(); + + lastSessionTabs = closedTabs; + recentTabsUpdateHandler.onRecentTabsCountUpdated( + getClosedTabsCount(), recentlyClosedTabsReceived); + panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS); + + // Handle the section header hiding/unhiding. + updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex); + + // Update the "Tabs from last time" part of the tab list. + updateTabsList(prevClosedTabsCount, lastSessionTabs.length, getFirstLastSessionTabIndex(), getLastLastSessionTabIndex()); + } + }); + } + }, "LastSessionTabsThread"); + + parseThread.start(); + } + + private void clearLastSessionData() { + final ClosedTab[] emptyLastSessionTabs = new ClosedTab[0]; + + // Only modify mLastSessionTabs on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Save some data about the old panel state, so we can be + // smarter about notifying the recycler view which bits changed. + int prevClosedTabsCount = lastSessionTabs.length; + boolean prevSectionHeaderVisibility = isSectionHeaderVisible(); + int prevSectionHeaderIndex = getSectionHeaderIndex(); + + lastSessionTabs = emptyLastSessionTabs; + recentTabsUpdateHandler.onRecentTabsCountUpdated( + getClosedTabsCount(), recentlyClosedTabsReceived); + panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS); + + // Handle the section header hiding. + updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex); + + // Handle the "tabs from last time" being cleared. + if (prevClosedTabsCount > 0) { + notifyItemRangeRemoved(getFirstLastSessionTabIndex(), prevClosedTabsCount); + } + } + }); + } + + private void updateHeaderVisibility(boolean prevSectionHeaderVisibility, int prevSectionHeaderIndex) { + if (prevSectionHeaderVisibility && !isSectionHeaderVisible()) { + notifyItemRemoved(prevSectionHeaderIndex); + } else if (!prevSectionHeaderVisibility && isSectionHeaderVisible()) { + notifyItemInserted(getSectionHeaderIndex()); + } + } + + /** + * Updates the tab list as necessary to account for any changes in tab count in a particular data source. + * + * Since the session store only sends out full updates, we don't know for sure what has changed compared + * to the last data set, so we can only animate if the tab count actually changes. + * + * @param prevClosedTabsCount The previous number of closed tabs from that data source. + * @param closedTabsCount The current number of closed tabs contained in that data source. + * @param firstTabListIndex The current position of that data source's first item in the RecyclerView. + * @param lastTabListIndex The current position of that data source's last item in the RecyclerView. + */ + private void updateTabsList(int prevClosedTabsCount, int closedTabsCount, int firstTabListIndex, int lastTabListIndex) { + final int closedTabsCountChange = closedTabsCount - prevClosedTabsCount; + + if (closedTabsCountChange <= 0) { + notifyItemRangeRemoved(lastTabListIndex + 1, -closedTabsCountChange); // Remove tabs from the bottom of the list. + notifyItemRangeChanged(firstTabListIndex, closedTabsCount); // Update the contents of the remaining items. + } else { // closedTabsCountChange > 0 + notifyItemRangeInserted(firstTabListIndex, closedTabsCountChange); // Add additional tabs at the top of the list. + notifyItemRangeChanged(firstTabListIndex + closedTabsCountChange, prevClosedTabsCount); // Update any previous list items. + } + } + + public String restoreTabFromPosition(int position) { + final List<String> dataList = new ArrayList<>(1); + dataList.add(getClosedTabForPosition(position).data); + + final String telemetryExtra = + position > getLastRecentTabIndex() ? TELEMETRY_EXTRA_LAST_TIME : TELEMETRY_EXTRA_RECENTLY_CLOSED; + + restoreSessionWithHistory(dataList); + + return telemetryExtra; + } + + public String restoreAllTabs() { + if (recentlyClosedTabs.length == 0 && lastSessionTabs.length == 0) { + return null; + } + + final List<String> dataList = new ArrayList<>(getClosedTabsCount()); + addTabDataToList(dataList, recentlyClosedTabs); + addTabDataToList(dataList, lastSessionTabs); + + final String telemetryExtra = recentlyClosedTabs.length > 0 && lastSessionTabs.length > 0 ? TELEMETRY_EXTRA_MIXED : + recentlyClosedTabs.length > 0 ? TELEMETRY_EXTRA_RECENTLY_CLOSED : TELEMETRY_EXTRA_LAST_TIME; + + restoreSessionWithHistory(dataList); + + return telemetryExtra; + } + + private void addTabDataToList(List<String> dataList, ClosedTab[] closedTabs) { + for (ClosedTab closedTab : closedTabs) { + dataList.add(closedTab.data); + } + } + + private static void restoreSessionWithHistory(List<String> dataList) { + final JSONObject json = new JSONObject(); + try { + json.put("tabs", new JSONArray(dataList)); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + + GeckoAppShell.notifyObservers("Session:RestoreRecentTabs", json.toString()); + } + + @Override + public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + final View view; + + final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType); + + switch (itemType) { + case NAVIGATION_BACK: + view = inflater.inflate(R.layout.home_combined_back_item, parent, false); + return new CombinedHistoryItem.HistoryItem(view); + + case SECTION_HEADER: + view = inflater.inflate(R.layout.home_header_row, parent, false); + return new CombinedHistoryItem.BasicItem(view); + + case CLOSED_TAB: + view = inflater.inflate(R.layout.home_item_row, parent, false); + return new CombinedHistoryItem.HistoryItem(view); + } + return null; + } + + @Override + public void onBindViewHolder(CombinedHistoryItem holder, final int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + + switch (itemType) { + case SECTION_HEADER: + ((TextView) holder.itemView).setText(context.getString(R.string.home_closed_tabs_title2)); + break; + + case CLOSED_TAB: + final ClosedTab closedTab = getClosedTabForPosition(position); + ((CombinedHistoryItem.HistoryItem) holder).bind(closedTab); + break; + } + } + + @Override + public int getItemCount() { + int itemCount = 1; // NAVIGATION_BACK button is always visible. + + if (isSectionHeaderVisible()) { + itemCount += 1; + } + + itemCount += getClosedTabsCount(); + + return itemCount; + } + + private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) { + if (position == NAVIGATION_BACK_BUTTON_INDEX) { + return ItemType.NAVIGATION_BACK; + } + + if (position == getSectionHeaderIndex() && isSectionHeaderVisible()) { + return ItemType.SECTION_HEADER; + } + + return ItemType.CLOSED_TAB; + } + + @Override + public int getItemViewType(int position) { + return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position)); + } + + public int getClosedTabsCount() { + return recentlyClosedTabs.length + lastSessionTabs.length; + } + + private boolean isSectionHeaderVisible() { + return recentlyClosedTabs.length > 0 || lastSessionTabs.length > 0; + } + + private int getSectionHeaderIndex() { + return isSectionHeaderVisible() ? + NAVIGATION_BACK_BUTTON_INDEX + 1 : + NAVIGATION_BACK_BUTTON_INDEX; + } + + private int getFirstRecentTabIndex() { + return getSectionHeaderIndex() + 1; + } + + private int getLastRecentTabIndex() { + return getSectionHeaderIndex() + recentlyClosedTabs.length; + } + + private int getFirstLastSessionTabIndex() { + return getLastRecentTabIndex() + 1; + } + + private int getLastLastSessionTabIndex() { + return getLastRecentTabIndex() + lastSessionTabs.length; + } + + /** + * Get the closed tab corresponding to a RecyclerView list item. + * + * The Recent Tab folder combines two data sources, so if we want to get the ClosedTab object + * behind a certain list item, we need to route this request to the corresponding data source + * and also transform the global list position into a local array index. + */ + private ClosedTab getClosedTabForPosition(int position) { + final ClosedTab closedTab; + if (position <= getLastRecentTabIndex()) { // Upper part of the list is "Recently closed tabs". + closedTab = recentlyClosedTabs[position - getFirstRecentTabIndex()]; + } else { // Lower part is "Tabs from last time". + closedTab = lastSessionTabs[position - getFirstLastSessionTabIndex()]; + } + + return closedTab; + } + + @Override + public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) { + final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position); + final HomeContextMenuInfo info; + + switch (itemType) { + case CLOSED_TAB: + info = new HomeContextMenuInfo(view, position, -1); + ClosedTab closedTab = getClosedTabForPosition(position); + return populateChildInfoFromTab(info, closedTab); + } + + return null; + } + + protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, ClosedTab tab) { + info.url = tab.url; + info.title = tab.title; + return info; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java new file mode 100644 index 000000000..43497ae6c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java @@ -0,0 +1,163 @@ +/* -*- 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.home; + +import java.util.HashSet; +import java.util.Set; + +import org.mozilla.gecko.util.PrefUtils; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; + +/** + * Encapsulate visual state maintained by the Remote Tabs home panel. + * <p> + * This state should persist across database updates by Sync and the like. This + * state could be stored in a separate "clients_metadata" table and served by + * the Tabs provider, but that is heavy-weight for what we want to achieve. Such + * a scheme would require either an expensive table join, or a tricky + * co-ordination between multiple cursors. In contrast, this is easy and cheap + * enough to do on the main thread. + * <p> + * This state is "per SharedPreferences" object. In practice, there should exist + * one state object per Gecko Profile; since we can't change profiles without + * killing our process, this can be a static singleton. + */ +public class RemoteTabsExpandableListState { + private static final String PREF_COLLAPSED_CLIENT_GUIDS = "remote_tabs_collapsed_client_guids"; + private static final String PREF_HIDDEN_CLIENT_GUIDS = "remote_tabs_hidden_client_guids"; + private static final String PREF_SELECTED_CLIENT_GUID = "remote_tabs_selected_client_guid"; + + protected final SharedPreferences sharedPrefs; + + // Synchronized by the state instance. The default is to expand a clients + // tabs, so "not present" means "expanded". + // Only accessed from the UI thread. + protected final Set<String> collapsedClients; + + // Synchronized by the state instance. The default is to show a client, so + // "not present" means "shown". + // Only accessed from the UI thread. + protected final Set<String> hiddenClients; + + // Synchronized by the state instance. The last user selected client guid. + // The selectedClient may be invalid or null. + protected String selectedClient; + + public RemoteTabsExpandableListState(SharedPreferences sharedPrefs) { + if (null == sharedPrefs) { + throw new IllegalArgumentException("sharedPrefs must not be null"); + } + this.sharedPrefs = sharedPrefs; + + this.collapsedClients = getStringSet(PREF_COLLAPSED_CLIENT_GUIDS); + this.hiddenClients = getStringSet(PREF_HIDDEN_CLIENT_GUIDS); + this.selectedClient = sharedPrefs.getString(PREF_SELECTED_CLIENT_GUID, null); + } + + /** + * Extract a string set from shared preferences. + * <p> + * Nota bene: it is not OK to modify the set returned by {@link SharedPreferences#getStringSet(String, Set)}. + * + * @param pref to read from. + * @returns string set; never null. + */ + protected Set<String> getStringSet(String pref) { + final Set<String> loaded = PrefUtils.getStringSet(sharedPrefs, pref, null); + if (loaded != null) { + return new HashSet<String>(loaded); + } else { + return new HashSet<String>(); + } + } + + /** + * Update client membership in a set. + * + * @param pref + * to write updated set to. + * @param clients + * set to update membership in. + * @param clientGuid + * to update membership of. + * @param isMember + * whether the client is a member of the set. + * @return true if the set of clients was modified. + */ + protected boolean updateClientMembership(String pref, Set<String> clients, String clientGuid, boolean isMember) { + final boolean modified; + if (isMember) { + modified = clients.add(clientGuid); + } else { + modified = clients.remove(clientGuid); + } + + if (modified) { + // This starts an asynchronous write. We don't care if we drop the + // write, and we don't really care if we race between writes, since + // we will return results from our in-memory cache. + final Editor editor = sharedPrefs.edit(); + PrefUtils.putStringSet(editor, pref, clients); + editor.apply(); + } + + return modified; + } + + /** + * Mark a client as collapsed. + * + * @param clientGuid + * to update. + * @param collapsed + * whether the client is collapsed. + * @return true if the set of collapsed clients was modified. + */ + protected synchronized boolean setClientCollapsed(String clientGuid, boolean collapsed) { + return updateClientMembership(PREF_COLLAPSED_CLIENT_GUIDS, collapsedClients, clientGuid, collapsed); + } + + /** + * Mark a client as the selected. + * + * @param clientGuid + * to update. + */ + protected synchronized void setClientAsSelected(String clientGuid) { + if (hiddenClients.contains(clientGuid)) { + selectedClient = null; + } else { + selectedClient = clientGuid; + } + + final Editor editor = sharedPrefs.edit(); + editor.putString(PREF_SELECTED_CLIENT_GUID, selectedClient); + editor.apply(); + } + + public synchronized boolean isClientCollapsed(String clientGuid) { + return collapsedClients.contains(clientGuid); + } + + /** + * Mark a client as hidden. + * + * @param clientGuid + * to update. + * @param hidden + * whether the client is hidden. + * @return true if the set of hidden clients was modified. + */ + protected synchronized boolean setClientHidden(String clientGuid, boolean hidden) { + return updateClientMembership(PREF_HIDDEN_CLIENT_GUIDS, hiddenClients, clientGuid, hidden); + } + + public synchronized boolean isClientHidden(String clientGuid) { + return hiddenClients.contains(clientGuid); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java new file mode 100644 index 000000000..9b2d2746a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java @@ -0,0 +1,102 @@ +/* -*- 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.home; + +import android.support.annotation.NonNull; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.R; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +public class SearchEngine { + public static final String LOG_TAG = "GeckoSearchEngine"; + + public final String name; // Never null. + public final String identifier; // Can be null. + + private final Bitmap icon; + private volatile List<String> suggestions = new ArrayList<String>(); // Never null. + + public SearchEngine(final Context context, final JSONObject engineJSON) throws JSONException { + if (engineJSON == null) { + throw new IllegalArgumentException("Can't instantiate SearchEngine from null JSON."); + } + + this.name = getString(engineJSON, "name"); + if (this.name == null) { + throw new IllegalArgumentException("Cannot have an unnamed search engine."); + } + + this.identifier = getString(engineJSON, "identifier"); + + final String iconURI = getString(engineJSON, "iconURI"); + if (iconURI == null) { + Log.w(LOG_TAG, "iconURI is null for search engine " + this.name); + } + final Bitmap tempIcon = BitmapUtils.getBitmapFromDataURI(iconURI); + + this.icon = (tempIcon != null) ? tempIcon : getDefaultFavicon(context); + } + + private Bitmap getDefaultFavicon(final Context context) { + return BitmapFactory.decodeResource(context.getResources(), R.drawable.search_icon_inactive); + } + + private static String getString(JSONObject data, String key) throws JSONException { + if (data.isNull(key)) { + return null; + } + return data.getString(key); + } + + /** + * @return a non-null string suitable for use by FHR. + */ + @NonNull + public String getEngineIdentifier() { + if (this.identifier != null) { + return this.identifier; + } + if (this.name != null) { + return "other-" + this.name; + } + return "other"; + } + + public boolean hasSuggestions() { + return !this.suggestions.isEmpty(); + } + + public int getSuggestionsCount() { + return this.suggestions.size(); + } + + public Iterable<String> getSuggestions() { + return this.suggestions; + } + + public void setSuggestions(List<String> suggestions) { + if (suggestions == null) { + this.suggestions = new ArrayList<String>(); + return; + } + this.suggestions = suggestions; + } + + public Bitmap getIcon() { + return this.icon; + } +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java new file mode 100644 index 000000000..be5b3b461 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java @@ -0,0 +1,122 @@ +package org.mozilla.gecko.home; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.v4.content.ContextCompat; +import android.support.v4.graphics.drawable.DrawableCompat; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import org.mozilla.gecko.R; + +import java.util.Collections; +import java.util.List; + +public class SearchEngineAdapter + extends RecyclerView.Adapter<SearchEngineAdapter.SearchEngineViewHolder> { + + private static final String LOGTAG = SearchEngineAdapter.class.getSimpleName(); + + private static final int VIEW_TYPE_SEARCH_ENGINE = 0; + private static final int VIEW_TYPE_LABEL = 1; + private final Context mContext; + + private int mContainerWidth; + private List<SearchEngine> mSearchEngines = Collections.emptyList(); + + public void setSearchEngines(List<SearchEngine> searchEngines) { + mSearchEngines = searchEngines; + notifyDataSetChanged(); + } + + /** + * The container width is used for setting the appropriate calculated amount of width that + * a search engine icon can have. This varies depending on the space available in the + * {@link SearchEngineBar}. The setter exists for this attribute, in creating the view in the + * adapter after said calculation is done when the search bar is created. + * @param iconContainerWidth Width of each search icon. + */ + void setIconContainerWidth(int iconContainerWidth) { + mContainerWidth = iconContainerWidth; + } + + public static class SearchEngineViewHolder extends RecyclerView.ViewHolder { + final private ImageView faviconView; + + public void bindItem(SearchEngine searchEngine) { + faviconView.setImageBitmap(searchEngine.getIcon()); + final String desc = itemView.getResources().getString(R.string.search_bar_item_desc, + searchEngine.getEngineIdentifier()); + itemView.setContentDescription(desc); + } + + public SearchEngineViewHolder(View itemView) { + super(itemView); + faviconView = (ImageView) itemView.findViewById(R.id.search_engine_icon); + } + } + + public SearchEngineAdapter(Context context) { + mContext = context; + } + + @Override + public int getItemViewType(int position) { + return position == 0 ? VIEW_TYPE_LABEL : VIEW_TYPE_SEARCH_ENGINE; + } + + public SearchEngine getItem(int position) { + // We omit the first position which is where the label currently is. + return position == 0 ? null : mSearchEngines.get(position - 1); + } + + @Override + public SearchEngineViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_LABEL: + return new SearchEngineViewHolder(createLabelView(parent)); + case VIEW_TYPE_SEARCH_ENGINE: + return new SearchEngineViewHolder(createSearchEngineView(parent)); + default: + throw new IllegalArgumentException("Unknown view type: " + viewType); + } + } + + @Override + public void onBindViewHolder(SearchEngineViewHolder holder, int position) { + if (position != 0) { + holder.bindItem(getItem(position)); + } + } + + @Override + public int getItemCount() { + return mSearchEngines.size() + 1; + } + + private View createLabelView(ViewGroup parent) { + View view = LayoutInflater.from(mContext) + .inflate(R.layout.search_engine_bar_label, parent, false); + final Drawable icon = DrawableCompat.wrap( + ContextCompat.getDrawable(mContext, R.drawable.search_icon_active).mutate()); + DrawableCompat.setTint(icon, ContextCompat.getColor(mContext, R.color.disabled_grey)); + + final ImageView iconView = (ImageView) view.findViewById(R.id.search_engine_label); + iconView.setImageDrawable(icon); + return view; + } + + private View createSearchEngineView(ViewGroup parent) { + View view = LayoutInflater.from(mContext) + .inflate(R.layout.search_engine_bar_item, parent, false); + + ViewGroup.LayoutParams params = view.getLayoutParams(); + params.width = mContainerWidth; + view.setLayoutParams(params); + + return view; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java new file mode 100644 index 000000000..6a6509bcb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java @@ -0,0 +1,148 @@ +/* -*- 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.home; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.View; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import java.util.List; + +public class SearchEngineBar extends RecyclerView + implements RecyclerViewClickSupport.OnItemClickListener { + private static final String LOGTAG = SearchEngineBar.class.getSimpleName(); + + private static final float ICON_CONTAINER_MIN_WIDTH_DP = 72; + private static final float LABEL_CONTAINER_WIDTH_DP = 48; + + public interface OnSearchBarClickListener { + void onSearchBarClickListener(SearchEngine searchEngine); + } + + private final SearchEngineAdapter mAdapter; + private final LinearLayoutManager mLayoutManager; + private final Paint mDividerPaint; + private final float mMinIconContainerWidth; + private final float mDividerHeight; + private final int mLabelContainerWidth; + + private int mIconContainerWidth; + private OnSearchBarClickListener mOnSearchBarClickListener; + + public SearchEngineBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + + mDividerPaint = new Paint(); + mDividerPaint.setColor(ContextCompat.getColor(context, R.color.toolbar_divider_grey)); + mDividerPaint.setStyle(Paint.Style.FILL_AND_STROKE); + + final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); + mMinIconContainerWidth = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, ICON_CONTAINER_MIN_WIDTH_DP, displayMetrics); + mDividerHeight = context.getResources().getDimension(R.dimen.page_row_divider_height); + mLabelContainerWidth = Math.round(TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, LABEL_CONTAINER_WIDTH_DP, displayMetrics)); + + mIconContainerWidth = Math.round(mMinIconContainerWidth); + + mAdapter = new SearchEngineAdapter(context); + mAdapter.setIconContainerWidth(mIconContainerWidth); + mLayoutManager = new LinearLayoutManager(context); + mLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); + + setAdapter(mAdapter); + setLayoutManager(mLayoutManager); + + RecyclerViewClickSupport.addTo(this) + .setOnItemClickListener(this); + } + + public void setSearchEngines(List<SearchEngine> searchEngines) { + mAdapter.setSearchEngines(searchEngines); + } + + public void setOnSearchBarClickListener(OnSearchBarClickListener listener) { + mOnSearchBarClickListener = listener; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int searchEngineCount = mAdapter.getItemCount() - 1; + + if (searchEngineCount > 0) { + final int availableWidth = getMeasuredWidth() - mLabelContainerWidth; + + if (searchEngineCount * mMinIconContainerWidth <= availableWidth) { + // All search engines fit int: So let's just display all. + mIconContainerWidth = (int) mMinIconContainerWidth; + } else { + // If only (n) search engines fit into the available space then display only (x) + // search engines with (x) picked so that the last search engine will be cut-off + // (we only display half of it) to show the ability to scroll this view. + + final double searchEnginesToDisplay = Math.floor((availableWidth / mMinIconContainerWidth) - 0.5) + 0.5; + // Use all available width and spread search engine icons + mIconContainerWidth = (int) (availableWidth / searchEnginesToDisplay); + } + + mAdapter.setIconContainerWidth(mIconContainerWidth); + } + } + + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + canvas.drawRect(0, 0, getWidth(), mDividerHeight, mDividerPaint); + } + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + if (mOnSearchBarClickListener == null) { + throw new IllegalStateException( + OnSearchBarClickListener.class.getSimpleName() + " is not initializer." + ); + } + + if (position == 0) { + final Intent settingsIntent = new Intent(getContext(), GeckoPreferences.class); + GeckoPreferences.setResourceToOpen(settingsIntent, "preferences_search"); + getContext().startActivity(settingsIntent); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "searchenginebar-settings"); + return; + } + + final SearchEngine searchEngine = mAdapter.getItem(position); + mOnSearchBarClickListener.onSearchBarClickListener(searchEngine); + } + + /** + * We manually add the override for getAdapter because we see this method getting stripped + * out during compile time by aggressive proguard rules. + */ + @RobocopTarget + @Override + public SearchEngineAdapter getAdapter() { + return mAdapter; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java new file mode 100644 index 000000000..5b97a8f5f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java @@ -0,0 +1,494 @@ +/* -*- 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.home; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.home.BrowserSearch.OnEditSuggestionListener; +import org.mozilla.gecko.home.BrowserSearch.OnSearchListener; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.DrawableUtil; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.widget.AnimatedHeightLayout; +import org.mozilla.gecko.widget.FaviconView; +import org.mozilla.gecko.widget.FlowLayout; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.graphics.Typeface; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +class SearchEngineRow extends AnimatedHeightLayout { + // Duration for fade-in animation + private static final int ANIMATION_DURATION = 250; + + // Inner views + private final FlowLayout mSuggestionView; + private final FaviconView mIconView; + private final LinearLayout mUserEnteredView; + private final TextView mUserEnteredTextView; + + // Inflater used when updating from suggestions + private final LayoutInflater mInflater; + + // Search engine associated with this view + private SearchEngine mSearchEngine; + + // Event listeners for suggestion views + private final OnClickListener mClickListener; + private final OnLongClickListener mLongClickListener; + + // On URL open listener + private OnUrlOpenListener mUrlOpenListener; + + // On search listener + private OnSearchListener mSearchListener; + + // On edit suggestion listener + private OnEditSuggestionListener mEditSuggestionListener; + + // Selected suggestion view + private int mSelectedView; + + // android:backgroundTint only works in Android 21 and higher so we can't do this statically in the xml + private Drawable mSearchHistorySuggestionIcon; + + // Maximums for suggestions + private int mMaxSavedSuggestions; + private int mMaxSearchSuggestions; + + private final List<Integer> mOccurrences = new ArrayList<Integer>(); + + public SearchEngineRow(Context context) { + this(context, null); + } + + public SearchEngineRow(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SearchEngineRow(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + mClickListener = new OnClickListener() { + @Override + public void onClick(View v) { + final String suggestion = getSuggestionTextFromView(v); + + // If we're not clicking the user-entered view (the first suggestion item) + // and the search matches a URL pattern, go to that URL. Otherwise, do a + // search for the term. + if (v != mUserEnteredView && !StringUtils.isSearchQuery(suggestion, true)) { + if (mUrlOpenListener != null) { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "url"); + + mUrlOpenListener.onUrlOpen(suggestion, EnumSet.noneOf(OnUrlOpenListener.Flags.class)); + } + } else if (mSearchListener != null) { + if (v == mUserEnteredView) { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user"); + } else { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, (String) v.getTag()); + } + mSearchListener.onSearch(mSearchEngine, suggestion, TelemetryContract.Method.SUGGESTION); + } + } + }; + + mLongClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (mEditSuggestionListener != null) { + final String suggestion = getSuggestionTextFromView(v); + mEditSuggestionListener.onEditSuggestion(suggestion); + return true; + } + + return false; + } + }; + + mInflater = LayoutInflater.from(context); + mInflater.inflate(R.layout.search_engine_row, this); + + mSuggestionView = (FlowLayout) findViewById(R.id.suggestion_layout); + mIconView = (FaviconView) findViewById(R.id.suggestion_icon); + + // User-entered search term is first suggestion + mUserEnteredView = (LinearLayout) findViewById(R.id.suggestion_user_entered); + mUserEnteredView.setOnClickListener(mClickListener); + + mUserEnteredTextView = (TextView) findViewById(R.id.suggestion_text); + mSearchHistorySuggestionIcon = DrawableUtil.tintDrawableWithColorRes(getContext(), R.drawable.icon_most_recent_empty, R.color.tabs_tray_icon_grey); + + // Suggestion limits + mMaxSavedSuggestions = getResources().getInteger(R.integer.max_saved_suggestions); + mMaxSearchSuggestions = getResources().getInteger(R.integer.max_search_suggestions); + } + + private void setDescriptionOnSuggestion(View v, String suggestion) { + v.setContentDescription(getResources().getString(R.string.suggestion_for_engine, + mSearchEngine.name, suggestion)); + } + + private String getSuggestionTextFromView(View v) { + final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text); + return suggestionText.getText().toString(); + } + + /** + * Finds all occurrences of pattern in string and returns a list of the starting indices + * of each occurrence. + * + * @param pattern The pattern that is searched for + * @param string The string where we search for the pattern + */ + private void refreshOccurrencesWith(String pattern, String string) { + mOccurrences.clear(); + + // Don't try to search for an empty string - String.indexOf will return 0, which would result + // in us iterating with lastIndexOfMatch = 0, which eventually results in an OOM. + if (TextUtils.isEmpty(pattern)) { + return; + } + + final int patternLength = pattern.length(); + + int indexOfMatch = 0; + int lastIndexOfMatch = 0; + while (indexOfMatch != -1) { + indexOfMatch = string.indexOf(pattern, lastIndexOfMatch); + lastIndexOfMatch = indexOfMatch + patternLength; + if (indexOfMatch != -1) { + mOccurrences.add(indexOfMatch); + } + } + } + + /** + * Sets the content for the suggestion view. + * + * If the suggestion doesn't contain mUserSearchTerm, nothing is made bold. + * All instances of mUserSearchTerm in the suggestion are not bold. + * + * @param v The View that needs to be populated + * @param suggestion The suggestion text that will be placed in the view + * @param isUserSavedSearch whether the suggestion is from history or not + */ + private void setSuggestionOnView(View v, String suggestion, boolean isUserSavedSearch) { + final ImageView historyIcon = (ImageView) v.findViewById(R.id.suggestion_item_icon); + if (isUserSavedSearch) { + historyIcon.setImageDrawable(mSearchHistorySuggestionIcon); + historyIcon.setVisibility(View.VISIBLE); + } else { + historyIcon.setVisibility(View.GONE); + } + + final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text); + final String searchTerm = getSuggestionTextFromView(mUserEnteredView); + final int searchTermLength = searchTerm.length(); + refreshOccurrencesWith(searchTerm, suggestion); + if (mOccurrences.size() > 0) { + final SpannableStringBuilder sb = new SpannableStringBuilder(suggestion); + int nextStartSpanIndex = 0; + // Done to make sure that the stretch of text after the last occurrence, till the end of the suggestion, is made bold + mOccurrences.add(suggestion.length()); + for (int occurrence : mOccurrences) { + // Even though they're the same style, SpannableStringBuilder will interpret there as being only one Span present if we re-use a StyleSpan + StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); + sb.setSpan(boldSpan, nextStartSpanIndex, occurrence, Spannable.SPAN_INCLUSIVE_INCLUSIVE); + nextStartSpanIndex = occurrence + searchTermLength; + } + mOccurrences.clear(); + suggestionText.setText(sb); + } else { + suggestionText.setText(suggestion); + } + + setDescriptionOnSuggestion(suggestionText, suggestion); + } + + /** + * Perform a search for the user-entered term. + */ + public void performUserEnteredSearch() { + String searchTerm = getSuggestionTextFromView(mUserEnteredView); + if (mSearchListener != null) { + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user"); + mSearchListener.onSearch(mSearchEngine, searchTerm, TelemetryContract.Method.SUGGESTION); + } + } + + public void setSearchTerm(String searchTerm) { + mUserEnteredTextView.setText(searchTerm); + + // mSearchEngine is not set in the first call to this method; the content description + // is instead initially set in updateSuggestions(). + if (mSearchEngine != null) { + setDescriptionOnSuggestion(mUserEnteredTextView, searchTerm); + } + } + + public void setOnUrlOpenListener(OnUrlOpenListener listener) { + mUrlOpenListener = listener; + } + + public void setOnSearchListener(OnSearchListener listener) { + mSearchListener = listener; + } + + public void setOnEditSuggestionListener(OnEditSuggestionListener listener) { + mEditSuggestionListener = listener; + } + + private void bindSuggestionView(String suggestion, boolean animate, int recycledSuggestionCount, Integer previousSuggestionChildIndex, boolean isUserSavedSearch, String telemetryTag) { + final View suggestionItem; + + // Reuse suggestion views from recycled view, if possible. + if (previousSuggestionChildIndex + 1 < recycledSuggestionCount) { + suggestionItem = mSuggestionView.getChildAt(previousSuggestionChildIndex + 1); + suggestionItem.setVisibility(View.VISIBLE); + } else { + suggestionItem = mInflater.inflate(R.layout.suggestion_item, null); + + suggestionItem.setOnClickListener(mClickListener); + suggestionItem.setOnLongClickListener(mLongClickListener); + + suggestionItem.setTag(telemetryTag); + + mSuggestionView.addView(suggestionItem); + } + + setSuggestionOnView(suggestionItem, suggestion, isUserSavedSearch); + + if (animate) { + AlphaAnimation anim = new AlphaAnimation(0, 1); + anim.setDuration(ANIMATION_DURATION); + anim.setStartOffset(previousSuggestionChildIndex * ANIMATION_DURATION); + suggestionItem.startAnimation(anim); + } + } + + private void hideRecycledSuggestions(int lastVisibleChildIndex, int recycledSuggestionCount) { + // Hide extra suggestions that have been recycled. + for (int i = lastVisibleChildIndex + 1; i < recycledSuggestionCount; ++i) { + mSuggestionView.getChildAt(i).setVisibility(View.GONE); + } + } + + /** + * Displays search suggestions from previous searches. + * + * @param savedSuggestions The List to iterate over for saved search suggestions to display. This function does not + * enforce a ui maximum or filter. It will show all the suggestions in this list. + * @param suggestionStartIndex global index of where to start adding suggestion "buttons" in the search engine row. Also + * acts as a counter for total number of suggestions visible. + * @param animate whether or not to animate suggestions for visual polish + * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls + */ + private void updateFromSavedSearches(List<String> savedSuggestions, boolean animate, int suggestionStartIndex, int recycledSuggestionCount) { + if (savedSuggestions == null || savedSuggestions.isEmpty()) { + hideRecycledSuggestions(suggestionStartIndex, recycledSuggestionCount); + return; + } + + final int numSavedSearches = savedSuggestions.size(); + int indexOfPreviousSuggestion = 0; + for (int i = 0; i < numSavedSearches; i++) { + String telemetryTag = "history." + i; + final String suggestion = savedSuggestions.get(i); + indexOfPreviousSuggestion = suggestionStartIndex + i; + bindSuggestionView(suggestion, animate, recycledSuggestionCount, indexOfPreviousSuggestion, true, telemetryTag); + } + + hideRecycledSuggestions(indexOfPreviousSuggestion + 1, recycledSuggestionCount); + } + + /** + * Displays suggestions supplied by the search engine, relative to number of suggestions from search history. + * + * @param animate whether or not to animate suggestions for visual polish + * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls + * @param savedSuggestionCount how many saved searches this searchTerm has + * @return the global count of how many suggestions have been bound/shown in the search engine row + */ + private int updateFromSearchEngine(boolean animate, List<String> searchEngineSuggestions, int recycledSuggestionCount, int savedSuggestionCount) { + int maxSuggestions = mMaxSearchSuggestions; + // If there are less than max saved searches on phones, fill the space with more search engine suggestions + if (!HardwareUtils.isTablet() && savedSuggestionCount < mMaxSavedSuggestions) { + maxSuggestions += mMaxSavedSuggestions - savedSuggestionCount; + } + + final int numSearchEngineSuggestions = searchEngineSuggestions.size(); + int relativeIndex; + for (relativeIndex = 0; relativeIndex < numSearchEngineSuggestions; relativeIndex++) { + if (relativeIndex == maxSuggestions) { + break; + } + + // Since the search engine suggestions are listed first, their relative index is their global index + String telemetryTag = "engine." + relativeIndex; + final String suggestion = searchEngineSuggestions.get(relativeIndex); + bindSuggestionView(suggestion, animate, recycledSuggestionCount, relativeIndex, false, telemetryTag); + } + + hideRecycledSuggestions(relativeIndex + 1, recycledSuggestionCount); + + // Make sure mSelectedView is still valid. + if (mSelectedView >= mSuggestionView.getChildCount()) { + mSelectedView = mSuggestionView.getChildCount() - 1; + } + + return relativeIndex; + } + + /** + * Updates the whole suggestions UI, the search engine UI, suggestions from the default search engine, + * and suggestions from search history. + * + * This can be called before the opt-in permission prompt is shown or set. + * Even if both suggestion types are disabled, we need to update the search engine, its image, and the content description. + * + * @param searchSuggestionsEnabled whether or not suggestions from the default search engine are enabled + * @param searchEngine the search engine to use throughout the SearchEngineRow class + * @param rawSearchHistorySuggestions search history suggestions + * @param animate whether or not to use animations + **/ + public void updateSuggestions(boolean searchSuggestionsEnabled, SearchEngine searchEngine, @Nullable List<String> rawSearchHistorySuggestions, boolean animate) { + mSearchEngine = searchEngine; + // Set the search engine icon (e.g., Google) for the row. + + mIconView.updateAndScaleImage(IconResponse.create(mSearchEngine.getIcon())); + // Set the initial content description. + setDescriptionOnSuggestion(mUserEnteredTextView, mUserEnteredTextView.getText().toString()); + + final int recycledSuggestionCount = mSuggestionView.getChildCount(); + final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext()); + final boolean savedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true); + + // Remove duplicates of search engine suggestions from saved searches. + List<String> searchHistorySuggestions = (rawSearchHistorySuggestions != null) ? rawSearchHistorySuggestions : new ArrayList<String>(); + + // Filter out URLs and long search suggestions + Iterator<String> searchHistoryIterator = searchHistorySuggestions.iterator(); + while (searchHistoryIterator.hasNext()) { + final String currentSearchHistory = searchHistoryIterator.next(); + + if (currentSearchHistory.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", currentSearchHistory)) { + searchHistoryIterator.remove(); + } + } + + + List<String> searchEngineSuggestions = new ArrayList<String>(); + for (String suggestion : searchEngine.getSuggestions()) { + searchHistorySuggestions.remove(suggestion); + searchEngineSuggestions.add(suggestion); + } + // Make sure the search term itself isn't duplicated. This is more important on phones than tablets where screen + // space is more precious. + searchHistorySuggestions.remove(getSuggestionTextFromView(mUserEnteredView)); + + // Trim the history suggestions down to the maximum allowed. + if (searchHistorySuggestions.size() >= mMaxSavedSuggestions) { + // The second index to subList() is exclusive, so this looks like an off by one error but it is not. + searchHistorySuggestions = searchHistorySuggestions.subList(0, mMaxSavedSuggestions); + } + final int searchHistoryCount = searchHistorySuggestions.size(); + + if (searchSuggestionsEnabled && savedSearchesEnabled) { + final int suggestionViewCount = updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, searchHistoryCount); + updateFromSavedSearches(searchHistorySuggestions, animate, suggestionViewCount, recycledSuggestionCount); + } else if (savedSearchesEnabled) { + updateFromSavedSearches(searchHistorySuggestions, animate, 0, recycledSuggestionCount); + } else if (searchSuggestionsEnabled) { + updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, 0); + } else { + // The current search term is treated separately from the suggestions list, hence we can + // recycle ALL suggestion items here. (We always show the current search term, i.e. 1 item, + // in front of the search engine suggestions and/or the search history.) + hideRecycledSuggestions(0, recycledSuggestionCount); + } + } + + @Override + public boolean onKeyDown(int keyCode, android.view.KeyEvent event) { + final View suggestion = mSuggestionView.getChildAt(mSelectedView); + + if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) { + return false; + } + + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_RIGHT: + final View nextSuggestion = mSuggestionView.getChildAt(mSelectedView + 1); + if (nextSuggestion != null) { + changeSelectedSuggestion(suggestion, nextSuggestion); + mSelectedView++; + return true; + } + break; + + case KeyEvent.KEYCODE_DPAD_LEFT: + final View prevSuggestion = mSuggestionView.getChildAt(mSelectedView - 1); + if (prevSuggestion != null) { + changeSelectedSuggestion(suggestion, prevSuggestion); + mSelectedView--; + return true; + } + break; + + case KeyEvent.KEYCODE_BUTTON_A: + // TODO: handle long pressing for editing suggestions + return suggestion.performClick(); + } + + return false; + } + + private void changeSelectedSuggestion(View oldSuggestion, View newSuggestion) { + oldSuggestion.setDuplicateParentStateEnabled(false); + newSuggestion.setDuplicateParentStateEnabled(true); + oldSuggestion.refreshDrawableState(); + newSuggestion.refreshDrawableState(); + } + + public void onSelected() { + mSelectedView = 0; + mUserEnteredView.setDuplicateParentStateEnabled(true); + mUserEnteredView.refreshDrawableState(); + } + + public void onDeselected() { + final View suggestion = mSuggestionView.getChildAt(mSelectedView); + suggestion.setDuplicateParentStateEnabled(false); + suggestion.refreshDrawableState(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java new file mode 100644 index 000000000..f7b5b6586 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java @@ -0,0 +1,114 @@ +/* -*- 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.home; + +import java.util.EnumSet; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.BrowserDB.FilterFlags; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.os.SystemClock; +import android.support.v4.app.LoaderManager; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.Loader; + +/** + * Encapsulates the implementation of the search cursor loader. + */ +class SearchLoader { + public static final String LOGTAG = "GeckoSearchLoader"; + + private static final String KEY_SEARCH_TERM = "search_term"; + private static final String KEY_FILTER_FLAGS = "flags"; + + private SearchLoader() { + } + + @SuppressWarnings("unchecked") + public static Loader<Cursor> createInstance(Context context, Bundle args) { + if (args != null) { + final String searchTerm = args.getString(KEY_SEARCH_TERM); + final EnumSet<FilterFlags> flags = + (EnumSet<FilterFlags>) args.getSerializable(KEY_FILTER_FLAGS); + return new SearchCursorLoader(context, searchTerm, flags); + } else { + return new SearchCursorLoader(context, "", EnumSet.noneOf(FilterFlags.class)); + } + } + + private static Bundle createArgs(String searchTerm, EnumSet<FilterFlags> flags) { + Bundle args = new Bundle(); + args.putString(SearchLoader.KEY_SEARCH_TERM, searchTerm); + args.putSerializable(SearchLoader.KEY_FILTER_FLAGS, flags); + + return args; + } + + public static void init(LoaderManager manager, int loaderId, + LoaderCallbacks<Cursor> callbacks, String searchTerm) { + init(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class)); + } + + public static void init(LoaderManager manager, int loaderId, + LoaderCallbacks<Cursor> callbacks, String searchTerm, + EnumSet<FilterFlags> flags) { + final Bundle args = createArgs(searchTerm, flags); + manager.initLoader(loaderId, args, callbacks); + } + + public static void restart(LoaderManager manager, int loaderId, + LoaderCallbacks<Cursor> callbacks, String searchTerm) { + restart(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class)); + } + + public static void restart(LoaderManager manager, int loaderId, + LoaderCallbacks<Cursor> callbacks, String searchTerm, + EnumSet<FilterFlags> flags) { + final Bundle args = createArgs(searchTerm, flags); + manager.restartLoader(loaderId, args, callbacks); + } + + public static class SearchCursorLoader extends SimpleCursorLoader { + private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_SEARCH_LOADER_TIME_MS"; + + // Max number of search results. + private static final int SEARCH_LIMIT = 100; + + // The target search term associated with the loader. + private final String mSearchTerm; + + // The filter flags associated with the loader. + private final EnumSet<FilterFlags> mFlags; + private final GeckoProfile mProfile; + + public SearchCursorLoader(Context context, String searchTerm, EnumSet<FilterFlags> flags) { + super(context); + mSearchTerm = searchTerm; + mFlags = flags; + mProfile = GeckoProfile.get(context); + } + + @Override + public Cursor loadCursor() { + final long start = SystemClock.uptimeMillis(); + final Cursor cursor = BrowserDB.from(mProfile).filter(getContext().getContentResolver(), mSearchTerm, SEARCH_LIMIT, mFlags); + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE)); + return cursor; + } + + public String getSearchTerm() { + return mSearchTerm; + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java new file mode 100644 index 000000000..b8889c033 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java @@ -0,0 +1,147 @@ +/* + * This is an adapted version of Android's original CursorLoader + * without all the ContentProvider-specific bits. + * + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.home; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.content.AsyncTaskLoader; + +import org.mozilla.gecko.GeckoApplication; + +/** + * A copy of the framework's {@link android.content.CursorLoader} that + * instead allows the caller to load the Cursor themselves via the abstract + * {@link #loadCursor()} method, rather than calling out to a ContentProvider via + * class methods. + * + * For new code, prefer {@link android.content.CursorLoader} (see @deprecated). + * + * This was originally created to re-use existing code which loaded Cursors manually. + * + * @deprecated since the framework provides an implementation, we'd like to eventually remove + * this class to reduce maintenance burden. Originally planned for bug 1239491, but + * it'd be more efficient to do this over time, rather than all at once. + */ +@Deprecated +public abstract class SimpleCursorLoader extends AsyncTaskLoader<Cursor> { + final ForceLoadContentObserver mObserver; + Cursor mCursor; + + public SimpleCursorLoader(Context context) { + super(context); + mObserver = new ForceLoadContentObserver(); + } + + /** + * Loads the target cursor for this loader. This method is called + * on a worker thread. + */ + protected abstract Cursor loadCursor(); + + /* Runs on a worker thread */ + @Override + public Cursor loadInBackground() { + Cursor cursor = loadCursor(); + + if (cursor != null) { + // Ensure the cursor window is filled + cursor.getCount(); + cursor.registerContentObserver(mObserver); + } + + return cursor; + } + + /* Runs on the UI thread */ + @Override + public void deliverResult(Cursor cursor) { + if (isReset()) { + // An async query came in while the loader is stopped + if (cursor != null) { + cursor.close(); + } + + return; + } + + Cursor oldCursor = mCursor; + mCursor = cursor; + + if (isStarted()) { + super.deliverResult(cursor); + } + + if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) { + oldCursor.close(); + + // Trying to read from the closed cursor will cause crashes, hence we should make + // sure that no adapters/LoaderCallbacks are holding onto this cursor. + GeckoApplication.getRefWatcher(getContext()).watch(oldCursor); + } + } + + /** + * Starts an asynchronous load of the list data. When the result is ready the callbacks + * will be called on the UI thread. If a previous load has been completed and is still valid + * the result may be passed to the callbacks immediately. + * + * Must be called from the UI thread + */ + @Override + protected void onStartLoading() { + if (mCursor != null) { + deliverResult(mCursor); + } + + if (takeContentChanged() || mCursor == null) { + forceLoad(); + } + } + + /** + * Must be called from the UI thread + */ + @Override + protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + @Override + public void onCanceled(Cursor cursor) { + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + if (mCursor != null && !mCursor.isClosed()) { + mCursor.close(); + } + + mCursor = null; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java new file mode 100644 index 000000000..039b65e82 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java @@ -0,0 +1,20 @@ +package org.mozilla.gecko.home; + +import android.graphics.Rect; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +public class SpacingDecoration extends RecyclerView.ItemDecoration { + private final int horizontalSpacing; + private final int verticalSpacing; + + public SpacingDecoration(int horizontalSpacing, int verticalSpacing) { + this.horizontalSpacing = horizontalSpacing; + this.verticalSpacing = verticalSpacing; + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.set(horizontalSpacing, verticalSpacing, horizontalSpacing, verticalSpacing); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java new file mode 100644 index 000000000..b302d3522 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java @@ -0,0 +1,127 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.home; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * {@code TabMenuStrip} is the view used to display {@code HomePager} tabs + * on tablets. See {@code TabMenuStripLayout} for details about how the + * tabs are created and updated. + */ +public class TabMenuStrip extends HorizontalScrollView + implements HomePager.Decor { + + // Offset between the selected tab title and the edge of the screen, + // except for the first and last tab in the tab strip. + private static final int TITLE_OFFSET_DIPS = 24; + + private final int titleOffset; + private final TabMenuStripLayout layout; + + private final Paint shadowPaint; + private final int shadowSize; + + public interface OnTitleClickListener { + void onTitleClicked(int index); + } + + public TabMenuStrip(Context context, AttributeSet attrs) { + super(context, attrs); + + // Disable the scroll bar. + setHorizontalScrollBarEnabled(false); + setFillViewport(true); + + final Resources res = getResources(); + + titleOffset = (int) (TITLE_OFFSET_DIPS * res.getDisplayMetrics().density); + + layout = new TabMenuStripLayout(context, attrs); + addView(layout, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + + shadowSize = res.getDimensionPixelSize(R.dimen.tabs_strip_shadow_size); + + shadowPaint = new Paint(); + shadowPaint.setColor(ContextCompat.getColor(context, R.color.url_bar_shadow)); + shadowPaint.setStrokeWidth(0.0f); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + final int height = getHeight(); + canvas.drawRect(0, height - shadowSize, layout.getWidth(), height, shadowPaint); + } + + @Override + public void onAddPagerView(String title) { + layout.onAddPagerView(title); + } + + @Override + public void removeAllPagerViews() { + layout.removeAllViews(); + } + + @Override + public void onPageSelected(final int position) { + layout.onPageSelected(position); + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + layout.onPageScrolled(position, positionOffset, positionOffsetPixels); + + final View selectedTitle = layout.getChildAt(position); + if (selectedTitle == null) { + return; + } + + final int selectedTitleOffset = (int) (positionOffset * selectedTitle.getWidth()); + + int titleLeft = selectedTitle.getLeft() + selectedTitleOffset; + if (position > 0) { + titleLeft -= titleOffset; + } + + int titleRight = selectedTitle.getRight() + selectedTitleOffset; + if (position < layout.getChildCount() - 1) { + titleRight += titleOffset; + } + + final int scrollX = getScrollX(); + if (titleLeft < scrollX) { + // Tab strip overflows to the left. + scrollTo(titleLeft, 0); + } else if (titleRight > scrollX + getWidth()) { + // Tab strip overflows to the right. + scrollTo(titleRight - getWidth(), 0); + } + } + + @Override + public void setOnTitleClickListener(OnTitleClickListener onTitleClickListener) { + layout.setOnTitleClickListener(onTitleClickListener); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java new file mode 100644 index 000000000..a09add80b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java @@ -0,0 +1,246 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.home; + +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import android.content.res.ColorStateList; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.widget.TextView; + +/** + * {@code TabMenuStripLayout} is the view that draws the {@code HomePager} + * tabs that are displayed in {@code TabMenuStrip}. + */ +class TabMenuStripLayout extends LinearLayout + implements View.OnFocusChangeListener { + + private TabMenuStrip.OnTitleClickListener onTitleClickListener; + private Drawable strip; + private TextView selectedView; + + // Data associated with the scrolling of the strip drawable. + private View toTab; + private View fromTab; + private int fromPosition; + private int toPosition; + private float progress; + + // This variable is used to predict the direction of scroll. + private float prevProgress; + private int tabContentStart; + private boolean titlebarFill; + private int activeTextColor; + private ColorStateList inactiveTextColor; + + TabMenuStripLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabMenuStrip); + final int stripResId = a.getResourceId(R.styleable.TabMenuStrip_strip, -1); + + titlebarFill = a.getBoolean(R.styleable.TabMenuStrip_titlebarFill, false); + tabContentStart = a.getDimensionPixelSize(R.styleable.TabMenuStrip_tabsMarginLeft, 0); + activeTextColor = a.getColor(R.styleable.TabMenuStrip_activeTextColor, R.color.text_and_tabs_tray_grey); + inactiveTextColor = a.getColorStateList(R.styleable.TabMenuStrip_inactiveTextColor); + a.recycle(); + + if (stripResId != -1) { + strip = getResources().getDrawable(stripResId); + } + + setWillNotDraw(false); + } + + void onAddPagerView(String title) { + final TextView button = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.tab_menu_strip, this, false); + button.setText(title.toUpperCase()); + button.setTextColor(inactiveTextColor); + + // Set titles width to weight, or wrap text width. + if (titlebarFill) { + button.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, 1.0f)); + } else { + button.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } + + if (getChildCount() == 0) { + button.setPadding(button.getPaddingLeft() + tabContentStart, + button.getPaddingTop(), + button.getPaddingRight(), + button.getPaddingBottom()); + } + + addView(button); + button.setOnClickListener(new ViewClickListener(getChildCount() - 1)); + button.setOnFocusChangeListener(this); + } + + void onPageSelected(final int position) { + if (selectedView != null) { + selectedView.setTextColor(inactiveTextColor); + } + + selectedView = (TextView) getChildAt(position); + selectedView.setTextColor(activeTextColor); + + // Callback to measure and draw the strip after the view is visible. + ViewTreeObserver vto = selectedView.getViewTreeObserver(); + if (vto.isAlive()) { + vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + selectedView.getViewTreeObserver().removeGlobalOnLayoutListener(this); + + if (strip != null) { + strip.setBounds(selectedView.getLeft() + (position == 0 ? tabContentStart : 0), + selectedView.getTop(), + selectedView.getRight(), + selectedView.getBottom()); + } + + prevProgress = position; + } + }); + } + } + + // Page scroll animates the drawable and its bounds from the previous to next child view. + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (strip == null) { + return; + } + + setScrollingData(position, positionOffset); + + if (fromTab == null || toTab == null) { + return; + } + + final int fromTabLeft = fromTab.getLeft(); + final int fromTabRight = fromTab.getRight(); + + final int toTabLeft = toTab.getLeft(); + final int toTabRight = toTab.getRight(); + + // The first tab has a padding applied (tabContentStart). We don't want the 'strip' to jump around so we remove + // this padding slowly (modifier) when scrolling to or from the first tab. + final int modifier; + + if (fromPosition == 0 && toPosition == 1) { + // Slowly remove extra padding (tabContentStart) based on scroll progress + modifier = (int) (tabContentStart * (1 - progress)); + } else if (fromPosition == 1 && toPosition == 0) { + // Slowly add extra padding (tabContentStart) based on scroll progress + modifier = (int) (tabContentStart * progress); + } else { + // We are not scrolling tab 0 in any way, no modifier needed + modifier = 0; + } + + strip.setBounds((int) (fromTabLeft + ((toTabLeft - fromTabLeft) * progress)) + modifier, + 0, + (int) (fromTabRight + ((toTabRight - fromTabRight) * progress)), + getHeight()); + invalidate(); + } + + /* + * position + positionOffset goes from 0 to 2 as we scroll from page 1 to 3. + * Normalized progress is relative to the the direction the page is being scrolled towards. + * For this, we maintain direction of scroll with a state, and the child view we are moving towards and away from. + */ + void setScrollingData(int position, float positionOffset) { + if (position >= getChildCount() - 1) { + return; + } + + final float currProgress = position + positionOffset; + + if (prevProgress > currProgress) { + toPosition = position; + fromPosition = position + 1; + progress = 1 - positionOffset; + } else { + toPosition = position + 1; + fromPosition = position; + progress = positionOffset; + } + + toTab = getChildAt(toPosition); + fromTab = getChildAt(fromPosition); + + prevProgress = currProgress; + } + + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (strip != null) { + strip.draw(canvas); + } + } + + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (v == this && hasFocus && getChildCount() > 0) { + selectedView.requestFocus(); + return; + } + + if (!hasFocus) { + return; + } + + int i = 0; + final int numTabs = getChildCount(); + + while (i < numTabs) { + View view = getChildAt(i); + if (view == v) { + view.requestFocus(); + if (isShown()) { + // A view is focused so send an event to announce the menu strip state. + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); + } + break; + } + + i++; + } + } + + void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener) { + this.onTitleClickListener = onTitleClickListener; + } + + private class ViewClickListener implements OnClickListener { + private final int mIndex; + + public ViewClickListener(int index) { + mIndex = index; + } + + @Override + public void onClick(View view) { + if (onTitleClickListener != null) { + onTitleClickListener.onTitleClicked(mIndex); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java new file mode 100644 index 000000000..c17aff209 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java @@ -0,0 +1,312 @@ +/* -*- 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.home; + +import android.content.Context; +import android.graphics.Bitmap; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.widget.ImageView.ScaleType; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract.TopSites; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; + +import java.util.concurrent.Future; + +/** + * A view that displays the thumbnail and the title/url for a top/pinned site. + * If the title/url is longer than the width of the view, they are faded out. + * If there is no valid url, a default string is shown at 50% opacity. + * This is denoted by the empty state. + */ +public class TopSitesGridItemView extends RelativeLayout implements IconCallback { + private static final String LOGTAG = "GeckoTopSitesGridItemView"; + + // Empty state, to denote there is no valid url. + private static final int[] STATE_EMPTY = { android.R.attr.state_empty }; + + private static final ScaleType SCALE_TYPE_FAVICON = ScaleType.CENTER; + private static final ScaleType SCALE_TYPE_RESOURCE = ScaleType.CENTER; + private static final ScaleType SCALE_TYPE_THUMBNAIL = ScaleType.CENTER_CROP; + private static final ScaleType SCALE_TYPE_URL = ScaleType.CENTER_INSIDE; + + // Child views. + private final TextView mTitleView; + private final TopSitesThumbnailView mThumbnailView; + + // Data backing this view. + private String mTitle; + private String mUrl; + + private boolean mThumbnailSet; + + // Matches BrowserContract.TopSites row types + private int mType = -1; + + // Dirty state. + private boolean mIsDirty; + + private Future<IconResponse> mOngoingIconRequest; + + public TopSitesGridItemView(Context context) { + this(context, null); + } + + public TopSitesGridItemView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.topSitesGridItemViewStyle); + } + + public TopSitesGridItemView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + LayoutInflater.from(context).inflate(R.layout.top_sites_grid_item_view, this); + + mTitleView = (TextView) findViewById(R.id.title); + mThumbnailView = (TopSitesThumbnailView) findViewById(R.id.thumbnail); + } + + /** + * {@inheritDoc} + */ + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (mType == TopSites.TYPE_BLANK) { + mergeDrawableStates(drawableState, STATE_EMPTY); + } + + return drawableState; + } + + /** + * @return The title shown by this view. + */ + public String getTitle() { + return (!TextUtils.isEmpty(mTitle) ? mTitle : mUrl); + } + + /** + * @return The url shown by this view. + */ + public String getUrl() { + return mUrl; + } + + /** + * @return The site type associated with this view. + */ + public int getType() { + return mType; + } + + /** + * @param title The title for this view. + */ + public void setTitle(String title) { + if (mTitle != null && mTitle.equals(title)) { + return; + } + + mTitle = title; + updateTitleView(); + } + + /** + * @param url The url for this view. + */ + public void setUrl(String url) { + if (mUrl != null && mUrl.equals(url)) { + return; + } + + mUrl = url; + updateTitleView(); + } + + public void blankOut() { + mUrl = ""; + mTitle = ""; + updateType(TopSites.TYPE_BLANK); + updateTitleView(); + cancelIconLoading(); + ImageLoader.with(getContext()).cancelRequest(mThumbnailView); + displayThumbnail(R.drawable.top_site_add); + + } + + public void markAsDirty() { + mIsDirty = true; + } + + /** + * Updates the title, URL, and pinned state of this view. + * + * Also resets our loadId to NOT_LOADING. + * + * Returns true if any fields changed. + */ + public boolean updateState(final String title, final String url, final int type, final TopSitesPanel.ThumbnailInfo thumbnail) { + boolean changed = false; + if (mUrl == null || !mUrl.equals(url)) { + mUrl = url; + changed = true; + } + + if (mTitle == null || !mTitle.equals(title)) { + mTitle = title; + changed = true; + } + + if (thumbnail != null) { + if (thumbnail.imageUrl != null) { + displayThumbnail(thumbnail.imageUrl, thumbnail.bgColor); + } else if (thumbnail.bitmap != null) { + displayThumbnail(thumbnail.bitmap); + } + } else if (changed) { + // Because we'll have a new favicon or thumbnail arriving shortly, and + // we need to not reject it because we already had a thumbnail. + mThumbnailSet = false; + } + + if (changed) { + updateTitleView(); + cancelIconLoading(); + ImageLoader.with(getContext()).cancelRequest(mThumbnailView); + } + + if (updateType(type)) { + changed = true; + } + + // The dirty state forces the state update to return true + // so that the adapter loads favicons once the thumbnails + // are loaded in TopSitesPanel/TopSitesGridAdapter. + changed = (changed || mIsDirty); + mIsDirty = false; + + return changed; + } + + /** + * Try to load an icon for the given page URL. + */ + public void loadFavicon(String pageUrl) { + mOngoingIconRequest = Icons.with(getContext()) + .pageUrl(pageUrl) + .skipNetwork() + .build() + .execute(this); + } + + private void cancelIconLoading() { + if (mOngoingIconRequest != null) { + mOngoingIconRequest.cancel(true); + } + } + + /** + * Display the thumbnail from a resource. + * + * @param resId Resource ID of the drawable to show. + */ + public void displayThumbnail(int resId) { + mThumbnailView.setScaleType(SCALE_TYPE_RESOURCE); + mThumbnailView.setImageResource(resId); + mThumbnailView.setBackgroundColor(0x0); + mThumbnailSet = false; + } + + /** + * Display the thumbnail from a bitmap. + * + * @param thumbnail The bitmap to show as thumbnail. + */ + public void displayThumbnail(Bitmap thumbnail) { + if (thumbnail == null) { + return; + } + + mThumbnailSet = true; + + cancelIconLoading(); + ImageLoader.with(getContext()).cancelRequest(mThumbnailView); + + mThumbnailView.setScaleType(SCALE_TYPE_THUMBNAIL); + mThumbnailView.setImageBitmap(thumbnail, true); + mThumbnailView.setBackgroundDrawable(null); + } + + /** + * Display the thumbnail from a URL. + * + * @param imageUrl URL of the image to show. + * @param bgColor background color to use in the view. + */ + public void displayThumbnail(final String imageUrl, final int bgColor) { + mThumbnailView.setScaleType(SCALE_TYPE_URL); + mThumbnailView.setBackgroundColor(bgColor); + mThumbnailSet = true; + + ImageLoader.with(getContext()) + .load(imageUrl) + .noFade() + .into(mThumbnailView); + } + + /** + * Update the item type associated with this view. Returns true if + * the type has changed, false otherwise. + */ + private boolean updateType(int type) { + if (mType == type) { + return false; + } + + mType = type; + refreshDrawableState(); + + int pinResourceId = (type == TopSites.TYPE_PINNED ? R.drawable.pin : 0); + mTitleView.setCompoundDrawablesWithIntrinsicBounds(pinResourceId, 0, 0, 0); + + return true; + } + + /** + * Update the title shown by this view. If both title and url + * are empty, mark the state as STATE_EMPTY and show a default text. + */ + private void updateTitleView() { + String title = getTitle(); + if (!TextUtils.isEmpty(title)) { + mTitleView.setText(title); + } else { + mTitleView.setText(R.string.home_top_sites_add); + } + } + + /** + * Display the loaded icon (if no thumbnail is set). + */ + @Override + public void onIconResponse(IconResponse response) { + if (mThumbnailSet) { + // Already showing a thumbnail; do nothing. + return; + } + + mThumbnailView.setScaleType(SCALE_TYPE_FAVICON); + mThumbnailView.setImageBitmap(response.getBitmap(), false); + mThumbnailView.setBackgroundColorWithOpacityFilter(response.getColor()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java new file mode 100644 index 000000000..58a05b198 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java @@ -0,0 +1,169 @@ +/* -*- 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.home; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.ThumbnailHelper; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.View; +import android.widget.AbsListView; +import android.widget.GridView; + +/** + * A grid view of top and pinned sites. + * Each cell in the grid is a TopSitesGridItemView. + */ +public class TopSitesGridView extends GridView { + private static final String LOGTAG = "GeckoTopSitesGridView"; + + // Listener for editing pinned sites. + public static interface OnEditPinnedSiteListener { + public void onEditPinnedSite(int position, String searchTerm); + } + + // Max number of top sites that needs to be shown. + private final int mMaxSites; + + // Number of columns to show. + private final int mNumColumns; + + // Horizontal spacing in between the rows. + private final int mHorizontalSpacing; + + // Vertical spacing in between the rows. + private final int mVerticalSpacing; + + // Measured width of this view. + private int mMeasuredWidth; + + // Measured height of this view. + private int mMeasuredHeight; + + // A dummy View used to measure the required size of the child Views. + private final TopSitesGridItemView dummyChildView; + + // Context menu info. + private TopSitesGridContextMenuInfo mContextMenuInfo; + + // Whether we're handling focus changes or not. This is used + // to avoid infinite re-layouts when using this GridView as + // a ListView header view (see bug 918044). + private boolean mIsHandlingFocusChange; + + public TopSitesGridView(Context context) { + this(context, null); + } + + public TopSitesGridView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.topSitesGridViewStyle); + } + + public TopSitesGridView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mMaxSites = getResources().getInteger(R.integer.number_of_top_sites); + mNumColumns = getResources().getInteger(R.integer.number_of_top_sites_cols); + setNumColumns(mNumColumns); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TopSitesGridView, defStyle, 0); + mHorizontalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_horizontalSpacing, 0x00); + mVerticalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_verticalSpacing, 0x00); + a.recycle(); + + dummyChildView = new TopSitesGridItemView(context); + // Set a default LayoutParams on the child, if it doesn't have one on its own. + AbsListView.LayoutParams params = (AbsListView.LayoutParams) dummyChildView.getLayoutParams(); + if (params == null) { + params = new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT, + AbsListView.LayoutParams.WRAP_CONTENT); + dummyChildView.setLayoutParams(params); + } + } + + @Override + protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { + mIsHandlingFocusChange = true; + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + mIsHandlingFocusChange = false; + } + + @Override + public void requestLayout() { + if (!mIsHandlingFocusChange) { + super.requestLayout(); + } + } + + @Override + public int getColumnWidth() { + // This method will be called from onMeasure() too. + // It's better to use getMeasuredWidth(), as it is safe in this case. + final int totalHorizontalSpacing = mNumColumns > 0 ? (mNumColumns - 1) * mHorizontalSpacing : 0; + return (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - totalHorizontalSpacing) / mNumColumns; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Sets the padding for this view. + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int measuredWidth = getMeasuredWidth(); + if (measuredWidth == mMeasuredWidth) { + // Return the cached values as the width is the same. + setMeasuredDimension(mMeasuredWidth, mMeasuredHeight); + return; + } + + final int columnWidth = getColumnWidth(); + + // Measure the exact width of the child, and the height based on the width. + // Note: the child (and TopSitesThumbnailView) takes care of calculating its height. + int childWidthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY); + int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + dummyChildView.measure(childWidthSpec, childHeightSpec); + final int childHeight = dummyChildView.getMeasuredHeight(); + + // This is the maximum width of the contents of each child in the grid. + // Use this as the target width for thumbnails. + final int thumbnailWidth = dummyChildView.getMeasuredWidth() - dummyChildView.getPaddingLeft() - dummyChildView.getPaddingRight(); + ThumbnailHelper.getInstance().setThumbnailWidth(thumbnailWidth); + + // Number of rows required to show these top sites. + final int rows = (int) Math.ceil((double) mMaxSites / mNumColumns); + final int childrenHeight = childHeight * rows; + final int totalVerticalSpacing = rows > 0 ? (rows - 1) * mVerticalSpacing : 0; + + // Total height of this view. + final int measuredHeight = childrenHeight + getPaddingTop() + getPaddingBottom() + totalVerticalSpacing; + setMeasuredDimension(measuredWidth, measuredHeight); + mMeasuredWidth = measuredWidth; + mMeasuredHeight = measuredHeight; + } + + @Override + public ContextMenuInfo getContextMenuInfo() { + return mContextMenuInfo; + } + + public void setContextMenuInfo(TopSitesGridContextMenuInfo contextMenuInfo) { + mContextMenuInfo = contextMenuInfo; + } + + /** + * Stores information regarding the creation of the context menu for a GridView item. + */ + public static class TopSitesGridContextMenuInfo extends HomeContextMenuInfo { + public int type = -1; + + public TopSitesGridContextMenuInfo(View targetView, int position, long id) { + super(targetView, position, id); + this.itemType = RemoveItemType.HISTORY; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java new file mode 100644 index 000000000..f39e51ac5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java @@ -0,0 +1,968 @@ +/* -*- 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.home; + +import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN; +import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Future; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserContract.Thumbnails; +import org.mozilla.gecko.db.BrowserContract.TopSites; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType; +import org.mozilla.gecko.home.HomePager.OnUrlOpenListener; +import org.mozilla.gecko.home.PinSiteDialog.OnSiteSelectedListener; +import org.mozilla.gecko.home.TopSitesGridView.OnEditPinnedSiteListener; +import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Bundle; +import android.os.SystemClock; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.LoaderManager.LoaderCallbacks; +import android.support.v4.content.AsyncTaskLoader; +import android.support.v4.content.Loader; +import android.support.v4.widget.CursorAdapter; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ListView; + +/** + * Fragment that displays frecency search results in a ListView. + */ +public class TopSitesPanel extends HomeFragment { + // Logging tag name + private static final String LOGTAG = "GeckoTopSitesPanel"; + + // Cursor loader ID for the top sites + private static final int LOADER_ID_TOP_SITES = 0; + + // Loader ID for thumbnails + private static final int LOADER_ID_THUMBNAILS = 1; + + // Key for thumbnail urls + private static final String THUMBNAILS_URLS_KEY = "urls"; + + // Adapter for the list of top sites + private VisitedAdapter mListAdapter; + + // Adapter for the grid of top sites + private TopSitesGridAdapter mGridAdapter; + + // List of top sites + private HomeListView mList; + + // Grid of top sites + private TopSitesGridView mGrid; + + // Callbacks used for the search and favicon cursor loaders + private CursorLoaderCallbacks mCursorLoaderCallbacks; + + // Callback for thumbnail loader + private ThumbnailsLoaderCallbacks mThumbnailsLoaderCallbacks; + + // Listener for editing pinned sites. + private EditPinnedSiteListener mEditPinnedSiteListener; + + // Max number of entries shown in the grid from the cursor. + private int mMaxGridEntries; + + // Time in ms until the Gecko thread is reset to normal priority. + private static final long PRIORITY_RESET_TIMEOUT = 10000; + + public static TopSitesPanel newInstance() { + return new TopSitesPanel(); + } + + private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); + private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); + + private static void debug(final String message) { + if (logDebug) { + Log.d(LOGTAG, message); + } + } + + private static void trace(final String message) { + if (logVerbose) { + Log.v(LOGTAG, message); + } + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + mMaxGridEntries = activity.getResources().getInteger(R.integer.number_of_top_sites); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.home_top_sites_panel, container, false); + + mList = (HomeListView) view.findViewById(R.id.list); + + mGrid = new TopSitesGridView(getActivity()); + mList.addHeaderView(mGrid); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + mEditPinnedSiteListener = new EditPinnedSiteListener(); + + mList.setTag(HomePager.LIST_TAG_TOP_SITES); + mList.setHeaderDividersEnabled(false); + + mList.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final ListView list = (ListView) parent; + final int headerCount = list.getHeaderViewsCount(); + if (position < headerCount) { + // The click is on a header, don't do anything. + return; + } + + // Absolute position for the adapter. + position += (mGridAdapter.getCount() - headerCount); + + final Cursor c = mListAdapter.getCursor(); + if (c == null || !c.moveToPosition(position)) { + return; + } + + final String url = c.getString(c.getColumnIndexOrThrow(TopSites.URL)); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "top_sites"); + + // This item is a TwoLinePageRow, so we allow switch-to-tab. + mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + } + }); + + mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() { + @Override + public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) { + final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id); + info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); + info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); + info.itemType = RemoveItemType.HISTORY; + final int bookmarkIdCol = cursor.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); + if (cursor.isNull(bookmarkIdCol)) { + // If this is a combined cursor, we may get a history item without a + // bookmark, in which case the bookmarks ID column value will be null. + info.bookmarkId = -1; + } else { + info.bookmarkId = cursor.getInt(bookmarkIdCol); + } + return info; + } + }); + + mGrid.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + TopSitesGridItemView item = (TopSitesGridItemView) view; + + // Decode "user-entered" URLs before loading them. + String url = StringUtils.decodeUserEnteredUrl(item.getUrl()); + int type = item.getType(); + + // If the url is empty, the user can pin a site. + // If not, navigate to the page given by the url. + if (type != TopSites.TYPE_BLANK) { + if (mUrlOpenListener != null) { + final TelemetryContract.Method method; + if (type == TopSites.TYPE_SUGGESTED) { + method = TelemetryContract.Method.SUGGESTION; + } else { + method = TelemetryContract.Method.GRID_ITEM; + } + + String extra = Integer.toString(position); + if (type == TopSites.TYPE_PINNED) { + extra += "-pinned"; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, extra); + + mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.NO_READER_VIEW)); + } + } else { + if (mEditPinnedSiteListener != null) { + mEditPinnedSiteListener.onEditPinnedSite(position, ""); + } + } + } + }); + + mGrid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + + Cursor cursor = (Cursor) parent.getItemAtPosition(position); + + TopSitesGridItemView item = (TopSitesGridItemView) view; + if (cursor == null || item.getType() == TopSites.TYPE_BLANK) { + mGrid.setContextMenuInfo(null); + return false; + } + + TopSitesGridContextMenuInfo contextMenuInfo = new TopSitesGridContextMenuInfo(view, position, id); + updateContextMenuFromCursor(contextMenuInfo, cursor); + mGrid.setContextMenuInfo(contextMenuInfo); + return mGrid.showContextMenuForChild(mGrid); + } + + /* + * Update the fields of a TopSitesGridContextMenuInfo object + * from a cursor. + * + * @param info context menu info object to be updated + * @param cursor used to update the context menu info object + */ + private void updateContextMenuFromCursor(TopSitesGridContextMenuInfo info, Cursor cursor) { + info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); + info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); + info.type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); + info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID)); + } + }); + + registerForContextMenu(mList); + registerForContextMenu(mGrid); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + // Discard any additional item clicks on the list as the + // panel is getting destroyed (see bugs 930160 & 1096958). + mList.setOnItemClickListener(null); + mGrid.setOnItemClickListener(null); + + mList = null; + mGrid = null; + mListAdapter = null; + mGridAdapter = null; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final Activity activity = getActivity(); + + // Setup the top sites grid adapter. + mGridAdapter = new TopSitesGridAdapter(activity, null); + mGrid.setAdapter(mGridAdapter); + + // Setup the top sites list adapter. + mListAdapter = new VisitedAdapter(activity, null); + mList.setAdapter(mListAdapter); + + // Create callbacks before the initial loader is started + mCursorLoaderCallbacks = new CursorLoaderCallbacks(); + mThumbnailsLoaderCallbacks = new ThumbnailsLoaderCallbacks(); + loadIfVisible(); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { + if (menuInfo == null) { + return; + } + + if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { + // Long pressed item was not a Top Sites GridView item. Superclass + // can handle this. + super.onCreateContextMenu(menu, view, menuInfo); + + if (!Restrictions.isAllowed(view.getContext(), Restrictable.CLEAR_HISTORY)) { + menu.findItem(R.id.home_remove).setVisible(false); + } + + return; + } + + final Context context = view.getContext(); + + // Long pressed item was a Top Sites GridView item, handle it. + MenuInflater inflater = new MenuInflater(context); + inflater.inflate(R.menu.home_contextmenu, menu); + + // Hide unused menu items. + menu.findItem(R.id.home_edit_bookmark).setVisible(false); + + menu.findItem(R.id.home_remove).setVisible(Restrictions.isAllowed(context, Restrictable.CLEAR_HISTORY)); + + TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; + menu.setHeaderTitle(info.getDisplayTitle()); + + if (info.type != TopSites.TYPE_BLANK) { + if (info.type == TopSites.TYPE_PINNED) { + menu.findItem(R.id.top_sites_pin).setVisible(false); + } else { + menu.findItem(R.id.top_sites_unpin).setVisible(false); + } + } else { + menu.findItem(R.id.home_open_new_tab).setVisible(false); + menu.findItem(R.id.home_open_private_tab).setVisible(false); + menu.findItem(R.id.top_sites_pin).setVisible(false); + menu.findItem(R.id.top_sites_unpin).setVisible(false); + } + + if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) { + menu.findItem(R.id.home_share).setVisible(false); + } + + if (!Restrictions.isAllowed(context, Restrictable.PRIVATE_BROWSING)) { + menu.findItem(R.id.home_open_private_tab).setVisible(false); + } + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + if (super.onContextItemSelected(item)) { + // HomeFragment was able to handle to selected item. + return true; + } + + ContextMenuInfo menuInfo = item.getMenuInfo(); + + if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) { + return false; + } + + TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo; + + final int itemId = item.getItemId(); + final BrowserDB db = BrowserDB.from(getActivity()); + + if (itemId == R.id.top_sites_pin) { + final String url = info.url; + final String title = info.title; + final int position = info.position; + final Context context = getActivity().getApplicationContext(); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.pinSite(context.getContentResolver(), url, title, position); + } + }); + + Telemetry.sendUIEvent(TelemetryContract.Event.PIN); + return true; + } + + if (itemId == R.id.top_sites_unpin) { + final int position = info.position; + final Context context = getActivity().getApplicationContext(); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.unpinSite(context.getContentResolver(), position); + } + }); + + Telemetry.sendUIEvent(TelemetryContract.Event.UNPIN); + + return true; + } + + if (itemId == R.id.top_sites_edit) { + // Decode "user-entered" URLs before showing them. + mEditPinnedSiteListener.onEditPinnedSite(info.position, + StringUtils.decodeUserEnteredUrl(info.url)); + + Telemetry.sendUIEvent(TelemetryContract.Event.EDIT); + return true; + } + + return false; + } + + @Override + protected void load() { + getLoaderManager().initLoader(LOADER_ID_TOP_SITES, null, mCursorLoaderCallbacks); + + // Since this is the primary fragment that loads whenever about:home is + // visited, we want to load it as quickly as possible. Heavy load on + // the Gecko thread can slow down the time it takes for thumbnails to + // appear, especially during startup (bug 897162). By minimizing the + // Gecko thread priority, we ensure that the UI appears quickly. The + // priority is reset to normal once thumbnails are loaded. + ThreadUtils.reduceGeckoPriority(PRIORITY_RESET_TIMEOUT); + } + + /** + * Listener for editing pinned sites. + */ + private class EditPinnedSiteListener implements OnEditPinnedSiteListener, + OnSiteSelectedListener { + // Tag for the PinSiteDialog fragment. + private static final String TAG_PIN_SITE = "pin_site"; + + // Position of the pin. + private int mPosition; + + @Override + public void onEditPinnedSite(int position, String searchTerm) { + final FragmentManager manager = getChildFragmentManager(); + PinSiteDialog dialog = (PinSiteDialog) manager.findFragmentByTag(TAG_PIN_SITE); + if (dialog == null) { + mPosition = position; + + dialog = PinSiteDialog.newInstance(); + dialog.setOnSiteSelectedListener(this); + dialog.setSearchTerm(searchTerm); + dialog.show(manager, TAG_PIN_SITE); + } + } + + @Override + public void onSiteSelected(final String url, final String title) { + final int position = mPosition; + final Context context = getActivity().getApplicationContext(); + final BrowserDB db = BrowserDB.from(getActivity()); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + db.pinSite(context.getContentResolver(), url, title, position); + } + }); + } + } + + private void updateUiFromCursor(Cursor c) { + mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries); + } + + private void updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails) { + if (mGridAdapter != null) { + mGridAdapter.updateThumbnails(thumbnails); + } + + // Once thumbnails have finished loading, the UI is ready. Reset + // Gecko to normal priority. + ThreadUtils.resetGeckoPriority(); + } + + private static class TopSitesLoader extends SimpleCursorLoader { + // Max number of search results. + private static final int SEARCH_LIMIT = 30; + private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_TOPSITES_LOADER_TIME_MS"; + private final BrowserDB mDB; + private final int mMaxGridEntries; + + public TopSitesLoader(Context context) { + super(context); + mMaxGridEntries = context.getResources().getInteger(R.integer.number_of_top_sites); + mDB = BrowserDB.from(context); + } + + @Override + public Cursor loadCursor() { + final long start = SystemClock.uptimeMillis(); + final Cursor cursor = mDB.getTopSites(getContext().getContentResolver(), mMaxGridEntries, SEARCH_LIMIT); + final long end = SystemClock.uptimeMillis(); + final long took = end - start; + Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE)); + return cursor; + } + } + + private class VisitedAdapter extends CursorAdapter { + public VisitedAdapter(Context context, Cursor cursor) { + super(context, cursor, 0); + } + + @Override + public int getCount() { + return Math.max(0, super.getCount() - mMaxGridEntries); + } + + @Override + public Object getItem(int position) { + return super.getItem(position + mMaxGridEntries); + } + + /** + * We have to override default getItemId implementation, since for a given position, it returns + * value of the _id column. In our case _id is always 0 (see Combined view). + */ + @Override + public long getItemId(int position) { + final int adjustedPosition = position + mMaxGridEntries; + final Cursor cursor = getCursor(); + + cursor.moveToPosition(adjustedPosition); + return getItemIdForTopSitesCursor(cursor); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + final int position = cursor.getPosition(); + cursor.moveToPosition(position + mMaxGridEntries); + + final TwoLinePageRow row = (TwoLinePageRow) view; + row.updateFromCursor(cursor); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return LayoutInflater.from(context).inflate(R.layout.bookmark_item_row, parent, false); + } + } + + public class TopSitesGridAdapter extends CursorAdapter { + private final BrowserDB mDB; + // Cache to store the thumbnails. + // Ensure that this is only accessed from the UI thread. + private Map<String, ThumbnailInfo> mThumbnailInfos; + + public TopSitesGridAdapter(Context context, Cursor cursor) { + super(context, cursor, 0); + mDB = BrowserDB.from(context); + } + + @Override + public int getCount() { + return Math.min(mMaxGridEntries, super.getCount()); + } + + @Override + protected void onContentChanged() { + // Don't do anything. We don't want to regenerate every time + // our database is updated. + return; + } + + /** + * Update the thumbnails returned by the db. + * + * @param thumbnails A map of urls and their thumbnail bitmaps. + */ + public void updateThumbnails(Map<String, ThumbnailInfo> thumbnails) { + mThumbnailInfos = thumbnails; + + final int count = mGrid.getChildCount(); + for (int i = 0; i < count; i++) { + TopSitesGridItemView gridItem = (TopSitesGridItemView) mGrid.getChildAt(i); + + // All the views have already got their initial state at this point. + // This will force each view to load favicons for the missing + // thumbnails if necessary. + gridItem.markAsDirty(); + } + + notifyDataSetChanged(); + } + + /** + * We have to override default getItemId implementation, since for a given position, it returns + * value of the _id column. In our case _id is always 0 (see Combined view). + */ + @Override + public long getItemId(int position) { + final Cursor cursor = getCursor(); + cursor.moveToPosition(position); + + return getItemIdForTopSitesCursor(cursor); + } + + @Override + public void bindView(View bindView, Context context, Cursor cursor) { + final String url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL)); + final String title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE)); + final int type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE)); + + final TopSitesGridItemView view = (TopSitesGridItemView) bindView; + + // If there is no url, then show "add bookmark". + if (type == TopSites.TYPE_BLANK) { + view.blankOut(); + return; + } + + // Show the thumbnail, if any. + ThumbnailInfo thumbnail = (mThumbnailInfos != null ? mThumbnailInfos.get(url) : null); + + // Debounce bindView calls to avoid redundant redraws and favicon + // fetches. + final boolean updated = view.updateState(title, url, type, thumbnail); + + // Thumbnails are delivered late, so we can't short-circuit any + // sooner than this. But we can avoid a duplicate favicon + // fetch... + if (!updated) { + debug("bindView called twice for same values; short-circuiting."); + return; + } + + // Make sure we query suggested images without the user-entered wrapper. + final String decodedUrl = StringUtils.decodeUserEnteredUrl(url); + + // Suggested images have precedence over thumbnails, no need to wait + // for them to be loaded. See: CursorLoaderCallbacks.onLoadFinished() + final String imageUrl = mDB.getSuggestedImageUrlForUrl(decodedUrl); + if (!TextUtils.isEmpty(imageUrl)) { + final int bgColor = mDB.getSuggestedBackgroundColorForUrl(decodedUrl); + view.displayThumbnail(imageUrl, bgColor); + return; + } + + // If thumbnails are still being loaded, don't try to load favicons + // just yet. If we sent in a thumbnail, we're done now. + if (mThumbnailInfos == null || thumbnail != null) { + return; + } + + view.loadFavicon(url); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return new TopSitesGridItemView(context); + } + } + + private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + trace("Creating TopSitesLoader: " + id); + return new TopSitesLoader(getActivity()); + } + + /** + * This method is called *twice* in some circumstances. + * + * If you try to avoid that through some kind of boolean flag, + * sometimes (e.g., returning to the activity) you'll *not* be called + * twice, and thus you'll never draw thumbnails. + * + * The root cause is TopSitesLoader.loadCursor being called twice. + * Why that is... dunno. + */ + public void onLoadFinished(Loader<Cursor> loader, Cursor c) { + debug("onLoadFinished: " + c.getCount() + " rows."); + + mListAdapter.swapCursor(c); + mGridAdapter.swapCursor(c); + updateUiFromCursor(c); + + final int col = c.getColumnIndexOrThrow(TopSites.URL); + + // Load the thumbnails. + // Even though the cursor we're given is supposed to be fresh, + // we getIcon a bad first value unless we reset its position. + // Using move(-1) and moveToNext() doesn't work correctly under + // rotation, so we use moveToFirst. + if (!c.moveToFirst()) { + return; + } + + final ArrayList<String> urls = new ArrayList<String>(); + int i = 1; + do { + final String url = c.getString(col); + + // Only try to fetch thumbnails for non-empty URLs that + // don't have an associated suggested image URL. + final GeckoProfile profile = GeckoProfile.get(getActivity()); + if (TextUtils.isEmpty(url) || BrowserDB.from(profile).hasSuggestedImageUrl(url)) { + continue; + } + + urls.add(url); + } while (i++ < mMaxGridEntries && c.moveToNext()); + + if (urls.isEmpty()) { + // Short-circuit empty results to the UI. + updateUiWithThumbnails(new HashMap<String, ThumbnailInfo>()); + return; + } + + Bundle bundle = new Bundle(); + bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls); + getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (mListAdapter != null) { + mListAdapter.swapCursor(null); + } + + if (mGridAdapter != null) { + mGridAdapter.swapCursor(null); + } + } + } + + static class ThumbnailInfo { + public final Bitmap bitmap; + public final String imageUrl; + public final int bgColor; + + public ThumbnailInfo(final Bitmap bitmap) { + this.bitmap = bitmap; + this.imageUrl = null; + this.bgColor = Color.TRANSPARENT; + } + + public ThumbnailInfo(final String imageUrl, final int bgColor) { + this.bitmap = null; + this.imageUrl = imageUrl; + this.bgColor = bgColor; + } + + public static ThumbnailInfo fromMetadata(final Map<String, Object> data) { + if (data == null) { + return null; + } + + final String imageUrl = (String) data.get(TILE_IMAGE_URL_COLUMN); + if (imageUrl == null) { + return null; + } + + int bgColor = Color.WHITE; + final String colorString = (String) data.get(TILE_COLOR_COLUMN); + try { + bgColor = Color.parseColor(colorString); + } catch (Exception ex) { + } + + return new ThumbnailInfo(imageUrl, bgColor); + } + } + + /** + * An AsyncTaskLoader to load the thumbnails from a cursor. + */ + static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, ThumbnailInfo>> { + private final BrowserDB mDB; + private Map<String, ThumbnailInfo> mThumbnailInfos; + private final ArrayList<String> mUrls; + + private static final List<String> COLUMNS; + static { + final ArrayList<String> tempColumns = new ArrayList<>(2); + tempColumns.add(TILE_IMAGE_URL_COLUMN); + tempColumns.add(TILE_COLOR_COLUMN); + COLUMNS = Collections.unmodifiableList(tempColumns); + } + + public ThumbnailsLoader(Context context, ArrayList<String> urls) { + super(context); + mUrls = urls; + mDB = BrowserDB.from(context); + } + + @Override + public Map<String, ThumbnailInfo> loadInBackground() { + final Map<String, ThumbnailInfo> thumbnails = new HashMap<String, ThumbnailInfo>(); + if (mUrls == null || mUrls.size() == 0) { + return thumbnails; + } + + // We need to query metadata based on the URL without any refs, hence we create a new + // mapping and list of these URLs (we need to preserve the original URL for display purposes) + final Map<String, String> queryURLs = new HashMap<>(); + for (final String pageURL : mUrls) { + queryURLs.put(pageURL, StringUtils.stripRef(pageURL)); + } + + // Query the DB for tile images. + final ContentResolver cr = getContext().getContentResolver(); + // Use the stripped URLs for querying the DB + final Map<String, Map<String, Object>> metadata = mDB.getURLMetadata().getForURLs(cr, queryURLs.values(), COLUMNS); + + // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead. + final List<String> thumbnailUrls = new ArrayList<String>(); + for (final String pageURL : mUrls) { + final String queryURL = queryURLs.get(pageURL); + + ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(queryURL)); + if (info == null) { + // If we didn't find metadata, we'll look for a thumbnail for this url. + thumbnailUrls.add(pageURL); + continue; + } + + thumbnails.put(pageURL, info); + } + + if (thumbnailUrls.size() == 0) { + return thumbnails; + } + + // Query the DB for tile thumbnails. + final Cursor cursor = mDB.getThumbnailsForUrls(cr, thumbnailUrls); + if (cursor == null) { + return thumbnails; + } + + try { + final int urlIndex = cursor.getColumnIndexOrThrow(Thumbnails.URL); + final int dataIndex = cursor.getColumnIndexOrThrow(Thumbnails.DATA); + + while (cursor.moveToNext()) { + String url = cursor.getString(urlIndex); + + // This should never be null, but if it is... + final byte[] b = cursor.getBlob(dataIndex); + if (b == null) { + continue; + } + + final Bitmap bitmap = BitmapUtils.decodeByteArray(b); + + // Our thumbnails are never null, so if we getIcon a null decoded + // bitmap, it's because we hit an OOM or some other disaster. + // Give up immediately rather than hammering on. + if (bitmap == null) { + Log.w(LOGTAG, "Aborting thumbnail load; decode failed."); + break; + } + + thumbnails.put(url, new ThumbnailInfo(bitmap)); + } + } finally { + cursor.close(); + } + + return thumbnails; + } + + @Override + public void deliverResult(Map<String, ThumbnailInfo> thumbnails) { + if (isReset()) { + mThumbnailInfos = null; + return; + } + + mThumbnailInfos = thumbnails; + + if (isStarted()) { + super.deliverResult(thumbnails); + } + } + + @Override + protected void onStartLoading() { + if (mThumbnailInfos != null) { + deliverResult(mThumbnailInfos); + } + + if (takeContentChanged() || mThumbnailInfos == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public void onCanceled(Map<String, ThumbnailInfo> thumbnails) { + mThumbnailInfos = null; + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped. + onStopLoading(); + + mThumbnailInfos = null; + } + } + + /** + * Loader callbacks for the thumbnails on TopSitesGridView. + */ + private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, ThumbnailInfo>> { + @Override + public Loader<Map<String, ThumbnailInfo>> onCreateLoader(int id, Bundle args) { + return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY)); + } + + @Override + public void onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails) { + updateUiWithThumbnails(thumbnails); + } + + @Override + public void onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader) { + if (mGridAdapter != null) { + mGridAdapter.updateThumbnails(null); + } + } + } + + /** + * We are trying to return stable IDs so that Android can recycle views appropriately: + * - If we have a history ID then we return it + * - If we only have a bookmark ID then we negate it and return it. We negate it in order + * to avoid clashing/conflicting with history IDs. + * + * @param cursorInPosition Cursor already moved to position for which we're getting a stable ID + * @return Stable ID for a given cursor + */ + private static long getItemIdForTopSitesCursor(final Cursor cursorInPosition) { + final int historyIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.HISTORY_ID); + final long historyId = cursorInPosition.getLong(historyIdCol); + if (historyId != 0) { + return historyId; + } + + final int bookmarkIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.BOOKMARK_ID); + final long bookmarkId = cursorInPosition.getLong(bookmarkIdCol); + return -1 * bookmarkId; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java new file mode 100644 index 000000000..dd45014b0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java @@ -0,0 +1,102 @@ +/* -*- 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.home; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.ThumbnailHelper; +import org.mozilla.gecko.widget.CropImageView; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff.Mode; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +/** + * A width constrained ImageView to show thumbnails of top and pinned sites. + */ +public class TopSitesThumbnailView extends CropImageView { + private static final String LOGTAG = "GeckoTopSitesThumbnailView"; + + // 27.34% opacity filter for the dominant color. + private static final int COLOR_FILTER = 0x46FFFFFF; + + // Default filter color for "Add a bookmark" views. + private final int mDefaultColor = ContextCompat.getColor(getContext(), R.color.top_site_default); + + // Stroke width for the border. + private final float mStrokeWidth = getResources().getDisplayMetrics().density * 2; + + // Paint for drawing the border. + private final Paint mBorderPaint; + + public TopSitesThumbnailView(Context context) { + this(context, null); + + // A border will be drawn if needed. + setWillNotDraw(false); + } + + public TopSitesThumbnailView(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.topSitesThumbnailViewStyle); + } + + public TopSitesThumbnailView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + // Initialize the border paint. + final Resources res = getResources(); + mBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mBorderPaint.setColor(ContextCompat.getColor(context, R.color.top_site_border)); + mBorderPaint.setStyle(Paint.Style.STROKE); + } + + @Override + protected float getAspectRatio() { + return ThumbnailHelper.TOP_SITES_THUMBNAIL_ASPECT_RATIO; + } + + /** + * {@inheritDoc} + */ + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (getBackground() == null) { + mBorderPaint.setStrokeWidth(mStrokeWidth); + canvas.drawRect(0, 0, getWidth(), getHeight(), mBorderPaint); + } + } + + /** + * Sets the background color with a filter to reduce the color opacity. + * + * @param color the color filter to apply over the drawable. + */ + public void setBackgroundColorWithOpacityFilter(int color) { + setBackgroundColor(color & COLOR_FILTER); + } + + /** + * Sets the background to a Drawable by applying the specified color as a filter. + * + * @param color the color filter to apply over the drawable. + */ + @Override + public void setBackgroundColor(int color) { + if (color == 0) { + color = mDefaultColor; + } + + Drawable drawable = getResources().getDrawable(R.drawable.top_sites_thumbnail_bg); + drawable.setColorFilter(color, Mode.SRC_ATOP); + setBackgroundDrawable(drawable); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java new file mode 100644 index 000000000..68eb8daa5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java @@ -0,0 +1,324 @@ +/* -*- 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.home; + +import java.lang.ref.WeakReference; +import java.util.concurrent.Future; + +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Combined; +import org.mozilla.gecko.db.BrowserContract.URLColumns; +import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy; +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.reader.SavedReaderViewHelper; +import org.mozilla.gecko.widget.FaviconView; + +public class TwoLinePageRow extends LinearLayout + implements Tabs.OnTabsChangedListener { + + protected static final int NO_ICON = 0; + + private final TextView mTitle; + private final TextView mUrl; + private final ImageView mStatusIcon; + + private int mSwitchToTabIconId; + + private final FaviconView mFavicon; + private Future<IconResponse> mOngoingIconLoad; + + private boolean mShowIcons; + + // The URL for the page corresponding to this view. + private String mPageUrl; + + private boolean mHasReaderCacheItem; + + public TwoLinePageRow(Context context) { + this(context, null); + } + + public TwoLinePageRow(Context context, AttributeSet attrs) { + super(context, attrs); + + setGravity(Gravity.CENTER_VERTICAL); + + LayoutInflater.from(context).inflate(R.layout.two_line_page_row, this); + // Merge layouts lose their padding, so set it dynamically. + setPadding(0, 0, (int) getResources().getDimension(R.dimen.page_row_edge_padding), 0); + + mTitle = (TextView) findViewById(R.id.title); + mUrl = (TextView) findViewById(R.id.url); + mStatusIcon = (ImageView) findViewById(R.id.status_icon_bookmark); + + mSwitchToTabIconId = NO_ICON; + mShowIcons = true; + + mFavicon = (FaviconView) findViewById(R.id.icon); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + Tabs.registerOnTabsChangedListener(this); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + // Tabs' listener array is safe to modify during use: its + // iteration pattern is based on snapshots. + Tabs.unregisterOnTabsChangedListener(this); + } + + /** + * Update the row in response to a tab change event. + * <p> + * This method is always invoked on the UI thread. + */ + @Override + public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) { + // Carefully check if this tab event is relevant to this row. + final String pageUrl = mPageUrl; + if (pageUrl == null) { + return; + } + if (tab == null) { + return; + } + + // Return early if the page URL doesn't match the current tab URL, + // or the old tab URL. + // data is an empty String for ADDED/CLOSED, and contains the previous/old URL during + // LOCATION_CHANGE (the new URL is retrieved using tab.getURL()). + // tabURL and data may be about:reader URLs if the current or old tab page was a reader view + // page, however pageUrl will always be a plain URL (i.e. we only add about:reader when opening + // a reader view bookmark, at all other times it's a normal bookmark with normal URL). + final String tabUrl = tab.getURL(); + if (!pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(tabUrl)) && + !pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(data))) { + return; + } + + // Note: we *might* need to update the display status (i.e. switch-to-tab icon/label) if + // a matching tab has been opened/closed/switched to a different page. updateDisplayedUrl() will + // determine the changes (if any) that actually need to be made. A tab change with a matching URL + // does not imply that any changes are needed - e.g. if a given URL is already open in one tab, and + // is also opened in a second tab, the switch-to-tab status doesn't change, closing 1 of 2 tabs with a URL + // similarly doesn't change the switch-to-tab display, etc. (However closing the last tab for + // a given URL does require a status change, as does opening the first tab with that URL.) + switch (msg) { + case ADDED: + case CLOSED: + case LOCATION_CHANGE: + updateDisplayedUrl(); + break; + default: + break; + } + } + + private void setTitle(String text) { + mTitle.setText(text); + } + + protected void setUrl(String text) { + mUrl.setText(text); + } + + protected void setUrl(int stringId) { + mUrl.setText(stringId); + } + + protected String getUrl() { + return mPageUrl; + } + + protected void setSwitchToTabIcon(int iconId) { + if (mSwitchToTabIconId == iconId) { + return; + } + + mSwitchToTabIconId = iconId; + mUrl.setCompoundDrawablesWithIntrinsicBounds(mSwitchToTabIconId, 0, 0, 0); + } + + private void updateStatusIcon(boolean isBookmark, boolean isReaderItem) { + if (isReaderItem) { + mStatusIcon.setImageResource(R.drawable.status_icon_readercache); + } else if (isBookmark) { + mStatusIcon.setImageResource(R.drawable.star_blue); + } + + if (mShowIcons && (isBookmark || isReaderItem)) { + mStatusIcon.setVisibility(View.VISIBLE); + } else if (mShowIcons) { + // We use INVISIBLE to have consistent padding for our items. This means text/URLs + // fade consistently in the same location, regardless of them being bookmarked. + mStatusIcon.setVisibility(View.INVISIBLE); + } else { + mStatusIcon.setVisibility(View.GONE); + } + + } + + /** + * Stores the page URL, so that we can use it to replace "Switch to tab" if the open + * tab changes or is closed. + */ + private void updateDisplayedUrl(String url, boolean hasReaderCacheItem) { + mPageUrl = url; + mHasReaderCacheItem = hasReaderCacheItem; + updateDisplayedUrl(); + } + + /** + * Replaces the page URL with "Switch to tab" if there is already a tab open with that URL. + * Only looks for tabs that are either private or non-private, depending on the current + * selected tab. + */ + protected void updateDisplayedUrl() { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + final boolean isPrivate = (selectedTab != null) && (selectedTab.isPrivate()); + + // We always want to display the underlying page url, however for readermode pages + // we navigate to the about:reader equivalent, hence we need to use that url when finding + // existing tabs + final String navigationUrl = mHasReaderCacheItem ? ReaderModeUtils.getAboutReaderForUrl(mPageUrl) : mPageUrl; + Tab tab = Tabs.getInstance().getFirstTabForUrl(navigationUrl, isPrivate); + + + if (!mShowIcons || tab == null) { + setUrl(mPageUrl); + setSwitchToTabIcon(NO_ICON); + } else { + setUrl(R.string.switch_to_tab); + setSwitchToTabIcon(R.drawable.ic_url_bar_tab); + } + } + + public void setShowIcons(boolean showIcons) { + mShowIcons = showIcons; + } + + /** + * Update the data displayed by this row. + * <p> + * This method must be invoked on the UI thread. + * + * @param title to display. + * @param url to display. + */ + public void update(String title, String url) { + update(title, url, 0, false); + } + + protected void update(String title, String url, long bookmarkId, boolean hasReaderCacheItem) { + if (mShowIcons) { + // The bookmark id will be 0 (null in database) when the url + // is not a bookmark and negative for 'fake' bookmarks. + final boolean isBookmark = bookmarkId > 0; + + updateStatusIcon(isBookmark, hasReaderCacheItem); + } else { + updateStatusIcon(false, false); + } + + // Use the URL instead of an empty title for consistency with the normal URL + // bar view - this is the equivalent of getDisplayTitle() in Tab.java + setTitle(TextUtils.isEmpty(title) ? url : title); + + // No point updating the below things if URL has not changed. Prevents evil Favicon flicker. + if (url.equals(mPageUrl)) { + return; + } + + // Blank the Favicon, so we don't show the wrong Favicon if we scroll and miss DB. + mFavicon.clearImage(); + + if (mOngoingIconLoad != null) { + mOngoingIconLoad.cancel(true); + } + + // Displayed RecentTabsPanel URLs may refer to pages opened in reader mode, so we + // remove the about:reader prefix to ensure the Favicon loads properly. + final String pageURL = ReaderModeUtils.stripAboutReaderUrl(url); + + if (TextUtils.isEmpty(pageURL)) { + // If url is empty, display the item as-is but do not load an icon if we do not have a page URL (bug 1310622) + } else if (bookmarkId < BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START) { + mOngoingIconLoad = Icons.with(getContext()) + .pageUrl(pageURL) + .skipNetwork() + .privileged(true) + .icon(IconDescriptor.createGenericIcon( + PartnerBookmarksProviderProxy.getUriForIcon(getContext(), bookmarkId).toString())) + .build() + .execute(mFavicon.createIconCallback()); + } else { + mOngoingIconLoad = Icons.with(getContext()) + .pageUrl(pageURL) + .skipNetwork() + .build() + .execute(mFavicon.createIconCallback()); + + } + + updateDisplayedUrl(url, hasReaderCacheItem); + } + + /** + * Update the data displayed by this row. + * <p> + * This method must be invoked on the UI thread. + * + * @param cursor to extract data from. + */ + public void updateFromCursor(Cursor cursor) { + if (cursor == null) { + return; + } + + int titleIndex = cursor.getColumnIndexOrThrow(URLColumns.TITLE); + final String title = cursor.getString(titleIndex); + + int urlIndex = cursor.getColumnIndexOrThrow(URLColumns.URL); + final String url = cursor.getString(urlIndex); + + final long bookmarkId; + final int bookmarkIdIndex = cursor.getColumnIndex(Combined.BOOKMARK_ID); + if (bookmarkIdIndex != -1) { + bookmarkId = cursor.getLong(bookmarkIdIndex); + } else { + bookmarkId = 0; + } + + SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(getContext()); + final boolean hasReaderCacheItem = rch.isURLCached(url); + + update(title, url, bookmarkId, hasReaderCacheItem); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java new file mode 100644 index 000000000..ef0c105d3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java @@ -0,0 +1,145 @@ +/* -*- 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.home.activitystream; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.ContextCompat; +import android.support.v4.content.Loader; +import android.support.v4.graphics.ColorUtils; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.widget.FrameLayout; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter; +import org.mozilla.gecko.util.ContextUtils; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +public class ActivityStream extends FrameLayout { + private final StreamRecyclerAdapter adapter; + + private static final int LOADER_ID_HIGHLIGHTS = 0; + private static final int LOADER_ID_TOPSITES = 1; + + private static final int MINIMUM_TILES = 4; + private static final int MAXIMUM_TILES = 6; + + private int desiredTileWidth; + private int desiredTilesHeight; + private int tileMargin; + + public ActivityStream(Context context, AttributeSet attrs) { + super(context, attrs); + + setBackgroundColor(ContextCompat.getColor(context, R.color.about_page_header_grey)); + + inflate(context, R.layout.as_content, this); + + adapter = new StreamRecyclerAdapter(); + + RecyclerView rv = (RecyclerView) findViewById(R.id.activity_stream_main_recyclerview); + + rv.setAdapter(adapter); + rv.setLayoutManager(new LinearLayoutManager(getContext())); + rv.setHasFixedSize(true); + + RecyclerViewClickSupport.addTo(rv) + .setOnItemClickListener(adapter); + + final Resources resources = getResources(); + desiredTileWidth = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_width); + desiredTilesHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_height); + tileMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin); + } + + void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + adapter.setOnUrlOpenListeners(onUrlOpenListener, onUrlOpenInBackgroundListener); + } + + public void load(LoaderManager lm) { + CursorLoaderCallbacks callbacks = new CursorLoaderCallbacks(); + + lm.initLoader(LOADER_ID_HIGHLIGHTS, null, callbacks); + lm.initLoader(LOADER_ID_TOPSITES, null, callbacks); + } + + public void unload() { + adapter.swapHighlightsCursor(null); + adapter.swapTopSitesCursor(null); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + int tiles = (w - tileMargin) / (desiredTileWidth + tileMargin); + + if (tiles < MINIMUM_TILES) { + tiles = MINIMUM_TILES; + + setPadding(0, 0, 0, 0); + } else if (tiles > MAXIMUM_TILES) { + tiles = MAXIMUM_TILES; + + // Use the remaining space as padding + int needed = tiles * (desiredTileWidth + tileMargin) + tileMargin; + int padding = (w - needed) / 2; + w = needed; + + setPadding(padding, 0, padding, 0); + } else { + setPadding(0, 0, 0, 0); + } + + final float ratio = (float) desiredTilesHeight / (float) desiredTileWidth; + final int tilesWidth = (w - (tiles * tileMargin) - tileMargin) / tiles; + final int tilesHeight = (int) (ratio * tilesWidth); + + adapter.setTileSize(tiles, tilesWidth, tilesHeight); + } + + private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + final Context context = getContext(); + if (id == LOADER_ID_HIGHLIGHTS) { + return BrowserDB.from(context).getHighlights(context, 10); + } else if (id == LOADER_ID_TOPSITES) { + return BrowserDB.from(context).getActivityStreamTopSites( + context, TopSitesPagerAdapter.PAGES * MAXIMUM_TILES); + } else { + throw new IllegalArgumentException("Can't handle loader id " + id); + } + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (loader.getId() == LOADER_ID_HIGHLIGHTS) { + adapter.swapHighlightsCursor(data); + } else if (loader.getId() == LOADER_ID_TOPSITES) { + adapter.swapTopSitesCursor(data); + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (loader.getId() == LOADER_ID_HIGHLIGHTS) { + adapter.swapHighlightsCursor(null); + } else if (loader.getId() == LOADER_ID_TOPSITES) { + adapter.swapTopSitesCursor(null); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java new file mode 100644 index 000000000..09f6705d7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java @@ -0,0 +1,39 @@ +/* -*- 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.home.activitystream; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.HomeFragment; + +/** + * Simple wrapper around the ActivityStream view that allows embedding as a HomePager panel. + */ +public class ActivityStreamHomeFragment + extends HomeFragment { + private ActivityStream activityStream; + + @Override + protected void load() { + activityStream.load(getLoaderManager()); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + if (activityStream == null) { + activityStream = (ActivityStream) inflater.inflate(R.layout.activity_stream, container, false); + activityStream.setOnUrlOpenListeners(mUrlOpenListener, mUrlOpenInBackgroundListener); + } + + return activityStream; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java new file mode 100644 index 000000000..4decc8218 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java @@ -0,0 +1,73 @@ +/* -*- 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.home.activitystream; + +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.LoaderManager; +import android.util.AttributeSet; + +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.home.HomeBanner; +import org.mozilla.gecko.home.HomeFragment; +import org.mozilla.gecko.home.HomeScreen; + +/** + * HomeScreen implementation that displays ActivityStream. + */ +public class ActivityStreamHomeScreen + extends ActivityStream + implements HomeScreen { + + private boolean visible = false; + + public ActivityStreamHomeScreen(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean isVisible() { + return visible; + } + + @Override + public void onToolbarFocusChange(boolean hasFocus) { + + } + + @Override + public void showPanel(String panelId, Bundle restoreData) { + + } + + @Override + public void setOnPanelChangeListener(OnPanelChangeListener listener) { + + } + + @Override + public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) { + + } + + @Override + public void setBanner(HomeBanner banner) { + + } + + @Override + public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, + PropertyAnimator animator) { + super.load(lm); + visible = true; + } + + @Override + public void unload() { + super.unload(); + visible = false; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java new file mode 100644 index 000000000..24348dfe0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java @@ -0,0 +1,196 @@ +/* -*- 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.home.activitystream; + +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.support.v4.view.ViewPager; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.activitystream.ActivityStream.LabelCallback; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu; +import org.mozilla.gecko.home.activitystream.topsites.CirclePageIndicator; +import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.util.DrawableUtil; +import org.mozilla.gecko.util.ViewUtil; +import org.mozilla.gecko.util.TouchTargetUtil; +import org.mozilla.gecko.widget.FaviconView; + +import java.util.concurrent.Future; + +import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel; + +public abstract class StreamItem extends RecyclerView.ViewHolder { + public StreamItem(View itemView) { + super(itemView); + } + + public static class HighlightsTitle extends StreamItem { + public static final int LAYOUT_ID = R.layout.activity_stream_main_highlightstitle; + + public HighlightsTitle(View itemView) { + super(itemView); + } + } + + public static class TopPanel extends StreamItem { + public static final int LAYOUT_ID = R.layout.activity_stream_main_toppanel; + + private final ViewPager topSitesPager; + + public TopPanel(View itemView, HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + super(itemView); + + topSitesPager = (ViewPager) itemView.findViewById(R.id.topsites_pager); + topSitesPager.setAdapter(new TopSitesPagerAdapter(itemView.getContext(), onUrlOpenListener, onUrlOpenInBackgroundListener)); + + CirclePageIndicator indicator = (CirclePageIndicator) itemView.findViewById(R.id.topsites_indicator); + indicator.setViewPager(topSitesPager); + } + + public void bind(Cursor cursor, int tiles, int tilesWidth, int tilesHeight) { + final TopSitesPagerAdapter adapter = (TopSitesPagerAdapter) topSitesPager.getAdapter(); + adapter.setTilesSize(tiles, tilesWidth, tilesHeight); + adapter.swapCursor(cursor); + + final Resources resources = itemView.getResources(); + final int tilesMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin); + final int textHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height); + + ViewGroup.LayoutParams layoutParams = topSitesPager.getLayoutParams(); + layoutParams.height = tilesHeight + tilesMargin + textHeight; + topSitesPager.setLayoutParams(layoutParams); + } + } + + public static class HighlightItem extends StreamItem implements IconCallback { + public static final int LAYOUT_ID = R.layout.activity_stream_card_history_item; + + String title; + String url; + + final FaviconView vIconView; + final TextView vLabel; + final TextView vTimeSince; + final TextView vSourceView; + final TextView vPageView; + final ImageView vSourceIconView; + + private Future<IconResponse> ongoingIconLoad; + private int tilesMargin; + + public HighlightItem(final View itemView, + final HomePager.OnUrlOpenListener onUrlOpenListener, + final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + super(itemView); + + tilesMargin = itemView.getResources().getDimensionPixelSize(R.dimen.activity_stream_base_margin); + + vLabel = (TextView) itemView.findViewById(R.id.card_history_label); + vTimeSince = (TextView) itemView.findViewById(R.id.card_history_time_since); + vIconView = (FaviconView) itemView.findViewById(R.id.icon); + vSourceView = (TextView) itemView.findViewById(R.id.card_history_source); + vPageView = (TextView) itemView.findViewById(R.id.page); + vSourceIconView = (ImageView) itemView.findViewById(R.id.source_icon); + + final ImageView menuButton = (ImageView) itemView.findViewById(R.id.menu); + + menuButton.setImageDrawable( + DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, Color.LTGRAY)); + + TouchTargetUtil.ensureTargetHitArea(menuButton, itemView); + + menuButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ActivityStreamContextMenu.show(v.getContext(), + menuButton, + ActivityStreamContextMenu.MenuMode.HIGHLIGHT, + title, url, onUrlOpenListener, onUrlOpenInBackgroundListener, + vIconView.getWidth(), vIconView.getHeight()); + } + }); + + ViewUtil.enableTouchRipple(menuButton); + } + + public void bind(Cursor cursor, int tilesWidth, int tilesHeight) { + + final long time = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.DATE)); + final String ago = DateUtils.getRelativeTimeSpanString(time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0).toString(); + + title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.History.TITLE)); + url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL)); + + vLabel.setText(title); + vTimeSince.setText(ago); + + ViewGroup.LayoutParams layoutParams = vIconView.getLayoutParams(); + layoutParams.width = tilesWidth - tilesMargin; + layoutParams.height = tilesHeight; + vIconView.setLayoutParams(layoutParams); + + updateSource(cursor); + updatePage(url); + + if (ongoingIconLoad != null) { + ongoingIconLoad.cancel(true); + } + + ongoingIconLoad = Icons.with(itemView.getContext()) + .pageUrl(url) + .skipNetwork() + .build() + .execute(this); + } + + private void updateSource(final Cursor cursor) { + final boolean isBookmark = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID)); + final boolean isHistory = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID)); + + if (isBookmark) { + vSourceView.setText(R.string.activity_stream_highlight_label_bookmarked); + vSourceView.setVisibility(View.VISIBLE); + vSourceIconView.setImageResource(R.drawable.ic_as_bookmarked); + } else if (isHistory) { + vSourceView.setText(R.string.activity_stream_highlight_label_visited); + vSourceView.setVisibility(View.VISIBLE); + vSourceIconView.setImageResource(R.drawable.ic_as_visited); + } else { + vSourceView.setVisibility(View.INVISIBLE); + vSourceIconView.setImageResource(0); + } + + vSourceView.setText(vSourceView.getText()); + } + + private void updatePage(final String url) { + extractLabel(itemView.getContext(), url, false, new LabelCallback() { + @Override + public void onLabelExtracted(String label) { + vPageView.setText(TextUtils.isEmpty(label) ? url : label); + } + }); + } + + @Override + public void onIconResponse(IconResponse response) { + vIconView.updateImage(response); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java new file mode 100644 index 000000000..f7cda2e7f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java @@ -0,0 +1,135 @@ +/* -*- 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.home.activitystream; + +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.home.activitystream.StreamItem.HighlightItem; +import org.mozilla.gecko.home.activitystream.StreamItem.TopPanel; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import java.util.EnumSet; + +public class StreamRecyclerAdapter extends RecyclerView.Adapter<StreamItem> implements RecyclerViewClickSupport.OnItemClickListener { + private Cursor highlightsCursor; + private Cursor topSitesCursor; + + private HomePager.OnUrlOpenListener onUrlOpenListener; + private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + private int tiles; + private int tilesWidth; + private int tilesHeight; + + void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + this.onUrlOpenListener = onUrlOpenListener; + this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener; + } + + public void setTileSize(int tiles, int tilesWidth, int tilesHeight) { + this.tilesWidth = tilesWidth; + this.tilesHeight = tilesHeight; + this.tiles = tiles; + + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return TopPanel.LAYOUT_ID; + } else if (position == 1) { + return StreamItem.HighlightsTitle.LAYOUT_ID; + } else { + return HighlightItem.LAYOUT_ID; + } + } + + @Override + public StreamItem onCreateViewHolder(ViewGroup parent, final int type) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + if (type == TopPanel.LAYOUT_ID) { + return new TopPanel(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener); + } else if (type == StreamItem.HighlightsTitle.LAYOUT_ID) { + return new StreamItem.HighlightsTitle(inflater.inflate(type, parent, false)); + } else if (type == HighlightItem.LAYOUT_ID) { + return new HighlightItem(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener); + } else { + throw new IllegalStateException("Missing inflation for ViewType " + type); + } + } + + private int translatePositionToCursor(int position) { + if (position == 0) { + throw new IllegalArgumentException("Requested cursor position for invalid item"); + } + + // We have two blank panels at the top, hence remove that to obtain the cursor position + return position - 2; + } + + @Override + public void onBindViewHolder(StreamItem holder, int position) { + int type = getItemViewType(position); + + if (type == HighlightItem.LAYOUT_ID) { + final int cursorPosition = translatePositionToCursor(position); + + highlightsCursor.moveToPosition(cursorPosition); + ((HighlightItem) holder).bind(highlightsCursor, tilesWidth, tilesHeight); + } else if (type == TopPanel.LAYOUT_ID) { + ((TopPanel) holder).bind(topSitesCursor, tiles, tilesWidth, tilesHeight); + } + } + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + if (position < 1) { + // The header contains top sites and has its own click handling. + return; + } + + highlightsCursor.moveToPosition( + translatePositionToCursor(position)); + + final String url = highlightsCursor.getString( + highlightsCursor.getColumnIndexOrThrow(BrowserContract.Combined.URL)); + + onUrlOpenListener.onUrlOpen(url, EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)); + } + + @Override + public int getItemCount() { + final int highlightsCount; + + if (highlightsCursor != null) { + highlightsCount = highlightsCursor.getCount(); + } else { + highlightsCount = 0; + } + + return highlightsCount + 2; + } + + public void swapHighlightsCursor(Cursor cursor) { + highlightsCursor = cursor; + + notifyDataSetChanged(); + } + + public void swapTopSitesCursor(Cursor cursor) { + this.topSitesCursor = cursor; + + notifyItemChanged(0); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java new file mode 100644 index 000000000..525d3b426 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java @@ -0,0 +1,239 @@ +/* -*- 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.home.activitystream.menu; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.support.annotation.NonNull; +import android.support.design.widget.NavigationView; +import android.view.MenuItem; +import android.view.View; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.IntentHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import java.util.EnumSet; + +@RobocopTarget +public abstract class ActivityStreamContextMenu + implements NavigationView.OnNavigationItemSelectedListener { + + public enum MenuMode { + HIGHLIGHT, + TOPSITE + } + + final Context context; + + final String title; + final String url; + + final HomePager.OnUrlOpenListener onUrlOpenListener; + final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + boolean isAlreadyBookmarked; // default false; + + public abstract MenuItem getItemByID(int id); + + public abstract void show(); + + public abstract void dismiss(); + + final MenuMode mode; + + /* package-private */ ActivityStreamContextMenu(final Context context, + final MenuMode mode, + final String title, @NonNull final String url, + HomePager.OnUrlOpenListener onUrlOpenListener, + HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + this.context = context; + + this.mode = mode; + + this.title = title; + this.url = url; + this.onUrlOpenListener = onUrlOpenListener; + this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener; + } + + /** + * Must be called before the menu is shown. + * <p/> + * Your implementation must be ready to return items from getItemByID() before postInit() is + * called, i.e. you should probably inflate your menu items before this call. + */ + protected void postInit() { + // Disable "dismiss" for topsites until we have decided on its behaviour for topsites + // (currently "dismiss" adds the URL to a highlights-specific blocklist, which the topsites + // query has no knowledge of). + if (mode == MenuMode.TOPSITE) { + final MenuItem dismissItem = getItemByID(R.id.dismiss); + dismissItem.setVisible(false); + } + + // Disable the bookmark item until we know its bookmark state + final MenuItem bookmarkItem = getItemByID(R.id.bookmark); + bookmarkItem.setEnabled(false); + + (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) { + @Override + protected Void doInBackground() { + isAlreadyBookmarked = BrowserDB.from(context).isBookmark(context.getContentResolver(), url); + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (isAlreadyBookmarked) { + bookmarkItem.setTitle(R.string.bookmark_remove); + } + + bookmarkItem.setEnabled(true); + } + }).execute(); + + // Only show the "remove from history" item if a page actually has history + final MenuItem deleteHistoryItem = getItemByID(R.id.delete); + deleteHistoryItem.setVisible(false); + + (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) { + boolean hasHistory; + + @Override + protected Void doInBackground() { + final Cursor cursor = BrowserDB.from(context).getHistoryForURL(context.getContentResolver(), url); + try { + if (cursor != null && + cursor.getCount() == 1) { + hasHistory = true; + } else { + hasHistory = false; + } + } finally { + cursor.close(); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (hasHistory) { + deleteHistoryItem.setVisible(true); + } + } + }).execute(); + } + + + @Override + public boolean onNavigationItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.share: + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "menu"); + IntentHelper.openUriExternal(url, "text/plain", "", "", Intent.ACTION_SEND, title, false); + break; + + case R.id.bookmark: + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final BrowserDB db = BrowserDB.from(context); + + if (isAlreadyBookmarked) { + db.removeBookmarksWithURL(context.getContentResolver(), url); + } else { + db.addBookmark(context.getContentResolver(), title, url); + } + + } + }); + break; + + case R.id.copy_url: + Clipboard.setText(url); + break; + + case R.id.add_homescreen: + GeckoAppShell.createShortcut(title, url); + break; + + case R.id.open_new_tab: + onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.noneOf(HomePager.OnUrlOpenInBackgroundListener.Flags.class)); + break; + + case R.id.open_new_private_tab: + onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.of(HomePager.OnUrlOpenInBackgroundListener.Flags.PRIVATE)); + break; + + case R.id.dismiss: + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + BrowserDB.from(context) + .blockActivityStreamSite(context.getContentResolver(), + url); + } + }); + break; + + case R.id.delete: + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + BrowserDB.from(context) + .removeHistoryEntry(context.getContentResolver(), + url); + } + }); + break; + + default: + throw new IllegalArgumentException("Menu item with ID=" + item.getItemId() + " not handled"); + } + + dismiss(); + return true; + } + + + @RobocopTarget + public static ActivityStreamContextMenu show(Context context, + View anchor, + final MenuMode menuMode, + final String title, @NonNull final String url, + HomePager.OnUrlOpenListener onUrlOpenListener, + HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener, + final int tilesWidth, final int tilesHeight) { + final ActivityStreamContextMenu menu; + + if (!HardwareUtils.isTablet()) { + menu = new BottomSheetContextMenu(context, + menuMode, + title, url, + onUrlOpenListener, onUrlOpenInBackgroundListener, + tilesWidth, tilesHeight); + } else { + menu = new PopupContextMenu(context, + anchor, + menuMode, + title, url, + onUrlOpenListener, onUrlOpenInBackgroundListener); + } + + menu.show(); + return menu; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java new file mode 100644 index 000000000..e95867c36 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java @@ -0,0 +1,102 @@ +/* -*- 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.home.activitystream.menu; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.design.widget.BottomSheetBehavior; +import android.support.design.widget.BottomSheetDialog; +import android.support.design.widget.NavigationView; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.activitystream.ActivityStream; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.widget.FaviconView; + +import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel; + +/* package-private */ class BottomSheetContextMenu + extends ActivityStreamContextMenu { + + + private final BottomSheetDialog bottomSheetDialog; + + private final NavigationView navigationView; + + public BottomSheetContextMenu(final Context context, + final MenuMode mode, + final String title, @NonNull final String url, + HomePager.OnUrlOpenListener onUrlOpenListener, + HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener, + final int tilesWidth, final int tilesHeight) { + + super(context, + mode, + title, + url, + onUrlOpenListener, + onUrlOpenInBackgroundListener); + + final LayoutInflater inflater = LayoutInflater.from(context); + final View content = inflater.inflate(R.layout.activity_stream_contextmenu_bottomsheet, null); + + bottomSheetDialog = new BottomSheetDialog(context); + bottomSheetDialog.setContentView(content); + + ((TextView) content.findViewById(R.id.title)).setText(title); + + extractLabel(context, url, false, new ActivityStream.LabelCallback() { + public void onLabelExtracted(String label) { + ((TextView) content.findViewById(R.id.url)).setText(label); + } + }); + + // Copy layouted parameters from the Highlights / TopSites items to ensure consistency + final FaviconView faviconView = (FaviconView) content.findViewById(R.id.icon); + ViewGroup.LayoutParams layoutParams = faviconView.getLayoutParams(); + layoutParams.width = tilesWidth; + layoutParams.height = tilesHeight; + faviconView.setLayoutParams(layoutParams); + + Icons.with(context) + .pageUrl(url) + .skipNetwork() + .build() + .execute(new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + faviconView.updateImage(response); + } + }); + + navigationView = (NavigationView) content.findViewById(R.id.menu); + navigationView.setNavigationItemSelectedListener(this); + + super.postInit(); + } + + @Override + public MenuItem getItemByID(int id) { + return navigationView.getMenu().findItem(id); + } + + @Override + public void show() { + bottomSheetDialog.show(); + } + + public void dismiss() { + bottomSheetDialog.dismiss(); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java new file mode 100644 index 000000000..56615937b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java @@ -0,0 +1,76 @@ +/* -*- 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.home.activitystream.menu; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.support.annotation.NonNull; +import android.support.design.widget.NavigationView; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupWindow; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.HomePager; + +/* package-private */ class PopupContextMenu + extends ActivityStreamContextMenu { + + private final PopupWindow popupWindow; + private final NavigationView navigationView; + + private final View anchor; + + public PopupContextMenu(final Context context, + View anchor, + final MenuMode mode, + final String title, + @NonNull final String url, + HomePager.OnUrlOpenListener onUrlOpenListener, + HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + super(context, + mode, + title, + url, + onUrlOpenListener, + onUrlOpenInBackgroundListener); + + this.anchor = anchor; + + final LayoutInflater inflater = LayoutInflater.from(context); + + View card = inflater.inflate(R.layout.activity_stream_contextmenu_popupmenu, null); + navigationView = (NavigationView) card.findViewById(R.id.menu); + navigationView.setNavigationItemSelectedListener(this); + + popupWindow = new PopupWindow(context); + popupWindow.setContentView(card); + popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + popupWindow.setFocusable(true); + + super.postInit(); + } + + @Override + public MenuItem getItemByID(int id) { + return navigationView.getMenu().findItem(id); + } + + @Override + public void show() { + // By default popupWindow follows the pre-material convention of displaying the popup + // below a View. We need to shift it over the view: + popupWindow.showAsDropDown(anchor, + 0, + -(anchor.getHeight() + anchor.getPaddingBottom())); + } + + public void dismiss() { + popupWindow.dismiss(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java new file mode 100644 index 000000000..096f0c597 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java @@ -0,0 +1,568 @@ +/* + * Copyright (C) 2011 Patrik Akerfeldt + * Copyright (C) 2011 Jake Wharton + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.gecko.home.activitystream.topsites; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.drawable.Drawable; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.ViewConfigurationCompat; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import org.mozilla.gecko.R; + +import static android.graphics.Paint.ANTI_ALIAS_FLAG; +import static android.widget.LinearLayout.HORIZONTAL; +import static android.widget.LinearLayout.VERTICAL; + +/** + * Draws circles (one for each view). The current view position is filled and + * others are only stroked. + * + * This file was imported from Jake Wharton's ViewPagerIndicator library: + * https://github.com/JakeWharton/ViewPagerIndicator + * It was modified to not extend the PageIndicator interface (as we only use one single Indicator) + * implementation, and has had some minor appearance related modifications added alter. + */ +public class CirclePageIndicator + extends View + implements ViewPager.OnPageChangeListener { + + /** + * Separation between circles, as a factor of the circle radius. By default CirclePageIndicator + * shipped with a separation factor of 3, however we want to be able to tweak this for + * ActivityStream. + * + * If/when we reuse this indicator elsewhere, this should probably become a configurable property. + */ + private static final int SEPARATION_FACTOR = 7; + + private static final int INVALID_POINTER = -1; + + private float mRadius; + private final Paint mPaintPageFill = new Paint(ANTI_ALIAS_FLAG); + private final Paint mPaintStroke = new Paint(ANTI_ALIAS_FLAG); + private final Paint mPaintFill = new Paint(ANTI_ALIAS_FLAG); + private ViewPager mViewPager; + private ViewPager.OnPageChangeListener mListener; + private int mCurrentPage; + private int mSnapPage; + private float mPageOffset; + private int mScrollState; + private int mOrientation; + private boolean mCentered; + private boolean mSnap; + + private int mTouchSlop; + private float mLastMotionX = -1; + private int mActivePointerId = INVALID_POINTER; + private boolean mIsDragging; + + + public CirclePageIndicator(Context context) { + this(context, null); + } + + public CirclePageIndicator(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.vpiCirclePageIndicatorStyle); + } + + public CirclePageIndicator(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + if (isInEditMode()) return; + + //Load defaults from resources + final Resources res = getResources(); + final int defaultPageColor = res.getColor(R.color.default_circle_indicator_page_color); + final int defaultFillColor = res.getColor(R.color.default_circle_indicator_fill_color); + final int defaultOrientation = res.getInteger(R.integer.default_circle_indicator_orientation); + final int defaultStrokeColor = res.getColor(R.color.default_circle_indicator_stroke_color); + final float defaultStrokeWidth = res.getDimension(R.dimen.default_circle_indicator_stroke_width); + final float defaultRadius = res.getDimension(R.dimen.default_circle_indicator_radius); + final boolean defaultCentered = res.getBoolean(R.bool.default_circle_indicator_centered); + final boolean defaultSnap = res.getBoolean(R.bool.default_circle_indicator_snap); + + //Retrieve styles attributes + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CirclePageIndicator, defStyle, 0); + + mCentered = a.getBoolean(R.styleable.CirclePageIndicator_centered, defaultCentered); + mOrientation = a.getInt(R.styleable.CirclePageIndicator_android_orientation, defaultOrientation); + mPaintPageFill.setStyle(Style.FILL); + mPaintPageFill.setColor(a.getColor(R.styleable.CirclePageIndicator_pageColor, defaultPageColor)); + mPaintStroke.setStyle(Style.STROKE); + mPaintStroke.setColor(a.getColor(R.styleable.CirclePageIndicator_strokeColor, defaultStrokeColor)); + mPaintStroke.setStrokeWidth(a.getDimension(R.styleable.CirclePageIndicator_strokeWidth, defaultStrokeWidth)); + mPaintFill.setStyle(Style.FILL); + mPaintFill.setColor(a.getColor(R.styleable.CirclePageIndicator_fillColor, defaultFillColor)); + mRadius = a.getDimension(R.styleable.CirclePageIndicator_radius, defaultRadius); + mSnap = a.getBoolean(R.styleable.CirclePageIndicator_snap, defaultSnap); + + Drawable background = a.getDrawable(R.styleable.CirclePageIndicator_android_background); + if (background != null) { + setBackgroundDrawable(background); + } + + a.recycle(); + + final ViewConfiguration configuration = ViewConfiguration.get(context); + mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration); + } + + + public void setCentered(boolean centered) { + mCentered = centered; + invalidate(); + } + + public boolean isCentered() { + return mCentered; + } + + public void setPageColor(int pageColor) { + mPaintPageFill.setColor(pageColor); + invalidate(); + } + + public int getPageColor() { + return mPaintPageFill.getColor(); + } + + public void setFillColor(int fillColor) { + mPaintFill.setColor(fillColor); + invalidate(); + } + + public int getFillColor() { + return mPaintFill.getColor(); + } + + public void setOrientation(int orientation) { + switch (orientation) { + case HORIZONTAL: + case VERTICAL: + mOrientation = orientation; + requestLayout(); + break; + + default: + throw new IllegalArgumentException("Orientation must be either HORIZONTAL or VERTICAL."); + } + } + + public int getOrientation() { + return mOrientation; + } + + public void setStrokeColor(int strokeColor) { + mPaintStroke.setColor(strokeColor); + invalidate(); + } + + public int getStrokeColor() { + return mPaintStroke.getColor(); + } + + public void setStrokeWidth(float strokeWidth) { + mPaintStroke.setStrokeWidth(strokeWidth); + invalidate(); + } + + public float getStrokeWidth() { + return mPaintStroke.getStrokeWidth(); + } + + public void setRadius(float radius) { + mRadius = radius; + invalidate(); + } + + public float getRadius() { + return mRadius; + } + + public void setSnap(boolean snap) { + mSnap = snap; + invalidate(); + } + + public boolean isSnap() { + return mSnap; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (mViewPager == null) { + return; + } + final int count = mViewPager.getAdapter().getCount(); + if (count == 0) { + return; + } + + if (mCurrentPage >= count) { + setCurrentItem(count - 1); + return; + } + + int longSize; + int longPaddingBefore; + int longPaddingAfter; + int shortPaddingBefore; + if (mOrientation == HORIZONTAL) { + longSize = getWidth(); + longPaddingBefore = getPaddingLeft(); + longPaddingAfter = getPaddingRight(); + shortPaddingBefore = getPaddingTop(); + } else { + longSize = getHeight(); + longPaddingBefore = getPaddingTop(); + longPaddingAfter = getPaddingBottom(); + shortPaddingBefore = getPaddingLeft(); + } + + final float threeRadius = mRadius * SEPARATION_FACTOR; + final float shortOffset = shortPaddingBefore + mRadius; + float longOffset = longPaddingBefore + mRadius; + if (mCentered) { + longOffset += ((longSize - longPaddingBefore - longPaddingAfter) / 2.0f) - ((count * threeRadius) / 2.0f); + } + + float dX; + float dY; + + float pageFillRadius = mRadius; + if (mPaintStroke.getStrokeWidth() > 0) { + pageFillRadius -= mPaintStroke.getStrokeWidth() / 2.0f; + } + + //Draw stroked circles + for (int iLoop = 0; iLoop < count; iLoop++) { + float drawLong = longOffset + (iLoop * threeRadius); + if (mOrientation == HORIZONTAL) { + dX = drawLong; + dY = shortOffset; + } else { + dX = shortOffset; + dY = drawLong; + } + // Only paint fill if not completely transparent + if (mPaintPageFill.getAlpha() > 0) { + canvas.drawCircle(dX, dY, pageFillRadius, mPaintPageFill); + } + + // Only paint stroke if a stroke width was non-zero + if (pageFillRadius != mRadius) { + canvas.drawCircle(dX, dY, mRadius, mPaintStroke); + } + } + + //Draw the filled circle according to the current scroll + float cx = (mSnap ? mSnapPage : mCurrentPage) * threeRadius; + if (!mSnap) { + cx += mPageOffset * threeRadius; + } + if (mOrientation == HORIZONTAL) { + dX = longOffset + cx; + dY = shortOffset; + } else { + dX = shortOffset; + dY = longOffset + cx; + } + canvas.drawCircle(dX, dY, mRadius, mPaintFill); + } + + public boolean onTouchEvent(android.view.MotionEvent ev) { + if (super.onTouchEvent(ev)) { + return true; + } + if ((mViewPager == null) || (mViewPager.getAdapter().getCount() == 0)) { + return false; + } + + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mLastMotionX = ev.getX(); + break; + + case MotionEvent.ACTION_MOVE: { + final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + final float x = MotionEventCompat.getX(ev, activePointerIndex); + final float deltaX = x - mLastMotionX; + + if (!mIsDragging) { + if (Math.abs(deltaX) > mTouchSlop) { + mIsDragging = true; + } + } + + if (mIsDragging) { + mLastMotionX = x; + if (mViewPager.isFakeDragging() || mViewPager.beginFakeDrag()) { + mViewPager.fakeDragBy(deltaX); + } + } + + break; + } + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (!mIsDragging) { + final int count = mViewPager.getAdapter().getCount(); + final int width = getWidth(); + final float halfWidth = width / 2f; + final float sixthWidth = width / 6f; + + if ((mCurrentPage > 0) && (ev.getX() < halfWidth - sixthWidth)) { + if (action != MotionEvent.ACTION_CANCEL) { + mViewPager.setCurrentItem(mCurrentPage - 1); + } + return true; + } else if ((mCurrentPage < count - 1) && (ev.getX() > halfWidth + sixthWidth)) { + if (action != MotionEvent.ACTION_CANCEL) { + mViewPager.setCurrentItem(mCurrentPage + 1); + } + return true; + } + } + + mIsDragging = false; + mActivePointerId = INVALID_POINTER; + if (mViewPager.isFakeDragging()) mViewPager.endFakeDrag(); + break; + + case MotionEventCompat.ACTION_POINTER_DOWN: { + final int index = MotionEventCompat.getActionIndex(ev); + mLastMotionX = MotionEventCompat.getX(ev, index); + mActivePointerId = MotionEventCompat.getPointerId(ev, index); + break; + } + + case MotionEventCompat.ACTION_POINTER_UP: + final int pointerIndex = MotionEventCompat.getActionIndex(ev); + final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); + if (pointerId == mActivePointerId) { + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); + } + mLastMotionX = MotionEventCompat.getX(ev, MotionEventCompat.findPointerIndex(ev, mActivePointerId)); + break; + } + + return true; + } + + public void setViewPager(ViewPager view) { + if (mViewPager == view) { + return; + } + if (mViewPager != null) { + mViewPager.setOnPageChangeListener(null); + } + if (view.getAdapter() == null) { + throw new IllegalStateException("ViewPager does not have adapter instance."); + } + mViewPager = view; + mViewPager.setOnPageChangeListener(this); + invalidate(); + } + + public void setViewPager(ViewPager view, int initialPosition) { + setViewPager(view); + setCurrentItem(initialPosition); + } + + public void setCurrentItem(int item) { + if (mViewPager == null) { + throw new IllegalStateException("ViewPager has not been bound."); + } + mViewPager.setCurrentItem(item); + mCurrentPage = item; + invalidate(); + } + + public void notifyDataSetChanged() { + invalidate(); + } + + @Override + public void onPageScrollStateChanged(int state) { + mScrollState = state; + + if (mListener != null) { + mListener.onPageScrollStateChanged(state); + } + } + + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mCurrentPage = position; + mPageOffset = positionOffset; + invalidate(); + + if (mListener != null) { + mListener.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + } + + @Override + public void onPageSelected(int position) { + if (mSnap || mScrollState == ViewPager.SCROLL_STATE_IDLE) { + mCurrentPage = position; + mSnapPage = position; + invalidate(); + } + + if (mListener != null) { + mListener.onPageSelected(position); + } + } + + public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) { + mListener = listener; + } + + /* + * (non-Javadoc) + * + * @see android.view.View#onMeasure(int, int) + */ + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mOrientation == HORIZONTAL) { + setMeasuredDimension(measureLong(widthMeasureSpec), measureShort(heightMeasureSpec)); + } else { + setMeasuredDimension(measureShort(widthMeasureSpec), measureLong(heightMeasureSpec)); + } + } + + /** + * Determines the width of this view + * + * @param measureSpec + * A measureSpec packed into an int + * @return The width of the view, honoring constraints from measureSpec + */ + private int measureLong(int measureSpec) { + int result; + int specMode = MeasureSpec.getMode(measureSpec); + int specSize = MeasureSpec.getSize(measureSpec); + + if ((specMode == MeasureSpec.EXACTLY) || (mViewPager == null)) { + //We were told how big to be + result = specSize; + } else { + //Calculate the width according the views count + final int count = mViewPager.getAdapter().getCount(); + result = (int)(getPaddingLeft() + getPaddingRight() + + (count * 2 * mRadius) + (count - 1) * mRadius + 1); + //Respect AT_MOST value if that was what is called for by measureSpec + if (specMode == MeasureSpec.AT_MOST) { + result = Math.min(result, specSize); + } + } + return result; + } + + /** + * Determines the height of this view + * + * @param measureSpec + * A measureSpec packed into an int + * @return The height of the view, honoring constraints from measureSpec + */ + private int measureShort(int measureSpec) { + int result; + int specMode = MeasureSpec.getMode(measureSpec); + int specSize = MeasureSpec.getSize(measureSpec); + + if (specMode == MeasureSpec.EXACTLY) { + //We were told how big to be + result = specSize; + } else { + //Measure the height + result = (int)(2 * mRadius + getPaddingTop() + getPaddingBottom() + 1); + //Respect AT_MOST value if that was what is called for by measureSpec + if (specMode == MeasureSpec.AT_MOST) { + result = Math.min(result, specSize); + } + } + return result; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState savedState = (SavedState)state; + super.onRestoreInstanceState(savedState.getSuperState()); + mCurrentPage = savedState.currentPage; + mSnapPage = savedState.currentPage; + requestLayout(); + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + SavedState savedState = new SavedState(superState); + savedState.currentPage = mCurrentPage; + return savedState; + } + + static class SavedState extends BaseSavedState { + int currentPage; + + public SavedState(Parcelable superState) { + super(superState); + } + + private SavedState(Parcel in) { + super(in); + currentPage = in.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(currentPage); + } + + @SuppressWarnings("UnusedDeclaration") + public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java new file mode 100644 index 000000000..b436a466f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java @@ -0,0 +1,105 @@ +/* -*- 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.home.activitystream.topsites; + +import android.graphics.Color; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.activitystream.ActivityStream; +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.util.DrawableUtil; +import org.mozilla.gecko.util.ViewUtil; +import org.mozilla.gecko.util.TouchTargetUtil; +import org.mozilla.gecko.widget.FaviconView; + +import java.util.EnumSet; +import java.util.concurrent.Future; + +class TopSitesCard extends RecyclerView.ViewHolder + implements IconCallback, View.OnClickListener { + private final FaviconView faviconView; + + private final TextView title; + private final ImageView menuButton; + private Future<IconResponse> ongoingIconLoad; + + private String url; + + private final HomePager.OnUrlOpenListener onUrlOpenListener; + private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + public TopSitesCard(FrameLayout card, final HomePager.OnUrlOpenListener onUrlOpenListener, final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + super(card); + + faviconView = (FaviconView) card.findViewById(R.id.favicon); + + title = (TextView) card.findViewById(R.id.title); + menuButton = (ImageView) card.findViewById(R.id.menu); + + this.onUrlOpenListener = onUrlOpenListener; + this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener; + + card.setOnClickListener(this); + + TouchTargetUtil.ensureTargetHitArea(menuButton, card); + menuButton.setOnClickListener(this); + + ViewUtil.enableTouchRipple(menuButton); + } + + void bind(final TopSitesPageAdapter.TopSite topSite) { + ActivityStream.extractLabel(itemView.getContext(), topSite.url, true, new ActivityStream.LabelCallback() { + @Override + public void onLabelExtracted(String label) { + title.setText(label); + } + }); + + this.url = topSite.url; + + if (ongoingIconLoad != null) { + ongoingIconLoad.cancel(true); + } + + ongoingIconLoad = Icons.with(itemView.getContext()) + .pageUrl(topSite.url) + .skipNetwork() + .build() + .execute(this); + } + + @Override + public void onIconResponse(IconResponse response) { + faviconView.updateImage(response); + + final int tintColor = !response.hasColor() || response.getColor() == Color.WHITE ? Color.LTGRAY : Color.WHITE; + + menuButton.setImageDrawable( + DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, tintColor)); + } + + @Override + public void onClick(View clickedView) { + if (clickedView == itemView) { + onUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(HomePager.OnUrlOpenListener.Flags.class)); + } else if (clickedView == menuButton) { + ActivityStreamContextMenu.show(clickedView.getContext(), + menuButton, + ActivityStreamContextMenu.MenuMode.TOPSITE, + title.getText().toString(), url, + onUrlOpenListener, onUrlOpenInBackgroundListener, + faviconView.getWidth(), faviconView.getHeight()); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java new file mode 100644 index 000000000..45fdc0d1a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java @@ -0,0 +1,38 @@ +/* -*- 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.home.activitystream.topsites; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.View; + +import org.mozilla.gecko.home.HomePager; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import java.util.EnumSet; + +public class TopSitesPage + extends RecyclerView { + public TopSitesPage(Context context, + @Nullable AttributeSet attrs) { + super(context, attrs); + + setLayoutManager(new GridLayoutManager(context, 1)); + } + + public void setTiles(int tiles) { + setLayoutManager(new GridLayoutManager(getContext(), tiles)); + } + + private HomePager.OnUrlOpenListener onUrlOpenListener; + private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + public TopSitesPageAdapter getAdapter() { + return (TopSitesPageAdapter) super.getAdapter(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java new file mode 100644 index 000000000..29e6aca3d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java @@ -0,0 +1,117 @@ +/* -*- 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.home.activitystream.topsites; + +import android.content.Context; +import android.database.Cursor; +import android.support.annotation.UiThread; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.home.HomePager; + +import java.util.ArrayList; +import java.util.List; + +public class TopSitesPageAdapter extends RecyclerView.Adapter<TopSitesCard> { + static final class TopSite { + public final long id; + public final String url; + public final String title; + + TopSite(long id, String url, String title) { + this.id = id; + this.url = url; + this.title = title; + } + } + + private List<TopSite> topSites; + private int tiles; + private int tilesWidth; + private int tilesHeight; + private int textHeight; + + private final HomePager.OnUrlOpenListener onUrlOpenListener; + private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + public TopSitesPageAdapter(Context context, int tiles, int tilesWidth, int tilesHeight, + HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + setHasStableIds(true); + + this.topSites = new ArrayList<>(); + this.tiles = tiles; + this.tilesWidth = tilesWidth; + this.tilesHeight = tilesHeight; + this.textHeight = context.getResources().getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height); + + this.onUrlOpenListener = onUrlOpenListener; + this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener; + } + + /** + * + * @param cursor + * @param startIndex The first item that this topsites group should show. This item, and the following + * 3 items will be displayed by this adapter. + */ + public void swapCursor(Cursor cursor, int startIndex) { + topSites.clear(); + + if (cursor == null) { + return; + } + + for (int i = 0; i < tiles && startIndex + i < cursor.getCount(); i++) { + cursor.moveToPosition(startIndex + i); + + // The Combined View only contains pages that have been visited at least once, i.e. any + // page in the TopSites query will contain a HISTORY_ID. _ID however will be 0 for all rows. + final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID)); + final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL)); + final String title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE)); + + topSites.add(new TopSite(id, url, title)); + } + + notifyDataSetChanged(); + } + + @Override + public void onBindViewHolder(TopSitesCard holder, int position) { + holder.bind(topSites.get(position)); + } + + @Override + public TopSitesCard onCreateViewHolder(ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + final FrameLayout card = (FrameLayout) inflater.inflate(R.layout.activity_stream_topsites_card, parent, false); + final View content = card.findViewById(R.id.content); + + ViewGroup.LayoutParams layoutParams = content.getLayoutParams(); + layoutParams.width = tilesWidth; + layoutParams.height = tilesHeight + textHeight; + content.setLayoutParams(layoutParams); + + return new TopSitesCard(card, onUrlOpenListener, onUrlOpenInBackgroundListener); + } + + @Override + public int getItemCount() { + return topSites.size(); + } + + @Override + @UiThread + public long getItemId(int position) { + return topSites.get(position).id; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java new file mode 100644 index 000000000..dc824d902 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java @@ -0,0 +1,124 @@ +/* -*- 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.home.activitystream.topsites; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.view.PagerAdapter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.HomePager; + +import java.util.LinkedList; + +/** + * The primary / top-level TopSites adapter: it handles the ViewPager, and also handles + * all lower-level Adapters that populate the individual topsite items. + */ +public class TopSitesPagerAdapter extends PagerAdapter { + public static final int PAGES = 4; + + private int tiles; + private int tilesWidth; + private int tilesHeight; + + private LinkedList<TopSitesPage> pages = new LinkedList<>(); + + private final Context context; + private final HomePager.OnUrlOpenListener onUrlOpenListener; + private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener; + + private int count = 0; + + public TopSitesPagerAdapter(Context context, + HomePager.OnUrlOpenListener onUrlOpenListener, + HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) { + this.context = context; + this.onUrlOpenListener = onUrlOpenListener; + this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener; + } + + public void setTilesSize(int tiles, int tilesWidth, int tilesHeight) { + this.tilesWidth = tilesWidth; + this.tilesHeight = tilesHeight; + this.tiles = tiles; + } + + @Override + public int getCount() { + return Math.min(count, 4); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + TopSitesPage page = pages.get(position); + + container.addView(page); + + return page; + } + + @Override + public int getItemPosition(Object object) { + return PagerAdapter.POSITION_NONE; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + container.removeView((View) object); + } + + public void swapCursor(Cursor cursor) { + // Divide while rounding up: 0 items = 0 pages, 1-ITEMS_PER_PAGE items = 1 page, etc. + if (cursor != null) { + count = (cursor.getCount() - 1) / tiles + 1; + } else { + count = 0; + } + + pages.clear(); + final int pageDelta = count; + + if (pageDelta > 0) { + final LayoutInflater inflater = LayoutInflater.from(context); + for (int i = 0; i < pageDelta; i++) { + final TopSitesPage page = (TopSitesPage) inflater.inflate(R.layout.activity_stream_topsites_page, null, false); + + page.setTiles(tiles); + final TopSitesPageAdapter adapter = new TopSitesPageAdapter(context, tiles, tilesWidth, tilesHeight, + onUrlOpenListener, onUrlOpenInBackgroundListener); + page.setAdapter(adapter); + pages.add(page); + } + } else if (pageDelta < 0) { + for (int i = 0; i > pageDelta; i--) { + final TopSitesPage page = pages.getLast(); + + // Ensure the page doesn't use the old/invalid cursor anymore + page.getAdapter().swapCursor(null, 0); + + pages.removeLast(); + } + } else { + // do nothing: we will be updating all the pages below + } + + int startIndex = 0; + for (TopSitesPage page : pages) { + page.getAdapter().swapCursor(cursor, startIndex); + startIndex += tiles; + } + + notifyDataSetChanged(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java new file mode 100644 index 000000000..0232a4ea6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java @@ -0,0 +1,13 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +/** + * Interface for a callback that will be executed once an icon has been loaded successfully. + */ +public interface IconCallback { + void onIconResponse(IconResponse response); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java new file mode 100644 index 000000000..359c47e53 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java @@ -0,0 +1,96 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +/** + * A class describing the location and properties of an icon that can be loaded. + */ +public class IconDescriptor { + @IntDef({ TYPE_GENERIC, TYPE_FAVICON, TYPE_TOUCHICON, TYPE_LOOKUP }) + @interface IconType {} + + // The type values are used for ranking icons (higher values = try to load first). + @VisibleForTesting static final int TYPE_GENERIC = 0; + @VisibleForTesting static final int TYPE_LOOKUP = 1; + @VisibleForTesting static final int TYPE_FAVICON = 5; + @VisibleForTesting static final int TYPE_TOUCHICON = 10; + + private final String url; + private final int size; + private final String mimeType; + private final int type; + + /** + * Create a generic icon located at the given URL. No MIME type or size is known. + */ + public static IconDescriptor createGenericIcon(String url) { + return new IconDescriptor(TYPE_GENERIC, url, 0, null); + } + + /** + * Create a favicon located at the given URL and with a known size and MIME type. + */ + public static IconDescriptor createFavicon(String url, int size, String mimeType) { + return new IconDescriptor(TYPE_FAVICON, url, size, mimeType); + } + + /** + * Create a touch icon located at the given URL and with a known MIME type and size. + */ + public static IconDescriptor createTouchicon(String url, int size, String mimeType) { + return new IconDescriptor(TYPE_TOUCHICON, url, size, mimeType); + } + + /** + * Create an icon located at an URL that has been returned from a disk or memory storage. This + * is an icon with an URL we loaded an icon from previously. Therefore we give it a little higher + * ranking than a generic icon - even though we do not know the MIME type or size of the icon. + */ + public static IconDescriptor createLookupIcon(String url) { + return new IconDescriptor(TYPE_LOOKUP, url, 0, null); + } + + private IconDescriptor(@IconType int type, String url, int size, String mimeType) { + this.type = type; + this.url = url; + this.size = size; + this.mimeType = mimeType; + } + + /** + * Get the URL of the icon. + */ + public String getUrl() { + return url; + } + + /** + * Get the (assumed) size of the icon. Returns 0 if no size is known. + */ + public int getSize() { + return size; + } + + /** + * Get the type of the icon (favicon, touch icon, generic, lookup). + */ + @IconType + public int getType() { + return type; + } + + /** + * Get the (assumed) MIME type of the icon. Returns null if no MIME type is known. + */ + @Nullable + public String getMimeType() { + return mimeType; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java new file mode 100644 index 000000000..3c6064825 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java @@ -0,0 +1,67 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import java.util.Comparator; + +/** + * This comparator implementation compares IconDescriptor objects in order to determine which icon + * to load first. + * + * In general this comparator will try touch icons before favicons (they usually have a higher resolution) + * and prefers larger icons over smaller ones. + */ +/* package-private */ class IconDescriptorComparator implements Comparator<IconDescriptor> { + @Override + public int compare(final IconDescriptor lhs, final IconDescriptor rhs) { + if (lhs.getUrl().equals(rhs.getUrl())) { + // Two descriptors pointing to the same URL are always referencing the same icon. So treat + // them as equal. + return 0; + } + + // First compare the types. We prefer touch icons because they tend to have a higher resolution + // than ordinary favicons. + if (lhs.getType() != rhs.getType()) { + return compareType(lhs, rhs); + } + + // If one of them is larger than pick the larger icon. + if (lhs.getSize() != rhs.getSize()) { + return compareSizes(lhs, rhs); + } + + // If there's no other way to choose, we prefer container types. They *might* contain + // an image larger than the size given in the <link> tag. + final boolean lhsContainer = IconsHelper.isContainerType(lhs.getMimeType()); + final boolean rhsContainer = IconsHelper.isContainerType(rhs.getMimeType()); + + if (lhsContainer != rhsContainer) { + return lhsContainer ? -1 : 1; + } + + // There's no way to know which icon might be better. However we need to pick a consistent + // one to avoid breaking the TreeSet implementation (See Bug 1331808). Therefore we are + // picking one by just comparing the URLs. + return lhs.getUrl().compareTo(rhs.getUrl()); + } + + private int compareType(IconDescriptor lhs, IconDescriptor rhs) { + if (lhs.getType() > rhs.getType()) { + return -1; + } else { + return 1; + } + } + + private int compareSizes(IconDescriptor lhs, IconDescriptor rhs) { + if (lhs.getSize() > rhs.getSize()) { + return -1; + } else { + return 1; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java new file mode 100644 index 000000000..be000642e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java @@ -0,0 +1,181 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.content.Context; +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.R; + +import java.util.Iterator; +import java.util.TreeSet; +import java.util.concurrent.Future; + +/** + * A class describing a request to load an icon for a website. + */ +public class IconRequest { + private Context context; + + // Those values are written by the IconRequestBuilder class. + /* package-private */ String pageUrl; + /* package-private */ boolean privileged; + /* package-private */ TreeSet<IconDescriptor> icons; + /* package-private */ boolean skipNetwork; + /* package-private */ boolean backgroundThread; + /* package-private */ boolean skipDisk; + /* package-private */ boolean skipMemory; + /* package-private */ int targetSize; + /* package-private */ boolean prepareOnly; + private IconCallback callback; + + /* package-private */ IconRequest(Context context) { + this.context = context.getApplicationContext(); + this.icons = new TreeSet<>(new IconDescriptorComparator()); + + // Setting some sensible defaults. + this.privileged = false; + this.skipMemory = false; + this.skipDisk = false; + this.skipNetwork = false; + this.targetSize = context.getResources().getDimensionPixelSize(R.dimen.favicon_bg); + this.prepareOnly = false; + } + + /** + * Execute this request and try to load an icon. Once an icon has been loaded successfully the + * callback will be executed. + * + * The returned Future can be used to cancel the job. + */ + public Future<IconResponse> execute(IconCallback callback) { + setCallback(callback); + + return IconRequestExecutor.submit(this); + } + + @VisibleForTesting void setCallback(IconCallback callback) { + this.callback = callback; + } + + /** + * Get the (application) context associated with this request. + */ + public Context getContext() { + return context; + } + + /** + * Get the descriptor for the potentially best icon. This is the icon that should be loaded if + * possible. + */ + public IconDescriptor getBestIcon() { + return icons.first(); + } + + /** + * Get the URL of the page for which an icon should be loaded. + */ + public String getPageUrl() { + return pageUrl; + } + + /** + * Is this request allowed to load icons from internal data sources like the omni.ja? + */ + public boolean isPrivileged() { + return privileged; + } + + /** + * Get the number of icon descriptors associated with this request. + */ + public int getIconCount() { + return icons.size(); + } + + /** + * Get the required target size of the icon. + */ + public int getTargetSize() { + return targetSize; + } + + /** + * Should a loader access the network to load this icon? + */ + public boolean shouldSkipNetwork() { + return skipNetwork; + } + + /** + * Should a loader access the disk to load this icon? + */ + public boolean shouldSkipDisk() { + return skipDisk; + } + + /** + * Should a loader access the memory cache to load this icon? + */ + public boolean shouldSkipMemory() { + return skipMemory; + } + + /** + * Get an iterator to iterate over all icon descriptors associated with this request. + */ + public Iterator<IconDescriptor> getIconIterator() { + return icons.iterator(); + } + + /** + * Create a builder to modify this request. + * + * Calling methods on the builder will modify this object and not create a copy. + */ + public IconRequestBuilder modify() { + return new IconRequestBuilder(this); + } + + /** + * Should the callback be executed on a background thread? By default a callback is always + * executed on the UI thread because an icon is usually loaded in order to display it somewhere + * in the UI. + */ + /* package-private */ boolean shouldRunOnBackgroundThread() { + return backgroundThread; + } + + /* package-private */ IconCallback getCallback() { + return callback; + } + + /* package-private */ boolean hasIconDescriptors() { + return !icons.isEmpty(); + } + + /** + * Move to the next icon. This method is called after all loaders for the current best icon + * have failed. After calling this method getBestIcon() will return the next icon to try. + * hasIconDescriptors() should be called before requesting the next icon. + */ + /* package-private */ void moveToNextIcon() { + if (!icons.remove(getBestIcon())) { + // Calling this method when there's no next icon is an error (use hasIconDescriptors()). + // Theoretically this method can fail even if there's a next icon (like it did in bug 1331808). + // In this case crashing to see and fix the issue is desired. + throw new IllegalStateException("Moving to next icon failed. Could not remove first icon from set."); + } + } + + /** + * Should this request be prepared but not actually load an icon? + */ + /* package-private */ boolean shouldPrepareOnly() { + return prepareOnly; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java new file mode 100644 index 000000000..d9fd9ec5a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java @@ -0,0 +1,143 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.content.Context; +import android.support.annotation.CheckResult; + +import org.mozilla.gecko.GeckoAppShell; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +/** + * Builder for creating a request to load an icon. + */ +public class IconRequestBuilder { + private final IconRequest request; + + /* package-private */ IconRequestBuilder(Context context) { + this(new IconRequest(context)); + } + + /* package-private */ IconRequestBuilder(IconRequest request) { + this.request = request; + } + + /** + * Set the URL of the page for which the icon should be loaded. + */ + @CheckResult + public IconRequestBuilder pageUrl(String pageUrl) { + request.pageUrl = pageUrl; + return this; + } + + /** + * Set whether this request is allowed to load icons from non http(s) URLs (e.g. the omni.ja). + * + * For example web content referencing internal URLs should not lead to us loading icons from + * internal data structures like the omni.ja. + */ + @CheckResult + public IconRequestBuilder privileged(boolean privileged) { + request.privileged = privileged; + return this; + } + + /** + * Add an icon descriptor describing the location and properties of an icon. All descriptors + * will be ranked and tried in order of their rank. Executing the request will modify the list + * of icons (filter or add additional descriptors). + */ + @CheckResult + public IconRequestBuilder icon(IconDescriptor descriptor) { + request.icons.add(descriptor); + return this; + } + + /** + * Skip the network and do not load an icon from a network connection. + */ + @CheckResult + public IconRequestBuilder skipNetwork() { + request.skipNetwork = true; + return this; + } + + /** + * Skip the disk cache and do not load an icon from disk. + */ + @CheckResult + public IconRequestBuilder skipDisk() { + request.skipDisk = true; + return this; + } + + /** + * Skip the memory cache and do not return a previously loaded icon. + */ + @CheckResult + public IconRequestBuilder skipMemory() { + request.skipMemory = true; + return this; + } + + /** + * The icon will be used as (Android) launcher icon. The loaded icon will be scaled to the + * preferred Android launcher icon size. + */ + public IconRequestBuilder forLauncherIcon() { + request.targetSize = GeckoAppShell.getPreferredIconSize(); + return this; + } + + /** + * Execute the callback on the background thread. By default the callback is always executed on + * the UI thread in order to add the loaded icon to a view easily. + */ + @CheckResult + public IconRequestBuilder executeCallbackOnBackgroundThread() { + request.backgroundThread = true; + return this; + } + + /** + * When executing the request then only prepare executing it but do not actually load an icon. + * This mode is only used for some legacy code that uses the icon URL and therefore needs to + * perform a lookup of the URL but doesn't want to load the icon yet. + */ + public IconRequestBuilder prepareOnly() { + request.prepareOnly = true; + return this; + } + + /** + * Return the request built with this builder. + */ + @CheckResult + public IconRequest build() { + if (TextUtils.isEmpty(request.pageUrl)) { + throw new IllegalStateException("Page URL is required"); + } + + return request; + } + + /** + * This is a no-op method. + * + * All builder methods are annotated with @CheckResult to denote that the + * methods return the builder object and that it is typically an error to not call another method + * on it until eventually calling build(). + * + * However in some situations code can keep a reference + * to the builder object and call methods only when a specific event occurs. To make this explicit + * and avoid lint errors this method can be called. + */ + public void deferBuild() { + // No op + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java new file mode 100644 index 000000000..aad784980 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java @@ -0,0 +1,152 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.support.annotation.NonNull; + +import org.mozilla.gecko.icons.loader.ContentProviderLoader; +import org.mozilla.gecko.icons.loader.DataUriLoader; +import org.mozilla.gecko.icons.loader.DiskLoader; +import org.mozilla.gecko.icons.loader.IconDownloader; +import org.mozilla.gecko.icons.loader.IconGenerator; +import org.mozilla.gecko.icons.loader.IconLoader; +import org.mozilla.gecko.icons.loader.JarLoader; +import org.mozilla.gecko.icons.loader.LegacyLoader; +import org.mozilla.gecko.icons.loader.MemoryLoader; +import org.mozilla.gecko.icons.preparation.AboutPagesPreparer; +import org.mozilla.gecko.icons.preparation.AddDefaultIconUrl; +import org.mozilla.gecko.icons.preparation.FilterKnownFailureUrls; +import org.mozilla.gecko.icons.preparation.FilterMimeTypes; +import org.mozilla.gecko.icons.preparation.FilterPrivilegedUrls; +import org.mozilla.gecko.icons.preparation.LookupIconUrl; +import org.mozilla.gecko.icons.preparation.Preparer; +import org.mozilla.gecko.icons.processing.ColorProcessor; +import org.mozilla.gecko.icons.processing.DiskProcessor; +import org.mozilla.gecko.icons.processing.MemoryProcessor; +import org.mozilla.gecko.icons.processing.Processor; +import org.mozilla.gecko.icons.processing.ResizingProcessor; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Executor for icon requests. + */ +/* package-private */ class IconRequestExecutor { + /** + * Loader implementation that generates an icon if none could be loaded. + */ + private static final IconLoader GENERATOR = new IconGenerator(); + + /** + * Ordered list of prepares that run before any icon is loaded. + */ + private static final List<Preparer> PREPARERS = Arrays.asList( + // First we look into our memory and disk caches if there are some known icon URLs for + // the page URL of the request. + new LookupIconUrl(), + + // For all icons with MIME type we filter entries with unknown MIME type that we probably + // cannot decode anyways. + new FilterMimeTypes(), + + // If this is not a request that is allowed to load icons from privileged locations (omni.jar) + // then filter such icon URLs. + new FilterPrivilegedUrls(), + + // This preparer adds an icon URL for about pages. It's added after the filter for privileged + // URLs. We always want to be able to load those specific icons. + new AboutPagesPreparer(), + + // Add the default favicon URL (*/favicon.ico) to the list of icon URLs; with a low priority, + // this icon URL should be tried last. + new AddDefaultIconUrl(), + + // Finally we filter all URLs that failed to load recently (4xx / 5xx errors). + new FilterKnownFailureUrls() + ); + + /** + * Ordered list of loaders. If a loader returns a response object then subsequent loaders are not run. + */ + private static final List<IconLoader> LOADERS = Arrays.asList( + // First we try to load an icon that is already in the memory. That's cheap. + new MemoryLoader(), + + // Try to decode the icon if it is a data: URI. + new DataUriLoader(), + + // Try to load the icon from the omni.ha if it's a jar:jar URI. + new JarLoader(), + + // Try to load the icon from a content provider (if applicable). + new ContentProviderLoader(), + + // Try to load the icon from the disk cache. + new DiskLoader(), + + // If the icon is not in any of our cashes and can't be decoded then look into the + // database (legacy). Maybe this icon was loaded before the new code was deployed. + new LegacyLoader(), + + // Download the icon from the web. + new IconDownloader() + ); + + /** + * Ordered list of processors that run after an icon has been loaded. + */ + private static final List<Processor> PROCESSORS = Arrays.asList( + // Store the icon (and mapping) in the disk cache if needed + new DiskProcessor(), + + // Resize the icon to match the target size (if possible) + new ResizingProcessor(), + + // Extract the dominant color from the icon + new ColorProcessor(), + + // Store the icon in the memory cache + new MemoryProcessor() + ); + + private static final ExecutorService EXECUTOR; + static { + final ThreadFactory factory = new ThreadFactory() { + @Override + public Thread newThread(@NonNull Runnable runnable) { + Thread thread = new Thread(runnable, "GeckoIconTask"); + thread.setDaemon(false); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + } + }; + + // Single thread executor + EXECUTOR = new ThreadPoolExecutor( + 1, /* corePoolSize */ + 1, /* maximumPoolSize */ + 0L, /* keepAliveTime */ + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<Runnable>(), + factory); + } + + /** + * Submit the request for execution. + */ + /* package-private */ static Future<IconResponse> submit(IconRequest request) { + return EXECUTOR.submit( + new IconTask(request, PREPARERS, LOADERS, PROCESSORS, GENERATOR) + ); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java new file mode 100644 index 000000000..726619eb9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java @@ -0,0 +1,167 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.graphics.Bitmap; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +/** + * Response object containing a successful loaded icon and meta data. + */ +public class IconResponse { + /** + * Create a response for a plain bitmap. + */ + public static IconResponse create(@NonNull Bitmap bitmap) { + return new IconResponse(bitmap); + } + + /** + * Create a response for a bitmap that has been loaded from the network by requesting a specific URL. + */ + public static IconResponse createFromNetwork(@NonNull Bitmap bitmap, @NonNull String url) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.fromNetwork = true; + return response; + } + + /** + * Create a response for a generated bitmap with a dominant color. + */ + public static IconResponse createGenerated(@NonNull Bitmap bitmap, int color) { + final IconResponse response = new IconResponse(bitmap); + response.color = color; + response.generated = true; + return response; + } + + /** + * Create a response for a bitmap that has been loaded from the memory cache. + */ + public static IconResponse createFromMemory(@NonNull Bitmap bitmap, @NonNull String url, int color) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.color = color; + response.fromMemory = true; + return response; + } + + /** + * Create a response for a bitmap that has been loaded from the disk cache. + */ + public static IconResponse createFromDisk(@NonNull Bitmap bitmap, @NonNull String url) { + final IconResponse response = new IconResponse(bitmap); + response.url = url; + response.fromDisk = true; + return response; + } + + private Bitmap bitmap; + private int color; + private boolean fromNetwork; + private boolean fromMemory; + private boolean fromDisk; + private boolean generated; + private String url; + + private IconResponse(Bitmap bitmap) { + if (bitmap == null) { + throw new NullPointerException("Bitmap is null"); + } + + this.bitmap = bitmap; + this.color = 0; + this.url = null; + this.fromNetwork = false; + this.fromMemory = false; + this.fromDisk = false; + this.generated = false; + } + + /** + * Get the icon bitmap. This method will always return a bitmap. + */ + @NonNull + public Bitmap getBitmap() { + return bitmap; + } + + /** + * Get the dominant color of the icon. Will return 0 if no color could be extracted. + */ + public int getColor() { + return color; + } + + /** + * Does this response contain a dominant color? + */ + public boolean hasColor() { + return color != 0; + } + + /** + * Has this icon been loaded from the network? + */ + public boolean isFromNetwork() { + return fromNetwork; + } + + /** + * Has this icon been generated? + */ + public boolean isGenerated() { + return generated; + } + + /** + * Has this icon been loaded from memory (cache)? + */ + public boolean isFromMemory() { + return fromMemory; + } + + /** + * Has this icon been loaded from disk (cache)? + */ + public boolean isFromDisk() { + return fromDisk; + } + + /** + * Get the URL this icon has been loaded from. + */ + @Nullable + public String getUrl() { + return url; + } + + /** + * Does this response contain an URL from which the icon has been loaded? + */ + public boolean hasUrl() { + return !TextUtils.isEmpty(url); + } + + /** + * Update the color of this response. This method is called by processors updating meta data + * after the icon has been loaded. + */ + public void updateColor(int color) { + this.color = color; + } + + /** + * Update the bitmap of this response. This method is called by processors that modify the + * loaded icon. + */ + public void updateBitmap(Bitmap bitmap) { + this.bitmap = bitmap; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java new file mode 100644 index 000000000..411a31980 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java @@ -0,0 +1,222 @@ +/* -*- 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.icons; + +import android.graphics.Bitmap; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.icons.loader.IconLoader; +import org.mozilla.gecko.icons.preparation.Preparer; +import org.mozilla.gecko.icons.processing.Processor; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Task that will be run by the IconRequestExecutor for every icon request. + */ +/* package-private */ class IconTask implements Callable<IconResponse> { + private static final String LOGTAG = "Gecko/IconTask"; + private static final boolean DEBUG = false; + + private final List<Preparer> preparers; + private final List<IconLoader> loaders; + private final List<Processor> processors; + private final IconLoader generator; + private final IconRequest request; + + /* package-private */ IconTask( + @NonNull IconRequest request, + @NonNull List<Preparer> preparers, + @NonNull List<IconLoader> loaders, + @NonNull List<Processor> processors, + @NonNull IconLoader generator) { + this.request = request; + this.preparers = preparers; + this.loaders = loaders; + this.processors = processors; + this.generator = generator; + } + + @Override + public IconResponse call() { + try { + logRequest(request); + + prepareRequest(request); + + if (request.shouldPrepareOnly()) { + // This request should only be prepared but not load an actual icon. + return null; + } + + final IconResponse response = loadIcon(request); + + if (response != null) { + processIcon(request, response); + executeCallback(request, response); + + logResponse(response); + + return response; + } + } catch (InterruptedException e) { + Log.d(LOGTAG, "IconTask was interrupted", e); + + // Clear interrupt thread. + Thread.interrupted(); + } catch (Throwable e) { + handleException(e); + } + + return null; + } + + /** + * Check if this thread was interrupted (e.g. this task was cancelled). Throws an InterruptedException + * to stop executing the task in this case. + */ + private void ensureNotInterrupted() throws InterruptedException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Task has been cancelled"); + } + } + + private void executeCallback(IconRequest request, final IconResponse response) { + final IconCallback callback = request.getCallback(); + + if (callback != null) { + if (request.shouldRunOnBackgroundThread()) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + callback.onIconResponse(response); + } + }); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + callback.onIconResponse(response); + } + }); + } + } + } + + private void prepareRequest(IconRequest request) throws InterruptedException { + for (Preparer preparer : preparers) { + ensureNotInterrupted(); + + preparer.prepare(request); + + logPreparer(request, preparer); + } + } + + private IconResponse loadIcon(IconRequest request) throws InterruptedException { + while (request.hasIconDescriptors()) { + for (IconLoader loader : loaders) { + ensureNotInterrupted(); + + IconResponse response = loader.load(request); + + logLoader(request, loader, response); + + if (response != null) { + return response; + } + } + + request.moveToNextIcon(); + } + + return generator.load(request); + } + + private void processIcon(IconRequest request, IconResponse response) throws InterruptedException { + for (Processor processor : processors) { + ensureNotInterrupted(); + + processor.process(request, response); + + logProcessor(processor); + } + } + + private void handleException(final Throwable t) { + if (AppConstants.NIGHTLY_BUILD) { + // We want to be aware of problems: Let's re-throw the exception on the main thread to + // force an app crash. However we only do this in Nightly builds. Every other build + // (especially release builds) should just carry on and log the error. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + throw new RuntimeException("Icon task thread crashed", t); + } + }); + } else { + Log.e(LOGTAG, "Icon task crashed", t); + } + } + + private boolean shouldLog() { + // Do not log anything if debugging is disabled and never log anything in a non-nightly build. + return DEBUG && AppConstants.NIGHTLY_BUILD; + } + + private void logPreparer(IconRequest request, Preparer preparer) { + if (!shouldLog()) { + return; + } + + Log.d(LOGTAG, String.format(" PREPARE %s" + " (%s)", + preparer.getClass().getSimpleName(), + request.getIconCount())); + } + + private void logLoader(IconRequest request, IconLoader loader, IconResponse response) { + if (!shouldLog()) { + return; + } + + Log.d(LOGTAG, String.format(" LOAD [%s] %s : %s", + response != null ? "X" : " ", + loader.getClass().getSimpleName(), + request.getBestIcon().getUrl())); + } + + private void logProcessor(Processor processor) { + if (!shouldLog()) { + return; + } + + Log.d(LOGTAG, " PROCESS " + processor.getClass().getSimpleName()); + } + + private void logResponse(IconResponse response) { + if (!shouldLog()) { + return; + } + + final Bitmap bitmap = response.getBitmap(); + + Log.d(LOGTAG, String.format("=> ICON: %sx%s", bitmap.getWidth(), bitmap.getHeight())); + } + + private void logRequest(IconRequest request) { + if (!shouldLog()) { + return; + } + + Log.d(LOGTAG, String.format("REQUEST (%s) %s", + request.getIconCount(), + request.getPageUrl())); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java b/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java new file mode 100644 index 000000000..a5505a694 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java @@ -0,0 +1,35 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.content.Context; +import android.support.annotation.CheckResult; + +/** + * Entry point for loading icons for websites (just high quality icons, can be favicons or + * touch icons). + * + * The API is loosely inspired by Picasso's builder. + * + * Example: + * + * Icons.with(context) + * .pageUrl(pageURL) + * .skipNetwork() + * .privileged(true) + * .icon(IconDescriptor.createGenericIcon(url)) + * .build() + * .execute(callback); + */ +public abstract class Icons { + /** + * Create a new request for loading a website icon. + */ + @CheckResult + public static IconRequestBuilder with(Context context) { + return new IconRequestBuilder(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java new file mode 100644 index 000000000..d351eb4b7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java @@ -0,0 +1,140 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.util.StringUtils; + +import java.util.HashSet; + +/** + * Helper methods for icon related tasks. + */ +public class IconsHelper { + private static final String LOGTAG = "Gecko/IconsHelper"; + + // Mime types of things we are capable of decoding. + private static final HashSet<String> sDecodableMimeTypes = new HashSet<>(); + + // Mime types of things we are both capable of decoding and are container formats (May contain + // multiple different sizes of image) + private static final HashSet<String> sContainerMimeTypes = new HashSet<>(); + + static { + // MIME types extracted from http://filext.com - ostensibly all in-use mime types for the + // corresponding formats. + // ICO + sContainerMimeTypes.add("image/vnd.microsoft.icon"); + sContainerMimeTypes.add("image/ico"); + sContainerMimeTypes.add("image/icon"); + sContainerMimeTypes.add("image/x-icon"); + sContainerMimeTypes.add("text/ico"); + sContainerMimeTypes.add("application/ico"); + + // Add supported container types to the set of supported types. + sDecodableMimeTypes.addAll(sContainerMimeTypes); + + // PNG + sDecodableMimeTypes.add("image/png"); + sDecodableMimeTypes.add("application/png"); + sDecodableMimeTypes.add("application/x-png"); + + // GIF + sDecodableMimeTypes.add("image/gif"); + + // JPEG + sDecodableMimeTypes.add("image/jpeg"); + sDecodableMimeTypes.add("image/jpg"); + sDecodableMimeTypes.add("image/pipeg"); + sDecodableMimeTypes.add("image/vnd.swiftview-jpeg"); + sDecodableMimeTypes.add("application/jpg"); + sDecodableMimeTypes.add("application/x-jpg"); + + // BMP + sDecodableMimeTypes.add("application/bmp"); + sDecodableMimeTypes.add("application/x-bmp"); + sDecodableMimeTypes.add("application/x-win-bitmap"); + sDecodableMimeTypes.add("image/bmp"); + sDecodableMimeTypes.add("image/x-bmp"); + sDecodableMimeTypes.add("image/x-bitmap"); + sDecodableMimeTypes.add("image/x-xbitmap"); + sDecodableMimeTypes.add("image/x-win-bitmap"); + sDecodableMimeTypes.add("image/x-windows-bitmap"); + sDecodableMimeTypes.add("image/x-ms-bitmap"); + sDecodableMimeTypes.add("image/x-ms-bmp"); + sDecodableMimeTypes.add("image/ms-bmp"); + } + + /** + * Helper method to getIcon the default Favicon URL for a given pageURL. Generally: somewhere.com/favicon.ico + * + * @param pageURL Page URL for which a default Favicon URL is requested + * @return The default Favicon URL or null if no default URL could be guessed. + */ + @Nullable + public static String guessDefaultFaviconURL(String pageURL) { + if (TextUtils.isEmpty(pageURL)) { + return null; + } + + // Special-casing for about: pages. The favicon for about:pages which don't provide a link tag + // is bundled in the database, keyed only by page URL, hence the need to return the page URL + // here. If the database ever migrates to stop being silly in this way, this can plausibly + // be removed. + if (AboutPages.isAboutPage(pageURL) || pageURL.startsWith("jar:")) { + return pageURL; + } + + if (!StringUtils.isHttpOrHttps(pageURL)) { + // Guessing a default URL only makes sense for http(s) URLs. + return null; + } + + try { + // Fall back to trying "someScheme:someDomain.someExtension/favicon.ico". + Uri uri = Uri.parse(pageURL); + if (uri.getAuthority().isEmpty()) { + return null; + } + + return uri.buildUpon() + .path("favicon.ico") + .clearQuery() + .fragment("") + .build() + .toString(); + } catch (Exception e) { + Log.d(LOGTAG, "Exception getting default favicon URL"); + return null; + } + } + + /** + * Helper function to determine if the provided mime type is that of a format that can contain + * multiple image types. At time of writing, the only such type is ICO. + * @param mimeType Mime type to check. + * @return true if the given mime type is a container type, false otherwise. + */ + public static boolean isContainerType(@NonNull String mimeType) { + return sContainerMimeTypes.contains(mimeType); + } + + /** + * Helper function to determine if we can decode a particular mime type. + * + * @param imgType Mime type to check for decodability. + * @return false if the given mime type is certainly not decodable, true if it might be. + */ + public static boolean canDecodeType(@NonNull String imgType) { + return sDecodableMimeTypes.contains(imgType); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java new file mode 100644 index 000000000..43f5d0ac6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java @@ -0,0 +1,197 @@ +/* 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.icons.decoders; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.Base64; +import android.util.Log; + +import org.mozilla.gecko.gfx.BitmapUtils; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * Class providing static utility methods for decoding favicons. + */ +public class FaviconDecoder { + private static final String LOG_TAG = "GeckoFaviconDecoder"; + + static enum ImageMagicNumbers { + // It is irritating that Java bytes are signed... + PNG(new byte[] {(byte) (0x89 & 0xFF), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}), + GIF(new byte[] {0x47, 0x49, 0x46, 0x38}), + JPEG(new byte[] {-0x1, -0x28, -0x1, -0x20}), + BMP(new byte[] {0x42, 0x4d}), + WEB(new byte[] {0x57, 0x45, 0x42, 0x50, 0x0a}); + + public byte[] value; + + private ImageMagicNumbers(byte[] value) { + this.value = value; + } + } + + /** + * Check for image format magic numbers of formats supported by Android. + * @param buffer Byte buffer to check for magic numbers + * @param offset Offset at which to look for magic numbers. + * @return true if the buffer contains a bitmap decodable by Android (Or at least, a sequence + * starting with the magic numbers thereof). false otherwise. + */ + private static boolean isDecodableByAndroid(byte[] buffer, int offset) { + for (ImageMagicNumbers m : ImageMagicNumbers.values()) { + if (bufferStartsWith(buffer, m.value, offset)) { + return true; + } + } + + return false; + } + + /** + * Utility function to check for the existence of a test byte sequence at a given offset in a + * buffer. + * + * @param buffer Byte buffer to search. + * @param test Byte sequence to search for. + * @param bufferOffset Index in input buffer to expect test sequence. + * @return true if buffer contains the byte sequence given in test at offset bufferOffset, false + * otherwise. + */ + static boolean bufferStartsWith(byte[] buffer, byte[] test, int bufferOffset) { + if (buffer.length < test.length) { + return false; + } + + for (int i = 0; i < test.length; ++i) { + if (buffer[bufferOffset + i] != test[i]) { + return false; + } + } + return true; + } + + /** + * Decode the favicon present in the region of the provided byte[] starting at offset and + * proceeding for length bytes, if any. Returns either the resulting LoadFaviconResult or null if the + * given range does not contain a bitmap we know how to decode. + * + * @param buffer Byte array containing the favicon to decode. + * @param offset The index of the first byte in the array of the region of interest. + * @param length The length of the region in the array to decode. + * @return The decoded version of the bitmap in the described region, or null if none can be + * decoded. + */ + public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer, int offset, int length) { + LoadFaviconResult result; + if (isDecodableByAndroid(buffer, offset)) { + result = new LoadFaviconResult(); + result.offset = offset; + result.length = length; + result.isICO = false; + + Bitmap decodedImage = BitmapUtils.decodeByteArray(buffer, offset, length); + if (decodedImage == null) { + // What we got wasn't decodable after all. Probably corrupted image, or we got a muffled OOM. + return null; + } + + // We assume here that decodeByteArray doesn't hold on to the entire supplied + // buffer -- worst case, each of our buffers will be twice the necessary size. + result.bitmapsDecoded = new SingleBitmapIterator(decodedImage); + result.faviconBytes = buffer; + + return result; + } + + // If it's not decodable by Android, it might be an ICO. Let's try. + ICODecoder decoder = new ICODecoder(context, buffer, offset, length); + + result = decoder.decode(); + + if (result == null) { + return null; + } + + return result; + } + + public static LoadFaviconResult decodeDataURI(Context context, String uri) { + if (uri == null) { + Log.w(LOG_TAG, "Can't decode null data: URI."); + return null; + } + + if (!uri.startsWith("data:image/")) { + // Can't decode non-image data: URI. + return null; + } + + // Otherwise, let's attack this blindly. Strictly we should be parsing. + int offset = uri.indexOf(',') + 1; + if (offset == 0) { + Log.w(LOG_TAG, "No ',' in data: URI; malformed?"); + return null; + } + + try { + String base64 = uri.substring(offset); + byte[] raw = Base64.decode(base64, Base64.DEFAULT); + return decodeFavicon(context, raw); + } catch (Exception e) { + Log.w(LOG_TAG, "Couldn't decode data: URI.", e); + return null; + } + } + + public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer) { + return decodeFavicon(context, buffer, 0, buffer.length); + } + + /** + * Iterator to hold a single bitmap. + */ + static class SingleBitmapIterator implements Iterator<Bitmap> { + private Bitmap bitmap; + + public SingleBitmapIterator(Bitmap b) { + bitmap = b; + } + + /** + * Slightly cheating here - this iterator supports peeking (Handy in a couple of obscure + * places where the runtime type of the Iterator under consideration is known and + * destruction of it is discouraged. + * + * @return The bitmap carried by this SingleBitmapIterator. + */ + public Bitmap peek() { + return bitmap; + } + + @Override + public boolean hasNext() { + return bitmap != null; + } + + @Override + public Bitmap next() { + if (bitmap == null) { + throw new NoSuchElementException("Element already returned from SingleBitmapIterator."); + } + + Bitmap ret = bitmap; + bitmap = null; + return ret; + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove() not supported on SingleBitmapIterator."); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java new file mode 100644 index 000000000..44e3f1252 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java @@ -0,0 +1,396 @@ +/* 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.icons.decoders; + +import android.content.Context; +import android.graphics.Bitmap; +import android.support.annotation.VisibleForTesting; +import android.util.SparseArray; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.R; + +/** + * Utility class for determining the region of a provided array which contains the largest bitmap, + * assuming the provided array is a valid ICO and the bitmap desired is square, and for pruning + * unwanted entries from ICO files, if desired. + * + * An ICO file is a container format that may hold up to 255 images in either BMP or PNG format. + * A mixture of image types may not exist. + * + * The format consists of a header specifying the number, n, of images, followed by the Icon Directory. + * + * The Icon Directory consists of n Icon Directory Entries, each 16 bytes in length, specifying, for + * the corresponding image, the dimensions, colour information, payload size, and location in the file. + * + * All numerical fields follow a little-endian byte ordering. + * + * Header format: + * + * 0 1 2 3 + * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Reserved field. Must be zero | Type (1 for ICO, 2 for CUR) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Image count (n) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * + * The type field is expected to always be 1. CUR format images should not be used for Favicons. + * + * + * Icon Directory Entry format: + * + * 0 1 2 3 + * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Image width | Image height | Palette size | Reserved (0) | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Colour plane count | Bits per pixel | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Size of image data, in bytes | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Start of image data, as an offset from start of file | + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * + * Image dimensions of zero are to be interpreted as image dimensions of 256. + * + * The palette size field records the number of colours in the stored BMP, if a palette is used. Zero + * if the payload is a PNG or no palette is in use. + * + * The number of colour planes is, usually, 0 (Not in use) or 1. Values greater than 1 are to be + * interpreted not as a colour plane count, but as a multiplying factor on the bits per pixel field. + * (Apparently 65535 was not deemed a sufficiently large maximum value of bits per pixel.) + * + * + * The Icon Directory consists of n-many Icon Directory Entries in sequence, with no gaps. + * + * This class is not thread safe. + */ +public class ICODecoder implements Iterable<Bitmap> { + // The number of bytes that compacting will save for us to bother doing it. + public static final int COMPACT_THRESHOLD = 4000; + + // Some geometry of an ICO file. + public static final int ICO_HEADER_LENGTH_BYTES = 6; + public static final int ICO_ICONDIRENTRY_LENGTH_BYTES = 16; + + // The buffer containing bytes to attempt to decode. + private byte[] decodand; + + // The region of the decodand to decode. + private int offset; + private int len; + + IconDirectoryEntry[] iconDirectory; + private boolean isValid; + private boolean hasDecoded; + private int largestFaviconSize; + + @RobocopTarget + public ICODecoder(Context context, byte[] decodand, int offset, int len) { + this.decodand = decodand; + this.offset = offset; + this.len = len; + this.largestFaviconSize = context.getResources() + .getDimensionPixelSize(R.dimen.favicon_largest_interesting_size); + } + + /** + * Decode the Icon Directory for this ICO and store the result in iconDirectory. + * + * @return true if ICO decoding was considered to probably be a success, false if it certainly + * was a failure. + */ + private boolean decodeIconDirectoryAndPossiblyPrune() { + hasDecoded = true; + + // Fail if the end of the described range is out of bounds. + if (offset + len > decodand.length) { + return false; + } + + // Fail if we don't have enough space for the header. + if (len < ICO_HEADER_LENGTH_BYTES) { + return false; + } + + // Check that the reserved fields in the header are indeed zero, and that the type field + // specifies ICO. If not, we've probably been given something that isn't really an ICO. + if (decodand[offset] != 0 || + decodand[offset + 1] != 0 || + decodand[offset + 2] != 1 || + decodand[offset + 3] != 0) { + return false; + } + + // Here, and in many other places, byte values are ANDed with 0xFF. This is because Java + // bytes are signed - to obtain a numerical value of a longer type which holds the unsigned + // interpretation of the byte of interest, we do this. + int numEncodedImages = (decodand[offset + 4] & 0xFF) | + (decodand[offset + 5] & 0xFF) << 8; + + + // Fail if there are no images or the field is corrupt. + if (numEncodedImages <= 0) { + return false; + } + + final int headerAndDirectorySize = ICO_HEADER_LENGTH_BYTES + (numEncodedImages * ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Fail if there is not enough space in the buffer for the stated number of icondir entries, + // let alone the data. + if (len < headerAndDirectorySize) { + return false; + } + + // Put the pointer on the first byte of the first Icon Directory Entry. + int bufferIndex = offset + ICO_HEADER_LENGTH_BYTES; + + // We now iterate over the Icon Directory, decoding each entry as we go. We also need to + // discard all entries except one >= the maximum interesting size. + + // Size of the smallest image larger than the limit encountered. + int minimumMaximum = Integer.MAX_VALUE; + + // Used to track the best entry for each size. The entries we want to keep. + SparseArray<IconDirectoryEntry> preferenceArray = new SparseArray<IconDirectoryEntry>(); + + for (int i = 0; i < numEncodedImages; i++, bufferIndex += ICO_ICONDIRENTRY_LENGTH_BYTES) { + // Decode the Icon Directory Entry at this offset. + IconDirectoryEntry newEntry = IconDirectoryEntry.createFromBuffer(decodand, offset, len, bufferIndex); + newEntry.index = i; + + if (newEntry.isErroneous) { + continue; + } + + if (newEntry.width > largestFaviconSize) { + // If we already have a smaller image larger than the maximum size of interest, we + // don't care about the new one which is larger than the smallest image larger than + // the maximum size. + if (newEntry.width >= minimumMaximum) { + continue; + } + + // Remove the previous minimum-maximum. + preferenceArray.delete(minimumMaximum); + + minimumMaximum = newEntry.width; + } + + IconDirectoryEntry oldEntry = preferenceArray.get(newEntry.width); + if (oldEntry == null) { + preferenceArray.put(newEntry.width, newEntry); + continue; + } + + if (oldEntry.compareTo(newEntry) < 0) { + preferenceArray.put(newEntry.width, newEntry); + } + } + + final int count = preferenceArray.size(); + + // Abort if no entries are desired (Perhaps all are corrupt?) + if (count == 0) { + return false; + } + + // Allocate space for the icon directory entries in the decoded directory. + iconDirectory = new IconDirectoryEntry[count]; + + // The size of the data in the buffer that we find useful. + int retainedSpace = ICO_HEADER_LENGTH_BYTES; + + for (int i = 0; i < count; i++) { + IconDirectoryEntry e = preferenceArray.valueAt(i); + retainedSpace += ICO_ICONDIRENTRY_LENGTH_BYTES + e.payloadSize; + iconDirectory[i] = e; + } + + isValid = true; + + // Set the number of images field in the buffer to reflect the number of retained entries. + decodand[offset + 4] = (byte) iconDirectory.length; + decodand[offset + 5] = (byte) (iconDirectory.length >>> 8); + + if ((len - retainedSpace) > COMPACT_THRESHOLD) { + compactingCopy(retainedSpace); + } + + return true; + } + + /** + * Copy the buffer into a new array of exactly the required size, omitting any unwanted data. + */ + private void compactingCopy(int spaceRetained) { + byte[] buf = new byte[spaceRetained]; + + // Copy the header. + System.arraycopy(decodand, offset, buf, 0, ICO_HEADER_LENGTH_BYTES); + + int headerPtr = ICO_HEADER_LENGTH_BYTES; + + int payloadPtr = ICO_HEADER_LENGTH_BYTES + (iconDirectory.length * ICO_ICONDIRENTRY_LENGTH_BYTES); + + int ind = 0; + for (IconDirectoryEntry entry : iconDirectory) { + // Copy this entry. + System.arraycopy(decodand, offset + entry.getOffset(), buf, headerPtr, ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Copy its payload. + System.arraycopy(decodand, offset + entry.payloadOffset, buf, payloadPtr, entry.payloadSize); + + // Update the offset field. + buf[headerPtr + 12] = (byte) payloadPtr; + buf[headerPtr + 13] = (byte) (payloadPtr >>> 8); + buf[headerPtr + 14] = (byte) (payloadPtr >>> 16); + buf[headerPtr + 15] = (byte) (payloadPtr >>> 24); + + entry.payloadOffset = payloadPtr; + entry.index = ind; + + payloadPtr += entry.payloadSize; + headerPtr += ICO_ICONDIRENTRY_LENGTH_BYTES; + ind++; + } + + decodand = buf; + offset = 0; + len = spaceRetained; + } + + /** + * Decode and return the bitmap represented by the given index in the Icon Directory, if valid. + * + * @param index The index into the Icon Directory of the image of interest. + * @return The decoded Bitmap object for this image, or null if the entry is invalid or decoding + * fails. + */ + public Bitmap decodeBitmapAtIndex(int index) { + final IconDirectoryEntry iconDirEntry = iconDirectory[index]; + + if (iconDirEntry.payloadIsPNG) { + // PNG payload. Simply extract it and decode it. + return BitmapUtils.decodeByteArray(decodand, offset + iconDirEntry.payloadOffset, iconDirEntry.payloadSize); + } + + // The payload is a BMP, so we need to do some magic to get the decoder to do what we want. + // We construct an ICO containing just the image we want, and let Android do the rest. + byte[] decodeTarget = new byte[ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES + iconDirEntry.payloadSize]; + + // Set the type field in the ICO header. + decodeTarget[2] = 1; + + // Set the num-images field in the header to 1. + decodeTarget[4] = 1; + + // Copy the ICONDIRENTRY we need into the new buffer. + System.arraycopy(decodand, offset + iconDirEntry.getOffset(), decodeTarget, ICO_HEADER_LENGTH_BYTES, ICO_ICONDIRENTRY_LENGTH_BYTES); + + // Copy the payload into the new buffer. + final int singlePayloadOffset = ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES; + System.arraycopy(decodand, offset + iconDirEntry.payloadOffset, decodeTarget, singlePayloadOffset, iconDirEntry.payloadSize); + + // Update the offset field of the ICONDIRENTRY to make the new ICO valid. + decodeTarget[ICO_HEADER_LENGTH_BYTES + 12] = singlePayloadOffset; + decodeTarget[ICO_HEADER_LENGTH_BYTES + 13] = (singlePayloadOffset >>> 8); + decodeTarget[ICO_HEADER_LENGTH_BYTES + 14] = (singlePayloadOffset >>> 16); + decodeTarget[ICO_HEADER_LENGTH_BYTES + 15] = (singlePayloadOffset >>> 24); + + // Decode the newly-constructed singleton-ICO. + return BitmapUtils.decodeByteArray(decodeTarget); + } + + /** + * Fetch an iterator over the images in this ICO, or null if this ICO seems to be invalid. + * + * @return An iterator over the Bitmaps stored in this ICO, or null if decoding fails. + */ + @Override + public ICOIterator iterator() { + // If a previous call to decode concluded this ICO is invalid, abort. + if (hasDecoded && !isValid) { + return null; + } + + // If we've not been decoded before, but now fail to make any sense of the ICO, abort. + if (!hasDecoded) { + if (!decodeIconDirectoryAndPossiblyPrune()) { + return null; + } + } + + // If decoding was a success, return an iterator over the images in this ICO. + return new ICOIterator(); + } + + /** + * Decode this ICO and return the result as a LoadFaviconResult. + * @return A LoadFaviconResult representing the decoded ICO. + */ + public LoadFaviconResult decode() { + // The call to iterator returns null if decoding fails. + Iterator<Bitmap> bitmaps = iterator(); + if (bitmaps == null) { + return null; + } + + LoadFaviconResult result = new LoadFaviconResult(); + + result.bitmapsDecoded = bitmaps; + result.faviconBytes = decodand; + result.offset = offset; + result.length = len; + result.isICO = true; + + return result; + } + + @VisibleForTesting + @RobocopTarget + public IconDirectoryEntry[] getIconDirectory() { + return iconDirectory; + } + + @VisibleForTesting + @RobocopTarget + public int getLargestFaviconSize() { + return largestFaviconSize; + } + + /** + * Inner class to iterate over the elements in the ICO represented by the enclosing instance. + */ + private class ICOIterator implements Iterator<Bitmap> { + private int mIndex; + + @Override + public boolean hasNext() { + return mIndex < iconDirectory.length; + } + + @Override + public Bitmap next() { + if (mIndex > iconDirectory.length) { + throw new NoSuchElementException("No more elements in this ICO."); + } + return decodeBitmapAtIndex(mIndex++); + } + + @Override + public void remove() { + if (iconDirectory[mIndex] == null) { + throw new IllegalStateException("Remove already called for element " + mIndex); + } + iconDirectory[mIndex] = null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java new file mode 100644 index 000000000..82ff91a55 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java @@ -0,0 +1,212 @@ +/* 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.icons.decoders; + +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.annotation.RobocopTarget; + +/** + * Representation of an ICO file ICONDIRENTRY structure. + */ +public class IconDirectoryEntry implements Comparable<IconDirectoryEntry> { + + public static int maxBPP; + + int width; + int height; + int paletteSize; + int bitsPerPixel; + int payloadSize; + int payloadOffset; + boolean payloadIsPNG; + + // Tracks the index in the Icon Directory of this entry. Useful only for pruning. + int index; + boolean isErroneous; + + @RobocopTarget + public IconDirectoryEntry(int width, int height, int paletteSize, int bitsPerPixel, int payloadSize, int payloadOffset, boolean payloadIsPNG) { + this.width = width; + this.height = height; + this.paletteSize = paletteSize; + this.bitsPerPixel = bitsPerPixel; + this.payloadSize = payloadSize; + this.payloadOffset = payloadOffset; + this.payloadIsPNG = payloadIsPNG; + } + + /** + * Method to get a dummy Icon Directory Entry with the Erroneous bit set. + * + * @return An erroneous placeholder Icon Directory Entry. + */ + public static IconDirectoryEntry getErroneousEntry() { + IconDirectoryEntry ret = new IconDirectoryEntry(-1, -1, -1, -1, -1, -1, false); + ret.isErroneous = true; + + return ret; + } + + /** + * Create an IconDirectoryEntry object from a byte[]. Interprets the buffer starting at the given + * offset as an IconDirectoryEntry and returns the result. + * + * @param buffer Byte array containing the icon directory entry to decode. + * @param regionOffset Offset into the byte array of the valid region of the buffer. + * @param regionLength Length of the valid region in the buffer. + * @param entryOffset Offset of the icon directory entry to decode within the buffer. + * @return An IconDirectoryEntry object representing the entry specified, or null if the entry + * is obviously invalid. + */ + public static IconDirectoryEntry createFromBuffer(byte[] buffer, int regionOffset, int regionLength, int entryOffset) { + // Verify that the reserved field is really zero. + if (buffer[entryOffset + 3] != 0) { + return getErroneousEntry(); + } + + // Verify that the entry points to a region that actually exists in the buffer, else bin it. + int fieldPtr = entryOffset + 8; + int entryLength = (buffer[fieldPtr] & 0xFF) | + (buffer[fieldPtr + 1] & 0xFF) << 8 | + (buffer[fieldPtr + 2] & 0xFF) << 16 | + (buffer[fieldPtr + 3] & 0xFF) << 24; + + // Advance to the offset field. + fieldPtr += 4; + + int payloadOffset = (buffer[fieldPtr] & 0xFF) | + (buffer[fieldPtr + 1] & 0xFF) << 8 | + (buffer[fieldPtr + 2] & 0xFF) << 16 | + (buffer[fieldPtr + 3] & 0xFF) << 24; + + // Fail if the entry describes a region outside the buffer. + if (payloadOffset < 0 || entryLength < 0 || payloadOffset + entryLength > regionOffset + regionLength) { + return getErroneousEntry(); + } + + // Extract the image dimensions. + int imageWidth = buffer[entryOffset] & 0xFF; + int imageHeight = buffer[entryOffset + 1] & 0xFF; + + // Because Microsoft, a size value of zero represents an image size of 256. + if (imageWidth == 0) { + imageWidth = 256; + } + + if (imageHeight == 0) { + imageHeight = 256; + } + + // If the image uses a colour palette, this is the number of colours, otherwise this is zero. + int paletteSize = buffer[entryOffset + 2] & 0xFF; + + // The plane count - usually 0 or 1. When > 1, taken as multiplier on bitsPerPixel. + int colorPlanes = buffer[entryOffset + 4] & 0xFF; + + int bitsPerPixel = (buffer[entryOffset + 6] & 0xFF) | + (buffer[entryOffset + 7] & 0xFF) << 8; + + if (colorPlanes > 1) { + bitsPerPixel *= colorPlanes; + } + + // Look for PNG magic numbers at the start of the payload. + boolean payloadIsPNG = FaviconDecoder.bufferStartsWith(buffer, FaviconDecoder.ImageMagicNumbers.PNG.value, regionOffset + payloadOffset); + + return new IconDirectoryEntry(imageWidth, imageHeight, paletteSize, bitsPerPixel, entryLength, payloadOffset, payloadIsPNG); + } + + /** + * Get the number of bytes from the start of the ICO file to the beginning of this entry. + */ + public int getOffset() { + return ICODecoder.ICO_HEADER_LENGTH_BYTES + (index * ICODecoder.ICO_ICONDIRENTRY_LENGTH_BYTES); + } + + @Override + public int compareTo(IconDirectoryEntry another) { + if (width > another.width) { + return 1; + } + + if (width < another.width) { + return -1; + } + + // Where both images exceed the max BPP, take the smaller of the two BPP values. + if (bitsPerPixel >= maxBPP && another.bitsPerPixel >= maxBPP) { + if (bitsPerPixel < another.bitsPerPixel) { + return 1; + } + + if (bitsPerPixel > another.bitsPerPixel) { + return -1; + } + } + + // Otherwise, take the larger of the BPP values. + if (bitsPerPixel > another.bitsPerPixel) { + return 1; + } + + if (bitsPerPixel < another.bitsPerPixel) { + return -1; + } + + // Prefer large palettes. + if (paletteSize > another.paletteSize) { + return 1; + } + + if (paletteSize < another.paletteSize) { + return -1; + } + + // Prefer smaller payloads. + if (payloadSize < another.payloadSize) { + return 1; + } + + if (payloadSize > another.payloadSize) { + return -1; + } + + // If all else fails, prefer PNGs over BMPs. They tend to be smaller. + if (payloadIsPNG && !another.payloadIsPNG) { + return 1; + } + + if (!payloadIsPNG && another.payloadIsPNG) { + return -1; + } + + return 0; + } + + public static void setMaxBPP(int maxBPP) { + IconDirectoryEntry.maxBPP = maxBPP; + } + + @VisibleForTesting + @RobocopTarget + public int getWidth() { + return width; + } + + @Override + public String toString() { + return "IconDirectoryEntry{" + + "\nwidth=" + width + + ", \nheight=" + height + + ", \npaletteSize=" + paletteSize + + ", \nbitsPerPixel=" + bitsPerPixel + + ", \npayloadSize=" + payloadSize + + ", \npayloadOffset=" + payloadOffset + + ", \npayloadIsPNG=" + payloadIsPNG + + ", \nindex=" + index + + '}'; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java new file mode 100644 index 000000000..cc196b91e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java @@ -0,0 +1,133 @@ +/* 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.icons.decoders; + +import android.graphics.Bitmap; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.SparseArray; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * Class representing the result of loading a favicon. + * This operation will produce either a collection of favicons, a single favicon, or no favicon. + * It is necessary to model single favicons differently to a collection of one favicon (An entity + * that may not exist with this scheme) since the in-database representation of these things differ. + * (In particular, collections of favicons are stored in encoded ICO format, whereas single icons are + * stored as decoded bitmap blobs.) + */ +public class LoadFaviconResult { + private static final String LOGTAG = "LoadFaviconResult"; + + byte[] faviconBytes; + int offset; + int length; + + boolean isICO; + Iterator<Bitmap> bitmapsDecoded; + + public Iterator<Bitmap> getBitmaps() { + return bitmapsDecoded; + } + + /** + * Return a representation of this result suitable for storing in the database. + * + * @return A byte array containing the bytes from which this result was decoded, + * or null if re-encoding failed. + */ + public byte[] getBytesForDatabaseStorage() { + // Begin by normalising the buffer. + if (offset != 0 || length != faviconBytes.length) { + final byte[] normalised = new byte[length]; + System.arraycopy(faviconBytes, offset, normalised, 0, length); + offset = 0; + faviconBytes = normalised; + } + + // For results containing multiple images, we store the result verbatim. (But cutting the + // buffer to size first). + // We may instead want to consider re-encoding the entire ICO as a collection of efficiently + // encoded PNGs. This may not be worth the CPU time (Indeed, the encoding of single-image + // favicons may also not be worth the time/space tradeoff.). + if (isICO) { + return faviconBytes; + } + + // For results containing a single image, we re-encode the + // result as a PNG in an effort to save space. + final Bitmap favicon = ((FaviconDecoder.SingleBitmapIterator) bitmapsDecoded).peek(); + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + try { + if (favicon.compress(Bitmap.CompressFormat.PNG, 100, stream)) { + return stream.toByteArray(); + } + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Out of memory re-compressing favicon."); + } + + Log.w(LOGTAG, "Favicon re-compression failed."); + return null; + } + + @Nullable + public Bitmap getBestBitmap(int targetWidthAndHeight) { + final SparseArray<Bitmap> iconMap = new SparseArray<>(); + final List<Integer> sizes = new ArrayList<>(); + + while (bitmapsDecoded.hasNext()) { + final Bitmap b = bitmapsDecoded.next(); + + // It's possible to receive null, most likely due to OOM or a zero-sized image, + // from BitmapUtils.decodeByteArray(byte[], int, int, BitmapFactory.Options) + if (b != null) { + iconMap.put(b.getWidth(), b); + sizes.add(b.getWidth()); + } + } + + int bestSize = selectBestSizeFromList(sizes, targetWidthAndHeight); + + if (bestSize == -1) { + // No icons found: this could occur if we weren't able to process any of the + // supplied icons. + return null; + } + + return iconMap.get(bestSize); + } + + /** + * Select the closest icon size from a list of icon sizes. + * We just find the first icon that is larger than the preferred size if available, or otherwise select the + * largest icon (if all icons are smaller than the preferred size). + * + * @return The closest icon size, or -1 if no sizes are supplied. + */ + public static int selectBestSizeFromList(final List<Integer> sizes, final int preferredSize) { + if (sizes.isEmpty()) { + // This isn't ideal, however current code assumes this as an error value for now. + return -1; + } + + Collections.sort(sizes); + + for (int size : sizes) { + if (size >= preferredSize) { + return size; + } + } + + // If all icons are smaller than the preferred size then we don't have an icon + // selected yet, therefore just take the largest (last) icon. + return sizes.get(sizes.size() - 1); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java new file mode 100644 index 000000000..be8e6d7de --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java @@ -0,0 +1,96 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.text.TextUtils; + +import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy; +import org.mozilla.gecko.icons.decoders.FaviconDecoder; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Loader for loading icons from a content provider. This loader was primarily written to load icons + * from the partner bookmarks provider. However it can load icons from arbitrary content providers + * as long as they return a cursor with a "favicon" or "touchicon" column (blob). + */ +public class ContentProviderLoader implements IconLoader { + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipDisk()) { + // If we should not load data from disk then we do not load from content providers either. + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + final Context context = request.getContext(); + final int targetSize = request.getTargetSize(); + + if (TextUtils.isEmpty(iconUrl) || !iconUrl.startsWith("content://")) { + return null; + } + + Cursor cursor = context.getContentResolver().query( + Uri.parse(iconUrl), + new String[] { + PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON, + PartnerBookmarksProviderProxy.PartnerContract.FAVICON, + }, + null, + null, + null + ); + + if (cursor == null) { + return null; + } + + try { + if (!cursor.moveToFirst()) { + return null; + } + + // Try the touch icon first. It has a higher resolution usually. + Bitmap icon = decodeFromCursor(request.getContext(), cursor, PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON, targetSize); + if (icon != null) { + return IconResponse.create(icon); + } + + icon = decodeFromCursor(request.getContext(), cursor, PartnerBookmarksProviderProxy.PartnerContract.FAVICON, targetSize); + if (icon != null) { + return IconResponse.create(icon); + } + } finally { + cursor.close(); + } + + return null; + } + + private Bitmap decodeFromCursor(Context context, Cursor cursor, String column, int targetWidthAndHeight) { + final int index = cursor.getColumnIndex(column); + if (index == -1) { + return null; + } + + if (cursor.isNull(index)) { + return null; + } + + final byte[] data = cursor.getBlob(index); + LoadFaviconResult result = FaviconDecoder.decodeFavicon(context, data, 0, data.length); + if (result == null) { + return null; + } + + return result.getBestBitmap(targetWidthAndHeight); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java new file mode 100644 index 000000000..9ddc138ec --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java @@ -0,0 +1,36 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import org.mozilla.gecko.icons.decoders.FaviconDecoder; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Loader for loading icons from a data URI. This loader will try to decode any data with an + * "image/*" MIME type. + * + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + */ +public class DataUriLoader implements IconLoader { + @Override + public IconResponse load(IconRequest request) { + final String iconUrl = request.getBestIcon().getUrl(); + + if (!iconUrl.startsWith("data:image/")) { + return null; + } + + LoadFaviconResult loadFaviconResult = FaviconDecoder.decodeDataURI(request.getContext(), iconUrl); + if (loadFaviconResult == null) { + return null; + } + + return IconResponse.create( + loadFaviconResult.getBestBitmap(request.getTargetSize())); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java new file mode 100644 index 000000000..18a38e32b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java @@ -0,0 +1,27 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.storage.DiskStorage; + +/** + * Loader implementation for loading icons from the disk cache (Implemented by DiskStorage). + */ +public class DiskLoader implements IconLoader { + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipDisk()) { + return null; + } + + final DiskStorage storage = DiskStorage.get(request.getContext()); + final String iconUrl = request.getBestIcon().getUrl(); + + return storage.getIcon(iconUrl); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java new file mode 100644 index 000000000..3ae9d15d0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java @@ -0,0 +1,219 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import android.content.Context; +import android.graphics.Bitmap; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.icons.decoders.FaviconDecoder; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.storage.FailureCache; +import org.mozilla.gecko.util.IOUtils; +import org.mozilla.gecko.util.ProxySelector; +import org.mozilla.gecko.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashSet; + +/** + * This loader implementation downloads icons from http(s) URLs. + */ +public class IconDownloader implements IconLoader { + private static final String LOGTAG = "Gecko/Downloader"; + + /** + * The maximum number of http redirects (3xx) until we give up. + */ + private static final int MAX_REDIRECTS_TO_FOLLOW = 5; + + /** + * The default size of the buffer to use for downloading Favicons in the event no size is given + * by the server. */ + private static final int DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000; + + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipNetwork()) { + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + + if (!StringUtils.isHttpOrHttps(iconUrl)) { + return null; + } + + try { + final LoadFaviconResult result = downloadAndDecodeImage(request.getContext(), iconUrl); + if (result == null) { + return null; + } + + final Bitmap bitmap = result.getBestBitmap(request.getTargetSize()); + if (bitmap == null) { + return null; + } + + return IconResponse.createFromNetwork(bitmap, iconUrl); + } catch (Exception e) { + Log.e(LOGTAG, "Error reading favicon", e); + } catch (OutOfMemoryError e) { + Log.e(LOGTAG, "Insufficient memory to process favicon"); + } + + return null; + } + + /** + * Download the Favicon from the given URL and pass it to the decoder function. + * + * @param targetFaviconURL URL of the favicon to download. + * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or + * null if no or corrupt data was received. + * @throws IOException If attempts to fully read the stream result in such an exception, such as + * in the event of a transient connection failure. + * @throws URISyntaxException If the underlying call to tryDownload retries and raises such an + * exception trying a fallback URL. + */ + @VisibleForTesting + LoadFaviconResult downloadAndDecodeImage(Context context, String targetFaviconURL) throws IOException, URISyntaxException { + // Try the URL we were given. + final HttpURLConnection connection = tryDownload(targetFaviconURL); + if (connection == null) { + return null; + } + + InputStream stream = null; + + // Decode the image from the fetched response. + try { + stream = connection.getInputStream(); + return decodeImageFromResponse(context, stream, connection.getHeaderFieldInt("Content-Length", -1)); + } finally { + // Close the stream and free related resources. + IOUtils.safeStreamClose(stream); + connection.disconnect(); + } + } + + /** + * Helper method for trying the download request to grab a Favicon. + * + * @param faviconURI URL of Favicon to try and download + * @return The HttpResponse containing the downloaded Favicon if successful, null otherwise. + */ + private HttpURLConnection tryDownload(String faviconURI) throws URISyntaxException, IOException { + final HashSet<String> visitedLinkSet = new HashSet<>(); + visitedLinkSet.add(faviconURI); + return tryDownloadRecurse(faviconURI, visitedLinkSet); + } + + /** + * Try to download from the favicon URL and recursively follow redirects. + */ + private HttpURLConnection tryDownloadRecurse(String faviconURI, HashSet<String> visited) throws URISyntaxException, IOException { + if (visited.size() == MAX_REDIRECTS_TO_FOLLOW) { + return null; + } + + final HttpURLConnection connection = connectTo(faviconURI); + + // Was the response a failure? + final int status = connection.getResponseCode(); + + // Handle HTTP status codes requesting a redirect. + if (status >= 300 && status < 400) { + final String newURI = connection.getHeaderField("Location"); + + // Handle mad web servers. + try { + if (newURI == null || newURI.equals(faviconURI)) { + return null; + } + + if (visited.contains(newURI)) { + // Already been redirected here - abort. + return null; + } + + visited.add(newURI); + } finally { + connection.disconnect(); + } + + return tryDownloadRecurse(newURI, visited); + } + + if (status >= 400) { + // Client or Server error. Let's not retry loading from this URL again for some time. + FailureCache.get().rememberFailure(faviconURI); + + connection.disconnect(); + return null; + } + + return connection; + } + + @VisibleForTesting + HttpURLConnection connectTo(String faviconURI) throws URISyntaxException, IOException { + final HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy( + new URI(faviconURI)); + + connection.setRequestProperty("User-Agent", GeckoAppShell.getGeckoInterface().getDefaultUAString()); + + // We implemented or own way of following redirects back when this code was using HttpClient. + // Nowadays we should let HttpUrlConnection do the work - assuming that it doesn't follow + // redirects in loops forever. + connection.setInstanceFollowRedirects(false); + + connection.connect(); + + return connection; + } + + /** + * Copies the favicon stream to a buffer and decodes downloaded content into bitmaps using the + * FaviconDecoder. + * + * @param stream to decode + * @param contentLength as reported by the server (or -1) + * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or + * null if no or corrupt data were received. + * @throws IOException If attempts to fully read the stream result in such an exception, such as + * in the event of a transient connection failure. + */ + private LoadFaviconResult decodeImageFromResponse(Context context, InputStream stream, int contentLength) throws IOException { + // This may not be provided, but if it is, it's useful. + final int bufferSize; + if (contentLength > 0) { + // The size was reported and sane, so let's use that. + // Integer overflow should not be a problem for Favicon sizes... + bufferSize = contentLength + 1; + } else { + // No declared size, so guess and reallocate later if it turns out to be too small. + bufferSize = DEFAULT_FAVICON_BUFFER_SIZE_BYTES; + } + + // Read the InputStream into a byte[]. + final IOUtils.ConsumedInputStream result = IOUtils.readFully(stream, bufferSize); + if (result == null) { + return null; + } + + // Having downloaded the image, decode it. + return FaviconDecoder.decodeFavicon(context, result.getData(), 0, result.consumedLength); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java new file mode 100644 index 000000000..e0139345d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java @@ -0,0 +1,168 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.TypedValue; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * This loader will generate an icon in case no icon could be loaded. In order to do so this needs + * to be the last loader that will be tried. + */ +public class IconGenerator implements IconLoader { + // Mozilla's Visual Design Colour Palette + // http://firefoxux.github.io/StyleGuide/#/visualDesign/colours + private static final int[] COLORS = { + 0xFFc33c32, + 0xFFf25820, + 0xFFff9216, + 0xFFffcb00, + 0xFF57bd35, + 0xFF01bdad, + 0xFF0996f8, + 0xFF02538b, + 0xFF1f386e, + 0xFF7a2f7a, + 0xFFea385e, + }; + + // List of common prefixes of host names. Those prefixes will be striped before a prepresentative + // character for an URL is determined. + private static final String[] COMMON_PREFIXES = { + "www.", + "m.", + "mobile.", + }; + + private static final int TEXT_SIZE_DP = 12; + @Override + public IconResponse load(IconRequest request) { + if (request.getIconCount() > 1) { + // There are still other icons to try. We will only generate an icon if there's only one + // icon left and all previous loaders have failed (assuming this is the last one). + return null; + } + + return generate(request.getContext(), request.getPageUrl()); + } + + /** + * Generate default favicon for the given page URL. + */ + @VisibleForTesting static IconResponse generate(Context context, String pageURL) { + final Resources resources = context.getResources(); + final int widthAndHeight = resources.getDimensionPixelSize(R.dimen.favicon_bg); + final int roundedCorners = resources.getDimensionPixelOffset(R.dimen.favicon_corner_radius); + + final Bitmap favicon = Bitmap.createBitmap(widthAndHeight, widthAndHeight, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(favicon); + + final int color = pickColor(pageURL); + + final Paint paint = new Paint(); + paint.setColor(color); + + canvas.drawRoundRect(new RectF(0, 0, widthAndHeight, widthAndHeight), roundedCorners, roundedCorners, paint); + + paint.setColor(Color.WHITE); + + final String character = getRepresentativeCharacter(pageURL); + + final float textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TEXT_SIZE_DP, context.getResources().getDisplayMetrics()); + + paint.setTextAlign(Paint.Align.CENTER); + paint.setTextSize(textSize); + paint.setAntiAlias(true); + + canvas.drawText(character, + canvas.getWidth() / 2, + (int) ((canvas.getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)), + paint); + + return IconResponse.createGenerated(favicon, color); + } + + /** + * Get a representative character for the given URL. + * + * For example this method will return "f" for "http://m.facebook.com/foobar". + */ + @VisibleForTesting static String getRepresentativeCharacter(String url) { + if (TextUtils.isEmpty(url)) { + return "?"; + } + + final String snippet = getRepresentativeSnippet(url); + for (int i = 0; i < snippet.length(); i++) { + char c = snippet.charAt(i); + + if (Character.isLetterOrDigit(c)) { + return String.valueOf(Character.toUpperCase(c)); + } + } + + // Nothing found.. + return "?"; + } + + /** + * Return a color for this URL. Colors will be based on the host. URLs with the same host will + * return the same color. + */ + @VisibleForTesting static int pickColor(String url) { + if (TextUtils.isEmpty(url)) { + return COLORS[0]; + } + + final String snippet = getRepresentativeSnippet(url); + final int color = Math.abs(snippet.hashCode() % COLORS.length); + + return COLORS[color]; + } + + /** + * Get the representative part of the URL. Usually this is the host (without common prefixes). + */ + private static String getRepresentativeSnippet(@NonNull String url) { + Uri uri = Uri.parse(url); + + // Use the host if available + String snippet = uri.getHost(); + + if (TextUtils.isEmpty(snippet)) { + // If the uri does not have a host (e.g. file:// uris) then use the path + snippet = uri.getPath(); + } + + if (TextUtils.isEmpty(snippet)) { + // If we still have no snippet then just return the question mark + return "?"; + } + + // Strip common prefixes that we do not want to use to determine the representative character + for (String prefix : COMMON_PREFIXES) { + if (snippet.startsWith(prefix)) { + snippet = snippet.substring(prefix.length()); + } + } + + return snippet; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java new file mode 100644 index 000000000..8158babc3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java @@ -0,0 +1,23 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import android.support.annotation.Nullable; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Generic interface for classes that can load icons. + */ +public interface IconLoader { + /** + * Loads the icon for this request or returns null if this loader can't load an icon for this + * request or just failed this time. + */ + @Nullable + IconResponse load(IconRequest request); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java new file mode 100644 index 000000000..882d32da5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java @@ -0,0 +1,45 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import android.content.Context; +import android.util.Log; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.util.GeckoJarReader; + +/** + * Loader implementation for loading icons from the omni.ja (jar:jar: URLs). + * + * https://developer.mozilla.org/en-US/docs/Mozilla/About_omni.ja_(formerly_omni.jar) + */ +public class JarLoader implements IconLoader { + private static final String LOGTAG = "Gecko/JarLoader"; + + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipDisk()) { + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + + if (!iconUrl.startsWith("jar:jar:")) { + return null; + } + + try { + final Context context = request.getContext(); + return IconResponse.create( + GeckoJarReader.getBitmap(context, context.getResources(), iconUrl)); + } catch (Exception e) { + // Just about anything could happen here. + Log.w(LOGTAG, "Error fetching favicon from JAR.", e); + return null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java new file mode 100644 index 000000000..d1efc3ad9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java @@ -0,0 +1,74 @@ +/* -*- 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.icons.loader; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.icons.decoders.LoadFaviconResult; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * This legacy loader loads icons from the abandoned database storage. This loader should only exist + * for a couple of releases and be removed afterwards. + * + * When updating to an app version with the new loaders our initial storage won't have any data so + * we need to continue loading from the database storage until the new storage has a good set of data. + */ +public class LegacyLoader implements IconLoader { + @Override + public IconResponse load(IconRequest request) { + if (!request.shouldSkipNetwork()) { + // If we are allowed to load from the network for this request then just ommit the legacy + // loader and fetch a fresh new icon. + return null; + } + + if (request.shouldSkipDisk()) { + return null; + } + + if (request.getIconCount() > 1) { + // There are still other icon URLs to try. Let's try to load from the legacy loader only + // if there's one icon left and the other loads have failed. We will ignore the icon URL + // anyways and try to receive the legacy icon URL from the database. + return null; + } + + final Bitmap bitmap = loadBitmapFromDatabase(request); + + if (bitmap == null) { + return null; + } + + return IconResponse.create(bitmap); + } + + /* package-private */ Bitmap loadBitmapFromDatabase(IconRequest request) { + final Context context = request.getContext(); + final ContentResolver contentResolver = context.getContentResolver(); + final BrowserDB db = BrowserDB.from(context); + + // We ask the database for the favicon URL and ignore the icon URL in the request object: + // As we are not updating the database anymore the icon might be stored under a different URL. + final String legacyFaviconUrl = db.getFaviconURLFromPageURL(contentResolver, request.getPageUrl()); + if (legacyFaviconUrl == null) { + // No URL -> Nothing to load. + return null; + } + + final LoadFaviconResult result = db.getFaviconForUrl(context, context.getContentResolver(), legacyFaviconUrl); + if (result == null) { + return null; + } + + return result.getBestBitmap(request.getTargetSize()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java new file mode 100644 index 000000000..98b651fc7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java @@ -0,0 +1,31 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.loader; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.storage.MemoryStorage; + +/** + * Loader implementation for loading icons from an in-memory cached (Implemented by MemoryStorage). + */ +public class MemoryLoader implements IconLoader { + private final MemoryStorage storage; + + public MemoryLoader() { + storage = MemoryStorage.get(); + } + + @Override + public IconResponse load(IconRequest request) { + if (request.shouldSkipMemory()) { + return null; + } + + final String iconUrl = request.getBestIcon().getUrl(); + return storage.getIcon(iconUrl); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java new file mode 100644 index 000000000..d335cbf51 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java @@ -0,0 +1,39 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.util.GeckoJarReader; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Preparer implementation for adding the omni.ja URL for internal about: pages. + */ +public class AboutPagesPreparer implements Preparer { + private Set<String> aboutUrls; + + public AboutPagesPreparer() { + aboutUrls = new HashSet<>(); + + Collections.addAll(aboutUrls, AboutPages.DEFAULT_ICON_PAGES); + } + + @Override + public void prepare(IconRequest request) { + if (aboutUrls.contains(request.getPageUrl())) { + final String iconUrl = GeckoJarReader.getJarURL(request.getContext(), "chrome/chrome/content/branding/favicon64.png"); + + request.modify() + .icon(IconDescriptor.createLookupIcon(iconUrl)) + .deferBuild(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java new file mode 100644 index 000000000..5bc7d1c1f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java @@ -0,0 +1,39 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import android.text.TextUtils; + +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconsHelper; +import org.mozilla.gecko.util.StringUtils; + +/** + * Preparer to add the "default/guessed" favicon URL (domain/favicon.ico) to the list of URLs to + * try loading the favicon from. + * + * The default URL will be added with a very low priority so that we will only try to load from this + * URL if all other options failed. + */ +public class AddDefaultIconUrl implements Preparer { + @Override + public void prepare(IconRequest request) { + if (!StringUtils.isHttpOrHttps(request.getPageUrl())) { + return; + } + + final String defaultFaviconUrl = IconsHelper.guessDefaultFaviconURL(request.getPageUrl()); + if (TextUtils.isEmpty(defaultFaviconUrl)) { + // We couldn't generate a default favicon URL for this URL. Nothing to do here. + return; + } + + request.modify() + .icon(IconDescriptor.createGenericIcon(defaultFaviconUrl)) + .deferBuild(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java new file mode 100644 index 000000000..effd31a03 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java @@ -0,0 +1,29 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.storage.FailureCache; + +import java.util.Iterator; + +public class FilterKnownFailureUrls implements Preparer { + @Override + public void prepare(IconRequest request) { + final FailureCache failureCache = FailureCache.get(); + final Iterator<IconDescriptor> iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + final IconDescriptor descriptor = iterator.next(); + + if (failureCache.isKnownFailure(descriptor.getUrl())) { + // Loading from this URL has failed in the past. Do not try again. + iterator.remove(); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java new file mode 100644 index 000000000..a12dad2ad --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java @@ -0,0 +1,39 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import android.text.TextUtils; + +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconsHelper; + +import java.util.Iterator; + +/** + * Preparer implementation to filter unknown MIME types to avoid loading images that we cannot decode. + */ +public class FilterMimeTypes implements Preparer { + @Override + public void prepare(IconRequest request) { + final Iterator<IconDescriptor> iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + final IconDescriptor descriptor = iterator.next(); + final String mimeType = descriptor.getMimeType(); + + if (TextUtils.isEmpty(mimeType)) { + // We do not have a MIME type for this icon, so we cannot know in advance if we are able + // to decode it. Let's just continue. + continue; + } + + if (!IconsHelper.canDecodeType(mimeType)) { + iterator.remove(); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java new file mode 100644 index 000000000..abf34c038 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java @@ -0,0 +1,30 @@ +package org.mozilla.gecko.icons.preparation; + +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.util.StringUtils; + +import java.util.Iterator; + +/** + * Filter non http/https URLs if the request is not from privileged code. + */ +public class FilterPrivilegedUrls implements Preparer { + @Override + public void prepare(IconRequest request) { + if (request.isPrivileged()) { + // This request is privileged. No need to filter anything. + return; + } + + final Iterator<IconDescriptor> iterator = request.getIconIterator(); + + while (iterator.hasNext()) { + IconDescriptor descriptor = iterator.next(); + + if (!StringUtils.isHttpOrHttps(descriptor.getUrl())) { + iterator.remove(); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java new file mode 100644 index 000000000..0c7641112 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java @@ -0,0 +1,56 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.storage.DiskStorage; +import org.mozilla.gecko.icons.storage.MemoryStorage; + +/** + * Preparer implementation to lookup the icon URL for the page URL in the request. This class tries + * to locate the icon URL by looking through previously stored mappings on disk and in memory. + */ +public class LookupIconUrl implements Preparer { + @Override + public void prepare(IconRequest request) { + if (lookupFromMemory(request)) { + return; + } + + lookupFromDisk(request); + } + + private boolean lookupFromMemory(IconRequest request) { + final String iconUrl = MemoryStorage.get() + .getMapping(request.getPageUrl()); + + if (iconUrl != null) { + request.modify() + .icon(IconDescriptor.createLookupIcon(iconUrl)) + .deferBuild(); + + return true; + } + + return false; + } + + private boolean lookupFromDisk(IconRequest request) { + final String iconUrl = DiskStorage.get(request.getContext()) + .getMapping(request.getPageUrl()); + + if (iconUrl != null) { + request.modify() + .icon(IconDescriptor.createLookupIcon(iconUrl)) + .deferBuild(); + + return true; + } + + return false; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java new file mode 100644 index 000000000..466307ead --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java @@ -0,0 +1,19 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.preparation; + +import org.mozilla.gecko.icons.IconRequest; + +/** + * Generic interface for a class "preparing" a request before we try to load icons. A class + * implementing this interface can modify the request (e.g. filter or add icon URLs). + */ +public interface Preparer { + /** + * Inspects or modifies the request before any icon is loaded. + */ + void prepare(IconRequest request); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java new file mode 100644 index 000000000..3f7110034 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java @@ -0,0 +1,61 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.processing; + +import android.support.v7.graphics.Palette; +import android.util.Log; + +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.util.HardwareUtils; + +/** + * Processor implementation to extract the dominant color from the icon and attach it to the icon + * response object. + */ +public class ColorProcessor implements Processor { + private static final String LOGTAG = "GeckoColorProcessor"; + private static final int DEFAULT_COLOR = 0; // 0 == No color + + @Override + public void process(IconRequest request, IconResponse response) { + if (response.hasColor()) { + return; + } + + if (HardwareUtils.isX86System()) { + // (Bug 1318667) We are running into crashes when using the palette library with + // specific icons on x86 devices. They take down the whole VM and are not recoverable. + // Unfortunately our release icon is triggering this crash. Until we can switch to a + // newer version of the support library where this does not happen, we are using our + // own slower implementation. + extractColorUsingCustomImplementation(response); + } else { + extractColorUsingPaletteSupportLibrary(response); + } + } + + private void extractColorUsingPaletteSupportLibrary(final IconResponse response) { + try { + final Palette palette = Palette.from(response.getBitmap()).generate(); + response.updateColor(palette.getVibrantColor(DEFAULT_COLOR)); + } catch (ArrayIndexOutOfBoundsException e) { + // We saw the palette library fail with an ArrayIndexOutOfBoundsException intermittently + // in automation. In this case lets just swallow the exception and move on without a + // color. This is a valid condition and callers should handle this gracefully (Bug 1318560). + Log.e(LOGTAG, "Palette generation failed with ArrayIndexOutOfBoundsException", e); + + response.updateColor(DEFAULT_COLOR); + } + } + + private void extractColorUsingCustomImplementation(final IconResponse response) { + final int dominantColor = BitmapUtils.getDominantColor(response.getBitmap()); + + response.updateColor(dominantColor); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java new file mode 100644 index 000000000..150aa503b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java @@ -0,0 +1,36 @@ +package org.mozilla.gecko.icons.processing; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.storage.DiskStorage; +import org.mozilla.gecko.util.StringUtils; + +public class DiskProcessor implements Processor { + @Override + public void process(IconRequest request, IconResponse response) { + if (request.shouldSkipDisk()) { + return; + } + + if (!response.hasUrl() || !StringUtils.isHttpOrHttps(response.getUrl())) { + // If the response does not contain an URL from which the icon was loaded or if this is + // not a http(s) URL then we cannot store this or do not need to (because it's already + // stored somewhere else, like for URLs pointing inside the omni.ja). + return; + } + + final DiskStorage storage = DiskStorage.get(request.getContext()); + + if (response.isFromNetwork()) { + // The icon has been loaded from the network. Store it on the disk now. + storage.putIcon(response); + } + + if (response.isFromMemory() || response.isFromDisk() || response.isFromNetwork()) { + // Remember mapping between page URL and storage URL. Even when this icon has been loaded + // from memory or disk this does not mean that we stored this mapping already: We could + // have loaded this icon for a different page URL previously. + storage.putMapping(request, response.getUrl()); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java new file mode 100644 index 000000000..245faded5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.processing; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.storage.MemoryStorage; + +public class MemoryProcessor implements Processor { + private final MemoryStorage storage; + + public MemoryProcessor() { + storage = MemoryStorage.get(); + } + + @Override + public void process(IconRequest request, IconResponse response) { + if (request.shouldSkipMemory() || request.getIconCount() == 0 || response.isGenerated()) { + // Do not cache this icon in memory if we should skip the memory cache or if this icon + // has been generated. We can re-generate it if needed. + return; + } + + final String iconUrl = request.getBestIcon().getUrl(); + + if (iconUrl.startsWith("data:image/")) { + // The image data is encoded in the URL. It doesn't make sense to store the URL and the + // bitmap in cache. + return; + } + + storage.putMapping(request, iconUrl); + storage.putIcon(iconUrl, response); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java new file mode 100644 index 000000000..df7d63c6c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java @@ -0,0 +1,21 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.processing; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Generic interface for a class that processes a response object after an icon has been loaded and + * decoded. A class implementing this interface can attach additional data to the response or modify + * the bitmap (e.g. resizing). + */ +public interface Processor { + /** + * Process a response object containing an icon loaded for this request. + */ + void process(IconRequest request, IconResponse response); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java new file mode 100644 index 000000000..63b479021 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java @@ -0,0 +1,68 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.processing; + +import android.graphics.Bitmap; +import android.support.annotation.VisibleForTesting; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Processor implementation for resizing the loaded icon based on the target size. + */ +public class ResizingProcessor implements Processor { + @Override + public void process(IconRequest request, IconResponse response) { + if (response.isFromMemory()) { + // This bitmap has been loaded from memory, so it has already gone through the resizing + // process. We do not want to resize the image every time we hit the memory cache. + return; + } + + final Bitmap originalBitmap = response.getBitmap(); + final int size = originalBitmap.getWidth(); + + final int targetSize = request.getTargetSize(); + + if (size == targetSize) { + // The bitmap has exactly the size we are looking for. + return; + } + + final Bitmap resizedBitmap; + + if (size > targetSize) { + resizedBitmap = resize(originalBitmap, targetSize); + } else { + // Our largest primary is smaller than the desired size. Upscale by a maximum of 2x. + // 'largestSize' now reflects the maximum size we can upscale to. + final int largestSize = size * 2; + + if (largestSize > targetSize) { + // Perfect! We can upscale by less than 2x and reach the needed size. Do it. + resizedBitmap = resize(originalBitmap, targetSize); + } else { + // We don't have enough information to make the target size look non terrible. Best effort: + resizedBitmap = resize(originalBitmap, largestSize); + } + } + + response.updateBitmap(resizedBitmap); + + originalBitmap.recycle(); + } + + @VisibleForTesting Bitmap resize(Bitmap bitmap, int targetSize) { + try { + return Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true); + } catch (OutOfMemoryError error) { + // There's not enough memory to create a resized copy of the bitmap in memory. Let's just + // use what we have. + return bitmap; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java new file mode 100644 index 000000000..3c0e2a554 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java @@ -0,0 +1,293 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.storage; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.annotation.CheckResult; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.jakewharton.disklrucache.DiskLruCache; + +import org.mozilla.gecko.background.nativecode.NativeCrypto; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.IOUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; + +/** + * Least Recently Used (LRU) disk cache for icons and the mappings from page URLs to icon URLs. + */ +public class DiskStorage { + private static final String LOGTAG = "Gecko/DiskStorage"; + + /** + * Maximum size (in bytes) of the cache. This cache is located in the cache directory of the + * application and can be cleared by the user. + */ + private static final int DISK_CACHE_SIZE = 50 * 1024 * 1024; + + /** + * Version of the cache. Updating the version will invalidate all existing items. + */ + private static final int CACHE_VERSION = 1; + + private static final String KEY_PREFIX_ICON = "icon:"; + private static final String KEY_PREFIX_MAPPING = "mapping:"; + + private static DiskStorage instance; + + public static DiskStorage get(Context context) { + if (instance == null) { + instance = new DiskStorage(context); + } + + return instance; + } + + private Context context; + private DiskLruCache cache; + + private DiskStorage(Context context) { + this.context = context.getApplicationContext(); + } + + @CheckResult + private synchronized DiskLruCache ensureCacheIsReady() throws IOException { + if (cache == null || cache.isClosed()) { + cache = DiskLruCache.open( + new File(context.getCacheDir(), "icons"), + CACHE_VERSION, + 1, + DISK_CACHE_SIZE); + } + + return cache; + } + + /** + * Store a mapping from page URL to icon URL in the cache. + */ + public void putMapping(IconRequest request, String iconUrl) { + putMapping(request.getPageUrl(), iconUrl); + } + + /** + * Store a mapping from page URL to icon URL in the cache. + */ + public void putMapping(String pageUrl, String iconUrl) { + DiskLruCache.Editor editor = null; + + try { + final DiskLruCache cache = ensureCacheIsReady(); + + final String key = createKey(KEY_PREFIX_MAPPING, pageUrl); + if (key == null) { + return; + } + + editor = cache.edit(key); + if (editor == null) { + return; + } + + editor.set(0, iconUrl); + editor.commit(); + } catch (IOException e) { + Log.w(LOGTAG, "IOException while accessing disk cache", e); + + abortSilently(editor); + } + } + + /** + * Store an icon in the cache (uses the icon URL as key). + */ + public void putIcon(IconResponse response) { + putIcon(response.getUrl(), response.getBitmap()); + } + + /** + * Store an icon in the cache (uses the icon URL as key). + */ + public void putIcon(String iconUrl, Bitmap bitmap) { + OutputStream outputStream = null; + DiskLruCache.Editor editor = null; + + try { + final DiskLruCache cache = ensureCacheIsReady(); + + final String key = createKey(KEY_PREFIX_ICON, iconUrl); + if (key == null) { + return; + } + + editor = cache.edit(key); + if (editor == null) { + return; + } + + outputStream = editor.newOutputStream(0); + boolean success = bitmap.compress(Bitmap.CompressFormat.PNG, 100 /* quality; ignored. PNG is lossless */, outputStream); + + outputStream.close(); + + if (success) { + editor.commit(); + } else { + editor.abort(); + } + } catch (IOException e) { + Log.w(LOGTAG, "IOException while accessing disk cache", e); + + abortSilently(editor); + } finally { + IOUtils.safeStreamClose(outputStream); + } + } + + + + /** + * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL. + */ + @Nullable + public IconResponse getIcon(String iconUrl) { + InputStream inputStream = null; + + try { + final DiskLruCache cache = ensureCacheIsReady(); + + final String key = createKey(KEY_PREFIX_ICON, iconUrl); + if (key == null) { + return null; + } + + if (cache.isClosed()) { + throw new RuntimeException("CLOSED"); + } + + final DiskLruCache.Snapshot snapshot = cache.get(key); + if (snapshot == null) { + return null; + } + + inputStream = snapshot.getInputStream(0); + + final Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap == null) { + return null; + } + + return IconResponse.createFromDisk(bitmap, iconUrl); + } catch (IOException e) { + Log.w(LOGTAG, "IOException while accessing disk cache", e); + } finally { + IOUtils.safeStreamClose(inputStream); + } + + return null; + } + + /** + * Get the icon URL for this page URL. Returns null if no mapping is in the cache. + */ + @Nullable + public String getMapping(String pageUrl) { + try { + final DiskLruCache cache = ensureCacheIsReady(); + + final String key = createKey(KEY_PREFIX_MAPPING, pageUrl); + if (key == null) { + return null; + } + + DiskLruCache.Snapshot snapshot = cache.get(key); + if (snapshot == null) { + return null; + } + + return snapshot.getString(0); + } catch (IOException e) { + Log.w(LOGTAG, "IOException while accessing disk cache", e); + } + + return null; + } + + /** + * Remove all entries from this cache. + */ + public void evictAll() { + try { + final DiskLruCache cache = ensureCacheIsReady(); + + cache.delete(); + + } catch (IOException e) { + Log.w(LOGTAG, "IOException while accessing disk cache", e); + } + } + + /** + * Create a key for this URL using the given prefix. + * + * The disk cache requires valid file names to be used as key. Therefore we hash the created key + * (SHA-256). + */ + @Nullable + private String createKey(String prefix, String url) { + try { + // We use our own crypto implementation to avoid the penalty of loading the java crypto + // framework. + byte[] ctx = NativeCrypto.sha256init(); + if (ctx == null) { + return null; + } + + byte[] data = prefix.getBytes("UTF-8"); + NativeCrypto.sha256update(ctx, data, data.length); + + data = url.getBytes("UTF-8"); + NativeCrypto.sha256update(ctx, data, data.length); + return Utils.byte2Hex(NativeCrypto.sha256finalize(ctx)); + } catch (NoClassDefFoundError | ExceptionInInitializerError error) { + // We could not load libmozglue.so. Let's use Java's MessageDigest as fallback. We do + // this primarily for our unit tests that can't load native libraries. On an device + // we will have a lot of other problems if we can't load libmozglue.so + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(prefix.getBytes("UTF-8")); + md.update(url.getBytes("UTF-8")); + return Utils.byte2Hex(md.digest()); + } catch (Exception e) { + // Just give up. And let everyone know. + throw new RuntimeException(e); + } + } catch (UnsupportedEncodingException e) { + throw new AssertionError("Should not happen: Device does not understand UTF-8"); + } + } + + private void abortSilently(DiskLruCache.Editor editor) { + if (editor != null) { + try { + editor.abort(); + } catch (IOException e) { + // Ignore + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java new file mode 100644 index 000000000..b45cb0fce --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java @@ -0,0 +1,70 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.storage; + +import android.os.SystemClock; +import android.support.annotation.VisibleForTesting; +import android.util.LruCache; + +/** + * In-memory cache to remember URLs from which loading icons has failed recently. + */ +public class FailureCache { + /** + * Retry loading failed icons after 4 hours. + */ + private static final long FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 60 * 4; + + private static final int MAX_ENTRIES = 25; + + private static FailureCache instance; + + public static synchronized FailureCache get() { + if (instance == null) { + instance = new FailureCache(); + } + + return instance; + } + + private final LruCache<String, Long> cache; + + private FailureCache() { + cache = new LruCache<>(MAX_ENTRIES); + } + + /** + * Remember this icon URL after loading from it (over the network) has failed. + */ + public void rememberFailure(String iconUrl) { + cache.put(iconUrl, SystemClock.elapsedRealtime()); + } + + /** + * Has loading from this URL failed previously and recently? + */ + public boolean isKnownFailure(String iconUrl) { + synchronized (cache) { + final Long failedAt = cache.get(iconUrl); + if (failedAt == null) { + return false; + } + + if (failedAt + FAILURE_RETRY_MILLISECONDS < SystemClock.elapsedRealtime()) { + // The wait time has passed and we can retry loading from this URL. + cache.remove(iconUrl); + return false; + } + } + + return true; + } + + @VisibleForTesting + public void evictAll() { + cache.evictAll(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java new file mode 100644 index 000000000..e0a96f7c7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java @@ -0,0 +1,112 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.icons.storage; + +import android.graphics.Bitmap; +import android.support.annotation.Nullable; +import android.util.Log; +import android.util.LruCache; + +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.IconResponse; + +/** + * Least Recently Used (LRU) memory cache for icons and the mappings from page URLs to icon URLs. + */ +public class MemoryStorage { + /** + * Maximum number of items in the cache for mapping page URLs to icon URLs. + */ + private static final int MAPPING_CACHE_SIZE = 500; + + private static MemoryStorage instance; + + public static synchronized MemoryStorage get() { + if (instance == null) { + instance = new MemoryStorage(); + } + + return instance; + } + + /** + * Class representing an cached icon. We store the original bitmap and the color in cache only. + */ + private static class CacheEntry { + private final Bitmap bitmap; + private final int color; + + private CacheEntry(Bitmap bitmap, int color) { + this.bitmap = bitmap; + this.color = color; + } + } + + private final LruCache<String, CacheEntry> iconCache; // Guarded by 'this' + private final LruCache<String, String> mappingCache; // Guarded by 'this' + + private MemoryStorage() { + iconCache = new LruCache<String, CacheEntry>(calculateCacheSize()) { + @Override + protected int sizeOf(String key, CacheEntry value) { + return value.bitmap.getByteCount() / 1024; + } + }; + + mappingCache = new LruCache<>(MAPPING_CACHE_SIZE); + } + + private int calculateCacheSize() { + // Use a maximum of 1/8 of the available memory for storing cached icons. + int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + return maxMemory / 8; + } + + /** + * Store a mapping from page URL to icon URL in the cache. + */ + public synchronized void putMapping(IconRequest request, String iconUrl) { + mappingCache.put(request.getPageUrl(), iconUrl); + } + + /** + * Get the icon URL for this page URL. Returns null if no mapping is in the cache. + */ + @Nullable + public synchronized String getMapping(String pageUrl) { + return mappingCache.get(pageUrl); + } + + /** + * Store an icon in the cache (uses the icon URL as key). + */ + public synchronized void putIcon(String url, IconResponse response) { + final CacheEntry entry = new CacheEntry(response.getBitmap(), response.getColor()); + + iconCache.put(url, entry); + } + + /** + * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL. + */ + @Nullable + public synchronized IconResponse getIcon(String iconUrl) { + final CacheEntry entry = iconCache.get(iconUrl); + if (entry == null) { + return null; + } + + return IconResponse.createFromMemory(entry.bitmap, iconUrl, entry.color); + } + + /** + * Remove all entries from this cache. + */ + public synchronized void evictAll() { + iconCache.evictAll(); + mappingCache.evictAll(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java new file mode 100644 index 000000000..33a97955f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java @@ -0,0 +1,195 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.javaaddons; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import dalvik.system.DexClassLoader; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoEventListener; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * The manager for addon-provided Java code. + * + * Java code in addons can be loaded using the Dex:Load message, and unloaded + * via the Dex:Unload message. Addon classes loaded are checked for a constructor + * that takes a Map<String, Handler.Callback>. If such a constructor + * exists, it is called and the objects populated into the map by the constructor + * are registered as event listeners. If no such constructor exists, the default + * constructor is invoked instead. + * + * Note: The Map and Handler.Callback classes were used in this API definition + * rather than defining a custom class. This was done explicitly so that the + * addon code can be compiled against the android.jar provided in the Android + * SDK, rather than having to be compiled against Fennec source code. + * + * The Handler.Callback instances provided (as described above) are invoked with + * Message objects when the corresponding events are dispatched. The Bundle + * object attached to the Message will contain the "primitive" values from the + * JSON of the event. ("primitive" includes bool/int/long/double/String). If + * the addon callback wishes to synchronously return a value back to the event + * dispatcher, they can do so by inserting the response string into the bundle + * under the key "response". + */ +public class JavaAddonManager implements GeckoEventListener { + private static final String LOGTAG = "GeckoJavaAddonManager"; + + private static JavaAddonManager sInstance; + + private final EventDispatcher mDispatcher; + private final Map<String, Map<String, GeckoEventListener>> mAddonCallbacks; + + private Context mApplicationContext; + + public static JavaAddonManager getInstance() { + if (sInstance == null) { + sInstance = new JavaAddonManager(); + } + return sInstance; + } + + private JavaAddonManager() { + mDispatcher = EventDispatcher.getInstance(); + mAddonCallbacks = new HashMap<String, Map<String, GeckoEventListener>>(); + } + + public void init(Context applicationContext) { + if (mApplicationContext != null) { + // we've already done this registration. don't do it again + return; + } + mApplicationContext = applicationContext; + mDispatcher.registerGeckoThreadListener(this, + "Dex:Load", + "Dex:Unload"); + JavaAddonManagerV1.getInstance().init(applicationContext); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("Dex:Load")) { + String zipFile = message.getString("zipfile"); + String implClass = message.getString("impl"); + Log.d(LOGTAG, "Attempting to load classes.dex file from " + zipFile + " and instantiate " + implClass); + try { + File tmpDir = mApplicationContext.getDir("dex", 0); + DexClassLoader loader = new DexClassLoader(zipFile, tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader()); + Class<?> c = loader.loadClass(implClass); + try { + Constructor<?> constructor = c.getDeclaredConstructor(Map.class); + Map<String, Handler.Callback> callbacks = new HashMap<String, Handler.Callback>(); + constructor.newInstance(callbacks); + registerCallbacks(zipFile, callbacks); + } catch (NoSuchMethodException nsme) { + Log.d(LOGTAG, "Did not find constructor with parameters Map<String, Handler.Callback>. Falling back to default constructor..."); + // fallback for instances with no constructor that takes a Map<String, Handler.Callback> + c.newInstance(); + } + } catch (Exception e) { + Log.e(LOGTAG, "Unable to load dex successfully", e); + } + } else if (event.equals("Dex:Unload")) { + String zipFile = message.getString("zipfile"); + unregisterCallbacks(zipFile); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Exception handling message [" + event + "]:", e); + } + } + + private void registerCallbacks(String zipFile, Map<String, Handler.Callback> callbacks) { + Map<String, GeckoEventListener> addonCallbacks = mAddonCallbacks.get(zipFile); + if (addonCallbacks != null) { + Log.w(LOGTAG, "Found pre-existing callbacks for zipfile [" + zipFile + "]; aborting re-registration!"); + return; + } + addonCallbacks = new HashMap<String, GeckoEventListener>(); + for (String event : callbacks.keySet()) { + CallbackWrapper wrapper = new CallbackWrapper(callbacks.get(event)); + mDispatcher.registerGeckoThreadListener(wrapper, event); + addonCallbacks.put(event, wrapper); + } + mAddonCallbacks.put(zipFile, addonCallbacks); + } + + private void unregisterCallbacks(String zipFile) { + Map<String, GeckoEventListener> callbacks = mAddonCallbacks.remove(zipFile); + if (callbacks == null) { + Log.w(LOGTAG, "Attempting to unregister callbacks from zipfile [" + zipFile + "] which has no callbacks registered."); + return; + } + for (String event : callbacks.keySet()) { + mDispatcher.unregisterGeckoThreadListener(callbacks.get(event), event); + } + } + + private static class CallbackWrapper implements GeckoEventListener { + private final Handler.Callback mDelegate; + private Bundle mBundle; + + CallbackWrapper(Handler.Callback delegate) { + mDelegate = delegate; + } + + private Bundle jsonToBundle(JSONObject json) { + // XXX right now we only support primitive types; + // we don't recurse down into JSONArray or JSONObject instances + Bundle b = new Bundle(); + for (Iterator<?> keys = json.keys(); keys.hasNext(); ) { + try { + String key = (String)keys.next(); + Object value = json.get(key); + if (value instanceof Integer) { + b.putInt(key, (Integer)value); + } else if (value instanceof String) { + b.putString(key, (String)value); + } else if (value instanceof Boolean) { + b.putBoolean(key, (Boolean)value); + } else if (value instanceof Long) { + b.putLong(key, (Long)value); + } else if (value instanceof Double) { + b.putDouble(key, (Double)value); + } + } catch (JSONException e) { + Log.d(LOGTAG, "Error during JSON->bundle conversion", e); + } + } + return b; + } + + @Override + public void handleMessage(String event, JSONObject json) { + try { + if (mBundle != null) { + Log.w(LOGTAG, "Event [" + event + "] handler is re-entrant; response messages may be lost"); + } + mBundle = jsonToBundle(json); + Message msg = new Message(); + msg.setData(mBundle); + mDelegate.handleMessage(msg); + + JSONObject obj = new JSONObject(); + obj.put("response", mBundle.getString("response")); + EventDispatcher.sendResponse(json, obj); + mBundle = null; + } catch (Exception e) { + Log.e(LOGTAG, "Caught exception thrown from wrapped addon message handler", e); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java new file mode 100644 index 000000000..f361773ca --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java @@ -0,0 +1,260 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.javaaddons; + +import android.content.Context; +import android.util.Log; +import android.util.Pair; +import dalvik.system.DexClassLoader; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.sync.Utils; +import org.mozilla.gecko.util.GeckoJarReader; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.javaaddons.JavaAddonInterfaceV1; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +public class JavaAddonManagerV1 implements NativeEventListener { + private static final String LOGTAG = "GeckoJavaAddonMgrV1"; + public static final String MESSAGE_LOAD = "JavaAddonManagerV1:Load"; + public static final String MESSAGE_UNLOAD = "JavaAddonManagerV1:Unload"; + + private static JavaAddonManagerV1 sInstance; + + // Protected by static synchronized. + private Context mApplicationContext; + + private final org.mozilla.gecko.EventDispatcher mDispatcher; + + // Protected by synchronized (this). + private final Map<String, EventDispatcherImpl> mGUIDToDispatcherMap = new HashMap<>(); + + public static synchronized JavaAddonManagerV1 getInstance() { + if (sInstance == null) { + sInstance = new JavaAddonManagerV1(); + } + return sInstance; + } + + private JavaAddonManagerV1() { + mDispatcher = org.mozilla.gecko.EventDispatcher.getInstance(); + } + + public synchronized void init(Context applicationContext) { + if (mApplicationContext != null) { + // We've already registered; don't register again. + return; + } + mApplicationContext = applicationContext; + mDispatcher.registerGeckoThreadListener(this, + MESSAGE_LOAD, + MESSAGE_UNLOAD); + } + + protected String getExtension(String filename) { + if (filename == null) { + return ""; + } + final int last = filename.lastIndexOf("."); + if (last < 0) { + return ""; + } + return filename.substring(last); + } + + protected synchronized EventDispatcherImpl registerNewInstance(String classname, String filename) + throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, IOException { + Log.d(LOGTAG, "Attempting to instantiate " + classname + "from filename " + filename); + + // It's important to maintain the extension, either .dex, .apk, .jar. + final String extension = getExtension(filename); + final File dexFile = GeckoJarReader.extractStream(mApplicationContext, filename, mApplicationContext.getCacheDir(), "." + extension); + try { + if (dexFile == null) { + throw new IOException("Could not find file " + filename); + } + final File tmpDir = mApplicationContext.getDir("dex", 0); // We'd prefer getCodeCacheDir but it's API 21+. + final DexClassLoader loader = new DexClassLoader(dexFile.getAbsolutePath(), tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader()); + final Class<?> c = loader.loadClass(classname); + final Constructor<?> constructor = c.getDeclaredConstructor(Context.class, JavaAddonInterfaceV1.EventDispatcher.class); + final String guid = Utils.generateGuid(); + final EventDispatcherImpl dispatcher = new EventDispatcherImpl(guid, filename); + final Object instance = constructor.newInstance(mApplicationContext, dispatcher); + mGUIDToDispatcherMap.put(guid, dispatcher); + return dispatcher; + } finally { + // DexClassLoader writes an optimized version, so we can get rid of our temporary extracted version. + if (dexFile != null) { + dexFile.delete(); + } + } + } + + @Override + public synchronized void handleMessage(String event, NativeJSObject message, org.mozilla.gecko.util.EventCallback callback) { + try { + switch (event) { + case MESSAGE_LOAD: { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + final String classname = message.getString("classname"); + final String filename = message.getString("filename"); + final EventDispatcherImpl dispatcher = registerNewInstance(classname, filename); + callback.sendSuccess(dispatcher.guid); + } + break; + case MESSAGE_UNLOAD: { + if (callback == null) { + throw new IllegalArgumentException("callback must not be null"); + } + final String guid = message.getString("guid"); + final EventDispatcherImpl dispatcher = mGUIDToDispatcherMap.remove(guid); + if (dispatcher == null) { + Log.w(LOGTAG, "Attempting to unload addon with unknown associated dispatcher; ignoring."); + callback.sendSuccess(false); + } + dispatcher.unregisterAllEventListeners(); + callback.sendSuccess(true); + } + break; + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message [" + event + "]", e); + if (callback != null) { + callback.sendError("Exception handling message [" + event + "]: " + e.toString()); + } + } + } + + /** + * An event dispatcher is tied to a single Java Addon instance. It serves to prefix all + * messages with its unique GUID. + * <p/> + * Curiously, the dispatcher does not hold a direct reference to its add-on instance. It will + * likely hold indirect instances through its wrapping map, since the instance will probably + * register event listeners that hold a reference to itself. When these listeners are + * unregistered, any link will be broken, allowing the instances to be garbage collected. + */ + private class EventDispatcherImpl implements JavaAddonInterfaceV1.EventDispatcher { + private final String guid; + private final String dexFileName; + + // Protected by synchronized (this). + private final Map<JavaAddonInterfaceV1.EventListener, Pair<NativeEventListener, String[]>> mListenerToWrapperMap = new IdentityHashMap<>(); + + public EventDispatcherImpl(String guid, String dexFileName) { + this.guid = guid; + this.dexFileName = dexFileName; + } + + protected class ListenerWrapper implements NativeEventListener { + private final JavaAddonInterfaceV1.EventListener listener; + + public ListenerWrapper(JavaAddonInterfaceV1.EventListener listener) { + this.listener = listener; + } + + @Override + public void handleMessage(String prefixedEvent, NativeJSObject message, final org.mozilla.gecko.util.EventCallback callback) { + if (!prefixedEvent.startsWith(guid + ":")) { + return; + } + final String event = prefixedEvent.substring(guid.length() + 1); // Skip "guid:". + try { + JavaAddonInterfaceV1.EventCallback callbackAdapter = null; + if (callback != null) { + callbackAdapter = new JavaAddonInterfaceV1.EventCallback() { + @Override + public void sendSuccess(Object response) { + callback.sendSuccess(response); + } + + @Override + public void sendError(Object response) { + callback.sendError(response); + } + }; + } + final JSONObject json = new JSONObject(message.toString()); + listener.handleMessage(mApplicationContext, event, json, callbackAdapter); + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message [" + prefixedEvent + "]", e); + if (callback != null) { + callback.sendError("Got exception handling message [" + prefixedEvent + "]: " + e.toString()); + } + } + } + } + + @Override + public synchronized void registerEventListener(final JavaAddonInterfaceV1.EventListener listener, String... events) { + if (mListenerToWrapperMap.containsKey(listener)) { + Log.e(LOGTAG, "Attempting to register listener which is already registered; ignoring."); + return; + } + + final NativeEventListener listenerWrapper = new ListenerWrapper(listener); + + final String[] prefixedEvents = new String[events.length]; + for (int i = 0; i < events.length; i++) { + prefixedEvents[i] = this.guid + ":" + events[i]; + } + mDispatcher.registerGeckoThreadListener(listenerWrapper, prefixedEvents); + mListenerToWrapperMap.put(listener, new Pair<>(listenerWrapper, prefixedEvents)); + } + + @Override + public synchronized void unregisterEventListener(final JavaAddonInterfaceV1.EventListener listener) { + final Pair<NativeEventListener, String[]> pair = mListenerToWrapperMap.remove(listener); + if (pair == null) { + Log.e(LOGTAG, "Attempting to unregister listener which is not registered; ignoring."); + return; + } + mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second); + } + + + protected synchronized void unregisterAllEventListeners() { + // Unregister everything, then forget everything. + for (Pair<NativeEventListener, String[]> pair : mListenerToWrapperMap.values()) { + mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second); + } + mListenerToWrapperMap.clear(); + } + + @Override + public void sendRequestToGecko(final String event, final JSONObject message, final JavaAddonInterfaceV1.RequestCallback callback) { + final String prefixedEvent = guid + ":" + event; + GeckoAppShell.sendRequestToGecko(new GeckoRequest(prefixedEvent, message) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + if (callback == null) { + // Nothing to do. + return; + } + try { + final JSONObject json = new JSONObject(nativeJSObject.toString()); + callback.onResponse(GeckoAppShell.getContext(), json); + } catch (JSONException e) { + // No way to report failure. + Log.e(LOGTAG, "Exception handling response to request [" + event + "]:", e); + } + } + }); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java new file mode 100644 index 000000000..0f27c1feb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java @@ -0,0 +1,455 @@ +/* -*- 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.lwt; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.WindowUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; + +import android.app.Application; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewParent; + +public class LightweightTheme implements GeckoEventListener { + private static final String LOGTAG = "GeckoLightweightTheme"; + + private static final String PREFS_URL = "lightweightTheme.headerURL"; + private static final String PREFS_COLOR = "lightweightTheme.color"; + + private static final String ASSETS_PREFIX = "resource://android/assets/"; + + private final Application mApplication; + + private Bitmap mBitmap; + private int mColor; + private boolean mIsLight; + + public static interface OnChangeListener { + // The View should change its background/text color. + public void onLightweightThemeChanged(); + + // The View should reset to its default background/text color. + public void onLightweightThemeReset(); + } + + private final List<OnChangeListener> mListeners; + + class LightweightThemeRunnable implements Runnable { + private String mHeaderURL; + private String mColor; + + private String mSavedURL; + private String mSavedColor; + + LightweightThemeRunnable() { + } + + LightweightThemeRunnable(final String headerURL, final String color) { + mHeaderURL = headerURL; + mColor = color; + } + + private void loadFromPrefs() { + SharedPreferences prefs = GeckoSharedPrefs.forProfile(mApplication); + mSavedURL = prefs.getString(PREFS_URL, null); + mSavedColor = prefs.getString(PREFS_COLOR, null); + } + + private void saveToPrefs() { + GeckoSharedPrefs.forProfile(mApplication) + .edit() + .putString(PREFS_URL, mHeaderURL) + .putString(PREFS_COLOR, mColor) + .apply(); + + // Let's keep the saved data in sync. + mSavedURL = mHeaderURL; + mSavedColor = mColor; + } + + @Override + public void run() { + // Load the data from preferences, if it exists. + loadFromPrefs(); + + if (TextUtils.isEmpty(mHeaderURL)) { + // mHeaderURL is null is this is the early startup path. Use + // the saved values, if we have any. + mHeaderURL = mSavedURL; + mColor = mSavedColor; + if (TextUtils.isEmpty(mHeaderURL)) { + // We don't have any saved values, so we probably don't have + // any lightweight theme set yet. + return; + } + } else if (TextUtils.equals(mHeaderURL, mSavedURL)) { + // If we are already using the given header, just return + // without doing any work. + return; + } else { + // mHeaderURL and mColor probably need to be saved if we get here. + saveToPrefs(); + } + + String croppedURL = mHeaderURL; + int mark = croppedURL.indexOf('?'); + if (mark != -1) { + croppedURL = croppedURL.substring(0, mark); + } + + if (croppedURL.startsWith(ASSETS_PREFIX)) { + onBitmapLoaded(loadFromAssets(croppedURL)); + } else { + onBitmapLoaded(BitmapUtils.decodeUrl(croppedURL)); + } + } + + private Bitmap loadFromAssets(String url) { + InputStream stream = null; + + try { + stream = mApplication.getAssets().open(url.substring(ASSETS_PREFIX.length())); + return BitmapFactory.decodeStream(stream); + } catch (IOException e) { + return null; + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { } + } + } + } + + private void onBitmapLoaded(final Bitmap bitmap) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + setLightweightTheme(bitmap, mColor); + } + }); + } + } + + public LightweightTheme(Application application) { + mApplication = application; + mListeners = new ArrayList<OnChangeListener>(); + + // unregister isn't needed as the lifetime is same as the application. + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "LightweightTheme:Update", + "LightweightTheme:Disable"); + + ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable()); + } + + public void addListener(final OnChangeListener listener) { + // Don't inform the listeners that attached late. + // Their onLayout() will take care of them before their onDraw(); + mListeners.add(listener); + } + + public void removeListener(OnChangeListener listener) { + mListeners.remove(listener); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("LightweightTheme:Update")) { + JSONObject lightweightTheme = message.getJSONObject("data"); + final String headerURL = lightweightTheme.getString("headerURL"); + final String color = lightweightTheme.optString("accentcolor"); + + ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable(headerURL, color)); + } else if (event.equals("LightweightTheme:Disable")) { + // Clear the saved data when a theme is disabled. + // Called on the Gecko thread, but should be very lightweight. + clearPrefs(); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + resetLightweightTheme(); + } + }); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + /** + * Clear the data stored in preferences for fast path loading during startup + */ + private void clearPrefs() { + GeckoSharedPrefs.forProfile(mApplication) + .edit() + .remove(PREFS_URL) + .remove(PREFS_COLOR) + .apply(); + } + + /** + * Set a new lightweight theme with the given bitmap. + * Note: This should be called on the UI thread to restrict accessing the + * bitmap to a single thread. + * + * @param bitmap The bitmap used for the lightweight theme. + * @param color The background/accent color used for the lightweight theme. + */ + private void setLightweightTheme(Bitmap bitmap, String color) { + if (bitmap == null || bitmap.getWidth() == 0 || bitmap.getHeight() == 0) { + mBitmap = null; + return; + } + + // Get the max display dimension so we can crop or expand the theme. + final int maxWidth = WindowUtils.getLargestDimension(mApplication); + + // The lightweight theme image's width and height. + final int bitmapWidth = bitmap.getWidth(); + final int bitmapHeight = bitmap.getHeight(); + + try { + mColor = Color.parseColor(color); + } catch (Exception e) { + // Malformed or missing color. + // Default to TRANSPARENT. + mColor = Color.TRANSPARENT; + } + + // Calculate the luminance to determine if it's a light or a dark theme. + double luminance = (0.2125 * ((mColor & 0x00FF0000) >> 16)) + + (0.7154 * ((mColor & 0x0000FF00) >> 8)) + + (0.0721 * (mColor & 0x000000FF)); + mIsLight = luminance > 110; + + // The bitmap image might be smaller than the device's width. + // If it's smaller, fill the extra space on the left with the dominant color. + if (bitmapWidth >= maxWidth) { + mBitmap = Bitmap.createBitmap(bitmap, bitmapWidth - maxWidth, 0, maxWidth, bitmapHeight); + } else { + Paint paint = new Paint(); + paint.setAntiAlias(true); + + // Create a bigger image that can fill the device width. + // By creating a canvas for the bitmap, anything drawn on the canvas + // will be drawn on the bitmap. + mBitmap = Bitmap.createBitmap(maxWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(mBitmap); + + // Fill the canvas with dominant color. + canvas.drawColor(mColor); + + // The image should be top-right aligned. + Rect rect = new Rect(); + Gravity.apply(Gravity.TOP | Gravity.RIGHT, + bitmapWidth, + bitmapHeight, + new Rect(0, 0, maxWidth, bitmapHeight), + rect); + + // Draw the bitmap. + canvas.drawBitmap(bitmap, null, rect, paint); + } + + for (OnChangeListener listener : mListeners) { + listener.onLightweightThemeChanged(); + } + } + + /** + * Reset the lightweight theme. + * Note: This should be called on the UI thread to restrict accessing the + * bitmap to a single thread. + */ + private void resetLightweightTheme() { + ThreadUtils.assertOnUiThread(AssertBehavior.NONE); + if (mBitmap == null) { + return; + } + + // Reset the bitmap. + mBitmap = null; + + for (OnChangeListener listener : mListeners) { + listener.onLightweightThemeReset(); + } + } + + /** + * A lightweight theme is enabled only if there is an active bitmap. + * + * @return True if the theme is enabled. + */ + public boolean isEnabled() { + return (mBitmap != null); + } + + /** + * Based on the luminance of the domanint color, a theme is classified as light or dark. + * + * @return True if the theme is light. + */ + public boolean isLightTheme() { + return mIsLight; + } + + /** + * Crop the image based on the position of the view on the window. + * Either the View or one of its ancestors might have scrolled or translated. + * This value should be taken into account while mapping the View to the Bitmap. + * + * @param view The view requesting a cropped bitmap. + */ + private Bitmap getCroppedBitmap(View view) { + if (mBitmap == null || view == null) { + return null; + } + + // Get the global position of the view on the entire screen. + Rect rect = new Rect(); + view.getGlobalVisibleRect(rect); + + // Get the activity's window position. This does an IPC call, may be expensive. + Rect window = new Rect(); + view.getWindowVisibleDisplayFrame(window); + + // Calculate the coordinates for the cropped bitmap. + int screenWidth = view.getContext().getResources().getDisplayMetrics().widthPixels; + int left = mBitmap.getWidth() - screenWidth + rect.left; + int right = mBitmap.getWidth() - screenWidth + rect.right; + int top = rect.top - window.top; + int bottom = rect.bottom - window.top; + + int offsetX = 0; + int offsetY = 0; + + // Find if this view or any of its ancestors has been translated or scrolled. + ViewParent parent; + View curView = view; + do { + offsetX += (int) curView.getTranslationX() - curView.getScrollX(); + offsetY += (int) curView.getTranslationY() - curView.getScrollY(); + + parent = curView.getParent(); + + if (parent instanceof View) { + curView = (View) parent; + } + + } while (parent instanceof View); + + // Adjust the coordinates for the offset. + left -= offsetX; + right -= offsetX; + top -= offsetY; + bottom -= offsetY; + + // The either the required height may be less than the available image height or more than it. + // If the height required is more, crop only the available portion on the image. + int width = right - left; + int height = (bottom > mBitmap.getHeight() ? mBitmap.getHeight() - top : bottom - top); + + // There is a chance that the view is not visible or doesn't fall within the phone's size. + // In this case, 'rect' will have all values as '0'. Hence 'top' and 'bottom' may be negative, + // and createBitmap() will fail. + // The view will get a background in its next layout pass. + try { + return Bitmap.createBitmap(mBitmap, left, top, width, height); + } catch (Exception e) { + return null; + } + } + + /** + * Converts the cropped bitmap to a BitmapDrawable and returns the same. + * + * @param view The view for which a background drawable is required. + * @return Either the cropped bitmap as a Drawable or null. + */ + public Drawable getDrawable(View view) { + Bitmap bitmap = getCroppedBitmap(view); + if (bitmap == null) { + return null; + } + + BitmapDrawable drawable = new BitmapDrawable(view.getContext().getResources(), bitmap); + drawable.setGravity(Gravity.TOP | Gravity.RIGHT); + drawable.setTileModeXY(Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); + return drawable; + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the dominant color. + * + * @param view The view for which a background drawable is required. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view) { + return getColorDrawable(view, mColor, false); + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color. + * + * @param view The view for which a background drawable is required. + * @param color The color over which the drawable should be drawn. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view, int color) { + return getColorDrawable(view, color, false); + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color. + * + * @param view The view for which a background drawable is required. + * @param color The color over which the drawable should be drawn. + * @param needsDominantColor A layer of dominant color is needed or not. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view, int color, boolean needsDominantColor) { + Bitmap bitmap = getCroppedBitmap(view); + if (bitmap == null) { + return null; + } + + LightweightThemeDrawable drawable = new LightweightThemeDrawable(view.getContext().getResources(), bitmap); + if (needsDominantColor) { + drawable.setColorWithFilter(color, (mColor & 0x22FFFFFF)); + } else { + drawable.setColor(color); + } + + return drawable; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java new file mode 100644 index 000000000..c0ae6eaed --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java @@ -0,0 +1,133 @@ +/* -*- 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.lwt; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.ComposeShader; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +/** + * A special drawable used with lightweight themes. This draws a color + * (with an optional color-filter) and a bitmap (with a linear gradient + * to specify the alpha) in order. + */ +public class LightweightThemeDrawable extends Drawable { + private final Paint mPaint; + private Paint mColorPaint; + + private final Bitmap mBitmap; + private final Resources mResources; + + private int mStartColor; + private int mEndColor; + + public LightweightThemeDrawable(Resources resources, Bitmap bitmap) { + mBitmap = bitmap; + mResources = resources; + + mPaint = new Paint(); + mPaint.setAntiAlias(true); + mPaint.setStrokeWidth(0.0f); + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + initializeBitmapShader(); + } + + @Override + public void draw(Canvas canvas) { + // Draw the colors, if available. + if (mColorPaint != null) { + canvas.drawPaint(mColorPaint); + } + + // Draw the bitmap. + canvas.drawPaint(mPaint); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public void setAlpha(int alpha) { + // A StateListDrawable will reset the alpha value with 255. + // We cannot use to be the bitmap alpha. + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter filter) { + mPaint.setColorFilter(filter); + } + + /** + * Creates a paint that paint a particular color. + * + * Note that the given color should include an alpha value. + * + * @param color The color to be painted. + */ + public void setColor(int color) { + mColorPaint = new Paint(mPaint); + mColorPaint.setColor(color); + } + + /** + * Creates a paint that paint a particular color, and a filter for the color. + * + * Note that the given color should include an alpha value. + * + * @param color The color to be painted. + * @param filter The filter color to be applied using SRC_OVER mode. + */ + public void setColorWithFilter(int color, int filter) { + mColorPaint = new Paint(mPaint); + mColorPaint.setColor(color); + mColorPaint.setColorFilter(new PorterDuffColorFilter(filter, PorterDuff.Mode.SRC_OVER)); + } + + /** + * Set the alpha for the linear gradient used with the bitmap's shader. + * + * @param startAlpha The starting alpha (0..255) value to be applied to the LinearGradient. + * @param startAlpha The ending alpha (0..255) value to be applied to the LinearGradient. + */ + public void setAlpha(int startAlpha, int endAlpha) { + mStartColor = startAlpha << 24; + mEndColor = endAlpha << 24; + initializeBitmapShader(); + } + + private void initializeBitmapShader() { + // A bitmap-shader to draw the bitmap. + // Clamp mode will repeat the last row of pixels. + // Hence its better to have an endAlpha of 0 for the linear-gradient. + BitmapShader bitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); + + // A linear-gradient to specify the opacity of the bitmap. + LinearGradient gradient = new LinearGradient(0, 0, 0, mBitmap.getHeight(), mStartColor, mEndColor, Shader.TileMode.CLAMP); + + // Make a combined shader -- a performance win. + // The linear-gradient is the 'SRC' and the bitmap-shader is the 'DST'. + // Drawing the DST in the SRC will provide the opacity. + mPaint.setShader(new ComposeShader(bitmapShader, gradient, PorterDuff.Mode.DST_IN)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java b/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java new file mode 100644 index 000000000..6f23790b9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java @@ -0,0 +1,535 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.mdns; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.annotation.TargetApi; +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.support.annotation.UiThread; +import android.util.Log; + +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * This class is the bridge between XPCOM mDNS module and NsdManager. + * + * @See nsIDNSServiceDiscovery.idl + */ +public abstract class MulticastDNSManager { + protected static final String LOGTAG = "GeckoMDNSManager"; + private static MulticastDNSManager instance = null; + + public static MulticastDNSManager getInstance(final Context context) { + if (instance == null) { + instance = new DummyMulticastDNSManager(); + } + return instance; + } + + public abstract void init(); + public abstract void tearDown(); +} + +/** + * Mix-in class for MulticastDNSManagers to call EventDispatcher. + */ +class MulticastDNSEventManager { + private NativeEventListener mListener = null; + private boolean mEventsRegistered = false; + + MulticastDNSEventManager(NativeEventListener listener) { + mListener = listener; + } + + @UiThread + public void init() { + ThreadUtils.assertOnUiThread(); + + if (mEventsRegistered || mListener == null) { + return; + } + + registerEvents(); + mEventsRegistered = true; + } + + @UiThread + public void tearDown() { + ThreadUtils.assertOnUiThread(); + + if (!mEventsRegistered || mListener == null) { + return; + } + + unregisterEvents(); + mEventsRegistered = false; + } + + private void registerEvents() { + EventDispatcher.getInstance().registerGeckoThreadListener(mListener, + "NsdManager:DiscoverServices", + "NsdManager:StopServiceDiscovery", + "NsdManager:RegisterService", + "NsdManager:UnregisterService", + "NsdManager:ResolveService"); + } + + private void unregisterEvents() { + EventDispatcher.getInstance().unregisterGeckoThreadListener(mListener, + "NsdManager:DiscoverServices", + "NsdManager:StopServiceDiscovery", + "NsdManager:RegisterService", + "NsdManager:UnregisterService", + "NsdManager:ResolveService"); + } +} + +class NsdMulticastDNSManager extends MulticastDNSManager implements NativeEventListener { + private final NsdManager nsdManager; + private final MulticastDNSEventManager mEventManager; + private Map<String, DiscoveryListener> mDiscoveryListeners = null; + private Map<String, RegistrationListener> mRegistrationListeners = null; + + @TargetApi(16) + public NsdMulticastDNSManager(final Context context) { + nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); + mEventManager = new MulticastDNSEventManager(this); + mDiscoveryListeners = new ConcurrentHashMap<String, DiscoveryListener>(); + mRegistrationListeners = new ConcurrentHashMap<String, RegistrationListener>(); + } + + @Override + public void init() { + mEventManager.init(); + } + + @Override + public void tearDown() { + mDiscoveryListeners.clear(); + mRegistrationListeners.clear(); + + mEventManager.tearDown(); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + Log.v(LOGTAG, "handleMessage: " + event); + + switch (event) { + case "NsdManager:DiscoverServices": { + DiscoveryListener listener = new DiscoveryListener(nsdManager); + listener.discoverServices(message.getString("serviceType"), callback); + mDiscoveryListeners.put(message.getString("uniqueId"), listener); + break; + } + case "NsdManager:StopServiceDiscovery": { + String uuid = message.getString("uniqueId"); + DiscoveryListener listener = mDiscoveryListeners.remove(uuid); + if (listener == null) { + Log.e(LOGTAG, "DiscoveryListener " + uuid + " was not found."); + return; + } + listener.stopServiceDiscovery(callback); + break; + } + case "NsdManager:RegisterService": { + RegistrationListener listener = new RegistrationListener(nsdManager); + listener.registerService(message.getInt("port"), + message.optString("serviceName", android.os.Build.MODEL), + message.getString("serviceType"), + parseAttributes(message.optObjectArray("attributes", null)), + callback); + mRegistrationListeners.put(message.getString("uniqueId"), listener); + break; + } + case "NsdManager:UnregisterService": { + String uuid = message.getString("uniqueId"); + RegistrationListener listener = mRegistrationListeners.remove(uuid); + if (listener == null) { + Log.e(LOGTAG, "RegistrationListener " + uuid + " was not found."); + return; + } + listener.unregisterService(callback); + break; + } + case "NsdManager:ResolveService": { + (new ResolveListener(nsdManager)).resolveService(message.getString("serviceName"), + message.getString("serviceType"), + callback); + break; + } + } + } + + private Map<String, String> parseAttributes(final NativeJSObject[] jsobjs) { + if (jsobjs == null || jsobjs.length == 0 || !Versions.feature21Plus) { + return null; + } + + Map<String, String> attributes = new HashMap<String, String>(jsobjs.length); + for (NativeJSObject obj : jsobjs) { + attributes.put(obj.getString("name"), obj.getString("value")); + } + + return attributes; + } + + @TargetApi(16) + public static JSONObject toJSON(final NsdServiceInfo serviceInfo) throws JSONException { + JSONObject obj = new JSONObject(); + + InetAddress host = serviceInfo.getHost(); + if (host != null) { + obj.put("host", host.getCanonicalHostName()); + obj.put("address", host.getHostAddress()); + } + + int port = serviceInfo.getPort(); + if (port != 0) { + obj.put("port", port); + } + + String serviceName = serviceInfo.getServiceName(); + if (serviceName != null) { + obj.put("serviceName", serviceName); + } + + String serviceType = serviceInfo.getServiceType(); + if (serviceType != null) { + obj.put("serviceType", serviceType); + } + + return obj; + } +} + +class DummyMulticastDNSManager extends MulticastDNSManager implements NativeEventListener { + static final int FAILURE_UNSUPPORTED = -65544; + private final MulticastDNSEventManager mEventManager; + + public DummyMulticastDNSManager() { + mEventManager = new MulticastDNSEventManager(this); + } + + @Override + public void init() { + mEventManager.init(); + } + + @Override + public void tearDown() { + mEventManager.tearDown(); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + Log.v(LOGTAG, "handleMessage: " + event); + callback.sendError(FAILURE_UNSUPPORTED); + } +} + +@TargetApi(16) +class DiscoveryListener implements NsdManager.DiscoveryListener { + private static final String LOGTAG = "GeckoMDNSManager"; + private final NsdManager nsdManager; + + // Callbacks are called from different thread, and every callback can be called only once. + private EventCallback mStartCallback = null; + private EventCallback mStopCallback = null; + + DiscoveryListener(final NsdManager nsdManager) { + this.nsdManager = nsdManager; + } + + public void discoverServices(final String serviceType, final EventCallback callback) { + synchronized (this) { + mStartCallback = callback; + } + nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, this); + } + + public void stopServiceDiscovery(final EventCallback callback) { + synchronized (this) { + mStopCallback = callback; + } + nsdManager.stopServiceDiscovery(this); + } + + @Override + public synchronized void onDiscoveryStarted(final String serviceType) { + Log.d(LOGTAG, "onDiscoveryStarted: " + serviceType); + + EventCallback callback; + synchronized (this) { + callback = mStartCallback; + } + + if (callback == null) { + return; + } + + callback.sendSuccess(serviceType); + } + + @Override + public synchronized void onStartDiscoveryFailed(final String serviceType, final int errorCode) { + Log.e(LOGTAG, "onStartDiscoveryFailed: " + serviceType + "(" + errorCode + ")"); + + EventCallback callback; + synchronized (this) { + callback = mStartCallback; + } + + callback.sendError(errorCode); + } + + @Override + public synchronized void onDiscoveryStopped(final String serviceType) { + Log.d(LOGTAG, "onDiscoveryStopped: " + serviceType); + + EventCallback callback; + synchronized (this) { + callback = mStopCallback; + } + + if (callback == null) { + return; + } + + callback.sendSuccess(serviceType); + } + + @Override + public synchronized void onStopDiscoveryFailed(final String serviceType, final int errorCode) { + Log.e(LOGTAG, "onStopDiscoveryFailed: " + serviceType + "(" + errorCode + ")"); + + EventCallback callback; + synchronized (this) { + callback = mStopCallback; + } + + if (callback == null) { + return; + } + + callback.sendError(errorCode); + } + + @Override + public void onServiceFound(final NsdServiceInfo serviceInfo) { + Log.d(LOGTAG, "onServiceFound: " + serviceInfo.getServiceName()); + JSONObject json; + try { + json = NsdMulticastDNSManager.toJSON(serviceInfo); + } catch (JSONException e) { + throw new RuntimeException(e); + } + GeckoAppShell.sendRequestToGecko(new GeckoRequest("NsdManager:ServiceFound", json) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + // don't care return value. + } + }); + } + + @Override + public void onServiceLost(final NsdServiceInfo serviceInfo) { + Log.d(LOGTAG, "onServiceLost: " + serviceInfo.getServiceName()); + JSONObject json; + try { + json = NsdMulticastDNSManager.toJSON(serviceInfo); + } catch (JSONException e) { + throw new RuntimeException(e); + } + GeckoAppShell.sendRequestToGecko(new GeckoRequest("NsdManager:ServiceLost", json) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + // don't care return value. + } + }); + } +} + +@TargetApi(16) +class RegistrationListener implements NsdManager.RegistrationListener { + private static final String LOGTAG = "GeckoMDNSManager"; + private final NsdManager nsdManager; + + // Callbacks are called from different thread, and every callback can be called only once. + private EventCallback mStartCallback = null; + private EventCallback mStopCallback = null; + + RegistrationListener(final NsdManager nsdManager) { + this.nsdManager = nsdManager; + } + + public void registerService(final int port, final String serviceName, final String serviceType, final Map<String, String> attributes, final EventCallback callback) { + Log.d(LOGTAG, "registerService: " + serviceName + "." + serviceType + ":" + port); + + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + serviceInfo.setPort(port); + serviceInfo.setServiceName(serviceName); + serviceInfo.setServiceType(serviceType); + setAttributes(serviceInfo, attributes); + + synchronized (this) { + mStartCallback = callback; + } + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, this); + } + + @TargetApi(21) + private void setAttributes(final NsdServiceInfo serviceInfo, final Map<String, String> attributes) { + if (attributes == null || !Versions.feature21Plus) { + return; + } + + for (Map.Entry<String, String> entry : attributes.entrySet()) { + serviceInfo.setAttribute(entry.getKey(), entry.getValue()); + } + } + + public void unregisterService(final EventCallback callback) { + Log.d(LOGTAG, "unregisterService"); + synchronized (this) { + mStopCallback = callback; + } + + nsdManager.unregisterService(this); + } + + @Override + public synchronized void onServiceRegistered(final NsdServiceInfo serviceInfo) { + Log.d(LOGTAG, "onServiceRegistered: " + serviceInfo.getServiceName()); + + EventCallback callback; + synchronized (this) { + callback = mStartCallback; + } + + if (callback == null) { + return; + } + + try { + callback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo)); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public synchronized void onRegistrationFailed(final NsdServiceInfo serviceInfo, final int errorCode) { + Log.e(LOGTAG, "onRegistrationFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")"); + + EventCallback callback; + synchronized (this) { + callback = mStartCallback; + } + + callback.sendError(errorCode); + } + + @Override + public synchronized void onServiceUnregistered(final NsdServiceInfo serviceInfo) { + Log.d(LOGTAG, "onServiceUnregistered: " + serviceInfo.getServiceName()); + + EventCallback callback; + synchronized (this) { + callback = mStopCallback; + } + + if (callback == null) { + return; + } + + try { + callback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo)); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public synchronized void onUnregistrationFailed(final NsdServiceInfo serviceInfo, final int errorCode) { + Log.e(LOGTAG, "onUnregistrationFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")"); + + EventCallback callback; + synchronized (this) { + callback = mStopCallback; + } + + if (callback == null) { + return; + } + + callback.sendError(errorCode); + } +} + +@TargetApi(16) +class ResolveListener implements NsdManager.ResolveListener { + private static final String LOGTAG = "GeckoMDNSManager"; + private final NsdManager nsdManager; + + // Callback is called from different thread, and the callback can be called only once. + private EventCallback mCallback = null; + + public ResolveListener(final NsdManager nsdManager) { + this.nsdManager = nsdManager; + } + + public void resolveService(final String serviceName, final String serviceType, final EventCallback callback) { + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + serviceInfo.setServiceName(serviceName); + serviceInfo.setServiceType(serviceType); + + mCallback = callback; + nsdManager.resolveService(serviceInfo, this); + } + + + @Override + public synchronized void onResolveFailed(final NsdServiceInfo serviceInfo, final int errorCode) { + Log.e(LOGTAG, "onResolveFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")"); + + if (mCallback == null) { + return; + } + mCallback.sendError(errorCode); + } + + @Override + public synchronized void onServiceResolved(final NsdServiceInfo serviceInfo) { + Log.d(LOGTAG, "onServiceResolved: " + serviceInfo.getServiceName()); + + if (mCallback == null) { + return; + } + + try { + mCallback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo)); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java new file mode 100644 index 000000000..c9c620606 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java @@ -0,0 +1,34 @@ +/* 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.media; + +import android.media.MediaCodec.BufferInfo; +import android.media.MediaFormat; +import android.os.Handler; +import android.view.Surface; + +import java.nio.ByteBuffer; + +// A wrapper interface that mimics the new {@link android.media.MediaCodec} +// asynchronous mode API in Lollipop. +public interface AsyncCodec { + public interface Callbacks { + void onInputBufferAvailable(AsyncCodec codec, int index); + void onOutputBufferAvailable(AsyncCodec codec, int index, BufferInfo info); + void onError(AsyncCodec codec, int error); + void onOutputFormatChanged(AsyncCodec codec, MediaFormat format); + } + + public abstract void setCallbacks(Callbacks callbacks, Handler handler); + public abstract void configure(MediaFormat format, Surface surface, int flags); + public abstract void start(); + public abstract void stop(); + public abstract void flush(); + public abstract void release(); + public abstract ByteBuffer getInputBuffer(int index); + public abstract ByteBuffer getOutputBuffer(int index); + public abstract void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags); + public abstract void releaseOutputBuffer(int index, boolean render); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java new file mode 100644 index 000000000..fd670e21b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java @@ -0,0 +1,14 @@ +/* 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.media; + +import java.io.IOException; + +public final class AsyncCodecFactory { + public static AsyncCodec create(String name) throws IOException { + // TODO: create (to be implemented) LollipopAsyncCodec when running on Lollipop or later devices. + return new JellyBeanAsyncCodec(name); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java b/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java new file mode 100644 index 000000000..93a63bcb5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java @@ -0,0 +1,135 @@ +package org.mozilla.gecko.media; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.util.Log; + +public class AudioFocusAgent { + private static final String LOGTAG = "AudioFocusAgent"; + + private static Context mContext; + private AudioManager mAudioManager; + private OnAudioFocusChangeListener mAfChangeListener; + + public static final String OWN_FOCUS = "own_focus"; + public static final String LOST_FOCUS = "lost_focus"; + public static final String LOST_FOCUS_TRANSIENT = "lost_focus_transient"; + + private String mAudioFocusState = LOST_FOCUS; + + @WrapForJNI(calledFrom = "gecko") + public static void notifyStartedPlaying() { + if (!isAttachedToContext()) { + return; + } + Log.d(LOGTAG, "NotifyStartedPlaying"); + AudioFocusAgent.getInstance().requestAudioFocusIfNeeded(); + } + + @WrapForJNI(calledFrom = "gecko") + public static void notifyStoppedPlaying() { + if (!isAttachedToContext()) { + return; + } + Log.d(LOGTAG, "NotifyStoppedPlaying"); + AudioFocusAgent.getInstance().abandonAudioFocusIfNeeded(); + } + + public synchronized void attachToContext(Context context) { + if (isAttachedToContext()) { + return; + } + + mContext = context; + mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + + mAfChangeListener = new OnAudioFocusChangeListener() { + public void onAudioFocusChange(int focusChange) { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS"); + notifyObservers("AudioFocusChanged", "lostAudioFocus"); + notifyMediaControlService(MediaControlService.ACTION_PAUSE_BY_AUDIO_FOCUS); + mAudioFocusState = LOST_FOCUS; + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS_TRANSIENT"); + notifyObservers("AudioFocusChanged", "lostAudioFocusTransiently"); + notifyMediaControlService(MediaControlService.ACTION_PAUSE_BY_AUDIO_FOCUS); + mAudioFocusState = LOST_FOCUS_TRANSIENT; + break; + case AudioManager.AUDIOFOCUS_GAIN: + if (!mAudioFocusState.equals(LOST_FOCUS_TRANSIENT)) { + return; + } + Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_GAIN"); + notifyObservers("AudioFocusChanged", "gainAudioFocus"); + notifyMediaControlService(MediaControlService.ACTION_RESUME_BY_AUDIO_FOCUS); + mAudioFocusState = OWN_FOCUS; + break; + default: + } + } + }; + notifyMediaControlService(MediaControlService.ACTION_INIT); + } + + @RobocopTarget + public static AudioFocusAgent getInstance() { + return AudioFocusAgent.SingletonHolder.INSTANCE; + } + + private static class SingletonHolder { + private static final AudioFocusAgent INSTANCE = new AudioFocusAgent(); + } + + private static boolean isAttachedToContext() { + return (mContext != null); + } + + private void notifyObservers(String topic, String data) { + GeckoAppShell.notifyObservers(topic, data); + } + + private AudioFocusAgent() {} + + private void requestAudioFocusIfNeeded() { + if (mAudioFocusState.equals(OWN_FOCUS)) { + return; + } + + int result = mAudioManager.requestAudioFocus(mAfChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + + String focusMsg = (result == AudioManager.AUDIOFOCUS_GAIN) ? + "AudioFocus request granted" : "AudioFoucs request failed"; + Log.d(LOGTAG, focusMsg); + if (result == AudioManager.AUDIOFOCUS_GAIN) { + mAudioFocusState = OWN_FOCUS; + } + } + + private void abandonAudioFocusIfNeeded() { + if (!mAudioFocusState.equals(OWN_FOCUS)) { + return; + } + + Log.d(LOGTAG, "Abandon AudioFocus"); + mAudioManager.abandonAudioFocus(mAfChangeListener); + mAudioFocusState = LOST_FOCUS; + } + + private void notifyMediaControlService(String action) { + Intent intent = new Intent(mContext, MediaControlService.class); + intent.setAction(action); + mContext.startService(intent); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/Codec.java b/mobile/android/base/java/org/mozilla/gecko/media/Codec.java new file mode 100644 index 000000000..b0a26dfb3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/Codec.java @@ -0,0 +1,366 @@ +/* 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.media; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.TransactionTooLargeException; +import android.util.Log; +import android.view.Surface; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.LinkedList; +import java.util.NoSuchElementException; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +/* package */ final class Codec extends ICodec.Stub implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteCodec"; + private static final boolean DEBUG = false; + + public enum Error { + DECODE, FATAL + }; + + private final class Callbacks implements AsyncCodec.Callbacks { + private ICodecCallbacks mRemote; + private boolean mHasInputCapacitySet; + private boolean mHasOutputCapacitySet; + + public Callbacks(ICodecCallbacks remote) { + mRemote = remote; + } + + @Override + public void onInputBufferAvailable(AsyncCodec codec, int index) { + if (mFlushing) { + // Flush invalidates all buffers. + return; + } + if (!mHasInputCapacitySet) { + int capacity = codec.getInputBuffer(index).capacity(); + if (capacity > 0) { + mSamplePool.setInputBufferSize(capacity); + mHasInputCapacitySet = true; + } + } + if (!mInputProcessor.onBuffer(index)) { + reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full")); + } + } + + @Override + public void onOutputBufferAvailable(AsyncCodec codec, int index, MediaCodec.BufferInfo info) { + if (mFlushing) { + // Flush invalidates all buffers. + return; + } + ByteBuffer output = codec.getOutputBuffer(index); + if (!mHasOutputCapacitySet) { + int capacity = output.capacity(); + if (capacity > 0) { + mSamplePool.setOutputBufferSize(capacity); + mHasOutputCapacitySet = true; + } + } + Sample copy = mSamplePool.obtainOutput(info); + try { + if (info.size > 0) { + copy.buffer.readFromByteBuffer(output, info.offset, info.size); + } + mSentOutputs.add(copy); + mRemote.onOutput(copy); + } catch (IOException e) { + Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage()); + outputDummy(info); + } catch (TransactionTooLargeException ttle) { + Log.e(LOGTAG, "Output is too large:" + ttle.getMessage()); + outputDummy(info); + } catch (RemoteException e) { + // Dead recipient. + e.printStackTrace(); + } + + mCodec.releaseOutputBuffer(index, true); + boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + if (DEBUG && eos) { + Log.d(LOGTAG, "output EOS"); + } + } + + private void outputDummy(MediaCodec.BufferInfo info) { + try { + if (DEBUG) Log.d(LOGTAG, "return dummy sample"); + mRemote.onOutput(Sample.create(null, info, null)); + } catch (RemoteException e) { + // Dead recipient. + e.printStackTrace(); + } + } + + @Override + public void onError(AsyncCodec codec, int error) { + reportError(Error.FATAL, new Exception("codec error:" + error)); + } + + @Override + public void onOutputFormatChanged(AsyncCodec codec, MediaFormat format) { + try { + mRemote.onOutputFormatChanged(new FormatParam(format)); + } catch (RemoteException re) { + // Dead recipient. + re.printStackTrace(); + } + } + } + + private final class InputProcessor { + private Queue<Sample> mInputSamples = new LinkedList<>(); + private Queue<Integer> mAvailableInputBuffers = new LinkedList<>(); + private Queue<Sample> mDequeuedSamples = new LinkedList<>(); + + private synchronized Sample onAllocate(int size) { + Sample sample = mSamplePool.obtainInput(size); + mDequeuedSamples.add(sample); + return sample; + } + + private synchronized boolean onSample(Sample sample) { + if (sample == null) { + return false; + } + + if (!sample.isEOS()) { + Sample temp = sample; + sample = mDequeuedSamples.remove(); + sample.info = temp.info; + sample.cryptoInfo = temp.cryptoInfo; + temp.dispose(); + } + + if (!mInputSamples.offer(sample)) { + return false; + } + feedSampleToBuffer(); + return true; + } + + private synchronized boolean onBuffer(int index) { + if (!mAvailableInputBuffers.offer(index)) { + return false; + } + feedSampleToBuffer(); + return true; + } + + private void feedSampleToBuffer() { + while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) { + int index = mAvailableInputBuffers.poll(); + int len = 0; + Sample sample = mInputSamples.poll(); + long pts = sample.info.presentationTimeUs; + int flags = sample.info.flags; + if (!sample.isEOS() && sample.buffer != null) { + len = sample.info.size; + ByteBuffer buf = mCodec.getInputBuffer(index); + try { + sample.writeToByteBuffer(buf); + mCallbacks.onInputExhausted(); + } catch (IOException e) { + e.printStackTrace(); + } catch (RemoteException e) { + e.printStackTrace(); + } + mSamplePool.recycleInput(sample); + } + mCodec.queueInputBuffer(index, 0, len, pts, flags); + } + } + + private synchronized void reset() { + mInputSamples.clear(); + mAvailableInputBuffers.clear(); + } + } + + private volatile ICodecCallbacks mCallbacks; + private AsyncCodec mCodec; + private InputProcessor mInputProcessor; + private volatile boolean mFlushing = false; + private SamplePool mSamplePool; + private Queue<Sample> mSentOutputs = new ConcurrentLinkedQueue<>(); + + public synchronized void setCallbacks(ICodecCallbacks callbacks) throws RemoteException { + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Callbacks is dead"); + try { + release(); + } catch (RemoteException e) { + // Nowhere to report the error. + } + } + + @Override + public synchronized boolean configure(FormatParam format, Surface surface, int flags) throws RemoteException { + if (mCallbacks == null) { + Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()"); + return false; + } + + if (mCodec != null) { + if (DEBUG) Log.d(LOGTAG, "release existing codec: " + mCodec); + releaseCodec(); + } + + if (DEBUG) Log.d(LOGTAG, "configure " + this); + + MediaFormat fmt = format.asFormat(); + String codecName = getDecoderForFormat(fmt); + if (codecName == null) { + Log.e(LOGTAG, "FAIL: cannot find codec"); + return false; + } + + try { + AsyncCodec codec = AsyncCodecFactory.create(codecName); + codec.setCallbacks(new Callbacks(mCallbacks), null); + codec.configure(fmt, surface, flags); + mCodec = codec; + mInputProcessor = new InputProcessor(); + mSamplePool = new SamplePool(codecName); + if (DEBUG) Log.d(LOGTAG, codec.toString() + " created"); + return true; + } catch (Exception e) { + if (DEBUG) Log.d(LOGTAG, "FAIL: cannot create codec -- " + codecName); + e.printStackTrace(); + return false; + } + } + + private void releaseCodec() { + mInputProcessor.reset(); + try { + mCodec.release(); + } catch (Exception e) { + reportError(Error.FATAL, e); + } + mCodec = null; + } + + private String getDecoderForFormat(MediaFormat format) { + String mime = format.getString(MediaFormat.KEY_MIME); + if (mime == null) { + return null; + } + int numCodecs = MediaCodecList.getCodecCount(); + for (int i = 0; i < numCodecs; i++) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder()) { + continue; + } + String[] types = info.getSupportedTypes(); + for (String t : types) { + if (t.equalsIgnoreCase(mime)) { + return info.getName(); + } + } + } + return null; + // TODO: API 21+ is simpler. + //static MediaCodecList sCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS); + //return sCodecList.findDecoderForFormat(format); + } + + @Override + public synchronized void start() throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "start " + this); + mFlushing = false; + try { + mCodec.start(); + } catch (Exception e) { + reportError(Error.FATAL, e); + } + } + + private void reportError(Error error, Exception e) { + if (e != null) { + e.printStackTrace(); + } + try { + mCallbacks.onError(error == Error.FATAL); + } catch (RemoteException re) { + re.printStackTrace(); + } + } + + @Override + public synchronized void stop() throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "stop " + this); + try { + mCodec.stop(); + } catch (Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void flush() throws RemoteException { + mFlushing = true; + if (DEBUG) Log.d(LOGTAG, "flush " + this); + mInputProcessor.reset(); + try { + mCodec.flush(); + } catch (Exception e) { + reportError(Error.FATAL, e); + } + + mFlushing = false; + if (DEBUG) Log.d(LOGTAG, "flushed " + this); + } + + @Override + public synchronized Sample dequeueInput(int size) { + return mInputProcessor.onAllocate(size); + } + + @Override + public synchronized void queueInput(Sample sample) throws RemoteException { + if (!mInputProcessor.onSample(sample)) { + reportError(Error.FATAL, new Exception("FAIL: input sample queue is full")); + } + } + + @Override + public synchronized void releaseOutput(Sample sample) { + try { + mSamplePool.recycleOutput(mSentOutputs.remove()); + } catch (Exception e) { + Log.e(LOGTAG, "failed to release output:" + sample); + e.printStackTrace(); + } + sample.dispose(); + } + + @Override + public synchronized void release() throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "release " + this); + releaseCodec(); + mSamplePool.reset(); + mSamplePool = null; + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java new file mode 100644 index 000000000..3025c14d0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java @@ -0,0 +1,191 @@ +/* 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.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaFormat; +import android.os.DeadObjectException; +import android.os.RemoteException; +import android.util.Log; +import android.view.Surface; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +import java.io.IOException; +import java.nio.ByteBuffer; + +// Proxy class of ICodec binder. +public final class CodecProxy { + private static final String LOGTAG = "GeckoRemoteCodecProxy"; + private static final boolean DEBUG = false; + + private ICodec mRemote; + private FormatParam mFormat; + private Surface mOutputSurface; + private CallbacksForwarder mCallbacks; + + public interface Callbacks { + void onInputExhausted(); + void onOutputFormatChanged(MediaFormat format); + void onOutput(Sample output); + void onError(boolean fatal); + } + + @WrapForJNI + public static class NativeCallbacks extends JNIObject implements Callbacks { + public native void onInputExhausted(); + public native void onOutputFormatChanged(MediaFormat format); + public native void onOutput(Sample output); + public native void onError(boolean fatal); + + @Override // JNIObject + protected native void disposeNative(); + } + + private class CallbacksForwarder extends ICodecCallbacks.Stub { + private final Callbacks mCallbacks; + + CallbacksForwarder(Callbacks callbacks) { + mCallbacks = callbacks; + } + + @Override + public void onInputExhausted() throws RemoteException { + mCallbacks.onInputExhausted(); + } + + @Override + public void onOutputFormatChanged(FormatParam format) throws RemoteException { + mCallbacks.onOutputFormatChanged(format.asFormat()); + } + + @Override + public void onOutput(Sample sample) throws RemoteException { + mCallbacks.onOutput(sample); + mRemote.releaseOutput(sample); + sample.dispose(); + } + + @Override + public void onError(boolean fatal) throws RemoteException { + reportError(fatal); + } + + public void reportError(boolean fatal) { + mCallbacks.onError(fatal); + } + } + + @WrapForJNI + public static CodecProxy create(MediaFormat format, Surface surface, Callbacks callbacks) { + return RemoteManager.getInstance().createCodec(format, surface, callbacks); + } + + public static CodecProxy createCodecProxy(MediaFormat format, Surface surface, Callbacks callbacks) { + return new CodecProxy(format, surface, callbacks); + } + + private CodecProxy(MediaFormat format, Surface surface, Callbacks callbacks) { + mFormat = new FormatParam(format); + mOutputSurface = surface; + mCallbacks = new CallbacksForwarder(callbacks); + } + + boolean init(ICodec remote) { + try { + remote.setCallbacks(mCallbacks); + remote.configure(mFormat, mOutputSurface, 0); + remote.start(); + } catch (RemoteException e) { + e.printStackTrace(); + return false; + } + + mRemote = remote; + return true; + } + + boolean deinit() { + try { + mRemote.stop(); + mRemote.release(); + mRemote = null; + return true; + } catch (RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean input(ByteBuffer bytes, BufferInfo info, CryptoInfo cryptoInfo) { + if (mRemote == null) { + Log.e(LOGTAG, "cannot send input to an ended codec"); + return false; + } + + try { + Sample sample = (info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) ? + Sample.EOS : mRemote.dequeueInput(info.size).set(bytes, info, cryptoInfo); + mRemote.queueInput(sample); + sample.dispose(); + } catch (IOException e) { + e.printStackTrace(); + return false; + } catch (DeadObjectException e) { + return false; + } catch (RemoteException e) { + e.printStackTrace(); + Log.e(LOGTAG, "fail to input sample: size=" + info.size + + ", pts=" + info.presentationTimeUs + + ", flags=" + Integer.toHexString(info.flags)); + return false; + } + return true; + } + + @WrapForJNI + public synchronized boolean flush() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot flush an ended codec"); + return false; + } + try { + if (DEBUG) Log.d(LOGTAG, "flush " + this); + mRemote.flush(); + } catch (DeadObjectException e) { + return false; + } catch (RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + + @WrapForJNI + public synchronized boolean release() { + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + return true; + } + if (DEBUG) Log.d(LOGTAG, "release " + this); + try { + RemoteManager.getInstance().releaseCodec(this); + } catch (DeadObjectException e) { + return false; + } catch (RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + + public synchronized void reportError(boolean fatal) { + mCallbacks.reportError(fatal); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java new file mode 100644 index 000000000..c6762672d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java @@ -0,0 +1,133 @@ +/* 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.media; + +import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import java.nio.ByteBuffer; + +/** A wrapper to make {@link MediaFormat} parcelable. + * Supports following keys: + * <ul> + * <li>{@link MediaFormat#KEY_MIME}</li> + * <li>{@link MediaFormat#KEY_WIDTH}</li> + * <li>{@link MediaFormat#KEY_HEIGHT}</li> + * <li>{@link MediaFormat#KEY_CHANNEL_COUNT}</li> + * <li>{@link MediaFormat#KEY_SAMPLE_RATE}</li> + * <li>"csd-0"</li> + * <li>"csd-1"</li> + * </ul> + */ +public final class FormatParam implements Parcelable { + // Keys for codec specific config bits not exposed in {@link MediaFormat}. + private static final String KEY_CONFIG_0 = "csd-0"; + private static final String KEY_CONFIG_1 = "csd-1"; + + private MediaFormat mFormat; + + public MediaFormat asFormat() { + return mFormat; + } + + public FormatParam(MediaFormat format) { + mFormat = format; + } + + protected FormatParam(Parcel in) { + mFormat = new MediaFormat(); + readFromParcel(in); + } + + public static final Creator<FormatParam> CREATOR = new Creator<FormatParam>() { + @Override + public FormatParam createFromParcel(Parcel in) { + return new FormatParam(in); + } + + @Override + public FormatParam[] newArray(int size) { + return new FormatParam[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + public void readFromParcel(Parcel in) { + Bundle bundle = in.readBundle(); + fromBundle(bundle); + } + + private void fromBundle(Bundle bundle) { + if (bundle.containsKey(MediaFormat.KEY_MIME)) { + mFormat.setString(MediaFormat.KEY_MIME, + bundle.getString(MediaFormat.KEY_MIME)); + } + if (bundle.containsKey(MediaFormat.KEY_WIDTH)) { + mFormat.setInteger(MediaFormat.KEY_WIDTH, + bundle.getInt(MediaFormat.KEY_WIDTH)); + } + if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) { + mFormat.setInteger(MediaFormat.KEY_HEIGHT, + bundle.getInt(MediaFormat.KEY_HEIGHT)); + } + if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + mFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, + bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, + bundle.getInt(MediaFormat.KEY_SAMPLE_RATE)); + } + if (bundle.containsKey(KEY_CONFIG_0)) { + mFormat.setByteBuffer(KEY_CONFIG_0, + ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0))); + } + if (bundle.containsKey(KEY_CONFIG_1)) { + mFormat.setByteBuffer(KEY_CONFIG_1, + ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1)))); + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeBundle(toBundle()); + } + + private Bundle toBundle() { + Bundle bundle = new Bundle(); + if (mFormat.containsKey(MediaFormat.KEY_MIME)) { + bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME)); + } + if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) { + bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH)); + } + if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) { + bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT)); + } + if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + bundle.putInt(MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)); + } + if (mFormat.containsKey(KEY_CONFIG_0)) { + ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0); + bundle.putByteArray(KEY_CONFIG_0, + Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + if (mFormat.containsKey(KEY_CONFIG_1)) { + ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1); + bundle.putByteArray(KEY_CONFIG_1, + Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + return bundle; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java new file mode 100644 index 000000000..7b3bda3fd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java @@ -0,0 +1,35 @@ +/* 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.media; + +import android.media.MediaCrypto; + +public interface GeckoMediaDrm { + public interface Callbacks { + void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request); + void onSessionUpdated(int promiseId, byte[] sessionId); + void onSessionClosed(int promiseId, byte[] sessionId); + void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request); + void onSessionError(byte[] sessionId, String message); + void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos); + // All failure cases should go through this function. + void onRejectPromise(int promiseId, String message); + } + void setCallbacks(Callbacks callbacks); + void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData); + void updateSession(int promiseId, String sessionId, byte[] response); + void closeSession(int promiseId, String sessionId); + void release(); + MediaCrypto getMediaCrypto(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java new file mode 100644 index 000000000..6ccaf80df --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java @@ -0,0 +1,627 @@ +/* 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.media; + +import java.lang.*; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.HashSet; +import java.util.UUID; +import java.util.ArrayDeque; + +import android.annotation.SuppressLint; +import android.os.AsyncTask; +import android.os.Handler; +import android.os.HandlerThread; +import android.media.MediaCrypto; +import android.media.MediaCryptoException; +import android.media.MediaDrm; +import android.media.MediaDrmException; +import android.util.Log; + +public class GeckoMediaDrmBridgeV21 implements GeckoMediaDrm { + private static final String LOGTAG = "GeckoMediaDrmBridgeV21"; + private static final String INVALID_SESSION_ID = "Invalid"; + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + // MediaDrm.KeyStatus information listener is supported on M+, adding a + // dummy key id to report key status. + private static final byte[] DUMMY_KEY_ID = new byte[] {0}; + + private UUID mSchemeUUID; + private Handler mHandler; + private HandlerThread mHandlerThread; + private ByteBuffer mCryptoSessionId; + + // mProvisioningPromiseId is great than 0 only during provisioning. + private int mProvisioningPromiseId; + private HashSet<ByteBuffer> mSessionIds; + private HashMap<ByteBuffer, String> mSessionMIMETypes; + private ArrayDeque<PendingCreateSessionData> mPendingCreateSessionDataQueue; + private GeckoMediaDrm.Callbacks mCallbacks; + + private MediaCrypto mCrypto; + protected MediaDrm mDrm; + + public static int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/ + public static int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/ + public static int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/ + + // Store session data while provisioning + private static class PendingCreateSessionData { + public final int mToken; + public final int mPromiseId; + public final byte[] mInitData; + public final String mMimeType; + + private PendingCreateSessionData(int token, int promiseId, + byte[] initData, String mimeType) { + mToken = token; + mPromiseId = promiseId; + mInitData = initData; + mMimeType = mimeType; + } + } + + public boolean isSecureDecoderComonentRequired(String mimeType) { + if (mCrypto != null) { + return mCrypto.requiresSecureDecoderComponent(mimeType); + } + return false; + } + + private static void assertTrue(boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + @SuppressLint("WrongConstant") + private void configureVendorSpecificProperty() { + assertTrue(mDrm != null); + // Support L3 for now + mDrm.setPropertyString("securityLevel", "L3"); + // Refer to chromium, set multi-session mode for Widevine. + if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) { + mDrm.setPropertyString("sessionSharing", "enable"); + } + } + + GeckoMediaDrmBridgeV21(String keySystem) throws Exception { + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21()"); + + mProvisioningPromiseId = 0; + mSessionIds = new HashSet<ByteBuffer>(); + mSessionMIMETypes = new HashMap<ByteBuffer, String>(); + mPendingCreateSessionDataQueue = new ArrayDeque<PendingCreateSessionData>(); + + mSchemeUUID = convertKeySystemToSchemeUUID(keySystem); + mCryptoSessionId = null; + + if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString()); + + // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions + // threw by the following steps. + mDrm = new MediaDrm(mSchemeUUID); + configureVendorSpecificProperty(); + mDrm.setOnEventListener(new MediaDrmListener()); + } + + @Override + public void setCallbacks(GeckoMediaDrm.Callbacks callbacks) { + assertTrue(callbacks != null); + mCallbacks = callbacks; + } + + @Override + public void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + if (mProvisioningPromiseId > 0 && mCrypto == null) { + if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !"); + savePendingCreateSessionData(createSessionToken, promiseId, + initData, initDataType); + return; + } + + ByteBuffer sessionId = null; + String strSessionId = null; + try { + boolean hasMediaCrypto = ensureMediaCryptoCreated(); + if (!hasMediaCrypto) { + onRejectPromise(promiseId, "MediaCrypto intance is not created !"); + return; + } + + sessionId = openSession(); + if (sessionId == null) { + onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !"); + return; + } + + MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType); + if (request == null) { + mDrm.closeSession(sessionId.array()); + onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !"); + return; + } + onSessionCreated(createSessionToken, + promiseId, + sessionId.array(), + request.getData()); + onSessionMessage(sessionId.array(), + LICENSE_REQUEST_INITIAL, + request.getData()); + mSessionMIMETypes.put(sessionId, initDataType); + strSessionId = new String(sessionId.array()); + mSessionIds.add(sessionId); + if (DEBUG) Log.d(LOGTAG, " StringID : " + strSessionId + " is put into mSessionIds "); + } catch (android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage()); + if (sessionId != null) { + // The promise of this createSession will be either resolved + // or rejected after provisioning. + mDrm.closeSession(sessionId.array()); + } + savePendingCreateSessionData(createSessionToken, promiseId, + initData, initDataType); + startProvisioning(promiseId); + } + } + + @Override + public void updateSession(int promiseId, + String sessionId, + byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes()); + if (!sessionExists(session)) { + onRejectPromise(promiseId, "Invalid session during updateSession."); + return; + } + + try { + final byte [] keySetId = mDrm.provideKeyResponse(session.array(), response); + if (DEBUG) { + HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array()); + for (String strKey : infoMap.keySet()) { + String strValue = infoMap.get(strKey); + Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")"); + } + } + SessionKeyInfo[] keyInfos = new SessionKeyInfo[1]; + keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID, + MediaDrm.KeyStatus.STATUS_USABLE); + onSessionBatchedKeyChanged(session.array(), keyInfos); + if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId); + onSessionUpdated(promiseId, session.array()); + return; + } catch (android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:" + e.getMessage()); + onSessionError(session.array(), "Got NotProvisionedException."); + onRejectPromise(promiseId, "Not provisioned during updateSession."); + } catch (android.media.DeniedByServerException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:" + e.getMessage()); + onSessionError(session.array(), "Got DeniedByServerException."); + onRejectPromise(promiseId, "Denied by server during updateSession."); + } catch (java.lang.IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Exception when calling provideKeyResponse():" + e.getMessage()); + onSessionError(session.array(), "Got IllegalStateException."); + onRejectPromise(promiseId, "Rejected during updateSession."); + } + release(); + return; + } + + @Override + public void closeSession(int promiseId, String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes()); + mSessionIds.remove(session); + mDrm.closeSession(session.array()); + onSessionClosed(promiseId, session.array()); + } + + @Override + public void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + if (mProvisioningPromiseId > 0) { + onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session."); + mProvisioningPromiseId = 0; + } + while (!mPendingCreateSessionDataQueue.isEmpty()) { + PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions."); + } + mPendingCreateSessionDataQueue = null; + + if (mDrm != null) { + for (ByteBuffer session : mSessionIds) { + mDrm.closeSession(session.array()); + } + mDrm.release(); + mDrm = null; + } + mSessionIds.clear(); + mSessionIds = null; + mSessionMIMETypes.clear(); + mSessionMIMETypes = null; + + mCryptoSessionId = null; + if (mCrypto != null) { + mCrypto.release(); + mCrypto = null; + } + if (mHandlerThread != null) { + mHandlerThread.quitSafely(); + mHandlerThread = null; + } + mHandler = null; + } + + @Override + public MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + return mCrypto; + } + + protected void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + + protected void onSessionUpdated(int promiseId, byte[] sessionId) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionUpdated(promiseId, sessionId); + } + + protected void onSessionClosed(int promiseId, byte[] sessionId) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionClosed(promiseId, sessionId); + } + + protected void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + + protected void onSessionError(byte[] sessionId, String message) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionError(sessionId, message); + } + + protected void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos) { + assertTrue(mCallbacks != null); + mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + + protected void onRejectPromise(int promiseId, String message) { + assertTrue(mCallbacks != null); + mCallbacks.onRejectPromise(promiseId, message); + } + + private MediaDrm.KeyRequest getKeyRequest(ByteBuffer aSession, + byte[] data, + String mimeType) + throws android.media.NotProvisionedException { + if (mProvisioningPromiseId > 0) { + // Now provisioning. + return null; + } + + try { + HashMap<String, String> optionalParameters = new HashMap<String, String>(); + return mDrm.getKeyRequest(aSession.array(), + data, + mimeType, + MediaDrm.KEY_TYPE_STREAMING, + optionalParameters); + } catch (Exception e) { + Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e); + } + return null; + } + + private class MediaDrmListener implements MediaDrm.OnEventListener { + @Override + public void onEvent(MediaDrm mediaDrm, byte[] sessionArray, int event, + int extra, byte[] data) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()"); + if (sessionArray == null) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session."); + return; + } + ByteBuffer session = ByteBuffer.wrap(sessionArray); + if (!sessionExists(session)) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session."); + return; + } + // On L, these events are treated as exceptions and handled correspondingly. + // Leaving this code block for logging message. + String sessionId = new String(session.array()); + switch (event) { + case MediaDrm.EVENT_PROVISION_REQUIRED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED"); + break; + case MediaDrm.EVENT_KEY_REQUIRED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED"); + // No need to handle here if we're not in privacy mode. + break; + case MediaDrm.EVENT_KEY_EXPIRED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + sessionId); + break; + case MediaDrm.EVENT_VENDOR_DEFINED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + sessionId); + break; + default: + if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event); + return; + } + } + } + + private ByteBuffer openSession() throws android.media.NotProvisionedException { + try { + byte[] sessionId = mDrm.openSession(); + // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in + // case the underlying byte[] is modified. + return ByteBuffer.wrap(sessionId.clone()); + } catch (android.media.NotProvisionedException e) { + // Throw NotProvisionedException so that we can startProvisioning(). + throw e; + } catch (java.lang.RuntimeException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage()); + release(); + return null; + } catch (android.media.MediaDrmException e) { + // Other MediaDrmExceptions (e.g. ResourceBusyException) are not + // recoverable. + release(); + return null; + } + } + + private boolean sessionExists(ByteBuffer session) { + if (mCryptoSessionId == null) { + if (DEBUG) Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created."); + return false; + } + if (session == null) { + if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !"); + return false; + } + return !session.equals(mCryptoSessionId) && mSessionIds.contains(session); + } + + private class PostRequestTask extends AsyncTask<Void, Void, Void> { + private static final String LOGTAG = "PostRequestTask"; + + private int mPromiseId; + private String mURL; + private byte[] mDrmRequest; + private byte[] mResponseBody; + + PostRequestTask(int promiseId, String url, byte[] drmRequest) { + this.mPromiseId = promiseId; + this.mURL = url; + this.mDrmRequest = drmRequest; + } + + @Override + protected Void doInBackground(Void... params) { + try { + URL finalURL = new URL(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8")); + HttpURLConnection urlConnection = (HttpURLConnection) finalURL.openConnection(); + urlConnection.setRequestMethod("POST"); + if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURL.toString()); + + // Add data + urlConnection.setRequestProperty("Accept", "*/*"); + urlConnection.setRequestProperty("User-Agent", getCDMUserAgent()); + urlConnection.setRequestProperty("Content-Type", "application/json"); + + // Execute HTTP Post Request + urlConnection.connect(); + + int responseCode = urlConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader in = + new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); + String inputLine; + StringBuffer response = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + mResponseBody = String.valueOf(response).getBytes(); + if (DEBUG) Log.d(LOGTAG, "Provisioning, response received."); + if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length); + } else { + Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode); + } + } catch (IOException e) { + Log.e(LOGTAG, "Got exception during posting provisioning request ...", e); + } + return null; + } + + @Override + protected void onPostExecute(Void v) { + onProvisionResponse(mPromiseId, mResponseBody); + } + } + + private boolean provideProvisionResponse(byte[] response) { + if (response == null || response.length == 0) { + if (DEBUG) Log.d(LOGTAG, "Invalid provision response."); + return false; + } + + try { + mDrm.provideProvisionResponse(response); + return true; + } catch (android.media.DeniedByServerException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } catch (java.lang.IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } + return false; + } + + private void savePendingCreateSessionData(int token, + int promiseId, + byte[] initData, + String mime) { + if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId); + mPendingCreateSessionDataQueue.offer(new PendingCreateSessionData(token, promiseId, initData, mime)); + } + + private void processPendingCreateSessionData() { + if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... "); + + assertTrue(mProvisioningPromiseId == 0); + try { + while (!mPendingCreateSessionDataQueue.isEmpty()) { + PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId); + + createSession(pendingData.mToken, + pendingData.mPromiseId, + pendingData.mMimeType, + pendingData.mInitData); + } + } catch (Exception e) { + Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e); + } + } + + private void resumePendingOperations() { + if (mHandlerThread == null) { + mHandlerThread = new HandlerThread("PendingSessionOpsThread"); + mHandlerThread.start(); + } + if (mHandler == null) { + mHandler = new Handler(mHandlerThread.getLooper()); + } + mHandler.post(new Runnable() { + @Override + public void run() { + processPendingCreateSessionData(); + } + }); + } + + // Only triggered when failed on {openSession, getKeyRequest} + private void startProvisioning(int promiseId) { + if (DEBUG) Log.d(LOGTAG, "startProvisioning()"); + if (mProvisioningPromiseId > 0) { + // Already in provisioning. + return; + } + try { + mProvisioningPromiseId = promiseId; + MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest(); + PostRequestTask postTask = + new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData()); + postTask.execute(); + } catch (Exception e) { + onRejectPromise(promiseId, "Exception happened in startProvisioning !"); + mProvisioningPromiseId = 0; + } + } + + private void onProvisionResponse(int promiseId, byte[] response) { + if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()"); + + mProvisioningPromiseId = 0; + boolean success = provideProvisionResponse(response); + if (success) { + // Promise will either be resovled / rejected in createSession during + // resuming operations. + resumePendingOperations(); + } else { + onRejectPromise(promiseId, "Failed to provide provision response."); + } + } + + private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException { + if (mCrypto != null) { + return true; + } + try { + mCryptoSessionId = openSession(); + if (mCryptoSessionId == null) { + if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto"); + return false; + } + + if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) { + final byte [] cryptoSessionId = mCryptoSessionId.array(); + mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId); + String strCryptoSessionId = new String(cryptoSessionId); + mSessionIds.add(mCryptoSessionId); + if (DEBUG) Log.d(LOGTAG, "MediaCrypto successfully created! - SId " + INVALID_SESSION_ID + ", " + strCryptoSessionId); + return true; + } else { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme."); + return false; + } + } catch (android.media.MediaCryptoException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage()); + release(); + return false; + } catch (android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage()); + throw e; + } + } + + private UUID convertKeySystemToSchemeUUID(String keySystem) { + if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) { + return WIDEVINE_SCHEME_UUID; + } + if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem); + return null; + } + + private String getCDMUserAgent() { + // This user agent is found and hard-coded in Android(L) source code and + // Chromium project. Not sure if it's gonna change in the future. + String ua = "Widevine CDM v1.0"; + return ua; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java new file mode 100644 index 000000000..74144f28e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java @@ -0,0 +1,44 @@ +/* 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.media; + +import android.annotation.TargetApi; +import static android.os.Build.VERSION_CODES.M; +import android.media.MediaDrm; +import android.util.Log; +import java.util.List; + +public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 { + + private static final String LOGTAG = "GeckoMediaDrmBridgeV23"; + private static final boolean DEBUG = false; + + GeckoMediaDrmBridgeV23(String keySystem) throws Exception { + super(keySystem); + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor"); + mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null); + } + + @TargetApi(M) + private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener { + @Override + public void onKeyStatusChange(MediaDrm mediaDrm, + byte[] sessionId, + List<MediaDrm.KeyStatus> keyInformation, + boolean hasNewUsableKey) { + if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey); + if (keyInformation.size() == 0) { + return; + } + SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()]; + for (int i = 0; i < keyInformation.size(); i++) { + MediaDrm.KeyStatus keyStatus = keyInformation.get(i); + keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(), + keyStatus.getStatusCode()); + } + onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java new file mode 100644 index 000000000..3df01f1fe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java @@ -0,0 +1,405 @@ +/* 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.media; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import android.view.Surface; + +import java.io.IOException; +import java.nio.ByteBuffer; + +// Implement async API using MediaCodec sync mode (API v16). +// This class uses internal worker thread/handler (mBufferPoller) to poll +// input and output buffer and notifies the client through callbacks. +final class JellyBeanAsyncCodec implements AsyncCodec { + private static final String LOGTAG = "GeckoAsyncCodecAPIv16"; + private static final boolean DEBUG = false; + + private static final int ERROR_CODEC = -10000; + + private abstract class CancelableHandler extends Handler { + private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL' + + protected CancelableHandler(Looper looper) { + super(looper); + } + + protected void cancel() { + removeCallbacksAndMessages(null); + sendEmptyMessage(MSG_CANCELLATION); + // Wait until handleMessageLocked() is done. + synchronized (this) { } + } + + protected boolean isCanceled() { + return hasMessages(MSG_CANCELLATION); + } + + // Subclass should implement this and return true if it handles msg. + // Warning: Never, ever call super.handleMessage() in this method! + protected abstract boolean handleMessageLocked(Message msg); + + public final void handleMessage(Message msg) { + // Block cancel() during handleMessageLocked(). + synchronized (this) { + if (isCanceled() || handleMessageLocked(msg)) { + return; + } + } + + switch (msg.what) { + case MSG_CANCELLATION: + // Just a marker. Nothing to do here. + if (DEBUG) Log.d(LOGTAG, "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this); + break; + default: + super.handleMessage(msg); + break; + } + } + } + + // A handler to invoke AsyncCodec.Callbacks methods. + private final class CallbackSender extends CancelableHandler { + private static final int MSG_INPUT_BUFFER_AVAILABLE = 1; + private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2; + private static final int MSG_OUTPUT_FORMAT_CHANGE = 3; + private static final int MSG_ERROR = 4; + private Callbacks mCallbacks; + + private CallbackSender(Looper looper, Callbacks callbacks) { + super(looper); + mCallbacks = callbacks; + } + + public void notifyInputBuffer(int index) { + if (isCanceled()) { + return; + } + + Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE); + msg.arg1 = index; + processMessage(msg); + } + + private void processMessage(Message msg) { + if (Looper.myLooper() == getLooper()) { + handleMessage(msg); + } else { + sendMessage(msg); + } + } + + public void notifyOutputBuffer(int index, MediaCodec.BufferInfo info) { + if (isCanceled()) { + return; + } + + Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info); + msg.arg1 = index; + processMessage(msg); + } + + public void notifyOutputFormat(MediaFormat format) { + if (isCanceled()) { + return; + } + processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format)); + } + + public void notifyError(int result) { + Log.e(LOGTAG, "codec error:" + result); + processMessage(obtainMessage(MSG_ERROR, result, 0)); + } + + protected boolean handleMessageLocked(Message msg) { + switch (msg.what) { + case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index. + mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this, + msg.arg1); + break; + case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info. + mCallbacks.onOutputBufferAvailable(JellyBeanAsyncCodec.this, + msg.arg1, + (MediaCodec.BufferInfo)msg.obj); + break; + case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format. + mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this, + (MediaFormat)msg.obj); + break; + case MSG_ERROR: // arg1: error code. + mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1); + break; + default: + return false; + } + + return true; + } + } + + // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(), + // with 10ms time-out. Once triggered and successfully gets a buffer, it + // will schedule next polling until EOS or failure. To prevent it from + // automatically polling more buffer, use cancel() it inherits from + // CancelableHandler. + private final class BufferPoller extends CancelableHandler { + private static final int MSG_POLL_INPUT_BUFFERS = 1; + private static final int MSG_POLL_OUTPUT_BUFFERS = 2; + + private static final long DEQUEUE_TIMEOUT_US = 10000; + + public BufferPoller(Looper looper) { + super(looper); + } + + private void schedulePollingIfNotCanceled(int what) { + if (isCanceled()) { + return; + } + + schedulePolling(what); + } + + private void schedulePolling(int what) { + if (needsBuffer(what)) { + sendEmptyMessage(what); + } + } + + private boolean needsBuffer(int what) { + if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) { + return false; + } + + if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) { + return false; + } + + return true; + } + + protected boolean handleMessageLocked(Message msg) { + try { + switch (msg.what) { + case MSG_POLL_INPUT_BUFFERS: + pollInputBuffer(); + break; + case MSG_POLL_OUTPUT_BUFFERS: + pollOutputBuffer(); + break; + default: + return false; + } + } catch (IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + } + + return true; + } + + private void pollInputBuffer() { + int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + if (result >= 0) { + mCallbackSender.notifyInputBuffer(result); + schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } else if (result != MediaCodec.INFO_TRY_AGAIN_LATER) { + mCallbackSender.notifyError(result); + } + } + + private void pollOutputBuffer() { + boolean dequeueMoreBuffer = true; + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US); + if (result >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + mOutputEnded = true; + } + mCallbackSender.notifyOutputBuffer(result, info); + if (!hasMessages(MSG_POLL_INPUT_BUFFERS)) { + schedulePollingIfNotCanceled(MSG_POLL_INPUT_BUFFERS); + } + } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + mOutputBuffers = mCodec.getOutputBuffers(); + } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat()); + } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) { + // When input ended, keep polling remaining output buffer until EOS. + dequeueMoreBuffer = mInputEnded; + } else { + mCallbackSender.notifyError(result); + dequeueMoreBuffer = false; + } + + if (dequeueMoreBuffer) { + schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS); + } + } + } + + private MediaCodec mCodec; + private ByteBuffer[] mInputBuffers; + private ByteBuffer[] mOutputBuffers; + private AsyncCodec.Callbacks mCallbacks; + private CallbackSender mCallbackSender; + + private BufferPoller mBufferPoller; + private volatile boolean mInputEnded; + private volatile boolean mOutputEnded; + + // Must be called on a thread with looper. + /* package */ JellyBeanAsyncCodec(String name) throws IOException { + mCodec = MediaCodec.createByCodecName(name); + initBufferPoller(name + " buffer poller"); + } + + private void initBufferPoller(String name) { + if (mBufferPoller != null) { + Log.e(LOGTAG, "poller already initialized"); + return; + } + HandlerThread thread = new HandlerThread(name); + thread.start(); + mBufferPoller = new BufferPoller(thread.getLooper()); + if (DEBUG) Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId()); + } + + @Override + public void setCallbacks(AsyncCodec.Callbacks callbacks, Handler handler) { + if (callbacks == null) { + return; + } + + Looper looper = (handler == null) ? null : handler.getLooper(); + if (looper == null) { + // Use this thread if no handler supplied. + looper = Looper.myLooper(); + } + if (looper == null) { + // This thread has no looper. Use poller thread. + looper = mBufferPoller.getLooper(); + } + mCallbackSender = new CallbackSender(looper, callbacks); + if (DEBUG) Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender); + } + + @Override + public void configure(MediaFormat format, Surface surface, int flags) { + assertCallbacks(); + + mCodec.configure(format, surface, null, flags); + } + + private void assertCallbacks() { + if (mCallbackSender == null) { + throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks()."); + } + } + + @Override + public void start() { + assertCallbacks(); + + mCodec.start(); + mInputEnded = false; + mOutputEnded = false; + mInputBuffers = mCodec.getInputBuffers(); + mOutputBuffers = mCodec.getOutputBuffers(); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + + @Override + public final void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags) { + assertCallbacks(); + + mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + + try { + mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } catch (IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + return; + } + + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS); + } + + @Override + public final void releaseOutputBuffer(int index, boolean render) { + assertCallbacks(); + + mCodec.releaseOutputBuffer(index, render); + } + + @Override + public final ByteBuffer getInputBuffer(int index) { + assertCallbacks(); + + return mInputBuffers[index]; + } + + @Override + public final ByteBuffer getOutputBuffer(int index) { + assertCallbacks(); + + return mOutputBuffers[index]; + } + + @Override + public void flush() { + assertCallbacks(); + + mInputEnded = false; + mOutputEnded = false; + cancelPendingTasks(); + mCodec.flush(); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + + private void cancelPendingTasks() { + mBufferPoller.cancel(); + mCallbackSender.cancel(); + } + + @Override + public void stop() { + assertCallbacks(); + + cancelPendingTasks(); + mCodec.stop(); + } + + @Override + public void release() { + assertCallbacks(); + + cancelPendingTasks(); + mCallbackSender = null; + mCodec.release(); + stopBufferPoller(); + } + + private void stopBufferPoller() { + if (mBufferPoller == null) { + Log.e(LOGTAG, "no initialized poller."); + return; + } + + mBufferPoller.getLooper().quit(); + mBufferPoller = null; + + if (DEBUG) Log.d(LOGTAG, "stop poller " + this); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java b/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java new file mode 100644 index 000000000..2aad674b6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java @@ -0,0 +1,162 @@ +/* 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.media; +import org.mozilla.gecko.AppConstants; + +import android.media.MediaCrypto; +import android.util.Log; + +final class LocalMediaDrmBridge implements GeckoMediaDrm { + private static final String LOGTAG = "GeckoLocalMediaDrmBridge"; + private static final boolean DEBUG = false; + private GeckoMediaDrm mBridge = null; + private CallbacksForwarder mCallbacksFwd; + + // Forward the callback calls from GeckoMediaDrmBridgeV{21,23} + // to the callback MediaDrmProxy.Callbacks. + private class CallbacksForwarder implements GeckoMediaDrm.Callbacks { + private final GeckoMediaDrm.Callbacks mProxyCallbacks; + + CallbacksForwarder(GeckoMediaDrm.Callbacks callbacks) { + assertTrue(callbacks != null); + mProxyCallbacks = callbacks; + } + + @Override + public void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionCreated(createSessionToken, + promiseId, + sessionId, + request); + } + + @Override + public void onSessionUpdated(int promiseId, byte[] sessionId) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionUpdated(promiseId, sessionId); + } + + @Override + public void onSessionClosed(int promiseId, byte[] sessionId) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionClosed(promiseId, sessionId); + } + + @Override + public void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + + @Override + public void onSessionError(byte[] sessionId, + String message) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionError(sessionId, message); + } + + @Override + public void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos) { + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + + @Override + public void onRejectPromise(int promiseId, String message) { + if (DEBUG) Log.d(LOGTAG, message); + assertTrue(mProxyCallbacks != null); + mProxyCallbacks.onRejectPromise(promiseId, message); + } + } // CallbacksForwarder + + private static void assertTrue(boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + LocalMediaDrmBridge(String keySystem) throws Exception { + if (AppConstants.Versions.preLollipop) { + mBridge = null; + } else if (AppConstants.Versions.feature21Plus && + AppConstants.Versions.preMarshmallow) { + mBridge = new GeckoMediaDrmBridgeV21(keySystem); + } else { + mBridge = new GeckoMediaDrmBridgeV23(keySystem); + } + } + + @Override + public synchronized void setCallbacks(Callbacks callbacks) { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + mCallbacksFwd = new CallbacksForwarder(callbacks); + assertTrue(mBridge != null); + mBridge.setCallbacks(mCallbacksFwd); + } + + @Override + public synchronized void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + assertTrue(mCallbacksFwd != null); + try { + mBridge.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to createSession.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to createSession."); + } + } + + @Override + public synchronized void updateSession(int promiseId, String sessionId, byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + assertTrue(mCallbacksFwd != null); + try { + mBridge.updateSession(promiseId, sessionId, response); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to updateSession.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to updateSession."); + } + } + + @Override + public synchronized void closeSession(int promiseId, String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + assertTrue(mCallbacksFwd != null); + try { + mBridge.closeSession(promiseId, sessionId); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to closeSession.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to closeSession."); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + try { + mBridge.release(); + mBridge = null; + mCallbacksFwd = null; + } catch (Exception e) { + Log.e(LOGTAG, "Failed to release", e); + } + } + + @Override + public synchronized MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + return mBridge != null ? mBridge.getMediaCrypto() : null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java new file mode 100644 index 000000000..2aa783050 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java @@ -0,0 +1,431 @@ +package org.mozilla.gecko.media; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.support.v4.app.NotificationManagerCompat; +import android.util.Log; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.util.ThreadUtils; + +import java.lang.ref.WeakReference; + +public class MediaControlService extends Service implements Tabs.OnTabsChangedListener { + private static final String LOGTAG = "MediaControlService"; + + public static final String ACTION_INIT = "action_init"; + public static final String ACTION_RESUME = "action_resume"; + public static final String ACTION_PAUSE = "action_pause"; + public static final String ACTION_STOP = "action_stop"; + public static final String ACTION_RESUME_BY_AUDIO_FOCUS = "action_resume_audio_focus"; + public static final String ACTION_PAUSE_BY_AUDIO_FOCUS = "action_pause_audio_focus"; + + private static final int MEDIA_CONTROL_ID = 1; + private static final String MEDIA_CONTROL_PREF = "dom.audiochannel.mediaControl"; + + private String mActionState = ACTION_STOP; + + private MediaSession mSession; + private MediaController mController; + + private PrefsHelper.PrefHandler mPrefsObserver; + private final String[] mPrefs = { MEDIA_CONTROL_PREF }; + + private boolean mInitialize = false; + private boolean mIsMediaControlPrefOn = true; + + private static WeakReference<Tab> mTabReference = new WeakReference<>(null); + + private int minCoverSize; + private int coverSize; + + @Override + public void onCreate() { + initialize(); + } + + @Override + public void onDestroy() { + shutdown(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + handleIntent(intent); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public boolean onUnbind(Intent intent) { + mSession.release(); + return super.onUnbind(intent); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + shutdown(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + if (!mInitialize) { + return; + } + + final Tab playingTab = mTabReference.get(); + switch (msg) { + case MEDIA_PLAYING_CHANGE: + // The 'MEDIA_PLAYING_CHANGE' would only be received when the + // media starts or ends. + if (playingTab != tab && tab.isMediaPlaying()) { + mTabReference = new WeakReference<>(tab); + notifyControlInterfaceChanged(ACTION_PAUSE); + } else if (playingTab == tab && !tab.isMediaPlaying()) { + notifyControlInterfaceChanged(ACTION_STOP); + mTabReference = new WeakReference<>(null); + } + break; + case MEDIA_PLAYING_RESUME: + // user resume the paused-by-control media from page so that we + // should make the control interface consistent. + if (playingTab == tab && !isMediaPlaying()) { + notifyControlInterfaceChanged(ACTION_PAUSE); + } + break; + case CLOSED: + if (playingTab == null || playingTab == tab) { + // Remove the controls when the playing tab disappeared or was closed. + notifyControlInterfaceChanged(ACTION_STOP); + } + break; + case FAVICON: + if (playingTab == tab) { + final String actionForPendingIntent = isMediaPlaying() ? + ACTION_PAUSE : ACTION_RESUME; + notifyControlInterfaceChanged(actionForPendingIntent); + } + break; + } + } + + private boolean isMediaPlaying() { + return mActionState.equals(ACTION_RESUME); + } + + private void initialize() { + if (mInitialize || + !isAndroidVersionLollopopOrHigher()) { + return; + } + + Log.d(LOGTAG, "initialize"); + getGeckoPreference(); + initMediaSession(); + + coverSize = (int) getResources().getDimension(R.dimen.notification_media_cover); + minCoverSize = getResources().getDimensionPixelSize(R.dimen.favicon_bg); + + Tabs.registerOnTabsChangedListener(this); + mInitialize = true; + } + + private void shutdown() { + if (!mInitialize) { + return; + } + + Log.d(LOGTAG, "shutdown"); + notifyControlInterfaceChanged(ACTION_STOP); + PrefsHelper.removeObserver(mPrefsObserver); + + Tabs.unregisterOnTabsChangedListener(this); + mInitialize = false; + stopSelf(); + } + + private boolean isAndroidVersionLollopopOrHigher() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + } + + private void handleIntent(Intent intent) { + if (intent == null || intent.getAction() == null || !mInitialize) { + return; + } + + Log.d(LOGTAG, "HandleIntent, action = " + intent.getAction() + ", actionState = " + mActionState); + switch (intent.getAction()) { + case ACTION_INIT : + // This action is used to create a service and do the initialization, + // the actual operation would be executed via control interface's + // pending intent. + break; + case ACTION_RESUME : + mController.getTransportControls().play(); + break; + case ACTION_PAUSE : + mController.getTransportControls().pause(); + break; + case ACTION_STOP : + mController.getTransportControls().stop(); + break; + case ACTION_PAUSE_BY_AUDIO_FOCUS : + mController.getTransportControls().sendCustomAction(ACTION_PAUSE_BY_AUDIO_FOCUS, null); + break; + case ACTION_RESUME_BY_AUDIO_FOCUS : + mController.getTransportControls().sendCustomAction(ACTION_RESUME_BY_AUDIO_FOCUS, null); + break; + } + } + + private void getGeckoPreference() { + mPrefsObserver = new PrefsHelper.PrefHandlerBase() { + @Override + public void prefValue(String pref, boolean value) { + if (pref.equals(MEDIA_CONTROL_PREF)) { + mIsMediaControlPrefOn = value; + + // If media is playing, we just need to create or remove + // the media control interface. + if (mActionState.equals(ACTION_RESUME)) { + notifyControlInterfaceChanged(mIsMediaControlPrefOn ? + ACTION_PAUSE : ACTION_STOP); + } + + // If turn off pref during pausing, except removing media + // interface, we also need to stop the service and notify + // gecko about that. + if (mActionState.equals(ACTION_PAUSE) && + !mIsMediaControlPrefOn) { + Intent intent = new Intent(getApplicationContext(), MediaControlService.class); + intent.setAction(ACTION_STOP); + handleIntent(intent); + } + } + } + }; + PrefsHelper.addObserver(mPrefs, mPrefsObserver); + } + + private void initMediaSession() { + // Android MediaSession is introduced since version L. + mSession = new MediaSession(getApplicationContext(), + "fennec media session"); + mController = new MediaController(getApplicationContext(), + mSession.getSessionToken()); + + mSession.setCallback(new MediaSession.Callback() { + @Override + public void onCustomAction(String action, Bundle extras) { + if (action.equals(ACTION_PAUSE_BY_AUDIO_FOCUS)) { + Log.d(LOGTAG, "Controller, pause by audio focus changed"); + notifyControlInterfaceChanged(ACTION_RESUME); + } else if (action.equals(ACTION_RESUME_BY_AUDIO_FOCUS)) { + Log.d(LOGTAG, "Controller, resume by audio focus changed"); + notifyControlInterfaceChanged(ACTION_PAUSE); + } + } + + @Override + public void onPlay() { + Log.d(LOGTAG, "Controller, onPlay"); + super.onPlay(); + notifyControlInterfaceChanged(ACTION_PAUSE); + notifyObservers("MediaControl", "resumeMedia"); + // To make sure we always own audio focus during playing. + AudioFocusAgent.notifyStartedPlaying(); + } + + @Override + public void onPause() { + Log.d(LOGTAG, "Controller, onPause"); + super.onPause(); + notifyControlInterfaceChanged(ACTION_RESUME); + notifyObservers("MediaControl", "mediaControlPaused"); + AudioFocusAgent.notifyStoppedPlaying(); + } + + @Override + public void onStop() { + Log.d(LOGTAG, "Controller, onStop"); + super.onStop(); + notifyControlInterfaceChanged(ACTION_STOP); + notifyObservers("MediaControl", "mediaControlStopped"); + mTabReference = new WeakReference<>(null); + } + }); + } + + private void notifyObservers(String topic, String data) { + GeckoAppShell.notifyObservers(topic, data); + } + + private boolean isNeedToRemoveControlInterface(String action) { + return action.equals(ACTION_STOP); + } + + private void notifyControlInterfaceChanged(final String uiAction) { + if (!mInitialize) { + return; + } + + Log.d(LOGTAG, "notifyControlInterfaceChanged, action = " + uiAction); + + if (isNeedToRemoveControlInterface(uiAction)) { + stopForeground(false); + NotificationManagerCompat.from(this).cancel(MEDIA_CONTROL_ID); + setActionState(uiAction); + return; + } + + if (!mIsMediaControlPrefOn) { + return; + } + + final Tab tab = mTabReference.get(); + + if (tab == null) { + return; + } + + setActionState(uiAction); + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + updateNotification(tab, uiAction); + } + }); + } + + private void setActionState(final String uiAction) { + switch (uiAction) { + case ACTION_PAUSE: + mActionState = ACTION_RESUME; + break; + case ACTION_RESUME: + mActionState = ACTION_PAUSE; + break; + case ACTION_STOP: + mActionState = ACTION_STOP; + break; + } + } + + private void updateNotification(Tab tab, String action) { + ThreadUtils.assertNotOnUiThread(); + + final Notification.MediaStyle style = new Notification.MediaStyle(); + style.setShowActionsInCompactView(0); + + final boolean isPlaying = isMediaPlaying(); + final int visibility = tab.isPrivate() ? + Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC; + + final Notification notification = new Notification.Builder(this) + .setSmallIcon(R.drawable.flat_icon) + .setLargeIcon(generateCoverArt(tab)) + .setContentTitle(tab.getTitle()) + .setContentText(tab.getURL()) + .setContentIntent(createContentIntent(tab.getId())) + .setDeleteIntent(createDeleteIntent()) + .setStyle(style) + .addAction(createNotificationAction(action)) + .setOngoing(isPlaying) + .setShowWhen(false) + .setWhen(0) + .setVisibility(visibility) + .build(); + + if (isPlaying) { + startForeground(MEDIA_CONTROL_ID, notification); + } else { + stopForeground(false); + NotificationManagerCompat.from(this) + .notify(MEDIA_CONTROL_ID, notification); + } + } + + private Notification.Action createNotificationAction(String action) { + boolean isPlayAction = action.equals(ACTION_RESUME); + + int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause; + String title = getString(isPlayAction ? R.string.media_play : R.string.media_pause); + + final Intent intent = new Intent(getApplicationContext(), MediaControlService.class); + intent.setAction(action); + final PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0); + + //noinspection deprecation - The new constructor is only for API > 23 + return new Notification.Action.Builder(icon, title, pendingIntent).build(); + } + + private PendingIntent createContentIntent(int tabId) { + Intent intent = new Intent(getApplicationContext(), BrowserApp.class); + intent.setAction(GeckoApp.ACTION_SWITCH_TAB); + intent.putExtra("TabId", tabId); + return PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private PendingIntent createDeleteIntent() { + Intent intent = new Intent(getApplicationContext(), MediaControlService.class); + intent.setAction(ACTION_STOP); + return PendingIntent.getService(getApplicationContext(), 1, intent, 0); + } + + private Bitmap generateCoverArt(Tab tab) { + final Bitmap favicon = tab.getFavicon(); + + // If we do not have a favicon or if it's smaller than 72 pixels then just use the default icon. + if (favicon == null || favicon.getWidth() < minCoverSize || favicon.getHeight() < minCoverSize) { + // Use the launcher icon as fallback + return BitmapFactory.decodeResource(getResources(), R.drawable.notification_media); + } + + // Favicon should at least have half of the size of the cover + int width = Math.max(favicon.getWidth(), coverSize / 2); + int height = Math.max(favicon.getHeight(), coverSize / 2); + + final Bitmap coverArt = Bitmap.createBitmap(coverSize, coverSize, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(coverArt); + canvas.drawColor(0xFF777777); + + int left = Math.max(0, (coverArt.getWidth() / 2) - (width / 2)); + int right = Math.min(coverSize, left + width); + int top = Math.max(0, (coverArt.getHeight() / 2) - (height / 2)); + int bottom = Math.min(coverSize, top + height); + + final Paint paint = new Paint(); + paint.setAntiAlias(true); + + canvas.drawBitmap(favicon, + new Rect(0, 0, favicon.getWidth(), favicon.getHeight()), + new Rect(left, top, right, bottom), + paint); + + return coverArt; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java new file mode 100644 index 000000000..faca2389e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java @@ -0,0 +1,307 @@ +/* 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.media; + +import java.util.ArrayList; +import java.util.UUID; + +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaCrypto; +import android.media.MediaDrm; +import android.util.Log; +import android.os.Build; + +public final class MediaDrmProxy { + private static final String LOGTAG = "GeckoMediaDrmProxy"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + @WrapForJNI + private static final String AAC = "audio/mp4a-latm"; + @WrapForJNI + private static final String AVC = "video/avc"; + @WrapForJNI + private static final String VORBIS = "audio/vorbis"; + @WrapForJNI + private static final String VP8 = "video/x-vnd.on2.vp8"; + @WrapForJNI + private static final String VP9 = "video/x-vnd.on2.vp9"; + @WrapForJNI + private static final String OPUS = "audio/opus"; + + // A flag to avoid using the native object that has been destroyed. + private boolean mDestroyed; + private GeckoMediaDrm mImpl; + public static ArrayList<MediaDrmProxy> mProxyList = new ArrayList<MediaDrmProxy>(); + + private static boolean isSystemSupported() { + // Support versions >= LOLLIPOP + if (AppConstants.Versions.preLollipop) { + if (DEBUG) Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT); + return false; + } + return true; + } + + @WrapForJNI + public static boolean isSchemeSupported(String keySystem) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID) + && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID); + } + if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem); + return false; + } + + @WrapForJNI + public static boolean IsCryptoSchemeSupported(String keySystem, + String container) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container); + } + if (DEBUG) Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container); + return false; + } + + @WrapForJNI + public static boolean CanDecode(String mimeType) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder()) { + continue; + } + for (String m : info.getSupportedTypes()) { + if (m.equals(mimeType)) { + return true; + } + } + } + if (DEBUG) Log.d(LOGTAG, "cannot decode mimetype = " + mimeType); + return false; + } + + // Interface for callback to native. + public interface Callbacks { + void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request); + + void onSessionUpdated(int promiseId, byte[] sessionId); + + void onSessionClosed(int promiseId, byte[] sessionId); + + void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request); + + void onSessionError(byte[] sessionId, + String message); + + // MediaDrm.KeyStatus is available in API level 23(M) + // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html + // For compatibility between L and M above, we'll unwrap the KeyStatus structure + // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy). + void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos); + + void onRejectPromise(int promiseId, + String message); + } // Callbacks + + public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks { + @WrapForJNI(calledFrom = "gecko") + NativeMediaDrmProxyCallbacks() {} + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionUpdated(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionClosed(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionError(byte[] sessionId, + String message); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onRejectPromise(int promiseId, + String message); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // NativeMediaDrmProxyCallbacks + + // A proxy to callback from LocalMediaDrmBridge to native instance. + public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks { + private final Callbacks mNativeCallbacks; + private final MediaDrmProxy mProxy; + + public MediaDrmProxyCallbacks(MediaDrmProxy proxy, Callbacks callbacks) { + mNativeCallbacks = callbacks; + mProxy = proxy; + } + + @Override + public void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionCreated(createSessionToken, + promiseId, + sessionId, + request); + } + } + + @Override + public void onSessionUpdated(int promiseId, byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionUpdated(promiseId, sessionId); + } + } + + @Override + public void onSessionClosed(int promiseId, byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionClosed(promiseId, sessionId); + } + } + + @Override + public void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + } + + @Override + public void onSessionError(byte[] sessionId, + String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionError(sessionId, message); + } + } + + @Override + public void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } + + @Override + public void onRejectPromise(int promiseId, + String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onRejectPromise(promiseId, message); + } + } + } // MediaDrmProxyCallbacks + + public boolean isDestroyed() { + return mDestroyed; + } + + @WrapForJNI(calledFrom = "gecko") + public static MediaDrmProxy create(String keySystem, + Callbacks nativeCallbacks, + boolean isRemote) { + // TODO: Will implement {Local,Remote}MediaDrmBridge instantiation by + // '''isRemote''' flag in Bug 1307818. + MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks); + return proxy; + } + + MediaDrmProxy(String keySystem, Callbacks nativeCallbacks) { + if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy"); + // TODO: Bug 1306185 will implement the LocalMediaDrmBridge as an impl + // of GeckoMediaDrm for in-process decoding mode. + //mImpl = new LocalMediaDrmBridge(keySystem); + mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks)); + mProxyList.add(this); + } + + @WrapForJNI + private void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId); + mImpl.createSession(createSessionToken, + promiseId, + initDataType, + initData); + } + + @WrapForJNI + private void updateSession(int promiseId, String sessionId, byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.updateSession(promiseId, sessionId, response); + } + + @WrapForJNI + private void closeSession(int promiseId, String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.closeSession(promiseId, sessionId); + } + + @WrapForJNI // Called when natvie object is destroyed. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mDestroyed) { + return; + } + mDestroyed = true; + release(); + } + + private void release() { + if (DEBUG) Log.d(LOGTAG, "release"); + mProxyList.remove(this); + mImpl.release(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java new file mode 100644 index 000000000..fcb0fc659 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java @@ -0,0 +1,44 @@ +/* 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.media; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.os.RemoteException; + +import org.mozilla.gecko.mozglue.GeckoLoader; + +public final class MediaManager extends Service { + private static boolean sNativeLibLoaded; + + private Binder mBinder = new IMediaManager.Stub() { + @Override + public ICodec createCodec() throws RemoteException { + return new Codec(); + } + + @Override + public IMediaDrmBridge createRemoteMediaDrmBridge(String keySystem, + String stubId) + throws RemoteException { + return new RemoteMediaDrmBridgeStub(keySystem, stubId); + } + }; + + @Override + public synchronized void onCreate() { + if (!sNativeLibLoaded) { + GeckoLoader.doLoadLibrary(this, "mozglue"); + sNativeLibLoaded = true; + } + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java new file mode 100644 index 000000000..260ca73c1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java @@ -0,0 +1,224 @@ +/* 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.media; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Telemetry; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.MediaFormat; +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.view.Surface; +import android.util.Log; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.LinkedList; +import java.util.List; + +public final class RemoteManager implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteManager"; + private static final boolean DEBUG = false; + private static RemoteManager sRemoteManager = null; + + public synchronized static RemoteManager getInstance() { + if (sRemoteManager == null) { + sRemoteManager = new RemoteManager(); + } + + sRemoteManager.init(); + return sRemoteManager; + } + + private List<CodecProxy> mProxies = new LinkedList<CodecProxy>(); + private volatile IMediaManager mRemote; + private volatile CountDownLatch mConnectionLatch; + private final ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + if (DEBUG) Log.d(LOGTAG, "service connected"); + try { + service.linkToDeath(RemoteManager.this, 0); + } catch (RemoteException e) { + e.printStackTrace(); + } + mRemote = IMediaManager.Stub.asInterface(service); + if (mConnectionLatch != null) { + mConnectionLatch.countDown(); + } + } + + /** + * Called when a connection to the Service has been lost. This typically + * happens when the process hosting the service has crashed or been killed. + * This does <em>not</em> remove the ServiceConnection itself -- this + * binding to the service will remain active, and you will receive a call + * to {@link #onServiceConnected} when the Service is next running. + * + * @param name The concrete component name of the service whose + * connection has been lost. + */ + @Override + public void onServiceDisconnected(ComponentName name) { + if (DEBUG) Log.d(LOGTAG, "service disconnected"); + mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0); + mRemote = null; + if (mConnectionLatch != null) { + mConnectionLatch.countDown(); + } + } + }; + + private synchronized boolean init() { + if (mRemote != null) { + return true; + } + + if (DEBUG) Log.d(LOGTAG, "init remote manager " + this); + Context appCtxt = GeckoAppShell.getApplicationContext(); + if (DEBUG) Log.d(LOGTAG, "ctxt=" + appCtxt); + appCtxt.bindService(new Intent(appCtxt, MediaManager.class), + mConnection, Context.BIND_AUTO_CREATE); + if (!waitConnection()) { + appCtxt.unbindService(mConnection); + return false; + } + return true; + } + + private boolean waitConnection() { + boolean ok = false; + + mConnectionLatch = new CountDownLatch(1); + try { + int retryCount = 0; + while (retryCount < 5) { + if (DEBUG) Log.d(LOGTAG, "waiting for connection latch:" + mConnectionLatch); + mConnectionLatch.await(1, TimeUnit.SECONDS); + if (mConnectionLatch.getCount() == 0) { + break; + } + Log.w(LOGTAG, "Creator not connected in 1s. Try again."); + retryCount++; + } + ok = true; + } catch (InterruptedException e) { + Log.e(LOGTAG, "service not connected in 5 seconds. Stop waiting."); + e.printStackTrace(); + } + mConnectionLatch = null; + + return ok; + } + + public synchronized CodecProxy createCodec(MediaFormat format, + Surface surface, + CodecProxy.Callbacks callbacks) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize"); + return null; + } + try { + ICodec remote = mRemote.createCodec(); + CodecProxy proxy = CodecProxy.createCodecProxy(format, surface, callbacks); + if (proxy.init(remote)) { + mProxies.add(proxy); + return proxy; + } else { + return null; + } + } catch (RemoteException e) { + e.printStackTrace(); + return null; + } + } + + private static final String MEDIA_DECODING_PROCESS_CRASH = "MEDIA_DECODING_PROCESS_CRASH"; + private void reportDecodingProcessCrash() { + Telemetry.addToHistogram(MEDIA_DECODING_PROCESS_CRASH, 1); + } + + public synchronized IMediaDrmBridge createRemoteMediaDrmBridge(String keySystem, + String stubId) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize"); + return null; + } + try { + IMediaDrmBridge remoteBridge = + mRemote.createRemoteMediaDrmBridge(keySystem, stubId); + return remoteBridge; + } catch (RemoteException e) { + Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e); + return null; + } + } + + @Override + public void binderDied() { + Log.e(LOGTAG, "remote codec is dead"); + reportDecodingProcessCrash(); + handleRemoteDeath(); + } + + private synchronized void handleRemoteDeath() { + // Wait for onServiceDisconnected() + if (!waitConnection()) { + notifyError(true); + return; + } + // Restart + if (init() && recoverRemoteCodec()) { + notifyError(false); + } else { + notifyError(true); + } + } + + private synchronized void notifyError(boolean fatal) { + for (CodecProxy proxy : mProxies) { + proxy.reportError(fatal); + } + } + + private synchronized boolean recoverRemoteCodec() { + if (DEBUG) Log.d(LOGTAG, "recover codec"); + boolean ok = true; + try { + for (CodecProxy proxy : mProxies) { + ok &= proxy.init(mRemote.createCodec()); + } + return ok; + } catch (RemoteException e) { + return false; + } + } + + public void releaseCodec(CodecProxy proxy) throws DeadObjectException, RemoteException { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet"); + return; + } + proxy.deinit(); + synchronized (this) { + if (mProxies.remove(proxy) && mProxies.isEmpty()) { + release(); + } + } + } + + private void release() { + if (DEBUG) Log.d(LOGTAG, "release remote manager " + this); + Context appCtxt = GeckoAppShell.getApplicationContext(); + mRemote.asBinder().unlinkToDeath(this, 0); + mRemote = null; + appCtxt.unbindService(mConnection); + } +} // RemoteManager
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java new file mode 100644 index 000000000..d65bb7872 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java @@ -0,0 +1,152 @@ +/* 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.media; + +import android.media.MediaCrypto; +import android.util.Log; + +final class RemoteMediaDrmBridge implements GeckoMediaDrm { + private static final String LOGTAG = "GeckoRemoteMediaDrmBridge"; + private static final boolean DEBUG = false; + private CallbacksForwarder mCallbacksFwd; + private IMediaDrmBridge mRemote; + + // Forward callbacks from remote bridge stub to MediaDrmProxy. + private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub { + private final GeckoMediaDrm.Callbacks mProxyCallbacks; + CallbacksForwarder(Callbacks callbacks) { + assertTrue(callbacks != null); + mProxyCallbacks = callbacks; + } + + @Override + public void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request) { + mProxyCallbacks.onSessionCreated(createSessionToken, + promiseId, + sessionId, + request); + } + + @Override + public void onSessionUpdated(int promiseId, byte[] sessionId) { + mProxyCallbacks.onSessionUpdated(promiseId, sessionId); + } + + @Override + public void onSessionClosed(int promiseId, byte[] sessionId) { + mProxyCallbacks.onSessionClosed(promiseId, sessionId); + } + + @Override + public void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request) { + mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + + @Override + public void onSessionError(byte[] sessionId, String message) { + mProxyCallbacks.onSessionError(sessionId, message); + } + + @Override + public void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos) { + mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + + @Override + public void onRejectPromise(int promiseId, String message) { + mProxyCallbacks.onRejectPromise(promiseId, message); + } + } // CallbacksForwarder + + /* package-private */ static void assertTrue(boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public RemoteMediaDrmBridge(IMediaDrmBridge remoteBridge) { + assertTrue(remoteBridge != null); + mRemote = remoteBridge; + } + + @Override + public synchronized void setCallbacks(Callbacks callbacks) { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(callbacks != null); + assertTrue(mRemote != null); + + mCallbacksFwd = new CallbacksForwarder(callbacks); + try { + mRemote.setCallbacks(mCallbacksFwd); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception during setCallbacks", e); + } + } + + @Override + public synchronized void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + + try { + mRemote.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception while creating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session."); + } + } + + @Override + public synchronized void updateSession(int promiseId, String sessionId, byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + + try { + mRemote.updateSession(promiseId, sessionId, response); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception while updating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session."); + } + } + + @Override + public synchronized void closeSession(int promiseId, String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + + try { + mRemote.closeSession(promiseId, sessionId); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception while closing remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session."); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + + try { + mRemote.release(); + } catch (Exception e) { + Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e); + } + mRemote = null; + mCallbacksFwd = null; + } + + @Override + public synchronized MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!"); + assertTrue(false); + return null; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java new file mode 100644 index 000000000..8aed0f851 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java @@ -0,0 +1,247 @@ +/* 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.media; +import org.mozilla.gecko.AppConstants; + +import java.util.ArrayList; + +import android.media.MediaCrypto; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteMediaDrmBridgeStub"; + private static final boolean DEBUG = false; + private volatile IMediaDrmBridgeCallbacks mCallbacks = null; + + // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21. + private GeckoMediaDrm mBridge = null; + + // mStubId is initialized during stub construction. It should be a unique + // string which is generated in MediaDrmProxy in Fennec App process and is + // used for Codec to obtain corresponding MediaCrypto as input to achieve + // decryption. + // The generated stubId will be delivered to Codec via a code path starting + // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec. + private String mStubId = ""; + + public static ArrayList<RemoteMediaDrmBridgeStub> mBridgeStubs = + new ArrayList<RemoteMediaDrmBridgeStub>(); + + private String getId() { + return mStubId; + } + + private MediaCrypto getMediaCryptoFromBridge() { + return mBridge != null ? mBridge.getMediaCrypto() : null; + } + + public static synchronized MediaCrypto getMediaCrypto(String stubId) { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + + for (int i = 0; i < mBridgeStubs.size(); i++) { + if (mBridgeStubs.get(i) != null && + mBridgeStubs.get(i).getId().equals(stubId)) { + return mBridgeStubs.get(i).getMediaCryptoFromBridge(); + } + } + return null; + } + + // Callback to RemoteMediaDrmBridge. + private final class Callbacks implements GeckoMediaDrm.Callbacks { + private IMediaDrmBridgeCallbacks mRemoteCallbacks; + + public Callbacks(IMediaDrmBridgeCallbacks remote) { + mRemoteCallbacks = remote; + } + + @Override + public void onSessionCreated(int createSessionToken, + int promiseId, + byte[] sessionId, + byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionCreated()"); + try { + mRemoteCallbacks.onSessionCreated(createSessionToken, + promiseId, + sessionId, + request); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionUpdated(int promiseId, byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()"); + try { + mRemoteCallbacks.onSessionUpdated(promiseId, sessionId); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionClosed(int promiseId, byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionClosed()"); + try { + mRemoteCallbacks.onSessionClosed(promiseId, sessionId); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionMessage(byte[] sessionId, + int sessionMessageType, + byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionMessage()"); + try { + mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionError(byte[] sessionId, String message) { + if (DEBUG) Log.d(LOGTAG, "onSessionError()"); + try { + mRemoteCallbacks.onSessionError(sessionId, message); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionBatchedKeyChanged(byte[] sessionId, + SessionKeyInfo[] keyInfos) { + if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()"); + try { + mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onRejectPromise(int promiseId, String message) { + if (DEBUG) Log.d(LOGTAG, "onRejectPromise()"); + try { + mRemoteCallbacks.onRejectPromise(promiseId, message); + } catch (RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + } + + /* package-private */ void assertTrue(boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + RemoteMediaDrmBridgeStub(String keySystem, String stubId) throws RemoteException { + if (AppConstants.Versions.preLollipop) { + Log.e(LOGTAG, "Pre-Lollipop should never enter here!!"); + throw new RemoteException("Error, unsupported version!"); + } + try { + if (AppConstants.Versions.feature21Plus && + AppConstants.Versions.preMarshmallow) { + mBridge = new GeckoMediaDrmBridgeV21(keySystem); + } else { + mBridge = new GeckoMediaDrmBridgeV23(keySystem); + } + mStubId = stubId; + mBridgeStubs.add(this); + } catch (Exception e) { + throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation."); + } + } + + @Override + public synchronized void setCallbacks(IMediaDrmBridgeCallbacks callbacks) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(mBridge != null); + assertTrue(callbacks != null); + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + mBridge.setCallbacks(new Callbacks(mCallbacks)); + } + + @Override + public synchronized void createSession(int createSessionToken, + int promiseId, + String initDataType, + byte[] initData) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.createSession(createSessionToken, + promiseId, + initDataType, + initData); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to createSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to createSession."); + } + } + + @Override + public synchronized void updateSession(int promiseId, + String sessionId, + byte[] response) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.updateSession(promiseId, sessionId, response); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to updateSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to updateSession."); + } + } + + @Override + public synchronized void closeSession(int promiseId, String sessionId) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.closeSession(promiseId, sessionId); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to closeSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to closeSession."); + } + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Binder died !!"); + try { + release(); + } catch (Exception e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + mBridgeStubs.remove(this); + if (mBridge != null) { + mBridge.release(); + mBridge = null; + } + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + mStubId = ""; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/Sample.java b/mobile/android/base/java/org/mozilla/gecko/media/Sample.java new file mode 100644 index 000000000..b7a98da8a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/Sample.java @@ -0,0 +1,264 @@ +/* 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.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Parcel; +import android.os.Parcelable; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.SharedMemBuffer; +import org.mozilla.gecko.mozglue.SharedMemory; + +import java.io.IOException; +import java.nio.ByteBuffer; + +// Parcelable carrying input/output sample data and info cross process. +public final class Sample implements Parcelable { + public static final Sample EOS; + static { + BufferInfo eosInfo = new BufferInfo(); + eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + EOS = new Sample(null, eosInfo, null); + } + + public interface Buffer extends Parcelable { + int capacity(); + void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException; + void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException; + void dispose(); + } + + private static final class ArrayBuffer implements Buffer { + private byte[] mArray; + + public static final Creator<ArrayBuffer> CREATOR = new Creator<ArrayBuffer>() { + @Override + public ArrayBuffer createFromParcel(Parcel in) { + return new ArrayBuffer(in); + } + + @Override + public ArrayBuffer[] newArray(int size) { + return new ArrayBuffer[size]; + } + }; + + private ArrayBuffer(Parcel in) { + mArray = in.createByteArray(); + } + + private ArrayBuffer(byte[] bytes) { mArray = bytes; } + + @Override + public int describeContents() { return 0; } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByteArray(mArray); + } + + @Override + public int capacity() { + return mArray != null ? mArray.length : 0; + } + + @Override + public void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException { + src.position(offset); + if (mArray == null || mArray.length != size) { + mArray = new byte[size]; + } + src.get(mArray, 0, size); + } + + @Override + public void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException { + dest.put(mArray, offset, size); + } + + @Override + public void dispose() { + mArray = null; + } + } + + public Buffer buffer; + @WrapForJNI + public BufferInfo info; + public CryptoInfo cryptoInfo; + + public static Sample create() { return create(null, new BufferInfo(), null); } + + public static Sample create(ByteBuffer src, BufferInfo info, CryptoInfo cryptoInfo) { + ArrayBuffer buffer = new ArrayBuffer(byteArrayFromBuffer(src, info.offset, info.size)); + + BufferInfo bufferInfo = new BufferInfo(); + bufferInfo.set(0, info.size, info.presentationTimeUs, info.flags); + + return new Sample(buffer, bufferInfo, cryptoInfo); + } + + public static Sample create(SharedMemory sharedMem) { + return new Sample(new SharedMemBuffer(sharedMem), new BufferInfo(), null); + } + + private Sample(Buffer bytes, BufferInfo info, CryptoInfo cryptoInfo) { + buffer = bytes; + this.info = info; + this.cryptoInfo = cryptoInfo; + } + + private Sample(Parcel in) { + readInfo(in); + readCrypto(in); + buffer = in.readParcelable(Sample.class.getClassLoader()); + } + + private void readInfo(Parcel in) { + int offset = in.readInt(); + int size = in.readInt(); + long pts = in.readLong(); + int flags = in.readInt(); + + info = new BufferInfo(); + info.set(offset, size, pts, flags); + } + + private void readCrypto(Parcel in) { + int hasCryptoInfo = in.readInt(); + if (hasCryptoInfo == 0) { + return; + } + + byte[] iv = in.createByteArray(); + byte[] key = in.createByteArray(); + int mode = in.readInt(); + int[] numBytesOfClearData = in.createIntArray(); + int[] numBytesOfEncryptedData = in.createIntArray(); + int numSubSamples = in.readInt(); + + cryptoInfo = new CryptoInfo(); + cryptoInfo.set(numSubSamples, + numBytesOfClearData, + numBytesOfEncryptedData, + key, + iv, + mode); + } + + public Sample set(ByteBuffer bytes, BufferInfo info, CryptoInfo cryptoInfo) throws IOException { + if (bytes != null && info.size > 0) { + buffer.readFromByteBuffer(bytes, info.offset, info.size); + } + this.info.set(0, info.size, info.presentationTimeUs, info.flags); + this.cryptoInfo = cryptoInfo; + + return this; + } + + public void dispose() { + if (isEOS()) { + return; + } + + if (buffer != null) { + buffer.dispose(); + buffer = null; + } + info = null; + cryptoInfo = null; + } + + public boolean isEOS() { + return (this == EOS) || + ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0); + } + + public static final Creator<Sample> CREATOR = new Creator<Sample>() { + @Override + public Sample createFromParcel(Parcel in) { + return new Sample(in); + } + + @Override + public Sample[] newArray(int size) { + return new Sample[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int parcelableFlags) { + writeInfo(dest); + writeCrypto(dest); + dest.writeParcelable(buffer, parcelableFlags); + } + + private void writeInfo(Parcel dest) { + dest.writeInt(info.offset); + dest.writeInt(info.size); + dest.writeLong(info.presentationTimeUs); + dest.writeInt(info.flags); + } + + private void writeCrypto(Parcel dest) { + if (cryptoInfo != null) { + dest.writeInt(1); + dest.writeByteArray(cryptoInfo.iv); + dest.writeByteArray(cryptoInfo.key); + dest.writeInt(cryptoInfo.mode); + dest.writeIntArray(cryptoInfo.numBytesOfClearData); + dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData); + dest.writeInt(cryptoInfo.numSubSamples); + } else { + dest.writeInt(0); + } + } + + public static byte[] byteArrayFromBuffer(ByteBuffer buffer, int offset, int size) { + if (buffer == null || buffer.capacity() == 0 || size == 0) { + return null; + } + if (buffer.hasArray() && offset == 0 && buffer.array().length == size) { + return buffer.array(); + } + int length = Math.min(offset + size, buffer.capacity()) - offset; + byte[] bytes = new byte[length]; + buffer.position(offset); + buffer.get(bytes); + return bytes; + } + + @WrapForJNI + public void writeToByteBuffer(ByteBuffer dest) throws IOException { + if (buffer != null && dest != null && info.size > 0) { + buffer.writeToByteBuffer(dest, info.offset, info.size); + } + } + + @Override + public String toString() { + if (isEOS()) { + return "EOS sample"; + } + + StringBuilder str = new StringBuilder(); + str.append("{ buffer=").append(buffer). + append(", info="). + append("{ offset=").append(info.offset). + append(", size=").append(info.size). + append(", pts=").append(info.presentationTimeUs). + append(", flags=").append(Integer.toHexString(info.flags)).append(" }"). + append(" }"); + return str.toString(); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java new file mode 100644 index 000000000..9041e3756 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java @@ -0,0 +1,115 @@ +/* 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.media; + +import android.media.MediaCodec; + +import org.mozilla.gecko.mozglue.SharedMemory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +final class SamplePool { + private final class Impl { + private final String mName; + private int mNextId = 0; + private int mDefaultBufferSize = 4096; + private final List<Sample> mRecycledSamples = new ArrayList<>(); + + private Impl(String name) { + mName = name; + } + + private void setDefaultBufferSize(int size) { + mDefaultBufferSize = size; + } + + private synchronized Sample allocate(int size) { + Sample sample; + if (!mRecycledSamples.isEmpty()) { + sample = mRecycledSamples.remove(0); + sample.info.set(0, 0, 0, 0); + } else { + SharedMemory shm = null; + try { + shm = new SharedMemory(mNextId++, Math.max(size, mDefaultBufferSize)); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + if (shm != null) { + sample = Sample.create(shm); + } else { + sample = Sample.create(); + } + } + + return sample; + } + + private synchronized void recycle(Sample recycled) { + if (recycled.buffer.capacity() >= mDefaultBufferSize) { + mRecycledSamples.add(recycled); + } else { + recycled.dispose(); + } + } + + private synchronized void clear() { + for (Sample s : mRecycledSamples) { + s.dispose(); + } + + mRecycledSamples.clear(); + } + + @Override + protected void finalize() { + clear(); + } + } + + private final Impl mInputs; + private final Impl mOutputs; + + /* package */ SamplePool(String name) { + mInputs = new Impl(name + " input buffer pool"); + mOutputs = new Impl(name + " output buffer pool"); + } + + /* package */ void setInputBufferSize(int size) { + mInputs.setDefaultBufferSize(size); + } + + /* package */ void setOutputBufferSize(int size) { + mOutputs.setDefaultBufferSize(size); + } + + /* package */ Sample obtainInput(int size) { + return mInputs.allocate(size); + } + + /* package */ Sample obtainOutput(MediaCodec.BufferInfo info) { + Sample output = mOutputs.allocate(info.size); + output.info.set(0, info.size, info.presentationTimeUs, info.flags); + return output; + } + + /* package */ void recycleInput(Sample sample) { + sample.cryptoInfo = null; + mInputs.recycle(sample); + } + + /* package */ void recycleOutput(Sample sample) { + mOutputs.recycle(sample); + } + + /* package */ void reset() { + mInputs.clear(); + mOutputs.clear(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java new file mode 100644 index 000000000..b41ef3625 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java @@ -0,0 +1,51 @@ +/* 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.media; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class SessionKeyInfo implements Parcelable { + @WrapForJNI + public byte[] keyId; + + @WrapForJNI + public int status; + + @WrapForJNI + public SessionKeyInfo(byte[] keyId, int status) { + this.keyId = keyId; + this.status = status; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int parcelableFlags) { + dest.writeByteArray(keyId); + dest.writeInt(status); + } + + public static final Creator<SessionKeyInfo> CREATOR = new Creator<SessionKeyInfo>() { + @Override + public SessionKeyInfo createFromParcel(Parcel in) { + return new SessionKeyInfo(in); + } + + @Override + public SessionKeyInfo[] newArray(int size) { + return new SessionKeyInfo[size]; + } + }; + + private SessionKeyInfo(Parcel src) { + keyId = src.createByteArray(); + status = src.readInt(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java b/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java new file mode 100644 index 000000000..508b9d015 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java @@ -0,0 +1,204 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.media; + +import android.content.Context; + +import android.graphics.Color; + +import android.net.Uri; + +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import android.widget.ImageButton; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.MediaController; +import android.widget.VideoView; + +import org.mozilla.gecko.R; + +public class VideoPlayer extends FrameLayout { + private VideoView video; + private FullScreenMediaController controller; + private FullScreenListener fullScreenListener; + + private boolean isFullScreen; + + public VideoPlayer(Context ctx) { + this(ctx, null); + } + + public VideoPlayer(Context ctx, AttributeSet attrs) { + this(ctx, attrs, 0); + } + + public VideoPlayer(Context ctx, AttributeSet attrs, int defStyle) { + super(ctx, attrs, defStyle); + setFullScreen(false); + setVisibility(View.GONE); + } + + public void start(Uri uri) { + stop(); + + video = new VideoView(getContext()); + controller = new FullScreenMediaController(getContext()); + video.setMediaController(controller); + controller.setAnchorView(video); + + video.setVideoURI(uri); + + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER); + + addView(video, layoutParams); + setVisibility(View.VISIBLE); + + video.setZOrderOnTop(true); + video.start(); + } + + public boolean isPlaying() { + return video != null; + } + + public void stop() { + if (video == null) { + return; + } + + removeAllViews(); + setVisibility(View.GONE); + video.stopPlayback(); + + video = null; + controller = null; + } + + public void setFullScreenListener(FullScreenListener listener) { + fullScreenListener = listener; + } + + public boolean isFullScreen() { + return isFullScreen; + } + + public void setFullScreen(boolean fullScreen) { + isFullScreen = fullScreen; + if (fullScreen) { + setBackgroundColor(Color.BLACK); + } else { + setBackgroundResource(R.color.dark_transparent_overlay); + } + + if (controller != null) { + controller.setFullScreen(fullScreen); + } + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (event.isSystem()) { + return super.onKeyDown(keyCode, event); + } + return true; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (event.isSystem()) { + return super.onKeyUp(keyCode, event); + } + return true; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + super.onTouchEvent(event); + return true; + } + + @Override + public boolean onTrackballEvent(MotionEvent event) { + super.onTrackballEvent(event); + return true; + } + + public interface FullScreenListener { + void onFullScreenChanged(boolean fullScreen); + } + + private class FullScreenMediaController extends MediaController { + private ImageButton mButton; + + public FullScreenMediaController(Context ctx) { + super(ctx); + + mButton = new ImageButton(getContext()); + mButton.setScaleType(ImageView.ScaleType.FIT_CENTER); + mButton.setBackgroundColor(Color.TRANSPARENT); + mButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + FullScreenMediaController.this.onFullScreenClicked(); + } + }); + + updateFullScreenButton(false); + } + + public void setFullScreen(boolean fullScreen) { + updateFullScreenButton(fullScreen); + } + + private void updateFullScreenButton(boolean fullScreen) { + mButton.setImageResource(fullScreen ? R.drawable.exit_fullscreen : R.drawable.fullscreen); + } + + private void onFullScreenClicked() { + if (VideoPlayer.this.fullScreenListener != null) { + boolean fullScreen = !VideoPlayer.this.isFullScreen(); + VideoPlayer.this.fullScreenListener.onFullScreenChanged(fullScreen); + } + } + + @Override + public void setAnchorView(final View view) { + super.setAnchorView(view); + + // Add the fullscreen button here because this is where the parent class actually creates + // the media buttons and their layout. + // + // http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/widget/MediaController.java#239 + // + // The media buttons are in a horizontal linear layout which is itself packed into + // a vertical layout. The vertical layout is the only child of the FrameLayout which + // MediaController inherits from. + LinearLayout child = (LinearLayout) getChildAt(0); + LinearLayout buttons = (LinearLayout) child.getChildAt(0); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.FILL_PARENT); + params.gravity = Gravity.CENTER_VERTICAL; + + if (mButton.getParent() != null) { + ((ViewGroup)mButton.getParent()).removeView(mButton); + } + + buttons.addView(mButton, params); + } + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java new file mode 100644 index 000000000..512f32002 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java @@ -0,0 +1,928 @@ +/* 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.menu; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; +import org.mozilla.gecko.widget.GeckoActionProvider; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.util.AttributeSet; +import android.util.Log; +import android.util.SparseArray; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.SubMenu; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.LinearLayout; +import android.widget.ListView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class GeckoMenu extends ListView + implements Menu, + AdapterView.OnItemClickListener, + GeckoMenuItem.OnShowAsActionChangedListener { + private static final String LOGTAG = "GeckoMenu"; + + /** + * Controls whether off-UI-thread method calls in this class cause an + * exception or just logging. + */ + private static final AssertBehavior THREAD_ASSERT_BEHAVIOR = AppConstants.RELEASE_OR_BETA ? AssertBehavior.NONE : AssertBehavior.THROW; + + /* + * A callback for a menu item click/long click event. + */ + public static interface Callback { + // Called when a menu item is clicked, with the actual menu item as the argument. + public boolean onMenuItemClick(MenuItem item); + + // Called when a menu item is long-clicked, with the actual menu item as the argument. + public boolean onMenuItemLongClick(MenuItem item); + } + + /* + * An interface for a presenter to show the menu. + * Either an Activity or a View can be a presenter, that can watch for events + * and show/hide menu. + */ + public static interface MenuPresenter { + // Open the menu. + public void openMenu(); + + // Show the actual view containing the menu items. This can either be a parent or sub-menu. + public void showMenu(View menu); + + // Close the menu. + public void closeMenu(); + } + + /* + * An interface for a presenter of action-items. + * Either an Activity or a View can be a presenter, that can watch for events + * and add/remove action-items. If not ActionItemBarPresenter, the menu uses a + * DefaultActionItemBar, that shows the action-items as a header over list-view. + */ + public static interface ActionItemBarPresenter { + // Add an action-item. + public boolean addActionItem(View actionItem); + + // Remove an action-item. + public void removeActionItem(View actionItem); + } + + protected static final int NO_ID = 0; + + // List of all menu items. + private final List<GeckoMenuItem> mItems; + + // Quick lookup array used to make a fast path in findItem. + private final SparseArray<MenuItem> mItemsById; + + // Map of "always" action-items in action-bar and their views. + private final Map<GeckoMenuItem, View> mPrimaryActionItems; + + // Map of "ifRoom" action-items in action-bar and their views. + private final Map<GeckoMenuItem, View> mSecondaryActionItems; + + // Map of "collapseActionView" action-items in action-bar and their views. + private final Map<GeckoMenuItem, View> mQuickShareActionItems; + + // Reference to a callback for menu events. + private Callback mCallback; + + // Reference to menu presenter. + private MenuPresenter mMenuPresenter; + + // Reference to "always" action-items bar in action-bar. + private ActionItemBarPresenter mPrimaryActionItemBar; + + // Reference to "ifRoom" action-items bar in action-bar. + private final ActionItemBarPresenter mSecondaryActionItemBar; + + // Reference to "collapseActionView" action-items bar in action-bar. + private final ActionItemBarPresenter mQuickShareActionItemBar; + + // Adapter to hold the list of menu items. + private final MenuItemsAdapter mAdapter; + + // Show/hide icons in the list. + boolean mShowIcons; + + public GeckoMenu(Context context) { + this(context, null); + } + + public GeckoMenu(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.geckoMenuListViewStyle); + } + + public GeckoMenu(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + + // Attach an adapter. + mAdapter = new MenuItemsAdapter(); + setAdapter(mAdapter); + setOnItemClickListener(this); + + mItems = new ArrayList<GeckoMenuItem>(); + mItemsById = new SparseArray<MenuItem>(); + mPrimaryActionItems = new HashMap<GeckoMenuItem, View>(); + mSecondaryActionItems = new HashMap<GeckoMenuItem, View>(); + mQuickShareActionItems = new HashMap<GeckoMenuItem, View>(); + + mPrimaryActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_action_bar, null); + mSecondaryActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_secondary_action_bar, null); + mQuickShareActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_secondary_action_bar, null); + } + + private static void assertOnUiThread() { + ThreadUtils.assertOnUiThread(THREAD_ASSERT_BEHAVIOR); + } + + @Override + public MenuItem add(CharSequence title) { + GeckoMenuItem menuItem = new GeckoMenuItem(this, NO_ID, 0, title); + addItem(menuItem); + return menuItem; + } + + @Override + public MenuItem add(int groupId, int itemId, int order, int titleRes) { + GeckoMenuItem menuItem = new GeckoMenuItem(this, itemId, order, titleRes); + addItem(menuItem); + return menuItem; + } + + @Override + public MenuItem add(int titleRes) { + GeckoMenuItem menuItem = new GeckoMenuItem(this, NO_ID, 0, titleRes); + addItem(menuItem); + return menuItem; + } + + @Override + public MenuItem add(int groupId, int itemId, int order, CharSequence title) { + GeckoMenuItem menuItem = new GeckoMenuItem(this, itemId, order, title); + addItem(menuItem); + return menuItem; + } + + private void addItem(GeckoMenuItem menuItem) { + assertOnUiThread(); + menuItem.setOnShowAsActionChangedListener(this); + mAdapter.addMenuItem(menuItem); + mItems.add(menuItem); + } + + private boolean addActionItem(final GeckoMenuItem menuItem) { + assertOnUiThread(); + menuItem.setOnShowAsActionChangedListener(this); + + final View actionView = menuItem.getActionView(); + final int actionEnum = menuItem.getActionEnum(); + boolean added = false; + + if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_ALWAYS) { + if (mPrimaryActionItems.size() == 0 && + mPrimaryActionItemBar instanceof DefaultActionItemBar) { + // Reset the adapter before adding the header view to a list. + setAdapter(null); + addHeaderView((DefaultActionItemBar) mPrimaryActionItemBar); + setAdapter(mAdapter); + } + + if (added = mPrimaryActionItemBar.addActionItem(actionView)) { + mPrimaryActionItems.put(menuItem, actionView); + mItems.add(menuItem); + } + } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_IF_ROOM) { + if (mSecondaryActionItems.size() == 0) { + // Reset the adapter before adding the header view to a list. + setAdapter(null); + addHeaderView((DefaultActionItemBar) mSecondaryActionItemBar); + setAdapter(mAdapter); + } + + if (added = mSecondaryActionItemBar.addActionItem(actionView)) { + mSecondaryActionItems.put(menuItem, actionView); + mItems.add(menuItem); + } + } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) { + if (actionView instanceof MenuItemSwitcherLayout) { + final MenuItemSwitcherLayout quickShareView = (MenuItemSwitcherLayout) actionView; + + // We don't want to add the quick share bar if we don't have any quick share items. + if (quickShareView.getActionButtonCount() > 0 && + (added = mQuickShareActionItemBar.addActionItem(quickShareView))) { + if (mQuickShareActionItems.size() == 0) { + // Reset the adapter before adding the header view to a list. + setAdapter(null); + addHeaderView((DefaultActionItemBar) mQuickShareActionItemBar); + setAdapter(mAdapter); + } + + mQuickShareActionItems.put(menuItem, quickShareView); + mItems.add(menuItem); + } + } + } + + // Set the listeners. + if (actionView instanceof MenuItemActionBar) { + ((MenuItemActionBar) actionView).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + handleMenuItemClick(menuItem); + } + }); + ((MenuItemActionBar) actionView).setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + if (handleMenuItemLongClick(menuItem)) { + GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec)); + return true; + } + return false; + } + }); + } else if (actionView instanceof MenuItemSwitcherLayout) { + ((MenuItemSwitcherLayout) actionView).setMenuItemClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + handleMenuItemClick(menuItem); + } + }); + ((MenuItemSwitcherLayout) actionView).setMenuItemLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + if (handleMenuItemLongClick(menuItem)) { + GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec)); + return true; + } + return false; + } + }); + } + + return added; + } + + @Override + public int addIntentOptions(int groupId, int itemId, int order, ComponentName caller, Intent[] specifics, Intent intent, int flags, MenuItem[] outSpecificItems) { + return 0; + } + + @Override + public SubMenu addSubMenu(int groupId, int itemId, int order, CharSequence title) { + MenuItem menuItem = add(groupId, itemId, order, title); + return addSubMenu(menuItem); + } + + @Override + public SubMenu addSubMenu(int groupId, int itemId, int order, int titleRes) { + MenuItem menuItem = add(groupId, itemId, order, titleRes); + return addSubMenu(menuItem); + } + + @Override + public SubMenu addSubMenu(CharSequence title) { + MenuItem menuItem = add(title); + return addSubMenu(menuItem); + } + + @Override + public SubMenu addSubMenu(int titleRes) { + MenuItem menuItem = add(titleRes); + return addSubMenu(menuItem); + } + + private SubMenu addSubMenu(MenuItem menuItem) { + GeckoSubMenu subMenu = new GeckoSubMenu(getContext()); + subMenu.setMenuItem(menuItem); + subMenu.setCallback(mCallback); + subMenu.setMenuPresenter(mMenuPresenter); + ((GeckoMenuItem) menuItem).setSubMenu(subMenu); + return subMenu; + } + + private void removePrimaryActionBarView() { + // Reset the adapter before removing the header view from a list. + setAdapter(null); + removeHeaderView((DefaultActionItemBar) mPrimaryActionItemBar); + setAdapter(mAdapter); + } + + private void removeSecondaryActionBarView() { + // Reset the adapter before removing the header view from a list. + setAdapter(null); + removeHeaderView((DefaultActionItemBar) mSecondaryActionItemBar); + setAdapter(mAdapter); + } + + private void removeQuickShareActionBarView() { + // Reset the adapter before removing the header view from a list. + setAdapter(null); + removeHeaderView((DefaultActionItemBar) mQuickShareActionItemBar); + setAdapter(mAdapter); + } + + @Override + public void clear() { + assertOnUiThread(); + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.hasSubMenu()) { + SubMenu sub = menuItem.getSubMenu(); + if (sub == null) { + continue; + } + try { + sub.clear(); + } catch (Exception ex) { + Log.e(LOGTAG, "Couldn't clear submenu.", ex); + } + } + } + + mAdapter.clear(); + mItems.clear(); + + /* + * Reinflating the menu will re-add any action items to the toolbar, so + * remove the old ones. This also ensures that any text associated with + * these is switched to the correct locale. + */ + if (mPrimaryActionItemBar != null) { + for (View item : mPrimaryActionItems.values()) { + mPrimaryActionItemBar.removeActionItem(item); + } + } + mPrimaryActionItems.clear(); + + if (mSecondaryActionItemBar != null) { + for (View item : mSecondaryActionItems.values()) { + mSecondaryActionItemBar.removeActionItem(item); + } + } + mSecondaryActionItems.clear(); + + if (mQuickShareActionItemBar != null) { + for (View item : mQuickShareActionItems.values()) { + mQuickShareActionItemBar.removeActionItem(item); + } + } + mQuickShareActionItems.clear(); + + // Remove the view, too -- the first addActionItem will re-add it, + // and this is simpler than changing that logic. + if (mPrimaryActionItemBar instanceof DefaultActionItemBar) { + removePrimaryActionBarView(); + } + + removeSecondaryActionBarView(); + removeQuickShareActionBarView(); + } + + @Override + public void close() { + if (mMenuPresenter != null) + mMenuPresenter.closeMenu(); + } + + private void showMenu(View viewForMenu) { + if (mMenuPresenter != null) + mMenuPresenter.showMenu(viewForMenu); + } + + @Override + public MenuItem findItem(int id) { + assertOnUiThread(); + MenuItem quickItem = mItemsById.get(id); + if (quickItem != null) { + return quickItem; + } + + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.getItemId() == id) { + mItemsById.put(id, menuItem); + return menuItem; + } else if (menuItem.hasSubMenu()) { + if (!menuItem.hasActionProvider()) { + SubMenu subMenu = menuItem.getSubMenu(); + MenuItem item = subMenu.findItem(id); + if (item != null) { + mItemsById.put(id, item); + return item; + } + } + } + } + return null; + } + + @Override + public MenuItem getItem(int index) { + if (index < mItems.size()) + return mItems.get(index); + + return null; + } + + @Override + public boolean hasVisibleItems() { + assertOnUiThread(); + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.isVisible() && + !mPrimaryActionItems.containsKey(menuItem) && + !mSecondaryActionItems.containsKey(menuItem) && + !mQuickShareActionItems.containsKey(menuItem)) + return true; + } + + return false; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Close the menu if it is open and the hardware menu key is pressed. + if (keyCode == KeyEvent.KEYCODE_MENU && isShown()) { + close(); + return true; + } + + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean isShortcutKey(int keyCode, KeyEvent event) { + return true; + } + + @Override + public boolean performIdentifierAction(int id, int flags) { + return false; + } + + @Override + public boolean performShortcut(int keyCode, KeyEvent event, int flags) { + return false; + } + + @Override + public void removeGroup(int groupId) { + } + + @Override + public void removeItem(int id) { + assertOnUiThread(); + GeckoMenuItem item = (GeckoMenuItem) findItem(id); + if (item == null) + return; + + // Remove it from the cache. + mItemsById.remove(id); + + // Remove it from any sub-menu. + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.hasSubMenu()) { + SubMenu subMenu = menuItem.getSubMenu(); + if (subMenu != null && subMenu.findItem(id) != null) { + subMenu.removeItem(id); + return; + } + } + } + + // Remove it from own menu. + if (mPrimaryActionItems.containsKey(item)) { + if (mPrimaryActionItemBar != null) + mPrimaryActionItemBar.removeActionItem(mPrimaryActionItems.get(item)); + + mPrimaryActionItems.remove(item); + mItems.remove(item); + + if (mPrimaryActionItems.size() == 0 && + mPrimaryActionItemBar instanceof DefaultActionItemBar) { + removePrimaryActionBarView(); + } + + return; + } + + if (mSecondaryActionItems.containsKey(item)) { + if (mSecondaryActionItemBar != null) + mSecondaryActionItemBar.removeActionItem(mSecondaryActionItems.get(item)); + + mSecondaryActionItems.remove(item); + mItems.remove(item); + + if (mSecondaryActionItems.size() == 0) { + removeSecondaryActionBarView(); + } + + return; + } + + if (mQuickShareActionItems.containsKey(item)) { + if (mQuickShareActionItemBar != null) + mQuickShareActionItemBar.removeActionItem(mQuickShareActionItems.get(item)); + + mQuickShareActionItems.remove(item); + mItems.remove(item); + + if (mQuickShareActionItems.size() == 0) { + removeQuickShareActionBarView(); + } + + return; + } + + mAdapter.removeMenuItem(item); + mItems.remove(item); + } + + @Override + public void setGroupCheckable(int group, boolean checkable, boolean exclusive) { + } + + @Override + public void setGroupEnabled(int group, boolean enabled) { + } + + @Override + public void setGroupVisible(int group, boolean visible) { + } + + @Override + public void setQwertyMode(boolean isQwerty) { + } + + @Override + public int size() { + return mItems.size(); + } + + @Override + public boolean hasActionItemBar() { + return (mPrimaryActionItemBar != null) && + (mSecondaryActionItemBar != null) && + (mQuickShareActionItemBar != null); + } + + @Override + public void onShowAsActionChanged(GeckoMenuItem item) { + removeItem(item.getItemId()); + + if (item.isActionItem() && addActionItem(item)) { + return; + } + + addItem(item); + } + + public void onItemChanged(GeckoMenuItem item) { + assertOnUiThread(); + if (item.isActionItem()) { + final View actionView; + final int actionEnum = item.getActionEnum(); + if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_ALWAYS) { + actionView = mPrimaryActionItems.get(item); + } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_IF_ROOM) { + actionView = mSecondaryActionItems.get(item); + } else { + actionView = mQuickShareActionItems.get(item); + } + + if (actionView != null) { + if (item.isVisible()) { + actionView.setVisibility(View.VISIBLE); + if (actionView instanceof MenuItemActionBar) { + ((MenuItemActionBar) actionView).initialize(item); + } else { + ((MenuItemSwitcherLayout) actionView).initialize(item); + } + } else { + actionView.setVisibility(View.GONE); + } + } + } else { + mAdapter.notifyDataSetChanged(); + } + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + // We might be showing headers. Account them while using the position. + position -= getHeaderViewsCount(); + + GeckoMenuItem item = mAdapter.getItem(position); + handleMenuItemClick(item); + } + + void handleMenuItemClick(GeckoMenuItem item) { + if (!item.isEnabled()) + return; + + if (item.invoke()) { + close(); + } else if (item.hasSubMenu()) { + // Refresh the submenu for the provider. + GeckoActionProvider provider = item.getGeckoActionProvider(); + if (provider != null) { + GeckoSubMenu subMenu = new GeckoSubMenu(getContext()); + subMenu.setShowIcons(true); + provider.onPrepareSubMenu(subMenu); + item.setSubMenu(subMenu); + } + + // Show the submenu. + GeckoSubMenu subMenu = (GeckoSubMenu) item.getSubMenu(); + showMenu(subMenu); + } else { + close(); + mCallback.onMenuItemClick(item); + } + } + + boolean handleMenuItemLongClick(GeckoMenuItem item) { + if (!item.isEnabled()) { + return false; + } + + if (mCallback != null) { + if (mCallback.onMenuItemLongClick(item)) { + close(); + return true; + } + } + return false; + } + + public Callback getCallback() { + return mCallback; + } + + public MenuPresenter getMenuPresenter() { + return mMenuPresenter; + } + + public void setCallback(Callback callback) { + mCallback = callback; + + // Update the submenus just in case this changes on the fly. + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.hasSubMenu()) { + GeckoSubMenu subMenu = (GeckoSubMenu) menuItem.getSubMenu(); + subMenu.setCallback(mCallback); + } + } + } + + public void setMenuPresenter(MenuPresenter presenter) { + mMenuPresenter = presenter; + + // Update the submenus just in case this changes on the fly. + for (GeckoMenuItem menuItem : mItems) { + if (menuItem.hasSubMenu()) { + GeckoSubMenu subMenu = (GeckoSubMenu) menuItem.getSubMenu(); + subMenu.setMenuPresenter(mMenuPresenter); + } + } + } + + public void setActionItemBarPresenter(ActionItemBarPresenter presenter) { + mPrimaryActionItemBar = presenter; + } + + public void setShowIcons(boolean show) { + if (mShowIcons != show) { + mShowIcons = show; + mAdapter.notifyDataSetChanged(); + } + } + + // Action Items are added to the header view by default. + // URL bar can register itself as a presenter, in case it has a different place to show them. + public static class DefaultActionItemBar extends LinearLayout + implements ActionItemBarPresenter { + private final int mRowHeight; + private float mWeightSum; + + public DefaultActionItemBar(Context context) { + this(context, null); + } + + public DefaultActionItemBar(Context context, AttributeSet attrs) { + super(context, attrs); + + mRowHeight = getResources().getDimensionPixelSize(R.dimen.menu_item_row_height); + } + + @Override + public boolean addActionItem(View actionItem) { + ViewGroup.LayoutParams actualParams = actionItem.getLayoutParams(); + LinearLayout.LayoutParams params; + + if (actualParams != null) { + params = new LinearLayout.LayoutParams(actionItem.getLayoutParams()); + params.width = 0; + } else { + params = new LinearLayout.LayoutParams(0, mRowHeight); + } + + if (actionItem instanceof MenuItemSwitcherLayout) { + params.weight = ((MenuItemSwitcherLayout) actionItem).getChildCount(); + } else { + params.weight = 1.0f; + } + + mWeightSum += params.weight; + + actionItem.setLayoutParams(params); + addView(actionItem); + setWeightSum(mWeightSum); + return true; + } + + @Override + public void removeActionItem(View actionItem) { + if (indexOfChild(actionItem) != -1) { + LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) actionItem.getLayoutParams(); + mWeightSum -= params.weight; + removeView(actionItem); + } + } + } + + // Adapter to bind menu items to the list. + private class MenuItemsAdapter extends BaseAdapter { + private static final int VIEW_TYPE_DEFAULT = 0; + private static final int VIEW_TYPE_ACTION_MODE = 1; + + private final List<GeckoMenuItem> mItems; + + public MenuItemsAdapter() { + mItems = new ArrayList<GeckoMenuItem>(); + } + + @Override + public int getCount() { + if (mItems == null) + return 0; + + int visibleCount = 0; + for (GeckoMenuItem item : mItems) { + if (item.isVisible()) + visibleCount++; + } + + return visibleCount; + } + + @Override + public GeckoMenuItem getItem(int position) { + for (GeckoMenuItem item : mItems) { + if (item.isVisible()) { + position--; + + if (position < 0) + return item; + } + } + + return null; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + GeckoMenuItem item = getItem(position); + GeckoMenuItem.Layout view = null; + + // Try to re-use the view. + if (convertView == null && getItemViewType(position) == VIEW_TYPE_DEFAULT) { + view = new MenuItemDefault(parent.getContext(), null); + } else { + view = (GeckoMenuItem.Layout) convertView; + } + + if (view == null || view instanceof MenuItemSwitcherLayout) { + // Always get from the menu item. + // This will ensure that the default activity is refreshed. + view = (MenuItemSwitcherLayout) item.getActionView(); + + // ListView will not perform an item click if the row has a focusable view in it. + // Hence, forward the click event on the menu item in the action-view to the ListView. + final View actionView = (View) view; + final int pos = position; + final long id = getItemId(position); + ((MenuItemSwitcherLayout) view).setMenuItemClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + GeckoMenu listView = GeckoMenu.this; + listView.performItemClick(actionView, pos + listView.getHeaderViewsCount(), id); + } + }); + } + + // Initialize the view. + view.setShowIcon(mShowIcons); + view.initialize(item); + return (View) view; + } + + @Override + public int getItemViewType(int position) { + return getItem(position).getGeckoActionProvider() == null ? VIEW_TYPE_DEFAULT : VIEW_TYPE_ACTION_MODE; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public boolean hasStableIds() { + return false; + } + + @Override + public boolean areAllItemsEnabled() { + // Setting this to true is a workaround to fix disappearing + // dividers in the menu (bug 963249). + return true; + } + + @Override + public boolean isEnabled(int position) { + // Setting this to true is a workaround to fix disappearing + // dividers in the menu in L (bug 1050780). + return true; + } + + public void addMenuItem(GeckoMenuItem menuItem) { + if (mItems.contains(menuItem)) + return; + + // Insert it in proper order. + int index = 0; + for (GeckoMenuItem item : mItems) { + if (item.getOrder() > menuItem.getOrder()) { + mItems.add(index, menuItem); + notifyDataSetChanged(); + return; + } else { + index++; + } + } + + // Add the menuItem at the end. + mItems.add(menuItem); + notifyDataSetChanged(); + } + + public void removeMenuItem(GeckoMenuItem menuItem) { + // Remove it from the list. + mItems.remove(menuItem); + notifyDataSetChanged(); + } + + public void clear() { + mItemsById.clear(); + mItems.clear(); + notifyDataSetChanged(); + } + + public GeckoMenuItem getMenuItem(int id) { + for (GeckoMenuItem item : mItems) { + if (item.getItemId() == id) + return item; + } + + return null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java new file mode 100644 index 000000000..dfcb31c5f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java @@ -0,0 +1,163 @@ +/* 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.menu; + +import java.io.IOException; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import android.content.Context; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.util.AttributeSet; +import android.util.Xml; +import android.view.InflateException; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.SubMenu; + +public class GeckoMenuInflater extends MenuInflater { + private static final String TAG_MENU = "menu"; + private static final String TAG_ITEM = "item"; + private static final int NO_ID = 0; + + private final Context mContext; + + // Private class to hold the parsed menu item. + private class ParsedItem { + public int id; + public int order; + public CharSequence title; + public int iconRes; + public boolean checkable; + public boolean checked; + public boolean visible; + public boolean enabled; + public int showAsAction; + public boolean hasSubMenu; + } + + public GeckoMenuInflater(Context context) { + super(context); + mContext = context; + } + + @Override + public void inflate(int menuRes, Menu menu) { + + // This does not check for a well-formed XML. + + XmlResourceParser parser = null; + try { + parser = mContext.getResources().getXml(menuRes); + AttributeSet attrs = Xml.asAttributeSet(parser); + + parseMenu(parser, attrs, menu); + + } catch (XmlPullParserException | IOException e) { + throw new InflateException("Error inflating menu XML", e); + } finally { + if (parser != null) + parser.close(); + } + } + + private void parseMenu(XmlResourceParser parser, AttributeSet attrs, Menu menu) + throws XmlPullParserException, IOException { + ParsedItem item = null; + + String tag; + int eventType = parser.getEventType(); + + do { + tag = parser.getName(); + + switch (eventType) { + case XmlPullParser.START_TAG: + if (tag.equals(TAG_ITEM)) { + // Parse the menu item. + item = new ParsedItem(); + parseItem(item, attrs); + } else if (tag.equals(TAG_MENU)) { + if (item != null) { + // Add the submenu item. + SubMenu subMenu = menu.addSubMenu(NO_ID, item.id, item.order, item.title); + item.hasSubMenu = true; + + // Set the menu item in main menu. + MenuItem menuItem = subMenu.getItem(); + setValues(item, menuItem); + + // Start parsing the sub menu. + parseMenu(parser, attrs, subMenu); + } + } + break; + + case XmlPullParser.END_TAG: + if (parser.getName().equals(TAG_ITEM)) { + if (!item.hasSubMenu) { + // Add the item. + MenuItem menuItem = menu.add(NO_ID, item.id, item.order, item.title); + setValues(item, menuItem); + } + } else if (tag.equals(TAG_MENU)) { + return; + } + break; + } + + eventType = parser.next(); + + } while (eventType != XmlPullParser.END_DOCUMENT); + } + + public void parseItem(ParsedItem item, AttributeSet attrs) { + TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.MenuItem); + + item.id = a.getResourceId(R.styleable.MenuItem_android_id, NO_ID); + item.order = a.getInt(R.styleable.MenuItem_android_orderInCategory, 0); + item.title = a.getText(R.styleable.MenuItem_android_title); + item.checkable = a.getBoolean(R.styleable.MenuItem_android_checkable, false); + item.checked = a.getBoolean(R.styleable.MenuItem_android_checked, false); + item.visible = a.getBoolean(R.styleable.MenuItem_android_visible, true); + item.enabled = a.getBoolean(R.styleable.MenuItem_android_enabled, true); + item.hasSubMenu = false; + item.iconRes = a.getResourceId(R.styleable.MenuItem_android_icon, 0); + item.showAsAction = a.getInt(R.styleable.MenuItem_android_showAsAction, 0); + + a.recycle(); + } + + public void setValues(ParsedItem item, MenuItem menuItem) { + // We are blocking any presenter updates during inflation. + GeckoMenuItem geckoItem = null; + if (menuItem instanceof GeckoMenuItem) { + geckoItem = (GeckoMenuItem) menuItem; + } + + if (geckoItem != null) { + geckoItem.stopDispatchingChanges(); + } + + menuItem.setChecked(item.checked) + .setVisible(item.visible) + .setEnabled(item.enabled) + .setCheckable(item.checkable) + .setIcon(item.iconRes); + + menuItem.setShowAsAction(item.showAsAction); + + if (geckoItem != null) { + // We don't need to allow presenter updates during inflation, + // so we use the weak form of re-enabling changes. + geckoItem.resumeDispatchingChanges(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java new file mode 100644 index 000000000..21df4208d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java @@ -0,0 +1,472 @@ +/* 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.menu; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.widget.GeckoActionProvider; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.ActionProvider; +import android.view.ContextMenu; +import android.view.MenuItem; +import android.view.SubMenu; +import android.view.View; + +public class GeckoMenuItem implements MenuItem { + private static final int SHARE_BAR_HISTORY_SIZE = 2; + + // These values mirror MenuItem values that are only available on API >= 11. + public static final int SHOW_AS_ACTION_NEVER = 0; + public static final int SHOW_AS_ACTION_IF_ROOM = 1; + public static final int SHOW_AS_ACTION_ALWAYS = 2; + public static final int SHOW_AS_ACTION_WITH_TEXT = 4; + public static final int SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW = 8; + + // A View that can show a MenuItem should be able to initialize from + // the properties of the MenuItem. + public static interface Layout { + public void initialize(GeckoMenuItem item); + public void setShowIcon(boolean show); + } + + public static interface OnShowAsActionChangedListener { + public boolean hasActionItemBar(); + public void onShowAsActionChanged(GeckoMenuItem item); + } + + private final int mId; + private final int mOrder; + private View mActionView; + private int mActionEnum; + private CharSequence mTitle; + private CharSequence mTitleCondensed; + private boolean mCheckable; + private boolean mChecked; + private boolean mVisible = true; + private boolean mEnabled = true; + private Drawable mIcon; + private int mIconRes; + private GeckoActionProvider mActionProvider; + private GeckoSubMenu mSubMenu; + private MenuItem.OnMenuItemClickListener mMenuItemClickListener; + final GeckoMenu mMenu; + OnShowAsActionChangedListener mShowAsActionChangedListener; + + private volatile boolean mShouldDispatchChanges = true; + private volatile boolean mDidChange; + + public GeckoMenuItem(GeckoMenu menu, int id, int order, int titleRes) { + mMenu = menu; + mId = id; + mOrder = order; + mTitle = mMenu.getResources().getString(titleRes); + } + + public GeckoMenuItem(GeckoMenu menu, int id, int order, CharSequence title) { + mMenu = menu; + mId = id; + mOrder = order; + mTitle = title; + } + + /** + * Stop dispatching item changed events to presenters until + * [start|resume]DispatchingItemsChanged() is called. Useful when + * many menu operations are going to be performed as a batch. + */ + public void stopDispatchingChanges() { + mDidChange = false; + mShouldDispatchChanges = false; + } + + /** + * Resume dispatching item changed events to presenters. This method + * will NOT call onItemChanged if any menu operations were queued. + * Only future menu operations will call onItemChanged. Useful for + * sequelching presenter updates. + */ + public void resumeDispatchingChanges() { + mShouldDispatchChanges = true; + } + + /** + * Start dispatching item changed events to presenters. This method + * will call onItemChanged if any menu operations were queued. + */ + public void startDispatchingChanges() { + if (mDidChange) { + mMenu.onItemChanged(this); + } + mShouldDispatchChanges = true; + } + + @Override + public boolean collapseActionView() { + return false; + } + + @Override + public boolean expandActionView() { + return false; + } + + public boolean hasActionProvider() { + return (mActionProvider != null); + } + + public int getActionEnum() { + return mActionEnum; + } + + public GeckoActionProvider getGeckoActionProvider() { + return mActionProvider; + } + + @Override + public ActionProvider getActionProvider() { + return null; + } + + @Override + public View getActionView() { + if (mActionProvider != null) { + return mActionProvider.onCreateActionView(SHARE_BAR_HISTORY_SIZE, + GeckoActionProvider.ActionViewType.DEFAULT); + } + + return mActionView; + } + + @Override + public char getAlphabeticShortcut() { + return 0; + } + + @Override + public int getGroupId() { + return 0; + } + + @Override + public Drawable getIcon() { + if (mIcon == null) { + if (mIconRes != 0) + return ResourceDrawableUtils.getDrawable(mMenu.getContext(), mIconRes); + else + return null; + } else { + return mIcon; + } + } + + @Override + public Intent getIntent() { + return null; + } + + @Override + public int getItemId() { + return mId; + } + + @Override + public ContextMenu.ContextMenuInfo getMenuInfo() { + return null; + } + + @Override + public char getNumericShortcut() { + return 0; + } + + @Override + public int getOrder() { + return mOrder; + } + + @Override + public SubMenu getSubMenu() { + return mSubMenu; + } + + @Override + public CharSequence getTitle() { + return mTitle; + } + + @Override + public CharSequence getTitleCondensed() { + return mTitleCondensed; + } + + @Override + public boolean hasSubMenu() { + if (mActionProvider != null) + return mActionProvider.hasSubMenu(); + + return (mSubMenu != null); + } + + public boolean isActionItem() { + return (mActionEnum > 0); + } + + @Override + public boolean isActionViewExpanded() { + return false; + } + + @Override + public boolean isCheckable() { + return mCheckable; + } + + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public boolean isEnabled() { + return mEnabled; + } + + @Override + public boolean isVisible() { + return mVisible; + } + + @Override + public MenuItem setActionProvider(ActionProvider actionProvider) { + return this; + } + + public MenuItem setActionProvider(GeckoActionProvider actionProvider) { + mActionProvider = actionProvider; + if (mActionProvider != null) { + actionProvider.setOnTargetSelectedListener(new GeckoActionProvider.OnTargetSelectedListener() { + @Override + public void onTargetSelected() { + mMenu.close(); + + // Refresh the menu item to show the high frequency apps. + mShowAsActionChangedListener.onShowAsActionChanged(GeckoMenuItem.this); + } + }); + } + + mShowAsActionChangedListener.onShowAsActionChanged(this); + return this; + } + + @Override + public MenuItem setActionView(int resId) { + return this; + } + + @Override + public MenuItem setActionView(View view) { + return this; + } + + @Override + public MenuItem setAlphabeticShortcut(char alphaChar) { + return this; + } + + @Override + public MenuItem setCheckable(boolean checkable) { + if (mCheckable != checkable) { + mCheckable = checkable; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setChecked(boolean checked) { + if (mChecked != checked) { + mChecked = checked; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setEnabled(boolean enabled) { + if (mEnabled != enabled) { + mEnabled = enabled; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setIcon(Drawable icon) { + if (mIcon != icon) { + mIcon = icon; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setIcon(int iconRes) { + if (mIconRes != iconRes) { + mIconRes = iconRes; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setIntent(Intent intent) { + return this; + } + + @Override + public MenuItem setNumericShortcut(char numericChar) { + return this; + } + + @Override + public MenuItem setOnActionExpandListener(MenuItem.OnActionExpandListener listener) { + return this; + } + + @Override + public MenuItem setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener menuItemClickListener) { + mMenuItemClickListener = menuItemClickListener; + return this; + } + + @Override + public MenuItem setShortcut(char numericChar, char alphaChar) { + return this; + } + + @Override + public void setShowAsAction(int actionEnum) { + setShowAsAction(actionEnum, 0); + } + + public void setShowAsAction(int actionEnum, int style) { + if (mShowAsActionChangedListener == null) + return; + + if (mActionEnum == actionEnum) + return; + + if (actionEnum > 0) { + if (!mShowAsActionChangedListener.hasActionItemBar()) + return; + + if (!hasActionProvider()) { + // Change the type to just an icon + MenuItemActionBar actionView; + if (style != 0) { + actionView = new MenuItemActionBar(mMenu.getContext(), null, style); + } else { + if (actionEnum == SHOW_AS_ACTION_ALWAYS) { + actionView = new MenuItemActionBar(mMenu.getContext()); + } else { + actionView = new MenuItemActionBar(mMenu.getContext(), null, R.attr.menuItemSecondaryActionBarStyle); + } + } + + actionView.initialize(this); + mActionView = actionView; + } + + mActionEnum = actionEnum; + } + + mShowAsActionChangedListener.onShowAsActionChanged(this); + } + + @Override + public MenuItem setShowAsActionFlags(int actionEnum) { + return this; + } + + public MenuItem setSubMenu(GeckoSubMenu subMenu) { + mSubMenu = subMenu; + return this; + } + + @Override + public MenuItem setTitle(CharSequence title) { + if (!TextUtils.equals(mTitle, title)) { + mTitle = title; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + @Override + public MenuItem setTitle(int title) { + CharSequence newTitle = mMenu.getResources().getString(title); + return setTitle(newTitle); + } + + @Override + public MenuItem setTitleCondensed(CharSequence title) { + mTitleCondensed = title; + return this; + } + + @Override + public MenuItem setVisible(boolean visible) { + // Action views are not normal menu items and visibility can get out + // of sync unless we dispatch whenever required. + if (isActionItem() || mVisible != visible) { + mVisible = visible; + if (mShouldDispatchChanges) { + mMenu.onItemChanged(this); + } else { + mDidChange = true; + } + } + return this; + } + + public boolean invoke() { + if (mMenuItemClickListener != null) + return mMenuItemClickListener.onMenuItemClick(this); + else + return false; + } + + public void setOnShowAsActionChangedListener(OnShowAsActionChangedListener listener) { + mShowAsActionChangedListener = listener; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java new file mode 100644 index 000000000..d774bdd37 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java @@ -0,0 +1,81 @@ +/* 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.menu; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.MenuItem; +import android.view.SubMenu; +import android.view.View; + +public class GeckoSubMenu extends GeckoMenu + implements SubMenu { + private static final String LOGTAG = "GeckoSubMenu"; + + // MenuItem associated with this submenu. + private MenuItem mMenuItem; + + public GeckoSubMenu(Context context) { + super(context); + } + + public GeckoSubMenu(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public GeckoSubMenu(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void clearHeader() { + } + + public SubMenu setMenuItem(MenuItem item) { + mMenuItem = item; + return this; + } + + @Override + public MenuItem getItem() { + return mMenuItem; + } + + @Override + public SubMenu setHeaderIcon(Drawable icon) { + return this; + } + + @Override + public SubMenu setHeaderIcon(int iconRes) { + return this; + } + + @Override + public SubMenu setHeaderTitle(CharSequence title) { + return this; + } + + @Override + public SubMenu setHeaderTitle(int titleRes) { + return this; + } + + @Override + public SubMenu setHeaderView(View view) { + return this; + } + + @Override + public SubMenu setIcon(Drawable icon) { + return this; + } + + @Override + public SubMenu setIcon(int iconRes) { + return this; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java new file mode 100644 index 000000000..882187dd6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java @@ -0,0 +1,64 @@ +/* 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.menu; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.widget.themed.ThemedImageButton; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class MenuItemActionBar extends ThemedImageButton + implements GeckoMenuItem.Layout { + private static final String LOGTAG = "GeckoMenuItemActionBar"; + + public MenuItemActionBar(Context context) { + this(context, null); + } + + public MenuItemActionBar(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.menuItemActionBarStyle); + } + + public MenuItemActionBar(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public void initialize(GeckoMenuItem item) { + if (item == null) + return; + + setIcon(item.getIcon()); + setTitle(item.getTitle()); + setEnabled(item.isEnabled()); + setId(item.getItemId()); + } + + void setIcon(Drawable icon) { + if (icon == null) { + setVisibility(GONE); + } else { + setVisibility(VISIBLE); + setImageDrawable(icon); + } + } + + void setIcon(int icon) { + setIcon((icon == 0) ? null : ResourceDrawableUtils.getDrawable(getContext(), icon)); + } + + void setTitle(CharSequence title) { + // set accessibility contentDescription here + setContentDescription(title); + } + + @Override + public void setShowIcon(boolean show) { + // Do nothing. + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java new file mode 100644 index 000000000..5b5069334 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java @@ -0,0 +1,152 @@ +/* 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.menu; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ResourceDrawableUtils; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.widget.TextView; + +public class MenuItemDefault extends TextView + implements GeckoMenuItem.Layout { + private static final int[] STATE_MORE = new int[] { R.attr.state_more }; + private static final int[] STATE_CHECKED = new int[] { android.R.attr.state_checkable, android.R.attr.state_checked }; + private static final int[] STATE_UNCHECKED = new int[] { android.R.attr.state_checkable }; + + private Drawable mIcon; + private final Drawable mState; + private static Rect sIconBounds; + + private boolean mCheckable; + private boolean mChecked; + private boolean mHasSubMenu; + private boolean mShowIcon; + + public MenuItemDefault(Context context) { + this(context, null); + } + + public MenuItemDefault(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.menuItemDefaultStyle); + } + + public MenuItemDefault(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + Resources res = getResources(); + int width = res.getDimensionPixelSize(R.dimen.menu_item_row_width); + int height = res.getDimensionPixelSize(R.dimen.menu_item_row_height); + setMinimumWidth(width); + setMinimumHeight(height); + + int stateIconSize = res.getDimensionPixelSize(R.dimen.menu_item_state_icon); + Rect stateIconBounds = new Rect(0, 0, stateIconSize, stateIconSize); + + mState = res.getDrawable(R.drawable.menu_item_state).mutate(); + mState.setBounds(stateIconBounds); + + if (sIconBounds == null) { + int iconSize = res.getDimensionPixelSize(R.dimen.menu_item_icon); + sIconBounds = new Rect(0, 0, iconSize, iconSize); + } + + setCompoundDrawables(mIcon, null, mState, null); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 2); + + if (mHasSubMenu) + mergeDrawableStates(drawableState, STATE_MORE); + else if (mCheckable && mChecked) + mergeDrawableStates(drawableState, STATE_CHECKED); + else if (mCheckable && !mChecked) + mergeDrawableStates(drawableState, STATE_UNCHECKED); + + return drawableState; + } + + @Override + public void initialize(GeckoMenuItem item) { + if (item == null) + return; + + setTitle(item.getTitle()); + setIcon(item.getIcon()); + setEnabled(item.isEnabled()); + setCheckable(item.isCheckable()); + setChecked(item.isChecked()); + setSubMenuIndicator(item.hasSubMenu()); + } + + private void refreshIcon() { + setCompoundDrawables(mShowIcon ? mIcon : null, null, mState, null); + } + + void setIcon(Drawable icon) { + mIcon = icon; + + if (mIcon != null) { + mIcon.setBounds(sIconBounds); + mIcon.setAlpha(isEnabled() ? 255 : 99); + } + + refreshIcon(); + } + + void setIcon(int icon) { + setIcon((icon == 0) ? null : ResourceDrawableUtils.getDrawable(getContext(), icon)); + } + + void setTitle(CharSequence title) { + setText(title); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + + if (mIcon != null) + mIcon.setAlpha(enabled ? 255 : 99); + + if (mState != null) + mState.setAlpha(enabled ? 255 : 99); + } + + private void setCheckable(boolean checkable) { + if (mCheckable != checkable) { + mCheckable = checkable; + refreshDrawableState(); + } + } + + private void setChecked(boolean checked) { + if (mChecked != checked) { + mChecked = checked; + refreshDrawableState(); + } + } + + @Override + public void setShowIcon(boolean show) { + if (mShowIcon != show) { + mShowIcon = show; + refreshIcon(); + } + } + + void setSubMenuIndicator(boolean hasSubMenu) { + if (mHasSubMenu != hasSubMenu) { + mHasSubMenu = hasSubMenu; + refreshDrawableState(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java new file mode 100644 index 000000000..d01f52687 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java @@ -0,0 +1,188 @@ +/* -*- 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.menu; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.R; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +/** + * This class is a container view for menu items that: + * * Shows text if there is enough space and there are + * no action buttons ({@link #mActionButtons}). + * * Shows an icon if there is not enough space for text, + * or there are action buttons. + */ +public class MenuItemSwitcherLayout extends LinearLayout + implements GeckoMenuItem.Layout, + View.OnClickListener { + private final MenuItemDefault mMenuItem; + private final MenuItemActionBar mMenuButton; + private final List<ImageButton> mActionButtons; + private final List<View.OnClickListener> mActionButtonListeners = new ArrayList<View.OnClickListener>(); + + public MenuItemSwitcherLayout(Context context) { + this(context, null); + } + + public MenuItemSwitcherLayout(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.menuItemSwitcherLayoutStyle); + } + + @TargetApi(14) + public MenuItemSwitcherLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.menu_item_switcher_layout, this); + mMenuItem = (MenuItemDefault) findViewById(R.id.menu_item); + mMenuButton = (MenuItemActionBar) findViewById(R.id.menu_item_button); + mActionButtons = new ArrayList<ImageButton>(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int width = right - left; + + final View parent = (View) getParent(); + final int parentPadding = parent.getPaddingLeft() + parent.getPaddingRight(); + final int horizontalSpaceAvailableInParent = parent.getMeasuredWidth() - parentPadding; + + // Check if there is another View sharing horizontal + // space with this View in the parent. + if (width < horizontalSpaceAvailableInParent || mActionButtons.size() != 0) { + // Use the icon. + mMenuItem.setVisibility(View.GONE); + mMenuButton.setVisibility(View.VISIBLE); + } else { + // Use the button. + mMenuItem.setVisibility(View.VISIBLE); + mMenuButton.setVisibility(View.GONE); + } + + super.onLayout(changed, left, top, right, bottom); + } + + @Override + public void initialize(GeckoMenuItem item) { + if (item == null) { + return; + } + + mMenuItem.initialize(item); + mMenuButton.initialize(item); + setEnabled(item.isEnabled()); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + mMenuItem.setEnabled(enabled); + mMenuButton.setEnabled(enabled); + + for (ImageButton button : mActionButtons) { + button.setEnabled(enabled); + button.setAlpha(enabled ? 255 : 99); + } + } + + public void setMenuItemClickListener(View.OnClickListener listener) { + mMenuItem.setOnClickListener(listener); + mMenuButton.setOnClickListener(listener); + } + + public void setMenuItemLongClickListener(View.OnLongClickListener listener) { + mMenuItem.setOnLongClickListener(listener); + mMenuButton.setOnLongClickListener(listener); + } + + public void addActionButtonClickListener(View.OnClickListener listener) { + mActionButtonListeners.add(listener); + } + + @Override + public void setShowIcon(boolean show) { + mMenuItem.setShowIcon(show); + } + + public void setIcon(Drawable icon) { + mMenuItem.setIcon(icon); + mMenuButton.setIcon(icon); + } + + public void setIcon(int icon) { + mMenuItem.setIcon(icon); + mMenuButton.setIcon(icon); + } + + public void setTitle(CharSequence title) { + mMenuItem.setTitle(title); + mMenuButton.setContentDescription(title); + } + + public void setSubMenuIndicator(boolean hasSubMenu) { + mMenuItem.setSubMenuIndicator(hasSubMenu); + } + + public void addActionButton(Drawable drawable, CharSequence label) { + // If this is the first icon, retain the text. + // If not, make the menu item an icon. + final int count = mActionButtons.size(); + mMenuItem.setVisibility(View.GONE); + mMenuButton.setVisibility(View.VISIBLE); + + if (drawable != null) { + ImageButton button = new ImageButton(getContext(), null, R.attr.menuItemSecondaryActionBarStyle); + button.setImageDrawable(drawable); + button.setContentDescription(label); + button.setOnClickListener(this); + button.setTag(count); + + final int height = (int) (getResources().getDimension(R.dimen.menu_item_row_height)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, height); + params.weight = 1.0f; + button.setLayoutParams(params); + + // Place action buttons to the right of the actual menu item + mActionButtons.add(button); + addView(button, count + 1); + } + } + + protected int getActionButtonCount() { + return mActionButtons.size(); + } + + @Override + public void onClick(View view) { + for (View.OnClickListener listener : mActionButtonListeners) { + listener.onClick(view); + } + } + + /** + * Update the styles if this view is being used in the context menus. + * + * Ideally, we just use different layout files and styles to set this, but + * MenuItemSwitcherLayout is too integrated into GeckoActionProvider to provide + * an easy separation so instead I provide this hack. I'm sorry. + */ + public void initContextMenuStyles() { + final int defaultContextMenuPadding = getContext().getResources().getDimensionPixelOffset( + R.dimen.context_menu_item_horizontal_padding); + mMenuItem.setPadding(defaultContextMenuPadding, getPaddingTop(), + defaultContextMenuPadding, getPaddingBottom()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java new file mode 100644 index 000000000..ce4da8b7f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java @@ -0,0 +1,36 @@ +/* -*- 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.menu; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import android.widget.LinearLayout; + +/** + * The outer container for the custom menu. On phones with h/w menu button, + * this is given to Android which inflates it to the right panel. On phones + * with s/w menu button, this is added to a MenuPopup. + */ +public class MenuPanel extends LinearLayout { + public MenuPanel(Context context, AttributeSet attrs) { + super(context, attrs); + + int width = (int) context.getResources().getDimension(R.dimen.menu_item_row_width); + setLayoutParams(new ViewGroup.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + @Override + public boolean dispatchPopulateAccessibilityEvent (AccessibilityEvent event) { + onPopulateAccessibilityEvent(event); + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java new file mode 100644 index 000000000..227cc7630 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java @@ -0,0 +1,76 @@ +/* -*- 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.menu; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.support.v7.widget.CardView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.PopupWindow; + +/** + * A popup to show the inflated MenuPanel. + */ +public class MenuPopup extends PopupWindow { + private final CardView mPanel; + + private final int mPopupWidth; + + public MenuPopup(Context context) { + super(context); + + setFocusable(true); + + mPopupWidth = context.getResources().getDimensionPixelSize(R.dimen.menu_popup_width); + + // Setting a null background makes the popup to not close on touching outside. + setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + setWindowLayoutMode(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + + LayoutInflater inflater = LayoutInflater.from(context); + mPanel = (CardView) inflater.inflate(R.layout.menu_popup, null); + setContentView(mPanel); + + setAnimationStyle(R.style.PopupAnimation); + } + + /** + * Adds the panel with the menu to its content. + * + * @param view The panel view with the menu to be shown. + */ + public void setPanelView(View view) { + view.setLayoutParams(new FrameLayout.LayoutParams(mPopupWidth, + FrameLayout.LayoutParams.WRAP_CONTENT)); + + mPanel.removeAllViews(); + mPanel.addView(view); + } + + /** + * A small little offset. + */ + @Override + public void showAsDropDown(View anchor) { + // Set a height, so that the popup will not be displayed below the bottom of the screen. + // We use the exact height of the internal content, which is the technique described in + // http://stackoverflow.com/a/7698709 + setHeight(mPanel.getHeight()); + + // Attempt to align the center of the popup with the center of the anchor. If the anchor is + // near the edge of the screen, the popup will just align with the edge of the screen. + final int xOffset = anchor.getWidth() / 2 - mPopupWidth / 2; + showAsDropDown(anchor, xOffset, -anchor.getHeight()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java new file mode 100644 index 000000000..cf22685c2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java @@ -0,0 +1,81 @@ +/* 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.mozglue; + +import android.os.Parcel; + +import org.mozilla.gecko.media.Sample; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public final class SharedMemBuffer implements Sample.Buffer { + private SharedMemory mSharedMem; + + /* package */ + public SharedMemBuffer(SharedMemory sharedMem) { + mSharedMem = sharedMem; + } + + protected SharedMemBuffer(Parcel in) { + mSharedMem = in.readParcelable(Sample.class.getClassLoader()); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mSharedMem, flags); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator<SharedMemBuffer> CREATOR = new Creator<SharedMemBuffer>() { + @Override + public SharedMemBuffer createFromParcel(Parcel in) { + return new SharedMemBuffer(in); + } + + @Override + public SharedMemBuffer[] newArray(int size) { + return new SharedMemBuffer[size]; + } + }; + + @Override + public int capacity() { + return mSharedMem != null ? mSharedMem.getSize() : 0; + } + + @Override + public void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException { + if (!src.isDirect()) { + throw new IOException("SharedMemBuffer only support reading from direct byte buffer."); + } + nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size); + } + + private native static void nativeReadFromDirectBuffer(ByteBuffer src, long dest, int offset, int size); + + @Override + public void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException { + if (!dest.isDirect()) { + throw new IOException("SharedMemBuffer only support writing to direct byte buffer."); + } + nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size); + } + + private native static void nativeWriteToDirectBuffer(long src, ByteBuffer dest, int offset, int size); + + @Override + public void dispose() { + if (mSharedMem != null) { + mSharedMem.dispose(); + mSharedMem = null; + } + } + + @Override public String toString() { return "Buffer: " + mSharedMem; } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java new file mode 100644 index 000000000..bc43a2755 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java @@ -0,0 +1,171 @@ +/* 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.mozglue; + +import android.os.MemoryFile; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Parcelable; +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.lang.reflect.Method; + +public class SharedMemory implements Parcelable { + private static final String LOGTAG = "GeckoShmem"; + private static Method sGetFDMethod = null; // MemoryFile.getFileDescriptor() is hidden. :( + private ParcelFileDescriptor mDescriptor; + private int mSize; + private int mId; + private long mHandle; // The native pointer. + private boolean mIsMapped; + private MemoryFile mBackedFile; + + static { + try { + sGetFDMethod = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + } + + private SharedMemory(Parcel in) { + mDescriptor = in.readFileDescriptor(); + mSize = in.readInt(); + mId = in.readInt(); + } + + public static final Creator<SharedMemory> CREATOR = new Creator<SharedMemory>() { + @Override + public SharedMemory createFromParcel(Parcel in) { + return new SharedMemory(in); + } + + @Override + public SharedMemory[] newArray(int size) { + return new SharedMemory[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + // We don't want ParcelFileDescriptor.writeToParcel() to close the fd. + dest.writeFileDescriptor(mDescriptor.getFileDescriptor()); + dest.writeInt(mSize); + dest.writeInt(mId); + } + + public SharedMemory(int id, int size) throws NoSuchMethodException, IOException { + if (sGetFDMethod == null) { + throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist."); + } + mBackedFile = new MemoryFile(null, size); + try { + FileDescriptor fd = (FileDescriptor)sGetFDMethod.invoke(mBackedFile); + mDescriptor = ParcelFileDescriptor.dup(fd); + mSize = size; + mId = id; + mBackedFile.allowPurging(false); + } catch (Exception e) { + e.printStackTrace(); + close(); + throw new IOException(e.getMessage()); + } + } + + public void flush() { + if (mBackedFile == null) { + close(); + } + } + + public void close() { + if (mIsMapped) { + unmap(mHandle, mSize); + mHandle = 0; + } + + if (mDescriptor != null) { + try { + mDescriptor.close(); + } catch (IOException e) { + e.printStackTrace(); + } + mDescriptor = null; + } + } + + // Should only be called by process that allocates shared memory. + public void dispose() { + if (!isValid()) { + return; + } + + close(); + + if (mBackedFile != null) { + mBackedFile.close(); + mBackedFile = null; + } + } + + private native void unmap(long address, int size); + + public boolean isValid() { return mDescriptor != null; } + + public int getSize() { return mSize; } + + private int getFD() { + return isValid() ? mDescriptor.getFd() : -1; + } + + public long getPointer() { + if (!isValid()) { + return 0; + } + + if (!mIsMapped) { + mHandle = map(getFD(), mSize); + if (mHandle != 0) { + mIsMapped = true; + } + } + return mHandle; + } + + private native long map(int fd, int size); + + @Override + protected void finalize() throws Throwable { + if (mBackedFile != null) { + Log.w(LOGTAG, "dispose() not called before finalizing"); + } + dispose(); + + super.finalize(); + } + + @Override + public String toString() { + return "SHM(" + getSize() + " bytes): id=" + mId + ", backing=" + mBackedFile + ",fd=" + mDescriptor; + } + + @Override + public boolean equals(Object that) { + return (this == that) || + ((that instanceof SharedMemory) && (hashCode() == that.hashCode())); + } + + @Override + public int hashCode() { + return mId; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java new file mode 100644 index 000000000..c46c01050 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java @@ -0,0 +1,324 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.notifications; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.util.Log; + +import java.util.HashMap; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoService; +import org.mozilla.gecko.NotificationListener; +import org.mozilla.gecko.R; +import org.mozilla.gecko.gfx.BitmapUtils; + +/** + * Client for posting notifications. + */ +public final class NotificationClient implements NotificationListener { + private static final String LOGTAG = "GeckoNotificationClient"; + /* package */ static final String CLICK_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".NOTIFICATION_CLICK"; + /* package */ static final String CLOSE_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".NOTIFICATION_CLOSE"; + /* package */ static final String PERSISTENT_INTENT_EXTRA = "persistentIntent"; + + private final Context mContext; + private final NotificationManagerCompat mNotificationManager; + + private final HashMap<String, Notification> mNotifications = new HashMap<>(); + + /** + * Notification associated with this service's foreground state. + * + * {@link android.app.Service#startForeground(int, android.app.Notification)} + * associates the foreground with exactly one notification from the service. + * To keep Fennec alive during downloads (and to make sure it can be killed + * once downloads are complete), we make sure that the foreground is always + * associated with an active progress notification if and only if at least + * one download is in progress. + */ + private String mForegroundNotification; + + public NotificationClient(Context context) { + mContext = context.getApplicationContext(); + mNotificationManager = NotificationManagerCompat.from(mContext); + } + + @Override // NotificationListener + public void showNotification(String name, String cookie, String title, + String text, String host, String imageUrl) { + showNotification(name, cookie, title, text, host, imageUrl, /* data */ null); + } + + @Override // NotificationListener + public void showPersistentNotification(String name, String cookie, String title, + String text, String host, String imageUrl, + String data) { + showNotification(name, cookie, title, text, host, imageUrl, data != null ? data : ""); + } + + private void showNotification(String name, String cookie, String title, + String text, String host, String imageUrl, + String persistentData) { + // Put the strings into the intent as an URI + // "alert:?name=<name>&cookie=<cookie>" + String packageName = AppConstants.ANDROID_PACKAGE_NAME; + String className = AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS; + if (GeckoAppShell.getGeckoInterface() != null) { + final ComponentName comp = GeckoAppShell.getGeckoInterface() + .getActivity().getComponentName(); + packageName = comp.getPackageName(); + className = comp.getClassName(); + } + final Uri dataUri = (new Uri.Builder()) + .scheme("moz-notification") + .authority(packageName) + .path(className) + .appendQueryParameter("name", name) + .appendQueryParameter("cookie", cookie) + .build(); + + final Intent clickIntent = new Intent(CLICK_ACTION); + clickIntent.setClass(mContext, NotificationReceiver.class); + clickIntent.setData(dataUri); + + if (persistentData != null) { + final Intent persistentIntent = GeckoService.getIntentToCreateServices( + mContext, "persistent-notification-click", persistentData); + clickIntent.putExtra(PERSISTENT_INTENT_EXTRA, persistentIntent); + } + + final PendingIntent clickPendingIntent = PendingIntent.getBroadcast( + mContext, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + final Intent closeIntent = new Intent(CLOSE_ACTION); + closeIntent.setClass(mContext, NotificationReceiver.class); + closeIntent.setData(dataUri); + + if (persistentData != null) { + final Intent persistentIntent = GeckoService.getIntentToCreateServices( + mContext, "persistent-notification-close", persistentData); + closeIntent.putExtra(PERSISTENT_INTENT_EXTRA, persistentIntent); + } + + final PendingIntent closePendingIntent = PendingIntent.getBroadcast( + mContext, 0, closeIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + add(name, imageUrl, host, title, text, clickPendingIntent, closePendingIntent); + GeckoAppShell.onNotificationShow(name, cookie); + } + + @Override // NotificationListener + public void closeNotification(String name) + { + remove(name); + } + + /** + * Adds a notification; used for web notifications. + * + * @param name the unique name of the notification + * @param imageUrl URL of the image to use + * @param alertTitle title of the notification + * @param alertText text of the notification + * @param contentIntent Intent used when the notification is clicked + * @param deleteIntent Intent used when the notification is closed + */ + private void add(final String name, final String imageUrl, final String host, + final String alertTitle, final String alertText, + final PendingIntent contentIntent, final PendingIntent deleteIntent) { + final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext) + .setContentTitle(alertTitle) + .setContentText(alertText) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentIntent(contentIntent) + .setDeleteIntent(deleteIntent) + .setAutoCancel(true) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(alertText) + .setSummaryText(host)); + + // Fetch icon. + if (!imageUrl.isEmpty()) { + final Bitmap image = BitmapUtils.decodeUrl(imageUrl); + builder.setLargeIcon(image); + } + + builder.setWhen(System.currentTimeMillis()); + final Notification notification = builder.build(); + + synchronized (this) { + mNotifications.put(name, notification); + } + + mNotificationManager.notify(name, 0, notification); + } + + /** + * Adds a notification; used for Fennec app notifications. + * + * @param name the unique name of the notification + * @param notification the Notification to add + */ + public synchronized void add(final String name, final Notification notification) { + final boolean ongoing = isOngoing(notification); + + if (ongoing != isOngoing(mNotifications.get(name))) { + // In order to change notification from ongoing to non-ongoing, or vice versa, + // we have to remove the previous notification, because ongoing notifications + // use a different id value than non-ongoing notifications. + onNotificationClose(name); + } + + mNotifications.put(name, notification); + + if (!ongoing) { + mNotificationManager.notify(name, 0, notification); + return; + } + + // Ongoing + if (mForegroundNotification == null) { + setForegroundNotificationLocked(name, notification); + } else if (mForegroundNotification.equals(name)) { + // Shortcut to update the current foreground notification, instead of + // going through the service. + mNotificationManager.notify(R.id.foregroundNotification, notification); + } + } + + /** + * Updates a notification. + * + * @param name Name of existing notification + * @param progress progress of item being updated + * @param progressMax max progress of item being updated + * @param alertText text of the notification + */ + public void update(final String name, final long progress, + final long progressMax, final String alertText) { + Notification notification; + synchronized (this) { + notification = mNotifications.get(name); + } + if (notification == null) { + return; + } + + notification = new NotificationCompat.Builder(mContext) + .setContentText(alertText) + .setSmallIcon(notification.icon) + .setWhen(notification.when) + .setContentIntent(notification.contentIntent) + .setProgress((int) progressMax, (int) progress, false) + .build(); + + add(name, notification); + } + + /* package */ synchronized Notification onNotificationClose(final String name) { + mNotificationManager.cancel(name, 0); + + final Notification notification = mNotifications.remove(name); + if (notification != null) { + updateForegroundNotificationLocked(name); + } + return notification; + } + + /** + * Removes a notification. + * + * @param name Name of existing notification + */ + public synchronized void remove(final String name) { + final Notification notification = onNotificationClose(name); + if (notification == null || notification.deleteIntent == null) { + return; + } + + // Canceling the notification doesn't trigger the delete intent, so we + // have to trigger it manually. + try { + notification.deleteIntent.send(); + } catch (final PendingIntent.CanceledException e) { + // Ignore. + } + } + + /** + * Determines whether the service is done. + * + * The service is considered finished when all notifications have been + * removed. + * + * @return whether all notifications have been removed + */ + public synchronized boolean isDone() { + return mNotifications.isEmpty(); + } + + /** + * Determines whether a notification should hold a foreground service to keep Gecko alive + * + * @param name the name of the notification to check + * @return whether the notification is ongoing + */ + public synchronized boolean isOngoing(final String name) { + return isOngoing(mNotifications.get(name)); + } + + /** + * Determines whether a notification should hold a foreground service to keep Gecko alive + * + * @param notification the notification to check + * @return whether the notification is ongoing + */ + public boolean isOngoing(final Notification notification) { + if (notification != null && (notification.flags & Notification.FLAG_ONGOING_EVENT) != 0) { + return true; + } + return false; + } + + private void setForegroundNotificationLocked(final String name, + final Notification notification) { + mForegroundNotification = name; + + final Intent intent = new Intent(mContext, NotificationService.class); + intent.putExtra(NotificationService.EXTRA_NOTIFICATION, notification); + mContext.startService(intent); + } + + private void updateForegroundNotificationLocked(final String oldName) { + if (mForegroundNotification == null || !mForegroundNotification.equals(oldName)) { + return; + } + + // If we're removing the notification associated with the + // foreground, we need to pick another active notification to act + // as the foreground notification. + for (final String name : mNotifications.keySet()) { + final Notification notification = mNotifications.get(name); + if (isOngoing(notification)) { + setForegroundNotificationLocked(name, notification); + return; + } + } + + setForegroundNotificationLocked(null, null); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java new file mode 100644 index 000000000..1e33031b5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java @@ -0,0 +1,366 @@ +/* -*- 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.notifications; + +import java.util.HashMap; +import java.util.Iterator; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.mozglue.SafeIntent; +import org.mozilla.gecko.util.GeckoEventListener; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +public final class NotificationHelper implements GeckoEventListener { + public static final String HELPER_BROADCAST_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".helperBroadcastAction"; + + public static final String NOTIFICATION_ID = "NotificationHelper_ID"; + private static final String LOGTAG = "GeckoNotificationHelper"; + private static final String HELPER_NOTIFICATION = "helperNotif"; + + // Attributes mandatory to be used while sending a notification from js. + private static final String TITLE_ATTR = "title"; + private static final String TEXT_ATTR = "text"; + /* package */ static final String ID_ATTR = "id"; + private static final String SMALLICON_ATTR = "smallIcon"; + + // Attributes that can be used while sending a notification from js. + private static final String PROGRESS_VALUE_ATTR = "progress_value"; + private static final String PROGRESS_MAX_ATTR = "progress_max"; + private static final String PROGRESS_INDETERMINATE_ATTR = "progress_indeterminate"; + private static final String LIGHT_ATTR = "light"; + private static final String ONGOING_ATTR = "ongoing"; + private static final String WHEN_ATTR = "when"; + private static final String PRIORITY_ATTR = "priority"; + private static final String LARGE_ICON_ATTR = "largeIcon"; + private static final String ACTIONS_ATTR = "actions"; + private static final String ACTION_ID_ATTR = "buttonId"; + private static final String ACTION_TITLE_ATTR = "title"; + private static final String ACTION_ICON_ATTR = "icon"; + private static final String PERSISTENT_ATTR = "persistent"; + private static final String HANDLER_ATTR = "handlerKey"; + private static final String COOKIE_ATTR = "cookie"; + static final String EVENT_TYPE_ATTR = "eventType"; + + private static final String NOTIFICATION_SCHEME = "moz-notification"; + + private static final String BUTTON_EVENT = "notification-button-clicked"; + private static final String CLICK_EVENT = "notification-clicked"; + static final String CLEARED_EVENT = "notification-cleared"; + + static final String ORIGINAL_EXTRA_COMPONENT = "originalComponent"; + + private final Context mContext; + + // Holds a list of notifications that should be cleared if the Fennec Activity is shut down. + // Will not include ongoing or persistent notifications that are tied to Gecko's lifecycle. + private HashMap<String, String> mClearableNotifications; + + private boolean mInitialized; + private static NotificationHelper sInstance; + + private NotificationHelper(Context context) { + mContext = context; + } + + public void init() { + if (mInitialized) { + return; + } + + mClearableNotifications = new HashMap<String, String>(); + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "Notification:Show", + "Notification:Hide"); + mInitialized = true; + } + + public static NotificationHelper getInstance(Context context) { + // If someone else created this singleton, but didn't initialize it, something has gone wrong. + if (sInstance != null && !sInstance.mInitialized) { + throw new IllegalStateException("NotificationHelper was created by someone else but not initialized"); + } + + if (sInstance == null) { + sInstance = new NotificationHelper(context.getApplicationContext()); + } + return sInstance; + } + + @Override + public void handleMessage(String event, JSONObject message) { + if (event.equals("Notification:Show")) { + showNotification(message); + } else if (event.equals("Notification:Hide")) { + hideNotification(message); + } + } + + public boolean isHelperIntent(Intent i) { + return i.getBooleanExtra(HELPER_NOTIFICATION, false); + } + + public static void getArgsAndSendNotificationIntent(SafeIntent intent) { + final JSONObject args = new JSONObject(); + final Uri data = intent.getData(); + + final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR); + + try { + args.put(ID_ATTR, data.getQueryParameter(ID_ATTR)); + args.put(EVENT_TYPE_ATTR, notificationType); + args.put(HANDLER_ATTR, data.getQueryParameter(HANDLER_ATTR)); + args.put(COOKIE_ATTR, intent.getStringExtra(COOKIE_ATTR)); + + if (BUTTON_EVENT.equals(notificationType)) { + final String actionName = data.getQueryParameter(ACTION_ID_ATTR); + args.put(ACTION_ID_ATTR, actionName); + } + + Log.i(LOGTAG, "Send " + args.toString()); + GeckoAppShell.notifyObservers("Notification:Event", args.toString()); + } catch (JSONException e) { + Log.e(LOGTAG, "Error building JSON notification arguments.", e); + } + } + + public void handleNotificationIntent(SafeIntent i) { + final Uri data = i.getData(); + final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR); + final String id = data.getQueryParameter(ID_ATTR); + if (id == null || notificationType == null) { + Log.e(LOGTAG, "handleNotificationEvent: invalid intent parameters"); + return; + } + + getArgsAndSendNotificationIntent(i); + + // If the notification was clicked, we are closing it. This must be executed after + // sending the event to js side because when the notification is canceled no event can be + // handled. + if (CLICK_EVENT.equals(notificationType) && !i.getBooleanExtra(ONGOING_ATTR, false)) { + // The handler and cookie parameters are optional. + final String handler = data.getQueryParameter(HANDLER_ATTR); + final String cookie = i.getStringExtra(COOKIE_ATTR); + hideNotification(id, handler, cookie); + } + } + + private Uri.Builder getNotificationBuilder(JSONObject message, String type) { + Uri.Builder b = new Uri.Builder(); + b.scheme(NOTIFICATION_SCHEME).appendQueryParameter(EVENT_TYPE_ATTR, type); + + try { + final String id = message.getString(ID_ATTR); + b.appendQueryParameter(ID_ATTR, id); + } catch (JSONException ex) { + Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex); + } + + try { + final String id = message.getString(HANDLER_ATTR); + b.appendQueryParameter(HANDLER_ATTR, id); + } catch (JSONException ex) { + Log.i(LOGTAG, "Notification doesn't have a handler"); + } + + return b; + } + + private Intent buildNotificationIntent(JSONObject message, Uri.Builder builder) { + Intent notificationIntent = new Intent(HELPER_BROADCAST_ACTION); + final boolean ongoing = message.optBoolean(ONGOING_ATTR); + notificationIntent.putExtra(ONGOING_ATTR, ongoing); + + final Uri dataUri = builder.build(); + notificationIntent.setData(dataUri); + notificationIntent.putExtra(HELPER_NOTIFICATION, true); + notificationIntent.putExtra(COOKIE_ATTR, message.optString(COOKIE_ATTR)); + + // All intents get routed through the notificationReceiver. That lets us bail if we don't want to start Gecko + final ComponentName name = new ComponentName(mContext, GeckoAppShell.getGeckoInterface().getActivity().getClass()); + notificationIntent.putExtra(ORIGINAL_EXTRA_COMPONENT, name); + + return notificationIntent; + } + + private PendingIntent buildNotificationPendingIntent(JSONObject message, String type) { + Uri.Builder builder = getNotificationBuilder(message, type); + final Intent notificationIntent = buildNotificationIntent(message, builder); + return PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private PendingIntent buildButtonClickPendingIntent(JSONObject message, JSONObject action) { + Uri.Builder builder = getNotificationBuilder(message, BUTTON_EVENT); + try { + // Action name must be in query uri, otherwise buttons pending intents + // would be collapsed. + if (action.has(ACTION_ID_ATTR)) { + builder.appendQueryParameter(ACTION_ID_ATTR, action.getString(ACTION_ID_ATTR)); + } else { + Log.i(LOGTAG, "button event with no name"); + } + } catch (JSONException ex) { + Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex); + } + final Intent notificationIntent = buildNotificationIntent(message, builder); + PendingIntent res = PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + return res; + } + + private void showNotification(JSONObject message) { + NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext); + + // These attributes are required + final String id; + try { + builder.setContentTitle(message.getString(TITLE_ATTR)); + builder.setContentText(message.getString(TEXT_ATTR)); + id = message.getString(ID_ATTR); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing", ex); + return; + } + + Uri imageUri = Uri.parse(message.optString(SMALLICON_ATTR)); + builder.setSmallIcon(BitmapUtils.getResource(mContext, imageUri)); + + JSONArray light = message.optJSONArray(LIGHT_ATTR); + if (light != null && light.length() == 3) { + try { + builder.setLights(light.getInt(0), + light.getInt(1), + light.getInt(2)); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing", ex); + } + } + + boolean ongoing = message.optBoolean(ONGOING_ATTR); + builder.setOngoing(ongoing); + + if (message.has(WHEN_ATTR)) { + long when = message.optLong(WHEN_ATTR); + builder.setWhen(when); + } + + if (message.has(PRIORITY_ATTR)) { + int priority = message.optInt(PRIORITY_ATTR); + builder.setPriority(priority); + } + + if (message.has(LARGE_ICON_ATTR)) { + Bitmap b = BitmapUtils.getBitmapFromDataURI(message.optString(LARGE_ICON_ATTR)); + builder.setLargeIcon(b); + } + + if (message.has(PROGRESS_VALUE_ATTR) && + message.has(PROGRESS_MAX_ATTR) && + message.has(PROGRESS_INDETERMINATE_ATTR)) { + try { + final int progress = message.getInt(PROGRESS_VALUE_ATTR); + final int progressMax = message.getInt(PROGRESS_MAX_ATTR); + final boolean progressIndeterminate = message.getBoolean(PROGRESS_INDETERMINATE_ATTR); + builder.setProgress(progressMax, progress, progressIndeterminate); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing", ex); + } + } + + JSONArray actions = message.optJSONArray(ACTIONS_ATTR); + if (actions != null) { + try { + for (int i = 0; i < actions.length(); i++) { + JSONObject action = actions.getJSONObject(i); + final PendingIntent pending = buildButtonClickPendingIntent(message, action); + final String actionTitle = action.getString(ACTION_TITLE_ATTR); + final Uri actionImage = Uri.parse(action.optString(ACTION_ICON_ATTR)); + builder.addAction(BitmapUtils.getResource(mContext, actionImage), + actionTitle, + pending); + } + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing", ex); + } + } + + PendingIntent pi = buildNotificationPendingIntent(message, CLICK_EVENT); + builder.setContentIntent(pi); + PendingIntent deletePendingIntent = buildNotificationPendingIntent(message, CLEARED_EVENT); + builder.setDeleteIntent(deletePendingIntent); + + ((NotificationClient) GeckoAppShell.getNotificationListener()).add(id, builder.build()); + + boolean persistent = message.optBoolean(PERSISTENT_ATTR); + // We add only not persistent notifications to the list since we want to purge only + // them when geckoapp is destroyed. + if (!persistent && !mClearableNotifications.containsKey(id)) { + mClearableNotifications.put(id, message.toString()); + } + } + + private void hideNotification(JSONObject message) { + final String id; + final String handler; + final String cookie; + try { + id = message.getString("id"); + handler = message.optString("handlerKey"); + cookie = message.optString("cookie"); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing", ex); + return; + } + + hideNotification(id, handler, cookie); + } + + private void closeNotification(String id, String handlerKey, String cookie) { + ((NotificationClient) GeckoAppShell.getNotificationListener()).remove(id); + } + + public void hideNotification(String id, String handlerKey, String cookie) { + mClearableNotifications.remove(id); + closeNotification(id, handlerKey, cookie); + } + + private void clearAll() { + for (Iterator<String> i = mClearableNotifications.keySet().iterator(); i.hasNext();) { + final String id = i.next(); + final String json = mClearableNotifications.get(id); + i.remove(); + + JSONObject obj; + try { + obj = new JSONObject(json); + } catch (JSONException ex) { + obj = new JSONObject(); + } + + closeNotification(id, obj.optString(HANDLER_ATTR), obj.optString(COOKIE_ATTR)); + } + } + + public static void destroy() { + if (sInstance != null) { + sInstance.clearAll(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java new file mode 100644 index 000000000..c3dd43297 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java @@ -0,0 +1,106 @@ +/* -*- 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.notifications; + +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.mozglue.SafeIntent; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +/** + * Broadcast receiver for Notifications. Will forward them to GeckoApp (and start Gecko) if they're clicked. + * If they're being dismissed, it will not start Gecko, but may forward them to JS if Gecko is running. + * This is also the only entry point for notification intents. + */ +public class NotificationReceiver extends BroadcastReceiver { + private static final String LOGTAG = "GeckoNotificationReceiver"; + + public void onReceive(Context context, Intent intent) { + final Uri data = intent.getData(); + if (data == null) { + Log.e(LOGTAG, "handleNotificationEvent: empty data"); + return; + } + + final String action = intent.getAction(); + if (NotificationClient.CLICK_ACTION.equals(action) || + NotificationClient.CLOSE_ACTION.equals(action)) { + onNotificationClientAction(context, action, data, intent); + return; + } + + final String notificationType = data.getQueryParameter(NotificationHelper.EVENT_TYPE_ATTR); + if (notificationType == null) { + return; + } + + // In case the user swiped out the notification, we empty the id set. + if (NotificationHelper.CLEARED_EVENT.equals(notificationType)) { + // If Gecko isn't running, we throw away events where the notification was cancelled. + // i.e. Don't bug the user if they're just closing a bunch of notifications. + if (GeckoThread.isRunning()) { + NotificationHelper.getArgsAndSendNotificationIntent(new SafeIntent(intent)); + } + + final NotificationClient client = (NotificationClient) + GeckoAppShell.getNotificationListener(); + client.onNotificationClose(data.getQueryParameter(NotificationHelper.ID_ATTR)); + return; + } + + forwardMessageToActivity(intent, context); + } + + private void forwardMessageToActivity(final Intent intent, final Context context) { + final ComponentName name = intent.getExtras().getParcelable(NotificationHelper.ORIGINAL_EXTRA_COMPONENT); + intent.setComponent(name); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + private void onNotificationClientAction(final Context context, final String action, + final Uri data, final Intent intent) { + final String name = data.getQueryParameter("name"); + final String cookie = data.getQueryParameter("cookie"); + final Intent persistentIntent = (Intent) + intent.getParcelableExtra(NotificationClient.PERSISTENT_INTENT_EXTRA); + + if (persistentIntent != null) { + // Go through GeckoService for persistent notifications. + context.startService(persistentIntent); + } + + if (NotificationClient.CLICK_ACTION.equals(action)) { + GeckoAppShell.onNotificationClick(name, cookie); + + if (persistentIntent != null) { + // Don't launch GeckoApp if it's a background persistent notification. + return; + } + + final Intent appIntent = new Intent(GeckoApp.ACTION_ALERT_CALLBACK); + appIntent.setComponent(new ComponentName( + data.getAuthority(), data.getPath().substring(1))); // exclude leading slash. + appIntent.setData(data); + appIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(appIntent); + + } else if (NotificationClient.CLOSE_ACTION.equals(action)) { + GeckoAppShell.onNotificationClose(name, cookie); + + final NotificationClient client = (NotificationClient) + GeckoAppShell.getNotificationListener(); + client.onNotificationClose(name); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java new file mode 100644 index 000000000..04b94cd1a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java @@ -0,0 +1,37 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.notifications; + +import android.app.Notification; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import org.mozilla.gecko.R; + +public final class NotificationService extends Service { + public static final String EXTRA_NOTIFICATION = "notification"; + + @Override // Service + public int onStartCommand(final Intent intent, final int flags, final int startId) { + final Notification notification = intent.getParcelableExtra(EXTRA_NOTIFICATION); + if (notification != null) { + // Start foreground notification. + startForeground(R.id.foregroundNotification, notification); + return START_NOT_STICKY; + } + + // Stop foreground notification + stopForeground(true); + stopSelfResult(startId); + return START_NOT_STICKY; + } + + @Override // Service + public IBinder onBind(final Intent intent) { + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java b/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java new file mode 100644 index 000000000..6e799bf74 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java @@ -0,0 +1,99 @@ +/* -*- 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.notifications; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.text.TextUtils; + +import com.keepsafe.switchboard.SwitchBoard; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.Experiments; + +import java.util.Locale; + +public class WhatsNewReceiver extends BroadcastReceiver { + + public static final String EXTRA_WHATSNEW_NOTIFICATION = "whatsnew_notification"; + private static final String ACTION_NOTIFICATION_CANCELLED = "notification_cancelled"; + + @Override + public void onReceive(Context context, Intent intent) { + if (ACTION_NOTIFICATION_CANCELLED.equals(intent.getAction())) { + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.NOTIFICATION, EXTRA_WHATSNEW_NOTIFICATION); + return; + } + + final String dataString = intent.getDataString(); + if (TextUtils.isEmpty(dataString) || !dataString.contains(AppConstants.ANDROID_PACKAGE_NAME)) { + return; + } + + if (!SwitchBoard.isInExperiment(context, Experiments.WHATSNEW_NOTIFICATION)) { + return; + } + + if (!isPreferenceEnabled(context)) { + return; + } + + showWhatsNewNotification(context); + } + + private boolean isPreferenceEnabled(Context context) { + return GeckoSharedPrefs.forApp(context).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_WHATS_NEW, true); + } + + private void showWhatsNewNotification(Context context) { + final Notification notification = new NotificationCompat.Builder(context) + .setContentTitle(context.getString(R.string.whatsnew_notification_title)) + .setContentText(context.getString(R.string.whatsnew_notification_summary)) + .setSmallIcon(R.drawable.ic_status_logo) + .setAutoCancel(true) + .setContentIntent(getContentIntent(context)) + .setDeleteIntent(getDeleteIntent(context)) + .build(); + + final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + final int notificationID = EXTRA_WHATSNEW_NOTIFICATION.hashCode(); + notificationManager.notify(notificationID, notification); + + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, EXTRA_WHATSNEW_NOTIFICATION); + } + + private PendingIntent getContentIntent(Context context) { + final String link = context.getString(R.string.whatsnew_notification_url, + AppConstants.MOZ_APP_VERSION, + AppConstants.OS_TARGET, + Locales.getLanguageTag(Locale.getDefault())); + + final Intent i = new Intent(Intent.ACTION_VIEW); + i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + i.setData(Uri.parse(link)); + i.putExtra(EXTRA_WHATSNEW_NOTIFICATION, true); + + return PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private PendingIntent getDeleteIntent(Context context) { + final Intent i = new Intent(context, WhatsNewReceiver.class); + i.setAction(ACTION_NOTIFICATION_CANCELLED); + + return PendingIntent.getBroadcast(context, 0, i, PendingIntent.FLAG_CANCEL_CURRENT); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java b/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java new file mode 100644 index 000000000..16f5560d3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java @@ -0,0 +1,68 @@ +/* -*- 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.overlays; + +/** + * Constants used by the share handler service (and clients). + * The intent API used by the service is defined herein. + */ +public class OverlayConstants { + /* + * OverlayIntentHandler service intent actions. + */ + + /* + * Causes the service to broadcast an intent containing state necessary for proper display of + * a UI to select a target share method. + * + * Intent parameters: + * + * None. + */ + public static final String ACTION_PREPARE_SHARE = "org.mozilla.gecko.overlays.ACTION_PREPARE_SHARE"; + + /* + * Action for sharing a page. + * + * Intent parameters: + * + * $EXTRA_URL: URL of page to share. (required) + * $EXTRA_SHARE_METHOD: Method(s) via which to share this url/title combination. Can be either a + * ShareType or a ShareType[] + * $EXTRA_TITLE: Title of page to share (optional) + * $EXTRA_PARAMETERS: Parcelable of extra data to pass to the ShareMethod (optional) + */ + public static final String ACTION_SHARE = "org.mozilla.gecko.overlays.ACTION_SHARE"; + + /* + * OverlayIntentHandler service intent extra field keys. + */ + + // The URL/title of the page being shared + public static final String EXTRA_URL = "URL"; + public static final String EXTRA_TITLE = "TITLE"; + + // The optional extra Parcelable parameters for a ShareMethod. + public static final String EXTRA_PARAMETERS = "EXTRA"; + + // The extra field key used for holding the ShareMethod.Type we wish to use for an operation. + public static final String EXTRA_SHARE_METHOD = "SHARE_METHOD"; + + /* + * ShareMethod UI event intent constants. Broadcast by ShareMethods using LocalBroadcastManager + * when state has changed that requires an update of any currently-displayed share UI. + */ + + /* + * Action for a ShareMethod UI event. + * + * Intent parameters: + * + * $EXTRA_SHARE_METHOD: The ShareType to which this event relates. + * ... ShareType-specific parameters as desired... (optional) + */ + public static final String SHARE_METHOD_UI_EVENT = "org.mozilla.gecko.overlays.ACTION_SHARE_METHOD_UI_EVENT"; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java new file mode 100644 index 000000000..7182fcce7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java @@ -0,0 +1,126 @@ +/* -*- 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.overlays.service; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; + +import org.mozilla.gecko.overlays.service.sharemethods.AddBookmark; +import org.mozilla.gecko.overlays.service.sharemethods.SendTab; +import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.EnumMap; +import java.util.Map; + +import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_PREPARE_SHARE; +import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_SHARE; + +/** + * A service to receive requests from overlays to perform actions. + * See OverlayConstants for details of the intent API supported by this service. + * + * Currently supported operations are: + * + * Add bookmark* + * Send tab (delegates to Sync's existing handler) + * Future: Load page in background. + * + * * Neither of these incur a page fetch on the service... yet. That will require headless Gecko, + * something we're yet to have. Refactoring Gecko as a service itself and restructing the rest of + * the app to talk to it seems like the way to go there. + */ +public class OverlayActionService extends Service { + private static final String LOGTAG = "GeckoOverlayService"; + + // Map used for selecting the appropriate helper object when handling a share. + final Map<ShareMethod.Type, ShareMethod> shareTypes = new EnumMap<>(ShareMethod.Type.class); + + // Map relating Strings representing share types to the corresponding ShareMethods. + // Share methods are initialised (and shown in the UI) in the order they are given here. + // This map is used to look up the appropriate ShareMethod when handling a request, as well as + // for identifying which ShareMethod needs re-initialising in response to such an intent (which + // will be necessary in situations such as the deletion of Sync accounts). + + // Not a bindable service. + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + return START_NOT_STICKY; + } + + // Dispatch intent to appropriate method according to its action. + String action = intent.getAction(); + + switch (action) { + case ACTION_SHARE: + handleShare(intent); + break; + case ACTION_PREPARE_SHARE: + initShareMethods(getApplicationContext()); + break; + default: + throw new IllegalArgumentException("Unsupported intent action: " + action); + } + + return START_NOT_STICKY; + } + + /** + * Reinitialise all ShareMethods, causing them to broadcast any UI update events necessary. + */ + private void initShareMethods(final Context context) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + shareTypes.clear(); + + shareTypes.put(ShareMethod.Type.ADD_BOOKMARK, new AddBookmark(context)); + shareTypes.put(ShareMethod.Type.SEND_TAB, new SendTab(context)); + } + }); + } + + public void handleShare(final Intent intent) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + ShareData shareData; + try { + shareData = ShareData.fromIntent(intent); + } catch (IllegalArgumentException e) { + Log.e(LOGTAG, "Error parsing share intent: ", e); + return; + } + + ShareMethod shareMethod = shareTypes.get(shareData.shareMethodType); + + final ShareMethod.Result result = shareMethod.handle(shareData); + // Dispatch the share to the targeted ShareMethod. + switch (result) { + case SUCCESS: + Log.d(LOGTAG, "Share was successful"); + break; + case TRANSIENT_FAILURE: + // Fall-through + case PERMANENT_FAILURE: + Log.e(LOGTAG, "Share failed: " + result); + break; + default: + throw new IllegalStateException("Unknown share method result code: " + result); + } + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java new file mode 100644 index 000000000..df233d74a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java @@ -0,0 +1,48 @@ +/* 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.overlays.service; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcelable; +import org.mozilla.gecko.overlays.OverlayConstants; +import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod; + +import static org.mozilla.gecko.overlays.OverlayConstants.EXTRA_SHARE_METHOD; + +/** + * Class to hold information related to a particular request to perform a share. + */ +public class ShareData { + private static final String LOGTAG = "GeckoShareRequest"; + + public final String url; + public final String title; + public final Parcelable extra; + public final ShareMethod.Type shareMethodType; + + public ShareData(String url, String title, Parcelable extra, ShareMethod.Type shareMethodType) { + if (url == null) { + throw new IllegalArgumentException("Null url passed to ShareData!"); + } + + this.url = url; + this.title = title; + this.extra = extra; + this.shareMethodType = shareMethodType; + } + + public static ShareData fromIntent(Intent intent) { + Bundle extras = intent.getExtras(); + + // Fish the parameters out of the Intent. + final String url = extras.getString(OverlayConstants.EXTRA_URL); + final String title = extras.getString(OverlayConstants.EXTRA_TITLE); + final Parcelable extra = extras.getParcelable(OverlayConstants.EXTRA_PARAMETERS); + ShareMethod.Type shareMethodType = (ShareMethod.Type) extras.get(EXTRA_SHARE_METHOD); + + return new ShareData(url, title, extra, shareMethodType); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java new file mode 100644 index 000000000..71931e683 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java @@ -0,0 +1,30 @@ +/* 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.overlays.service.sharemethods; + +import android.content.ContentResolver; +import android.content.Context; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.LocalBrowserDB; +import org.mozilla.gecko.overlays.service.ShareData; + +public class AddBookmark extends ShareMethod { + private static final String LOGTAG = "GeckoAddBookmark"; + + @Override + public Result handle(ShareData shareData) { + ContentResolver resolver = context.getContentResolver(); + + LocalBrowserDB browserDB = new LocalBrowserDB(GeckoProfile.DEFAULT_PROFILE); + browserDB.addBookmark(resolver, shareData.title, shareData.url); + + return Result.SUCCESS; + } + + public AddBookmark(Context context) { + super(context); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java new file mode 100644 index 000000000..5abcbd99f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java @@ -0,0 +1,296 @@ +/* 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.overlays.service.sharemethods; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.content.LocalBroadcastManager; +import android.util.Log; + +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.db.TabsAccessor; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.fxa.login.State; +import org.mozilla.gecko.overlays.OverlayConstants; +import org.mozilla.gecko.overlays.service.ShareData; +import org.mozilla.gecko.sync.CommandProcessor; +import org.mozilla.gecko.sync.CommandRunner; +import org.mozilla.gecko.sync.GlobalSession; +import org.mozilla.gecko.sync.SyncConfiguration; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * ShareMethod implementation to handle Sync's "Send tab to device" mechanism. + * See OverlayConstants for documentation of OverlayIntentHandler service intent API (which is how + * this class is chiefly interacted with). + */ +public class SendTab extends ShareMethod { + private static final String LOGTAG = "GeckoSendTab"; + + // Key used in the extras Bundle in the share intent used for a send tab ShareMethod. + public static final String SEND_TAB_TARGET_DEVICES = "SEND_TAB_TARGET_DEVICES"; + + // Key used in broadcast intent from SendTab ShareMethod specifying available RemoteClients. + public static final String EXTRA_REMOTE_CLIENT_RECORDS = "RECORDS"; + + // The intent we should dispatch when the button for this ShareMethod is tapped, instead of + // taking the normal action (e.g., "Set up Sync!") + public static final String OVERRIDE_INTENT = "OVERRIDE_INTENT"; + + private Set<String> validGUIDs; + + // A TabSender appropriate to the account type we're connected to. + private TabSender tabSender; + + @Override + public Result handle(ShareData shareData) { + if (shareData.extra == null) { + Log.e(LOGTAG, "No target devices specified!"); + + // Retrying with an identical lack of devices ain't gonna fix it... + return Result.PERMANENT_FAILURE; + } + + String[] targetGUIDs = ((Bundle) shareData.extra).getStringArray(SEND_TAB_TARGET_DEVICES); + + // Ensure all target GUIDs are devices we actually know about. + if (!validGUIDs.containsAll(Arrays.asList(targetGUIDs))) { + // Find the set of invalid GUIDs to provide a nice error message. + Log.e(LOGTAG, "Not all provided GUIDs are real devices:"); + for (String targetGUID : targetGUIDs) { + if (!validGUIDs.contains(targetGUID)) { + Log.e(LOGTAG, "Invalid GUID: " + targetGUID); + } + } + + return Result.PERMANENT_FAILURE; + } + + Log.i(LOGTAG, "Send tab handler invoked."); + + final CommandProcessor processor = CommandProcessor.getProcessor(); + + final String accountGUID = tabSender.getAccountGUID(); + Log.d(LOGTAG, "Retrieved local account GUID '" + accountGUID + "'."); + + if (accountGUID == null) { + Log.e(LOGTAG, "Cannot determine account GUID"); + + // It's not completely out of the question that a background sync might come along and + // fix everything for us... + return Result.TRANSIENT_FAILURE; + } + + // Queue up the share commands for each destination device. + // Remember that ShareMethod.handle is always run on the background thread, so the database + // access here is of no concern. + for (int i = 0; i < targetGUIDs.length; i++) { + processor.sendURIToClientForDisplay(shareData.url, targetGUIDs[i], shareData.title, accountGUID, context); + } + + // Request an immediate sync to push these new commands to the network ASAP. + Log.i(LOGTAG, "Requesting immediate clients stage sync."); + tabSender.sync(); + + return Result.SUCCESS; + // ... Probably. + } + + /** + * Get an Intent suitable for broadcasting the UI state of this ShareMethod. + * The caller shall populate the intent with the actual state. + */ + private Intent getUIStateIntent() { + Intent uiStateIntent = new Intent(OverlayConstants.SHARE_METHOD_UI_EVENT); + uiStateIntent.putExtra(OverlayConstants.EXTRA_SHARE_METHOD, (Parcelable) Type.SEND_TAB); + return uiStateIntent; + } + + /** + * Broadcast the given intent to any UIs that may be listening. + */ + private void broadcastUIState(Intent uiStateIntent) { + LocalBroadcastManager.getInstance(context).sendBroadcast(uiStateIntent); + } + + /** + * Load the state of the user's Firefox Sync accounts and broadcast it to any registered + * listeners. This will cause any UIs that may exist that depend on this information to update. + */ + public SendTab(Context aContext) { + super(aContext); + // Initialise the UI state intent... + + // Determine if the user has a new or old style sync account and load the available sync + // clients for it. + final AccountManager accountManager = AccountManager.get(context); + final Account[] fxAccounts = accountManager.getAccountsByType(FxAccountConstants.ACCOUNT_TYPE); + + if (fxAccounts.length > 0) { + final AndroidFxAccount fxAccount = new AndroidFxAccount(context, fxAccounts[0]); + if (fxAccount.getState().getNeededAction() != State.Action.None) { + // We have a Firefox Account, but it's definitely not able to send a tab + // right now. Redirect to the status activity. + Log.w(LOGTAG, "Firefox Account named like " + fxAccount.getObfuscatedEmail() + + " needs action before it can send a tab; redirecting to status activity."); + + setOverrideIntentAction(FxAccountConstants.ACTION_FXA_STATUS); + return; + } + + tabSender = new FxAccountTabSender(fxAccount); + + updateClientList(tabSender); + + Log.i(LOGTAG, "Allowing tab send for Firefox Account."); + registerDisplayURICommand(); + return; + } + + // Have registered UIs offer to set up a Firefox Account. + setOverrideIntentAction(FxAccountConstants.ACTION_FXA_GET_STARTED); + } + + /** + * Load the list of Sync clients that are not this device using the given TabSender. + */ + private void updateClientList(TabSender tabSender) { + Collection<RemoteClient> otherClients = getOtherClients(tabSender); + + // Put the list of RemoteClients into the uiStateIntent and broadcast it. + RemoteClient[] records = new RemoteClient[otherClients.size()]; + records = otherClients.toArray(records); + + validGUIDs = new HashSet<>(); + + for (RemoteClient client : otherClients) { + validGUIDs.add(client.guid); + } + + if (validGUIDs.isEmpty()) { + // Guess we'd better override. We have no clients. + // This does the broadcast for us. + setOverrideIntentAction(FxAccountConstants.ACTION_FXA_GET_STARTED); + return; + } + + Intent uiStateIntent = getUIStateIntent(); + uiStateIntent.putExtra(EXTRA_REMOTE_CLIENT_RECORDS, records); + broadcastUIState(uiStateIntent); + } + + /** + * Record our intention to redirect the user to a different activity when they attempt to share + * with us, usually because we found something wrong with their Sync account (a need to login, + * register, etc.) + * This will be recorded in the OVERRIDE_INTENT field of the UI broadcast. Consumers should + * dispatch this intent instead of attempting to share with this ShareMethod whenever it is + * non-null. + * + * @param action to launch instead of invoking a share. + */ + protected void setOverrideIntentAction(final String action) { + Intent intent = new Intent(action); + // Per http://stackoverflow.com/a/8992365, this triggers a known bug with + // the soft keyboard not being shown for the started activity. Why, Android, why? + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + Intent uiStateIntent = getUIStateIntent(); + uiStateIntent.putExtra(OVERRIDE_INTENT, intent); + + broadcastUIState(uiStateIntent); + } + + private static void registerDisplayURICommand() { + final CommandProcessor processor = CommandProcessor.getProcessor(); + processor.registerCommand("displayURI", new CommandRunner(3) { + @Override + public void executeCommand(final GlobalSession session, List<String> args) { + CommandProcessor.displayURI(args, session.getContext()); + } + }); + } + + /** + * @return A collection of unique remote clients sorted by most recently used. + */ + protected Collection<RemoteClient> getOtherClients(final TabSender sender) { + if (sender == null) { + Log.w(LOGTAG, "No tab sender when fetching other client IDs."); + return Collections.emptyList(); + } + + final BrowserDB browserDB = BrowserDB.from(context); + final TabsAccessor tabsAccessor = browserDB.getTabsAccessor(); + final Cursor remoteTabsCursor = tabsAccessor.getRemoteClientsByRecencyCursor(context); + try { + if (remoteTabsCursor.getCount() == 0) { + return Collections.emptyList(); + } + return tabsAccessor.getClientsWithoutTabsByRecencyFromCursor(remoteTabsCursor); + } finally { + remoteTabsCursor.close(); + } + } + + /** + * Inteface for interacting with Sync accounts. Used to hide the difference in implementation + * between FXA and "old sync" accounts when sending tabs. + */ + private interface TabSender { + public static final String[] STAGES_TO_SYNC = new String[] { "clients", "tabs" }; + + /** + * @return Return null if the account isn't correctly initialized. Return + * the account GUID otherwise. + */ + String getAccountGUID(); + + /** + * Sync this account, specifying only clients and tabs as the engines to sync. + */ + void sync(); + } + + private static class FxAccountTabSender implements TabSender { + private final AndroidFxAccount fxAccount; + + public FxAccountTabSender(AndroidFxAccount fxa) { + fxAccount = fxa; + } + + @Override + public String getAccountGUID() { + try { + final SharedPreferences prefs = fxAccount.getSyncPrefs(); + return prefs.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null); + } catch (Exception e) { + Log.w(LOGTAG, "Could not get Firefox Account parameters or preferences; aborting."); + return null; + } + } + + @Override + public void sync() { + fxAccount.requestImmediateSync(STAGES_TO_SYNC, null); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java new file mode 100644 index 000000000..768176d63 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java @@ -0,0 +1,82 @@ +/*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.overlays.service.sharemethods; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.gecko.overlays.service.ShareData; + +/** + * Represents a method of sharing a URL/title. Add a bookmark? Send to a device? Add to reading list? + */ +public abstract class ShareMethod { + protected final Context context; + + public ShareMethod(Context aContext) { + context = aContext; + } + + /** + * Perform a share for the given title/URL combination. Called on the background thread by the + * handler service when a request is made. The "extra" parameter is provided should a ShareMethod + * desire to handle the share differently based on some additional parameters. + * + * @param title The page title for the page being shared. May be null if none can be found. + * @param url The URL of the page to be shared. Never null. + * @param extra A Parcelable of ShareMethod-specific parameters that may be provided by the + * caller. Generally null, but this field may be used to provide extra input to + * the ShareMethod (such as the device to share to in the case of SendTab). + * @return true if the attempt to share was a success. False in the event of an error. + */ + public abstract Result handle(ShareData shareData); + + /** + * Enum representing the possible results of performing a share. + */ + public static enum Result { + // Victory! + SUCCESS, + + // Failure, but retrying the same action again might lead to success. + TRANSIENT_FAILURE, + + // Failure, and you're not going to succeed until you reinitialise the ShareMethod (ie. + // until you repeat the entire share action). Examples include broken Sync accounts, or + // Sync accounts with no valid target devices (so the only way to fix this is to add some + // and try again: pushing a retry button isn't sane). + PERMANENT_FAILURE + } + + /** + * Enum representing types of ShareMethod. Parcelable so it may be efficiently used in Intents. + */ + public static enum Type implements Parcelable { + ADD_BOOKMARK, + SEND_TAB; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeInt(ordinal()); + } + + public static final Creator<Type> CREATOR = new Creator<Type>() { + @Override + public Type createFromParcel(final Parcel source) { + return Type.values()[source.readInt()]; + } + + @Override + public Type[] newArray(final int size) { + return new Type[size]; + } + }; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java new file mode 100644 index 000000000..8b7bc872b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java @@ -0,0 +1,128 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.overlays.ui; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * A button in the share overlay, such as the "Add to Reading List" button. + * Has an associated icon and label, and two states: enabled and disabled. + * + * When disabled, tapping results in a "pop" animation causing the icon to pulse. When enabled, + * tapping calls the OnClickListener set by the consumer in the usual way. + */ +public class OverlayDialogButton extends LinearLayout { + private static final String LOGTAG = "GeckoOverlayDialogButton"; + + // We can't use super.isEnabled(), since we want to stay clickable in disabled state. + private boolean isEnabled = true; + + private final ImageView iconView; + private final TextView labelView; + + private String enabledText = ""; + private String disabledText = ""; + + private OnClickListener enabledOnClickListener; + + public OverlayDialogButton(Context context) { + this(context, null); + } + + public OverlayDialogButton(Context context, AttributeSet attrs) { + super(context, attrs); + + setOrientation(LinearLayout.HORIZONTAL); + + LayoutInflater.from(context).inflate(R.layout.overlay_share_button, this); + + iconView = (ImageView) findViewById(R.id.overlaybtn_icon); + labelView = (TextView) findViewById(R.id.overlaybtn_label); + + super.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + + if (isEnabled) { + if (enabledOnClickListener != null) { + enabledOnClickListener.onClick(v); + } else { + Log.e(LOGTAG, "enabledOnClickListener is null."); + } + } else { + Animation anim = AnimationUtils.loadAnimation(getContext(), R.anim.overlay_pop); + iconView.startAnimation(anim); + } + } + }); + + final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.OverlayDialogButton); + + Drawable drawable = typedArray.getDrawable(R.styleable.OverlayDialogButton_drawable); + if (drawable != null) { + setDrawable(drawable); + } + + String disabledText = typedArray.getString(R.styleable.OverlayDialogButton_disabledText); + if (disabledText != null) { + this.disabledText = disabledText; + } + + String enabledText = typedArray.getString(R.styleable.OverlayDialogButton_enabledText); + if (enabledText != null) { + this.enabledText = enabledText; + } + + typedArray.recycle(); + + setEnabled(true); + } + + public void setDrawable(Drawable drawable) { + iconView.setImageDrawable(drawable); + } + + public void setText(String text) { + labelView.setText(text); + } + + @Override + public void setOnClickListener(OnClickListener listener) { + enabledOnClickListener = listener; + } + + /** + * Set the enabledness state of this view. We don't call super.setEnabled, as we want to remain + * clickable even in the disabled state (but with a different click listener). + */ + @Override + public void setEnabled(boolean enabled) { + isEnabled = enabled; + iconView.setEnabled(enabled); + labelView.setEnabled(enabled); + + if (enabled) { + setText(enabledText); + } else { + setText(disabledText); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java new file mode 100644 index 000000000..08e9c59f5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java @@ -0,0 +1,185 @@ +/* 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.overlays.ui; + +import java.util.Collection; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.overlays.ui.SendTabList.State; + +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +public class SendTabDeviceListArrayAdapter extends ArrayAdapter<RemoteClient> { + @SuppressWarnings("unused") + private static final String LOGTAG = "GeckoSendTabAdapter"; + + private State currentState; + + // String to display when in a "button-like" special state. Instead of using a + // RemoteClient we override the rendering using this string. + private String dummyRecordName; + + private final SendTabTargetSelectedListener listener; + + private Collection<RemoteClient> records; + + // The AlertDialog to show in the event the record is pressed while in the SHOW_DEVICES state. + // This will show the user a prompt to select a device from a longer list of devices. + private AlertDialog dialog; + + public SendTabDeviceListArrayAdapter(Context context, SendTabTargetSelectedListener aListener) { + super(context, R.layout.overlay_share_send_tab_item, R.id.overlaybtn_label); + + listener = aListener; + + // We do this manually and avoid multiple notifications when doing compound operations. + setNotifyOnChange(false); + } + + /** + * Get an array of the contents of this adapter were it in the LIST state. + * Useful for determining the "real" contents of the adapter. + */ + public RemoteClient[] toArray() { + return records.toArray(new RemoteClient[records.size()]); + } + + public void setRemoteClientsList(Collection<RemoteClient> remoteClientsList) { + records = remoteClientsList; + updateRecordList(); + } + + /** + * Ensure the contents of the Adapter are synchronised with the `records` field. This may not + * be the case if records has recently changed, or if we have experienced a state change. + */ + public void updateRecordList() { + if (currentState != State.LIST) { + return; + } + + clear(); + + setNotifyOnChange(false); // So we don't notify for each add. + addAll(records); + + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + final Context context = getContext(); + + // Reuse View objects if they exist. + OverlayDialogButton row = (OverlayDialogButton) convertView; + if (row == null) { + row = (OverlayDialogButton) View.inflate(context, R.layout.overlay_share_send_tab_item, null); + } + + // The first view in the list has a unique style. + if (position == 0) { + row.setBackgroundResource(R.drawable.overlay_share_button_background_first); + } else { + row.setBackgroundResource(R.drawable.overlay_share_button_background); + } + + if (currentState != State.LIST) { + // If we're in a special "Button-like" state, use the override string and a generic icon. + final Drawable sendTabIcon = context.getResources().getDrawable(R.drawable.shareplane); + row.setText(dummyRecordName); + row.setDrawable(sendTabIcon); + } + + // If we're just a button to launch the dialog, set the listener and abort. + if (currentState == State.SHOW_DEVICES) { + row.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + dialog.show(); + } + }); + + return row; + } + + // The remaining states delegate to the SentTabTargetSelectedListener. + final RemoteClient remoteClient = getItem(position); + if (currentState == State.LIST) { + final Drawable clientIcon = context.getResources().getDrawable(getImage(remoteClient)); + row.setText(remoteClient.name); + row.setDrawable(clientIcon); + + final String listenerGUID = remoteClient.guid; + + row.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + listener.onSendTabTargetSelected(listenerGUID); + } + }); + } else { + row.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + listener.onSendTabActionSelected(); + } + }); + } + + return row; + } + + private static int getImage(RemoteClient record) { + if ("mobile".equals(record.deviceType)) { + return R.drawable.device_mobile; + } + + return R.drawable.device_desktop; + } + + public void switchState(State newState) { + if (currentState == newState) { + return; + } + + currentState = newState; + + switch (newState) { + case LIST: + updateRecordList(); + break; + case NONE: + showDummyRecord(getContext().getResources().getString(R.string.overlay_share_send_tab_btn_label)); + break; + case SHOW_DEVICES: + showDummyRecord(getContext().getResources().getString(R.string.overlay_share_send_other)); + break; + default: + throw new IllegalStateException("Unexpected state transition: " + newState); + } + } + + /** + * Set the dummy override string to the given value and clear the list. + */ + private void showDummyRecord(String name) { + dummyRecordName = name; + clear(); + add(null); + notifyDataSetChanged(); + } + + public void setDialog(AlertDialog aDialog) { + dialog = aDialog; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java new file mode 100644 index 000000000..4fc6caaa9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java @@ -0,0 +1,150 @@ +/* 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.overlays.ui; + +import static org.mozilla.gecko.overlays.ui.SendTabList.State.LOADING; +import static org.mozilla.gecko.overlays.ui.SendTabList.State.SHOW_DEVICES; + +import java.util.Arrays; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.RemoteClient; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.util.AttributeSet; +import android.widget.ListAdapter; +import android.widget.ListView; + +/** + * The SendTab button has a few different states depending on the available devices (and whether + * we've loaded them yet...) + * + * Initially, the view resembles a disabled button. (the LOADING state) + * Once state is loaded from Sync's database, we know how many devices the user may send their tab + * to. + * + * If there are no targets, the user was found to not have a Sync account, or their Sync account is + * in a state that prevents it from being able to send a tab, we enter the NONE state and display + * a generic button which launches an appropriate activity to fix the situation when tapped (such + * as the set up Sync wizard). + * + * If the number of targets does not MAX_INLINE_SYNC_TARGETS, we present a button for each of them. + * (the LIST state) + * + * Otherwise, we enter the SHOW_DEVICES state, in which we display a "Send to other devices" button + * that takes the user to a menu for selecting a target device from their complete list of many + * devices. + */ +public class SendTabList extends ListView { + @SuppressWarnings("unused") + private static final String LOGTAG = "GeckoSendTabList"; + + // The maximum number of target devices to show in the main list. Further devices are available + // from a secondary menu. + public static final int MAXIMUM_INLINE_ELEMENTS = R.integer.number_of_inline_share_devices; + + private SendTabDeviceListArrayAdapter clientListAdapter; + + // Listener to fire when a share target is selected (either directly or via the prompt) + private SendTabTargetSelectedListener listener; + + private final State currentState = LOADING; + + /** + * Enum defining the states this view may occupy. + */ + public enum State { + // State when no sync targets exist (a generic "Send to Firefox Sync" button which launches + // an activity to set it up) + NONE, + + // As NONE, but disabled. Initial state. Used until we get information from Sync about what + // we really want. + LOADING, + + // A list of devices to share to. + LIST, + + // A single button prompting the user to select a device to share to. + SHOW_DEVICES + } + + public SendTabList(Context context) { + super(context); + } + + public SendTabList(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void setAdapter(ListAdapter adapter) { + if (!(adapter instanceof SendTabDeviceListArrayAdapter)) { + throw new IllegalArgumentException("adapter must be a SendTabDeviceListArrayAdapter instance"); + } + + clientListAdapter = (SendTabDeviceListArrayAdapter) adapter; + super.setAdapter(adapter); + } + + public void setSendTabTargetSelectedListener(SendTabTargetSelectedListener aListener) { + listener = aListener; + } + + public void switchState(State state) { + if (state == currentState) { + return; + } + + clientListAdapter.switchState(state); + if (state == SHOW_DEVICES) { + clientListAdapter.setDialog(getDialog()); + } + } + + public void setSyncClients(final RemoteClient[] c) { + final RemoteClient[] clients = c == null ? new RemoteClient[0] : c; + + clientListAdapter.setRemoteClientsList(Arrays.asList(clients)); + } + + /** + * Get an AlertDialog listing all devices, allowing the user to select the one they want. + * Used when more than MAXIMUM_INLINE_ELEMENTS devices are found (to avoid displaying them all + * inline and looking crazy). + */ + public AlertDialog getDialog() { + final Context context = getContext(); + + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + + final RemoteClient[] records = clientListAdapter.toArray(); + final String[] dialogElements = new String[records.length]; + + for (int i = 0; i < records.length; i++) { + dialogElements[i] = records[i].name; + } + + builder.setTitle(R.string.overlay_share_select_device) + .setItems(dialogElements, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int index) { + listener.onSendTabTargetSelected(records[index].guid); + } + }) + .setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY, "device_selection_cancel"); + } + }); + + return builder.create(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java new file mode 100644 index 000000000..79da526da --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java @@ -0,0 +1,25 @@ +/* + * 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.overlays.ui; + +/** + * Interface for classes that wish to listen for the selection of an element from a SendTabList. + */ +public interface SendTabTargetSelectedListener { + /** + * Called when a row in the SendTabList is clicked. + * + * @param targetGUID The GUID of the ClientRecord the element represents (if any, otherwise null) + */ + public void onSendTabTargetSelected(String targetGUID); + + /** + * Called when the overall Send Tab item is clicked. + * + * This implies that the clients list was unavailable. + */ + public void onSendTabActionSelected(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java new file mode 100644 index 000000000..156fdda2a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java @@ -0,0 +1,493 @@ +/* -*- 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.overlays.ui; + +import java.net.URISyntaxException; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.LocalBrowserDB; +import org.mozilla.gecko.db.RemoteClient; +import org.mozilla.gecko.overlays.OverlayConstants; +import org.mozilla.gecko.overlays.service.OverlayActionService; +import org.mozilla.gecko.overlays.service.sharemethods.SendTab; +import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod; +import org.mozilla.gecko.sync.setup.activities.WebURLFinder; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; +import android.view.animation.AnimationSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.TextView; +import android.widget.Toast; + +/** + * A transparent activity that displays the share overlay. + */ +public class ShareDialog extends Locales.LocaleAwareActivity implements SendTabTargetSelectedListener { + + private enum State { + DEFAULT, + DEVICES_ONLY // Only display the device list. + } + + private static final String LOGTAG = "GeckoShareDialog"; + + /** Flag to indicate that we should always show the device list; specific to this release channel. **/ + public static final String INTENT_EXTRA_DEVICES_ONLY = + AppConstants.ANDROID_PACKAGE_NAME + ".intent.extra.DEVICES_ONLY"; + + /** The maximum number of devices we'll show in the dialog when in State.DEFAULT. **/ + private static final int MAXIMUM_INLINE_DEVICES = 2; + + private State state; + + private SendTabList sendTabList; + private OverlayDialogButton bookmarkButton; + + // The bookmark button drawable set from XML - we need this to reset state. + private Drawable bookmarkButtonDrawable; + + private String url; + private String title; + + // The override intent specified by SendTab (if any). See SendTab.java. + private Intent sendTabOverrideIntent; + + // Flag set during animation to prevent animation multiple-start. + private boolean isAnimating; + + // BroadcastReceiver to receive callbacks from ShareMethods which are changing state. + private final BroadcastReceiver uiEventListener = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + ShareMethod.Type originShareMethod = intent.getParcelableExtra(OverlayConstants.EXTRA_SHARE_METHOD); + switch (originShareMethod) { + case SEND_TAB: + handleSendTabUIEvent(intent); + break; + default: + throw new IllegalArgumentException("UIEvent broadcast from ShareMethod that isn't thought to support such broadcasts."); + } + } + }; + + /** + * Called when a UI event broadcast is received from the SendTab ShareMethod. + */ + protected void handleSendTabUIEvent(Intent intent) { + sendTabOverrideIntent = intent.getParcelableExtra(SendTab.OVERRIDE_INTENT); + + RemoteClient[] remoteClientRecords = (RemoteClient[]) intent.getParcelableArrayExtra(SendTab.EXTRA_REMOTE_CLIENT_RECORDS); + + // Escape hatch: we don't show the option to open this dialog in this state so this should + // never be run. However, due to potential inconsistencies in synced client state + // (e.g. bug 1122302 comment 47), we might fail. + if (state == State.DEVICES_ONLY && + (remoteClientRecords == null || remoteClientRecords.length == 0)) { + Log.e(LOGTAG, "In state: " + State.DEVICES_ONLY + " and received 0 synced clients. Finishing..."); + Toast.makeText(this, getResources().getText(R.string.overlay_no_synced_devices), Toast.LENGTH_SHORT) + .show(); + finish(); + return; + } + + sendTabList.setSyncClients(remoteClientRecords); + + if (state == State.DEVICES_ONLY || + remoteClientRecords == null || + remoteClientRecords.length <= MAXIMUM_INLINE_DEVICES) { + // Show the list of devices in-line. + sendTabList.switchState(SendTabList.State.LIST); + + // The first item in the list has a unique style. If there are no items + // in the list, the next button appears to be the first item in the list. + // + // Note: a more thorough implementation would add this + // (and other non-ListView buttons) into a custom ListView. + if (remoteClientRecords == null || remoteClientRecords.length == 0) { + bookmarkButton.setBackgroundResource( + R.drawable.overlay_share_button_background_first); + } + return; + } + + // Just show a button to launch the list of devices to choose from. + sendTabList.switchState(SendTabList.State.SHOW_DEVICES); + } + + @Override + protected void onDestroy() { + // Remove the listener when the activity is destroyed: we no longer care. + // Note: The activity can be destroyed without onDestroy being called. However, this occurs + // only when the application is killed, something which also kills the registered receiver + // list, and the service, and everything else: so we don't care. + LocalBroadcastManager.getInstance(this).unregisterReceiver(uiEventListener); + + super.onDestroy(); + } + + /** + * Show a toast indicating we were started with no URL, and then stop. + */ + private void abortDueToNoURL() { + Log.e(LOGTAG, "Unable to process shared intent. No URL found!"); + + // Display toast notifying the user of failure (most likely a developer who screwed up + // trying to send a share intent). + Toast toast = Toast.makeText(this, getResources().getText(R.string.overlay_share_no_url), Toast.LENGTH_SHORT); + toast.show(); + finish(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.overlay_share_dialog); + + LocalBroadcastManager.getInstance(this).registerReceiver(uiEventListener, + new IntentFilter(OverlayConstants.SHARE_METHOD_UI_EVENT)); + + // Send tab. + sendTabList = (SendTabList) findViewById(R.id.overlay_send_tab_btn); + + // Register ourselves as both the listener and the context for the Adapter. + final SendTabDeviceListArrayAdapter adapter = new SendTabDeviceListArrayAdapter(this, this); + sendTabList.setAdapter(adapter); + sendTabList.setSendTabTargetSelectedListener(this); + + bookmarkButton = (OverlayDialogButton) findViewById(R.id.overlay_share_bookmark_btn); + + bookmarkButtonDrawable = bookmarkButton.getBackground(); + + // Bookmark button + bookmarkButton = (OverlayDialogButton) findViewById(R.id.overlay_share_bookmark_btn); + bookmarkButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + addBookmark(); + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + + final Intent intent = getIntent(); + + state = intent.getBooleanExtra(INTENT_EXTRA_DEVICES_ONLY, false) ? + State.DEVICES_ONLY : State.DEFAULT; + + // If the Activity is being reused, we need to reset the state. Ideally, we create a + // new instance for each call, but Android L breaks this (bug 1137928). + sendTabList.switchState(SendTabList.State.LOADING); + bookmarkButton.setBackgroundDrawable(bookmarkButtonDrawable); + + // The URL is usually hiding somewhere in the extra text. Extract it. + final String extraText = IntentUtils.getStringExtraSafe(intent, Intent.EXTRA_TEXT); + if (TextUtils.isEmpty(extraText)) { + abortDueToNoURL(); + return; + } + + final String pageUrl = new WebURLFinder(extraText).bestWebURL(); + if (TextUtils.isEmpty(pageUrl)) { + abortDueToNoURL(); + return; + } + + // Have the service start any initialisation work that's necessary for us to show the correct + // UI. The results of such work will come in via the BroadcastListener. + Intent serviceStartupIntent = new Intent(this, OverlayActionService.class); + serviceStartupIntent.setAction(OverlayConstants.ACTION_PREPARE_SHARE); + startService(serviceStartupIntent); + + // Start the slide-up animation. + getWindow().setWindowAnimations(0); + final Animation anim = AnimationUtils.loadAnimation(this, R.anim.overlay_slide_up); + findViewById(R.id.sharedialog).startAnimation(anim); + + // If provided, we use the subject text to give us something nice to display. + // If not, we wing it with the URL. + + // TODO: Consider polling Fennec databases to find better information to display. + final String subjectText = intent.getStringExtra(Intent.EXTRA_SUBJECT); + + final String telemetryExtras = "title=" + (subjectText != null); + if (subjectText != null) { + ((TextView) findViewById(R.id.title)).setText(subjectText); + } + + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.SHARE_OVERLAY, telemetryExtras); + + title = subjectText; + url = pageUrl; + + // Set the subtitle text on the view and cause it to marquee if it's too long (which it will + // be, since it's a URL). + final TextView subtitleView = (TextView) findViewById(R.id.subtitle); + subtitleView.setText(pageUrl); + subtitleView.setEllipsize(TextUtils.TruncateAt.MARQUEE); + subtitleView.setSingleLine(true); + subtitleView.setMarqueeRepeatLimit(5); + subtitleView.setSelected(true); + + final View titleView = findViewById(R.id.title); + + if (state == State.DEVICES_ONLY) { + bookmarkButton.setVisibility(View.GONE); + + titleView.setOnClickListener(null); + subtitleView.setOnClickListener(null); + return; + } + + bookmarkButton.setVisibility(View.VISIBLE); + + // Configure buttons. + final View.OnClickListener launchBrowser = new View.OnClickListener() { + @Override + public void onClick(View view) { + ShareDialog.this.launchBrowser(); + } + }; + + titleView.setOnClickListener(launchBrowser); + subtitleView.setOnClickListener(launchBrowser); + + final LocalBrowserDB browserDB = new LocalBrowserDB(getCurrentProfile()); + setButtonState(url, browserDB); + } + + @Override + protected void onNewIntent(final Intent intent) { + super.onNewIntent(intent); + + // The intent returned by getIntent is not updated automatically. + setIntent(intent); + } + + /** + * Sets the state of the bookmark/reading list buttons: they are disabled if the given URL is + * already in the corresponding list. + */ + private void setButtonState(final String pageURL, final LocalBrowserDB browserDB) { + new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) { + // Flags to hold the result + boolean isBookmark; + + @Override + protected Void doInBackground() { + final ContentResolver contentResolver = getApplicationContext().getContentResolver(); + + isBookmark = browserDB.isBookmark(contentResolver, pageURL); + + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + findViewById(R.id.overlay_share_bookmark_btn).setEnabled(!isBookmark); + } + }.execute(); + } + + /** + * Helper method to get an overlay service intent populated with the data held in this dialog. + */ + private Intent getServiceIntent(ShareMethod.Type method) { + final Intent serviceIntent = new Intent(this, OverlayActionService.class); + serviceIntent.setAction(OverlayConstants.ACTION_SHARE); + + serviceIntent.putExtra(OverlayConstants.EXTRA_SHARE_METHOD, (Parcelable) method); + serviceIntent.putExtra(OverlayConstants.EXTRA_URL, url); + serviceIntent.putExtra(OverlayConstants.EXTRA_TITLE, title); + + return serviceIntent; + } + + @Override + public void finish() { + finish(true); + } + + private void finish(final boolean shouldOverrideAnimations) { + super.finish(); + if (shouldOverrideAnimations) { + // Don't perform an activity-dismiss animation. + overridePendingTransition(0, 0); + } + } + + /* + * Button handlers. Send intents to the background service responsible for processing requests + * on Fennec in the background. (a nice extensible mechanism for "doing stuff without properly + * launching Fennec"). + */ + + @Override + public void onSendTabActionSelected() { + // This requires an override intent. + if (sendTabOverrideIntent == null) { + throw new IllegalStateException("sendTabOverrideIntent must not be null"); + } + + startActivity(sendTabOverrideIntent); + finish(); + } + + @Override + public void onSendTabTargetSelected(String targetGUID) { + // targetGUID being null with no override intent should be an impossible state. + if (targetGUID == null) { + throw new IllegalStateException("targetGUID must not be null"); + } + + Intent serviceIntent = getServiceIntent(ShareMethod.Type.SEND_TAB); + + // Currently, only one extra parameter is necessary (the GUID of the target device). + Bundle extraParameters = new Bundle(); + + // Future: Handle multiple-selection. Bug 1061297. + extraParameters.putStringArray(SendTab.SEND_TAB_TARGET_DEVICES, new String[] { targetGUID }); + + serviceIntent.putExtra(OverlayConstants.EXTRA_PARAMETERS, extraParameters); + + startService(serviceIntent); + animateOut(true); + + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.SHARE_OVERLAY, "sendtab"); + } + + public void addBookmark() { + startService(getServiceIntent(ShareMethod.Type.ADD_BOOKMARK)); + animateOut(true); + + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SHARE_OVERLAY, "bookmark"); + } + + public void launchBrowser() { + try { + // This can launch in the guest profile. Sorry. + final Intent i = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + startActivity(i); + } catch (URISyntaxException e) { + // Nothing much we can do. + } finally { + // Since we're changing apps, users expect the default app switch animations. + finish(false); + } + } + + private String getCurrentProfile() { + return GeckoProfile.DEFAULT_PROFILE; + } + + /** + * Slide the overlay down off the screen, display + * a check (if given), and finish the activity. + */ + private void animateOut(final boolean shouldDisplayConfirmation) { + if (isAnimating) { + return; + } + + isAnimating = true; + final Animation slideOutAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_slide_down); + + final Animation animationToFinishActivity; + if (!shouldDisplayConfirmation) { + animationToFinishActivity = slideOutAnim; + } else { + final View check = findViewById(R.id.check); + check.setVisibility(View.VISIBLE); + final Animation checkEntryAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_check_entry); + final Animation checkExitAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_check_exit); + checkExitAnim.setStartOffset(checkEntryAnim.getDuration() + 500); + + final AnimationSet checkAnimationSet = new AnimationSet(this, null); + checkAnimationSet.addAnimation(checkEntryAnim); + checkAnimationSet.addAnimation(checkExitAnim); + + check.startAnimation(checkAnimationSet); + animationToFinishActivity = checkExitAnim; + } + + findViewById(R.id.sharedialog).startAnimation(slideOutAnim); + animationToFinishActivity.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { /* Unused. */ } + + @Override + public void onAnimationEnd(Animation animation) { + finish(); + } + + @Override + public void onAnimationRepeat(Animation animation) { /* Unused. */ } + }); + + // Allows the user to dismiss the animation early. + setFullscreenFinishOnClickListener(); + } + + /** + * Sets a fullscreen {@link #finish()} click listener. We do this rather than attaching an + * onClickListener to the root View because in that case, we need to remove all of the + * existing listeners, which is less robust. + */ + private void setFullscreenFinishOnClickListener() { + final View clickTarget = findViewById(R.id.fullscreen_click_target); + clickTarget.setVisibility(View.VISIBLE); + clickTarget.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + finish(); + } + }); + } + + /** + * Close the dialog if back is pressed. + */ + @Override + public void onBackPressed() { + animateOut(false); + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY); + } + + /** + * Close the dialog if the anything that isn't a button is tapped. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + animateOut(false); + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY); + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java new file mode 100644 index 000000000..b68a018f2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java @@ -0,0 +1,24 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.util.AttributeSet; + +class AlignRightLinkPreference extends LinkPreference { + + public AlignRightLinkPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setLayoutResource(R.layout.preference_rightalign_icon); + } + + public AlignRightLinkPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setLayoutResource(R.layout.preference_rightalign_icon); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java new file mode 100644 index 000000000..bb71ce78b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java @@ -0,0 +1,230 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import android.content.ContentValues; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserContract.Bookmarks; +import org.mozilla.gecko.db.LocalBrowserDB; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.IconsHelper; +import org.mozilla.gecko.icons.storage.DiskStorage; + +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.BaseColumns; +import android.text.TextUtils; +import android.util.Log; + +import java.util.ArrayList; + +public class AndroidImport implements Runnable { + /** + * The Android M SDK removed several fields and methods from android.provider.Browser. This class is used as a + * replacement to support building with the new SDK but at the same time still use these fields on lower Android + * versions. + */ + private static class LegacyBrowserProvider { + public static final Uri BOOKMARKS_URI = Uri.parse("content://browser/bookmarks"); + + // Incomplete: This are just the fields we currently use in our code base + public static class BookmarkColumns implements BaseColumns { + public static final String URL = "url"; + public static final String VISITS = "visits"; + public static final String DATE = "date"; + public static final String BOOKMARK = "bookmark"; + public static final String TITLE = "title"; + public static final String CREATED = "created"; + public static final String FAVICON = "favicon"; + } + } + + public static final Uri SAMSUNG_BOOKMARKS_URI = Uri.parse("content://com.sec.android.app.sbrowser.browser/bookmarks"); + public static final Uri SAMSUNG_HISTORY_URI = Uri.parse("content://com.sec.android.app.sbrowser.browser/history"); + public static final String SAMSUNG_MANUFACTURER = "samsung"; + + private static final String LOGTAG = "AndroidImport"; + private final Context mContext; + private final Runnable mOnDoneRunnable; + private final ArrayList<ContentProviderOperation> mOperations; + private final ContentResolver mCr; + private final LocalBrowserDB mDB; + private final boolean mImportBookmarks; + private final boolean mImportHistory; + + public AndroidImport(Context context, Runnable onDoneRunnable, + boolean doBookmarks, boolean doHistory) { + mContext = context; + mOnDoneRunnable = onDoneRunnable; + mOperations = new ArrayList<ContentProviderOperation>(); + mCr = mContext.getContentResolver(); + mDB = new LocalBrowserDB(GeckoProfile.get(context).getName()); + mImportBookmarks = doBookmarks; + mImportHistory = doHistory; + } + + public void mergeBookmarks() { + Cursor cursor = null; + try { + cursor = query(LegacyBrowserProvider.BOOKMARKS_URI, + SAMSUNG_BOOKMARKS_URI, + LegacyBrowserProvider.BookmarkColumns.BOOKMARK + " = 1"); + + if (cursor != null) { + final int faviconCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.FAVICON); + final int titleCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.TITLE); + final int urlCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.URL); + // http://code.google.com/p/android/issues/detail?id=17969 + final int createCol = cursor.getColumnIndex(LegacyBrowserProvider.BookmarkColumns.CREATED); + + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + String url = cursor.getString(urlCol); + String title = cursor.getString(titleCol); + long created; + if (createCol >= 0) { + created = cursor.getLong(createCol); + } else { + created = System.currentTimeMillis(); + } + // Need to set it to the current time so Sync picks it up. + long modified = System.currentTimeMillis(); + byte[] data = cursor.getBlob(faviconCol); + mDB.updateBookmarkInBatch(mCr, mOperations, + url, title, null, -1, + created, modified, + BrowserContract.Bookmarks.DEFAULT_POSITION, + null, Bookmarks.TYPE_BOOKMARK); + if (data != null) { + storeBitmap(data, url); + } + cursor.moveToNext(); + } + } + } finally { + if (cursor != null) + cursor.close(); + } + + flushBatchOperations(); + } + + public void mergeHistory() { + ArrayList<ContentValues> visitsToSynthesize = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = query (LegacyBrowserProvider.BOOKMARKS_URI, + SAMSUNG_HISTORY_URI, + LegacyBrowserProvider.BookmarkColumns.BOOKMARK + " = 0 AND " + + LegacyBrowserProvider.BookmarkColumns.VISITS + " > 0"); + + if (cursor != null) { + final int dateCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.DATE); + final int faviconCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.FAVICON); + final int titleCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.TITLE); + final int urlCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.URL); + final int visitsCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.VISITS); + + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + String url = cursor.getString(urlCol); + String title = cursor.getString(titleCol); + long date = cursor.getLong(dateCol); + int visits = cursor.getInt(visitsCol); + byte[] data = cursor.getBlob(faviconCol); + mDB.updateHistoryInBatch(mCr, mOperations, url, title, date, visits); + if (data != null) { + storeBitmap(data, url); + } + ContentValues visitData = new ContentValues(); + visitData.put(LocalBrowserDB.HISTORY_VISITS_DATE, date); + visitData.put(LocalBrowserDB.HISTORY_VISITS_URL, url); + visitData.put(LocalBrowserDB.HISTORY_VISITS_COUNT, visits); + visitsToSynthesize.add(visitData); + cursor.moveToNext(); + } + } + } finally { + if (cursor != null) + cursor.close(); + } + + flushBatchOperations(); + + // Now that we have flushed history records, we need to synthesize individual visits. We have + // gathered information about all of the visits we need to synthesize into visitsForSynthesis. + mDB.insertVisitsFromImportHistoryInBatch(mCr, mOperations, visitsToSynthesize); + + flushBatchOperations(); + } + + private void storeBitmap(byte[] data, String url) { + if (TextUtils.isEmpty(url) || data == null) { + return; + } + + final Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length); + if (bitmap == null) { + return; + } + + final String iconUrl = IconsHelper.guessDefaultFaviconURL(url); + if (iconUrl == null) { + return; + } + + final DiskStorage storage = DiskStorage.get(mContext); + + storage.putIcon(url, bitmap); + storage.putMapping(url, iconUrl); + } + + protected Cursor query(Uri mainUri, Uri fallbackUri, String condition) { + final Cursor cursor = mCr.query(mainUri, null, condition, null, null); + if (Build.MANUFACTURER.equals(SAMSUNG_MANUFACTURER) && (cursor == null || cursor.getCount() == 0)) { + if (cursor != null) { + cursor.close(); + } + return mCr.query(fallbackUri, null, null, null, null); + } + return cursor; + } + + protected void flushBatchOperations() { + Log.d(LOGTAG, "Flushing " + mOperations.size() + " DB operations"); + try { + // We don't really care for the results, this is best-effort. + mCr.applyBatch(BrowserContract.AUTHORITY, mOperations); + } catch (RemoteException e) { + Log.e(LOGTAG, "Remote exception while updating db: ", e); + } catch (OperationApplicationException e) { + // Bug 716729 means this happens even in normal circumstances + Log.d(LOGTAG, "Error while applying database updates: ", e); + } + mOperations.clear(); + } + + @Override + public void run() { + if (mImportBookmarks) { + mergeBookmarks(); + } + if (mImportHistory) { + mergeHistory(); + } + + mOnDoneRunnable.run(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java new file mode 100644 index 000000000..0f1d3ec3f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java @@ -0,0 +1,112 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.Set; + +import android.app.ProgressDialog; +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.util.Log; + +class AndroidImportPreference extends MultiPrefMultiChoicePreference { + private static final String LOGTAG = "AndroidImport"; + public static final String PREF_KEY = "android.not_a_preference.import_android"; + private static final String PREF_KEY_PREFIX = "import_android.data."; + private final Context mContext; + + public static class Handler implements GeckoPreferences.PrefHandler { + public boolean setupPref(Context context, Preference pref) { + // Feature disabled on devices running Android M+ (Bug 1183559) + return Versions.preMarshmallow && Restrictions.isAllowed(context, Restrictable.IMPORT_SETTINGS); + } + + public void onChange(Context context, Preference pref, Object newValue) { } + } + + public AndroidImportPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (!positiveResult) + return; + + boolean bookmarksChecked = false; + boolean historyChecked = false; + + Set<String> values = getValues(); + + for (String value : values) { + // Import checkbox values are stored in Android prefs to + // remember their check states. The key names are import_android.data.X + String key = value.substring(PREF_KEY_PREFIX.length()); + if ("bookmarks".equals(key)) { + bookmarksChecked = true; + } else if ("history".equals(key)) { + historyChecked = true; + } + } + + runImport(bookmarksChecked, historyChecked); + } + + protected void runImport(final boolean doBookmarks, final boolean doHistory) { + Log.i(LOGTAG, "Importing Android history/bookmarks"); + if (!doBookmarks && !doHistory) { + return; + } + + final String dialogTitle; + if (doBookmarks && doHistory) { + dialogTitle = mContext.getString(R.string.bookmarkhistory_import_both); + } else if (doBookmarks) { + dialogTitle = mContext.getString(R.string.bookmarkhistory_import_bookmarks); + } else { + dialogTitle = mContext.getString(R.string.bookmarkhistory_import_history); + } + + final ProgressDialog dialog = + ProgressDialog.show(mContext, + dialogTitle, + mContext.getString(R.string.bookmarkhistory_import_wait), + true); + + final Runnable stopCallback = new Runnable() { + @Override + public void run() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + dialog.dismiss(); + } + }); + } + }; + + ThreadUtils.postToBackgroundThread( + // Constructing AndroidImport may need finding the profile, + // which hits disk, so it needs to go into a Runnable too. + new Runnable() { + @Override + public void run() { + new AndroidImport(mContext, stopCallback, doBookmarks, doHistory).run(); + } + } + ); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java new file mode 100644 index 000000000..fb4a8f751 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.preferences; + + +import android.content.res.Configuration; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import android.support.annotation.LayoutRes; +import android.support.annotation.Nullable; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatDelegate; +import android.support.v7.widget.Toolbar; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +/** + * A {@link android.preference.PreferenceActivity} which implements and proxies the necessary calls + * to be used with AppCompat. + * + * This technique can be used with an {@link android.app.Activity} class, not just + * {@link android.preference.PreferenceActivity}. + * + * This class was directly imported (without any modifications) from Android SDK examples, at: + * https://android.googlesource.com/platform/development/+/master/samples/Support7Demos/src/com/example/android/supportv7/app/AppCompatPreferenceActivity.java + */ +public abstract class AppCompatPreferenceActivity extends PreferenceActivity { + private AppCompatDelegate mDelegate; + @Override + protected void onCreate(Bundle savedInstanceState) { + getDelegate().installViewFactory(); + getDelegate().onCreate(savedInstanceState); + super.onCreate(savedInstanceState); + } + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + getDelegate().onPostCreate(savedInstanceState); + } + public ActionBar getSupportActionBar() { + return getDelegate().getSupportActionBar(); + } + public void setSupportActionBar(@Nullable Toolbar toolbar) { + getDelegate().setSupportActionBar(toolbar); + } + @Override + public MenuInflater getMenuInflater() { + return getDelegate().getMenuInflater(); + } + @Override + public void setContentView(@LayoutRes int layoutResID) { + getDelegate().setContentView(layoutResID); + } + @Override + public void setContentView(View view) { + getDelegate().setContentView(view); + } + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().setContentView(view, params); + } + @Override + public void addContentView(View view, ViewGroup.LayoutParams params) { + getDelegate().addContentView(view, params); + } + @Override + protected void onPostResume() { + super.onPostResume(); + getDelegate().onPostResume(); + } + @Override + protected void onTitleChanged(CharSequence title, int color) { + super.onTitleChanged(title, color); + getDelegate().setTitle(title); + } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + getDelegate().onConfigurationChanged(newConfig); + } + @Override + protected void onStop() { + super.onStop(); + getDelegate().onStop(); + } + @Override + protected void onDestroy() { + super.onDestroy(); + getDelegate().onDestroy(); + } + public void invalidateOptionsMenu() { + getDelegate().invalidateOptionsMenu(); + } + private AppCompatDelegate getDelegate() { + if (mDelegate == null) { + mDelegate = AppCompatDelegate.create(this, null); + } + return mDelegate; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java new file mode 100644 index 000000000..5218cd06d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java @@ -0,0 +1,37 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import java.util.HashSet; +import java.util.Set; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.util.PrefUtils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.Preference; + +public class ClearOnShutdownPref implements GeckoPreferences.PrefHandler { + public static final String PREF = GeckoPreferences.NON_PREF_PREFIX + "history.clear_on_exit"; + + @Override + public boolean setupPref(Context context, Preference pref) { + // The pref is initialized asynchronously. Read the pref explicitly + // here to make sure we have the data. + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context); + final Set<String> clearItems = PrefUtils.getStringSet(prefs, PREF, new HashSet<String>()); + ((ListCheckboxPreference) pref).setChecked(clearItems.size() > 0); + return true; + } + + @Override + @SuppressWarnings("unchecked") + public void onChange(Context context, Preference pref, Object newValue) { + final Set<String> vals = (Set<String>) newValue; + ((ListCheckboxPreference) pref).setChecked(vals.size() > 0); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java new file mode 100644 index 000000000..2934ca88e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java @@ -0,0 +1,44 @@ +/* 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.preferences; + +import android.content.Context; +import android.preference.CheckBoxPreference; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +/** + * Represents a Checkbox element in a preference menu. + * The title of the Checkbox can be larger than the view. + * In this case, it will be displayed in 2 or more lines. + * The default behavior of the class CheckBoxPreference + * doesn't wrap the title. + */ + +public class CustomCheckBoxPreference extends CheckBoxPreference { + + public CustomCheckBoxPreference(Context context) { + super(context); + } + + public CustomCheckBoxPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + public CustomCheckBoxPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + final TextView title = (TextView) view.findViewById(android.R.id.title); + if (title != null) { + title.setSingleLine(false); + title.setEllipsize(null); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java new file mode 100644 index 000000000..ee5a46bef --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java @@ -0,0 +1,72 @@ +/* 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.preferences; + +import android.content.Context; +import android.preference.PreferenceCategory; +import android.util.AttributeSet; + +public abstract class CustomListCategory extends PreferenceCategory { + protected CustomListPreference mDefaultReference; + + public CustomListCategory(Context context) { + super(context); + } + + public CustomListCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CustomListCategory(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onAttachedToActivity() { + super.onAttachedToActivity(); + + setOrderingAsAdded(true); + } + + /** + * Set the default to some available list item. Used if the current default is removed or + * disabled. + */ + protected void setFallbackDefault() { + if (getPreferenceCount() > 0) { + CustomListPreference aItem = (CustomListPreference) getPreference(0); + setDefault(aItem); + } + } + + /** + * Removes the given item from the set of available list items. + * This only updates the UI, so callers are responsible for persisting any state. + * + * @param item The given item to remove. + */ + public void uninstall(CustomListPreference item) { + removePreference(item); + if (item == mDefaultReference) { + // If the default is being deleted, set a new default. + setFallbackDefault(); + } + } + + /** + * Sets the given item as the current default. + * This only updates the UI, so callers are responsible for persisting any state. + * + * @param item The intended new default. + */ + public void setDefault(CustomListPreference item) { + if (mDefaultReference != null) { + mDefaultReference.setIsDefault(false); + } + + item.setIsDefault(true); + mDefaultReference = item; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java new file mode 100644 index 000000000..8b7e0e7b3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java @@ -0,0 +1,182 @@ +/* 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.preferences; + +import org.mozilla.gecko.R; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.preference.Preference; +import android.view.View; +import android.widget.TextView; + +/** + * Represents an element in a <code>CustomListCategory</code> preference menu. + * This preference con display a dialog when clicked, and also supports + * being set as a default item within the preference list category. + */ + +public abstract class CustomListPreference extends Preference implements View.OnLongClickListener { + protected String LOGTAG = "CustomListPreference"; + + // Indices of the buttons of the Dialog. + public static final int INDEX_SET_DEFAULT_BUTTON = 0; + + // Dialog item labels. + private String[] mDialogItems; + + // Dialog displayed when this element is tapped. + protected AlertDialog mDialog; + + // Cache label to avoid repeated use of the resource system. + protected final String LABEL_IS_DEFAULT; + protected final String LABEL_SET_AS_DEFAULT; + protected final String LABEL_REMOVE; + + protected boolean mIsDefault; + + // Enclosing parent category that contains this preference. + protected final CustomListCategory mParentCategory; + + /** + * Create a preference object to represent a list preference that is attached to + * a category. + * + * @param context The activity context we operate under. + * @param parentCategory The PreferenceCategory this object exists within. + */ + public CustomListPreference(Context context, CustomListCategory parentCategory) { + super(context); + + mParentCategory = parentCategory; + setLayoutResource(getPreferenceLayoutResource()); + + setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + CustomListPreference sPref = (CustomListPreference) preference; + sPref.showDialog(); + return true; + } + }); + + Resources res = getContext().getResources(); + + // Fetch these strings now, instead of every time we ever want to relabel a button. + LABEL_IS_DEFAULT = res.getString(R.string.pref_default); + LABEL_SET_AS_DEFAULT = res.getString(R.string.pref_dialog_set_default); + LABEL_REMOVE = res.getString(R.string.pref_dialog_remove); + } + + /** + * Returns the Android resource id for the layout. + */ + protected abstract int getPreferenceLayoutResource(); + + /** + * Set whether this object's UI should display this as the default item. + * Note: This must be called from the UI thread because it touches the view hierarchy. + * + * To ensure proper ordering, this method should only be called after this Preference + * is added to the PreferenceCategory. + * + * @param isDefault Flag indicating if this represents the default list item. + */ + public void setIsDefault(boolean isDefault) { + mIsDefault = isDefault; + if (isDefault) { + setOrder(0); + setSummary(LABEL_IS_DEFAULT); + } else { + setOrder(1); + setSummary(""); + } + } + + private String[] getCachedDialogItems() { + if (mDialogItems == null) { + mDialogItems = createDialogItems(); + } + return mDialogItems; + } + + /** + * Returns the strings to be displayed in the dialog. + */ + abstract protected String[] createDialogItems(); + + /** + * Display a dialog for this preference, when the preference is clicked. + */ + public void showDialog() { + final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setTitle(getTitle().toString()); + builder.setItems(getCachedDialogItems(), new DialogInterface.OnClickListener() { + // Forward relevant events to the container class for handling. + @Override + public void onClick(DialogInterface dialog, int indexClicked) { + hideDialog(); + onDialogIndexClicked(indexClicked); + } + }); + + configureDialogBuilder(builder); + + // We have to construct the dialog itself on the UI thread. + mDialog = builder.create(); + mDialog.setOnShowListener(new DialogInterface.OnShowListener() { + // Called when the dialog is shown (so we're finally able to manipulate button enabledness). + @Override + public void onShow(DialogInterface dialog) { + configureShownDialog(); + } + }); + mDialog.show(); + } + + /** + * (Optional) Configure the AlertDialog builder. + */ + protected void configureDialogBuilder(AlertDialog.Builder builder) { + return; + } + + abstract protected void onDialogIndexClicked(int index); + + /** + * Disables buttons in the shown AlertDialog as required. The button elements are not created + * until after show is called, so this method has to be called from the onShowListener above. + * @see this.showDialog + */ + protected void configureShownDialog() { + // If this is already the default list item, disable the button for setting this as the default. + final TextView defaultButton = (TextView) mDialog.getListView().getChildAt(INDEX_SET_DEFAULT_BUTTON); + if (mIsDefault) { + defaultButton.setEnabled(false); + + // Failure to unregister this listener leads to tapping the button dismissing the dialog + // without doing anything. + defaultButton.setOnClickListener(null); + } + } + + /** + * Hide the dialog we previously created, if any. + */ + public void hideDialog() { + if (mDialog != null && mDialog.isShowing()) { + mDialog.dismiss(); + } + } + + @Override + public boolean onLongClick(View view) { + // Show the preference dialog on long-press. + showDialog(); + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java b/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java new file mode 100644 index 000000000..1e235640e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java @@ -0,0 +1,61 @@ +/* -*- 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.preferences; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.distribution.Distribution; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Iterator; + +public class DistroSharedPrefsImport { + + public static final String LOGTAG = DistroSharedPrefsImport.class.getSimpleName(); + + public static void importPreferences(final Context context, final Distribution distribution) { + if (distribution == null) { + return; + } + + final JSONObject preferences = distribution.getAndroidPreferences(); + if (preferences.length() == 0) { + return; + } + + final Iterator<?> keys = preferences.keys(); + final SharedPreferences.Editor sharedPreferences = GeckoSharedPrefs.forProfile(context).edit(); + + while (keys.hasNext()) { + final String key = (String) keys.next(); + final Object value; + try { + value = preferences.get(key); + } catch (JSONException e) { + Log.e(LOGTAG, "Unable to completely process Android Preferences JSON.", e); + continue; + } + + // We currently don't support Float preferences. + if (value instanceof String) { + sharedPreferences.putString(GeckoPreferences.NON_PREF_PREFIX + key, (String) value); + } else if (value instanceof Boolean) { + sharedPreferences.putBoolean(GeckoPreferences.NON_PREF_PREFIX + key, (boolean) value); + } else if (value instanceof Integer) { + sharedPreferences.putInt(GeckoPreferences.NON_PREF_PREFIX + key, (int) value); + } else if (value instanceof Long) { + sharedPreferences.putLong(GeckoPreferences.NON_PREF_PREFIX + key, (long) value); + } else { + Log.d(LOGTAG, "Unknown preference value type whilst importing android preferences from distro file."); + } + } + sharedPreferences.apply(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java new file mode 100644 index 000000000..c77c2cc23 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java @@ -0,0 +1,192 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.R; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.util.HashMap; + +class FontSizePreference extends DialogPreference { + private static final String LOGTAG = "FontSizePreference"; + private static final int TWIP_TO_PT_RATIO = 20; // 20 twip = 1 point. + private static final int PREVIEW_FONT_SIZE_UNIT = TypedValue.COMPLEX_UNIT_PT; + private static final int DEFAULT_FONT_INDEX = 2; + + private final Context mContext; + /** Container for mPreviewFontView to allow for scrollable padding at the top of the view. */ + private ScrollView mScrollingContainer; + private TextView mPreviewFontView; + private Button mIncreaseFontButton; + private Button mDecreaseFontButton; + + private final String[] mFontTwipValues; + private final String[] mFontSizeNames; // Ex: "Small". + /** Index into the above arrays for the saved preference value (from Gecko). */ + private int mSavedFontIndex = DEFAULT_FONT_INDEX; + /** Index into the above arrays for the currently displayed font size (the preview). */ + private int mPreviewFontIndex = mSavedFontIndex; + private final HashMap<String, Integer> mFontTwipToIndexMap; + + public FontSizePreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + + final Resources res = mContext.getResources(); + mFontTwipValues = res.getStringArray(R.array.pref_font_size_values); + mFontSizeNames = res.getStringArray(R.array.pref_font_size_entries); + mFontTwipToIndexMap = new HashMap<String, Integer>(); + for (int i = 0; i < mFontTwipValues.length; ++i) { + mFontTwipToIndexMap.put(mFontTwipValues[i], i); + } + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + final LayoutInflater inflater = + (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View dialogView = inflater.inflate(R.layout.font_size_preference, null); + initInternalViews(dialogView); + updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]); + + builder.setTitle(null); + builder.setView(dialogView); + } + + /** Saves relevant views to instance variables and initializes their settings. */ + private void initInternalViews(View dialogView) { + mScrollingContainer = (ScrollView) dialogView.findViewById(R.id.scrolling_container); + // Background cannot be set in XML (see bug 783597 - TODO: Change this to XML when bug is fixed). + mScrollingContainer.setBackgroundColor(Color.WHITE); + mPreviewFontView = (TextView) dialogView.findViewById(R.id.preview); + + mDecreaseFontButton = (Button) dialogView.findViewById(R.id.decrease_preview_font_button); + mIncreaseFontButton = (Button) dialogView.findViewById(R.id.increase_preview_font_button); + setButtonState(mPreviewFontIndex); + mDecreaseFontButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mPreviewFontIndex = Math.max(mPreviewFontIndex - 1, 0); + updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]); + mIncreaseFontButton.setEnabled(true); + // If we reached the minimum index, disable the button. + if (mPreviewFontIndex == 0) { + mDecreaseFontButton.setEnabled(false); + } + } + }); + mIncreaseFontButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mPreviewFontIndex = Math.min(mPreviewFontIndex + 1, mFontTwipValues.length - 1); + updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]); + + mDecreaseFontButton.setEnabled(true); + // If we reached the maximum index, disable the button. + if (mPreviewFontIndex == mFontTwipValues.length - 1) { + mIncreaseFontButton.setEnabled(false); + } + } + }); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + if (!positiveResult) { + mPreviewFontIndex = mSavedFontIndex; + return; + } + mSavedFontIndex = mPreviewFontIndex; + final String twipVal = mFontTwipValues[mSavedFontIndex]; + final OnPreferenceChangeListener prefChangeListener = getOnPreferenceChangeListener(); + if (prefChangeListener == null) { + Log.e(LOGTAG, "PreferenceChangeListener is null. FontSizePreference will not be saved to Gecko."); + return; + } + prefChangeListener.onPreferenceChange(this, twipVal); + } + + /** + * Finds the index of the given twip value and sets it as the saved preference value. Also the + * current preview text size to the given value. Does not update the mPreviewFontView text size. + */ + protected void setSavedFontSize(String twip) { + final Integer index = mFontTwipToIndexMap.get(twip); + if (index != null) { + mSavedFontIndex = index; + mPreviewFontIndex = mSavedFontIndex; + return; + } + resetSavedFontSizeToDefault(); + Log.e(LOGTAG, "setSavedFontSize: Given font size does not exist in twip values map. Reverted to default font size."); + } + + /** + * Updates the mPreviewFontView to the given text size, resets the container's scroll to the top + * left, and invalidates the view. Does not update the font indices. + */ + private void updatePreviewFontSize(String twip) { + float pt = convertTwipStrToPT(twip); + // Android will not render a font size of 0 pt but for Gecko, 0 twip turns off font + // inflation. Thus we special case 0 twip to display a renderable font size. + if (pt == 0) { + // Android adds an inexplicable extra margin on the smallest font size so to get around + // this, we reinflate the view. + ViewGroup parentView = (ViewGroup) mScrollingContainer.getParent(); + parentView.removeAllViews(); + final LayoutInflater inflater = + (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View dialogView = inflater.inflate(R.layout.font_size_preference, parentView); + initInternalViews(dialogView); + mPreviewFontView.setTextSize(PREVIEW_FONT_SIZE_UNIT, 1); + } else { + mPreviewFontView.setTextSize(PREVIEW_FONT_SIZE_UNIT, pt); + } + mScrollingContainer.scrollTo(0, 0); + } + + /** + * Resets the font indices to the default value. Does not update the mPreviewFontView text size. + */ + private void resetSavedFontSizeToDefault() { + mSavedFontIndex = DEFAULT_FONT_INDEX; + mPreviewFontIndex = mSavedFontIndex; + } + + private void setButtonState(int index) { + if (index == 0) { + mDecreaseFontButton.setEnabled(false); + } else if (index == mFontTwipValues.length - 1) { + mIncreaseFontButton.setEnabled(false); + } + } + + /** + * Returns the name of the font size (ex: "Small") at the currently saved preference value. + */ + protected String getSavedFontSizeName() { + return mFontSizeNames[mSavedFontIndex]; + } + + private float convertTwipStrToPT(String twip) { + return Float.parseFloat(twip) / TWIP_TO_PT_RATIO; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java new file mode 100644 index 000000000..6be9e6ea5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java @@ -0,0 +1,296 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import java.util.Locale; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.BrowserLocaleManager; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.LocaleManager; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.fxa.AccountLoader; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; + +import android.accounts.Account; +import android.app.ActionBar; +import android.app.Activity; +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.preference.PreferenceActivity; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; + +import com.squareup.leakcanary.RefWatcher; + +/* A simple implementation of PreferenceFragment for large screen devices + * This will strip category headers (so that they aren't shown to the user twice) + * as well as initializing Gecko prefs when a fragment is shown. +*/ +public class GeckoPreferenceFragment extends PreferenceFragment { + + public static final int ACCOUNT_LOADER_ID = 1; + private AccountLoaderCallbacks accountLoaderCallbacks; + private SyncPreference syncPreference; + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale); + + final Activity context = getActivity(); + + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + final Locale changed = localeManager.onSystemConfigurationChanged(context, getResources(), newConfig, lastLocale); + if (changed != null) { + applyLocale(changed); + } + } + + private static final String LOGTAG = "GeckoPreferenceFragment"; + private PrefsHelper.PrefHandler mPrefsRequest; + private Locale lastLocale = Locale.getDefault(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Write prefs to our custom GeckoSharedPrefs file. + getPreferenceManager().setSharedPreferencesName(GeckoSharedPrefs.APP_PREFS_NAME); + + int res = getResource(); + if (res == R.xml.preferences) { + Telemetry.startUISession(TelemetryContract.Session.SETTINGS); + } else { + final String resourceName = getArguments().getString("resource"); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.SETTINGS, resourceName); + } + + // Display a menu for Search preferences. + if (res == R.xml.preferences_search) { + setHasOptionsMenu(true); + } + + addPreferencesFromResource(res); + + PreferenceScreen screen = getPreferenceScreen(); + setPreferenceScreen(screen); + mPrefsRequest = ((GeckoPreferences)getActivity()).setupPreferences(screen); + syncPreference = (SyncPreference) findPreference(GeckoPreferences.PREFS_SYNC); + } + + /** + * Return the title to use for this preference fragment. + * + * We only return titles for the preference screens that are + * launched directly, and thus might need to be redisplayed. + * + * This method sets the title that you see on non-multi-pane devices. + */ + private String getTitle() { + final int res = getResource(); + if (res == R.xml.preferences) { + return getString(R.string.settings_title); + } + + // We can launch this category from the Data Reporting notification. + if (res == R.xml.preferences_privacy) { + return getString(R.string.pref_category_privacy_short); + } + + // We can launch this category from the the magnifying glass in the quick search bar. + if (res == R.xml.preferences_search) { + return getString(R.string.pref_category_search); + } + + // Launched as action from content notifications. + if (res == R.xml.preferences_notifications) { + return getString(R.string.pref_category_notifications); + } + + return null; + } + + /** + * Return the header id for this preference fragment. This allows + * us to select the correct header when launching a preference + * screen directly. + * + * We only return titles for the preference screens that are + * launched directly. + */ + private int getHeader() { + final int res = getResource(); + if (res == R.xml.preferences) { + return R.id.pref_header_general; + } + + // We can launch this category from the Data Reporting notification. + if (res == R.xml.preferences_privacy) { + return R.id.pref_header_privacy; + } + + // We can launch this category from the the magnifying glass in the quick search bar. + if (res == R.xml.preferences_search) { + return R.id.pref_header_search; + } + + // Launched as action from content notifications. + if (res == R.xml.preferences_notifications) { + return R.id.pref_header_notifications; + } + + return -1; + } + + private void updateTitle() { + final String newTitle = getTitle(); + if (newTitle == null) { + Log.d(LOGTAG, "No new title to show."); + return; + } + + final GeckoPreferences activity = (GeckoPreferences) getActivity(); + if (activity.isMultiPane()) { + // In a multi-pane activity, the title is "Settings", and the action + // bar is along the top of the screen. We don't want to change those. + activity.showBreadCrumbs(newTitle, newTitle); + activity.switchToHeader(getHeader()); + return; + } + + Log.v(LOGTAG, "Setting activity title to " + newTitle); + activity.setTitle(newTitle); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + accountLoaderCallbacks = new AccountLoaderCallbacks(); + getLoaderManager().initLoader(ACCOUNT_LOADER_ID, null, accountLoaderCallbacks); + } + + @Override + public void onResume() { + // This is a little delicate. Ensure that you do nothing prior to + // super.onResume that you wouldn't do in onCreate. + applyLocale(Locale.getDefault()); + super.onResume(); + + // Force reload as the account may have been deleted while the app was in background. + getLoaderManager().restartLoader(ACCOUNT_LOADER_ID, null, accountLoaderCallbacks); + } + + private void applyLocale(final Locale currentLocale) { + final Context context = getActivity().getApplicationContext(); + + BrowserLocaleManager.getInstance().updateConfiguration(context, currentLocale); + + if (!currentLocale.equals(lastLocale)) { + // Locales differ. Let's redisplay. + Log.d(LOGTAG, "Locale changed: " + currentLocale); + this.lastLocale = currentLocale; + + // Rebuild the list to reflect the current locale. + getPreferenceScreen().removeAll(); + addPreferencesFromResource(getResource()); + } + + // Fix the parent title regardless. + updateTitle(); + } + + /* + * Get the resource from Fragment arguments and return it. + * + * If no resource can be found, return the resource id of the default preference screen. + */ + private int getResource() { + int resid = 0; + + final String resourceName = getArguments().getString("resource"); + final Activity activity = getActivity(); + + if (resourceName != null) { + // Fetch resource id by resource name. + final Resources resources = activity.getResources(); + final String packageName = activity.getPackageName(); + resid = resources.getIdentifier(resourceName, "xml", packageName); + } + + if (resid == 0) { + // The resource was invalid. Use the default resource. + Log.e(LOGTAG, "Failed to find resource: " + resourceName + ". Displaying default settings."); + + boolean isMultiPane = ((GeckoPreferences) activity).isMultiPane(); + resid = isMultiPane ? R.xml.preferences_general_tablet : R.xml.preferences; + } + + return resid; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.preferences_search_menu, menu); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mPrefsRequest != null) { + PrefsHelper.removeObserver(mPrefsRequest); + mPrefsRequest = null; + } + + final int res = getResource(); + if (res == R.xml.preferences) { + Telemetry.stopUISession(TelemetryContract.Session.SETTINGS); + } + + GeckoApplication.watchReference(getActivity(), this); + } + + private class AccountLoaderCallbacks implements LoaderManager.LoaderCallbacks<Account> { + @Override + public Loader<Account> onCreateLoader(int id, Bundle args) { + return new AccountLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader<Account> loader, Account account) { + if (syncPreference == null) { + return; + } + + if (account == null) { + syncPreference.update(null); + return; + } + + syncPreference.update(new AndroidFxAccount(getActivity(), account)); + } + + @Override + public void onLoaderReset(Loader<Account> loader) { + if (syncPreference != null) { + syncPreference.update(null); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java new file mode 100644 index 000000000..5ab1bc3fd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java @@ -0,0 +1,1520 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.json.JSONArray; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.AdjustConstants; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.BrowserLocaleManager; +import org.mozilla.gecko.DataReportingNotification; +import org.mozilla.gecko.DynamicToolbar; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoActivityStatus; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.LocaleManager; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.activitystream.ActivityStream; +import org.mozilla.gecko.background.common.GlobalConstants; +import org.mozilla.gecko.db.BrowserContract.SuggestedSites; +import org.mozilla.gecko.feeds.FeedService; +import org.mozilla.gecko.feeds.action.CheckForUpdatesAction; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.tabqueue.TabQueueHelper; +import org.mozilla.gecko.tabqueue.TabQueuePrompt; +import org.mozilla.gecko.updater.UpdateService; +import org.mozilla.gecko.updater.UpdateServiceHelper; +import org.mozilla.gecko.util.ContextUtils; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.InputOptionsUtils; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.content.res.Configuration; +import android.Manifest; +import android.os.Build; +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceChangeListener; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceActivity; +import android.preference.PreferenceGroup; +import android.preference.SwitchPreference; +import android.preference.TwoStatePreference; +import android.support.design.widget.Snackbar; +import android.support.design.widget.TextInputLayout; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.ActionBar; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListAdapter; +import android.widget.ListView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class GeckoPreferences +extends AppCompatPreferenceActivity +implements +GeckoActivityStatus, +NativeEventListener, +OnPreferenceChangeListener, +OnSharedPreferenceChangeListener +{ + private static final String LOGTAG = "GeckoPreferences"; + + // We have a white background, which makes transitions on + // some devices look bad. Don't use transitions on those + // devices. + private static final boolean NO_TRANSITIONS = HardwareUtils.IS_KINDLE_DEVICE; + private static final int NO_SUCH_ID = 0; + + public static final String NON_PREF_PREFIX = "android.not_a_preference."; + public static final String INTENT_EXTRA_RESOURCES = "resource"; + public static final String PREFS_TRACKING_PROTECTION_PROMPT_SHOWN = NON_PREF_PREFIX + "trackingProtectionPromptShown"; + public static String PREFS_HEALTHREPORT_UPLOAD_ENABLED = NON_PREF_PREFIX + "healthreport.uploadEnabled"; + public static final String PREFS_SYNC = NON_PREF_PREFIX + "sync"; + + private static boolean sIsCharEncodingEnabled; + private boolean mInitialized; + private PrefsHelper.PrefHandler mPrefsRequest; + private List<Header> mHeaders; + + // These match keys in resources/xml*/preferences*.xml + private static final String PREFS_SEARCH_RESTORE_DEFAULTS = NON_PREF_PREFIX + "search.restore_defaults"; + private static final String PREFS_DATA_REPORTING_PREFERENCES = NON_PREF_PREFIX + "datareporting.preferences"; + private static final String PREFS_TELEMETRY_ENABLED = "toolkit.telemetry.enabled"; + private static final String PREFS_CRASHREPORTER_ENABLED = "datareporting.crashreporter.submitEnabled"; + private static final String PREFS_MENU_CHAR_ENCODING = "browser.menu.showCharacterEncoding"; + private static final String PREFS_MP_ENABLED = "privacy.masterpassword.enabled"; + private static final String PREFS_UPDATER_AUTODOWNLOAD = "app.update.autodownload"; + private static final String PREFS_UPDATER_URL = "app.update.url.android"; + private static final String PREFS_GEO_REPORTING = NON_PREF_PREFIX + "app.geo.reportdata"; + private static final String PREFS_GEO_LEARN_MORE = NON_PREF_PREFIX + "geo.learn_more"; + private static final String PREFS_HEALTHREPORT_LINK = NON_PREF_PREFIX + "healthreport.link"; + private static final String PREFS_DEVTOOLS_REMOTE_USB_ENABLED = "devtools.remote.usb.enabled"; + private static final String PREFS_DEVTOOLS_REMOTE_WIFI_ENABLED = "devtools.remote.wifi.enabled"; + private static final String PREFS_DEVTOOLS_REMOTE_LINK = NON_PREF_PREFIX + "remote_debugging.link"; + private static final String PREFS_TRACKING_PROTECTION = "privacy.trackingprotection.state"; + private static final String PREFS_TRACKING_PROTECTION_PB = "privacy.trackingprotection.pbmode.enabled"; + private static final String PREFS_ZOOMED_VIEW_ENABLED = "ui.zoomedview.enabled"; + public static final String PREFS_VOICE_INPUT_ENABLED = NON_PREF_PREFIX + "voice_input_enabled"; + public static final String PREFS_QRCODE_ENABLED = NON_PREF_PREFIX + "qrcode_enabled"; + private static final String PREFS_TRACKING_PROTECTION_PRIVATE_BROWSING = "privacy.trackingprotection.pbmode.enabled"; + private static final String PREFS_TRACKING_PROTECTION_LEARN_MORE = NON_PREF_PREFIX + "trackingprotection.learn_more"; + private static final String PREFS_CLEAR_PRIVATE_DATA = NON_PREF_PREFIX + "privacy.clear"; + private static final String PREFS_CLEAR_PRIVATE_DATA_EXIT = NON_PREF_PREFIX + "history.clear_on_exit"; + private static final String PREFS_SCREEN_ADVANCED = NON_PREF_PREFIX + "advanced_screen"; + public static final String PREFS_HOMEPAGE = NON_PREF_PREFIX + "homepage"; + public static final String PREFS_HOMEPAGE_PARTNER_COPY = GeckoPreferences.PREFS_HOMEPAGE + ".partner"; + public static final String PREFS_HISTORY_SAVED_SEARCH = NON_PREF_PREFIX + "search.search_history.enabled"; + private static final String PREFS_FAQ_LINK = NON_PREF_PREFIX + "faq.link"; + private static final String PREFS_FEEDBACK_LINK = NON_PREF_PREFIX + "feedback.link"; + public static final String PREFS_NOTIFICATIONS_CONTENT = NON_PREF_PREFIX + "notifications.content"; + public static final String PREFS_NOTIFICATIONS_CONTENT_LEARN_MORE = NON_PREF_PREFIX + "notifications.content.learn_more"; + public static final String PREFS_NOTIFICATIONS_WHATS_NEW = NON_PREF_PREFIX + "notifications.whats_new"; + public static final String PREFS_APP_UPDATE_LAST_BUILD_ID = "app.update.last_build_id"; + public static final String PREFS_READ_PARTNER_CUSTOMIZATIONS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_customizations_provider"; + public static final String PREFS_READ_PARTNER_BOOKMARKS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_bookmarks_provider"; + public static final String PREFS_CUSTOM_TABS = NON_PREF_PREFIX + "customtabs"; + public static final String PREFS_ACTIVITY_STREAM = NON_PREF_PREFIX + "activitystream"; + public static final String PREFS_CATEGORY_EXPERIMENTAL_FEATURES = NON_PREF_PREFIX + "category_experimental"; + + private static final String ACTION_STUMBLER_UPLOAD_PREF = "STUMBLER_PREF"; + + + // This isn't a Gecko pref, even if it looks like one. + private static final String PREFS_BROWSER_LOCALE = "locale"; + + public static final String PREFS_RESTORE_SESSION = NON_PREF_PREFIX + "restoreSession3"; + public static final String PREFS_RESTORE_SESSION_FROM_CRASH = "browser.sessionstore.resume_from_crash"; + public static final String PREFS_RESTORE_SESSION_MAX_CRASH_RESUMES = "browser.sessionstore.max_resumed_crashes"; + public static final String PREFS_TAB_QUEUE = NON_PREF_PREFIX + "tab_queue"; + public static final String PREFS_TAB_QUEUE_LAST_SITE = NON_PREF_PREFIX + "last_site"; + public static final String PREFS_TAB_QUEUE_LAST_TIME = NON_PREF_PREFIX + "last_time"; + + private static final String PREFS_DYNAMIC_TOOLBAR = "browser.chrome.dynamictoolbar"; + + // These values are chosen to be distinct from other Activity constants. + private static final int REQUEST_CODE_PREF_SCREEN = 5; + private static final int RESULT_CODE_EXIT_SETTINGS = 6; + + // Result code used when a locale preference changes. + // Callers can recognize this code to refresh themselves to + // accommodate a locale change. + public static final int RESULT_CODE_LOCALE_DID_CHANGE = 7; + + private static final int REQUEST_CODE_TAB_QUEUE = 8; + + private final Map<String, PrefHandler> HANDLERS; + { + final HashMap<String, PrefHandler> tempHandlers = new HashMap<>(2); + tempHandlers.put(ClearOnShutdownPref.PREF, new ClearOnShutdownPref()); + tempHandlers.put(AndroidImportPreference.PREF_KEY, new AndroidImportPreference.Handler()); + HANDLERS = Collections.unmodifiableMap(tempHandlers); + } + + private SwitchPreference tabQueuePreference; + + /** + * Track the last locale so we know whether to redisplay. + */ + private Locale lastLocale = Locale.getDefault(); + private boolean localeSwitchingIsEnabled; + + private void startActivityForResultChoosingTransition(final Intent intent, final int requestCode) { + startActivityForResult(intent, requestCode); + if (NO_TRANSITIONS) { + overridePendingTransition(0, 0); + } + } + + private void finishChoosingTransition() { + finish(); + if (NO_TRANSITIONS) { + overridePendingTransition(0, 0); + } + } + private void updateActionBarTitle(int title) { + final String newTitle = getString(title); + if (newTitle != null) { + Log.v(LOGTAG, "Setting action bar title to " + newTitle); + + setTitle(newTitle); + } + } + + /** + * We only call this method for pre-HC versions of Android. + */ + private void updateTitleForPrefsResource(int res) { + // At present we only need to do this for non-leaf prefs views + // and the locale switcher itself. + int title = -1; + if (res == R.xml.preferences) { + title = R.string.settings_title; + } else if (res == R.xml.preferences_locale) { + title = R.string.pref_category_language; + } else if (res == R.xml.preferences_vendor) { + title = R.string.pref_category_vendor; + } else if (res == R.xml.preferences_general) { + title = R.string.pref_category_general; + } else if (res == R.xml.preferences_search) { + title = R.string.pref_category_search; + } + if (title != -1) { + setTitle(title); + } + } + + private void onLocaleChanged(Locale newLocale) { + Log.d(LOGTAG, "onLocaleChanged: " + newLocale); + + BrowserLocaleManager.getInstance().updateConfiguration(getApplicationContext(), newLocale); + this.lastLocale = newLocale; + + if (isMultiPane()) { + // This takes care of the left pane. + invalidateHeaders(); + + // Detach and reattach the current prefs pane so that it + // reflects the new locale. + final FragmentManager fragmentManager = getFragmentManager(); + int id = getResources().getIdentifier("android:id/prefs", null, null); + final Fragment current = fragmentManager.findFragmentById(id); + if (current != null) { + fragmentManager.beginTransaction() + .disallowAddToBackStack() + .detach(current) + .attach(current) + .commitAllowingStateLoss(); + } else { + Log.e(LOGTAG, "No prefs fragment to reattach!"); + } + + // Because Android just rebuilt the activity itself with the + // old language, we need to update the top title and other + // wording again. + if (onIsMultiPane()) { + updateActionBarTitle(R.string.settings_title); + } + + // Update the title to for the preference pane that we're currently showing. + final int titleId = getIntent().getExtras().getInt(PreferenceActivity.EXTRA_SHOW_FRAGMENT_TITLE); + if (titleId != NO_SUCH_ID) { + setTitle(titleId); + } else { + throw new IllegalStateException("Title id not found in intent bundle extras"); + } + + // Don't finish the activity -- we just reloaded all of the + // individual parts! -- but when it returns, make sure that the + // caller knows the locale changed. + setResult(RESULT_CODE_LOCALE_DID_CHANGE); + return; + } + + refreshSuggestedSites(); + + // Cause the current fragment to redisplay, the hard way. + // This avoids nonsense with trying to reach inside fragments and force them + // to redisplay themselves. + // We also don't need to update the title. + final Intent intent = (Intent) getIntent().clone(); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN); + + setResult(RESULT_CODE_LOCALE_DID_CHANGE); + finishChoosingTransition(); + } + + private void checkLocale() { + final Locale currentLocale = Locale.getDefault(); + Log.v(LOGTAG, "Checking locale: " + currentLocale + " vs " + lastLocale); + if (currentLocale.equals(lastLocale)) { + return; + } + + onLocaleChanged(currentLocale); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // Apply the current user-selected locale, if necessary. + checkLocale(); + + // Track this so we can decide whether to show locale options. + // See also the workaround below for Bug 1015209. + localeSwitchingIsEnabled = BrowserLocaleManager.getInstance().isEnabled(); + + // For Android v11+ where we use Fragments (v11+ only due to bug 866352), + // check that PreferenceActivity.EXTRA_SHOW_FRAGMENT has been set + // (or set it) before super.onCreate() is called so Android can display + // the correct Fragment resource. + // Note: this seems to only be required for non-multipane devices, multipane + // manages to automatically select the correct fragments. + if (!getIntent().hasExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT)) { + // Set up the default fragment if there is no explicit fragment to show. + setupTopLevelFragmentIntent(); + } + + // We must call this before setTitle to avoid crashes. Most devices don't seem to care + // (we used to call onCreate later), however the ASUS TF300T (running 4.2) crashes + // with an NPE in android.support.v7.app.AppCompatDelegateImplV7.ensureSubDecor(), and it's + // likely other strange devices (other Asus devices, some Samsungs) could do the same. + super.onCreate(savedInstanceState); + + if (onIsMultiPane()) { + // So that Android doesn't put the fragment title (or nothing at + // all) in the action bar. + updateActionBarTitle(R.string.settings_title); + + if (Build.VERSION.SDK_INT < 13) { + // Affected by Bug 1015209 -- no detach/attach. + // If we try rejigging fragments, we'll crash, so don't + // enable locale switching at all. + localeSwitchingIsEnabled = false; + throw new IllegalStateException("foobar"); + } + } + + // Use setResourceToOpen to specify these extras. + Bundle intentExtras = getIntent().getExtras(); + + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "Sanitize:Finished", + "Snackbar:Show"); + + // Add handling for long-press click. + // This is only for Android 3.0 and below (which use the long-press-context-menu paradigm). + final ListView mListView = getListView(); + mListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + // Call long-click handler if it the item implements it. + final ListAdapter listAdapter = ((ListView) parent).getAdapter(); + final Object listItem = listAdapter.getItem(position); + + // Only CustomListPreference handles long clicks. + if (listItem instanceof CustomListPreference && listItem instanceof View.OnLongClickListener) { + final View.OnLongClickListener longClickListener = (View.OnLongClickListener) listItem; + return longClickListener.onLongClick(view); + } + return false; + } + }); + + // N.B., if we ever need to redisplay the locale selection UI without + // just finishing and recreating the activity, right here we'll need to + // capture EXTRA_SHOW_FRAGMENT_TITLE from the intent and store the title ID. + + // If launched from notification, explicitly cancel the notification. + if (intentExtras != null && intentExtras.containsKey(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION)) { + Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, Method.NOTIFICATION, "settings-data-choices"); + NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode()); + } + + // Launched from "Notifications settings" action button in a notification. + if (intentExtras != null && intentExtras.containsKey(CheckForUpdatesAction.EXTRA_CONTENT_NOTIFICATION)) { + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(this)); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.BUTTON, "notification-settings"); + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(this)); + } + } + + /** + * Set intent to display top-level settings fragment, + * and show the correct title. + */ + private void setupTopLevelFragmentIntent() { + Intent intent = getIntent(); + // Check intent to determine settings screen to display. + Bundle intentExtras = intent.getExtras(); + Bundle fragmentArgs = new Bundle(); + // Add resource argument to fragment if it exists. + if (intentExtras != null && intentExtras.containsKey(INTENT_EXTRA_RESOURCES)) { + String resourceName = intentExtras.getString(INTENT_EXTRA_RESOURCES); + fragmentArgs.putString(INTENT_EXTRA_RESOURCES, resourceName); + } else { + // Use top-level settings screen. + if (!onIsMultiPane()) { + fragmentArgs.putString(INTENT_EXTRA_RESOURCES, "preferences"); + } else { + fragmentArgs.putString(INTENT_EXTRA_RESOURCES, "preferences_general_tablet"); + } + } + + // Build fragment intent. + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, GeckoPreferenceFragment.class.getName()); + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs); + // Used to get fragment title when locale changes (see onLocaleChanged method above) + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_TITLE, R.string.settings_title); + } + + @Override + public boolean isValidFragment(String fragmentName) { + return GeckoPreferenceFragment.class.getName().equals(fragmentName); + } + + @TargetApi(11) + @Override + public void onBuildHeaders(List<Header> target) { + if (onIsMultiPane()) { + loadHeadersFromResource(R.xml.preference_headers, target); + + Iterator<Header> iterator = target.iterator(); + + while (iterator.hasNext()) { + Header header = iterator.next(); + + if (header.id == R.id.pref_header_advanced && !Restrictions.isAllowed(this, Restrictable.ADVANCED_SETTINGS)) { + iterator.remove(); + } else if (header.id == R.id.pref_header_clear_private_data + && !Restrictions.isAllowed(this, Restrictable.CLEAR_HISTORY)) { + iterator.remove(); + } + } + + mHeaders = target; + } + } + + @TargetApi(11) + public void switchToHeader(int id) { + if (mHeaders == null) { + // Can't switch to a header if there are no headers! + return; + } + + for (Header header : mHeaders) { + if (header.id == id) { + switchToHeader(header); + return; + } + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (!hasFocus || mInitialized) + return; + + mInitialized = true; + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + + if (NO_TRANSITIONS) { + overridePendingTransition(0, 0); + } + + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, Method.BACK, "settings"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + EventDispatcher.getInstance().unregisterGeckoThreadListener(this, + "Sanitize:Finished", + "Snackbar:Show"); + + if (mPrefsRequest != null) { + PrefsHelper.removeObserver(mPrefsRequest); + mPrefsRequest = null; + } + } + + @Override + public void onPause() { + // Symmetric with onResume. + if (isMultiPane()) { + SharedPreferences prefs = GeckoSharedPrefs.forApp(this); + prefs.unregisterOnSharedPreferenceChangeListener(this); + } + + super.onPause(); + + if (getApplication() instanceof GeckoApplication) { + ((GeckoApplication) getApplication()).onActivityPause(this); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (getApplication() instanceof GeckoApplication) { + ((GeckoApplication) getApplication()).onActivityResume(this); + } + + // Watch prefs, otherwise we don't reliably get told when they change. + // See documentation for onSharedPreferenceChange for more. + // Inexplicably only needed on tablet. + if (isMultiPane()) { + SharedPreferences prefs = GeckoSharedPrefs.forApp(this); + prefs.registerOnSharedPreferenceChangeListener(this); + } + } + + @Override + public void startActivity(Intent intent) { + // For settings, we want to be able to pass results up the chain + // of preference screens so Settings can behave as a single unit. + // Specifically, when we open a link, we want to back out of all + // the settings screens. + // We need to start nested PreferenceScreens withStartActivityForResult(). + // Android doesn't let us do that (see Preference.onClick), so we're overriding here. + startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN); + } + + @Override + public void startWithFragment(String fragmentName, Bundle args, + Fragment resultTo, int resultRequestCode, int titleRes, int shortTitleRes) { + Log.v(LOGTAG, "Starting with fragment: " + fragmentName + ", title " + titleRes); + + // Overriding because we want to use startActivityForResult for Fragment intents. + Intent intent = onBuildStartFragmentIntent(fragmentName, args, titleRes, shortTitleRes); + if (resultTo == null) { + startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN); + } else { + resultTo.startActivityForResult(intent, resultRequestCode); + if (NO_TRANSITIONS) { + overridePendingTransition(0, 0); + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + // We might have just returned from a settings activity that allows us + // to switch locales, so reflect any change that occurred. + checkLocale(); + + switch (requestCode) { + case REQUEST_CODE_PREF_SCREEN: + switch (resultCode) { + case RESULT_CODE_EXIT_SETTINGS: + updateActionBarTitle(R.string.settings_title); + + // Pass this result up to the parent activity. + setResult(RESULT_CODE_EXIT_SETTINGS); + finishChoosingTransition(); + break; + } + break; + case REQUEST_CODE_TAB_QUEUE: + if (TabQueueHelper.processTabQueuePromptResponse(resultCode, this)) { + tabQueuePreference.setChecked(true); + } + break; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + Permissions.onRequestPermissionsResult(this, permissions, grantResults); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + try { + switch (event) { + case "Sanitize:Finished": + boolean success = message.getBoolean("success"); + final int stringRes = success ? R.string.private_data_success : R.string.private_data_fail; + + SnackbarBuilder.builder(GeckoPreferences.this) + .message(stringRes) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + break; + case "Snackbar:Show": + SnackbarBuilder.builder(this) + .fromEvent(message) + .callback(callback) + .buildAndShow(); + break; + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + /** + * Initialize all of the preferences (native of Gecko ones) for this screen. + * + * @param prefs The android.preference.PreferenceGroup to initialize + * @return The integer id for the PrefsHelper.PrefHandlerBase listener added + * to monitor changes to Gecko prefs. + */ + public PrefsHelper.PrefHandler setupPreferences(PreferenceGroup prefs) { + ArrayList<String> list = new ArrayList<String>(); + setupPreferences(prefs, list); + return getGeckoPreferences(prefs, list); + } + + /** + * Recursively loop through a PreferenceGroup. Initialize native Android prefs, + * and build a list of Gecko preferences in the passed in prefs array + * + * @param preferences The android.preference.PreferenceGroup to initialize + * @param prefs An ArrayList to fill with Gecko preferences that need to be + * initialized + * @return The integer id for the PrefsHelper.PrefHandlerBase listener added + * to monitor changes to Gecko prefs. + */ + private void setupPreferences(PreferenceGroup preferences, ArrayList<String> prefs) { + for (int i = 0; i < preferences.getPreferenceCount(); i++) { + final Preference pref = preferences.getPreference(i); + + // Eliminate locale switching if necessary. + // This logic will need to be extended when + // content language selection (Bug 881510) is implemented. + if (!localeSwitchingIsEnabled && + "preferences_locale".equals(pref.getExtras().getString("resource"))) { + preferences.removePreference(pref); + i--; + continue; + } + + String key = pref.getKey(); + if (pref instanceof PreferenceGroup) { + // If datareporting is disabled, remove UI. + if (PREFS_DATA_REPORTING_PREFERENCES.equals(key)) { + if (!AppConstants.MOZ_DATA_REPORTING || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_SCREEN_ADVANCED.equals(key) && + !Restrictions.isAllowed(this, Restrictable.ADVANCED_SETTINGS)) { + preferences.removePreference(pref); + i--; + continue; + } else if (PREFS_CATEGORY_EXPERIMENTAL_FEATURES.equals(key) + && !AppConstants.MOZ_ANDROID_ACTIVITY_STREAM + && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) { + preferences.removePreference(pref); + i--; + continue; + } + setupPreferences((PreferenceGroup) pref, prefs); + } else { + if (HANDLERS.containsKey(key)) { + PrefHandler handler = HANDLERS.get(key); + if (!handler.setupPref(this, pref)) { + preferences.removePreference(pref); + i--; + continue; + } + } + + pref.setOnPreferenceChangeListener(this); + if (PREFS_UPDATER_AUTODOWNLOAD.equals(key)) { + if (!AppConstants.MOZ_UPDATER || ContextUtils.isInstalledFromGooglePlay(this)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_TRACKING_PROTECTION.equals(key)) { + // Remove UI for global TP pref in non-Nightly builds. + if (!AppConstants.NIGHTLY_BUILD) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_TRACKING_PROTECTION_PB.equals(key)) { + // Remove UI for private-browsing-only TP pref in Nightly builds. + if (AppConstants.NIGHTLY_BUILD) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_TELEMETRY_ENABLED.equals(key)) { + if (!AppConstants.MOZ_TELEMETRY_REPORTING || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(key) || + PREFS_HEALTHREPORT_LINK.equals(key)) { + if (!AppConstants.MOZ_SERVICES_HEALTHREPORT || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_CRASHREPORTER_ENABLED.equals(key)) { + if (!AppConstants.MOZ_CRASHREPORTER || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_GEO_REPORTING.equals(key) || + PREFS_GEO_LEARN_MORE.equals(key)) { + if (!AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_DEVTOOLS_REMOTE_USB_ENABLED.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_DEVTOOLS_REMOTE_WIFI_ENABLED.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) { + preferences.removePreference(pref); + i--; + continue; + } + if (!InputOptionsUtils.supportsQrCodeReader(getApplicationContext())) { + // WiFi debugging requires a QR code reader + pref.setEnabled(false); + pref.setSummary(getString(R.string.pref_developer_remotedebugging_wifi_disabled_summary)); + continue; + } + } else if (PREFS_DEVTOOLS_REMOTE_LINK.equals(key)) { + // Remove the "Learn more" link if remote debugging is disabled + if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_RESTORE_SESSION.equals(key) || + PREFS_BROWSER_LOCALE.equals(key)) { + // Set the summary string to the current entry. The summary + // for other list prefs will be set in the PrefsHelper + // callback, but since this pref doesn't live in Gecko, we + // need to handle it separately. + ListPreference listPref = (ListPreference) pref; + CharSequence selectedEntry = listPref.getEntry(); + listPref.setSummary(selectedEntry); + continue; + } else if (PREFS_SYNC.equals(key)) { + // Don't show sync prefs while in guest mode. + if (!Restrictions.isAllowed(this, Restrictable.MODIFY_ACCOUNTS)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_SEARCH_RESTORE_DEFAULTS.equals(key)) { + pref.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + GeckoPreferences.this.restoreDefaultSearchEngines(); + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_RESTORE_DEFAULTS, Method.LIST_ITEM); + return true; + } + }); + } else if (PREFS_TAB_QUEUE.equals(key)) { + tabQueuePreference = (SwitchPreference) pref; + // Only show tab queue pref on nightly builds with the tab queue build flag. + if (!TabQueueHelper.TAB_QUEUE_ENABLED) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_ZOOMED_VIEW_ENABLED.equals(key)) { + if (!AppConstants.NIGHTLY_BUILD) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_VOICE_INPUT_ENABLED.equals(key)) { + if (!InputOptionsUtils.supportsVoiceRecognizer(getApplicationContext(), getResources().getString(R.string.voicesearch_prompt))) { + // Remove UI for voice input on non nightly builds. + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_QRCODE_ENABLED.equals(key)) { + if (!InputOptionsUtils.supportsQrCodeReader(getApplicationContext())) { + // Remove UI for qr code input on non nightly builds + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_TRACKING_PROTECTION_PRIVATE_BROWSING.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_TRACKING_PROTECTION_LEARN_MORE.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_MP_ENABLED.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.MASTER_PASSWORD)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_CLEAR_PRIVATE_DATA.equals(key) || PREFS_CLEAR_PRIVATE_DATA_EXIT.equals(key)) { + if (!Restrictions.isAllowed(this, Restrictable.CLEAR_HISTORY)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_HOMEPAGE.equals(key)) { + String setUrl = GeckoSharedPrefs.forProfile(getBaseContext()).getString(PREFS_HOMEPAGE, AboutPages.HOME); + setHomePageSummary(pref, setUrl); + pref.setOnPreferenceChangeListener(this); + } else if (PREFS_FAQ_LINK.equals(key)) { + // Format the FAQ link + final String VERSION = AppConstants.MOZ_APP_VERSION; + final String OS = AppConstants.OS_TARGET; + final String LOCALE = Locales.getLanguageTag(Locale.getDefault()); + + final String url = getResources().getString(R.string.faq_link, VERSION, OS, LOCALE); + ((LinkPreference) pref).setUrl(url); + } else if (PREFS_FEEDBACK_LINK.equals(key)) { + // Format the feedback link. We can't easily use this "app.feedbackURL" + // Gecko preference because the URL must be formatted. + final String url = getResources().getString(R.string.feedback_link, AppConstants.MOZ_APP_VERSION, AppConstants.MOZ_UPDATE_CHANNEL); + ((LinkPreference) pref).setUrl(url); + } else if (PREFS_DYNAMIC_TOOLBAR.equals(key)) { + if (DynamicToolbar.isForceDisabled()) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_NOTIFICATIONS_CONTENT.equals(key) || + PREFS_NOTIFICATIONS_CONTENT_LEARN_MORE.equals(key)) { + if (!FeedService.isInExperiment(this)) { + preferences.removePreference(pref); + i--; + continue; + } + } else if (PREFS_CUSTOM_TABS.equals(key) && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) { + preferences.removePreference(pref); + i--; + continue; + } else if (PREFS_ACTIVITY_STREAM.equals(key) && !ActivityStream.isUserEligible(this)) { + preferences.removePreference(pref); + i--; + continue; + } + + // Some Preference UI elements are not actually preferences, + // but they require a key to work correctly. For example, + // "Clear private data" requires a key for its state to be + // saved when the orientation changes. It uses the + // "android.not_a_preference.privacy.clear" key - which doesn't + // exist in Gecko - to satisfy this requirement. + if (isGeckoPref(key)) { + prefs.add(key); + } + } + } + } + + private void setHomePageSummary(Preference pref, String value) { + if (!TextUtils.isEmpty(value)) { + pref.setSummary(value); + } else { + pref.setSummary(AboutPages.HOME); + } + } + + private boolean isGeckoPref(String key) { + if (TextUtils.isEmpty(key)) { + return false; + } + + if (key.startsWith(NON_PREF_PREFIX)) { + return false; + } + + if (key.equals(PREFS_BROWSER_LOCALE)) { + return false; + } + + return true; + } + + /** + * Restore default search engines in Gecko and retrigger a search engine refresh. + */ + protected void restoreDefaultSearchEngines() { + GeckoAppShell.notifyObservers("SearchEngines:RestoreDefaults", null); + + // Send message to Gecko to get engines. SearchPreferenceCategory listens for the response. + GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + switch (itemId) { + case android.R.id.home: + finishChoosingTransition(); + return true; + } + + // Generated R.id.* apparently aren't constant expressions, so they can't be switched. + if (itemId == R.id.restore_defaults) { + restoreDefaultSearchEngines(); + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_RESTORE_DEFAULTS, Method.MENU); + return true; + } + + return super.onOptionsItemSelected(item); + } + + final private int DIALOG_CREATE_MASTER_PASSWORD = 0; + final private int DIALOG_REMOVE_MASTER_PASSWORD = 1; + + public static void setCharEncodingState(boolean enabled) { + sIsCharEncodingEnabled = enabled; + } + + public static boolean getCharEncodingState() { + return sIsCharEncodingEnabled; + } + + public static void broadcastAction(final Context context, final Intent intent) { + fillIntentWithProfileInfo(context, intent); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + + private static void fillIntentWithProfileInfo(final Context context, final Intent intent) { + // There is a race here, but GeckoProfile returns the default profile + // when Gecko is not explicitly running for a different profile. In a + // multi-profile world, this will need to be updated (possibly to + // broadcast settings for all profiles). See Bug 882182. + GeckoProfile profile = GeckoProfile.get(context); + if (profile != null) { + intent.putExtra("profileName", profile.getName()) + .putExtra("profilePath", profile.getDir().getAbsolutePath()); + } + } + + /** + * Broadcast the provided value as the value of the + * <code>PREFS_GEO_REPORTING</code> pref. + */ + public static void broadcastStumblerPref(final Context context, final boolean value) { + Intent intent = new Intent(ACTION_STUMBLER_UPLOAD_PREF) + .putExtra("pref", PREFS_GEO_REPORTING) + .putExtra("branch", GeckoSharedPrefs.APP_PREFS_NAME) + .putExtra("enabled", value) + .putExtra("moz_mozilla_api_key", AppConstants.MOZ_MOZILLA_API_KEY); + if (GeckoAppShell.getGeckoInterface() != null) { + intent.putExtra("user_agent", GeckoAppShell.getGeckoInterface().getDefaultUAString()); + } + broadcastAction(context, intent); + } + + /** + * Broadcast the current value of the + * <code>PREFS_GEO_REPORTING</code> pref. + */ + public static void broadcastStumblerPref(final Context context) { + final boolean value = getBooleanPref(context, PREFS_GEO_REPORTING, false); + broadcastStumblerPref(context, value); + } + + /** + * Return the value of the named preference in the default preferences file. + * + * This corresponds to the storage that backs preferences.xml. + * @param context a <code>Context</code>; the + * <code>PreferenceActivity</code> will suffice, but this + * method is intended to be called from other contexts + * within the application, not just this <code>Activity</code>. + * @param name the name of the preference to retrieve. + * @param def the default value to return if the preference is not present. + * @return the value of the preference, or the default. + */ + public static boolean getBooleanPref(final Context context, final String name, boolean def) { + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + return prefs.getBoolean(name, def); + } + + /** + * Immediately handle the user's selection of a browser locale. + * + * Earlier locale-handling code did this with centralized logic in + * GeckoApp, delegating to LocaleManager for persistence and refreshing + * the activity as necessary. + * + * We no longer handle this by sending a message to GeckoApp, for + * several reasons: + * + * * GeckoApp might not be running. Activities don't always stick around. + * A Java bridge message might not be handled. + * * We need to adapt the preferences UI to the locale ourselves. + * * The user might not hit Back (or Up) -- they might hit Home and never + * come back. + * + * We handle the case of the user returning to the browser via the + * onActivityResult mechanism: see {@link BrowserApp#onActivityResult(int, int, Intent)}. + */ + private boolean onLocaleSelected(final String currentLocale, final String newValue) { + final Context context = getApplicationContext(); + + // LocaleManager operations need to occur on the background thread. + // ... but activity operations need to occur on the UI thread. So we + // have nested runnables. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + + if (TextUtils.isEmpty(newValue)) { + BrowserLocaleManager.getInstance().resetToSystemLocale(context); + Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_RESET); + } else { + if (null == localeManager.setSelectedLocale(context, newValue)) { + localeManager.updateConfiguration(context, Locale.getDefault()); + } + Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_UNSELECTED, Method.NONE, + currentLocale == null ? "unknown" : currentLocale); + Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_SELECTED, Method.NONE, newValue); + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + onLocaleChanged(Locale.getDefault()); + } + }); + } + }); + + return true; + } + + private void refreshSuggestedSites() { + final ContentResolver cr = getApplicationContext().getContentResolver(); + + // This will force all active suggested sites cursors + // to request a refresh (e.g. cursor loaders). + cr.notifyChange(SuggestedSites.CONTENT_URI, null); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale); + + if (lastLocale.equals(newConfig.locale)) { + Log.d(LOGTAG, "Old locale same as new locale. Short-circuiting."); + return; + } + + final LocaleManager localeManager = BrowserLocaleManager.getInstance(); + final Locale changed = localeManager.onSystemConfigurationChanged(this, getResources(), newConfig, lastLocale); + if (changed != null) { + onLocaleChanged(changed); + } + } + + /** + * Implementation for the {@link OnSharedPreferenceChangeListener} interface, + * which we use to watch changes in our prefs file. + * + * This is reliably called whenever the pref changes, which is not the case + * for multiple consecutive changes in the case of onPreferenceChange. + * + * Note that this listener is not always registered: we use it only on + * tablets, Honeycomb and up, where we'll have a multi-pane view and prefs + * changing multiple times. + */ + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (PREFS_BROWSER_LOCALE.equals(key)) { + onLocaleSelected(Locales.getLanguageTag(lastLocale), + sharedPreferences.getString(key, null)); + } + } + + public interface PrefHandler { + // Allows the pref to do any initialization it needs. Return false to have the pref removed + // from the prefs screen entirely. + public boolean setupPref(Context context, Preference pref); + public void onChange(Context context, Preference pref, Object newValue); + } + + private void recordSettingChangeTelemetry(String prefName, Object newValue) { + final String value; + if (newValue instanceof Boolean) { + value = (Boolean) newValue ? "1" : "0"; + } else if (prefName.equals(PREFS_HOMEPAGE)) { + // Don't record the user's homepage preference. + value = "*"; + } else { + value = newValue.toString(); + } + + final JSONArray extras = new JSONArray(); + extras.put(prefName); + extras.put(value); + Telemetry.sendUIEvent(TelemetryContract.Event.EDIT, Method.SETTINGS, extras.toString()); + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + final String prefName = preference.getKey(); + Log.i(LOGTAG, "Changed " + prefName + " = " + newValue); + recordSettingChangeTelemetry(prefName, newValue); + + if (PREFS_MP_ENABLED.equals(prefName)) { + showDialog((Boolean) newValue ? DIALOG_CREATE_MASTER_PASSWORD : DIALOG_REMOVE_MASTER_PASSWORD); + + // We don't want the "use master password" pref to change until the + // user has gone through the dialog. + return false; + } + + if (PREFS_HOMEPAGE.equals(prefName)) { + setHomePageSummary(preference, String.valueOf(newValue)); + } + + if (PREFS_BROWSER_LOCALE.equals(prefName)) { + // Even though this is a list preference, we don't want to handle it + // below, so we return here. + return onLocaleSelected(Locales.getLanguageTag(lastLocale), (String) newValue); + } + + if (PREFS_MENU_CHAR_ENCODING.equals(prefName)) { + setCharEncodingState(((String) newValue).equals("true")); + } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) { + UpdateServiceHelper.setAutoDownloadPolicy(this, UpdateService.AutoDownloadPolicy.get((String) newValue)); + } else if (PREFS_UPDATER_URL.equals(prefName)) { + UpdateServiceHelper.setUpdateUrl(this, (String) newValue); + } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(prefName)) { + final Boolean newBooleanValue = (Boolean) newValue; + AdjustConstants.getAdjustHelper().setEnabled(newBooleanValue); + } else if (PREFS_GEO_REPORTING.equals(prefName)) { + if ((Boolean) newValue) { + enableStumbler((CheckBoxPreference) preference); + return false; + } else { + broadcastStumblerPref(GeckoPreferences.this, false); + return true; + } + } else if (PREFS_TAB_QUEUE.equals(prefName)) { + if ((Boolean) newValue && !TabQueueHelper.canDrawOverlays(this)) { + Intent promptIntent = new Intent(this, TabQueuePrompt.class); + startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE); + return false; + } + } else if (PREFS_NOTIFICATIONS_CONTENT.equals(prefName)) { + FeedService.setup(this); + } else if (PREFS_ACTIVITY_STREAM.equals(prefName)) { + ThreadUtils.postDelayedToUiThread(new Runnable() { + @Override + public void run() { + GeckoAppShell.scheduleRestart(); + } + }, 1000); + } else if (HANDLERS.containsKey(prefName)) { + PrefHandler handler = HANDLERS.get(prefName); + handler.onChange(this, preference, newValue); + } + + // Send Gecko-side pref changes to Gecko + if (isGeckoPref(prefName)) { + PrefsHelper.setPref(prefName, newValue, true /* flush */); + } + + if (preference instanceof ListPreference) { + // We need to find the entry for the new value + int newIndex = ((ListPreference) preference).findIndexOfValue((String) newValue); + CharSequence newEntry = ((ListPreference) preference).getEntries()[newIndex]; + ((ListPreference) preference).setSummary(newEntry); + } else if (preference instanceof LinkPreference) { + setResult(RESULT_CODE_EXIT_SETTINGS); + finishChoosingTransition(); + } else if (preference instanceof FontSizePreference) { + final FontSizePreference fontSizePref = (FontSizePreference) preference; + fontSizePref.setSummary(fontSizePref.getSavedFontSizeName()); + } + + return true; + } + + private void enableStumbler(final CheckBoxPreference preference) { + Permissions + .from(this) + .withPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + .onUIThread() + .andFallback(new Runnable() { + @Override + public void run() { + preference.setChecked(false); + } + }) + .run(new Runnable() { + @Override + public void run() { + preference.setChecked(true); + broadcastStumblerPref(GeckoPreferences.this, true); + } + }); + } + + private TextInputLayout getTextBox(int aHintText) { + final EditText input = new EditText(this); + int inputtype = InputType.TYPE_CLASS_TEXT; + inputtype |= InputType.TYPE_TEXT_VARIATION_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + input.setInputType(inputtype); + + input.setHint(aHintText); + + final TextInputLayout layout = new TextInputLayout(this); + layout.addView(input); + + return layout; + } + + private class PasswordTextWatcher implements TextWatcher { + EditText input1; + EditText input2; + AlertDialog dialog; + + PasswordTextWatcher(EditText aInput1, EditText aInput2, AlertDialog aDialog) { + input1 = aInput1; + input2 = aInput2; + dialog = aDialog; + } + + @Override + public void afterTextChanged(Editable s) { + if (dialog == null) + return; + + String text1 = input1.getText().toString(); + String text2 = input2.getText().toString(); + boolean disabled = TextUtils.isEmpty(text1) || TextUtils.isEmpty(text2) || !text1.equals(text2); + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!disabled); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { } + } + + private class EmptyTextWatcher implements TextWatcher { + EditText input; + AlertDialog dialog; + + EmptyTextWatcher(EditText aInput, AlertDialog aDialog) { + input = aInput; + dialog = aDialog; + } + + @Override + public void afterTextChanged(Editable s) { + if (dialog == null) + return; + + String text = input.getText().toString(); + boolean disabled = TextUtils.isEmpty(text); + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!disabled); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { } + } + + @Override + protected Dialog onCreateDialog(int id) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + LinearLayout linearLayout = new LinearLayout(this); + linearLayout.setOrientation(LinearLayout.VERTICAL); + AlertDialog dialog; + switch (id) { + case DIALOG_CREATE_MASTER_PASSWORD: + final TextInputLayout inputLayout1 = getTextBox(R.string.masterpassword_password); + final TextInputLayout inputLayout2 = getTextBox(R.string.masterpassword_confirm); + linearLayout.addView(inputLayout1); + linearLayout.addView(inputLayout2); + + final EditText input1 = inputLayout1.getEditText(); + final EditText input2 = inputLayout2.getEditText(); + + builder.setTitle(R.string.masterpassword_create_title) + .setView((View) linearLayout) + .setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + PrefsHelper.setPref(PREFS_MP_ENABLED, + input1.getText().toString(), + /* flush */ true); + } + }) + .setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + return; + } + }); + dialog = builder.create(); + dialog.setOnShowListener(new DialogInterface.OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + input1.setText(""); + input2.setText(""); + input1.requestFocus(); + } + }); + + PasswordTextWatcher watcher = new PasswordTextWatcher(input1, input2, dialog); + input1.addTextChangedListener((TextWatcher) watcher); + input2.addTextChangedListener((TextWatcher) watcher); + + break; + case DIALOG_REMOVE_MASTER_PASSWORD: + final TextInputLayout inputLayout = getTextBox(R.string.masterpassword_password); + linearLayout.addView(inputLayout); + final EditText input = inputLayout.getEditText(); + + builder.setTitle(R.string.masterpassword_remove_title) + .setView((View) linearLayout) + .setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + PrefsHelper.setPref(PREFS_MP_ENABLED, input.getText().toString()); + } + }) + .setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + return; + } + }); + dialog = builder.create(); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + input.setText(""); + } + }); + dialog.setOnShowListener(new DialogInterface.OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + input.setText(""); + } + }); + input.addTextChangedListener(new EmptyTextWatcher(input, dialog)); + break; + default: + return null; + } + + return dialog; + } + + // Initialize preferences by requesting the preference values from Gecko + private static class PrefCallbacks extends PrefsHelper.PrefHandlerBase { + private final PreferenceGroup screen; + + public PrefCallbacks(final PreferenceGroup screen) { + this.screen = screen; + } + + private Preference getField(String prefName) { + return screen.findPreference(prefName); + } + + @Override + public void prefValue(String prefName, final boolean value) { + final TwoStatePreference pref = (TwoStatePreference) getField(prefName); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (pref.isChecked() != value) { + pref.setChecked(value); + } + } + }); + } + + @Override + public void prefValue(String prefName, final String value) { + final Preference pref = getField(prefName); + if (pref instanceof EditTextPreference) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + ((EditTextPreference) pref).setText(value); + } + }); + } else if (pref instanceof ListPreference) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + ((ListPreference) pref).setValue(value); + // Set the summary string to the current entry + CharSequence selectedEntry = ((ListPreference) pref).getEntry(); + ((ListPreference) pref).setSummary(selectedEntry); + } + }); + } else if (pref instanceof FontSizePreference) { + final FontSizePreference fontSizePref = (FontSizePreference) pref; + fontSizePref.setSavedFontSize(value); + final String fontSizeName = fontSizePref.getSavedFontSizeName(); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + fontSizePref.setSummary(fontSizeName); // Ex: "Small". + } + }); + } + } + + @Override + public void prefValue(String prefName, final int value) { + final Preference pref = getField(prefName); + Log.w(LOGTAG, "Unhandled int value for pref [" + pref + "]"); + } + + @Override + public void finish() { + // enable all preferences once we have them from gecko + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + screen.setEnabled(true); + } + }); + } + } + + private PrefsHelper.PrefHandler getGeckoPreferences(final PreferenceGroup screen, + ArrayList<String> prefs) { + final PrefsHelper.PrefHandler prefHandler = new PrefCallbacks(screen); + final String[] prefNames = prefs.toArray(new String[prefs.size()]); + PrefsHelper.addObserver(prefNames, prefHandler); + return prefHandler; + } + + @Override + public boolean isGeckoActivityOpened() { + return false; + } + + /** + * Given an Intent instance, add extras to specify which settings section to + * open. + * + * resource should be a valid Android XML resource identifier. + * + * The mechanism to open a section differs based on Android version. + */ + public static void setResourceToOpen(final Intent intent, final String resource) { + if (intent == null) { + throw new IllegalArgumentException("intent must not be null"); + } + if (resource == null) { + return; + } + + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, GeckoPreferenceFragment.class.getName()); + + Bundle fragmentArgs = new Bundle(); + fragmentArgs.putString("resource", resource); + intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java new file mode 100644 index 000000000..774f78c53 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java @@ -0,0 +1,35 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.Tabs; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; + +class LinkPreference extends Preference { + private String mUrl; + + public LinkPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mUrl = attrs.getAttributeValue(null, "url"); + } + public LinkPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mUrl = attrs.getAttributeValue(null, "url"); + } + + public void setUrl(String url) { + mUrl = url; + } + + @Override + protected void onClick() { + Tabs.getInstance().loadUrlInTab(mUrl); + callChangeListener(mUrl); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java new file mode 100644 index 000000000..f56ea58b9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java @@ -0,0 +1,58 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Checkable; + +import org.mozilla.gecko.R; + +/** + * This preference shows a checkbox on its left hand side, but will show a menu when clicked. + * Its used for preferences like "Clear on Exit" that have a boolean on-off state, but that represent + * multiple boolean options inside. + **/ +class ListCheckboxPreference extends MultiChoicePreference implements Checkable { + private static final String LOGTAG = "GeckoListCheckboxPreference"; + private boolean checked; + + public ListCheckboxPreference(Context context, AttributeSet attrs) { + super(context, attrs); + setWidgetLayoutResource(R.layout.preference_checkbox); + } + + @Override + public boolean isChecked() { + return checked; + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + + View checkboxView = view.findViewById(R.id.checkbox); + if (checkboxView instanceof Checkable) { + ((Checkable) checkboxView).setChecked(checked); + } + } + + @Override + public void setChecked(boolean checked) { + boolean changed = checked != this.checked; + this.checked = checked; + if (changed) { + notifyDependencyChange(shouldDisableDependents()); + notifyChanged(); + } + } + + @Override + public void toggle() { + checked = !checked; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java new file mode 100644 index 000000000..c962a3d19 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java @@ -0,0 +1,316 @@ +/* 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.preferences; + +import java.nio.ByteBuffer; +import java.text.Collator; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.BrowserLocaleManager; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.preference.ListPreference; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; + +public class LocaleListPreference extends ListPreference { + private static final String LOG_TAG = "GeckoLocaleList"; + + /** + * With thanks to <http://stackoverflow.com/a/22679283/22003> for the + * initial solution. + * + * This class encapsulates an approach to checking whether a script + * is usable on a device. We attempt to draw a character from the + * script (e.g., ব). If the fonts on the device don't have the correct + * glyph, Android typically renders whitespace (rather than .notdef). + * + * Pass in part of the name of the locale in its local representation, + * and a whitespace character; this class performs the graphical comparison. + * + * See Bug 1023451 Comment 24 for extensive explanation. + */ + private static class CharacterValidator { + private static final int BITMAP_WIDTH = 32; + private static final int BITMAP_HEIGHT = 48; + + private final Paint paint = new Paint(); + private final byte[] missingCharacter; + + public CharacterValidator(String missing) { + this.missingCharacter = getPixels(drawBitmap(missing)); + } + + private Bitmap drawBitmap(String text) { + Bitmap b = Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ALPHA_8); + Canvas c = new Canvas(b); + c.drawText(text, 0, BITMAP_HEIGHT / 2, this.paint); + return b; + } + + private static byte[] getPixels(final Bitmap b) { + final int byteCount; + if (Versions.feature19Plus) { + byteCount = b.getAllocationByteCount(); + } else { + // Close enough for government work. + // Equivalent to getByteCount, but works on <12. + byteCount = b.getRowBytes() * b.getHeight(); + } + + final ByteBuffer buffer = ByteBuffer.allocate(byteCount); + try { + b.copyPixelsToBuffer(buffer); + } catch (RuntimeException e) { + // Android throws this if there's not enough space in the buffer. + // This should never occur, but if it does, we don't + // really care -- we probably don't need the entire image. + // This is awful. I apologize. + if ("Buffer not large enough for pixels".equals(e.getMessage())) { + return buffer.array(); + } + throw e; + } + + return buffer.array(); + } + + public boolean characterIsMissingInFont(String ch) { + byte[] rendered = getPixels(drawBitmap(ch)); + return Arrays.equals(rendered, missingCharacter); + } + } + + private volatile Locale entriesLocale; + private final CharacterValidator characterValidator; + + public LocaleListPreference(Context context) { + this(context, null); + } + + public LocaleListPreference(Context context, AttributeSet attributes) { + super(context, attributes); + + // Thus far, missing glyphs are replaced by whitespace, not a box + // or other Unicode codepoint. + this.characterValidator = new CharacterValidator(" "); + buildList(); + } + + private static final class LocaleDescriptor implements Comparable<LocaleDescriptor> { + // We use Locale.US here to ensure a stable ordering of entries. + private static final Collator COLLATOR = Collator.getInstance(Locale.US); + + public final String tag; + private final String nativeName; + + public LocaleDescriptor(String tag) { + this(Locales.parseLocaleCode(tag), tag); + } + + public LocaleDescriptor(Locale locale, String tag) { + this.tag = tag; + + final String displayName = locale.getDisplayName(locale); + if (TextUtils.isEmpty(displayName)) { + // There's nothing sane we can do. + Log.w(LOG_TAG, "Display name is empty. Using " + locale.toString()); + this.nativeName = locale.toString(); + return; + } + + // For now, uppercase the first character of LTR locale names. + // This is pretty much what Android does. This is a reasonable hack + // for Bug 1014602, but it won't generalize to all locales. + final byte directionality = Character.getDirectionality(displayName.charAt(0)); + if (directionality == Character.DIRECTIONALITY_LEFT_TO_RIGHT) { + this.nativeName = displayName.substring(0, 1).toUpperCase(locale) + + displayName.substring(1); + return; + } + + this.nativeName = displayName; + } + + public String getTag() { + return this.tag; + } + + public String getDisplayName() { + return this.nativeName; + } + + @Override + public String toString() { + return this.nativeName; + } + + + @Override + public int compareTo(LocaleDescriptor another) { + // We sort by name, so we use Collator. + return COLLATOR.compare(this.nativeName, another.nativeName); + } + + /** + * See Bug 1023451 Comment 10 for the research that led to + * this method. + * + * @return true if this locale can be used for displaying UI + * on this device without known issues. + */ + public boolean isUsable(CharacterValidator validator) { + if (Versions.preLollipop && this.tag.matches("[a-zA-Z]{3}.*")) { + // Earlier versions of Android can't load three-char locale code + // resources. + return false; + } + + // Oh, for Java 7 switch statements. + if (this.tag.equals("bn-IN")) { + // Bengali sometimes has an English label if the Bengali script + // is missing. This prevents us from simply checking character + // rendering for bn-IN; we'll get a false positive for "B", not "ব". + // + // This doesn't seem to affect other Bengali-script locales + // (below), which always have a label in native script. + if (!this.nativeName.startsWith("বাংলা")) { + // We're on an Android version that doesn't even have + // characters to say বাংলা. Definite failure. + return false; + } + } + + // These locales use a script that is often unavailable + // on common Android devices. Make sure we can show them. + // See documentation for CharacterValidator. + // Note that bn-IN is checked here even if it passed above. + if (this.tag.equals("or") || + this.tag.equals("my") || + this.tag.equals("pa-IN") || + this.tag.equals("gu-IN") || + this.tag.equals("bn-IN")) { + if (validator.characterIsMissingInFont(this.nativeName.substring(0, 1))) { + return false; + } + } + + return true; + } + } + + /** + * Not every locale we ship can be used on every device, due to + * font or rendering constraints. + * + * This method filters down the list before generating the descriptor array. + */ + private LocaleDescriptor[] getUsableLocales() { + Collection<String> shippingLocales = BrowserLocaleManager.getPackagedLocaleTags(getContext()); + + // Future: single-locale builds should be specified, too. + if (shippingLocales == null) { + final String fallbackTag = BrowserLocaleManager.getInstance().getFallbackLocaleTag(); + return new LocaleDescriptor[] { new LocaleDescriptor(fallbackTag) }; + } + + final int initialCount = shippingLocales.size(); + final Set<LocaleDescriptor> locales = new HashSet<LocaleDescriptor>(initialCount); + for (String tag : shippingLocales) { + final LocaleDescriptor descriptor = new LocaleDescriptor(tag); + + if (!descriptor.isUsable(this.characterValidator)) { + Log.w(LOG_TAG, "Skipping locale " + tag + " on this device."); + continue; + } + + locales.add(descriptor); + } + + final int usableCount = locales.size(); + final LocaleDescriptor[] descriptors = locales.toArray(new LocaleDescriptor[usableCount]); + Arrays.sort(descriptors, 0, usableCount); + return descriptors; + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + // The superclass will take care of persistence. + super.onDialogClosed(positiveResult); + + // Use this hook to try to fix up the environment ASAP. + // Do this so that the redisplayed fragment is inflated + // with the right locale. + final Locale selectedLocale = getSelectedLocale(); + final Context context = getContext(); + BrowserLocaleManager.getInstance().updateConfiguration(context, selectedLocale); + } + + private Locale getSelectedLocale() { + final String tag = getValue(); + if (tag == null || tag.equals("")) { + return Locale.getDefault(); + } + return Locales.parseLocaleCode(tag); + } + + @Override + public CharSequence getSummary() { + final String value = getValue(); + + if (TextUtils.isEmpty(value)) { + return getContext().getString(R.string.locale_system_default); + } + + // We can't trust super.getSummary() across locale changes, + // apparently, so let's do the same work. + return new LocaleDescriptor(value).getDisplayName(); + } + + private void buildList() { + final Locale currentLocale = Locale.getDefault(); + Log.d(LOG_TAG, "Building locales list. Current locale: " + currentLocale); + + if (currentLocale.equals(this.entriesLocale) && + getEntries() != null) { + Log.v(LOG_TAG, "No need to build list."); + return; + } + + final LocaleDescriptor[] descriptors = getUsableLocales(); + final int count = descriptors.length; + + this.entriesLocale = currentLocale; + + // We leave room for "System default". + final String[] entries = new String[count + 1]; + final String[] values = new String[count + 1]; + + entries[0] = getContext().getString(R.string.locale_system_default); + values[0] = ""; + + for (int i = 0; i < count; ++i) { + final String displayName = descriptors[i].getDisplayName(); + final String tag = descriptors[i].getTag(); + Log.v(LOG_TAG, displayName + " => " + tag); + entries[i + 1] = displayName; + values[i + 1] = tag; + } + + setEntries(entries); + setEntryValues(values); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java new file mode 100644 index 000000000..c545472e2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java @@ -0,0 +1,67 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.preference.Preference; +import android.text.Spanned; +import android.text.SpannableStringBuilder; +import android.text.style.ImageSpan; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +class ModifiableHintPreference extends Preference { + private static final String LOGTAG = "ModifiableHintPref"; + private final Context mContext; + + private final String MATCH_STRING = "%I"; + private final int RESID_TEXT_VIEW = R.id.label_search_hint; + private final int RESID_DRAWABLE = R.drawable.ab_add_search_engine; + private final double SCALE_FACTOR = 0.5; + + public ModifiableHintPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + } + + public ModifiableHintPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mContext = context; + } + + @Override + protected View onCreateView(ViewGroup parent) { + View thisView = super.onCreateView(parent); + configurePreferenceView(thisView); + return thisView; + } + + private void configurePreferenceView(View view) { + TextView textView = (TextView) view.findViewById(RESID_TEXT_VIEW); + String searchHint = textView.getText().toString(); + + // Use an ImageSpan to include the "add search" icon in the Tip. + int imageSpanIndex = searchHint.indexOf(MATCH_STRING); + if (imageSpanIndex != -1) { + // Scale the resource. + Drawable drawable = mContext.getResources().getDrawable(RESID_DRAWABLE); + drawable.setBounds(0, 0, (int) (drawable.getIntrinsicWidth() * SCALE_FACTOR), + (int) (drawable.getIntrinsicHeight() * SCALE_FACTOR)); + + ImageSpan searchIcon = new ImageSpan(drawable); + final SpannableStringBuilder hintBuilder = new SpannableStringBuilder(searchHint); + + // Insert the image. + hintBuilder.setSpan(searchIcon, imageSpanIndex, imageSpanIndex + MATCH_STRING.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); + textView.setText(hintBuilder, TextView.BufferType.SPANNABLE); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java new file mode 100644 index 000000000..5749bf29d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java @@ -0,0 +1,271 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.util.PrefUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.AlertDialog.Builder; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.TypedArray; +import android.content.SharedPreferences; +import android.preference.DialogPreference; +import android.util.AttributeSet; + +import java.util.HashSet; +import java.util.Set; + +class MultiChoicePreference extends DialogPreference implements DialogInterface.OnMultiChoiceClickListener { + private static final String LOGTAG = "GeckoMultiChoicePreference"; + + private boolean mValues[]; + private boolean mPrevValues[]; + private CharSequence mEntryValues[]; + private CharSequence mEntries[]; + private CharSequence mInitialValues[]; + + public MultiChoicePreference(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiChoicePreference); + mEntries = a.getTextArray(R.styleable.MultiChoicePreference_entries); + mEntryValues = a.getTextArray(R.styleable.MultiChoicePreference_entryValues); + mInitialValues = a.getTextArray(R.styleable.MultiChoicePreference_initialValues); + a.recycle(); + + loadPersistedValues(); + } + + public MultiChoicePreference(Context context) { + this(context, null); + } + + /** + * Sets the human-readable entries to be shown in the list. This will be + * shown in subsequent dialogs. + * <p> + * Each entry must have a corresponding index in + * {@link #setEntryValues(CharSequence[])} and + * {@link #setInitialValues(CharSequence[])}. + * + * @param entries The entries. + */ + public void setEntries(CharSequence[] entries) { + mEntries = entries.clone(); + } + + /** + * @param entriesResId The entries array as a resource. + */ + public void setEntries(int entriesResId) { + setEntries(getContext().getResources().getTextArray(entriesResId)); + } + + /** + * Sets the preference values for preferences shown in the list. + * + * @param entryValues The entry values. + */ + public void setEntryValues(CharSequence[] entryValues) { + mEntryValues = entryValues.clone(); + loadPersistedValues(); + } + + /** + * Entry values define a separate pref for each row in the dialog. + * + * @param entryValuesResId The entryValues array as a resource. + */ + public void setEntryValues(int entryValuesResId) { + setEntryValues(getContext().getResources().getTextArray(entryValuesResId)); + } + + /** + * The array of initial entry values in this list. Each entryValue + * corresponds to an entryKey. These values are used if a) the preference + * isn't persisted, or b) the preference is persisted but hasn't yet been + * set. + * + * @param initialValues The entry values + */ + public void setInitialValues(CharSequence[] initialValues) { + mInitialValues = initialValues.clone(); + loadPersistedValues(); + } + + /** + * @param initialValuesResId The initialValues array as a resource. + */ + public void setInitialValues(int initialValuesResId) { + setInitialValues(getContext().getResources().getTextArray(initialValuesResId)); + } + + /** + * The list of translated strings corresponding to each preference. + * + * @return The array of entries. + */ + public CharSequence[] getEntries() { + return mEntries.clone(); + } + + /** + * The list of values corresponding to each preference. + * + * @return The array of values. + */ + public CharSequence[] getEntryValues() { + return mEntryValues.clone(); + } + + /** + * The list of initial values for each preference. Each string in this list + * should be either "true" or "false". + * + * @return The array of initial values. + */ + public CharSequence[] getInitialValues() { + return mInitialValues.clone(); + } + + public void setValue(final int i, final boolean value) { + mValues[i] = value; + mPrevValues = mValues.clone(); + } + + /** + * The list of values for each preference. These values are updated after + * the dialog has been displayed. + * + * @return The array of values. + */ + public Set<String> getValues() { + final Set<String> values = new HashSet<String>(); + + if (mValues == null) { + return values; + } + + for (int i = 0; i < mValues.length; i++) { + if (mValues[i]) { + values.add(mEntryValues[i].toString()); + } + } + + return values; + } + + @Override + public void onClick(DialogInterface dialog, int which, boolean val) { + } + + @Override + protected void onPrepareDialogBuilder(Builder builder) { + if (mEntries == null || mInitialValues == null || mEntryValues == null) { + throw new IllegalStateException( + "MultiChoicePreference requires entries, entryValues, and initialValues arrays."); + } + + if (mEntries.length != mEntryValues.length || mEntries.length != mInitialValues.length) { + throw new IllegalStateException( + "MultiChoicePreference entries, entryValues, and initialValues arrays must be the same length"); + } + + builder.setMultiChoiceItems(mEntries, mValues, this); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + if (mPrevValues == null || mInitialValues == null) { + // Initialization is done asynchronously, so these values may not + // have been set before the dialog was closed. + return; + } + + if (!positiveResult) { + // user cancelled; reset checkbox values to their previous state + mValues = mPrevValues.clone(); + return; + } + + mPrevValues = mValues.clone(); + + if (!callChangeListener(getValues())) { + return; + } + + persist(); + } + + /* Persists the current data stored by this pref to SharedPreferences. */ + public boolean persist() { + if (isPersistent()) { + final SharedPreferences.Editor edit = GeckoSharedPrefs.forProfile(getContext()).edit(); + final boolean res = persist(edit); + edit.apply(); + return res; + } + + return false; + } + + /* Internal persist method. Take an edit so that multiple prefs can be persisted in a single commit. */ + protected boolean persist(SharedPreferences.Editor edit) { + if (isPersistent()) { + Set<String> vals = getValues(); + PrefUtils.putStringSet(edit, getKey(), vals).apply();; + return true; + } + + return false; + } + + /* Returns a list of EntryValues that are currently enabled. */ + public Set<String> getPersistedStrings(Set<String> defaultVal) { + if (!isPersistent()) { + return defaultVal; + } + + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext()); + return PrefUtils.getStringSet(prefs, getKey(), defaultVal); + } + + /** + * Loads persistent prefs from shared preferences. If the preferences + * aren't persistent or haven't yet been stored, they will be set to their + * initial values. + */ + protected void loadPersistedValues() { + final int entryCount = mInitialValues.length; + mValues = new boolean[entryCount]; + + if (entryCount != mEntries.length || entryCount != mEntryValues.length) { + throw new IllegalStateException( + "MultiChoicePreference entryValues and initialValues arrays must be the same length"); + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final Set<String> stringVals = getPersistedStrings(null); + + for (int i = 0; i < entryCount; i++) { + if (stringVals != null) { + mValues[i] = stringVals.contains(mEntryValues[i]); + } else { + final boolean defaultVal = mInitialValues[i].equals("true"); + mValues[i] = defaultVal; + } + } + + mPrevValues = mValues.clone(); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java new file mode 100644 index 000000000..580d613ca --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java @@ -0,0 +1,116 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.TypedArray; +import android.content.SharedPreferences; +import android.widget.Button; +import android.util.AttributeSet; +import android.util.Log; + +import java.util.Set; + +/* Provides backwards compatibility for some old multi-choice pref types used by Gecko. + * This will import the old data from the old prefs the first time it is run. + */ +class MultiPrefMultiChoicePreference extends MultiChoicePreference { + private static final String LOGTAG = "GeckoMultiPrefPreference"; + private static final String IMPORT_SUFFIX = "_imported_"; + private final CharSequence[] keys; + + public MultiPrefMultiChoicePreference(Context context, AttributeSet attrs) { + super(context, attrs); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiPrefMultiChoicePreference); + keys = a.getTextArray(R.styleable.MultiPrefMultiChoicePreference_entryKeys); + a.recycle(); + + loadPersistedValues(); + } + + // Helper method for reading a boolean pref. + private boolean getPersistedBoolean(SharedPreferences prefs, String key, boolean defaultReturnValue) { + if (!isPersistent()) { + return defaultReturnValue; + } + + return prefs.getBoolean(key, defaultReturnValue); + } + + // Overridden to do a one time import for the old preference type to the new one. + @Override + protected synchronized void loadPersistedValues() { + // This will load the new pref if it exists. + super.loadPersistedValues(); + + // First check if we've already done the import the old data. If so, nothing to load. + final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext()); + final boolean imported = getPersistedBoolean(prefs, getKey() + IMPORT_SUFFIX, false); + if (imported) { + return; + } + + // Load the data we'll need to find the old style prefs + final CharSequence[] init = getInitialValues(); + final CharSequence[] entries = getEntries(); + if (keys == null || init == null) { + return; + } + + final int entryCount = keys.length; + if (entryCount != entries.length || entryCount != init.length) { + throw new IllegalStateException("MultiChoicePreference entryKeys and initialValues arrays must be the same length"); + } + + // Now iterate through the entries on a background thread. + final SharedPreferences.Editor edit = prefs.edit(); + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + try { + // Use one editor to batch as many changes as we can. + for (int i = 0; i < entryCount; i++) { + String key = keys[i].toString(); + boolean initialValue = "true".equals(init[i]); + boolean val = getPersistedBoolean(prefs, key, initialValue); + + // Save the pref and remove the old preference. + setValue(i, val); + edit.remove(key); + } + + persist(edit); + edit.putBoolean(getKey() + IMPORT_SUFFIX, true); + edit.apply(); + } catch (Exception ex) { + Log.i(LOGTAG, "Err", ex); + } + } + }); + } + + + @Override + public void onClick(DialogInterface dialog, int which, boolean val) { + // enable positive button only if at least one item is checked + boolean enabled = false; + final Set<String> values = getValues(); + + enabled = (values.size() > 0); + final Button button = ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE); + if (button.isEnabled() != enabled) { + button.setEnabled(enabled); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java new file mode 100644 index 000000000..337d9dd2f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java @@ -0,0 +1,255 @@ +/* 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.preferences; + +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.PropertyAnimator.Property; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.R; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.DialogInterface.OnShowListener; +import android.content.res.Resources; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +public class PanelsPreference extends CustomListPreference { + protected String LOGTAG = "PanelsPreference"; + + // Position state of this Preference in enclosing category. + private static final int STATE_IS_FIRST = 0; + private static final int STATE_IS_LAST = 1; + + /** + * Index of the context menu button for controlling display options. + * For (removable) Dynamic panels, this button removes the panel. + * For built-in panels, this button toggles showing or hiding the panel. + */ + private static final int INDEX_DISPLAY_BUTTON = 1; + private static final int INDEX_REORDER_BUTTON = 2; + + // Indices of buttons in context menu for reordering. + private static final int INDEX_MOVE_UP_BUTTON = 0; + private static final int INDEX_MOVE_DOWN_BUTTON = 1; + + private String LABEL_HIDE; + private String LABEL_SHOW; + + private View preferenceView; + protected boolean mIsHidden; + private final boolean mIsRemovable; + + private boolean mAnimate; + private static final int ANIMATION_DURATION_MS = 400; + + // State for reordering. + private int mPositionState = -1; + private final int mIndex; + + public PanelsPreference(Context context, CustomListCategory parentCategory, boolean isRemovable, int index, boolean animate) { + super(context, parentCategory); + mIsRemovable = isRemovable; + mIndex = index; + mAnimate = animate; + } + + @Override + protected int getPreferenceLayoutResource() { + return R.layout.preference_panels; + } + + @Override + protected void onBindView(View view) { + super.onBindView(view); + + // Override view handling so we can grey out "hidden" PanelPreferences. + view.setEnabled(!mIsHidden); + + if (view instanceof ViewGroup) { + final ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + group.getChildAt(i).setEnabled(!mIsHidden); + } + preferenceView = group; + } + + if (mAnimate) { + ViewHelper.setAlpha(preferenceView, 0); + + final PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION_MS); + animator.attach(preferenceView, Property.ALPHA, 1); + animator.start(); + + // Clear animate flag. + mAnimate = false; + } + } + + @Override + protected String[] createDialogItems() { + final Resources res = getContext().getResources(); + final String labelReorder = res.getString(R.string.pref_panels_reorder); + + if (mIsRemovable) { + return new String[] { LABEL_SET_AS_DEFAULT, LABEL_REMOVE, labelReorder }; + } + + // Built-in panels can't be removed, so use show/hide options. + LABEL_HIDE = res.getString(R.string.pref_panels_hide); + LABEL_SHOW = res.getString(R.string.pref_panels_show); + + return new String[] { LABEL_SET_AS_DEFAULT, LABEL_HIDE, labelReorder }; + } + + @Override + public void setIsDefault(boolean isDefault) { + mIsDefault = isDefault; + if (isDefault) { + setSummary(LABEL_IS_DEFAULT); + if (mIsHidden) { + // Unhide the panel if it's being set as the default. + setHidden(false); + } + } else { + setSummary(""); + } + } + + @Override + protected void onDialogIndexClicked(int index) { + switch (index) { + case INDEX_SET_DEFAULT_BUTTON: + mParentCategory.setDefault(this); + break; + + case INDEX_DISPLAY_BUTTON: + // Handle display options for the panel. + if (mIsRemovable) { + // For removable panels, the button displays text for removing the panel. + mParentCategory.uninstall(this); + } else { + // Otherwise, the button toggles between text for showing or hiding the panel. + ((PanelsPreferenceCategory) mParentCategory).setHidden(this, !mIsHidden); + } + break; + + case INDEX_REORDER_BUTTON: + // Display dialog for changing preference order. + final Dialog orderDialog = makeReorderDialog(); + orderDialog.show(); + break; + + default: + Log.w(LOGTAG, "Selected index out of range: " + index); + } + } + + @Override + protected void configureShownDialog() { + super.configureShownDialog(); + + // Handle Show/Hide buttons. + if (!mIsRemovable) { + final TextView hideButton = (TextView) mDialog.getListView().getChildAt(INDEX_DISPLAY_BUTTON); + hideButton.setText(mIsHidden ? LABEL_SHOW : LABEL_HIDE); + } + } + + + private Dialog makeReorderDialog() { + final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + + final Resources res = getContext().getResources(); + final String labelUp = res.getString(R.string.pref_panels_move_up); + final String labelDown = res.getString(R.string.pref_panels_move_down); + + builder.setTitle(getTitle()); + builder.setItems(new String[] { labelUp, labelDown }, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int index) { + dialog.dismiss(); + switch (index) { + case INDEX_MOVE_UP_BUTTON: + ((PanelsPreferenceCategory) mParentCategory).moveUp(PanelsPreference.this); + break; + + case INDEX_MOVE_DOWN_BUTTON: + ((PanelsPreferenceCategory) mParentCategory).moveDown(PanelsPreference.this); + break; + } + } + }); + + final Dialog dialog = builder.create(); + dialog.setOnShowListener(new OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + setReorderItemsEnabled(dialog); + } + }); + + return dialog; + } + + public void setIsFirst() { + mPositionState = STATE_IS_FIRST; + } + + public void setIsLast() { + mPositionState = STATE_IS_LAST; + } + + /** + * Configure enabled state of the reorder dialog, which must be done after the dialog is shown. + * @param dialog Dialog to configure + */ + private void setReorderItemsEnabled(DialogInterface dialog) { + // Update button enabled-ness for reordering. + switch (mPositionState) { + case STATE_IS_FIRST: + final TextView itemUp = (TextView) ((AlertDialog) dialog).getListView().getChildAt(INDEX_MOVE_UP_BUTTON); + itemUp.setEnabled(false); + // Disable clicks to this view. + itemUp.setOnClickListener(null); + break; + + case STATE_IS_LAST: + final TextView itemDown = (TextView) ((AlertDialog) dialog).getListView().getChildAt(INDEX_MOVE_DOWN_BUTTON); + itemDown.setEnabled(false); + // Disable clicks to this view. + itemDown.setOnClickListener(null); + break; + + default: + // Do nothing. + break; + } + } + + public void setHidden(boolean toHide) { + if (toHide) { + setIsDefault(false); + } + + if (mIsHidden != toHide) { + mIsHidden = toHide; + notifyChanged(); + } + } + + public boolean isHidden() { + return mIsHidden; + } + + public int getIndex() { + return mIndex; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java new file mode 100644 index 000000000..d44b6eaa9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java @@ -0,0 +1,261 @@ +/* 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.preferences; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.home.HomeConfig; +import org.mozilla.gecko.home.HomeConfig.PanelConfig; +import org.mozilla.gecko.home.HomeConfig.State; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; + +public class PanelsPreferenceCategory extends CustomListCategory { + public static final String LOGTAG = "PanelsPrefCategory"; + + protected HomeConfig mHomeConfig; + protected HomeConfig.Editor mConfigEditor; + + protected UIAsyncTask.WithoutParams<State> mLoadTask; + + public PanelsPreferenceCategory(Context context) { + super(context); + initConfig(context); + } + + public PanelsPreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + initConfig(context); + } + + public PanelsPreferenceCategory(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initConfig(context); + } + + protected void initConfig(Context context) { + mHomeConfig = HomeConfig.getDefault(context); + } + + @Override + public void onAttachedToActivity() { + super.onAttachedToActivity(); + + loadHomeConfig(null); + } + + /** + * Load the Home Panels config and populate the preferences screen and maintain local state. + */ + private void loadHomeConfig(final String animatePanelId) { + mLoadTask = new UIAsyncTask.WithoutParams<State>(ThreadUtils.getBackgroundHandler()) { + @Override + public HomeConfig.State doInBackground() { + return mHomeConfig.load(); + } + + @Override + public void onPostExecute(HomeConfig.State configState) { + mConfigEditor = configState.edit(); + displayHomeConfig(configState, animatePanelId); + } + }; + mLoadTask.execute(); + } + + /** + * Simplified refresh of Home Panels when there is no state to be persisted. + */ + public void refresh() { + refresh(null, null); + } + + /** + * Refresh the Home Panels list and animate a panel, if specified. + * If null, load from HomeConfig. + * + * @param State HomeConfig.State to rebuild Home Panels list from. + * @param String panelId of panel to be animated. + */ + public void refresh(State state, String animatePanelId) { + // Clear all the existing home panels. + removeAll(); + + if (state == null) { + loadHomeConfig(animatePanelId); + } else { + displayHomeConfig(state, animatePanelId); + } + } + + private void displayHomeConfig(HomeConfig.State configState, String animatePanelId) { + int index = 0; + for (PanelConfig panelConfig : configState) { + final boolean isRemovable = panelConfig.isDynamic(); + + // Create and add the pref. + final String panelId = panelConfig.getId(); + final boolean animate = TextUtils.equals(animatePanelId, panelId); + + final PanelsPreference pref = new PanelsPreference(getContext(), PanelsPreferenceCategory.this, isRemovable, index, animate); + pref.setTitle(panelConfig.getTitle()); + pref.setKey(panelConfig.getId()); + // XXX: Pull icon from PanelInfo. + addPreference(pref); + + if (panelConfig.isDisabled()) { + pref.setHidden(true); + } + + index++; + } + + setPositionState(); + setDefaultFromConfig(); + } + + private void setPositionState() { + final int prefCount = getPreferenceCount(); + + // Pass in position state to first and last preference. + final PanelsPreference firstPref = (PanelsPreference) getPreference(0); + firstPref.setIsFirst(); + + final PanelsPreference lastPref = (PanelsPreference) getPreference(prefCount - 1); + lastPref.setIsLast(); + } + + private void setDefaultFromConfig() { + final String defaultPanelId = mConfigEditor.getDefaultPanelId(); + if (defaultPanelId == null) { + mDefaultReference = null; + return; + } + + final int prefCount = getPreferenceCount(); + + for (int i = 0; i < prefCount; i++) { + final PanelsPreference pref = (PanelsPreference) getPreference(i); + + if (defaultPanelId.equals(pref.getKey())) { + super.setDefault(pref); + break; + } + } + } + + @Override + public void setDefault(CustomListPreference pref) { + super.setDefault(pref); + + final String id = pref.getKey(); + + final String defaultPanelId = mConfigEditor.getDefaultPanelId(); + if (defaultPanelId != null && defaultPanelId.equals(id)) { + return; + } + + updateVisibilityPrefsForPanel(id, true); + + mConfigEditor.setDefault(id); + mConfigEditor.apply(); + + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_SET_DEFAULT, Method.DIALOG, id); + } + + @Override + protected void onPrepareForRemoval() { + super.onPrepareForRemoval(); + if (mLoadTask != null) { + mLoadTask.cancel(); + } + } + + @Override + public void uninstall(CustomListPreference pref) { + final String id = pref.getKey(); + mConfigEditor.uninstall(id); + mConfigEditor.apply(); + + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_REMOVE, Method.DIALOG, id); + + super.uninstall(pref); + } + + public void moveUp(PanelsPreference pref) { + final int panelIndex = pref.getIndex(); + if (panelIndex > 0) { + final String panelKey = pref.getKey(); + mConfigEditor.moveTo(panelKey, panelIndex - 1); + final State state = mConfigEditor.apply(); + + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_MOVE, Method.DIALOG, panelKey); + + refresh(state, panelKey); + } + } + + public void moveDown(PanelsPreference pref) { + final int panelIndex = pref.getIndex(); + if (panelIndex < getPreferenceCount() - 1) { + final String panelKey = pref.getKey(); + mConfigEditor.moveTo(panelKey, panelIndex + 1); + final State state = mConfigEditor.apply(); + + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_MOVE, Method.DIALOG, panelKey); + + refresh(state, panelKey); + } + } + + /** + * Update the hide/show state of the preference and save the HomeConfig + * changes. + * + * @param pref Preference to update + * @param toHide New hidden state of the preference + */ + protected void setHidden(PanelsPreference pref, boolean toHide) { + final String id = pref.getKey(); + mConfigEditor.setDisabled(id, toHide); + mConfigEditor.apply(); + + if (toHide) { + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_HIDE, Method.DIALOG, id); + } else { + Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_SHOW, Method.DIALOG, id); + } + + updateVisibilityPrefsForPanel(id, !toHide); + + pref.setHidden(toHide); + setDefaultFromConfig(); + } + + /** + * When the default panel is removed or disabled, find an enabled panel + * if possible and set it as mDefaultReference. + */ + @Override + protected void setFallbackDefault() { + setDefaultFromConfig(); + } + + private void updateVisibilityPrefsForPanel(String panelId, boolean toShow) { + if (HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS).equals(panelId)) { + GeckoSharedPrefs.forProfile(getContext()).edit().putBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, toShow).apply(); + } + + if (HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.COMBINED_HISTORY).equals(panelId)) { + GeckoSharedPrefs.forProfile(getContext()).edit().putBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, toShow).apply(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java new file mode 100644 index 000000000..61eff98e7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java @@ -0,0 +1,67 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.icons.storage.DiskStorage; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; + +class PrivateDataPreference extends MultiPrefMultiChoicePreference { + private static final String LOGTAG = "GeckoPrivateDataPreference"; + private static final String PREF_KEY_PREFIX = "private.data."; + + public PrivateDataPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onDialogClosed(boolean positiveResult) { + super.onDialogClosed(positiveResult); + + if (!positiveResult) { + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.DIALOG, "settings"); + + final Set<String> values = getValues(); + final JSONObject json = new JSONObject(); + + for (String value : values) { + // Privacy pref checkbox values are stored in Android prefs to + // remember their check states. The key names are private.data.X, + // where X is a string from Gecko sanitization. This prefix is + // removed here so we can send the values to Gecko, which then does + // the sanitization for each key. + final String key = value.substring(PREF_KEY_PREFIX.length()); + try { + json.put(key, true); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + } + + if (values.contains("private.data.offlineApps")) { + // Remove all icons from storage if removing "Offline website data" was selected. + DiskStorage.get(getContext()).evictAll(); + } + + // clear private data in gecko + GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java new file mode 100644 index 000000000..3ba80b562 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java @@ -0,0 +1,183 @@ +/* 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.preferences; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconDescriptor; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.widget.FaviconView; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.support.design.widget.Snackbar; +import android.text.SpannableString; +import android.util.Log; +import android.view.View; + +/** + * Represents an element in the list of search engines on the preferences menu. + */ +public class SearchEnginePreference extends CustomListPreference { + protected String LOGTAG = "SearchEnginePreference"; + + protected static final int INDEX_REMOVE_BUTTON = 1; + + // The icon to display in the prompt when clicked. + private BitmapDrawable mPromptIcon; + + // The bitmap backing the drawable above - needed separately for the FaviconView. + private Bitmap mIconBitmap; + private final Object bitmapLock = new Object(); + + private FaviconView mFaviconView; + + // Search engine identifier specified by the gecko search service. This will be "other" + // for engines that are not shipped with the app. + private String mIdentifier; + + public SearchEnginePreference(Context context, SearchPreferenceCategory parentCategory) { + super(context, parentCategory); + } + + /** + * Called by Android when we're bound to the custom view. Allows us to set the custom properties + * of our custom view elements as we desire (We can now use findViewById on them). + * + * @param view The view instance for this Preference object. + */ + @Override + protected void onBindView(View view) { + super.onBindView(view); + + // We synchronise to avoid a race condition between this and the favicon loading callback in + // setSearchEngineFromJSON. + synchronized (bitmapLock) { + // Set the icon in the FaviconView. + mFaviconView = ((FaviconView) view.findViewById(R.id.search_engine_icon)); + + if (mIconBitmap != null) { + mFaviconView.updateAndScaleImage(IconResponse.create(mIconBitmap)); + } + } + } + + @Override + protected int getPreferenceLayoutResource() { + return R.layout.preference_search_engine; + } + + /** + * Returns the strings to be displayed in the dialog. + */ + @Override + protected String[] createDialogItems() { + return new String[] { LABEL_SET_AS_DEFAULT, + LABEL_REMOVE }; + } + + @Override + public void showDialog() { + // If this is the last engine, then we are the default, and none of the options + // on this menu can do anything. + if (mParentCategory.getPreferenceCount() == 1) { + Activity activity = (Activity) getContext(); + + SnackbarBuilder.builder(activity) + .message(R.string.pref_search_last_toast) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + + return; + } + + super.showDialog(); + } + + @Override + protected void configureDialogBuilder(AlertDialog.Builder builder) { + // Copy the icon from this object to the prompt we produce. We lazily create the drawable, + // as the user may not ever actually tap this object. + if (mPromptIcon == null && mIconBitmap != null) { + mPromptIcon = new BitmapDrawable(getContext().getResources(), mFaviconView.getBitmap()); + } + + builder.setIcon(mPromptIcon); + } + + @Override + protected void onDialogIndexClicked(int index) { + switch (index) { + case INDEX_SET_DEFAULT_BUTTON: + mParentCategory.setDefault(this); + break; + + case INDEX_REMOVE_BUTTON: + mParentCategory.uninstall(this); + break; + + default: + Log.w(LOGTAG, "Selected index out of range."); + break; + } + } + + /** + * @return Identifier of built-in search engine, or "other" if engine is not built-in. + */ + public String getIdentifier() { + return mIdentifier; + } + + /** + * Configure this Preference object from the Gecko search engine JSON object. + * @param geckoEngineJSON The Gecko-formatted JSON object representing the search engine. + * @throws JSONException If the JSONObject is invalid. + */ + public void setSearchEngineFromJSON(JSONObject geckoEngineJSON) throws JSONException { + mIdentifier = geckoEngineJSON.getString("identifier"); + + // A null JS value gets converted into a string. + if (mIdentifier.equals("null")) { + mIdentifier = "other"; + } + + final String engineName = geckoEngineJSON.getString("name"); + final SpannableString titleSpannable = new SpannableString(engineName); + + setTitle(titleSpannable); + + final String iconURI = geckoEngineJSON.getString("iconURI"); + // Keep a reference to the bitmap - we'll need it later in onBindView. + try { + Icons.with(getContext()) + .pageUrl(mIdentifier) + .icon(IconDescriptor.createGenericIcon(iconURI)) + .privileged(true) + .build() + .execute(new IconCallback() { + @Override + public void onIconResponse(IconResponse response) { + mIconBitmap = response.getBitmap(); + + if (mFaviconView != null) { + mFaviconView.updateAndScaleImage(response); + } + } + }); + } catch (IllegalArgumentException e) { + Log.e(LOGTAG, "IllegalArgumentException creating Bitmap. Most likely a zero-length bitmap.", e); + } catch (NullPointerException e) { + Log.e(LOGTAG, "NullPointerException creating Bitmap. Most likely a zero-length bitmap.", e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java new file mode 100644 index 000000000..47db8b9b0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java @@ -0,0 +1,145 @@ +/* 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.preferences; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +public class SearchPreferenceCategory extends CustomListCategory implements GeckoEventListener { + public static final String LOGTAG = "SearchPrefCategory"; + + public SearchPreferenceCategory(Context context) { + super(context); + } + + public SearchPreferenceCategory(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SearchPreferenceCategory(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onAttachedToActivity() { + super.onAttachedToActivity(); + + // Register for SearchEngines messages and request list of search engines from Gecko. + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, "SearchEngines:Data"); + GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null); + } + + @Override + protected void onPrepareForRemoval() { + super.onPrepareForRemoval(); + + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, "SearchEngines:Data"); + } + + @Override + public void setDefault(CustomListPreference item) { + super.setDefault(item); + + sendGeckoEngineEvent("SearchEngines:SetDefault", item.getTitle().toString()); + + final String identifier = ((SearchEnginePreference) item).getIdentifier(); + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_SET_DEFAULT, Method.DIALOG, identifier); + } + + @Override + public void uninstall(CustomListPreference item) { + super.uninstall(item); + + sendGeckoEngineEvent("SearchEngines:Remove", item.getTitle().toString()); + + final String identifier = ((SearchEnginePreference) item).getIdentifier(); + Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_REMOVE, Method.DIALOG, identifier); + } + + @Override + public void handleMessage(String event, final JSONObject data) { + if (event.equals("SearchEngines:Data")) { + // Parse engines array from JSON. + JSONArray engines; + try { + engines = data.getJSONArray("searchEngines"); + } catch (JSONException e) { + Log.e(LOGTAG, "Unable to decode search engine data from Gecko.", e); + return; + } + + // Clear the preferences category from this thread. + this.removeAll(); + + // Create an element in this PreferenceCategory for each engine. + for (int i = 0; i < engines.length(); i++) { + try { + final JSONObject engineJSON = engines.getJSONObject(i); + + final SearchEnginePreference enginePreference = new SearchEnginePreference(getContext(), this); + enginePreference.setSearchEngineFromJSON(engineJSON); + enginePreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + SearchEnginePreference sPref = (SearchEnginePreference) preference; + // Display the configuration dialog associated with the tapped engine. + sPref.showDialog(); + return true; + } + }); + + addPreference(enginePreference); + + // The first element in the array is the default engine. + if (i == 0) { + // We set this here, not in setSearchEngineFromJSON, because it allows us to + // keep a reference to the default engine to use when the AlertDialog + // callbacks are used. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + enginePreference.setIsDefault(true); + } + }); + mDefaultReference = enginePreference; + } + } catch (JSONException e) { + Log.e(LOGTAG, "JSONException parsing engine at index " + i, e); + } + } + } + } + + /** + * Helper method to send a particular event string to Gecko with an associated engine name. + * @param event The type of event to send. + * @param engine The engine to which the event relates. + */ + private void sendGeckoEngineEvent(String event, String engineName) { + JSONObject json = new JSONObject(); + try { + json.put("engine", engineName); + } catch (JSONException e) { + Log.e(LOGTAG, "JSONException creating search engine configuration change message for Gecko.", e); + return; + } + GeckoAppShell.notifyObservers(event, json.toString()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java new file mode 100644 index 000000000..55be702c4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java @@ -0,0 +1,124 @@ +/* -*- 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.preferences; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.DialogPreference; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.RadioButton; +import android.widget.RadioGroup; + +public class SetHomepagePreference extends DialogPreference { + private static final String DEFAULT_HOMEPAGE = AboutPages.HOME; + + private final SharedPreferences prefs; + + private RadioGroup homepageLayout; + private RadioButton defaultRadio; + private RadioButton userAddressRadio; + private EditText homepageEditText; + + // This is the url that 1) was loaded from prefs or, 2) stored + // when the user pressed the "default homepage" checkbox. + private String storedUrl; + + public SetHomepagePreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + prefs = GeckoSharedPrefs.forProfile(context); + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + // Without this GB devices have a black background to the dialog. + builder.setInverseBackgroundForced(true); + } + + @Override + protected void onBindDialogView(final View view) { + super.onBindDialogView(view); + + homepageLayout = (RadioGroup) view.findViewById(R.id.homepage_layout); + defaultRadio = (RadioButton) view.findViewById(R.id.radio_default); + userAddressRadio = (RadioButton) view.findViewById(R.id.radio_user_address); + homepageEditText = (EditText) view.findViewById(R.id.edittext_user_address); + + storedUrl = prefs.getString(GeckoPreferences.PREFS_HOMEPAGE, DEFAULT_HOMEPAGE); + + homepageLayout.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(final RadioGroup radioGroup, final int checkedId) { + if (checkedId == R.id.radio_user_address) { + homepageEditText.setVisibility(View.VISIBLE); + openKeyboardAndSelectAll(getContext(), homepageEditText); + } else { + homepageEditText.setVisibility(View.GONE); + } + } + }); + setUIState(storedUrl); + } + + private void setUIState(final String url) { + if (isUrlDefaultHomepage(url)) { + defaultRadio.setChecked(true); + } else { + userAddressRadio.setChecked(true); + homepageEditText.setText(url); + } + } + + private boolean isUrlDefaultHomepage(final String url) { + return TextUtils.isEmpty(url) || DEFAULT_HOMEPAGE.equals(url); + } + + private static void openKeyboardAndSelectAll(final Context context, final View viewToFocus) { + viewToFocus.requestFocus(); + viewToFocus.post(new Runnable() { + @Override + public void run() { + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(viewToFocus, InputMethodManager.SHOW_IMPLICIT); + // android:selectAllOnFocus doesn't work for the initial focus: + // I'm not sure why. We manually selectAll instead. + if (viewToFocus instanceof EditText) { + ((EditText) viewToFocus).selectAll(); + } + } + }); + } + + @Override + protected void onDialogClosed(final boolean positiveResult) { + super.onDialogClosed(positiveResult); + if (positiveResult) { + final SharedPreferences.Editor editor = prefs.edit(); + final String homePageEditTextValue = homepageEditText.getText().toString(); + final String newPrefValue; + if (homepageLayout.getCheckedRadioButtonId() == R.id.radio_default || + isUrlDefaultHomepage(homePageEditTextValue)) { + newPrefValue = ""; + editor.remove(GeckoPreferences.PREFS_HOMEPAGE); + } else { + newPrefValue = homePageEditTextValue; + editor.putString(GeckoPreferences.PREFS_HOMEPAGE, newPrefValue); + } + editor.apply(); + + if (getOnPreferenceChangeListener() != null) { + getOnPreferenceChangeListener().onPreferenceChange(this, newPrefValue); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java new file mode 100644 index 000000000..350ac8fc0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java @@ -0,0 +1,103 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.preferences; + +import android.content.Context; +import android.content.Intent; +import android.preference.Preference; +import android.text.TextUtils; +import android.util.AttributeSet; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TelemetryContract.Method; +import org.mozilla.gecko.fxa.FxAccountConstants; +import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity; +import org.mozilla.gecko.fxa.activities.PicassoPreferenceIconTarget; +import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.util.ThreadUtils; + +class SyncPreference extends Preference { + private final Context mContext; + private final Target profileAvatarTarget; + + public SyncPreference(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + final float cornerRadius = mContext.getResources().getDimension(R.dimen.fxaccount_profile_image_width) / 2; + profileAvatarTarget = new PicassoPreferenceIconTarget(mContext.getResources(), this, cornerRadius); + } + + private void launchFxASetup() { + final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); + intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + mContext.startActivity(intent); + } + + public void update(final AndroidFxAccount fxAccount) { + if (fxAccount == null) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + setTitle(R.string.pref_sync); + setSummary(R.string.pref_sync_summary); + // Cancel any pending task. + Picasso.with(mContext).cancelRequest(profileAvatarTarget); + // Clear previously set icon. + // Bug 1312719 - IconDrawable is prior to IconResId, drawable must be set null before setIcon(resId) + // http://androidxref.com/5.1.1_r6/xref/frameworks/base/core/java/android/preference/Preference.java#562 + setIcon(null); + setIcon(R.drawable.sync_avatar_default); + } + }); + return; + } + + // Update title from account email. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + setTitle(fxAccount.getEmail()); + setSummary(""); + } + }); + + final ExtendedJSONObject profileJSON = fxAccount.getProfileJSON(); + if (profileJSON == null) { + return; + } + + // Avatar URI empty, return early. + final String avatarURI = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_AVATAR); + if (TextUtils.isEmpty(avatarURI)) { + return; + } + + Picasso.with(mContext) + .load(avatarURI) + .centerInside() + .resizeDimen(R.dimen.fxaccount_profile_image_width, R.dimen.fxaccount_profile_image_height) + .placeholder(R.drawable.sync_avatar_default) + .error(R.drawable.sync_avatar_default) + .into(profileAvatarTarget); + } + + @Override + protected void onClick() { + // Launch the FxA "Get started" activity, which will dispatch to the + // right location. + launchFxASetup(); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.SETTINGS, "sync_setup"); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java b/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java new file mode 100644 index 000000000..c1eeb6bd5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java @@ -0,0 +1,237 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.promotion; + +import android.app.Activity; +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.CallSuper; +import android.util.Log; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.db.BrowserContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.delegates.TabsTrayVisibilityAwareDelegate; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.util.ThreadUtils; + +import java.lang.ref.WeakReference; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +/** + * Promote "Add to home screen" if user visits website often. + */ +public class AddToHomeScreenPromotion extends TabsTrayVisibilityAwareDelegate implements Tabs.OnTabsChangedListener { + private static class URLHistory { + public final long visits; + public final long lastVisit; + + private URLHistory(long visits, long lastVisit) { + this.visits = visits; + this.lastVisit = lastVisit; + } + } + + private static final String LOGTAG = "GeckoPromoteShortcut"; + + private static final String EXPERIMENT_MINIMUM_TOTAL_VISITS = "minimumTotalVisits"; + private static final String EXPERIMENT_LAST_VISIT_MINIMUM_AGE = "lastVisitMinimumAgeMs"; + private static final String EXPERIMENT_LAST_VISIT_MAXIMUM_AGE = "lastVisitMaximumAgeMs"; + + private WeakReference<Activity> activityReference; + private boolean isEnabled; + private int minimumVisits; + private int lastVisitMinimumAgeMs; + private int lastVisitMaximumAgeMs; + + @CallSuper + @Override + public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) { + super.onCreate(browserApp, savedInstanceState); + activityReference = new WeakReference<Activity>(browserApp); + + initializeExperiment(browserApp); + } + + @Override + public void onResume(BrowserApp browserApp) { + Tabs.registerOnTabsChangedListener(this); + } + + @Override + public void onPause(BrowserApp browserApp) { + Tabs.unregisterOnTabsChangedListener(this); + } + + private void initializeExperiment(Context context) { + if (!SwitchBoard.isInExperiment(context, Experiments.PROMOTE_ADD_TO_HOMESCREEN)) { + Log.v(LOGTAG, "Experiment not enabled"); + // Experiment is not enabled. No need to try to read values. + return; + } + + JSONObject values = SwitchBoard.getExperimentValuesFromJson(context, Experiments.PROMOTE_ADD_TO_HOMESCREEN); + if (values == null) { + // We didn't get any values for this experiment. Let's disable it instead of picking default + // values that might be bad. + return; + } + + try { + initializeWithValues( + values.getInt(EXPERIMENT_MINIMUM_TOTAL_VISITS), + values.getInt(EXPERIMENT_LAST_VISIT_MINIMUM_AGE), + values.getInt(EXPERIMENT_LAST_VISIT_MAXIMUM_AGE)); + } catch (JSONException e) { + Log.w(LOGTAG, "Could not read experiment values", e); + } + } + + private void initializeWithValues(int minimumVisits, int lastVisitMinimumAgeMs, int lastVisitMaximumAgeMs) { + this.isEnabled = true; + + this.minimumVisits = minimumVisits; + this.lastVisitMinimumAgeMs = lastVisitMinimumAgeMs; + this.lastVisitMaximumAgeMs = lastVisitMaximumAgeMs; + } + + @Override + public void onTabChanged(final Tab tab, Tabs.TabEvents msg, String data) { + if (tab == null) { + return; + } + + if (!Tabs.getInstance().isSelectedTab(tab)) { + // We only ever want to show this promotion for the current tab. + return; + } + + if (Tabs.TabEvents.LOADED != msg) { + return; + } + + if (tab.isPrivate()) { + // Never show the prompt for private browsing tabs. + return; + } + + if (isTabsTrayVisible()) { + // We only want to show this prompt if this tab is in the foreground and not on top + // of the tabs tray. + return; + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + maybeShowPromotionForUrl(tab.getURL(), tab.getTitle()); + } + }); + } + + private void maybeShowPromotionForUrl(String url, String title) { + if (!isEnabled) { + return; + } + + final Context context = activityReference.get(); + if (context == null) { + return; + } + + if (!shouldShowPromotion(context, url, title)) { + return; + } + + HomeScreenPrompt.show(context, url, title); + } + + private boolean shouldShowPromotion(Context context, String url, String title) { + if (TextUtils.isEmpty(url) || TextUtils.isEmpty(title)) { + // We require an URL and a title for the shortcut. + return false; + } + + if (AboutPages.isAboutPage(url)) { + // No promotion for our internal sites. + return false; + } + + if (!url.startsWith("https://")) { + // Only promote websites that are served over HTTPS. + return false; + } + + URLHistory history = getHistoryForURL(context, url); + if (history == null) { + // There's no history for this URL yet or we can't read it right now. Just ignore. + return false; + } + + if (history.visits < minimumVisits) { + // This URL has not been visited often enough. + return false; + } + + if (history.lastVisit > System.currentTimeMillis() - lastVisitMinimumAgeMs) { + // The last visit is too new. Do not show promotion. This is mostly to avoid that the + // promotion shows up for a quick refreshs and in the worst case the last visit could + // be the current visit (race). + return false; + } + + if (history.lastVisit < System.currentTimeMillis() - lastVisitMaximumAgeMs) { + // The last visit is to old. Do not show promotion. + return false; + } + + if (hasAcceptedOrDeclinedHomeScreenShortcut(context, url)) { + // The user has already created a shortcut in the past or actively declined to create one. + // Let's not ask again for this url - We do not want to be annoying. + return false; + } + + return true; + } + + protected boolean hasAcceptedOrDeclinedHomeScreenShortcut(Context context, String url) { + final UrlAnnotations urlAnnotations = BrowserDB.from(context).getUrlAnnotations(); + return urlAnnotations.hasAcceptedOrDeclinedHomeScreenShortcut(context.getContentResolver(), url); + } + + protected URLHistory getHistoryForURL(Context context, String url) { + final GeckoProfile profile = GeckoProfile.get(context); + final BrowserDB browserDB = BrowserDB.from(profile); + + Cursor cursor = null; + try { + cursor = browserDB.getHistoryForURL(context.getContentResolver(), url); + + if (cursor.moveToFirst()) { + return new URLHistory( + cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)), + cursor.getLong(cursor.getColumnIndex(BrowserContract.History.DATE_LAST_VISITED))); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return null; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java b/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java new file mode 100644 index 000000000..0f2df8a2c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java @@ -0,0 +1,237 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.promotion; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.UrlAnnotations; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.util.ActivityUtils; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Prompt to promote adding the current website to the home screen. + */ +public class HomeScreenPrompt extends Locales.LocaleAwareActivity implements IconCallback { + private static final String EXTRA_TITLE = "title"; + private static final String EXTRA_URL = "url"; + + private static final String TELEMETRY_EXTRA = "home_screen_promotion"; + + private View containerView; + private ImageView iconView; + private String title; + private String url; + private boolean isAnimating; + private boolean hasAccepted; + private boolean hasDeclined; + + public static void show(Context context, String url, String title) { + Intent intent = new Intent(context, HomeScreenPrompt.class); + intent.putExtra(EXTRA_TITLE, title); + intent.putExtra(EXTRA_URL, url); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + fetchDataFromIntent(); + setupViews(); + loadShortcutIcon(); + + slideIn(); + + Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN); + + // Technically this isn't triggered by a "service". But it's also triggered by a background task and without + // actual user interaction. + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.SERVICE, TELEMETRY_EXTRA); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN); + } + + private void fetchDataFromIntent() { + final Bundle extras = getIntent().getExtras(); + + title = extras.getString(EXTRA_TITLE); + url = extras.getString(EXTRA_URL); + } + + private void setupViews() { + setContentView(R.layout.homescreen_prompt); + + ((TextView) findViewById(R.id.title)).setText(title); + + Uri uri = Uri.parse(url); + ((TextView) findViewById(R.id.host)).setText(uri.getHost()); + + containerView = findViewById(R.id.container); + iconView = (ImageView) findViewById(R.id.icon); + + findViewById(R.id.add).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + hasAccepted = true; + + addToHomeScreen(); + } + }); + + findViewById(R.id.close).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onDecline(); + } + }); + } + + private void addToHomeScreen() { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + GeckoAppShell.createShortcut(title, url); + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, TELEMETRY_EXTRA); + + ActivityUtils.goToHomeScreen(HomeScreenPrompt.this); + + finish(); + } + }); + } + + + + private void loadShortcutIcon() { + Icons.with(this) + .pageUrl(url) + .skipNetwork() + .skipMemory() + .forLauncherIcon() + .build() + .execute(this); + } + + private void slideIn() { + containerView.setTranslationY(500); + containerView.setAlpha(0); + + final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0); + translateAnimator.setDuration(400); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1); + alphaAnimator.setStartDelay(200); + alphaAnimator.setDuration(600); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(alphaAnimator, translateAnimator); + set.setStartDelay(400); + + set.start(); + } + + /** + * Remember that the user rejected creating a home screen shortcut for this URL. + */ + private void rememberRejection() { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final UrlAnnotations urlAnnotations = BrowserDB.from(HomeScreenPrompt.this).getUrlAnnotations(); + urlAnnotations.insertHomeScreenShortcut(getContentResolver(), url, false); + } + }); + } + + private void slideOut() { + if (isAnimating) { + return; + } + + isAnimating = true; + + ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight()); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + finish(); + } + + }); + animator.start(); + } + + @Override + public void finish() { + super.finish(); + + // Don't perform an activity-dismiss animation. + overridePendingTransition(0, 0); + } + + @Override + public void onBackPressed() { + onDecline(); + } + + private void onDecline() { + if (hasDeclined || hasAccepted) { + return; + } + + rememberRejection(); + slideOut(); + + // Technically not always an action triggered by the "back" button but with the same effect: Finishing this + // activity and going back to the previous one. + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, TELEMETRY_EXTRA); + + hasDeclined = true; + } + + /** + * User clicked outside of the prompt. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + onDecline(); + + return true; + } + + @Override + public void onIconResponse(IconResponse response) { + iconView.setImageBitmap(response.getBitmap()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java b/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java new file mode 100644 index 000000000..db5a531c6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java @@ -0,0 +1,103 @@ +/* -*- 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.promotion; + +import android.content.Intent; +import android.content.SharedPreferences; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.Experiments; + +public class ReaderViewBookmarkPromotion extends BrowserAppDelegateWithReference implements Tabs.OnTabsChangedListener { + private static final String PREF_FIRST_RV_HINT_SHOWN = "first_reader_view_hint_shown"; + private static final String FIRST_READERVIEW_OPEN_TELEMETRYEXTRA = "first_readerview_open_prompt"; + + private boolean hasEnteredReaderMode = false; + + @Override + public void onResume(BrowserApp browserApp) { + Tabs.registerOnTabsChangedListener(this); + } + + @Override + public void onPause(BrowserApp browserApp) { + Tabs.unregisterOnTabsChangedListener(this); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case LOCATION_CHANGE: + // old url: data + // new url: tab.getURL() + final boolean enteringReaderMode = ReaderModeUtils.isEnteringReaderMode(data, tab.getURL()); + + if (!hasEnteredReaderMode && enteringReaderMode) { + hasEnteredReaderMode = true; + promoteBookmarking(); + } + + break; + } + } + + @Override + public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case BrowserApp.ACTIVITY_REQUEST_TRIPLE_READERVIEW: + if (resultCode == BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK) { + final Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + tab.addBookmark(); + } + } else if (resultCode == BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE) { + // Nothing to do: we won't show this promotion again either way. + } + break; + } + } + + private void promoteBookmarking() { + final BrowserApp browserApp = getBrowserApp(); + if (browserApp == null) { + return; + } + + final SharedPreferences prefs = GeckoSharedPrefs.forProfile(browserApp); + final boolean isEnabled = SwitchBoard.isInExperiment(browserApp, Experiments.TRIPLE_READERVIEW_BOOKMARK_PROMPT); + + // We reuse the same preference as for the first offline reader view bookmark + // as we only want to show one of the two UIs (they both explain the same + // functionality). + if (!isEnabled || prefs.getBoolean(PREF_FIRST_RV_HINT_SHOWN, false)) { + return; + } + + SimpleHelperUI.show(browserApp, + FIRST_READERVIEW_OPEN_TELEMETRYEXTRA, + BrowserApp.ACTIVITY_REQUEST_TRIPLE_READERVIEW, + R.string.helper_triple_readerview_open_title, + R.string.helper_triple_readerview_open_message, + R.drawable.helper_readerview_bookmark, // We share the icon with the usual helper UI + R.string.helper_triple_readerview_open_button, + BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK, + BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE); + + prefs + .edit() + .putBoolean(PREF_FIRST_RV_HINT_SHOWN, true) + .apply(); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java b/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java new file mode 100644 index 000000000..b6b857fb9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java @@ -0,0 +1,194 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.promotion; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.DrawableRes; +import android.support.annotation.StringRes; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +/** + * Generic HelperUI (prompt) that can be populated with an image, title, message and action button. + * See show() for usage. This is run as an Activity, results must be handled in the parent Activities + * onActivityResult(). + */ +public class SimpleHelperUI extends Locales.LocaleAwareActivity { + public static final String PREF_FIRST_RVBP_SHOWN = "first_reader_view_bookmark_prompt_shown"; + public static final String FIRST_RVBP_SHOWN_TELEMETRYEXTRA = "first_readerview_bookmark_prompt"; + + private View containerView; + + private boolean isAnimating; + + private String mTelemetryExtra; + + private static final String EXTRA_TELEMETRYEXTRA = "telemetryextra"; + private static final String EXTRA_TITLE = "title"; + private static final String EXTRA_MESSAGE = "message"; + private static final String EXTRA_IMAGE = "image"; + private static final String EXTRA_BUTTON = "button"; + private static final String EXTRA_RESULTCODE_POSITIVE = "positive"; + private static final String EXTRA_RESULTCODE_NEGATIVE = "negative"; + + + /** + * Show a generic helper UI/prompt. + * + * @param owner The owning Activity, the result of this prompt will be delivered to its + * onActivityResult(). + * @param requestCode The request code for the Activity that will be created, this is passed to + * onActivityResult() to identify the prompt. + * + * @param positiveResultCode The result code passed to onActivityResult() when the button has + * been pressed. + * @param negativeResultCode The result code passed to onActivityResult() when the prompt was + * dismissed, either by pressing outside the prompt or by pressing the + * device back button. + */ + public static void show(Activity owner, String telemetryExtra, + int requestCode, + @StringRes int title, @StringRes int message, + @DrawableRes int image, @StringRes int buttonText, + int positiveResultCode, int negativeResultCode) { + Intent intent = new Intent(owner, SimpleHelperUI.class); + + intent.putExtra(EXTRA_TELEMETRYEXTRA, telemetryExtra); + + intent.putExtra(EXTRA_TITLE, title); + intent.putExtra(EXTRA_MESSAGE, message); + + intent.putExtra(EXTRA_IMAGE, image); + intent.putExtra(EXTRA_BUTTON, buttonText); + + intent.putExtra(EXTRA_RESULTCODE_POSITIVE, positiveResultCode); + intent.putExtra(EXTRA_RESULTCODE_NEGATIVE, negativeResultCode); + + owner.startActivityForResult(intent, requestCode); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mTelemetryExtra = getIntent().getStringExtra(EXTRA_TELEMETRYEXTRA); + + setupViews(); + + slideIn(); + } + + private void setupViews() { + final Intent i = getIntent(); + + setContentView(R.layout.simple_helper_ui); + + containerView = findViewById(R.id.container); + + ((ImageView) findViewById(R.id.image)).setImageResource(i.getIntExtra(EXTRA_IMAGE, 0)); + + ((TextView) findViewById(R.id.title)).setText(i.getIntExtra(EXTRA_TITLE, 0)); + + ((TextView) findViewById(R.id.message)).setText(i.getIntExtra(EXTRA_MESSAGE, 0)); + + final Button button = ((Button) findViewById(R.id.button)); + button.setText(i.getIntExtra(EXTRA_BUTTON, 0)); + button.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + slideOut(); + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, mTelemetryExtra); + + setResult(i.getIntExtra(EXTRA_RESULTCODE_POSITIVE, -1)); + } + }); + } + + private void slideIn() { + containerView.setTranslationY(500); + containerView.setAlpha(0); + + final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0); + translateAnimator.setDuration(400); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1); + alphaAnimator.setStartDelay(200); + alphaAnimator.setDuration(600); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(alphaAnimator, translateAnimator); + set.setStartDelay(400); + + set.start(); + } + + private void slideOut() { + if (isAnimating) { + return; + } + + isAnimating = true; + + ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight()); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + finish(); + } + + }); + animator.start(); + } + + @Override + public void finish() { + super.finish(); + + // Don't perform an activity-dismiss animation. + overridePendingTransition(0, 0); + } + + @Override + public void onBackPressed() { + slideOut(); + + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, mTelemetryExtra); + + setResult(getIntent().getIntExtra(EXTRA_RESULTCODE_NEGATIVE, -1)); + + } + + /** + * User clicked outside of the prompt. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + slideOut(); + + // Not really an action triggered by the "back" button but with the same effect: Finishing this + // activity and going back to the previous one. + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, mTelemetryExtra); + + setResult(getIntent().getIntExtra(EXTRA_RESULTCODE_NEGATIVE, -1)); + + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java new file mode 100644 index 000000000..3d66eeea8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java @@ -0,0 +1,59 @@ +/* -*- 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.prompts; + +import org.json.JSONObject; +import org.mozilla.gecko.R; +import org.mozilla.gecko.widget.BasicColorPicker; + +import android.content.Context; +import android.graphics.Color; +import android.view.LayoutInflater; +import android.view.View; + +public class ColorPickerInput extends PromptInput { + public static final String INPUT_TYPE = "color"; + public static final String LOGTAG = "GeckoColorPickerInput"; + + private final boolean mShowAdvancedButton = true; + private final int mInitialColor; + + public ColorPickerInput(JSONObject obj) { + super(obj); + String init = obj.optString("value"); + mInitialColor = Color.rgb(Integer.parseInt(init.substring(1, 3), 16), + Integer.parseInt(init.substring(3, 5), 16), + Integer.parseInt(init.substring(5, 7), 16)); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + LayoutInflater inflater = LayoutInflater.from(context); + mView = inflater.inflate(R.layout.basic_color_picker_dialog, null); + + BasicColorPicker cp = (BasicColorPicker) mView.findViewById(R.id.colorpicker); + cp.setColor(mInitialColor); + + return mView; + } + + @Override + public Object getValue() { + BasicColorPicker cp = (BasicColorPicker) mView.findViewById(R.id.colorpicker); + int color = cp.getColor(); + return "#" + Integer.toHexString(color).substring(2); + } + + @Override + public boolean getScrollable() { + return true; + } + + @Override + public boolean canApplyInputStyle() { + return false; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java new file mode 100644 index 000000000..bc7d7ac20 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java @@ -0,0 +1,171 @@ +/* -*- 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.prompts; + +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ResourceDrawableUtils; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.GridView; +import android.widget.ImageView; +import android.widget.TextView; + +public class IconGridInput extends PromptInput implements OnItemClickListener { + public static final String INPUT_TYPE = "icongrid"; + public static final String LOGTAG = "GeckoIconGridInput"; + + private ArrayAdapter<IconGridItem> mAdapter; // An adapter holding a list of items to show in the grid + + private static int mColumnWidth = -1; // The maximum width of columns + private static int mMaxColumns = -1; // The maximum number of columns to show + private static int mIconSize = -1; // Size of icons in the grid + private int mSelected; // Current selection + private final JSONArray mArray; + + public IconGridInput(JSONObject obj) { + super(obj); + mArray = obj.optJSONArray("items"); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + if (mColumnWidth < 0) { + // getColumnWidth isn't available on pre-ICS, so we pull it out and assign it here + mColumnWidth = context.getResources().getDimensionPixelSize(R.dimen.icongrid_columnwidth); + } + + if (mIconSize < 0) { + mIconSize = GeckoAppShell.getPreferredIconSize(); + } + + if (mMaxColumns < 0) { + mMaxColumns = context.getResources().getInteger(R.integer.max_icon_grid_columns); + } + + // TODO: Dynamically handle size changes + final WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + final Display display = wm.getDefaultDisplay(); + final int screenWidth = display.getWidth(); + int maxColumns = Math.min(mMaxColumns, screenWidth / mColumnWidth); + + final GridView view = (GridView) LayoutInflater.from(context).inflate(R.layout.icon_grid, null, false); + view.setColumnWidth(mColumnWidth); + + final ArrayList<IconGridItem> items = new ArrayList<IconGridItem>(mArray.length()); + for (int i = 0; i < mArray.length(); i++) { + IconGridItem item = new IconGridItem(context, mArray.optJSONObject(i)); + items.add(item); + if (item.selected) { + mSelected = i; + } + } + + view.setNumColumns(Math.min(items.size(), maxColumns)); + view.setOnItemClickListener(this); + // Despite what the docs say, setItemChecked was not moved into the AbsListView class until sometime between + // Android 2.3.7 and Android 4.0.3. For other versions the item won't be visually highlighted, BUT we really only + // mSelected will still be set so that we default to its behavior. + if (mSelected > -1) { + view.setItemChecked(mSelected, true); + } + + mAdapter = new IconGridAdapter(context, -1, items); + view.setAdapter(mAdapter); + mView = view; + return mView; + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + mSelected = position; + notifyListeners(Integer.toString(position)); + } + + @Override + public Object getValue() { + return mSelected; + } + + @Override + public boolean getScrollable() { + return true; + } + + private class IconGridAdapter extends ArrayAdapter<IconGridItem> { + public IconGridAdapter(Context context, int resource, List<IconGridItem> items) { + super(context, resource, items); + } + + @Override + public View getView(int position, View convert, ViewGroup parent) { + final Context context = parent.getContext(); + if (convert == null) { + convert = LayoutInflater.from(context).inflate(R.layout.icon_grid_item, parent, false); + } + bindView(convert, context, position); + return convert; + } + + private void bindView(View v, Context c, int position) { + final IconGridItem item = getItem(position); + final TextView text1 = (TextView) v.findViewById(android.R.id.text1); + text1.setText(item.label); + + final TextView text2 = (TextView) v.findViewById(android.R.id.text2); + if (TextUtils.isEmpty(item.description)) { + text2.setVisibility(View.GONE); + } else { + text2.setVisibility(View.VISIBLE); + text2.setText(item.description); + } + + final ImageView icon = (ImageView) v.findViewById(R.id.icon); + icon.setImageDrawable(item.icon); + ViewGroup.LayoutParams lp = icon.getLayoutParams(); + lp.width = lp.height = mIconSize; + } + } + + private class IconGridItem { + final String label; + final String description; + final boolean selected; + Drawable icon; + + public IconGridItem(final Context context, final JSONObject obj) { + label = obj.optString("name"); + final String iconUrl = obj.optString("iconUri"); + description = obj.optString("description"); + selected = obj.optBoolean("selected"); + + ResourceDrawableUtils.getDrawable(context, iconUrl, new ResourceDrawableUtils.BitmapLoader() { + @Override + public void onBitmapFound(Drawable d) { + icon = d; + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + }); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java new file mode 100644 index 000000000..502f1156d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java @@ -0,0 +1,158 @@ +/* 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.prompts; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.GeckoActionProvider; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.widget.ListView; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a prompt letting the user pick from a list of intent handlers for a set of Intents or + * for a GeckoActionProvider. Basic usage: + * IntentChooserPrompt prompt = new IntentChooserPrompt(context, new Intent[] { + * ... // some intents + * }); + * prompt.show("Title", context, new IntentHandler() { + * public void onIntentSelected(Intent intent, int position) { } + * public void onCancelled() { } + * }); + **/ +public class IntentChooserPrompt { + private static final String LOGTAG = "GeckoIntentChooser"; + + private final ArrayList<PromptListItem> mItems; + + public IntentChooserPrompt(Context context, Intent[] intents) { + mItems = getItems(context, intents); + } + + public IntentChooserPrompt(Context context, GeckoActionProvider provider) { + mItems = getItems(context, provider); + } + + /* If an IntentHandler is passed in, will asynchronously call the handler when the dialog is closed + * Otherwise, will return the Intent that was chosen by the user. Must be called on the UI thread. + */ + public void show(final String title, final Context context, final IntentHandler handler) { + ThreadUtils.assertOnUiThread(); + + if (mItems.isEmpty()) { + Log.i(LOGTAG, "No activities for the intent chooser!"); + handler.onCancelled(); + return; + } + + // If there's only one item in the intent list, just return it + if (mItems.size() == 1) { + handler.onIntentSelected(mItems.get(0).getIntent(), 0); + return; + } + + final Prompt prompt = new Prompt(context, new Prompt.PromptCallback() { + @Override + public void onPromptFinished(String promptServiceResult) { + if (handler == null) { + return; + } + + int itemId = -1; + try { + itemId = new JSONObject(promptServiceResult).getInt("button"); + } catch (JSONException e) { + Log.e(LOGTAG, "result from promptservice was invalid: ", e); + } + + if (itemId == -1) { + handler.onCancelled(); + } else { + handler.onIntentSelected(mItems.get(itemId).getIntent(), itemId); + } + } + }); + + PromptListItem[] arrays = new PromptListItem[mItems.size()]; + mItems.toArray(arrays); + prompt.show(title, "", arrays, ListView.CHOICE_MODE_NONE); + + return; + } + + // Whether or not any activities were found. Useful for checking if you should try a different Intent set + public boolean hasActivities(Context context) { + return mItems.isEmpty(); + } + + // Gets a list of PromptListItems for an Intent array + private ArrayList<PromptListItem> getItems(final Context context, Intent[] intents) { + final ArrayList<PromptListItem> items = new ArrayList<PromptListItem>(); + + // If we have intents, use them to build the initial list + for (final Intent intent : intents) { + items.addAll(getItemsForIntent(context, intent)); + } + + return items; + } + + // Gets a list of PromptListItems for a GeckoActionProvider + private ArrayList<PromptListItem> getItems(final Context context, final GeckoActionProvider provider) { + final ArrayList<PromptListItem> items = new ArrayList<PromptListItem>(); + + // Add any intents from the provider. + final PackageManager packageManager = context.getPackageManager(); + final ArrayList<ResolveInfo> infos = provider.getSortedActivities(); + + for (final ResolveInfo info : infos) { + items.add(getItemForResolveInfo(info, packageManager, provider.getIntent())); + } + + return items; + } + + private PromptListItem getItemForResolveInfo(ResolveInfo info, PackageManager pm, Intent intent) { + PromptListItem item = new PromptListItem(info.loadLabel(pm).toString()); + item.setIcon(info.loadIcon(pm)); + + Intent i = new Intent(intent); + // These intents should be implicit. + i.setComponent(new ComponentName(info.activityInfo.applicationInfo.packageName, + info.activityInfo.name)); + item.setIntent(new Intent(i)); + + return item; + } + + private ArrayList<PromptListItem> getItemsForIntent(Context context, Intent intent) { + ArrayList<PromptListItem> items = new ArrayList<PromptListItem>(); + PackageManager pm = context.getPackageManager(); + List<ResolveInfo> lri = pm.queryIntentActivityOptions(GeckoAppShell.getGeckoInterface().getActivity().getComponentName(), null, intent, 0); + + // If we didn't find any activities, just return the empty list + if (lri == null) { + return items; + } + + // Otherwise, convert the ResolveInfo. Note we don't currently check for duplicates here. + for (ResolveInfo ri : lri) { + items.add(getItemForResolveInfo(ri, pm, intent)); + } + + return items; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java new file mode 100644 index 000000000..1509ab626 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java @@ -0,0 +1,12 @@ +/* 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.prompts; + +import android.content.Intent; + +public interface IntentHandler { + public void onIntentSelected(Intent intent, int position); + public void onCancelled(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java b/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java new file mode 100644 index 000000000..11121b2cc --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java @@ -0,0 +1,586 @@ +/* -*- 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.prompts; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.res.Resources; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.ScrollView; + +import java.util.ArrayList; + +public class Prompt implements OnClickListener, OnCancelListener, OnItemClickListener, + PromptInput.OnChangeListener, Tabs.OnTabsChangedListener { + private static final String LOGTAG = "GeckoPromptService"; + + private String[] mButtons; + private PromptInput[] mInputs; + private AlertDialog mDialog; + private int mDoubleTapButtonType; + + private final LayoutInflater mInflater; + private final Context mContext; + private PromptCallback mCallback; + private String mGuid; + private PromptListAdapter mAdapter; + + private static boolean mInitialized; + private static int mInputPaddingSize; + + private int mTabId = Tabs.INVALID_TAB_ID; + private Object mPreviousInputValue = null; + + public Prompt(Context context, PromptCallback callback) { + this(context); + mCallback = callback; + } + + private Prompt(Context context) { + mContext = context; + mInflater = LayoutInflater.from(mContext); + + if (!mInitialized) { + Resources res = mContext.getResources(); + mInputPaddingSize = (int) (res.getDimension(R.dimen.prompt_service_inputs_padding)); + mInitialized = true; + } + } + + private View applyInputStyle(View view, PromptInput input) { + // Don't add padding to color picker views + if (input.canApplyInputStyle()) { + view.setPadding(mInputPaddingSize, 0, mInputPaddingSize, 0); + } + return view; + } + + public void show(JSONObject message) { + String title = message.optString("title"); + String text = message.optString("text"); + mGuid = message.optString("guid"); + + mButtons = getStringArray(message, "buttons"); + final int buttonCount = mButtons == null ? 0 : mButtons.length; + mDoubleTapButtonType = convertIndexToButtonType(message.optInt("doubleTapButton", -1), buttonCount); + mPreviousInputValue = null; + + JSONArray inputs = getSafeArray(message, "inputs"); + mInputs = new PromptInput[inputs.length()]; + for (int i = 0; i < mInputs.length; i++) { + try { + mInputs[i] = PromptInput.getInput(inputs.getJSONObject(i)); + mInputs[i].setListener(this); + } catch (Exception ex) { } + } + + PromptListItem[] menuitems = PromptListItem.getArray(message.optJSONArray("listitems")); + String selected = message.optString("choiceMode"); + + int choiceMode = ListView.CHOICE_MODE_NONE; + if ("single".equals(selected)) { + choiceMode = ListView.CHOICE_MODE_SINGLE; + } else if ("multiple".equals(selected)) { + choiceMode = ListView.CHOICE_MODE_MULTIPLE; + } + + if (message.has("tabId")) { + mTabId = message.optInt("tabId", Tabs.INVALID_TAB_ID); + } + + show(title, text, menuitems, choiceMode); + } + + private int convertIndexToButtonType(final int buttonIndex, final int buttonCount) { + if (buttonIndex < 0 || buttonIndex >= buttonCount) { + // All valid DialogInterface button values are < 0, + // so we return 0 as an invalid value. + return 0; + } + + switch (buttonIndex) { + case 0: + return DialogInterface.BUTTON_POSITIVE; + case 1: + return DialogInterface.BUTTON_NEUTRAL; + case 2: + return DialogInterface.BUTTON_NEGATIVE; + default: + return 0; + } + } + + public void show(String title, String text, PromptListItem[] listItems, int choiceMode) { + ThreadUtils.assertOnUiThread(); + + try { + create(title, text, listItems, choiceMode); + } catch (IllegalStateException ex) { + Log.i(LOGTAG, "Error building dialog", ex); + return; + } + + if (mTabId != Tabs.INVALID_TAB_ID) { + Tabs.registerOnTabsChangedListener(this); + + final Tab tab = Tabs.getInstance().getTab(mTabId); + if (Tabs.getInstance().getSelectedTab() == tab) { + mDialog.show(); + } + } else { + mDialog.show(); + } + } + + @Override + public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) { + if (tab != Tabs.getInstance().getTab(mTabId)) { + return; + } + + switch (msg) { + case SELECTED: + Log.i(LOGTAG, "Selected"); + mDialog.show(); + break; + case UNSELECTED: + Log.i(LOGTAG, "Unselected"); + mDialog.hide(); + break; + case LOCATION_CHANGE: + Log.i(LOGTAG, "Location change"); + mDialog.cancel(); + break; + } + } + + private void create(String title, String text, PromptListItem[] listItems, int choiceMode) + throws IllegalStateException { + + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + if (!TextUtils.isEmpty(title)) { + // Long strings can delay showing the dialog, so we cap the number of characters shown to 256. + builder.setTitle(title.substring(0, Math.min(title.length(), 256))); + } + + if (!TextUtils.isEmpty(text)) { + builder.setMessage(text); + } + + // Because lists are currently added through the normal Android AlertBuilder interface, they're + // incompatible with also adding additional input elements to a dialog. + if (listItems != null && listItems.length > 0) { + addListItems(builder, listItems, choiceMode); + } else if (!addInputs(builder)) { + throw new IllegalStateException("Could not add inputs to dialog"); + } + + int length = mButtons == null ? 0 : mButtons.length; + if (length > 0) { + builder.setPositiveButton(mButtons[0], this); + if (length > 1) { + builder.setNeutralButton(mButtons[1], this); + if (length > 2) { + builder.setNegativeButton(mButtons[2], this); + } + } + } + + mDialog = builder.create(); + mDialog.setOnCancelListener(Prompt.this); + } + + public void setButtons(String[] buttons) { + mButtons = buttons; + } + + public void setInputs(PromptInput[] inputs) { + mInputs = inputs; + } + + /* Adds to a result value from the lists that can be shown in dialogs. + * Will set the selected value(s) to the button attribute of the + * object that's passed in. If this is a multi-select dialog, sets a + * selected attribute to an array of booleans. + */ + private void addListResult(final JSONObject result, int which) { + if (mAdapter == null) { + return; + } + + try { + JSONArray selected = new JSONArray(); + + // If the button has already been filled in + ArrayList<Integer> selectedItems = mAdapter.getSelected(); + for (Integer item : selectedItems) { + selected.put(item); + } + + // If we haven't assigned a button yet, or we assigned it to -1, assign the which + // parameter to both selected and the button. + if (!result.has("button") || result.optInt("button") == -1) { + if (!selectedItems.contains(which)) { + selected.put(which); + } + + result.put("button", which); + } + + result.put("list", selected); + } catch (JSONException ex) { } + } + + /* Adds to a result value from the inputs that can be shown in dialogs. + * Each input will set its own value in the result. + */ + private void addInputValues(final JSONObject result) { + try { + if (mInputs != null) { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i] != null) { + result.put(mInputs[i].getId(), mInputs[i].getValue()); + } + } + } + } catch (JSONException ex) { } + } + + /* Adds the selected button to a result. This should only be called if there + * are no lists shown on the dialog, since they also write their results to the button + * attribute. + */ + private void addButtonResult(final JSONObject result, int which) { + int button = -1; + switch (which) { + case DialogInterface.BUTTON_POSITIVE : button = 0; break; + case DialogInterface.BUTTON_NEUTRAL : button = 1; break; + case DialogInterface.BUTTON_NEGATIVE : button = 2; break; + } + try { + result.put("button", button); + } catch (JSONException ex) { } + } + + @Override + public void onClick(DialogInterface dialog, int which) { + ThreadUtils.assertOnUiThread(); + closeDialog(which); + } + + /* Adds a set of list items to the prompt. This can be used for either context menu type dialogs, checked lists, + * or multiple selection lists. + * + * @param builder + * The alert builder currently building this dialog. + * @param listItems + * The items to add. + * @param choiceMode + * One of the ListView.CHOICE_MODE constants to designate whether this list shows checkmarks, radios buttons, or nothing. + */ + private void addListItems(AlertDialog.Builder builder, PromptListItem[] listItems, int choiceMode) { + switch (choiceMode) { + case ListView.CHOICE_MODE_MULTIPLE_MODAL: + case ListView.CHOICE_MODE_MULTIPLE: + addMultiSelectList(builder, listItems); + break; + case ListView.CHOICE_MODE_SINGLE: + addSingleSelectList(builder, listItems); + break; + case ListView.CHOICE_MODE_NONE: + default: + addMenuList(builder, listItems); + } + } + + /* Shows a multi-select list with checkmarks on the side. Android doesn't support using an adapter for + * multi-choice lists by default so instead we insert our own custom list so that we can do fancy things + * to the rows like disabling/indenting them. + * + * @param builder + * The alert builder currently building this dialog. + * @param listItems + * The items to add. + */ + private void addMultiSelectList(AlertDialog.Builder builder, PromptListItem[] listItems) { + ListView listView = (ListView) mInflater.inflate(R.layout.select_dialog_list, null); + listView.setOnItemClickListener(this); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + + mAdapter = new PromptListAdapter(mContext, R.layout.select_dialog_multichoice, listItems); + listView.setAdapter(mAdapter); + builder.setView(listView); + } + + /* Shows a single-select list with radio boxes on the side. + * + * @param builder + * the alert builder currently building this dialog. + * @param listItems + * The items to add. + */ + private void addSingleSelectList(AlertDialog.Builder builder, PromptListItem[] listItems) { + mAdapter = new PromptListAdapter(mContext, R.layout.select_dialog_singlechoice, listItems); + builder.setSingleChoiceItems(mAdapter, mAdapter.getSelectedIndex(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // The adapter isn't aware of single vs. multi choice lists, so manually + // clear any other selected items first. + ArrayList<Integer> selected = mAdapter.getSelected(); + for (Integer sel : selected) { + mAdapter.toggleSelected(sel); + } + + // Now select this item. + mAdapter.toggleSelected(which); + closeIfNoButtons(which); + } + }); + } + + /* Shows a single-select list. + * + * @param builder + * the alert builder currently building this dialog. + * @param listItems + * The items to add. + */ + private void addMenuList(AlertDialog.Builder builder, PromptListItem[] listItems) { + mAdapter = new PromptListAdapter(mContext, android.R.layout.simple_list_item_1, listItems); + builder.setAdapter(mAdapter, this); + } + + /* Wraps an input in a linearlayout. We do this so that we can set padding that appears outside the background + * drawable for the view. + */ + private View wrapInput(final PromptInput input) { + final LinearLayout linearLayout = new LinearLayout(mContext); + linearLayout.setOrientation(LinearLayout.VERTICAL); + applyInputStyle(linearLayout, input); + + linearLayout.addView(input.getView(mContext)); + + return linearLayout; + } + + /* Add the requested input elements to the dialog. + * + * @param builder + * the alert builder currently building this dialog. + * @return + * return true if the inputs were added successfully. This may fail + * if the requested input is compatible with this Android version. + */ + private boolean addInputs(AlertDialog.Builder builder) { + int length = mInputs == null ? 0 : mInputs.length; + if (length == 0) { + return true; + } + + try { + View root = null; + boolean scrollable = false; // If any of the inputs are scrollable, we won't wrap this in a ScrollView + + if (length == 1) { + root = wrapInput(mInputs[0]); + scrollable |= mInputs[0].getScrollable(); + } else if (length > 1) { + LinearLayout linearLayout = new LinearLayout(mContext); + linearLayout.setOrientation(LinearLayout.VERTICAL); + for (int i = 0; i < length; i++) { + View content = wrapInput(mInputs[i]); + linearLayout.addView(content); + scrollable |= mInputs[i].getScrollable(); + } + root = linearLayout; + } + + if (scrollable) { + // If we're showing some sort of scrollable list, force an inverse background. + builder.setInverseBackgroundForced(true); + builder.setView(root); + } else { + ScrollView view = new ScrollView(mContext); + view.addView(root); + builder.setView(view); + } + } catch (Exception ex) { + Log.e(LOGTAG, "Error showing prompt inputs", ex); + // We cannot display these input widgets with this sdk version, + // do not display any dialog and finish the prompt now. + cancelDialog(); + return false; + } + + return true; + } + + /* AdapterView.OnItemClickListener + * Called when a list item is clicked + */ + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + ThreadUtils.assertOnUiThread(); + mAdapter.toggleSelected(position); + + // If there are no buttons on this dialog, then we take selecting an item as a sign to close + // the dialog. Note that means it will be hard to select multiple things in this list, but + // given there is no way to confirm+close the dialog, it seems reasonable. + closeIfNoButtons(position); + } + + private boolean closeIfNoButtons(int selected) { + ThreadUtils.assertOnUiThread(); + if (mButtons == null || mButtons.length == 0) { + closeDialog(selected); + return true; + } + return false; + } + + /* @DialogInterface.OnCancelListener + * Called when the user hits back to cancel a dialog. The dialog will close itself when this + * ends. Setup the correct return values here. + * + * @param aDialog + * A dialog interface for the dialog that's being closed. + */ + @Override + public void onCancel(DialogInterface aDialog) { + ThreadUtils.assertOnUiThread(); + cancelDialog(); + } + + /* Called in situations where we want to cancel the dialog . This can happen if the user hits back, + * or if the dialog can't be created because of invalid JSON. + */ + private void cancelDialog() { + JSONObject ret = new JSONObject(); + try { + ret.put("button", -1); + } catch (Exception ex) { } + addInputValues(ret); + notifyClosing(ret); + } + + /* Called any time we're closing the dialog to cleanup and notify listeners that the dialog + * is closing. + */ + private void closeDialog(int which) { + JSONObject ret = new JSONObject(); + mDialog.dismiss(); + + addButtonResult(ret, which); + addListResult(ret, which); + addInputValues(ret); + + notifyClosing(ret); + } + + /* Called any time we're closing the dialog to cleanup and notify listeners that the dialog + * is closing. + */ + private void notifyClosing(JSONObject aReturn) { + try { + aReturn.put("guid", mGuid); + } catch (JSONException ex) { } + + if (mTabId != Tabs.INVALID_TAB_ID) { + Tabs.unregisterOnTabsChangedListener(this); + } + + if (mCallback != null) { + mCallback.onPromptFinished(aReturn.toString()); + } + } + + // Called when the prompt inputs on the dialog change + @Override + public void onChange(PromptInput input) { + // If there are no buttons on this dialog, assuming that "changing" an input + // means something was selected and we can close. This provides a way to tap + // on a list item and close the dialog automatically. + if (!closeIfNoButtons(-1)) { + // Alternatively, if a default button has been specified for double tapping, + // we want to close the dialog if the same input value has been transmitted + // twice in a row. + closeIfDoubleTapEnabled(input.getValue()); + } + } + + private boolean closeIfDoubleTapEnabled(Object inputValue) { + if (mDoubleTapButtonType != 0 && inputValue == mPreviousInputValue) { + closeDialog(mDoubleTapButtonType); + return true; + } + mPreviousInputValue = inputValue; + return false; + } + + private static JSONArray getSafeArray(JSONObject json, String key) { + try { + return json.getJSONArray(key); + } catch (Exception e) { + return new JSONArray(); + } + } + + public static String[] getStringArray(JSONObject aObject, String aName) { + JSONArray items = getSafeArray(aObject, aName); + int length = items.length(); + String[] list = new String[length]; + for (int i = 0; i < length; i++) { + try { + list[i] = items.getString(i); + } catch (Exception ex) { } + } + return list; + } + + private static boolean[] getBooleanArray(JSONObject aObject, String aName) { + JSONArray items = new JSONArray(); + try { + items = aObject.getJSONArray(aName); + } catch (Exception ex) { return null; } + int length = items.length(); + boolean[] list = new boolean[length]; + for (int i = 0; i < length; i++) { + try { + list[i] = items.getBoolean(i); + } catch (Exception ex) { } + } + return list; + } + + public interface PromptCallback { + + /** + * Called when the Prompt has been completed (i.e. when the user has selected an item or action in the Prompt). + * This callback is run on the UI thread. + */ + public void onPromptFinished(String jsonResult); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java new file mode 100644 index 000000000..752f5c24c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java @@ -0,0 +1,398 @@ +/* -*- 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.prompts; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.widget.AllCapsTextView; +import org.mozilla.gecko.widget.DateTimePicker; + +import android.content.Context; +import android.content.res.Configuration; +import android.support.design.widget.TextInputLayout; +import android.support.v7.widget.AppCompatCheckBox; +import android.text.Html; +import android.text.InputType; +import android.text.TextUtils; +import android.text.format.DateFormat; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.view.inputmethod.InputMethodManager; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.DatePicker; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.TimePicker; + +public abstract class PromptInput { + protected final String mLabel; + protected final String mType; + protected final String mId; + protected final String mValue; + protected final String mMinValue; + protected final String mMaxValue; + protected OnChangeListener mListener; + protected View mView; + public static final String LOGTAG = "GeckoPromptInput"; + + public interface OnChangeListener { + void onChange(PromptInput input); + } + + public void setListener(OnChangeListener listener) { + mListener = listener; + } + + public static class EditInput extends PromptInput { + protected final String mHint; + protected final boolean mAutofocus; + public static final String INPUT_TYPE = "textbox"; + + public EditInput(JSONObject object) { + super(object); + mHint = object.optString("hint"); + mAutofocus = object.optBoolean("autofocus"); + } + + @Override + public View getView(final Context context) throws UnsupportedOperationException { + EditText input = new EditText(context); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setText(mValue); + + if (!TextUtils.isEmpty(mHint)) { + input.setHint(mHint); + } + + if (mAutofocus) { + input.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + ((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE)).showSoftInput(v, 0); + } + } + }); + input.requestFocus(); + } + + TextInputLayout inputLayout = new TextInputLayout(context); + inputLayout.addView(input); + + mView = (View) inputLayout; + return mView; + } + + @Override + public Object getValue() { + final TextInputLayout inputLayout = (TextInputLayout) mView; + return inputLayout.getEditText().getText(); + } + } + + public static class NumberInput extends EditInput { + public static final String INPUT_TYPE = "number"; + public NumberInput(JSONObject obj) { + super(obj); + } + + @Override + public View getView(final Context context) throws UnsupportedOperationException { + final TextInputLayout inputLayout = (TextInputLayout) super.getView(context); + final EditText input = inputLayout.getEditText(); + input.setRawInputType(Configuration.KEYBOARD_12KEY); + input.setInputType(InputType.TYPE_CLASS_NUMBER | + InputType.TYPE_NUMBER_FLAG_SIGNED); + return input; + } + } + + public static class PasswordInput extends EditInput { + public static final String INPUT_TYPE = "password"; + public PasswordInput(JSONObject obj) { + super(obj); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + final TextInputLayout inputLayout = (TextInputLayout) super.getView(context); + inputLayout.getEditText().setInputType(InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_VARIATION_PASSWORD | + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + return inputLayout; + } + } + + public static class CheckboxInput extends PromptInput { + public static final String INPUT_TYPE = "checkbox"; + private final boolean mChecked; + + public CheckboxInput(JSONObject obj) { + super(obj); + mChecked = obj.optBoolean("checked"); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + final CheckBox checkbox = new AppCompatCheckBox(context); + checkbox.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + checkbox.setText(mLabel); + checkbox.setChecked(mChecked); + mView = (View)checkbox; + return mView; + } + + @Override + public Object getValue() { + CheckBox checkbox = (CheckBox)mView; + return checkbox.isChecked() ? Boolean.TRUE : Boolean.FALSE; + } + } + + public static class DateTimeInput extends PromptInput { + public static final String[] INPUT_TYPES = new String[] { + "date", + "week", + "time", + "datetime-local", + "datetime", + "month" + }; + + public DateTimeInput(JSONObject obj) { + super(obj); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + if (mType.equals("date")) { + try { + DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd", mValue, + DateTimePicker.PickersState.DATE, mMinValue, mMaxValue); + input.toggleCalendar(true); + mView = (View)input; + } catch (UnsupportedOperationException ex) { + // We can't use our custom version of the DatePicker widget because the sdk is too old. + // But we can fallback on the native one. + DatePicker input = new DatePicker(context); + try { + if (!TextUtils.isEmpty(mValue)) { + GregorianCalendar calendar = new GregorianCalendar(); + calendar.setTime(new SimpleDateFormat("yyyy-MM-dd").parse(mValue)); + input.updateDate(calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH)); + } + } catch (Exception e) { + Log.e(LOGTAG, "error parsing format string: " + e); + } + mView = (View)input; + } + } else if (mType.equals("week")) { + DateTimePicker input = new DateTimePicker(context, "yyyy-'W'ww", mValue, + DateTimePicker.PickersState.WEEK, mMinValue, mMaxValue); + mView = (View)input; + } else if (mType.equals("time")) { + TimePicker input = new TimePicker(context); + input.setIs24HourView(DateFormat.is24HourFormat(context)); + + GregorianCalendar calendar = new GregorianCalendar(); + if (!TextUtils.isEmpty(mValue)) { + try { + calendar.setTime(new SimpleDateFormat("HH:mm").parse(mValue)); + } catch (Exception e) { } + } + input.setCurrentHour(calendar.get(GregorianCalendar.HOUR_OF_DAY)); + input.setCurrentMinute(calendar.get(GregorianCalendar.MINUTE)); + mView = (View)input; + } else if (mType.equals("datetime-local") || mType.equals("datetime")) { + DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd HH:mm", mValue.replace("T", " ").replace("Z", ""), + DateTimePicker.PickersState.DATETIME, + mMinValue.replace("T", " ").replace("Z", ""), mMaxValue.replace("T", " ").replace("Z", "")); + input.toggleCalendar(true); + mView = (View)input; + } else if (mType.equals("month")) { + DateTimePicker input = new DateTimePicker(context, "yyyy-MM", mValue, + DateTimePicker.PickersState.MONTH, mMinValue, mMaxValue); + mView = (View)input; + } + return mView; + } + + private static String formatDateString(String dateFormat, Calendar calendar) { + return new SimpleDateFormat(dateFormat).format(calendar.getTime()); + } + + @Override + public Object getValue() { + if (mType.equals("time")) { + TimePicker tp = (TimePicker)mView; + GregorianCalendar calendar = + new GregorianCalendar(0, 0, 0, tp.getCurrentHour(), tp.getCurrentMinute()); + return formatDateString("HH:mm", calendar); + } else { + DateTimePicker dp = (DateTimePicker)mView; + GregorianCalendar calendar = new GregorianCalendar(); + calendar.setTimeInMillis(dp.getTimeInMillis()); + if (mType.equals("date")) { + return formatDateString("yyyy-MM-dd", calendar); + } else if (mType.equals("week")) { + return formatDateString("yyyy-'W'ww", calendar); + } else if (mType.equals("datetime-local")) { + return formatDateString("yyyy-MM-dd'T'HH:mm", calendar); + } else if (mType.equals("datetime")) { + calendar.set(GregorianCalendar.ZONE_OFFSET, 0); + calendar.setTimeInMillis(dp.getTimeInMillis()); + return formatDateString("yyyy-MM-dd'T'HH:mm'Z'", calendar); + } else if (mType.equals("month")) { + return formatDateString("yyyy-MM", calendar); + } + } + return super.getValue(); + } + } + + public static class MenulistInput extends PromptInput { + public static final String INPUT_TYPE = "menulist"; + private static String[] mListitems; + private static int mSelected; + + public Spinner spinner; + public AllCapsTextView textView; + + public MenulistInput(JSONObject obj) { + super(obj); + mListitems = Prompt.getStringArray(obj, "values"); + mSelected = obj.optInt("selected"); + } + + @Override + public View getView(final Context context) throws UnsupportedOperationException { + spinner = new Spinner(context, Spinner.MODE_DIALOG); + try { + if (mListitems.length > 0) { + ArrayAdapter<String> adapter = new ArrayAdapter<String>(context, android.R.layout.simple_spinner_item, mListitems); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + spinner.setAdapter(adapter); + spinner.setSelection(mSelected); + } + } catch (Exception ex) { + } + + if (!TextUtils.isEmpty(mLabel)) { + LinearLayout container = new LinearLayout(context); + container.setOrientation(LinearLayout.VERTICAL); + + textView = new AllCapsTextView(context, null); + textView.setText(mLabel); + container.addView(textView); + + container.addView(spinner); + return container; + } + + return spinner; + } + + @Override + public Object getValue() { + return spinner.getSelectedItemPosition(); + } + } + + public static class LabelInput extends PromptInput { + public static final String INPUT_TYPE = "label"; + public LabelInput(JSONObject obj) { + super(obj); + } + + @Override + public View getView(Context context) throws UnsupportedOperationException { + // not really an input, but a way to add labels and such to the dialog + TextView view = new TextView(context); + view.setText(Html.fromHtml(mLabel)); + mView = view; + return mView; + } + } + + public PromptInput(JSONObject obj) { + mLabel = obj.optString("label"); + mType = obj.optString("type"); + String id = obj.optString("id"); + mId = TextUtils.isEmpty(id) ? mType : id; + mValue = obj.optString("value"); + mMaxValue = obj.optString("max"); + mMinValue = obj.optString("min"); + } + + public static PromptInput getInput(JSONObject obj) { + String type = obj.optString("type"); + switch (type) { + case EditInput.INPUT_TYPE: + return new EditInput(obj); + case NumberInput.INPUT_TYPE: + return new NumberInput(obj); + case PasswordInput.INPUT_TYPE: + return new PasswordInput(obj); + case CheckboxInput.INPUT_TYPE: + return new CheckboxInput(obj); + case MenulistInput.INPUT_TYPE: + return new MenulistInput(obj); + case LabelInput.INPUT_TYPE: + return new LabelInput(obj); + case IconGridInput.INPUT_TYPE: + return new IconGridInput(obj); + case ColorPickerInput.INPUT_TYPE: + return new ColorPickerInput(obj); + case TabInput.INPUT_TYPE: + return new TabInput(obj); + default: + for (String dtType : DateTimeInput.INPUT_TYPES) { + if (dtType.equals(type)) { + return new DateTimeInput(obj); + } + } + + break; + } + + return null; + } + + public abstract View getView(Context context) throws UnsupportedOperationException; + + public String getId() { + return mId; + } + + public Object getValue() { + return null; + } + + public boolean getScrollable() { + return false; + } + + public boolean canApplyInputStyle() { + return true; + } + + protected void notifyListeners(String val) { + if (mListener != null) { + mListener.onChange(this); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java new file mode 100644 index 000000000..720086c92 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java @@ -0,0 +1,281 @@ +package org.mozilla.gecko.prompts; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.menu.MenuItemSwitcherLayout; +import org.mozilla.gecko.widget.GeckoActionProvider; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckedTextView; +import android.widget.TextView; +import android.widget.ListView; +import android.widget.ArrayAdapter; +import android.util.TypedValue; + +import java.util.ArrayList; + +public class PromptListAdapter extends ArrayAdapter<PromptListItem> { + private static final int VIEW_TYPE_ITEM = 0; + private static final int VIEW_TYPE_GROUP = 1; + private static final int VIEW_TYPE_ACTIONS = 2; + private static final int VIEW_TYPE_COUNT = 3; + + private static final String LOGTAG = "GeckoPromptListAdapter"; + + private final int mResourceId; + private Drawable mBlankDrawable; + private Drawable mMoreDrawable; + private static int mGroupPaddingSize; + private static int mLeftRightTextWithIconPadding; + private static int mTopBottomTextWithIconPadding; + private static int mIconSize; + private static int mMinRowSize; + private static int mIconTextPadding; + private static float mTextSize; + private static boolean mInitialized; + + PromptListAdapter(Context context, int textViewResourceId, PromptListItem[] objects) { + super(context, textViewResourceId, objects); + mResourceId = textViewResourceId; + init(); + } + + private void init() { + if (!mInitialized) { + Resources res = getContext().getResources(); + mGroupPaddingSize = (int) (res.getDimension(R.dimen.prompt_service_group_padding_size)); + mLeftRightTextWithIconPadding = (int) (res.getDimension(R.dimen.prompt_service_left_right_text_with_icon_padding)); + mTopBottomTextWithIconPadding = (int) (res.getDimension(R.dimen.prompt_service_top_bottom_text_with_icon_padding)); + mIconTextPadding = (int) (res.getDimension(R.dimen.prompt_service_icon_text_padding)); + mIconSize = (int) (res.getDimension(R.dimen.prompt_service_icon_size)); + mMinRowSize = (int) (res.getDimension(R.dimen.menu_item_row_height)); + mTextSize = res.getDimension(R.dimen.menu_item_textsize); + + mInitialized = true; + } + } + + @Override + public int getItemViewType(int position) { + PromptListItem item = getItem(position); + if (item.isGroup) { + return VIEW_TYPE_GROUP; + } else if (item.showAsActions) { + return VIEW_TYPE_ACTIONS; + } else { + return VIEW_TYPE_ITEM; + } + } + + @Override + public int getViewTypeCount() { + return VIEW_TYPE_COUNT; + } + + private Drawable getMoreDrawable(Resources res) { + if (mMoreDrawable == null) { + mMoreDrawable = res.getDrawable(R.drawable.menu_item_more); + } + return mMoreDrawable; + } + + private Drawable getBlankDrawable(Resources res) { + if (mBlankDrawable == null) { + mBlankDrawable = res.getDrawable(R.drawable.blank); + } + return mBlankDrawable; + } + + public void toggleSelected(int position) { + PromptListItem item = getItem(position); + item.setSelected(!item.getSelected()); + } + + private void maybeUpdateIcon(PromptListItem item, TextView t) { + if (item.getIcon() == null && !item.inGroup && !item.isParent) { + t.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + return; + } + + Drawable d = null; + Resources res = getContext().getResources(); + // Set the padding between the icon and the text. + t.setCompoundDrawablePadding(mIconTextPadding); + if (item.getIcon() != null) { + // We want the icon to be of a specific size. Some do not + // follow this rule so we have to resize them. + Bitmap bitmap = ((BitmapDrawable) item.getIcon()).getBitmap(); + d = new BitmapDrawable(res, Bitmap.createScaledBitmap(bitmap, mIconSize, mIconSize, true)); + } else if (item.inGroup) { + // We don't currently support "indenting" items with icons + d = getBlankDrawable(res); + } + + Drawable moreDrawable = null; + if (item.isParent) { + moreDrawable = getMoreDrawable(res); + } + + if (d != null || moreDrawable != null) { + t.setCompoundDrawablesWithIntrinsicBounds(d, null, moreDrawable, null); + } + } + + private void maybeUpdateCheckedState(ListView list, int position, PromptListItem item, ViewHolder viewHolder) { + viewHolder.textView.setEnabled(!item.disabled && !item.isGroup); + viewHolder.textView.setClickable(item.isGroup || item.disabled); + if (viewHolder.textView instanceof CheckedTextView) { + // Apparently just using ct.setChecked(true) doesn't work, so this + // is stolen from the android source code as a way to set the checked + // state of these items + list.setItemChecked(position, item.getSelected()); + } + } + + boolean isSelected(int position) { + return getItem(position).getSelected(); + } + + ArrayList<Integer> getSelected() { + int length = getCount(); + + ArrayList<Integer> selected = new ArrayList<Integer>(); + for (int i = 0; i < length; i++) { + if (isSelected(i)) { + selected.add(i); + } + } + + return selected; + } + + int getSelectedIndex() { + int length = getCount(); + for (int i = 0; i < length; i++) { + if (isSelected(i)) { + return i; + } + } + return -1; + } + + private View getActionView(PromptListItem item, final ListView list, final int position) { + final GeckoActionProvider provider = GeckoActionProvider.getForType(item.getIntent().getType(), getContext()); + provider.setIntent(item.getIntent()); + + final MenuItemSwitcherLayout view = (MenuItemSwitcherLayout) provider.onCreateActionView( + GeckoActionProvider.ActionViewType.CONTEXT_MENU); + // If a quickshare button is clicked, we need to close the dialog. + view.addActionButtonClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ListView.OnItemClickListener listener = list.getOnItemClickListener(); + if (listener != null) { + listener.onItemClick(list, view, position, position); + } + } + }); + + return view; + } + + private void updateActionView(final PromptListItem item, final MenuItemSwitcherLayout view, final ListView list, final int position) { + view.setTitle(item.label); + view.setIcon(item.getIcon()); + view.setSubMenuIndicator(item.isParent); + + // If the share button is clicked, we need to close the dialog and then show an intent chooser + view.setMenuItemClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ListView.OnItemClickListener listener = list.getOnItemClickListener(); + if (listener != null) { + listener.onItemClick(list, view, position, position); + } + + final GeckoActionProvider provider = GeckoActionProvider.getForType(item.getIntent().getType(), getContext()); + IntentChooserPrompt prompt = new IntentChooserPrompt(getContext(), provider); + prompt.show(item.label, getContext(), new IntentHandler() { + @Override + public void onIntentSelected(final Intent intent, final int p) { + provider.chooseActivity(p); + + // Context: Sharing via content contextmenu list (no explicit session is active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "promptlist"); + } + + @Override + public void onCancelled() { + // do nothing + } + }); + } + }); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + PromptListItem item = getItem(position); + int type = getItemViewType(position); + ViewHolder viewHolder = null; + + if (convertView == null) { + if (type == VIEW_TYPE_ACTIONS) { + convertView = getActionView(item, (ListView) parent, position); + } else { + int resourceId = mResourceId; + if (item.isGroup) { + resourceId = R.layout.list_item_header; + } + + LayoutInflater mInflater = LayoutInflater.from(getContext()); + convertView = mInflater.inflate(resourceId, null); + convertView.setMinimumHeight(mMinRowSize); + + TextView tv = (TextView) convertView.findViewById(android.R.id.text1); + tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + viewHolder = new ViewHolder(tv, tv.getPaddingLeft(), tv.getPaddingRight(), + tv.getPaddingTop(), tv.getPaddingBottom()); + + convertView.setTag(viewHolder); + } + } else { + viewHolder = (ViewHolder) convertView.getTag(); + } + + if (type == VIEW_TYPE_ACTIONS) { + updateActionView(item, (MenuItemSwitcherLayout) convertView, (ListView) parent, position); + } else { + viewHolder.textView.setText(item.label); + maybeUpdateCheckedState((ListView) parent, position, item, viewHolder); + maybeUpdateIcon(item, viewHolder.textView); + } + + return convertView; + } + + private static class ViewHolder { + public final TextView textView; + public final int paddingLeft; + public final int paddingRight; + public final int paddingTop; + public final int paddingBottom; + + ViewHolder(TextView aTextView, int aLeft, int aRight, int aTop, int aBottom) { + textView = aTextView; + paddingLeft = aLeft; + paddingRight = aRight; + paddingTop = aTop; + paddingBottom = aBottom; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java new file mode 100644 index 000000000..48ace735c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java @@ -0,0 +1,128 @@ +package org.mozilla.gecko.prompts; + +import org.json.JSONException; +import org.mozilla.gecko.IntentHelper; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.ThumbnailHelper; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.widget.GeckoActionProvider; + +import org.json.JSONArray; +import org.json.JSONObject; + +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; + +import java.util.List; +import java.util.ArrayList; + +// This class should die and be replaced with normal menu items +public class PromptListItem { + private static final String LOGTAG = "GeckoPromptListItem"; + public final String label; + public final boolean isGroup; + public final boolean inGroup; + public final boolean disabled; + public final int id; + public final boolean showAsActions; + public final boolean isParent; + + public Intent mIntent; + public boolean mSelected; + public Drawable mIcon; + + PromptListItem(JSONObject aObject) { + Context context = GeckoAppShell.getContext(); + label = aObject.isNull("label") ? "" : aObject.optString("label"); + isGroup = aObject.optBoolean("isGroup"); + inGroup = aObject.optBoolean("inGroup"); + disabled = aObject.optBoolean("disabled"); + id = aObject.optInt("id"); + mSelected = aObject.optBoolean("selected"); + + JSONObject obj = aObject.optJSONObject("showAsActions"); + if (obj != null) { + showAsActions = true; + String uri = obj.isNull("uri") ? "" : obj.optString("uri"); + String type = obj.isNull("type") ? GeckoActionProvider.DEFAULT_MIME_TYPE : + obj.optString("type", GeckoActionProvider.DEFAULT_MIME_TYPE); + + mIntent = IntentHelper.getShareIntent(context, uri, type, ""); + isParent = true; + } else { + mIntent = null; + showAsActions = false; + // Support both "isParent" (backwards compat for older consumers), and "menu" for the new Tabbed prompt ui. + isParent = aObject.optBoolean("isParent") || aObject.optBoolean("menu"); + } + + final String iconStr = aObject.optString("icon"); + if (iconStr != null) { + final ResourceDrawableUtils.BitmapLoader loader = new ResourceDrawableUtils.BitmapLoader() { + @Override + public void onBitmapFound(Drawable d) { + mIcon = d; + } + }; + + if (iconStr.startsWith("thumbnail:")) { + final int id = Integer.parseInt(iconStr.substring(10), 10); + ThumbnailHelper.getInstance().getAndProcessThumbnailFor(id, loader); + } else { + ResourceDrawableUtils.getDrawable(context, iconStr, loader); + } + } + } + + public void setIntent(Intent i) { + mIntent = i; + } + + public Intent getIntent() { + return mIntent; + } + + public void setIcon(Drawable icon) { + mIcon = icon; + } + + public Drawable getIcon() { + return mIcon; + } + + public void setSelected(boolean selected) { + mSelected = selected; + } + + public boolean getSelected() { + return mSelected; + } + + public PromptListItem(String aLabel) { + label = aLabel; + isGroup = false; + inGroup = false; + isParent = false; + disabled = false; + id = 0; + showAsActions = false; + } + + static PromptListItem[] getArray(JSONArray items) { + if (items == null) { + return new PromptListItem[0]; + } + + int length = items.length(); + List<PromptListItem> list = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + try { + PromptListItem item = new PromptListItem(items.getJSONObject(i)); + list.add(item); + } catch (JSONException ex) { } + } + + return list.toArray(new PromptListItem[length]); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java new file mode 100644 index 000000000..8155cc1c6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java @@ -0,0 +1,72 @@ +/* -*- 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.prompts; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.util.Log; + +public class PromptService implements GeckoEventListener { + private static final String LOGTAG = "GeckoPromptService"; + + private final Context mContext; + + public PromptService(Context context) { + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "Prompt:Show", + "Prompt:ShowTop"); + mContext = context; + } + + public void destroy() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "Prompt:Show", + "Prompt:ShowTop"); + } + + public void show(final String aTitle, final String aText, final PromptListItem[] aMenuList, + final int aChoiceMode, final Prompt.PromptCallback callback) { + // The dialog must be created on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + Prompt p; + p = new Prompt(mContext, callback); + p.show(aTitle, aText, aMenuList, aChoiceMode); + } + }); + } + + // GeckoEventListener implementation + @Override + public void handleMessage(String event, final JSONObject message) { + // The dialog must be created on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + Prompt p; + p = new Prompt(mContext, new Prompt.PromptCallback() { + @Override + public void onPromptFinished(String jsonResult) { + try { + EventDispatcher.sendResponse(message, new JSONObject(jsonResult)); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error building json response", ex); + } + } + }); + p.show(message); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java new file mode 100644 index 000000000..ab490e79c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java @@ -0,0 +1,107 @@ +/* -*- 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.prompts; + +import java.util.LinkedHashMap; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.TabHost; +import android.widget.TextView; + +public class TabInput extends PromptInput implements AdapterView.OnItemClickListener { + public static final String INPUT_TYPE = "tabs"; + public static final String LOGTAG = "GeckoTabInput"; + + /* Keeping the order of this in sync with the JSON is important. */ + final private LinkedHashMap<String, PromptListItem[]> mTabs; + + private TabHost mHost; + private int mPosition; + + public TabInput(JSONObject obj) { + super(obj); + mTabs = new LinkedHashMap<String, PromptListItem[]>(); + try { + JSONArray tabs = obj.getJSONArray("items"); + for (int i = 0; i < tabs.length(); i++) { + JSONObject tab = tabs.getJSONObject(i); + String title = tab.getString("label"); + JSONArray items = tab.getJSONArray("items"); + mTabs.put(title, PromptListItem.getArray(items)); + } + } catch (JSONException ex) { + Log.e(LOGTAG, "Exception", ex); + } + } + + @Override + public View getView(final Context context) throws UnsupportedOperationException { + final LayoutInflater inflater = LayoutInflater.from(context); + mHost = (TabHost) inflater.inflate(R.layout.tab_prompt_input, null); + mHost.setup(); + + for (String title : mTabs.keySet()) { + final TabHost.TabSpec spec = mHost.newTabSpec(title); + spec.setContent(new TabHost.TabContentFactory() { + @Override + public View createTabContent(final String tag) { + PromptListAdapter adapter = new PromptListAdapter(context, android.R.layout.simple_list_item_1, mTabs.get(tag)); + ListView listView = new ListView(context); + listView.setCacheColorHint(0); + listView.setOnItemClickListener(TabInput.this); + listView.setAdapter(adapter); + return listView; + } + }); + + spec.setIndicator(title); + mHost.addTab(spec); + } + mView = mHost; + return mHost; + } + + @Override + public Object getValue() { + JSONObject obj = new JSONObject(); + try { + obj.put("tab", mHost.getCurrentTab()); + obj.put("item", mPosition); + } catch (JSONException ex) { } + + return obj; + } + + @Override + public boolean getScrollable() { + return true; + } + + @Override + public boolean canApplyInputStyle() { + return false; + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + ThreadUtils.assertOnUiThread(); + mPosition = position; + notifyListeners(Integer.toString(position)); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java new file mode 100644 index 000000000..42a7c6a90 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java @@ -0,0 +1,71 @@ +/* -*- 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.push; + +import android.support.annotation.NonNull; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Pair a (String) value with a timestamp. The timestamp is usually when the + * value was fetched from a remote service or when the value was locally + * generated. + * + * It's awkward to serialize generic values to JSON -- that requires lots of + * factory classes -- so we specialize to String instances. + */ +public class Fetched { + public final String value; + public final long timestamp; + + public Fetched(String value, long timestamp) { + this.value = value; + this.timestamp = timestamp; + } + + public static Fetched now(String value) { + return new Fetched(value, System.currentTimeMillis()); + } + + public static @NonNull Fetched fromJSONObject(@NonNull JSONObject json) { + final String value = json.optString("value", null); + final String timestampString = json.optString("timestamp", null); + final long timestamp = timestampString != null ? Long.valueOf(timestampString) : 0L; + return new Fetched(value, timestamp); + } + + public JSONObject toJSONObject() throws JSONException { + final JSONObject jsonObject = new JSONObject(); + if (value != null) { + jsonObject.put("value", value); + } else { + jsonObject.remove("value"); + } + jsonObject.put("timestamp", Long.toString(timestamp)); + return jsonObject; + } + + @Override + public boolean equals(Object o) { + // Auto-generated. + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Fetched fetched = (Fetched) o; + + if (timestamp != fetched.timestamp) return false; + return !(value != null ? !value.equals(fetched.value) : fetched.value != null); + + } + + @Override + public int hashCode() { + // Auto-generated. + int result = value != null ? value.hashCode() : 0; + result = 31 * result + (int) (timestamp ^ (timestamp >>> 32)); + return result; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java new file mode 100644 index 000000000..9c1fab5f9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java @@ -0,0 +1,110 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.push; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.mozilla.gecko.push.RegisterUserAgentResponse; +import org.mozilla.gecko.push.SubscribeChannelResponse; +import org.mozilla.gecko.push.autopush.AutopushClient; +import org.mozilla.gecko.push.autopush.AutopushClientException; +import org.mozilla.gecko.sync.Utils; + +import java.util.concurrent.Executor; + +/** + * This class bridges the autopush client, which is written in callback style, with the Fennec + * push implementation, which is written in a linear style. It handles returning results and + * re-throwing exceptions passed as messages. + * <p/> + * TODO: fold this into the autopush client directly. + */ +public class PushClient { + public static class LocalException extends Exception { + private static final long serialVersionUID = 2387554736L; + + public LocalException(Throwable throwable) { + super(throwable); + } + } + + private final AutopushClient autopushClient; + + public PushClient(String serverURI) { + this.autopushClient = new AutopushClient(serverURI, Utils.newSynchronousExecutor()); + } + + /** + * Each instance is <b>single-use</b>! Exactly one delegate method should be invoked once, + * but we take care to handle multiple invocations (favoring the earliest), just to be safe. + */ + protected static class Delegate<T> implements AutopushClient.RequestDelegate<T> { + Object result; // Oh, for an algebraic data type when you need one! + + @SuppressWarnings("unchecked") + public T responseOrThrow() throws LocalException, AutopushClientException { + if (result instanceof LocalException) { + throw (LocalException) result; + } + if (result instanceof AutopushClientException) { + throw (AutopushClientException) result; + } + return (T) result; + } + + @Override + public void handleError(Exception e) { + if (result == null) { + result = new LocalException(e); + } + } + + @Override + public void handleFailure(AutopushClientException e) { + if (result == null) { + result = e; + } + } + + @Override + public void handleSuccess(T response) { + if (result == null) { + result = response; + } + } + } + + public RegisterUserAgentResponse registerUserAgent(@NonNull String token) throws LocalException, AutopushClientException { + final Delegate<RegisterUserAgentResponse> delegate = new Delegate<>(); + autopushClient.registerUserAgent(token, delegate); + return delegate.responseOrThrow(); + } + + public void reregisterUserAgent(@NonNull String uaid, @NonNull String secret, @NonNull String token) throws LocalException, AutopushClientException { + final Delegate<Void> delegate = new Delegate<>(); + autopushClient.reregisterUserAgent(uaid, secret, token, delegate); + delegate.responseOrThrow(); // For side-effects only. + } + + public void unregisterUserAgent(@NonNull String uaid, @NonNull String secret) throws LocalException, AutopushClientException { + final Delegate<Void> delegate = new Delegate<>(); + autopushClient.unregisterUserAgent(uaid, secret, delegate); + delegate.responseOrThrow(); // For side-effects only. + } + + public SubscribeChannelResponse subscribeChannel(@NonNull String uaid, @NonNull String secret, @Nullable String appServerKey) throws LocalException, AutopushClientException { + final Delegate<SubscribeChannelResponse> delegate = new Delegate<>(); + autopushClient.subscribeChannel(uaid, secret, appServerKey, delegate); + return delegate.responseOrThrow(); + } + + public void unsubscribeChannel(@NonNull String uaid, @NonNull String secret, @NonNull String chid) throws LocalException, AutopushClientException { + final Delegate<Void> delegate = new Delegate<>(); + autopushClient.unsubscribeChannel(uaid, secret, chid, delegate); + delegate.responseOrThrow(); // For side-effects only. + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java new file mode 100644 index 000000000..42ef60b61 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java @@ -0,0 +1,354 @@ +/* -*- 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.push; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.gcm.GcmTokenClient; +import org.mozilla.gecko.push.autopush.AutopushClientException; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * The push manager advances push registrations, ensuring that the upstream autopush endpoint has + * a fresh GCM token. It brokers channel subscription requests to the upstream and maintains + * local state. + * <p/> + * This class is not thread safe. An individual instance should be accessed on a single + * (background) thread. + */ +public class PushManager { + public static final long TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS = 7 * 24 * 60 * 60 * 1000L; // One week. + + public static class ProfileNeedsConfigurationException extends Exception { + private static final long serialVersionUID = 3326738888L; + + public ProfileNeedsConfigurationException() { + super(); + } + } + + private static final String LOG_TAG = "GeckoPushManager"; + + protected final @NonNull PushState state; + protected final @NonNull GcmTokenClient gcmClient; + protected final @NonNull PushClientFactory pushClientFactory; + + // For testing only. + public interface PushClientFactory { + PushClient getPushClient(String autopushEndpoint, boolean debug); + } + + public PushManager(@NonNull PushState state, @NonNull GcmTokenClient gcmClient, @NonNull PushClientFactory pushClientFactory) { + this.state = state; + this.gcmClient = gcmClient; + this.pushClientFactory = pushClientFactory; + } + + public PushRegistration registrationForSubscription(String chid) { + // chids are globally unique, so we're not concerned about finding a chid associated to + // any particular profile. + for (Map.Entry<String, PushRegistration> entry : state.getRegistrations().entrySet()) { + final PushSubscription subscription = entry.getValue().getSubscription(chid); + if (subscription != null) { + return entry.getValue(); + } + } + return null; + } + + public Map<String, PushSubscription> allSubscriptionsForProfile(String profileName) { + final PushRegistration registration = state.getRegistration(profileName); + if (registration == null) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(registration.subscriptions); + } + + public PushRegistration registerUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException { + Log.i(LOG_TAG, "Registering user agent for profile named: " + profileName); + return advanceRegistration(profileName, now); + } + + public PushRegistration unregisterUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException { + Log.i(LOG_TAG, "Unregistering user agent for profile named: " + profileName); + + final PushRegistration registration = state.getRegistration(profileName); + if (registration == null) { + Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote uaid for profileName: " + profileName); + return null; + } + + final String uaid = registration.uaid.value; + final String secret = registration.secret; + if (uaid == null || secret == null) { + Log.e(LOG_TAG, "Cannot unregisterUserAgent with null registration uaid or secret!"); + return null; + } + + unregisterUserAgentOnBackgroundThread(registration); + return registration; + } + + public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException { + Log.i(LOG_TAG, "Subscribing to channel for service: " + service + "; for profile named: " + profileName); + final PushRegistration registration = advanceRegistration(profileName, now); + final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, appServerKey, System.currentTimeMillis()); + return subscription; + } + + protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws AutopushClientException, PushClient.LocalException { + final String uaid = registration.uaid.value; + final String secret = registration.secret; + if (uaid == null || secret == null) { + throw new IllegalStateException("Cannot subscribeChannel with null uaid or secret!"); + } + + // Verify endpoint is not null? + final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug); + + final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret, appServerKey); + if (registration.debug) { + Log.i(LOG_TAG, "Got chid: " + result.channelID + " and endpoint: " + result.endpoint); + } else { + Log.i(LOG_TAG, "Got chid and endpoint."); + } + + final PushSubscription subscription = new PushSubscription(result.channelID, profileName, result.endpoint, service, serviceData); + registration.putSubscription(result.channelID, subscription); + state.checkpoint(); + + return subscription; + } + + public PushSubscription unsubscribeChannel(final @NonNull String chid) { + Log.i(LOG_TAG, "Unsubscribing from channel with chid: " + chid); + + final PushRegistration registration = registrationForSubscription(chid); + if (registration == null) { + Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote subscription: " + chid); + return null; + } + + // We remove the local subscription before the remote subscription: without the local + // subscription we'll ignoring incoming messages, and after some amount of time the + // server will expire the channel due to non-activity. This is also Desktop's approach. + final PushSubscription subscription = registration.removeSubscription(chid); + state.checkpoint(); + + if (subscription == null) { + // This should never happen. + Log.e(LOG_TAG, "Subscription did not exist: " + chid); + return null; + } + + final String uaid = registration.uaid.value; + final String secret = registration.secret; + if (uaid == null || secret == null) { + Log.e(LOG_TAG, "Cannot unsubscribeChannel with null registration uaid or secret!"); + return null; + } + + final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug); + // Fire and forget. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + try { + pushClient.unsubscribeChannel(registration.uaid.value, registration.secret, chid); + Log.i(LOG_TAG, "Unsubscribed from channel with chid: " + chid); + } catch (PushClient.LocalException | AutopushClientException e) { + Log.w(LOG_TAG, "Failed to unsubscribe from channel with chid; ignoring: " + chid, e); + } + } + }); + + return subscription; + } + + public PushRegistration configure(final @NonNull String profileName, final @NonNull String endpoint, final boolean debug, final long now) { + Log.i(LOG_TAG, "Updating configuration."); + final PushRegistration registration = state.getRegistration(profileName); + final PushRegistration newRegistration; + if (registration != null) { + if (!endpoint.equals(registration.autopushEndpoint)) { + if (debug) { + Log.i(LOG_TAG, "Push configuration autopushEndpoint changed! Was: " + registration.autopushEndpoint + "; now: " + endpoint); + } else { + Log.i(LOG_TAG, "Push configuration autopushEndpoint changed!"); + } + + newRegistration = new PushRegistration(endpoint, debug, Fetched.now(null), null); + + if (registration.uaid.value != null) { + // New endpoint! All registrations and subscriptions have been dropped, and + // should be removed remotely. + unregisterUserAgentOnBackgroundThread(registration); + } + } else if (debug != registration.debug) { + Log.i(LOG_TAG, "Push configuration debug changed: " + debug); + newRegistration = registration.withDebug(debug); + } else { + newRegistration = registration; + } + } else { + if (debug) { + Log.i(LOG_TAG, "Push configuration set: " + endpoint + "; debug: " + debug); + } else { + Log.i(LOG_TAG, "Push configuration set!"); + } + newRegistration = new PushRegistration(endpoint, debug, new Fetched(null, now), null); + } + + if (newRegistration != registration) { + state.putRegistration(profileName, newRegistration); + state.checkpoint(); + } + + return newRegistration; + } + + private void unregisterUserAgentOnBackgroundThread(final PushRegistration registration) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + try { + pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug).unregisterUserAgent(registration.uaid.value, registration.secret); + Log.i(LOG_TAG, "Unregistered user agent with uaid: " + registration.uaid.value); + } catch (PushClient.LocalException | AutopushClientException e) { + Log.w(LOG_TAG, "Failed to unregister user agent with uaid; ignoring: " + registration.uaid.value, e); + } + } + }); + } + + protected @NonNull PushRegistration advanceRegistration(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException { + final PushRegistration registration = state.getRegistration(profileName); + if (registration == null || registration.autopushEndpoint == null) { + Log.i(LOG_TAG, "Cannot advance to registered: registration needs configuration."); + throw new ProfileNeedsConfigurationException(); + } + return advanceRegistration(registration, profileName, now); + } + + protected @NonNull PushRegistration advanceRegistration(final PushRegistration registration, final @NonNull String profileName, final long now) throws AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException { + final Fetched gcmToken = gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, registration.debug); + + final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug); + + if (registration.uaid.value == null) { + if (registration.debug) { + Log.i(LOG_TAG, "No uaid; requesting from autopush endpoint: " + registration.autopushEndpoint); + } else { + Log.i(LOG_TAG, "No uaid: requesting from autopush endpoint."); + } + final RegisterUserAgentResponse result = pushClient.registerUserAgent(gcmToken.value); + if (registration.debug) { + Log.i(LOG_TAG, "Got uaid: " + result.uaid + " and secret: " + result.secret); + } else { + Log.i(LOG_TAG, "Got uaid and secret."); + } + final long nextNow = System.currentTimeMillis(); + final PushRegistration nextRegistration = registration.withUserAgentID(result.uaid, result.secret, nextNow); + state.putRegistration(profileName, nextRegistration); + state.checkpoint(); + return advanceRegistration(nextRegistration, profileName, nextNow); + } + + if (registration.uaid.timestamp + TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS < now + || registration.uaid.timestamp < gcmToken.timestamp) { + if (registration.debug) { + Log.i(LOG_TAG, "Stale uaid; re-registering with autopush endpoint: " + registration.autopushEndpoint); + } else { + Log.i(LOG_TAG, "Stale uaid: re-registering with autopush endpoint."); + } + + pushClient.reregisterUserAgent(registration.uaid.value, registration.secret, gcmToken.value); + + Log.i(LOG_TAG, "Re-registered uaid and secret."); + final long nextNow = System.currentTimeMillis(); + final PushRegistration nextRegistration = registration.withUserAgentID(registration.uaid.value, registration.secret, nextNow); + state.putRegistration(profileName, nextRegistration); + state.checkpoint(); + return advanceRegistration(nextRegistration, profileName, nextNow); + } + + Log.d(LOG_TAG, "Existing uaid is fresh; no need to request from autopush endpoint."); + return registration; + } + + public void invalidateGcmToken() { + gcmClient.invalidateToken(); + } + + public void startup(long now) { + try { + Log.i(LOG_TAG, "Startup: requesting GCM token."); + gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false); // For side-effects. + } catch (GcmTokenClient.NeedsGooglePlayServicesException e) { + // Requires user intervention. At App startup, we don't want to address this. In + // response to user activity, we do want to try to have the user address this. + Log.w(LOG_TAG, "Startup: needs Google Play Services. Ignoring until GCM is requested in response to user activity."); + return; + } catch (IOException e) { + // We're temporarily unable to get a GCM token. There's nothing to be done; we'll + // try to advance the App's state in response to user activity or at next startup. + Log.w(LOG_TAG, "Startup: Google Play Services is available, but we can't get a token; ignoring.", e); + return; + } + + Log.i(LOG_TAG, "Startup: advancing all registrations."); + final Map<String, PushRegistration> registrations = state.getRegistrations(); + + // Now advance all registrations. + try { + final Iterator<Map.Entry<String, PushRegistration>> it = registrations.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry<String, PushRegistration> entry = it.next(); + final String profileName = entry.getKey(); + final PushRegistration registration = entry.getValue(); + if (registration.subscriptions.isEmpty()) { + Log.i(LOG_TAG, "Startup: no subscriptions for profileName; not advancing registration: " + profileName); + continue; + } + + try { + advanceRegistration(profileName, now); // For side-effects. + Log.i(LOG_TAG, "Startup: advanced registration for profileName: " + profileName); + } catch (ProfileNeedsConfigurationException e) { + Log.i(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; profile needs configuration from Gecko."); + } catch (AutopushClientException e) { + if (e.isTransientError()) { + Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got transient autopush error. Ignoring; will advance on demand.", e); + } else { + Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got permanent autopush error. Removing registration entirely.", e); + it.remove(); + } + } catch (PushClient.LocalException e) { + Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got local exception. Ignoring; will advance on demand.", e); + } + } + } catch (GcmTokenClient.NeedsGooglePlayServicesException e) { + Log.w(LOG_TAG, "Startup: cannot advance any registrations; need Google Play Services!", e); + return; + } catch (IOException e) { + Log.w(LOG_TAG, "Startup: cannot advance any registrations; intermittent Google Play Services exception; ignoring, will advance on demand.", e); + return; + } + + // We may have removed registrations above. Checkpoint just to be safe! + state.checkpoint(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java new file mode 100644 index 000000000..a991774ff --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java @@ -0,0 +1,126 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.push; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Represent an autopush User Agent registration. + * <p/> + * Such a registration associates an endpoint, optional debug flag, some Google + * Cloud Messaging data, and the returned uaid and secret. + * <p/> + * Each registration is associated to a single Gecko profile, although we don't + * enforce that here. This class is immutable, so it is by definition + * thread-safe. + */ +public class PushRegistration { + public final String autopushEndpoint; + public final boolean debug; + // TODO: fold (timestamp, {uaid, secret}) into this class. + public final @NonNull Fetched uaid; + public final String secret; + + protected final @NonNull Map<String, PushSubscription> subscriptions; + + public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret, @NonNull Map<String, PushSubscription> subscriptions) { + this.autopushEndpoint = autopushEndpoint; + this.debug = debug; + this.uaid = uaid; + this.secret = secret; + this.subscriptions = subscriptions; + } + + public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret) { + this(autopushEndpoint, debug, uaid, secret, new HashMap<String, PushSubscription>()); + } + + public JSONObject toJSONObject() throws JSONException { + final JSONObject subscriptions = new JSONObject(); + for (Map.Entry<String, PushSubscription> entry : this.subscriptions.entrySet()) { + subscriptions.put(entry.getKey(), entry.getValue().toJSONObject()); + } + + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("autopushEndpoint", autopushEndpoint); + jsonObject.put("debug", debug); + jsonObject.put("uaid", uaid.toJSONObject()); + jsonObject.put("secret", secret); + jsonObject.put("subscriptions", subscriptions); + return jsonObject; + } + + public static PushRegistration fromJSONObject(@NonNull JSONObject registration) throws JSONException { + final String endpoint = registration.optString("autopushEndpoint", null); + final boolean debug = registration.getBoolean("debug"); + final Fetched uaid = Fetched.fromJSONObject(registration.getJSONObject("uaid")); + final String secret = registration.optString("secret", null); + + final JSONObject subscriptionsObject = registration.getJSONObject("subscriptions"); + final Map<String, PushSubscription> subscriptions = new HashMap<>(); + final Iterator<String> it = subscriptionsObject.keys(); + while (it.hasNext()) { + final String chid = it.next(); + subscriptions.put(chid, PushSubscription.fromJSONObject(subscriptionsObject.getJSONObject(chid))); + } + + return new PushRegistration(endpoint, debug, uaid, secret, subscriptions); + } + + @Override + public boolean equals(Object o) { + // Auto-generated. + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PushRegistration that = (PushRegistration) o; + + if (autopushEndpoint != null ? !autopushEndpoint.equals(that.autopushEndpoint) : that.autopushEndpoint != null) + return false; + if (!uaid.equals(that.uaid)) return false; + if (secret != null ? !secret.equals(that.secret) : that.secret != null) return false; + if (subscriptions != null ? !subscriptions.equals(that.subscriptions) : that.subscriptions != null) return false; + return (debug == that.debug); + } + + @Override + public int hashCode() { + // Auto-generated. + int result = autopushEndpoint != null ? autopushEndpoint.hashCode() : 0; + result = 31 * result + (debug ? 1 : 0); + result = 31 * result + uaid.hashCode(); + result = 31 * result + (secret != null ? secret.hashCode() : 0); + result = 31 * result + (subscriptions != null ? subscriptions.hashCode() : 0); + return result; + } + + public PushRegistration withDebug(boolean debug) { + return new PushRegistration(this.autopushEndpoint, debug, this.uaid, this.secret, this.subscriptions); + } + + public PushRegistration withUserAgentID(String uaid, String secret, long nextNow) { + return new PushRegistration(this.autopushEndpoint, this.debug, new Fetched(uaid, nextNow), secret, this.subscriptions); + } + + public PushSubscription getSubscription(@NonNull String chid) { + return subscriptions.get(chid); + } + + public PushSubscription putSubscription(@NonNull String chid, @NonNull PushSubscription subscription) { + return subscriptions.put(chid, subscription); + } + + public PushSubscription removeSubscription(@NonNull String chid) { + return subscriptions.remove(chid); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java new file mode 100644 index 000000000..8d3a92e48 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java @@ -0,0 +1,460 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.push; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoService; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.fxa.FxAccountPushHandler; +import org.mozilla.gecko.gcm.GcmTokenClient; +import org.mozilla.gecko.push.autopush.AutopushClientException; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Class that handles messages used in the Google Cloud Messaging and DOM push API integration. + * <p/> + * This singleton services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud + * Messaging requests. + * <p/> + * It is expected that Gecko is started (if not already running) soon after receiving GCM messages + * otherwise there is a greater risk that pending messages that have not been handle by Gecko will + * be lost if this service is killed. + * <p/> + * It's worth noting that we allow the DOM push API in restricted profiles. + */ +@ReflectionTarget +public class PushService implements BundleEventListener { + private static final String LOG_TAG = "GeckoPushService"; + + public static final String SERVICE_WEBPUSH = "webpush"; + public static final String SERVICE_FXA = "fxa"; + + private static PushService sInstance; + + private static final String[] GECKO_EVENTS = new String[] { + "PushServiceAndroidGCM:Configure", + "PushServiceAndroidGCM:DumpRegistration", + "PushServiceAndroidGCM:DumpSubscriptions", + "PushServiceAndroidGCM:Initialized", + "PushServiceAndroidGCM:Uninitialized", + "PushServiceAndroidGCM:RegisterUserAgent", + "PushServiceAndroidGCM:UnregisterUserAgent", + "PushServiceAndroidGCM:SubscribeChannel", + "PushServiceAndroidGCM:UnsubscribeChannel", + "FxAccountsPush:Initialized", + "FxAccountsPush:ReceivedPushMessageToDecode:Response", + "History:GetPrePathLastVisitedTimeMilliseconds", + }; + + private enum GeckoComponent { + FxAccountsPush, + PushServiceAndroidGCM + } + + public static synchronized PushService getInstance(Context context) { + if (sInstance == null) { + onCreate(context); + } + return sInstance; + } + + @ReflectionTarget + public static synchronized void onCreate(Context context) { + if (sInstance != null) { + return; + } + sInstance = new PushService(context); + + sInstance.registerGeckoEventListener(); + sInstance.onStartup(); + } + + protected final PushManager pushManager; + + // NB: These are not thread-safe, we're depending on these being access from the same background thread. + private boolean isReadyPushServiceAndroidGCM = false; + private boolean isReadyFxAccountsPush = false; + private final List<JSONObject> pendingPushMessages; + + public PushService(Context context) { + pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() { + @Override + public PushClient getPushClient(String autopushEndpoint, boolean debug) { + return new PushClient(autopushEndpoint); + } + }); + + pendingPushMessages = new LinkedList<>(); + } + + public void onStartup() { + Log.i(LOG_TAG, "Starting up."); + ThreadUtils.assertOnBackgroundThread(); + + try { + pushManager.startup(System.currentTimeMillis()); + } catch (Exception e) { + Log.e(LOG_TAG, "Got exception during startup; ignoring.", e); + return; + } + } + + public void onRefresh() { + Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again."); + ThreadUtils.assertOnBackgroundThread(); + + pushManager.invalidateGcmToken(); + try { + pushManager.startup(System.currentTimeMillis()); + } catch (Exception e) { + Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e); + return; + } + } + + public void onMessageReceived(final @NonNull Context context, final @NonNull Bundle bundle) { + Log.i(LOG_TAG, "Google Play Services GCM message received; delivering."); + ThreadUtils.assertOnBackgroundThread(); + + final String chid = bundle.getString("chid"); + if (chid == null) { + Log.w(LOG_TAG, "No chid found; ignoring message."); + return; + } + + final PushRegistration registration = pushManager.registrationForSubscription(chid); + if (registration == null) { + Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message."); + return; + } + + final PushSubscription subscription = registration.getSubscription(chid); + if (subscription == null) { + // This should never happen. There's not much to be done; in the future, perhaps we + // could try to drop the remote subscription? + Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message."); + return; + } + + boolean isWebPush = SERVICE_WEBPUSH.equals(subscription.service); + boolean isFxAPush = SERVICE_FXA.equals(subscription.service); + if (!isWebPush && !isFxAPush) { + Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service); + return; + } + + Log.i(LOG_TAG, "Message directed to service: " + subscription.service); + + if (subscription.serviceData == null) { + Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message."); + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.SERVICE, "dom-push-api"); + + final String profileName = subscription.serviceData.optString("profileName", null); + final String profilePath = subscription.serviceData.optString("profilePath", null); + if (profileName == null || profilePath == null) { + Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message."); + return; + } + + if (canSendPushMessagesToGecko()) { + if (!GeckoThread.canUseProfile(profileName, new File(profilePath))) { + Log.e(LOG_TAG, "Mismatched profile for chid: " + chid + "; ignoring dom/push message."); + return; + } + } else { + final Intent intent = GeckoService.getIntentToCreateServices(context, "android-push-service"); + GeckoService.setIntentProfile(intent, profileName, profilePath); + context.startService(intent); + } + + final JSONObject data = new JSONObject(); + try { + data.put("channelID", chid); + data.put("con", bundle.getString("con")); + data.put("enc", bundle.getString("enc")); + // Only one of cryptokey (newer) and enckey (deprecated) should be set, but the + // Gecko handler will verify this. + data.put("cryptokey", bundle.getString("cryptokey")); + data.put("enckey", bundle.getString("enckey")); + data.put("message", bundle.getString("body")); + + if (!canSendPushMessagesToGecko()) { + data.put("profileName", profileName); + data.put("profilePath", profilePath); + data.put("service", subscription.service); + } + } catch (JSONException e) { + Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e); + return; + } + + if (!canSendPushMessagesToGecko()) { + Log.i(LOG_TAG, "Required service not initialized, adding message to queue."); + pendingPushMessages.add(data); + return; + } + + if (isWebPush) { + sendMessageToGeckoService(data); + } else { + sendMessageToDecodeToGeckoService(data); + } + } + + protected static void sendMessageToGeckoService(final @NonNull JSONObject message) { + Log.i(LOG_TAG, "Delivering dom/push message to Gecko!"); + GeckoAppShell.notifyObservers("PushServiceAndroidGCM:ReceivedPushMessage", + message.toString(), + GeckoThread.State.PROFILE_READY); + } + + protected static void sendMessageToDecodeToGeckoService(final @NonNull JSONObject message) { + Log.i(LOG_TAG, "Delivering dom/push message to decode to Gecko!"); + GeckoAppShell.notifyObservers("FxAccountsPush:ReceivedPushMessageToDecode", + message.toString(), + GeckoThread.State.PROFILE_READY); + } + + protected void registerGeckoEventListener() { + Log.d(LOG_TAG, "Registered Gecko event listener."); + EventDispatcher.getInstance().registerBackgroundThreadListener(this, GECKO_EVENTS); + } + + protected void unregisterGeckoEventListener() { + Log.d(LOG_TAG, "Unregistered Gecko event listener."); + EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS); + } + + @Override + public void handleMessage(final String event, final Bundle message, final EventCallback callback) { + Log.i(LOG_TAG, "Handling event: " + event); + ThreadUtils.assertOnBackgroundThread(); + + final Context context = GeckoAppShell.getApplicationContext(); + // We're invoked in response to a Gecko message on a background thread. We should always + // be able to safely retrieve the current Gecko profile. + final GeckoProfile geckoProfile = GeckoProfile.get(context); + + if (callback == null) { + Log.e(LOG_TAG, "callback must not be null in " + event); + return; + } + + try { + if ("PushServiceAndroidGCM:Initialized".equals(event)) { + processComponentState(GeckoComponent.PushServiceAndroidGCM, true); + callback.sendSuccess(null); + return; + } + if ("PushServiceAndroidGCM:Uninitialized".equals(event)) { + processComponentState(GeckoComponent.PushServiceAndroidGCM, false); + callback.sendSuccess(null); + return; + } + if ("FxAccountsPush:Initialized".equals(event)) { + processComponentState(GeckoComponent.FxAccountsPush, true); + callback.sendSuccess(null); + return; + } + if ("PushServiceAndroidGCM:Configure".equals(event)) { + final String endpoint = message.getString("endpoint"); + if (endpoint == null) { + callback.sendError("endpoint must not be null in " + event); + return; + } + final boolean debug = message.getBoolean("debug", false); + pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects. + callback.sendSuccess(null); + return; + } + if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) { + // In the future, this might be used to interrogate the Java Push Manager + // registration state from JavaScript. + callback.sendError("Not yet implemented!"); + return; + } + if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) { + try { + final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName()); + + final JSONObject json = new JSONObject(); + for (Map.Entry<String, PushSubscription> entry : result.entrySet()) { + json.put(entry.getKey(), entry.getValue().toJSONObject()); + } + callback.sendSuccess(json); + } catch (JSONException e) { + callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); + } + return; + } + if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) { + try { + pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects. + callback.sendSuccess(null); + } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) { + Log.e(LOG_TAG, "Got exception in " + event, e); + callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); + } + return; + } + if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) { + // In the future, this might be used to tell the Java Push Manager to unregister + // a User Agent entirely from JavaScript. Right now, however, everything is + // subscription based; there's no concept of unregistering all subscriptions + // simultaneously. + callback.sendError("Not yet implemented!"); + return; + } + if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) { + final String service = SERVICE_FXA.equals(message.getString("service")) ? + SERVICE_FXA : + SERVICE_WEBPUSH; + final JSONObject serviceData; + final String appServerKey = message.getString("appServerKey"); + try { + serviceData = new JSONObject(); + serviceData.put("profileName", geckoProfile.getName()); + serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath()); + } catch (JSONException e) { + Log.e(LOG_TAG, "Got exception in " + event, e); + callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); + return; + } + + final PushSubscription subscription; + try { + subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis()); + } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) { + Log.e(LOG_TAG, "Got exception in " + event, e); + callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); + return; + } + + final JSONObject json = new JSONObject(); + try { + json.put("channelID", subscription.chid); + json.put("endpoint", subscription.webpushEndpoint); + } catch (JSONException e) { + Log.e(LOG_TAG, "Got exception in " + event, e); + callback.sendError("Got exception handling message [" + event + "]: " + e.toString()); + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "dom-push-api"); + callback.sendSuccess(json); + return; + } + if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) { + final String channelID = message.getString("channelID"); + if (channelID == null) { + callback.sendError("channelID must not be null in " + event); + return; + } + + // Fire and forget. See comments in the function itself. + final PushSubscription pushSubscription = pushManager.unsubscribeChannel(channelID); + if (pushSubscription != null) { + Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "dom-push-api"); + callback.sendSuccess(null); + return; + } + + callback.sendError("Could not unsubscribe from channel: " + channelID); + return; + } + if ("FxAccountsPush:ReceivedPushMessageToDecode:Response".equals(event)) { + FxAccountPushHandler.handleFxAPushMessage(context, message); + return; + } + if ("History:GetPrePathLastVisitedTimeMilliseconds".equals(event)) { + if (callback == null) { + Log.e(LOG_TAG, "callback must not be null in " + event); + return; + } + final String prePath = message.getString("prePath"); + if (prePath == null) { + callback.sendError("prePath must not be null in " + event); + return; + } + // We're on a background thread, so we can be synchronous. + final long millis = BrowserDB.from(geckoProfile).getPrePathLastVisitedTimeMilliseconds( + context.getContentResolver(), prePath); + callback.sendSuccess(millis); + return; + } + } catch (GcmTokenClient.NeedsGooglePlayServicesException e) { + // TODO: improve this. Can we find a point where the user is *definitely* interacting + // with the WebPush? Perhaps we can show a dialog when interacting with the Push + // permissions, and then be more aggressive showing this notification when we have + // registrations and subscriptions that can't be advanced. + callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services."); + } + } + + private void processComponentState(@NonNull GeckoComponent component, boolean isReady) { + if (component == GeckoComponent.FxAccountsPush) { + isReadyFxAccountsPush = isReady; + + } else if (component == GeckoComponent.PushServiceAndroidGCM) { + isReadyPushServiceAndroidGCM = isReady; + } + + // Send all pending messages to Gecko. + if (canSendPushMessagesToGecko()) { + sendPushMessagesToGecko(pendingPushMessages); + pendingPushMessages.clear(); + } + } + + private boolean canSendPushMessagesToGecko() { + return isReadyFxAccountsPush && isReadyPushServiceAndroidGCM; + } + + private static void sendPushMessagesToGecko(@NonNull List<JSONObject> messages) { + for (JSONObject pushMessage : messages) { + final String profileName = pushMessage.optString("profileName", null); + final String profilePath = pushMessage.optString("profilePath", null); + final String service = pushMessage.optString("service", null); + if (profileName == null || profilePath == null || + !GeckoThread.canUseProfile(profileName, new File(profilePath))) { + Log.e(LOG_TAG, "Mismatched profile for chid: " + + pushMessage.optString("channelID") + + "; ignoring dom/push message."); + continue; + } + if (SERVICE_WEBPUSH.equals(service)) { + sendMessageToGeckoService(pushMessage); + } else if (SERVICE_FXA.equals(service)) { + sendMessageToDecodeToGeckoService(pushMessage); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushState.java b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java new file mode 100644 index 000000000..686bf5a0d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java @@ -0,0 +1,137 @@ +/* -*- 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.push; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; +import android.support.v4.util.AtomicFile; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Firefox for Android maintains an App-wide mapping associating + * profile names to push registrations. Each push registration in turn associates channels to + * push subscriptions. + * <p/> + * We use a simple storage model of JSON backed by an atomic file. It is assumed that instances + * of this class will reference distinct files on disk; and that all accesses will be happen on a + * single (worker thread). + */ +public class PushState { + private static final String LOG_TAG = "GeckoPushState"; + + private static final long VERSION = 1L; + + protected final @NonNull AtomicFile file; + + protected final @NonNull Map<String, PushRegistration> registrations; + + public PushState(Context context, @NonNull String fileName) { + this.registrations = new HashMap<>(); + + file = new AtomicFile(new File(context.getApplicationInfo().dataDir, fileName)); + synchronized (file) { + try { + final String s = new String(file.readFully(), "UTF-8"); + final JSONObject temp = new JSONObject(s); + if (temp.optLong("version", 0L) != VERSION) { + throw new JSONException("Unknown version!"); + } + + final JSONObject registrationsObject = temp.getJSONObject("registrations"); + final Iterator<String> it = registrationsObject.keys(); + while (it.hasNext()) { + final String profileName = it.next(); + final PushRegistration registration = PushRegistration.fromJSONObject(registrationsObject.getJSONObject(profileName)); + this.registrations.put(profileName, registration); + } + } catch (FileNotFoundException e) { + Log.i(LOG_TAG, "No storage found; starting fresh."); + this.registrations.clear(); + } catch (IOException | JSONException e) { + Log.w(LOG_TAG, "Got exception reading storage; dropping storage and starting fresh.", e); + this.registrations.clear(); + } + } + } + + public JSONObject toJSONObject() throws JSONException { + final JSONObject registrations = new JSONObject(); + for (Map.Entry<String, PushRegistration> entry : this.registrations.entrySet()) { + registrations.put(entry.getKey(), entry.getValue().toJSONObject()); + } + + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("version", 1L); + jsonObject.put("registrations", registrations); + return jsonObject; + } + + /** + * Synchronously persist the cache to disk. + * @return whether the cache was persisted successfully. + */ + @WorkerThread + public boolean checkpoint() { + synchronized (file) { + FileOutputStream fileOutputStream = null; + try { + fileOutputStream = file.startWrite(); + fileOutputStream.write(toJSONObject().toString().getBytes("UTF-8")); + file.finishWrite(fileOutputStream); + return true; + } catch (JSONException | IOException e) { + Log.e(LOG_TAG, "Got exception writing JSON storage; ignoring.", e); + if (fileOutputStream != null) { + file.failWrite(fileOutputStream); + } + return false; + } + } + } + + public PushRegistration putRegistration(@NonNull String profileName, @NonNull PushRegistration registration) { + return registrations.put(profileName, registration); + } + + /** + * Return the existing push registration for the given profile name. + * @return the push registration, if one is registered; null otherwise. + */ + public PushRegistration getRegistration(@NonNull String profileName) { + return registrations.get(profileName); + } + + /** + * Return all push registrations, keyed by profile names. + * @return a map of all push registrations. <b>The map is intentionally mutable - be careful!</b> + */ + public @NonNull Map<String, PushRegistration> getRegistrations() { + return registrations; + } + + /** + * Remove any existing push registration for the given profile name. + * </p> + * Most registration removals are during iteration, which should use an iterator that is + * aware of removals. + * @return the removed push registration, if one was removed; null otherwise. + */ + public PushRegistration removeRegistration(@NonNull String profileName) { + return registrations.remove(profileName); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java new file mode 100644 index 000000000..ecf752591 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java @@ -0,0 +1,81 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.push; + +import android.support.annotation.NonNull; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Represent an autopush Channel subscription. + * <p/> + * Such a subscription associates a user agent and autopush data with a channel + * ID, a WebPush endpoint, and some service-specific data. + * <p/> + * Cloud Messaging data, and the returned uaid and secret. + * <p/> + * Each registration is associated to a single Gecko profile, although we don't + * enforce that here. This class is immutable, so it is by definition + * thread-safe. + */ +public class PushSubscription { + public final @NonNull String chid; + public final @NonNull String profileName; + public final @NonNull String webpushEndpoint; + public final @NonNull String service; + public final JSONObject serviceData; + + public PushSubscription(@NonNull String chid, @NonNull String profileName, @NonNull String webpushEndpoint, @NonNull String service, JSONObject serviceData) { + this.chid = chid; + this.profileName = profileName; + this.webpushEndpoint = webpushEndpoint; + this.service = service; + this.serviceData = serviceData; + } + + public JSONObject toJSONObject() throws JSONException { + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("chid", chid); + jsonObject.put("profileName", profileName); + jsonObject.put("webpushEndpoint", webpushEndpoint); + jsonObject.put("service", service); + jsonObject.put("serviceData", serviceData); + return jsonObject; + } + + public static PushSubscription fromJSONObject(@NonNull JSONObject subscription) throws JSONException { + final String chid = subscription.getString("chid"); + final String profileName = subscription.getString("profileName"); + final String webpushEndpoint = subscription.getString("webpushEndpoint"); + final String service = subscription.getString("service"); + final JSONObject serviceData = subscription.optJSONObject("serviceData"); + return new PushSubscription(chid, profileName, webpushEndpoint, service, serviceData); + } + + @Override + public boolean equals(Object o) { + // Auto-generated. + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PushSubscription that = (PushSubscription) o; + + if (!chid.equals(that.chid)) return false; + if (!profileName.equals(that.profileName)) return false; + if (!webpushEndpoint.equals(that.webpushEndpoint)) return false; + return service.equals(that.service); + } + + @Override + public int hashCode() { + // Auto-generated. + int result = profileName.hashCode(); + result = 31 * result + chid.hashCode(); + result = 31 * result + webpushEndpoint.hashCode(); + result = 31 * result + service.hashCode(); + return result; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java b/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java new file mode 100644 index 000000000..e70aac5b5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java @@ -0,0 +1,72 @@ +/* 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 org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.util.StringUtils; + +import android.net.Uri; + +public class ReaderModeUtils { + private static final String LOGTAG = "ReaderModeUtils"; + + /** + * Extract the URL from a valid about:reader URL. You may want to use stripAboutReaderUrl + * instead to always obtain a valid String. + * + * @see #stripAboutReaderUrl(String) for a safer version that returns the original URL for malformed/invalid + * URLs. + * @return <code>null</code> if the URL is malformed or doesn't contain a URL parameter. + */ + private static String getUrlFromAboutReader(String aboutReaderUrl) { + return StringUtils.getQueryParameter(aboutReaderUrl, "url"); + } + + public static boolean isEnteringReaderMode(String oldURL, String newURL) { + if (oldURL == null || newURL == null) { + return false; + } + + if (!AboutPages.isAboutReader(newURL)) { + return false; + } + + String urlFromAboutReader = getUrlFromAboutReader(newURL); + if (urlFromAboutReader == null) { + return false; + } + + return urlFromAboutReader.equals(oldURL); + } + + public static String getAboutReaderForUrl(String url) { + return getAboutReaderForUrl(url, -1); + } + + /** + * Obtain the underlying URL from an about:reader URL. + * This will return the input URL if either of the following is true: + * 1. the input URL is a non about:reader URL + * 2. the input URL is an invalid/unparseable about:reader URL + */ + public static String stripAboutReaderUrl(String url) { + if (!AboutPages.isAboutReader(url)) { + return url; + } + + final String strippedUrl = getUrlFromAboutReader(url); + return strippedUrl != null ? strippedUrl : url; + } + + public static String getAboutReaderForUrl(String url, int tabId) { + String aboutReaderUrl = AboutPages.READER + "?url=" + Uri.encode(url); + + if (tabId >= 0) { + aboutReaderUrl += "&tabId=" + tabId; + } + + return aboutReaderUrl; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java b/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java new file mode 100644 index 000000000..e01ff79ac --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java @@ -0,0 +1,154 @@ +/* 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 org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.icons.IconRequest; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.Context; +import android.util.Log; + +import java.util.concurrent.ExecutionException; + +public final class ReadingListHelper implements NativeEventListener { + private static final String LOGTAG = "GeckoReadingListHelper"; + + protected final Context context; + private final BrowserDB db; + + public ReadingListHelper(Context context, GeckoProfile profile) { + this.context = context; + this.db = BrowserDB.from(profile); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener((NativeEventListener) this, + "Reader:FaviconRequest", "Reader:AddedToCache"); + } + + public void uninit() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener((NativeEventListener) this, + "Reader:FaviconRequest", "Reader:AddedToCache"); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, + final EventCallback callback) { + switch (event) { + case "Reader:FaviconRequest": { + handleReaderModeFaviconRequest(callback, message.getString("url")); + break; + } + case "Reader:AddedToCache": { + // AddedToCache is a one way message: callback will be null, and we therefore shouldn't + // attempt to handle it. + handleAddedToCache(message.getString("url"), message.getString("path"), message.getInt("size")); + break; + } + } + } + + /** + * Gecko (ReaderMode) requests the page favicon to append to the + * document head for display. + */ + private void handleReaderModeFaviconRequest(final EventCallback callback, final String url) { + (new UIAsyncTask.WithoutParams<String>(ThreadUtils.getBackgroundHandler()) { + @Override + public String doInBackground() { + // This is a bit ridiculous if you look at the bigger picture: Reader mode extracts + // the article content. We insert the content into a new document (about:reader). + // Some events are exchanged to lookup the icon URL for the actual website. This + // URL is then added to the markup which will then trigger our icon loading code in + // the Tab class. + // + // The Tab class could just lookup and load the icon itself. All it needs to do is + // to strip the about:reader URL and perform a normal icon load from cache. + // + // A more global solution (looking at desktop and iOS) would be to copy the <link> + // markup from the original page to the about:reader page and then rely on our normal + // icon loading code. This would work even if we do not have anything in the cache + // for some kind of reason. + + final IconRequest request = Icons.with(context) + .pageUrl(url) + .prepareOnly() + .build(); + + try { + request.execute(null).get(); + if (request.getIconCount() > 0) { + return request.getBestIcon().getUrl(); + } + } catch (InterruptedException | ExecutionException e) { + // Ignore + } + + return null; + } + + @Override + public void onPostExecute(String faviconUrl) { + JSONObject args = new JSONObject(); + if (faviconUrl != null) { + try { + args.put("url", url); + args.put("faviconUrl", faviconUrl); + } catch (JSONException e) { + Log.w(LOGTAG, "Error building JSON favicon arguments.", e); + } + } + callback.sendSuccess(args.toString()); + } + }).execute(); + } + + private void handleAddedToCache(final String url, final String path, final int size) { + final SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context); + + rch.put(url, path, size); + } + + public static void cacheReaderItem(final String url, final int tabID, Context context) { + if (AboutPages.isAboutReader(url)) { + throw new IllegalArgumentException("Page url must be original (not about:reader) url"); + } + + SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context); + + if (!rch.isURLCached(url)) { + GeckoAppShell.notifyObservers("Reader:AddToCache", Integer.toString(tabID)); + } + } + + public static void removeCachedReaderItem(final String url, Context context) { + if (AboutPages.isAboutReader(url)) { + throw new IllegalArgumentException("Page url must be original (not about:reader) url"); + } + + SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context); + + if (rch.isURLCached(url)) { + GeckoAppShell.notifyObservers("Reader:RemoveFromCache", url); + } + + // When removing items from the cache we can probably spare ourselves the async callback + // that we use when adding cached items. We know the cached item will be gone, hence + // we no longer need to track it in the SavedReaderViewHelper + rch.remove(url); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java b/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java new file mode 100644 index 000000000..e60abac71 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java @@ -0,0 +1,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; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java new file mode 100644 index 000000000..480078a98 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java @@ -0,0 +1,34 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +/** + * Default implementation of RestrictionConfiguration interface. Used whenever no restrictions are enforced for the + * current profile. + */ +public class DefaultConfiguration implements RestrictionConfiguration { + @Override + public boolean isAllowed(Restrictable restrictable) { + if (restrictable == Restrictable.BLOCK_LIST) { + return false; + } + + return true; + } + + @Override + public boolean canLoadUrl(String url) { + return true; + } + + @Override + public boolean isRestricted() { + return false; + } + + @Override + public void update() {} +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java new file mode 100644 index 000000000..f9663ccf7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java @@ -0,0 +1,83 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import android.net.Uri; + +import java.util.Arrays; +import java.util.List; + +/** + * RestrictionConfiguration implementation for guest profiles. + */ +public class GuestProfileConfiguration implements RestrictionConfiguration { + static List<Restrictable> DISABLED_FEATURES = Arrays.asList( + Restrictable.DOWNLOAD, + Restrictable.INSTALL_EXTENSION, + Restrictable.INSTALL_APPS, + Restrictable.BROWSE, + Restrictable.SHARE, + Restrictable.BOOKMARK, + Restrictable.ADD_CONTACT, + Restrictable.SET_IMAGE, + Restrictable.MODIFY_ACCOUNTS, + Restrictable.REMOTE_DEBUGGING, + Restrictable.IMPORT_SETTINGS, + Restrictable.BLOCK_LIST, + Restrictable.DATA_CHOICES, + Restrictable.DEFAULT_THEME + ); + + @SuppressWarnings("serial") + private static final List<String> BANNED_SCHEMES = Arrays.asList( + "file", + "chrome", + "resource", + "jar", + "wyciwyg" + ); + + private static final List<String> BANNED_URLS = Arrays.asList( + "about:config", + "about:addons" + ); + + @Override + public boolean isAllowed(Restrictable restrictable) { + return !DISABLED_FEATURES.contains(restrictable); + } + + @Override + public boolean canLoadUrl(String url) { + // Null URLs are always permitted. + if (url == null) { + return true; + } + + final Uri u = Uri.parse(url); + final String scheme = u.getScheme(); + if (BANNED_SCHEMES.contains(scheme)) { + return false; + } + + url = url.toLowerCase(); + for (String banned : BANNED_URLS) { + if (url.startsWith(banned)) { + return false; + } + } + + return true; + } + + @Override + public boolean isRestricted() { + return true; + } + + @Override + public void update() {} +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java new file mode 100644 index 000000000..f794c5782 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java @@ -0,0 +1,112 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.support.annotation.StringRes; + +/** + * This is a list of things we can restrict you from doing. Some of these are reflected in Android UserManager constants. + * Others are specific to us. + * These constants should be in sync with the ones from toolkit/components/parentalcontrols/nsIParentalControlsService.idl + */ +public enum Restrictable { + DOWNLOAD(1, "downloads", 0, 0), + + INSTALL_EXTENSION( + 2, "no_install_extensions", + R.string.restrictable_feature_addons_installation, + R.string.restrictable_feature_addons_installation_description), + + // UserManager.DISALLOW_INSTALL_APPS + INSTALL_APPS(3, "no_install_apps", 0 , 0), + + BROWSE(4, "browse", 0, 0), + + SHARE(5, "share", 0, 0), + + BOOKMARK(6, "bookmark", 0, 0), + + ADD_CONTACT(7, "add_contact", 0, 0), + + SET_IMAGE(8, "set_image", 0, 0), + + // UserManager.DISALLOW_MODIFY_ACCOUNTS + MODIFY_ACCOUNTS(9, "no_modify_accounts", 0, 0), + + REMOTE_DEBUGGING(10, "remote_debugging", 0, 0), + + IMPORT_SETTINGS(11, "import_settings", 0, 0), + + PRIVATE_BROWSING( + 12, "private_browsing", + R.string.restrictable_feature_private_browsing, + R.string.restrictable_feature_private_browsing_description), + + DATA_CHOICES(13, "data_coices", 0, 0), + + CLEAR_HISTORY(14, "clear_history", + R.string.restrictable_feature_clear_history, + R.string.restrictable_feature_clear_history_description), + + MASTER_PASSWORD(15, "master_password", 0, 0), + + GUEST_BROWSING(16, "guest_browsing", 0, 0), + + ADVANCED_SETTINGS(17, "advanced_settings", + R.string.restrictable_feature_advanced_settings, + R.string.restrictable_feature_advanced_settings_description), + + CAMERA_MICROPHONE(18, "camera_microphone", + R.string.restrictable_feature_camera_microphone, + R.string.restrictable_feature_camera_microphone_description), + + BLOCK_LIST(19, "block_list", + R.string.restrictable_feature_block_list, + R.string.restrictable_feature_block_list_description), + + TELEMETRY(20, "telemetry", + R.string.datareporting_telemetry_title, + R.string.datareporting_telemetry_summary), + + HEALTH_REPORT(21, "health_report", + R.string.datareporting_fhr_title, + R.string.datareporting_fhr_summary2), + + DEFAULT_THEME(22, "default_theme", 0, 0); + + public final int id; + public final String name; + + @StringRes + public final int title; + + @StringRes + public final int description; + + Restrictable(final int id, final String name, @StringRes int title, @StringRes int description) { + this.id = id; + this.name = name; + this.title = title; + this.description = description; + } + + public String getTitle(Context context) { + if (title == 0) { + return toString(); + } + return context.getResources().getString(title); + } + + public String getDescription(Context context) { + if (description == 0) { + return null; + } + return context.getResources().getString(description); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java new file mode 100644 index 000000000..15a0b97f4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java @@ -0,0 +1,129 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.util.ThreadUtils; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.StrictMode; +import android.os.UserManager; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) +public class RestrictedProfileConfiguration implements RestrictionConfiguration { + // Mapping from restrictable feature to default state (on/off) + private static Map<Restrictable, Boolean> configuration = new LinkedHashMap<>(); + static { + configuration.put(Restrictable.INSTALL_EXTENSION, false); + configuration.put(Restrictable.PRIVATE_BROWSING, false); + configuration.put(Restrictable.CLEAR_HISTORY, false); + configuration.put(Restrictable.MASTER_PASSWORD, false); + configuration.put(Restrictable.GUEST_BROWSING, false); + configuration.put(Restrictable.ADVANCED_SETTINGS, false); + configuration.put(Restrictable.CAMERA_MICROPHONE, false); + configuration.put(Restrictable.DATA_CHOICES, false); + configuration.put(Restrictable.BLOCK_LIST, false); + configuration.put(Restrictable.TELEMETRY, false); + configuration.put(Restrictable.HEALTH_REPORT, true); + configuration.put(Restrictable.DEFAULT_THEME, true); + } + + /** + * These restrictions are hidden from the admin configuration UI. + */ + private static List<Restrictable> hiddenRestrictions = new ArrayList<>(); + static { + hiddenRestrictions.add(Restrictable.MASTER_PASSWORD); + hiddenRestrictions.add(Restrictable.GUEST_BROWSING); + hiddenRestrictions.add(Restrictable.DATA_CHOICES); + hiddenRestrictions.add(Restrictable.DEFAULT_THEME); + + // Hold behind Nightly flag until we have an actual block list deployed. + if (!AppConstants.NIGHTLY_BUILD) { + hiddenRestrictions.add(Restrictable.BLOCK_LIST); + } + } + + /* package-private */ static boolean shouldHide(Restrictable restrictable) { + return hiddenRestrictions.contains(restrictable); + } + + /* package-private */ static Map<Restrictable, Boolean> getConfiguration() { + return configuration; + } + + private Context context; + + public RestrictedProfileConfiguration(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public synchronized boolean isAllowed(Restrictable restrictable) { + // Special casing system/user restrictions + if (restrictable == Restrictable.INSTALL_APPS || restrictable == Restrictable.MODIFY_ACCOUNTS) { + return RestrictionCache.getUserRestriction(context, restrictable.name); + } + + if (!RestrictionCache.hasApplicationRestriction(context, restrictable.name) && !configuration.containsKey(restrictable)) { + // Always allow features that are not in the configuration + return true; + } + + return RestrictionCache.getApplicationRestriction(context, restrictable.name, configuration.get(restrictable)); + } + + @Override + public boolean canLoadUrl(String url) { + if (!isAllowed(Restrictable.INSTALL_EXTENSION) && AboutPages.isAboutAddons(url)) { + return false; + } + + if (!isAllowed(Restrictable.PRIVATE_BROWSING) && AboutPages.isAboutPrivateBrowsing(url)) { + return false; + } + + if (AboutPages.isAboutConfig(url)) { + // Always block access to about:config to prevent circumventing restrictions (Bug 1189233) + return false; + } + + return true; + } + + @Override + public boolean isRestricted() { + return true; + } + + @Override + public synchronized void update() { + RestrictionCache.invalidate(); + } + + public static List<Restrictable> getVisibleRestrictions() { + final List<Restrictable> visibleList = new ArrayList<>(); + + for (Restrictable restrictable : configuration.keySet()) { + if (hiddenRestrictions.contains(restrictable)) { + continue; + } + visibleList.add(restrictable); + } + + return visibleList; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java new file mode 100644 index 000000000..523cc113b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java @@ -0,0 +1,99 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.os.StrictMode; +import android.os.UserManager; + +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Cache for user and application restrictions. + */ +public class RestrictionCache { + private static Bundle cachedAppRestrictions; + private static Bundle cachedUserRestrictions; + private static boolean isCacheInvalid = true; + + private RestrictionCache() {} + + public static synchronized boolean getUserRestriction(Context context, String restriction) { + updateCacheIfNeeded(context); + return cachedUserRestrictions.getBoolean(restriction); + } + + public static synchronized boolean hasApplicationRestriction(Context context, String restriction) { + updateCacheIfNeeded(context); + return cachedAppRestrictions.containsKey(restriction); + } + + public static synchronized boolean getApplicationRestriction(Context context, String restriction, boolean defaultValue) { + updateCacheIfNeeded(context); + return cachedAppRestrictions.getBoolean(restriction, defaultValue); + } + + public static synchronized boolean hasApplicationRestrictions(Context context) { + updateCacheIfNeeded(context); + return !cachedAppRestrictions.isEmpty(); + } + + public static synchronized void invalidate() { + isCacheInvalid = true; + } + + private static void updateCacheIfNeeded(Context context) { + // If we are not on the UI thread then we can just go ahead and read the values (Bug 1189347). + // Otherwise we read from the cache to avoid blocking the UI thread. If the cache is invalid + // then we hazard the consequences and just do the read. + if (isCacheInvalid || !ThreadUtils.isOnUiThread()) { + readRestrictions(context); + isCacheInvalid = false; + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + private static void readRestrictions(Context context) { + final UserManager mgr = (UserManager) context.getSystemService(Context.USER_SERVICE); + + // If we do not have anything in the cache yet then this read might happen on the UI thread (Bug 1189347). + final StrictMode.ThreadPolicy policy = StrictMode.allowThreadDiskReads(); + + try { + Bundle appRestrictions = mgr.getApplicationRestrictions(context.getPackageName()); + migrateRestrictionsIfNeeded(appRestrictions); + + cachedAppRestrictions = appRestrictions; + cachedUserRestrictions = mgr.getUserRestrictions(); // Always implies disk read + } finally { + StrictMode.setThreadPolicy(policy); + } + } + + /** + * This method migrates the old set of DISALLOW_ restrictions to the new restrictable feature ones (Bug 1189336). + */ + /* package-private */ static void migrateRestrictionsIfNeeded(Bundle bundle) { + if (!bundle.containsKey(Restrictable.INSTALL_EXTENSION.name) && bundle.containsKey("no_install_extensions")) { + bundle.putBoolean(Restrictable.INSTALL_EXTENSION.name, !bundle.getBoolean("no_install_extensions")); + } + + if (!bundle.containsKey(Restrictable.PRIVATE_BROWSING.name) && bundle.containsKey("no_private_browsing")) { + bundle.putBoolean(Restrictable.PRIVATE_BROWSING.name, !bundle.getBoolean("no_private_browsing")); + } + + if (!bundle.containsKey(Restrictable.CLEAR_HISTORY.name) && bundle.containsKey("no_clear_history")) { + bundle.putBoolean(Restrictable.CLEAR_HISTORY.name, !bundle.getBoolean("no_clear_history")); + } + + if (!bundle.containsKey(Restrictable.ADVANCED_SETTINGS.name) && bundle.containsKey("no_advanced_settings")) { + bundle.putBoolean(Restrictable.ADVANCED_SETTINGS.name, !bundle.getBoolean("no_advanced_settings")); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java new file mode 100644 index 000000000..7c40da734 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java @@ -0,0 +1,31 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +/** + * Interface for classes that Restrictions will delegate to for making decisions. + */ +public interface RestrictionConfiguration { + /** + * Is the user allowed to perform this action? + */ + boolean isAllowed(Restrictable restrictable); + + /** + * Is the user allowed to load the given URL? + */ + boolean canLoadUrl(String url); + + /** + * Is this user restricted in any way? + */ + boolean isRestricted(); + + /** + * Update restrictions if needed. + */ + void update(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java new file mode 100644 index 000000000..26b9a446f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java @@ -0,0 +1,84 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import org.mozilla.gecko.AppConstants; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.RestrictionEntry; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.Map; + +/** + * Broadcast receiver providing supported restrictions to the system. + */ +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) +public class RestrictionProvider extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + if (AppConstants.Versions.preJBMR2) { + // This broadcast does not make any sense prior to Jelly Bean MR2. + return; + } + + final PendingResult result = goAsync(); + + new Thread() { + @Override + public void run() { + final Bundle oldRestrictions = intent.getBundleExtra(Intent.EXTRA_RESTRICTIONS_BUNDLE); + RestrictionCache.migrateRestrictionsIfNeeded(oldRestrictions); + + final Bundle extras = new Bundle(); + + ArrayList<RestrictionEntry> entries = initRestrictions(context, oldRestrictions); + extras.putParcelableArrayList(Intent.EXTRA_RESTRICTIONS_LIST, entries); + + result.setResult(Activity.RESULT_OK, null, extras); + result.finish(); + } + }.start(); + } + + private ArrayList<RestrictionEntry> initRestrictions(Context context, Bundle oldRestrictions) { + ArrayList<RestrictionEntry> entries = new ArrayList<RestrictionEntry>(); + + final Map<Restrictable, Boolean> configuration = RestrictedProfileConfiguration.getConfiguration(); + + for (Restrictable restrictable : configuration.keySet()) { + if (RestrictedProfileConfiguration.shouldHide(restrictable)) { + continue; + } + + RestrictionEntry entry = createRestrictionEntryWithDefaultValue(context, restrictable, + oldRestrictions.getBoolean(restrictable.name, configuration.get(restrictable))); + entries.add(entry); + } + + return entries; + } + + private RestrictionEntry createRestrictionEntryWithDefaultValue(Context context, Restrictable restrictable, boolean defaultValue) { + RestrictionEntry entry = new RestrictionEntry(restrictable.name, defaultValue); + + entry.setTitle(restrictable.getTitle(context)); + + final String description = restrictable.getDescription(context); + if (!TextUtils.isEmpty(description)) { + entry.setDescription(description); + } + + return entry; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java new file mode 100644 index 000000000..0cf680810 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java @@ -0,0 +1,127 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.restrictions; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; + +@RobocopTarget +public class Restrictions { + private static final String LOGTAG = "GeckoRestrictedProfiles"; + + private static RestrictionConfiguration configuration; + + private static RestrictionConfiguration getConfiguration(Context context) { + if (configuration == null) { + configuration = createConfiguration(context); + } + + return configuration; + } + + public static synchronized RestrictionConfiguration createConfiguration(Context context) { + if (configuration != null) { + // This method is synchronized and another thread might already have created the configuration. + return configuration; + } + + if (isGuestProfile(context)) { + return new GuestProfileConfiguration(); + } else if (isRestrictedProfile(context)) { + return new RestrictedProfileConfiguration(context); + } else { + return new DefaultConfiguration(); + } + } + + private static boolean isGuestProfile(Context context) { + if (configuration != null) { + return configuration instanceof GuestProfileConfiguration; + } + + GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface(); + if (geckoInterface != null) { + return geckoInterface.getProfile().inGuestMode(); + } + + return GeckoProfile.get(context).inGuestMode(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + public static boolean isRestrictedProfile(Context context) { + if (configuration != null) { + return configuration instanceof RestrictedProfileConfiguration; + } + + if (Versions.preJBMR2) { + // Early versions don't support restrictions at all + return false; + } + + // The user is on a restricted profile if, and only if, we injected application restrictions during account setup. + return RestrictionCache.hasApplicationRestrictions(context); + } + + public static void update(Context context) { + getConfiguration(context).update(); + } + + private static Restrictable geckoActionToRestriction(int action) { + for (Restrictable rest : Restrictable.values()) { + if (rest.id == action) { + return rest; + } + } + + throw new IllegalArgumentException("Unknown action " + action); + } + + private static boolean canLoadUrl(final Context context, final String url) { + return getConfiguration(context).canLoadUrl(url); + } + + @WrapForJNI(calledFrom = "gecko") + public static boolean isUserRestricted() { + return isUserRestricted(GeckoAppShell.getApplicationContext()); + } + + public static boolean isUserRestricted(final Context context) { + return getConfiguration(context).isRestricted(); + } + + public static boolean isAllowed(final Context context, final Restrictable restrictable) { + return getConfiguration(context).isAllowed(restrictable); + } + + @WrapForJNI(calledFrom = "gecko") + public static boolean isAllowed(int action, String url) { + final Restrictable restrictable; + try { + restrictable = geckoActionToRestriction(action); + } catch (IllegalArgumentException ex) { + // Unknown actions represent a coding error, so we + // refuse the action and log. + Log.e(LOGTAG, "Unknown action " + action + "; check calling code."); + return false; + } + + final Context context = GeckoAppShell.getApplicationContext(); + + if (Restrictable.BROWSE == restrictable) { + return canLoadUrl(context, url); + } else { + return isAllowed(context, restrictable); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java new file mode 100644 index 000000000..d4d9938e2 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java @@ -0,0 +1,304 @@ +/* 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.search; + +import android.net.Uri; +import android.util.Log; +import android.util.Xml; + +import org.mozilla.gecko.util.StringUtils; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Extend this class to add a new search engine to + * the search activity. + */ +public class SearchEngine { + private static final String LOG_TAG = "SearchEngine"; + + private static final String URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; + private static final String URLTYPE_SEARCH_HTML = "text/html"; + + private static final String URL_REL_MOBILE = "mobile"; + + // Parameters copied from nsSearchService.js + private static final String MOZ_PARAM_LOCALE = "\\{moz:locale\\}"; + private static final String MOZ_PARAM_DIST_ID = "\\{moz:distributionID\\}"; + private static final String MOZ_PARAM_OFFICIAL = "\\{moz:official\\}"; + + // Supported OpenSearch parameters + // See http://opensearch.a9.com/spec/1.1/querysyntax/#core + private static final String OS_PARAM_USER_DEFINED = "\\{searchTerms\\??\\}"; + private static final String OS_PARAM_INPUT_ENCODING = "\\{inputEncoding\\??\\}"; + private static final String OS_PARAM_LANGUAGE = "\\{language\\??\\}"; + private static final String OS_PARAM_OUTPUT_ENCODING = "\\{outputEncoding\\??\\}"; + private static final String OS_PARAM_OPTIONAL = "\\{(?:\\w+:)?\\w+\\?\\}"; + + // Boilerplate bookmarklet-style JS for injecting CSS into the + // head of a web page. The actual CSS is inserted at `%s`. + private static final String STYLE_INJECTION_SCRIPT = + "javascript:(function(){" + + "var tag=document.createElement('style');" + + "tag.type='text/css';" + + "document.getElementsByTagName('head')[0].appendChild(tag);" + + "tag.innerText='%s'})();"; + + // The Gecko search identifier. This will be null for engines that don't ship with the locale. + private final String identifier; + + private String shortName; + private String iconURL; + + // Ordered list of preferred results URIs. + private final List<Uri> resultsUris = new ArrayList<Uri>(); + private Uri suggestUri; + + /** + * + * @param in InputStream of open search plugin XML + */ + public SearchEngine(String identifier, InputStream in) throws IOException, XmlPullParserException { + this.identifier = identifier; + + final XmlPullParser parser = Xml.newPullParser(); + parser.setInput(in, null); + parser.nextTag(); + readSearchPlugin(parser); + } + + private void readSearchPlugin(XmlPullParser parser) throws XmlPullParserException, IOException { + if (XmlPullParser.START_TAG != parser.getEventType()) { + throw new XmlPullParserException("Expected start tag: " + parser.getPositionDescription()); + } + + final String name = parser.getName(); + if (!"SearchPlugin".equals(name) && !"OpenSearchDescription".equals(name)) { + throw new XmlPullParserException("Expected <SearchPlugin> or <OpenSearchDescription> as root tag: " + + parser.getPositionDescription()); + } + + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + final String tag = parser.getName(); + if (tag.equals("ShortName")) { + readShortName(parser); + } else if (tag.equals("Url")) { + readUrl(parser); + } else if (tag.equals("Image")) { + readImage(parser); + } else { + skip(parser); + } + } + } + + private void readShortName(XmlPullParser parser) throws IOException, XmlPullParserException { + parser.require(XmlPullParser.START_TAG, null, "ShortName"); + if (parser.next() == XmlPullParser.TEXT) { + shortName = parser.getText(); + parser.nextTag(); + } + } + + private void readUrl(XmlPullParser parser) throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, null, "Url"); + + final String type = parser.getAttributeValue(null, "type"); + final String template = parser.getAttributeValue(null, "template"); + final String rel = parser.getAttributeValue(null, "rel"); + + Uri uri = Uri.parse(template); + + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + final String tag = parser.getName(); + + if (tag.equals("Param")) { + final String name = parser.getAttributeValue(null, "name"); + final String value = parser.getAttributeValue(null, "value"); + uri = uri.buildUpon().appendQueryParameter(name, value).build(); + parser.nextTag(); + // TODO: Support for other tags + //} else if (tag.equals("MozParam")) { + } else { + skip(parser); + } + } + + if (type.equals(URLTYPE_SEARCH_HTML)) { + // Prefer mobile URIs. + if (rel != null && rel.equals(URL_REL_MOBILE)) { + resultsUris.add(0, uri); + } else { + resultsUris.add(uri); + } + } else if (type.equals(URLTYPE_SUGGEST_JSON)) { + suggestUri = uri; + } + } + + private void readImage(XmlPullParser parser) throws XmlPullParserException, IOException { + parser.require(XmlPullParser.START_TAG, null, "Image"); + + // TODO: Use width and height to get a preferred icon URL. + //final int width = Integer.parseInt(parser.getAttributeValue(null, "width")); + //final int height = Integer.parseInt(parser.getAttributeValue(null, "height")); + + if (parser.next() == XmlPullParser.TEXT) { + iconURL = parser.getText(); + parser.nextTag(); + } + } + + private void skip(XmlPullParser parser) throws XmlPullParserException, IOException { + if (parser.getEventType() != XmlPullParser.START_TAG) { + throw new IllegalStateException(); + } + int depth = 1; + while (depth != 0) { + switch (parser.next()) { + case XmlPullParser.END_TAG: + depth--; + break; + case XmlPullParser.START_TAG: + depth++; + break; + } + } + } + + /** + * HACKS! We'll need to replace this with endpoints that return the correct content. + * + * Retrieve a JS snippet, in bookmarklet style, that can be used + * to modify the results page. + */ + public String getInjectableJs() { + final String css; + + if (identifier == null) { + css = ""; + } else if (identifier.equals("bing")) { + css = "#mHeader{display:none}#contentWrapper{margin-top:0}"; + } else if (identifier.equals("google")) { + css = "#sfcnt,#top_nav{display:none}"; + } else if (identifier.equals("yahoo")) { + css = "#nav,#header{display:none}"; + } else { + css = ""; + } + + return String.format(STYLE_INJECTION_SCRIPT, css); + } + + public String getIdentifier() { + return identifier; + } + + public String getName() { + return shortName; + } + + public String getIconURL() { + return iconURL; + } + + /** + * Finds the search query encoded in a given results URL. + * + * @param url Current results URL. + * @return The search query, or an empty string if a query couldn't be found. + */ + public String queryForResultsUrl(String url) { + final Uri resultsUri = getResultsUri(); + final Set<String> names = StringUtils.getQueryParameterNames(resultsUri); + for (String name : names) { + if (resultsUri.getQueryParameter(name).matches(OS_PARAM_USER_DEFINED)) { + return Uri.parse(url).getQueryParameter(name); + } + } + return ""; + } + + /** + * Create a uri string that can be used to fetch the results page. + * + * @param query The user's query. This method will escape and encode the query. + */ + public String resultsUriForQuery(String query) { + final Uri resultsUri = getResultsUri(); + if (resultsUri == null) { + Log.e(LOG_TAG, "No results URL for search engine: " + shortName); + return ""; + } + final String template = Uri.decode(resultsUri.toString()); + return paramSubstitution(template, Uri.encode(query)); + } + + /** + * Create a uri string to fetch autocomplete suggestions. + * + * @param query The user's query. This method will escape and encode the query. + */ + public String getSuggestionTemplate(String query) { + if (suggestUri == null) { + Log.e(LOG_TAG, "No suggestions template for search engine: " + shortName); + return ""; + } + final String template = Uri.decode(suggestUri.toString()); + return paramSubstitution(template, Uri.encode(query)); + } + + /** + * @return Preferred results URI. + */ + private Uri getResultsUri() { + if (resultsUris.isEmpty()) { + return null; + } + return resultsUris.get(0); + } + + /** + * Formats template string with proper parameters. Modeled after + * ParamSubstitution in nsSearchService.js + * + * @param template + * @param query + * @return + */ + private String paramSubstitution(String template, String query) { + final String locale = Locale.getDefault().toString(); + + template = template.replaceAll(MOZ_PARAM_LOCALE, locale); + template = template.replaceAll(MOZ_PARAM_DIST_ID, ""); + template = template.replaceAll(MOZ_PARAM_OFFICIAL, "unofficial"); + + template = template.replaceAll(OS_PARAM_USER_DEFINED, query); + template = template.replaceAll(OS_PARAM_INPUT_ENCODING, "UTF-8"); + + template = template.replaceAll(OS_PARAM_LANGUAGE, locale); + template = template.replaceAll(OS_PARAM_OUTPUT_ENCODING, "UTF-8"); + + // Replace any optional parameters + template = template.replaceAll(OS_PARAM_OPTIONAL, ""); + + return template; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java new file mode 100644 index 000000000..4b33db40a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java @@ -0,0 +1,764 @@ +/* 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.search; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; +import android.support.annotation.UiThread; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; +import android.util.Log; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.util.FileUtils; +import org.mozilla.gecko.util.GeckoJarReader; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.RawResource; +import org.mozilla.gecko.util.ThreadUtils; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Locale; + +/** + * This class is not thread-safe, except where otherwise noted. + * + * This class contains a reference to {@link Context} - DO NOT LEAK! + */ +public class SearchEngineManager implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String LOG_TAG = "GeckoSearchEngineManager"; + + // Gecko pref that defines the name of the default search engine. + private static final String PREF_GECKO_DEFAULT_ENGINE = "browser.search.defaultenginename"; + + // Gecko pref that defines the name of the default searchplugin locale. + private static final String PREF_GECKO_DEFAULT_LOCALE = "distribution.searchplugins.defaultLocale"; + + // Key for shared preference that stores default engine name. + private static final String PREF_DEFAULT_ENGINE_KEY = "search.engines.defaultname"; + + // Key for shared preference that stores search region. + private static final String PREF_REGION_KEY = "search.region"; + + // URL for the geo-ip location service. Keep in sync with "browser.search.geoip.url" perference in Gecko. + private static final String GEOIP_LOCATION_URL = "https://location.services.mozilla.com/v1/country?key=" + AppConstants.MOZ_MOZILLA_API_KEY; + + // This should go through GeckoInterface to get the UA, but the search activity + // doesn't use a GeckoView yet. Until it does, get the UA directly. + private static final String USER_AGENT = HardwareUtils.isTablet() ? + AppConstants.USER_AGENT_FENNEC_TABLET : AppConstants.USER_AGENT_FENNEC_MOBILE; + + private final Context context; + private final Distribution distribution; + @Nullable private volatile SearchEngineCallback changeCallback; + @Nullable private volatile SearchEngine engine; + + // Cached version of default locale included in Gecko chrome manifest. + // This should only be accessed from the background thread. + private String fallbackLocale; + + // Cached version of default locale included in Distribution preferences. + // This should only be accessed from the background thread. + private String distributionLocale; + + public static interface SearchEngineCallback { + public void execute(@Nullable SearchEngine engine); + } + + public SearchEngineManager(Context context, Distribution distribution) { + this.context = context; + this.distribution = distribution; + GeckoSharedPrefs.forApp(context).registerOnSharedPreferenceChangeListener(this); + } + + /** + * Sets a callback to be called when the default engine changes. This can be called from any thread. + * + * @param changeCallback SearchEngineCallback to be called after the search engine + * changed. This will run on the UI thread. + * Note: callback may be called with null engine. + */ + public void setChangeCallback(SearchEngineCallback changeCallback) { + this.changeCallback = changeCallback; + } + + /** + * Perform an action with the user's default search engine. This can be called from any thread. + * + * @param callback The callback to be used with the user's default search engine. The call + * may be sync or async; if the call is async, it will be called on the + * ui thread. + */ + public void getEngine(SearchEngineCallback callback) { + if (engine != null) { + callback.execute(engine); + } else { + getDefaultEngine(callback); + } + } + + /** + * Should be called when the object goes out of scope. + */ + public void unregisterListeners() { + GeckoSharedPrefs.forApp(context).unregisterOnSharedPreferenceChangeListener(this); + } + + private volatile int ignorePreferenceChange = 0; + + @UiThread // according to the docs. + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { + if (!TextUtils.equals(PREF_DEFAULT_ENGINE_KEY, key)) { + return; + } + + if (ignorePreferenceChange > 0) { + ignorePreferenceChange--; + return; + } + + getDefaultEngine(changeCallback); + } + + /** + * Runs a SearchEngineCallback on the main thread. + */ + private void runCallback(final SearchEngine engine, @Nullable final SearchEngineCallback callback) { + ThreadUtils.postToUiThread(new RunCallbackUiThreadRunnable(this, engine, callback)); + } + + // Static is not strictly necessary but the outer class has a reference to Context so we should GC ASAP. + private static class RunCallbackUiThreadRunnable implements Runnable { + private final WeakReference<SearchEngineManager> searchEngineManagerWeakReference; + private final SearchEngine searchEngine; + private final SearchEngineCallback callback; + + public RunCallbackUiThreadRunnable(final SearchEngineManager searchEngineManager, final SearchEngine searchEngine, + final SearchEngineCallback callback) { + this.searchEngineManagerWeakReference = new WeakReference<>(searchEngineManager); + this.searchEngine = searchEngine; + this.callback = callback; + } + + @UiThread + @Override + public void run() { + final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get(); + if (searchEngineManager == null) { + return; + } + + // Cache engine for future calls to getEngine. + searchEngineManager.engine = searchEngine; + if (callback != null) { + callback.execute(searchEngine); + } + + } + } + + /** + * This method finds and creates the default search engine. It will first look for + * the default engine name, then create the engine from that name. + * + * To find the default engine name, we first look in shared preferences, then + * the distribution (if one exists), and finally fall back to the localized default. + * + * @param callback SearchEngineCallback to be called after successfully looking + * up the search engine. This will run on the UI thread. + * Note: callback may be called with null engine. + */ + private void getDefaultEngine(final SearchEngineCallback callback) { + // This runnable is posted to the background thread. + distribution.addOnDistributionReadyCallback(new GetDefaultEngineDistributionCallbacks(this, callback)); + } + + // Static is not strictly necessary but the outer class contains a reference to Context so we should GC ASAP. + private static class GetDefaultEngineDistributionCallbacks implements Distribution.ReadyCallback { + private final WeakReference<SearchEngineManager> searchEngineManagerWeakReference; + private final SearchEngineCallback callback; + + public GetDefaultEngineDistributionCallbacks(final SearchEngineManager searchEngineManager, + final SearchEngineCallback callback) { + this.searchEngineManagerWeakReference = new WeakReference<>(searchEngineManager); + this.callback = callback; + } + + @Override + public void distributionNotFound() { + defaultBehavior(); + } + + @Override + public void distributionFound(Distribution distribution) { + defaultBehavior(); + } + + @Override + public void distributionArrivedLate(Distribution distribution) { + final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get(); + if (searchEngineManager == null) { + return; + } + + // Let's see if there's a name in the distro. + // If so, just this once we'll override the saved value. + final String name = searchEngineManager.getDefaultEngineNameFromDistribution(); + + if (name == null) { + return; + } + + // Store the default engine name for the future. + // Increment an 'ignore' counter so that this preference change + // won't cause getDefaultEngine to be called again. + searchEngineManager.ignorePreferenceChange++; + GeckoSharedPrefs.forApp(searchEngineManager.context) + .edit() + .putString(PREF_DEFAULT_ENGINE_KEY, name) + .apply(); + + final SearchEngine engine = searchEngineManager.createEngineFromName(name); + searchEngineManager.runCallback(engine, callback); + } + + @WorkerThread // calling methods are @WorkerThread + private void defaultBehavior() { + final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get(); + if (searchEngineManager == null) { + return; + } + + // First look for a default name stored in shared preferences. + String name = GeckoSharedPrefs.forApp(searchEngineManager.context).getString(PREF_DEFAULT_ENGINE_KEY, null); + + // Check for a region stored in shared preferences. If we don't have a region, + // we should force a recheck of the default engine. + String region = GeckoSharedPrefs.forApp(searchEngineManager.context).getString(PREF_REGION_KEY, null); + + if (name != null && region != null) { + Log.d(LOG_TAG, "Found default engine name in SharedPreferences: " + name); + } else { + // First, look for the default search engine in a distribution. + name = searchEngineManager.getDefaultEngineNameFromDistribution(); + if (name == null) { + // Otherwise, get the default engine that we ship. + name = searchEngineManager.getDefaultEngineNameFromLocale(); + } + + // Store the default engine name for the future. + // Increment an 'ignore' counter so that this preference change + // won't cause getDefaultEngine to be called again. + searchEngineManager.ignorePreferenceChange++; + GeckoSharedPrefs.forApp(searchEngineManager.context) + .edit() + .putString(PREF_DEFAULT_ENGINE_KEY, name) + .apply(); + } + + final SearchEngine engine = searchEngineManager.createEngineFromName(name); + searchEngineManager.runCallback(engine, callback); + } + } + + /** + * Looks for a default search engine included in a distribution. + * This method must be called after the distribution is ready. + * + * @return search engine name. + */ + private String getDefaultEngineNameFromDistribution() { + if (!distribution.exists()) { + return null; + } + + final File prefFile = distribution.getDistributionFile("preferences.json"); + if (prefFile == null) { + return null; + } + + try { + final JSONObject all = FileUtils.readJSONObjectFromFile(prefFile); + + // First, look for a default locale specified by the distribution. + if (all.has("Preferences")) { + final JSONObject prefs = all.getJSONObject("Preferences"); + if (prefs.has(PREF_GECKO_DEFAULT_LOCALE)) { + Log.d(LOG_TAG, "Found default searchplugin locale in distribution Preferences."); + distributionLocale = prefs.getString(PREF_GECKO_DEFAULT_LOCALE); + } + } + + // Then, check to see if there's a locale-specific default engine override. + final String languageTag = Locales.getLanguageTag(Locale.getDefault()); + final String overridesKey = "LocalizablePreferences." + languageTag; + if (all.has(overridesKey)) { + final JSONObject overridePrefs = all.getJSONObject(overridesKey); + if (overridePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) { + Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences override."); + return overridePrefs.getString(PREF_GECKO_DEFAULT_ENGINE); + } + } + + // Next, check to see if there's a non-override default engine pref. + if (all.has("LocalizablePreferences")) { + final JSONObject localizablePrefs = all.getJSONObject("LocalizablePreferences"); + if (localizablePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) { + Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences."); + return localizablePrefs.getString(PREF_GECKO_DEFAULT_ENGINE); + } + } + } catch (IOException e) { + Log.e(LOG_TAG, "Error getting search engine name from preferences.json", e); + } catch (JSONException e) { + Log.e(LOG_TAG, "Error parsing preferences.json", e); + } + return null; + } + + /** + * Helper function for converting an InputStream to a String. + * @param is InputStream you want to convert to a String + * + * @return String containing the data + */ + private String getHttpResponse(HttpURLConnection conn) { + InputStream is = null; + try { + is = new BufferedInputStream(conn.getInputStream()); + return new java.util.Scanner(is).useDelimiter("\\A").next(); + } catch (Exception e) { + return ""; + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + Log.e(LOG_TAG, "Error closing InputStream", e); + } + } + } + } + + /** + * Gets the country code based on the current IP, using the Mozilla Location Service. + * We cache the country code in a shared preference, so we only fetch from the network + * once. + * + * @return String containing the country code + */ + private String fetchCountryCode() { + // First, we look to see if we have a cached code. + final String region = GeckoSharedPrefs.forApp(context).getString(PREF_REGION_KEY, null); + if (region != null) { + return region; + } + + // Since we didn't have a cached code, we need to fetch a code from the service. + try { + String responseText = null; + + URL url = new URL(GEOIP_LOCATION_URL); + HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); + try { + // POST an empty JSON object. + final String message = "{}"; + + urlConnection.setDoOutput(true); + urlConnection.setConnectTimeout(10000); + urlConnection.setReadTimeout(10000); + urlConnection.setRequestMethod("POST"); + urlConnection.setRequestProperty("User-Agent", USER_AGENT); + urlConnection.setRequestProperty("Content-Type", "application/json"); + urlConnection.setFixedLengthStreamingMode(message.getBytes().length); + + final OutputStream out = urlConnection.getOutputStream(); + out.write(message.getBytes()); + out.close(); + + responseText = getHttpResponse(urlConnection); + } finally { + urlConnection.disconnect(); + } + + if (responseText == null) { + Log.e(LOG_TAG, "Country code fetch failed"); + return null; + } + + // Extract the country code and save it for later in a cache. + final JSONObject response = new JSONObject(responseText); + return response.optString("country_code", null); + } catch (Exception e) { + Log.e(LOG_TAG, "Country code fetch failed", e); + } + + return null; + } + + /** + * Looks for the default search engine shipped in the locale. + * + * @return search engine name. + */ + private String getDefaultEngineNameFromLocale() { + try { + final JSONObject browsersearch = new JSONObject(RawResource.getAsString(context, R.raw.browsersearch)); + + // Get the region used to fence search engines. + String region = fetchCountryCode(); + + // Store the result, even if it's empty. If we fail to get a region, we never + // try to get it again, and we will always fallback to the non-region engine. + GeckoSharedPrefs.forApp(context) + .edit() + .putString(PREF_REGION_KEY, (region == null ? "" : region)) + .apply(); + + if (region != null) { + if (browsersearch.has("regions")) { + final JSONObject regions = browsersearch.getJSONObject("regions"); + if (regions.has(region)) { + final JSONObject regionData = regions.getJSONObject(region); + Log.d(LOG_TAG, "Found region-specific default engine name in browsersearch.json."); + return regionData.getString("default"); + } + } + } + + // Either we have no geoip region, or we didn't find the right region and we are falling back to the default. + if (browsersearch.has("default")) { + Log.d(LOG_TAG, "Found default engine name in browsersearch.json."); + return browsersearch.getString("default"); + } + } catch (IOException e) { + Log.e(LOG_TAG, "Error getting search engine name from browsersearch.json", e); + } catch (JSONException e) { + Log.e(LOG_TAG, "Error parsing browsersearch.json", e); + } + return null; + } + + /** + * Creates a SearchEngine instance from an engine name. + * + * To create the engine, we first try to find the search plugin in the distribution + * (if one exists), followed by the localized plugins we ship with the browser, and + * then finally third-party plugins that are installed in the profile directory. + * + * This method must be called after the distribution is ready. + * + * @param name The search engine name (e.g. "Google" or "Amazon.com") + * @return SearchEngine instance for name. + */ + private SearchEngine createEngineFromName(String name) { + // First, look in the distribution. + SearchEngine engine = createEngineFromDistribution(name); + + // Second, look in the jar for plugins shipped with the locale. + if (engine == null) { + engine = createEngineFromLocale(name); + } + + // Finally, look in the profile for third-party plugins. + if (engine == null) { + engine = createEngineFromProfile(name); + } + + if (engine == null) { + Log.e(LOG_TAG, "Could not create search engine from name: " + name); + } + + return engine; + } + + /** + * Creates a SearchEngine instance for a distribution search plugin. + * + * This method iterates through the distribution searchplugins directory, + * creating SearchEngine instances until it finds one with the right name. + * + * This method must be called after the distribution is ready. + * + * @param name Search engine name. + * @return SearchEngine instance for name. + */ + private SearchEngine createEngineFromDistribution(String name) { + if (!distribution.exists()) { + return null; + } + + final File pluginsDir = distribution.getDistributionFile("searchplugins"); + if (pluginsDir == null) { + return null; + } + + // Collect an array of files to scan using the same approach as + // DirectoryService._appendDistroSearchDirs which states: + // Common engines are loaded for all locales. If there is no locale directory for + // the current locale, there is a pref: "distribution.searchplugins.defaultLocale", + // which specifies a default locale to use. + ArrayList<File> files = new ArrayList<>(); + + // Load files from the common folder first + final File[] commonFiles = (new File(pluginsDir, "common")).listFiles(); + if (commonFiles != null) { + Collections.addAll(files, commonFiles); + } + + // Next, check to see if there's a locale-specific override. + final File localeDir = new File(pluginsDir, "locale"); + if (localeDir != null) { + final String languageTag = Locales.getLanguageTag(Locale.getDefault()); + final File[] localeFiles = (new File(localeDir, languageTag)).listFiles(); + if (localeFiles != null) { + Collections.addAll(files, localeFiles); + } else { + // We didn't append the locale dir - try the default one. + if (distributionLocale != null) { + final File[] defaultLocaleFiles = (new File(localeDir, distributionLocale)).listFiles(); + if (defaultLocaleFiles != null) { + Collections.addAll(files, defaultLocaleFiles); + } + } + } + } + + if (files.isEmpty()) { + Log.e(LOG_TAG, "Could not find search plugin files in distribution directory"); + return null; + } + + return createEngineFromFileList(files.toArray(new File[files.size()]), name); + } + + /** + * Creates a SearchEngine instance for a search plugin shipped in the locale. + * + * This method reads the list of search plugin file names from list.txt, then + * iterates through the files, creating SearchEngine instances until it finds one + * with the right name. Unfortunately, we need to do this because there is no + * other way to map the search engine "name" to the file for the search plugin. + * + * @param name Search engine name. + * @return SearchEngine instance for name. + */ + private SearchEngine createEngineFromLocale(String name) { + final InputStream in = getInputStreamFromSearchPluginsJar("list.txt"); + if (in == null) { + return null; + } + final BufferedReader br = getBufferedReader(in); + + try { + String identifier; + while ((identifier = br.readLine()) != null) { + final InputStream pluginIn = getInputStreamFromSearchPluginsJar(identifier + ".xml"); + // pluginIn can be null if the xml file doesn't exist which + // can happen with :hidden plugins + if (pluginIn != null) { + final SearchEngine engine = createEngineFromInputStream(identifier, pluginIn); + if (engine != null && engine.getName().equals(name)) { + return engine; + } + } + } + } catch (Exception e) { + Log.e(LOG_TAG, "Error creating shipped search engine from name: " + name, e); + } finally { + try { + br.close(); + } catch (IOException e) { + // Ignore. + } + } + return null; + } + + /** + * Creates a SearchEngine instance for a search plugin in the profile directory. + * + * This method iterates through the profile searchplugins directory, creating + * SearchEngine instances until it finds one with the right name. + * + * @param name Search engine name. + * @return SearchEngine instance for name. + */ + private SearchEngine createEngineFromProfile(String name) { + final File pluginsDir = GeckoProfile.get(context).getFile("searchplugins"); + if (pluginsDir == null) { + return null; + } + + final File[] files = pluginsDir.listFiles(); + if (files == null) { + Log.e(LOG_TAG, "Could not find search plugin files in profile directory"); + return null; + } + return createEngineFromFileList(files, name); + } + + /** + * This method iterates through an array of search plugin files, creating + * SearchEngine instances until it finds one with the right name. + * + * @param files Array of search plugin files. Should not be null. + * @param name Search engine name. + * @return SearchEngine instance for name. + */ + private SearchEngine createEngineFromFileList(File[] files, String name) { + for (int i = 0; i < files.length; i++) { + try { + final FileInputStream fis = new FileInputStream(files[i]); + final SearchEngine engine = createEngineFromInputStream(null, fis); + if (engine != null && engine.getName().equals(name)) { + return engine; + } + } catch (IOException e) { + Log.e(LOG_TAG, "Error creating search engine from name: " + name, e); + } + } + return null; + } + + /** + * Creates a SearchEngine instance from an InputStream. + * + * This method closes the stream after it is done reading it. + * + * @param identifier Seach engine identifier. This only exists for search engines that + * ship with the default set of engines in the locale. + * @param in InputStream for search plugin XML file. + * @return SearchEngine instance. + */ + private SearchEngine createEngineFromInputStream(String identifier, InputStream in) { + try { + try { + return new SearchEngine(identifier, in); + } finally { + in.close(); + } + } catch (Exception e) { + Log.e(LOG_TAG, "Exception creating search engine", e); + } + + return null; + } + + /** + * Reads a file from the searchplugins directory in the Gecko jar. + * + * @param fileName name of the file to read. + * @return InputStream for file. + */ + private InputStream getInputStreamFromSearchPluginsJar(String fileName) { + final Locale locale = Locale.getDefault(); + + // First, try a file path for the full locale. + final String languageTag = Locales.getLanguageTag(locale); + String url = getSearchPluginsJarURL(context, languageTag, fileName); + + InputStream in = GeckoJarReader.getStream(context, url); + if (in != null) { + return in; + } + + // If that doesn't work, try a file path for just the language. + final String language = Locales.getLanguage(locale); + if (!languageTag.equals(language)) { + url = getSearchPluginsJarURL(context, language, fileName); + in = GeckoJarReader.getStream(context, url); + if (in != null) { + return in; + } + } + + // Finally, fall back to default locale defined in chrome registry. + url = getSearchPluginsJarURL(context, getFallbackLocale(), fileName); + return GeckoJarReader.getStream(context, url); + } + + /** + * Finds a fallback locale in the Gecko chrome registry. If a locale is declared + * here, we should be guaranteed to find a searchplugins directory for it. + * + * This method should only be accessed from the background thread. + */ + private String getFallbackLocale() { + if (fallbackLocale != null) { + return fallbackLocale; + } + + final InputStream in = GeckoJarReader.getStream( + context, GeckoJarReader.getJarURL(context, "chrome/chrome.manifest")); + if (in == null) { + return null; + } + final BufferedReader br = getBufferedReader(in); + + try { + String line; + while ((line = br.readLine()) != null) { + // We're looking for a line like "locale global en-US en-US/locale/en-US/global/" + // https://developer.mozilla.org/en/docs/Chrome_Registration#locale + if (line.startsWith("locale global ")) { + fallbackLocale = line.split(" ", 4)[2]; + break; + } + } + } catch (IOException e) { + Log.e(LOG_TAG, "Error reading fallback locale from chrome registry", e); + } finally { + try { + br.close(); + } catch (IOException e) { + // Ignore. + } + } + return fallbackLocale; + } + + /** + * Gets the jar URL for a file in the searchplugins directory. + * + * @param locale String representing the Gecko locale (e.g. "en-US"). + * @param fileName The name of the file to read. + * @return URL for jar file. + */ + private static String getSearchPluginsJarURL(Context context, String locale, String fileName) { + final String path = "chrome/" + locale + "/locale/" + locale + "/browser/searchplugins/" + fileName; + return GeckoJarReader.getJarURL(context, path); + } + + private BufferedReader getBufferedReader(InputStream in) { + try { + return new BufferedReader(new InputStreamReader(in, "UTF-8")); + } catch (UnsupportedEncodingException e) { + // Cannot happen. + return null; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java new file mode 100644 index 000000000..667eb8f6c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java @@ -0,0 +1,357 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.tabqueue; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.support.v4.app.NotificationCompat; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class TabQueueHelper { + private static final String LOGTAG = "Gecko" + TabQueueHelper.class.getSimpleName(); + + // Disable Tab Queue for API level 10 (GB) - Bug 1206055 + public static final boolean TAB_QUEUE_ENABLED = true; + + public static final String FILE_NAME = "tab_queue_url_list.json"; + public static final String LOAD_URLS_ACTION = "TAB_QUEUE_LOAD_URLS_ACTION"; + public static final int TAB_QUEUE_NOTIFICATION_ID = R.id.tabQueueNotification; + + public static final String PREF_TAB_QUEUE_COUNT = "tab_queue_count"; + public static final String PREF_TAB_QUEUE_LAUNCHES = "tab_queue_launches"; + public static final String PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN = "tab_queue_times_prompt_shown"; + + public static final int MAX_TIMES_TO_SHOW_PROMPT = 3; + public static final int EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT = 3; + + // result codes for returning from the prompt + public static final int TAB_QUEUE_YES = 201; + public static final int TAB_QUEUE_NO = 202; + + /** + * Checks if the specified context can draw on top of other apps. As of API level 23, an app + * cannot draw on top of other apps unless it declares the SYSTEM_ALERT_WINDOW permission in + * its manifest, AND the user specifically grants the app this capability. + * + * @return true if the specified context can draw on top of other apps, false otherwise. + */ + public static boolean canDrawOverlays(Context context) { + if (AppConstants.Versions.preMarshmallow) { + return true; // We got the permission at install time. + } + + // It would be nice to just use Settings.canDrawOverlays() - but this helper is buggy for + // apps using sharedUserId (See bug 1244722). + // Instead we'll add and remove an invisible view. If this is successful then we seem to + // have permission to draw overlays. + + View view = new View(context); + view.setVisibility(View.INVISIBLE); + + WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams( + 1, 1, + WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + PixelFormat.TRANSLUCENT); + + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + + try { + windowManager.addView(view, layoutParams); + windowManager.removeView(view); + return true; + } catch (final SecurityException | WindowManager.BadTokenException e) { + return false; + } + } + + /** + * Check if we should show the tab queue prompt + * + * @param context + * @return true if we should display the prompt, false if not. + */ + public static boolean shouldShowTabQueuePrompt(Context context) { + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + + int numberOfTimesTabQueuePromptSeen = prefs.getInt(PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0); + + // Exit early if the feature is already enabled or the user has seen the + // prompt more than MAX_TIMES_TO_SHOW_PROMPT times. + if (isTabQueueEnabled(prefs) || numberOfTimesTabQueuePromptSeen >= MAX_TIMES_TO_SHOW_PROMPT) { + return false; + } + + final int viewActionIntentLaunches = prefs.getInt(PREF_TAB_QUEUE_LAUNCHES, 0) + 1; + if (viewActionIntentLaunches < EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) { + // Allow a few external links to open before we prompt the user. + prefs.edit().putInt(PREF_TAB_QUEUE_LAUNCHES, viewActionIntentLaunches).apply(); + } else if (viewActionIntentLaunches == EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) { + // Reset to avoid repeatedly showing the prompt if the user doesn't interact with it and + // we get more external VIEW action intents in. + final SharedPreferences.Editor editor = prefs.edit(); + editor.remove(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES); + + int timesPromptShown = prefs.getInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0) + 1; + editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, timesPromptShown); + editor.apply(); + + // Show the prompt + return true; + } + + return false; + } + + /** + * Reads file and converts any content to JSON, adds passed in URL to the data and writes back to the file, + * creating the file if it doesn't already exist. This should not be run on the UI thread. + * + * @param profile + * @param url URL to add + * @param filename filename to add URL to + * @return the number of tabs currently queued + */ + public static int queueURL(final GeckoProfile profile, final String url, final String filename) { + ThreadUtils.assertNotOnUiThread(); + + JSONArray jsonArray = profile.readJSONArrayFromFile(filename); + + jsonArray.put(url); + + profile.writeFile(filename, jsonArray.toString()); + + return jsonArray.length(); + } + + /** + * Remove a url from the file, if it exists. + * If the url exists multiple times, all instances of it will be removed. + * This should not be run on the UI thread. + * + * @param context + * @param urlToRemove URL to remove + * @param filename filename to remove URL from + * @return the number of queued urls + */ + public static int removeURLFromFile(final Context context, final String urlToRemove, final String filename) { + ThreadUtils.assertNotOnUiThread(); + + final GeckoProfile profile = GeckoProfile.get(context); + + JSONArray jsonArray = profile.readJSONArrayFromFile(filename); + JSONArray newArray = new JSONArray(); + String url; + + // Since JSONArray.remove was only added in API 19, we have to use two arrays in order to remove. + for (int i = 0; i < jsonArray.length(); i++) { + try { + url = jsonArray.getString(i); + } catch (JSONException e) { + url = ""; + } + if (!TextUtils.isEmpty(url) && !urlToRemove.equals(url)) { + newArray.put(url); + } + } + + profile.writeFile(filename, newArray.toString()); + + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + prefs.edit().putInt(PREF_TAB_QUEUE_COUNT, newArray.length()).apply(); + + return newArray.length(); + } + + /** + * Get up to eight of the last queued URLs for displaying in the notification. + */ + public static List<String> getLastURLs(final Context context, final String filename) { + final GeckoProfile profile = GeckoProfile.get(context); + final JSONArray jsonArray = profile.readJSONArrayFromFile(filename); + final List<String> urls = new ArrayList<>(8); + + for (int i = 0; i < 8; i++) { + try { + urls.add(jsonArray.getString(i)); + } catch (JSONException e) { + Log.w(LOGTAG, "Unable to parse URL from tab queue array", e); + } + } + + return urls; + } + + /** + * Displays a notification showing the total number of tabs queue. If there is already a notification displayed, it + * will be replaced. + * + * @param context + * @param tabsQueued + */ + public static void showNotification(final Context context, final int tabsQueued, final List<String> urls) { + ThreadUtils.assertNotOnUiThread(); + + Intent resultIntent = new Intent(); + resultIntent.setClassName(context, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + resultIntent.setAction(TabQueueHelper.LOAD_URLS_ACTION); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, resultIntent, PendingIntent.FLAG_CANCEL_CURRENT); + + final String text; + final Resources resources = context.getResources(); + if (tabsQueued == 1) { + text = resources.getString(R.string.tab_queue_notification_text_singular); + } else { + text = resources.getString(R.string.tab_queue_notification_text_plural, tabsQueued); + } + + NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); + inboxStyle.setBigContentTitle(text); + for (String url : urls) { + inboxStyle.addLine(url); + } + inboxStyle.setSummaryText(resources.getString(R.string.tab_queue_notification_title)); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentTitle(text) + .setContentText(resources.getString(R.string.tab_queue_notification_title)) + .setStyle(inboxStyle) + .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange)) + .setNumber(tabsQueued) + .setContentIntent(pendingIntent); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(TabQueueHelper.TAB_QUEUE_NOTIFICATION_ID, builder.build()); + } + + public static boolean shouldOpenTabQueueUrls(final Context context) { + ThreadUtils.assertNotOnUiThread(); + + // TODO: Use profile shared prefs when bug 1147925 gets fixed. + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + + int tabsQueued = prefs.getInt(PREF_TAB_QUEUE_COUNT, 0); + + return isTabQueueEnabled(prefs) && tabsQueued > 0; + } + + public static int getTabQueueLength(final Context context) { + ThreadUtils.assertNotOnUiThread(); + + // TODO: Use profile shared prefs when bug 1147925 gets fixed. + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + return prefs.getInt(PREF_TAB_QUEUE_COUNT, 0); + } + + public static void openQueuedUrls(final Context context, final GeckoProfile profile, final String filename, boolean shouldPerformJavaScriptCallback) { + ThreadUtils.assertNotOnUiThread(); + + removeNotification(context); + + // exit early if we don't have any tabs queued + if (getTabQueueLength(context) < 1) { + return; + } + + JSONArray jsonArray = profile.readJSONArrayFromFile(filename); + + if (jsonArray.length() > 0) { + JSONObject data = new JSONObject(); + try { + data.put("urls", jsonArray); + data.put("shouldNotifyTabsOpenedToJava", shouldPerformJavaScriptCallback); + GeckoAppShell.notifyObservers("Tabs:OpenMultiple", data.toString()); + } catch (JSONException e) { + // Don't exit early as we perform cleanup at the end of this function. + Log.e(LOGTAG, "Error sending tab queue data", e); + } + } + + try { + profile.deleteFileFromProfileDir(filename); + } catch (IllegalArgumentException e) { + Log.e(LOGTAG, "Error deleting Tab Queue data file.", e); + } + + // TODO: Use profile shared prefs when bug 1147925 gets fixed. + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + prefs.edit().remove(PREF_TAB_QUEUE_COUNT).apply(); + } + + protected static void removeNotification(Context context) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(TAB_QUEUE_NOTIFICATION_ID); + } + + public static boolean processTabQueuePromptResponse(int resultCode, Context context) { + final SharedPreferences prefs = GeckoSharedPrefs.forApp(context); + final SharedPreferences.Editor editor = prefs.edit(); + + switch (resultCode) { + case TAB_QUEUE_YES: + editor.putBoolean(GeckoPreferences.PREFS_TAB_QUEUE, true); + + // By making this one more than EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT we ensure the prompt + // will never show again without having to keep track of an extra pref. + editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES, + TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1); + break; + + case TAB_QUEUE_NO: + // The user clicked the 'no' button, so let's make sure the user never sees the prompt again by + // maxing out the pref used to count the VIEW action intents received and times they've seen the prompt. + + editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES, + TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1); + + editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, + TabQueueHelper.MAX_TIMES_TO_SHOW_PROMPT + 1); + break; + + default: + // We shouldn't ever get here. + Log.w(LOGTAG, "Unrecognized result code received from the tab queue prompt: " + resultCode); + } + + editor.apply(); + + return resultCode == TAB_QUEUE_YES; + } + + public static boolean isTabQueueEnabled(Context context) { + return isTabQueueEnabled(GeckoSharedPrefs.forApp(context)); + } + + public static boolean isTabQueueEnabled(SharedPreferences prefs) { + return prefs.getBoolean(GeckoPreferences.PREFS_TAB_QUEUE, false); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java new file mode 100644 index 000000000..ead16ccba --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java @@ -0,0 +1,215 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.tabqueue; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.provider.Settings; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Toast; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +public class TabQueuePrompt extends Locales.LocaleAwareActivity { + public static final String LOGTAG = "Gecko" + TabQueuePrompt.class.getSimpleName(); + + private static final int SETTINGS_REQUEST_CODE = 1; + + // Flag set during animation to prevent animation multiple-start. + private boolean isAnimating; + + private View containerView; + private View buttonContainer; + private View enabledConfirmation; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + showTabQueueEnablePrompt(); + } + + private void showTabQueueEnablePrompt() { + setContentView(R.layout.tab_queue_prompt); + + final View okButton = findViewById(R.id.ok_button); + okButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onConfirmButtonPressed(); + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes"); + } + }); + findViewById(R.id.cancel_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_no"); + setResult(TabQueueHelper.TAB_QUEUE_NO); + finish(); + } + }); + final View settingsButton = findViewById(R.id.settings_button); + settingsButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onSettingsButtonPressed(); + } + }); + + final View tipView = findViewById(R.id.tip_text); + final View settingsPermitView = findViewById(R.id.settings_permit_text); + + if (TabQueueHelper.canDrawOverlays(this)) { + okButton.setVisibility(View.VISIBLE); + settingsButton.setVisibility(View.GONE); + tipView.setVisibility(View.VISIBLE); + settingsPermitView.setVisibility(View.GONE); + } else { + okButton.setVisibility(View.GONE); + settingsButton.setVisibility(View.VISIBLE); + tipView.setVisibility(View.GONE); + settingsPermitView.setVisibility(View.VISIBLE); + } + + containerView = findViewById(R.id.tab_queue_container); + buttonContainer = findViewById(R.id.button_container); + enabledConfirmation = findViewById(R.id.enabled_confirmation); + + containerView.setTranslationY(500); + containerView.setAlpha(0); + + final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0); + translateAnimator.setDuration(400); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1); + alphaAnimator.setStartDelay(200); + alphaAnimator.setDuration(600); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(alphaAnimator, translateAnimator); + set.setStartDelay(400); + + set.start(); + } + + @Override + public void finish() { + super.finish(); + + // Don't perform an activity-dismiss animation. + overridePendingTransition(0, 0); + } + + private void onConfirmButtonPressed() { + enabledConfirmation.setVisibility(View.VISIBLE); + enabledConfirmation.setAlpha(0); + + final Animator buttonsAlphaAnimator = ObjectAnimator.ofFloat(buttonContainer, "alpha", 0); + buttonsAlphaAnimator.setDuration(300); + + final Animator messagesAlphaAnimator = ObjectAnimator.ofFloat(enabledConfirmation, "alpha", 1); + messagesAlphaAnimator.setDuration(300); + messagesAlphaAnimator.setStartDelay(200); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(buttonsAlphaAnimator, messagesAlphaAnimator); + + set.addListener(new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(Animator animation) { + + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + slideOut(); + setResult(TabQueueHelper.TAB_QUEUE_YES); + } + }, 1000); + } + }); + + set.start(); + } + + @TargetApi(Build.VERSION_CODES.M) + private void onSettingsButtonPressed() { + Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); + intent.setData(Uri.parse("package:" + getPackageName())); + startActivityForResult(intent, SETTINGS_REQUEST_CODE); + + Toast.makeText(this, R.string.tab_queue_prompt_permit_drawing_over_apps, Toast.LENGTH_LONG).show(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode != SETTINGS_REQUEST_CODE) { + return; + } + + if (TabQueueHelper.canDrawOverlays(this)) { + // User granted the permission in Android's settings. + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes"); + + setResult(TabQueueHelper.TAB_QUEUE_YES); + finish(); + } + } + + /** + * Slide the overlay down off the screen and destroy it. + */ + private void slideOut() { + if (isAnimating) { + return; + } + + isAnimating = true; + + ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight()); + animator.addListener(new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(Animator animation) { + finish(); + } + + }); + animator.start(); + } + + /** + * Close the dialog if back is pressed. + */ + @Override + public void onBackPressed() { + slideOut(); + } + + /** + * Close the dialog if the anything that isn't a button is tapped. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + slideOut(); + return true; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java new file mode 100644 index 000000000..ebb1bd761 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java @@ -0,0 +1,342 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.tabqueue; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.preferences.GeckoPreferences; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.provider.Settings; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.text.TextUtils; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; +import org.mozilla.gecko.mozglue.SafeIntent; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +/** + * On launch this Service displays a View over the currently running process with an action to open the url in Fennec + * immediately. If the user takes no action, allowing the runnable to be processed after the specified + * timeout (TOAST_TIMEOUT), the url is added to a file which is then read in Fennec on next launch, this allows the + * user to quickly queue urls to open without having to open Fennec each time. If the Service receives an Intent whilst + * the created View is still active, the old url is immediately processed and the View is re-purposed with the new + * Intent data. + * <p/> + * The SYSTEM_ALERT_WINDOW permission is used to allow us to insert a View from this Service which responds to user + * interaction, whilst still allowing whatever is in the background to be seen and interacted with. + * <p/> + * Using an Activity to do this doesn't seem to work as there's an issue to do with the native android intent resolver + * dialog not being hidden when the toast is shown. Using an IntentService instead of a Service doesn't work as + * each new Intent received kicks off the IntentService lifecycle anew which means that a new View is created each time, + * meaning that we can't quickly queue the current data and re-purpose the View. The asynchronous nature of the + * IntentService is another prohibitive factor. + * <p/> + * General approach taken is similar to the FB chat heads functionality: + * http://stackoverflow.com/questions/15975988/what-apis-in-android-is-facebook-using-to-create-chat-heads + */ +public class TabQueueService extends Service { + private static final String LOGTAG = "Gecko" + TabQueueService.class.getSimpleName(); + + private static final long TOAST_TIMEOUT = 3000; + private static final long TOAST_DOUBLE_TAP_TIMEOUT_MILLIS = 6000; + + private WindowManager windowManager; + private View toastLayout; + private Button openNowButton; + private Handler tabQueueHandler; + private WindowManager.LayoutParams toastLayoutParams; + private volatile StopServiceRunnable stopServiceRunnable; + private HandlerThread handlerThread; + private ExecutorService executorService; + + @Override + public IBinder onBind(Intent intent) { + // Not used + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + executorService = Executors.newSingleThreadExecutor(); + + handlerThread = new HandlerThread("TabQueueHandlerThread"); + handlerThread.start(); + tabQueueHandler = new Handler(handlerThread.getLooper()); + + windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); + + LayoutInflater layoutInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); + toastLayout = layoutInflater.inflate(R.layout.tab_queue_toast, null); + + final Resources resources = getResources(); + + TextView messageView = (TextView) toastLayout.findViewById(R.id.toast_message); + messageView.setText(resources.getText(R.string.tab_queue_toast_message)); + + openNowButton = (Button) toastLayout.findViewById(R.id.toast_button); + openNowButton.setText(resources.getText(R.string.tab_queue_toast_action)); + + toastLayoutParams = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT); + + toastLayoutParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + // If this is a redelivery then lets bypass the entire double tap to open now code as that's a big can of worms, + // we also don't expect redeliveries because of the short time window associated with this feature. + if (flags != START_FLAG_REDELIVERY) { + final Context applicationContext = getApplicationContext(); + final SharedPreferences sharedPreferences = GeckoSharedPrefs.forApp(applicationContext); + + final String lastUrl = sharedPreferences.getString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, ""); + + final SafeIntent safeIntent = new SafeIntent(intent); + final String intentUrl = safeIntent.getDataString(); + + final long lastRunTime = sharedPreferences.getLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, 0); + final boolean isWithinDoubleTapTimeLimit = System.currentTimeMillis() - lastRunTime < TOAST_DOUBLE_TAP_TIMEOUT_MILLIS; + + if (!TextUtils.isEmpty(lastUrl) && lastUrl.equals(intentUrl) && isWithinDoubleTapTimeLimit) { + // Background thread because we could do some file IO if we have to remove a url from the list. + tabQueueHandler.post(new Runnable() { + @Override + public void run() { + // If there is a runnable around, that means that the previous process hasn't yet completed, so + // we will need to prevent it from running and remove the view from the window manager. + // If there is no runnable around then the url has already been added to the list, so we'll + // need to remove it before proceeding or that url will open multiple times. + if (stopServiceRunnable != null) { + tabQueueHandler.removeCallbacks(stopServiceRunnable); + stopSelfResult(stopServiceRunnable.getStartId()); + stopServiceRunnable = null; + removeView(); + } else { + TabQueueHelper.removeURLFromFile(applicationContext, intentUrl, TabQueueHelper.FILE_NAME); + } + openNow(safeIntent.getUnsafe()); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-doubletap"); + stopSelfResult(startId); + } + }); + + return START_REDELIVER_INTENT; + } + + sharedPreferences.edit().putString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, intentUrl) + .putLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, System.currentTimeMillis()) + .apply(); + } + + if (stopServiceRunnable != null) { + // If we're already displaying a toast, keep displaying it but store the previous url. + // The open button will refer to the most recently opened link. + tabQueueHandler.removeCallbacks(stopServiceRunnable); + stopServiceRunnable.run(false); + } else { + try { + windowManager.addView(toastLayout, toastLayoutParams); + } catch (final SecurityException | WindowManager.BadTokenException e) { + Toast.makeText(this, getText(R.string.tab_queue_toast_message), Toast.LENGTH_SHORT).show(); + showSettingsNotification(); + } + } + + stopServiceRunnable = new StopServiceRunnable(startId) { + @Override + public void onRun() { + addURLToTabQueue(intent, TabQueueHelper.FILE_NAME); + stopServiceRunnable = null; + } + }; + + openNowButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + tabQueueHandler.removeCallbacks(stopServiceRunnable); + stopServiceRunnable = null; + removeView(); + openNow(intent); + + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-now"); + stopSelfResult(startId); + } + }); + + tabQueueHandler.postDelayed(stopServiceRunnable, TOAST_TIMEOUT); + + return START_REDELIVER_INTENT; + } + + private void openNow(Intent intent) { + Intent forwardIntent = new Intent(intent); + forwardIntent.setClassName(getApplicationContext(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + forwardIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(forwardIntent); + + TabQueueHelper.removeNotification(getApplicationContext()); + + GeckoSharedPrefs.forApp(getApplicationContext()).edit().remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE) + .remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME) + .apply(); + + executorService.submit(new Runnable() { + @Override + public void run() { + int queuedTabCount = TabQueueHelper.getTabQueueLength(TabQueueService.this); + Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount); + } + }); + + } + + @TargetApi(Build.VERSION_CODES.M) + private void showSettingsNotification() { + if (AppConstants.Versions.preMarshmallow) { + return; + } + + final Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); + intent.setData(Uri.parse("package:" + getPackageName())); + PendingIntent pendingIntent = PendingIntent.getActivity(this, intent.hashCode(), intent, 0); + + final String text = getString(R.string.tab_queue_notification_settings); + + final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle() + .bigText(text); + + final Notification notification = new NotificationCompat.Builder(this) + .setContentTitle(getString(R.string.pref_tab_queue_title)) + .setContentText(text) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setStyle(style) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setAutoCancel(true) + .addAction(R.drawable.ic_action_settings, getString(R.string.tab_queue_prompt_settings_button), pendingIntent) + .build(); + + NotificationManagerCompat.from(this).notify(R.id.tabQueueSettingsNotification, notification); + } + + private void removeView() { + try { + windowManager.removeView(toastLayout); + } catch (IllegalArgumentException | IllegalStateException e) { + // This can happen if the Service is killed by the system. If this happens the View will have already + // been removed but the runnable will have been kept alive. + Log.e(LOGTAG, "Error removing Tab Queue toast from service", e); + } + } + + private void addURLToTabQueue(final Intent intent, final String filename) { + if (intent == null) { + // This should never happen, but let's return silently instead of crashing if it does. + Log.w(LOGTAG, "Error adding URL to tab queue - invalid intent passed in."); + return; + } + final SafeIntent safeIntent = new SafeIntent(intent); + final String intentData = safeIntent.getDataString(); + + // As we're doing disk IO, let's run this stuff in a separate thread. + executorService.submit(new Runnable() { + @Override + public void run() { + Context applicationContext = getApplicationContext(); + final GeckoProfile profile = GeckoProfile.get(applicationContext); + int tabsQueued = TabQueueHelper.queueURL(profile, intentData, filename); + List<String> urls = TabQueueHelper.getLastURLs(applicationContext, filename); + + TabQueueHelper.showNotification(applicationContext, tabsQueued, urls); + + // Store the number of URLs queued so that we don't have to read and process the file to see if we have + // any urls to open. + // TODO: Use profile shared prefs when bug 1147925 gets fixed. + final SharedPreferences prefs = GeckoSharedPrefs.forApp(applicationContext); + + prefs.edit().putInt(TabQueueHelper.PREF_TAB_QUEUE_COUNT, tabsQueued).apply(); + } + }); + } + + @Override + public void onDestroy() { + super.onDestroy(); + handlerThread.quit(); + } + + /** + * A modified Runnable which additionally removes the view from the window view hierarchy and stops the service + * when run, unless explicitly instructed not to. + */ + private abstract class StopServiceRunnable implements Runnable { + + private final int startId; + + public StopServiceRunnable(final int startId) { + this.startId = startId; + } + + public void run() { + run(true); + } + + public void run(final boolean shouldRemoveView) { + onRun(); + + if (shouldRemoveView) { + removeView(); + } + + stopSelfResult(startId); + } + + public int getStartId() { + return startId; + } + + public abstract void onRun(); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java new file mode 100644 index 000000000..4f5baacdb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java @@ -0,0 +1,130 @@ +/* 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.tabqueue; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserLocaleManager; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.db.BrowserContract; + +import android.app.IntentService; +import android.app.PendingIntent; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.database.Cursor; +import android.media.RingtoneManager; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.util.Log; + +/** + * An IntentService that displays a notification for a tab sent to this device. + * + * The expected Intent should contain: + * * Data: URI to open in the notification + * * EXTRA_TITLE: Page title of the URI to open + */ +public class TabReceivedService extends IntentService { + private static final String LOGTAG = "Gecko" + TabReceivedService.class.getSimpleName(); + + private static final String PREF_NOTIFICATION_ID = "tab_received_notification_id"; + + private static final int MAX_NOTIFICATION_COUNT = 1000; + + public TabReceivedService() { + super(LOGTAG); + setIntentRedelivery(true); + } + + @Override + protected void onHandleIntent(final Intent intent) { + // IntentServices don't keep the process alive so + // we need to do this every time. Ideally, we wouldn't. + final Resources res = getResources(); + BrowserLocaleManager.getInstance().correctLocale(this, res, res.getConfiguration()); + + final String uri = intent.getDataString(); + if (uri == null) { + Log.d(LOGTAG, "Received null uri – ignoring"); + return; + } + + final Intent notificationIntent = new Intent(Intent.ACTION_VIEW, intent.getData()); + notificationIntent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true); + final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0); + + final String notificationTitle = getNotificationTitle(intent.getStringExtra(BrowserContract.EXTRA_CLIENT_GUID)); + final NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setSmallIcon(R.drawable.flat_icon); + builder.setContentTitle(notificationTitle); + builder.setWhen(System.currentTimeMillis()); + builder.setAutoCancel(true); + builder.setContentText(uri); + builder.setContentIntent(contentIntent); + + // Trigger "heads-up" notification mode on supported Android versions. + builder.setPriority(NotificationCompat.PRIORITY_HIGH); + final Uri notificationSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + if (notificationSoundUri != null) { + builder.setSound(notificationSoundUri); + } + + final SharedPreferences prefs = GeckoSharedPrefs.forApp(this); + final int notificationId = getNextNotificationId(prefs.getInt(PREF_NOTIFICATION_ID, 0)); + final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + notificationManager.notify(notificationId, builder.build()); + + // Save the ID last so if the Service is killed and the Intent is redelivered, + // the ID is unlikely to have been updated and we would re-use the the old one. + // This would prevent two identical notifications from appearing if the + // notification was shown during the previous Intent processing attempt. + prefs.edit().putInt(PREF_NOTIFICATION_ID, notificationId).apply(); + } + + /** + * @param clientGUID the guid of the client in the clients table + * @return the client's name from the clients table, if possible, else the brand name. + */ + @WorkerThread + private String getNotificationTitle(@Nullable final String clientGUID) { + if (clientGUID == null) { + Log.w(LOGTAG, "Received null guid, using brand name."); + return AppConstants.MOZ_APP_DISPLAYNAME; + } + + final Cursor c = getContentResolver().query(BrowserContract.Clients.CONTENT_URI, + new String[] { BrowserContract.Clients.NAME }, + BrowserContract.Clients.GUID + "=?", new String[] { clientGUID }, null); + try { + if (c != null && c.moveToFirst()) { + return c.getString(c.getColumnIndex(BrowserContract.Clients.NAME)); + } else { + Log.w(LOGTAG, "Device not found, using brand name."); + return AppConstants.MOZ_APP_DISPLAYNAME; + } + } finally { + if (c != null) { + c.close(); + } + } + } + + /** + * Notification IDs must be unique else a notification + * will be overwritten so we cycle them. + */ + private int getNextNotificationId(final int currentId) { + if (currentId > MAX_NOTIFICATION_COUNT) { + return 0; + } else { + return currentId + 1; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java new file mode 100644 index 000000000..b7bd83376 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java @@ -0,0 +1,63 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.tabs.TabsPanel.CloseAllPanelView; +import org.mozilla.gecko.tabs.TabsPanel.TabsLayout; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; + +/** + * A container that wraps the private tabs {@link android.widget.AdapterView} and empty + * {@link android.view.View} to manage both of their visibility states by changing the visibility of + * this container as calling {@link android.widget.AdapterView#setVisibility} does not affect the + * empty View's visibility. + */ +class PrivateTabsPanel extends FrameLayout implements CloseAllPanelView { + private final TabsLayout tabsLayout; + + public PrivateTabsPanel(final Context context, final AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.private_tabs_panel, this); + tabsLayout = (TabsLayout) findViewById(R.id.private_tabs_layout); + + final View emptyTabsFrame = findViewById(R.id.private_tabs_empty); + tabsLayout.setEmptyView(emptyTabsFrame); + } + + @Override + public void setTabsPanel(final TabsPanel panel) { + tabsLayout.setTabsPanel(panel); + } + + @Override + public void show() { + tabsLayout.show(); + setVisibility(View.VISIBLE); + } + + @Override + public void hide() { + setVisibility(View.GONE); + tabsLayout.hide(); + } + + @Override + public boolean shouldExpand() { + return tabsLayout.shouldExpand(); + } + + @Override + public void closeAll() { + tabsLayout.closeAll(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java new file mode 100644 index 000000000..0b6a30d7a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java @@ -0,0 +1,70 @@ +/* -*- 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.tabs; + +import android.graphics.Path; + +/** + * Utility methods to draws Firefox's tab curve shape. + */ +public class TabCurve { + + public enum Direction { + LEFT(-1), + RIGHT(1); + + private final int value; + + private Direction(int value) { + this.value = value; + } + } + + // Curve's aspect ratio + private static final float ASPECT_RATIO = 0.729f; + + // Width multipliers + private static final float W_M1 = 0.343f; + private static final float W_M2 = 0.514f; + private static final float W_M3 = 0.49f; + private static final float W_M4 = 0.545f; + private static final float W_M5 = 0.723f; + + // Height multipliers + private static final float H_M1 = 0.25f; + private static final float H_M2 = 0.5f; + private static final float H_M3 = 0.72f; + private static final float H_M4 = 0.961f; + + private TabCurve() { + } + + public static float getWidthForHeight(float height) { + return (int) (height * ASPECT_RATIO); + } + + public static void drawFromTop(Path path, float from, float height, Direction dir) { + final float width = getWidthForHeight(height); + + path.cubicTo(from + width * W_M1 * dir.value, 0.0f, + from + width * W_M3 * dir.value, height * H_M1, + from + width * W_M2 * dir.value, height * H_M2); + path.cubicTo(from + width * W_M4 * dir.value, height * H_M3, + from + width * W_M5 * dir.value, height * H_M4, + from + width * dir.value, height); + } + + public static void drawFromBottom(Path path, float from, float height, Direction dir) { + final float width = getWidthForHeight(height); + + path.cubicTo(from + width * (1f - W_M5) * dir.value, height * H_M4, + from + width * (1f - W_M4) * dir.value, height * H_M3, + from + width * (1f - W_M2) * dir.value, height * H_M2); + path.cubicTo(from + width * (1f - W_M3) * dir.value, height * H_M1, + from + width * (1f - W_M1) * dir.value, 0.0f, + from + width * dir.value, 0.0f); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java new file mode 100644 index 000000000..7b06c994c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java @@ -0,0 +1,87 @@ +/* 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.tabs; + +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeJSObject; + +import android.util.Log; + +public class TabHistoryController { + private static final String LOGTAG = "TabHistoryController"; + private final OnShowTabHistory showTabHistoryListener; + + public static enum HistoryAction { + ALL, + BACK, + FORWARD + }; + + public interface OnShowTabHistory { + void onShowHistory(List<TabHistoryPage> historyPageList, int toIndex); + } + + public TabHistoryController(OnShowTabHistory showTabHistoryListener) { + this.showTabHistoryListener = showTabHistoryListener; + } + + /** + * This method will show the history for the current tab. + */ + public boolean showTabHistory(final Tab tab, final HistoryAction action) { + JSONObject json = new JSONObject(); + try { + json.put("action", action.name()); + json.put("tabId", tab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + + GeckoAppShell.sendRequestToGecko(new GeckoRequest("Session:GetHistory", json) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + /* + * The response from gecko request is of the form + * { + * "historyItems" : [ + * { + * "title": "google", + * "url": "google.com", + * "selected": false + * } + * ], + * toIndex = 1 + * } + */ + + final NativeJSObject[] historyItems = nativeJSObject.getObjectArray("historyItems"); + if (historyItems.length == 0) { + // Empty history, return without showing the popup. + return; + } + + final List<TabHistoryPage> historyPageList = new ArrayList<>(historyItems.length); + final int toIndex = nativeJSObject.getInt("toIndex"); + + for (NativeJSObject obj : historyItems) { + final String title = obj.getString("title"); + final String url = obj.getString("url"); + final boolean selected = obj.getBoolean("selected"); + historyPageList.add(new TabHistoryPage(title, url, selected)); + } + + showTabHistoryListener.onShowHistory(historyPageList, toIndex); + } + }); + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java new file mode 100644 index 000000000..e6deabdcf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java @@ -0,0 +1,172 @@ +/* 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.tabs; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.ListView; + +public class TabHistoryFragment extends Fragment implements OnItemClickListener, OnClickListener { + private static final String ARG_LIST = "historyPageList"; + private static final String ARG_INDEX = "index"; + private static final String BACK_STACK_ID = "backStateId"; + + private List<TabHistoryPage> historyPageList; + private int toIndex; + private ListView dialogList; + private int backStackId = -1; + private ViewGroup parent; + private boolean dismissed; + + public TabHistoryFragment() { + + } + + public static TabHistoryFragment newInstance(List<TabHistoryPage> historyPageList, int toIndex) { + final TabHistoryFragment fragment = new TabHistoryFragment(); + final Bundle args = new Bundle(); + args.putParcelableArrayList(ARG_LIST, (ArrayList<? extends Parcelable>) historyPageList); + args.putInt(ARG_INDEX, toIndex); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + backStackId = savedInstanceState.getInt(BACK_STACK_ID, -1); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + this.parent = container; + parent.setVisibility(View.VISIBLE); + View view = inflater.inflate(R.layout.tab_history_layout, container, false); + view.setOnClickListener(this); + dialogList = (ListView) view.findViewById(R.id.tab_history_list); + dialogList.setDivider(null); + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Bundle bundle = getArguments(); + historyPageList = bundle.getParcelableArrayList(ARG_LIST); + toIndex = bundle.getInt(ARG_INDEX); + final ArrayAdapter<TabHistoryPage> urlAdapter = new TabHistoryAdapter(getActivity(), historyPageList); + dialogList.setAdapter(urlAdapter); + dialogList.setOnItemClickListener(this); + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + String index = String.valueOf(toIndex - position); + GeckoAppShell.notifyObservers("Session:Navigate", index); + dismiss(); + } + + @Override + public void onClick(View v) { + // Since the fragment view fills the entire screen, any clicks outside of the history + // ListView will end up here. + dismiss(); + } + + @Override + public void onPause() { + super.onPause(); + dismiss(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + dismiss(); + + GeckoApplication.watchReference(getActivity(), this); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (backStackId >= 0) { + outState.putInt(BACK_STACK_ID, backStackId); + } + } + + // Function to add this fragment to activity state with containerViewId as parent. + // This similar in functionality to DialogFragment.show() except that containerId is provided here. + public void show(final int containerViewId, final FragmentTransaction transaction, final String tag) { + dismissed = false; + transaction.add(containerViewId, this, tag); + transaction.addToBackStack(tag); + // Populating the tab history requires a gecko call (which can be slow) - therefore the app + // state by the time we try to show this fragment is unknown, and we could be in the + // middle of shutting down: + backStackId = transaction.commitAllowingStateLoss(); + } + + // Pop the fragment from backstack if it exists. + public void dismiss() { + if (dismissed) { + return; + } + + dismissed = true; + + if (backStackId >= 0) { + getFragmentManager().popBackStackImmediate(backStackId, FragmentManager.POP_BACK_STACK_INCLUSIVE); + backStackId = -1; + } + + if (parent != null) { + parent.setVisibility(View.GONE); + } + } + + private static class TabHistoryAdapter extends ArrayAdapter<TabHistoryPage> { + private final List<TabHistoryPage> pages; + private final Context context; + + public TabHistoryAdapter(Context context, List<TabHistoryPage> pages) { + super(context, R.layout.tab_history_item_row, pages); + this.context = context; + this.pages = pages; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + TabHistoryItemRow row = (TabHistoryItemRow) convertView; + if (row == null) { + row = new TabHistoryItemRow(context, null); + } + + row.update(pages.get(position), position == 0, position == pages.size() - 1); + return row; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java new file mode 100644 index 000000000..112dbc07d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java @@ -0,0 +1,69 @@ +/* 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.tabs; + +import android.content.Context; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.FaviconView; + +import java.util.concurrent.Future; + +public class TabHistoryItemRow extends RelativeLayout { + private final FaviconView favicon; + private final TextView title; + private final ImageView timeLineTop; + private final ImageView timeLineBottom; + private Future<IconResponse> ongoingIconLoad; + + public TabHistoryItemRow(Context context, AttributeSet attrs) { + super(context, attrs); + LayoutInflater.from(context).inflate(R.layout.tab_history_item_row, this); + favicon = (FaviconView) findViewById(R.id.tab_history_icon); + title = (TextView) findViewById(R.id.tab_history_title); + timeLineTop = (ImageView) findViewById(R.id.tab_history_timeline_top); + timeLineBottom = (ImageView) findViewById(R.id.tab_history_timeline_bottom); + } + + // Update the views with historic page detail. + public void update(final TabHistoryPage historyPage, boolean isFirstElement, boolean isLastElement) { + ThreadUtils.assertOnUiThread(); + + timeLineTop.setVisibility(isFirstElement ? View.INVISIBLE : View.VISIBLE); + timeLineBottom.setVisibility(isLastElement ? View.INVISIBLE : View.VISIBLE); + title.setText(historyPage.getTitle()); + + if (historyPage.isSelected()) { + // Highlight title with bold font. + title.setTypeface(null, Typeface.BOLD); + } else { + // Clear previously set bold font. + title.setTypeface(null, Typeface.NORMAL); + } + + favicon.setEnabled(historyPage.isSelected()); + favicon.clearImage(); + + if (ongoingIconLoad != null) { + ongoingIconLoad.cancel(true); + } + + ongoingIconLoad = Icons.with(getContext()) + .pageUrl(historyPage.getUrl()) + .skipNetwork() + .build() + .execute(favicon.createIconCallback()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java new file mode 100644 index 000000000..6c608b2ac --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java @@ -0,0 +1,60 @@ +/* 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.tabs; + +import android.os.Parcel; +import android.os.Parcelable; + +public class TabHistoryPage implements Parcelable { + private final String title; + private final String url; + private final boolean selected; + + public TabHistoryPage(String title, String url, boolean selected) { + this.title = title; + this.url = url; + this.selected = selected; + } + + public String getTitle() { + return title; + } + + public String getUrl() { + return url; + } + + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(title); + dest.writeString(url); + dest.writeInt(selected ? 1 : 0); + } + + public static final Parcelable.Creator<TabHistoryPage> CREATOR = new Parcelable.Creator<TabHistoryPage>() { + @Override + public TabHistoryPage createFromParcel(final Parcel source) { + final String title = source.readString(); + final String url = source.readString(); + final boolean selected = source.readByte() != 0; + + final TabHistoryPage page = new TabHistoryPage(title, url, selected); + return page; + } + + @Override + public TabHistoryPage[] newArray(int size) { + return new TabHistoryPage[size]; + } + }; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java new file mode 100644 index 000000000..7ea02407e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java @@ -0,0 +1,55 @@ +/* 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.tabs; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.ImageButton; + +public class TabPanelBackButton extends ImageButton { + + private int dividerWidth = 0; + + private final Drawable divider; + private final int dividerPadding; + + public TabPanelBackButton(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabPanelBackButton); + divider = a.getDrawable(R.styleable.TabPanelBackButton_rightDivider); + dividerPadding = (int) a.getDimension(R.styleable.TabPanelBackButton_dividerVerticalPadding, 0); + a.recycle(); + + if (divider != null) { + dividerWidth = divider.getIntrinsicWidth(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + setMeasuredDimension(getMeasuredWidth() + dividerWidth, getMeasuredHeight()); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (divider != null) { + final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) getLayoutParams(); + final int left = getRight() - lp.rightMargin - dividerWidth; + + divider.setBounds(left, getPaddingTop() + dividerPadding, + left + dividerWidth, getHeight() - getPaddingBottom() - dividerPadding); + divider.draw(canvas); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java new file mode 100644 index 000000000..5d3719343 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java @@ -0,0 +1,170 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.graphics.Rect; +import android.support.v4.content.ContextCompat; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.TouchDelegate; +import android.view.View; +import android.view.ViewTreeObserver; + +import org.mozilla.gecko.BrowserApp.TabStripInterface; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.themed.ThemedImageButton; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; + +public class TabStrip extends ThemedLinearLayout + implements TabStripInterface { + private static final String LOGTAG = "GeckoTabStrip"; + + private final TabStripView tabStripView; + private final ThemedImageButton addTabButton; + + private final TabsListener tabsListener; + private OnTabAddedOrRemovedListener tabChangedListener; + + public TabStrip(Context context) { + this(context, null); + } + + public TabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + + LayoutInflater.from(context).inflate(R.layout.tab_strip_inner, this); + tabStripView = (TabStripView) findViewById(R.id.tab_strip); + + addTabButton = (ThemedImageButton) findViewById(R.id.add_tab); + addTabButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final Tabs tabs = Tabs.getInstance(); + if (isPrivateMode()) { + tabs.addPrivateTab(); + } else { + tabs.addTab(); + } + } + }); + + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final Rect r = new Rect(); + r.left = addTabButton.getRight(); + r.right = getWidth(); + r.top = 0; + r.bottom = getHeight(); + + // Redirect touch events between the 'new tab' button and the edge + // of the screen to the 'new tab' button. + setTouchDelegate(new TouchDelegate(r, addTabButton)); + + return true; + } + }); + + tabsListener = new TabsListener(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + Tabs.registerOnTabsChangedListener(tabsListener); + tabStripView.refreshTabs(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + Tabs.unregisterOnTabsChangedListener(tabsListener); + tabStripView.clearTabs(); + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + addTabButton.setPrivateMode(isPrivate); + } + + public void setOnTabChangedListener(OnTabAddedOrRemovedListener listener) { + tabChangedListener = listener; + } + + private class TabsListener implements Tabs.OnTabsChangedListener { + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case RESTORED: + tabStripView.restoreTabs(); + break; + + case ADDED: + tabStripView.addTab(tab); + if (tabChangedListener != null) { + tabChangedListener.onTabChanged(); + } + break; + + case CLOSED: + tabStripView.removeTab(tab); + if (tabChangedListener != null) { + tabChangedListener.onTabChanged(); + } + break; + + case SELECTED: + // Update the selected position, then fall through... + tabStripView.selectTab(tab); + setPrivateMode(tab.isPrivate()); + case UNSELECTED: + // We just need to update the style for the unselected tab... + case TITLE: + case FAVICON: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + tabStripView.updateTab(tab); + break; + } + } + } + + @Override + public void refresh() { + tabStripView.refresh(); + } + + @Override + public void onLightweightThemeChanged() { + final Drawable drawable = getTheme().getDrawable(this); + if (drawable == null) { + return; + } + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey)); + stateList.addState(EMPTY_STATE_SET, drawable); + + setBackgroundDrawable(stateList); + } + + @Override + public void onLightweightThemeReset() { + final int defaultBackgroundColor = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + setBackgroundColor(defaultBackgroundColor); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java new file mode 100644 index 000000000..8778aac31 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java @@ -0,0 +1,98 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; + +class TabStripAdapter extends BaseAdapter { + private static final String LOGTAG = "GeckoTabStripAdapter"; + + private final Context context; + private List<Tab> tabs; + + public TabStripAdapter(Context context) { + this.context = context; + } + + @Override + public Tab getItem(int position) { + return (tabs != null && + position >= 0 && + position < tabs.size() ? tabs.get(position) : null); + } + + @Override + public long getItemId(int position) { + final Tab tab = getItem(position); + return (tab != null ? tab.getId() : -1); + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final TabStripItemView item; + if (convertView == null) { + item = (TabStripItemView) + LayoutInflater.from(context).inflate(R.layout.tab_strip_item, parent, false); + } else { + item = (TabStripItemView) convertView; + } + + final Tab tab = tabs.get(position); + item.updateFromTab(tab); + + return item; + } + + @Override + public int getCount() { + return (tabs != null ? tabs.size() : 0); + } + + int getPositionForTab(Tab tab) { + if (tabs == null || tab == null) { + return -1; + } + + return tabs.indexOf(tab); + } + + void removeTab(Tab tab) { + if (tabs == null) { + return; + } + + tabs.remove(tab); + notifyDataSetChanged(); + } + + void refresh(List<Tab> tabs) { + // The list of tabs is guaranteed to be non-null. + // See TabStripView.refreshTabs(). + this.tabs = tabs; + notifyDataSetChanged(); + } + + void clear() { + tabs = null; + notifyDataSetInvalidated(); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java new file mode 100644 index 000000000..27eaed125 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java @@ -0,0 +1,254 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.ResizablePathDrawable; +import org.mozilla.gecko.widget.ResizablePathDrawable.NonScaledPathShape; +import org.mozilla.gecko.widget.themed.ThemedImageButton; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; +import org.mozilla.gecko.widget.themed.ThemedTextView; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.Region; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Checkable; +import android.widget.ImageView; + +public class TabStripItemView extends ThemedLinearLayout + implements Checkable { + private static final String LOGTAG = "GeckoTabStripItem"; + + private static final int[] STATE_CHECKED = { + android.R.attr.state_checked + }; + + private int id = -1; + private boolean checked; + + private final ImageView faviconView; + private final ThemedTextView titleView; + private final ThemedImageButton closeView; + + private final ResizablePathDrawable backgroundDrawable; + private final Region tabRegion; + private final Region tabClipRegion; + private boolean tabRegionNeedsUpdate; + + private final int faviconSize; + private Bitmap lastFavicon; + + public TabStripItemView(Context context) { + this(context, null); + } + + public TabStripItemView(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + + tabRegion = new Region(); + tabClipRegion = new Region(); + + final Resources res = context.getResources(); + + final ColorStateList tabColors = + res.getColorStateList(R.color.tab_strip_item_bg); + backgroundDrawable = new ResizablePathDrawable(new TabCurveShape(), tabColors); + setBackgroundDrawable(backgroundDrawable); + + faviconSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_favicon_size); + + LayoutInflater.from(context).inflate(R.layout.tab_strip_item_view, this); + setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (id < 0) { + throw new IllegalStateException("Invalid tab id:" + id); + } + + Tabs.getInstance().selectTab(id); + } + }); + + faviconView = (ImageView) findViewById(R.id.favicon); + titleView = (ThemedTextView) findViewById(R.id.title); + + closeView = (ThemedImageButton) findViewById(R.id.close); + closeView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (id < 0) { + throw new IllegalStateException("Invalid tab id:" + id); + } + + final Tabs tabs = Tabs.getInstance(); + tabs.closeTab(tabs.getTab(id), true); + } + }); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + // Queue a tab region update in the next draw() call. We don't + // update it immediately here because we need the new path from + // the background drawable to be updated first. + tabRegionNeedsUpdate = true; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + final int action = event.getActionMasked(); + final int x = (int) event.getX(); + final int y = (int) event.getY(); + + // Let motion events through if they're off the tab shape bounds. + if (action == MotionEvent.ACTION_DOWN && !tabRegion.contains(x, y)) { + return false; + } + + return super.onTouchEvent(event); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + if (tabRegionNeedsUpdate) { + final Path path = backgroundDrawable.getPath(); + tabClipRegion.set(0, 0, getWidth(), getHeight()); + tabRegion.setPath(path, tabClipRegion); + tabRegionNeedsUpdate = false; + } + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (checked) { + mergeDrawableStates(drawableState, STATE_CHECKED); + } + + return drawableState; + } + + @Override + public boolean isChecked() { + return checked; + } + + @Override + public void setChecked(boolean checked) { + if (this.checked == checked) { + return; + } + + this.checked = checked; + refreshDrawableState(); + } + + @Override + public void toggle() { + setChecked(!checked); + } + + @Override + public void setPressed(boolean pressed) { + super.setPressed(pressed); + + // The surrounding tab strip dividers need to be hidden + // when a tab item enters pressed state. + View parent = (View) getParent(); + if (parent != null) { + parent.invalidate(); + } + } + + void updateFromTab(Tab tab) { + if (tab == null) { + return; + } + + id = tab.getId(); + + updateTitle(tab); + updateFavicon(tab.getFavicon()); + setPrivateMode(tab.isPrivate()); + } + + private void updateTitle(Tab tab) { + final String title; + + // Avoid flickering the about:home URL on every load given how often + // this page is used in the UI. + if (AboutPages.isAboutHome(tab.getURL())) { + titleView.setText(R.string.home_title); + } else { + titleView.setText(tab.getDisplayTitle()); + } + + // TODO: Set content description to indicate audio is playing. + if (tab.isAudioPlaying()) { + titleView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0); + } else { + titleView.setCompoundDrawables(null, null, null, null); + } + } + + private void updateFavicon(final Bitmap favicon) { + if (favicon == null) { + lastFavicon = null; + faviconView.setImageResource(R.drawable.toolbar_favicon_default); + return; + } + if (favicon == lastFavicon) { + return; + } + + // Cache the original so we can debounce without scaling. + lastFavicon = favicon; + + final Bitmap scaledFavicon = + Bitmap.createScaledBitmap(favicon, faviconSize, faviconSize, false); + faviconView.setImageBitmap(scaledFavicon); + } + + private static class TabCurveShape extends NonScaledPathShape { + @Override + protected void onResize(float width, float height) { + final Path path = getPath(); + + path.reset(); + + final float curveWidth = TabCurve.getWidthForHeight(height); + + path.moveTo(0, height); + TabCurve.drawFromBottom(path, 0, height, TabCurve.Direction.RIGHT); + path.lineTo(width - curveWidth, 0); + + TabCurve.drawFromTop(path, width - curveWidth, height, TabCurve.Direction.RIGHT); + path.lineTo(0, height); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java new file mode 100644 index 000000000..f3ec19cef --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java @@ -0,0 +1,449 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.animation.DecelerateInterpolator; +import android.view.View; +import android.view.ViewTreeObserver.OnPreDrawListener; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.TwoWayView; + +public class TabStripView extends TwoWayView { + private static final String LOGTAG = "GeckoTabStrip"; + + private static final int ANIM_TIME_MS = 200; + private static final DecelerateInterpolator ANIM_INTERPOLATOR = + new DecelerateInterpolator(); + + private final TabStripAdapter adapter; + private final Drawable divider; + + private final TabAnimatorListener animatorListener; + + private boolean isRestoringTabs; + + // Filled by calls to ShapeDrawable.getPadding(); + // saved to prevent allocation in draw(). + private final Rect dividerPadding = new Rect(); + + private boolean isPrivate; + + private final Paint fadingEdgePaint; + private final int fadingEdgeSize; + + public TabStripView(Context context, AttributeSet attrs) { + super(context, attrs); + + setOrientation(Orientation.HORIZONTAL); + setChoiceMode(ChoiceMode.SINGLE); + setItemsCanFocus(true); + setChildrenDrawingOrderEnabled(true); + setWillNotDraw(false); + + final Resources resources = getResources(); + + divider = resources.getDrawable(R.drawable.tab_strip_divider); + divider.getPadding(dividerPadding); + + final int itemMargin = + resources.getDimensionPixelSize(R.dimen.tablet_tab_strip_item_margin); + setItemMargin(itemMargin); + + animatorListener = new TabAnimatorListener(); + + fadingEdgePaint = new Paint(); + fadingEdgeSize = + resources.getDimensionPixelOffset(R.dimen.tablet_tab_strip_fading_edge_size); + + adapter = new TabStripAdapter(context); + setAdapter(adapter); + } + + private View getViewForTab(Tab tab) { + final int position = adapter.getPositionForTab(tab); + return getChildAt(position - getFirstVisiblePosition()); + } + + private int getPositionForSelectedTab() { + return adapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + } + + private void updateSelectedStyle(int selected) { + setItemChecked(selected, true); + } + + private void updateSelectedPosition(boolean ensureVisible) { + final int selected = getPositionForSelectedTab(); + if (selected != -1) { + updateSelectedStyle(selected); + + if (ensureVisible) { + ensurePositionIsVisible(selected, true); + } + } + } + + private void animateRemoveTab(Tab removedTab) { + final int removedPosition = adapter.getPositionForTab(removedTab); + + final View removedView = getViewForTab(removedTab); + + // The removed position might not have a matching child view + // when it's not within the visible range of positions in the strip. + if (removedView == null) { + return; + } + + // We don't animate the removed child view (it just disappears) + // but we still need its size of animate all affected children + // within the visible viewport. + final int removedSize = removedView.getWidth() + getItemMargin(); + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final int firstPosition = getFirstVisiblePosition(); + final List<Animator> childAnimators = new ArrayList<Animator>(); + + final int childCount = getChildCount(); + for (int i = removedPosition - firstPosition; i < childCount; i++) { + final View child = getChildAt(i); + + final ObjectAnimator animator = + ObjectAnimator.ofFloat(child, "translationX", removedSize, 0); + childAnimators.add(animator); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + private void animateNewTab(Tab newTab) { + final int newPosition = adapter.getPositionForTab(newTab); + if (newPosition < 0) { + return; + } + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final int firstPosition = getFirstVisiblePosition(); + + final View newChild = getChildAt(newPosition - firstPosition); + if (newChild == null) { + return true; + } + + final List<Animator> childAnimators = new ArrayList<Animator>(); + childAnimators.add( + ObjectAnimator.ofFloat(newChild, "translationY", newChild.getHeight(), 0)); + + // This will momentaneously add a gap on the right side + // because TwoWayView doesn't provide APIs to control + // view recycling programatically to handle these transitory + // states in the container during animations. + + final int tabSize = newChild.getWidth(); + final int newIndex = newPosition - firstPosition; + final int childCount = getChildCount(); + for (int i = newIndex + 1; i < childCount; i++) { + final View child = getChildAt(i); + + childAnimators.add( + ObjectAnimator.ofFloat(child, "translationX", -tabSize, 0)); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + private void animateRestoredTabs() { + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final List<Animator> childAnimators = new ArrayList<Animator>(); + + final int tabHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + childAnimators.add( + ObjectAnimator.ofFloat(child, "translationY", tabHeight, 0)); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + /** + * Ensures the tab at the given position is visible. If we are not restoring tabs and + * shouldAnimate == true, the tab will animate to be visible, if it is not already visible. + */ + private void ensurePositionIsVisible(final int position, final boolean shouldAnimate) { + // We just want to move the strip to the right position + // when restoring tabs on startup. + if (isRestoringTabs || !shouldAnimate) { + setSelection(position); + return; + } + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + smoothScrollToPosition(position); + return true; + } + }); + } + + private int getCheckedIndex(int childCount) { + final int checkedIndex = getCheckedItemPosition() - getFirstVisiblePosition(); + if (checkedIndex < 0 || checkedIndex > childCount - 1) { + return INVALID_POSITION; + } + + return checkedIndex; + } + + void refreshTabs() { + // Store a different copy of the tabs, so that we don't have + // to worry about accidentally updating it on the wrong thread. + final List<Tab> tabs = new ArrayList<Tab>(); + + for (Tab tab : Tabs.getInstance().getTabsInOrder()) { + if (tab.isPrivate() == isPrivate) { + tabs.add(tab); + } + } + + adapter.refresh(tabs); + updateSelectedPosition(true); + } + + void clearTabs() { + adapter.clear(); + } + + void restoreTabs() { + isRestoringTabs = true; + refreshTabs(); + animateRestoredTabs(); + isRestoringTabs = false; + } + + void addTab(Tab tab) { + // Refresh the list to make sure the new tab is + // added in the right position. + refreshTabs(); + animateNewTab(tab); + } + + void removeTab(Tab tab) { + animateRemoveTab(tab); + adapter.removeTab(tab); + updateSelectedPosition(false); + } + + void selectTab(Tab tab) { + if (tab.isPrivate() != isPrivate) { + isPrivate = tab.isPrivate(); + refreshTabs(); + } else { + updateSelectedPosition(true); + } + } + + void updateTab(Tab tab) { + final TabStripItemView item = (TabStripItemView) getViewForTab(tab); + if (item != null) { + item.updateFromTab(tab); + } + } + + private float getFadingEdgeStrength() { + final int childCount = getChildCount(); + if (childCount == 0) { + return 0.0f; + } else { + if (getFirstVisiblePosition() + childCount - 1 < adapter.getCount() - 1) { + return 1.0f; + } + + final int right = getChildAt(childCount - 1).getRight(); + final int paddingRight = getPaddingRight(); + final int width = getWidth(); + + final float strength = (right > width - paddingRight ? + (float) (right - width + paddingRight) / fadingEdgeSize : 0.0f); + + return Math.max(0.0f, Math.min(strength, 1.0f)); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + fadingEdgePaint.setShader(new LinearGradient(w - fadingEdgeSize, 0, w, 0, + new int[] { 0x0, 0x11292C29, 0xDD292C29 }, + new float[] { 0, 0.4f, 1.0f }, Shader.TileMode.CLAMP)); + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + final int checkedIndex = getCheckedIndex(childCount); + if (checkedIndex == INVALID_POSITION) { + return i; + } + + // Always draw the currently selected tab on top of all + // other child views so that its curve is fully visible. + if (i == childCount - 1) { + return checkedIndex; + } else if (checkedIndex <= i) { + return i + 1; + } else { + return i; + } + } + + private void drawDividers(Canvas canvas) { + final int bottom = getHeight() - getPaddingBottom() - dividerPadding.bottom; + final int top = bottom - divider.getIntrinsicHeight(); + + final int dividerWidth = divider.getIntrinsicWidth(); + final int itemMargin = getItemMargin(); + + final int childCount = getChildCount(); + final int checkedIndex = getCheckedIndex(childCount); + + for (int i = 1; i < childCount; i++) { + final View child = getChildAt(i); + + final boolean pressed = (child.isPressed() || getChildAt(i - 1).isPressed()); + final boolean checked = (i == checkedIndex || i == checkedIndex + 1); + + // Don't draw dividers for around checked or pressed items + // so that they are not drawn on top of the tab curves. + if (pressed || checked) { + continue; + } + + final int left = child.getLeft() - (itemMargin / 2) - dividerWidth; + final int right = left + dividerWidth; + + divider.setBounds(left, top, right, bottom); + divider.draw(canvas); + } + } + + private void drawFadingEdge(Canvas canvas) { + final float strength = getFadingEdgeStrength(); + if (strength > 0.0f) { + final int r = getRight(); + canvas.drawRect(r - fadingEdgeSize, getTop(), r, getBottom(), fadingEdgePaint); + fadingEdgePaint.setAlpha((int) (strength * 255)); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + drawDividers(canvas); + drawFadingEdge(canvas); + } + + public void refresh() { + final int selectedPosition = getPositionForSelectedTab(); + if (selectedPosition != -1) { + ensurePositionIsVisible(selectedPosition, false); + } + } + + private class TabAnimatorListener implements AnimatorListener { + private void setLayerType(int layerType) { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + getChildAt(i).setLayerType(layerType, null); + } + } + + @Override + public void onAnimationStart(Animator animation) { + setLayerType(View.LAYER_TYPE_HARDWARE); + } + + @Override + public void onAnimationEnd(Animator animation) { + // This method is called even if the animator is canceled. + setLayerType(View.LAYER_TYPE_NONE); + } + + @Override + public void onAnimationRepeat(Animator animation) { + } + + @Override + public void onAnimationCancel(Animator animation) { + } + + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java new file mode 100644 index 000000000..ead7db9fe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java @@ -0,0 +1,712 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.tabs.TabsPanel.TabsLayout; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.DecelerateInterpolator; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.GridView; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; + +import java.util.ArrayList; +import java.util.List; + +/** + * A tabs layout implementation for the tablet redesign (bug 1014156) and later ported to mobile (bug 1193745). + */ + +class TabsGridLayout extends GridView + implements TabsLayout, + Tabs.OnTabsChangedListener { + + private static final String LOGTAG = "Gecko" + TabsGridLayout.class.getSimpleName(); + + public static final int ANIM_DELAY_MULTIPLE_MS = 20; + private static final int ANIM_TIME_MS = 200; + private static final DecelerateInterpolator ANIM_INTERPOLATOR = new DecelerateInterpolator(); + + private final SparseArray<PointF> tabLocations = new SparseArray<PointF>(); + private final boolean isPrivate; + private final TabsLayoutAdapter tabsAdapter; + private final int columnWidth; + private TabsPanel tabsPanel; + private int lastSelectedTabId; + + public TabsGridLayout(final Context context, final AttributeSet attrs) { + super(context, attrs, R.attr.tabGridLayoutViewStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout); + isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1); + a.recycle(); + + tabsAdapter = new TabsGridLayoutAdapter(context); + setAdapter(tabsAdapter); + + setRecyclerListener(new RecyclerListener() { + @Override + public void onMovedToScrapHeap(View view) { + TabsLayoutItemView item = (TabsLayoutItemView) view; + item.setThumbnail(null); + } + }); + + // The clipToPadding setting in the styles.xml doesn't seem to be working (bug 1101784) + // so lets set it manually in code for the moment as it's needed for the padding animation + setClipToPadding(false); + + setVerticalFadingEdgeEnabled(false); + + final Resources resources = getResources(); + columnWidth = resources.getDimensionPixelSize(R.dimen.tab_panel_column_width); + + final int padding = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding); + final int paddingTop = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding_top); + + // Lets set double the top padding on the bottom so that the last row shows up properly! + // Your demise, GridView, cannot come fast enough. + final int paddingBottom = paddingTop * 2; + + setPadding(padding, paddingTop, padding, paddingBottom); + + setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final TabsLayoutItemView tabView = (TabsLayoutItemView) view; + final int tabId = tabView.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab == null) { + return; + } + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + }); + + TabSwipeGestureListener mSwipeListener = new TabSwipeGestureListener(); + setOnTouchListener(mSwipeListener); + setOnScrollListener(mSwipeListener.makeScrollListener()); + } + + private void populateTabLocations(final Tab removedTab) { + tabLocations.clear(); + + final int firstPosition = getFirstVisiblePosition(); + final int lastPosition = getLastVisiblePosition(); + final int numberOfColumns = getNumColumns(); + final int childCount = getChildCount(); + final int removedPosition = tabsAdapter.getPositionForTab(removedTab); + + for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) { + final View child = getChildAt(i); + if (child != null) { + // Reset the transformations here in case the user is swiping tabs away fast and they swipe a tab + // before the last animation has finished (bug 1179195). + resetTransforms(child); + + tabLocations.append(x, new PointF(child.getX(), child.getY())); + } + } + + final boolean firstChildOffScreen = ((firstPosition > 0) || getChildAt(0).getY() < 0); + final boolean lastChildVisible = (lastPosition - childCount == firstPosition - 1); + final boolean oneItemOnLastRow = (lastPosition % numberOfColumns == 0); + if (firstChildOffScreen && lastChildVisible && oneItemOnLastRow) { + // We need to set the view's bottom padding to prevent a sudden jump as the + // last item in the row is being removed. We then need to remove the padding + // via a sweet animation + + final int removedHeight = getChildAt(0).getMeasuredHeight(); + final int verticalSpacing = + getResources().getDimensionPixelOffset(R.dimen.tab_panel_grid_vspacing); + + ValueAnimator paddingAnimator = ValueAnimator.ofInt(getPaddingBottom() + removedHeight + verticalSpacing, getPaddingBottom()); + paddingAnimator.setDuration(ANIM_TIME_MS * 2); + + paddingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), (Integer) animation.getAnimatedValue()); + } + }); + paddingAnimator.start(); + } + } + + @Override + public void setTabsPanel(TabsPanel panel) { + tabsPanel = panel; + } + + @Override + public void show() { + setVisibility(View.VISIBLE); + Tabs.getInstance().refreshThumbnails(); + Tabs.registerOnTabsChangedListener(this); + refreshTabsData(); + + final Tab currentlySelectedTab = Tabs.getInstance().getSelectedTab(); + final int position = currentlySelectedTab != null ? tabsAdapter.getPositionForTab(currentlySelectedTab) : -1; + if (position != -1) { + final boolean selectionChanged = lastSelectedTabId != currentlySelectedTab.getId(); + final boolean positionIsVisible = position >= getFirstVisiblePosition() && position <= getLastVisiblePosition(); + + if (selectionChanged || !positionIsVisible) { + smoothScrollToPosition(position); + } + } + } + + @Override + public void hide() { + lastSelectedTabId = Tabs.getInstance().getSelectedTab().getId(); + setVisibility(View.GONE); + Tabs.unregisterOnTabsChangedListener(this); + GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", ""); + tabsAdapter.clear(); + } + + @Override + public boolean shouldExpand() { + return true; + } + + private void autoHidePanel() { + tabsPanel.autoHidePanel(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case ADDED: + // Refresh only if panel is shown. show() will call refreshTabsData() later again. + if (tabsPanel.isShown()) { + // Refresh the list to make sure the new tab is added in the right position. + refreshTabsData(); + } + break; + + case CLOSED: + + // This is limited to >= ICS as animations on GB devices are generally pants + if (Build.VERSION.SDK_INT >= 11 && tabsAdapter.getCount() > 0) { + animateRemoveTab(tab); + } + + final Tabs tabsInstance = Tabs.getInstance(); + + if (tabsAdapter.removeTab(tab)) { + if (tab.isPrivate() == isPrivate && tabsAdapter.getCount() > 0) { + int selected = tabsAdapter.getPositionForTab(tabsInstance.getSelectedTab()); + updateSelectedStyle(selected); + } + if (!tab.isPrivate()) { + // Make sure we always have at least one normal tab + final Iterable<Tab> tabs = tabsInstance.getTabsInOrder(); + boolean removedTabIsLastNormalTab = true; + for (Tab singleTab : tabs) { + if (!singleTab.isPrivate()) { + removedTabIsLastNormalTab = false; + break; + } + } + if (removedTabIsLastNormalTab) { + tabsInstance.addTab(); + } + } + } + break; + + case SELECTED: + // Update the selected position, then fall through... + updateSelectedPosition(); + case UNSELECTED: + // We just need to update the style for the unselected tab... + case THUMBNAIL: + case TITLE: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + View view = getChildAt(tabsAdapter.getPositionForTab(tab) - getFirstVisiblePosition()); + if (view == null) + return; + + ((TabsLayoutItemView) view).assignValues(tab); + break; + } + } + + // Updates the selected position in the list so that it will be scrolled to the right place. + private void updateSelectedPosition() { + int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + updateSelectedStyle(selected); + + if (selected != -1) { + setSelection(selected); + } + } + + /** + * Updates the selected/unselected style for the tabs. + * + * @param selected position of the selected tab + */ + private void updateSelectedStyle(final int selected) { + post(new Runnable() { + @Override + public void run() { + final int displayCount = tabsAdapter.getCount(); + + for (int i = 0; i < displayCount; i++) { + final Tab tab = tabsAdapter.getItem(i); + final boolean checked = displayCount == 1 || i == selected; + final View tabView = getViewForTab(tab); + if (tabView != null) { + ((TabsLayoutItemView) tabView).setChecked(checked); + } + // setItemChecked doesn't exist until API 11, despite what the API docs say! + setItemChecked(i, checked); + } + } + }); + } + + private void refreshTabsData() { + // Store a different copy of the tabs, so that we don't have to worry about + // accidentally updating it on the wrong thread. + ArrayList<Tab> tabData = new ArrayList<>(); + + Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder(); + for (Tab tab : allTabs) { + if (tab.isPrivate() == isPrivate) + tabData.add(tab); + } + + tabsAdapter.setTabs(tabData); + updateSelectedPosition(); + } + + private void resetTransforms(View view) { + view.setAlpha(1); + view.setTranslationX(0); + view.setTranslationY(0); + + ((TabsLayoutItemView) view).setCloseVisible(true); + } + + @Override + public void closeAll() { + + autoHidePanel(); + + if (getChildCount() == 0) { + return; + } + + final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder(); + for (Tab tab : tabs) { + // In the normal panel we want to close all tabs (both private and normal), + // but in the private panel we only want to close private tabs. + if (!isPrivate || tab.isPrivate()) { + Tabs.getInstance().closeTab(tab, false); + } + } + } + + private View getViewForTab(Tab tab) { + final int position = tabsAdapter.getPositionForTab(tab); + return getChildAt(position - getFirstVisiblePosition()); + } + + void closeTab(View v) { + if (tabsAdapter.getCount() == 1) { + autoHidePanel(); + } + + TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag(); + Tab tab = Tabs.getInstance().getTab(itemView.getTabId()); + + Tabs.getInstance().closeTab(tab, true); + } + + private void animateRemoveTab(final Tab removedTab) { + final int removedPosition = tabsAdapter.getPositionForTab(removedTab); + + final View removedView = getViewForTab(removedTab); + + // The removed position might not have a matching child view + // when it's not within the visible range of positions in the strip. + if (removedView == null) { + return; + } + final int removedHeight = removedView.getMeasuredHeight(); + + populateTabLocations(removedTab); + + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + // We don't animate the removed child view (it just disappears) + // but we still need its size to animate all affected children + // within the visible viewport. + final int childCount = getChildCount(); + final int firstPosition = getFirstVisiblePosition(); + final int numberOfColumns = getNumColumns(); + + final List<Animator> childAnimators = new ArrayList<>(); + + PropertyValuesHolder translateX, translateY; + for (int x = 0, i = removedPosition - firstPosition; i < childCount; i++, x++) { + final View child = getChildAt(i); + ObjectAnimator animator; + + if (i % numberOfColumns == numberOfColumns - 1) { + // Animate X & Y + translateX = PropertyValuesHolder.ofFloat("translationX", -(columnWidth * numberOfColumns), 0); + translateY = PropertyValuesHolder.ofFloat("translationY", removedHeight, 0); + animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX, translateY); + } else { + // Just animate X + translateX = PropertyValuesHolder.ofFloat("translationX", columnWidth, 0); + animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX); + } + animator.setStartDelay(x * ANIM_DELAY_MULTIPLE_MS); + childAnimators.add(animator); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.start(); + + // Set the starting position of the child views - because we are delaying the start + // of the animation, we need to prevent the items being drawn in their final position + // prior to the animation starting + for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) { + final View child = getChildAt(i); + + final PointF targetLocation = tabLocations.get(x + 1); + if (targetLocation == null) { + continue; + } + + child.setX(targetLocation.x); + child.setY(targetLocation.y); + } + + return true; + } + }); + } + + + private void animateCancel(final View view) { + PropertyAnimator animator = new PropertyAnimator(ANIM_TIME_MS); + animator.attach(view, PropertyAnimator.Property.ALPHA, 1); + animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, 0); + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + TabsLayoutItemView tab = (TabsLayoutItemView) view; + tab.setCloseVisible(true); + } + }); + + animator.start(); + } + + private class TabsGridLayoutAdapter extends TabsLayoutAdapter { + + final private Button.OnClickListener mCloseClickListener; + + public TabsGridLayoutAdapter(Context context) { + super(context, R.layout.tabs_layout_item_view); + + mCloseClickListener = new Button.OnClickListener() { + @Override + public void onClick(View v) { + closeTab(v); + } + }; + } + + @Override + TabsLayoutItemView newView(int position, ViewGroup parent) { + final TabsLayoutItemView item = super.newView(position, parent); + + item.setCloseOnClickListener(mCloseClickListener); + ((ThemedRelativeLayout) item.findViewById(R.id.wrapper)).setPrivateMode(isPrivate); + + return item; + } + + @Override + public void bindView(TabsLayoutItemView view, Tab tab) { + super.bindView(view, tab); + + // If we're recycling this view, there's a chance it was transformed during + // the close animation. Remove any of those properties. + resetTransforms(view); + } + } + + private class TabSwipeGestureListener implements View.OnTouchListener { + // same value the stock browser uses for after drag animation velocity in pixels/sec + // http://androidxref.com/4.0.4/xref/packages/apps/Browser/src/com/android/browser/NavTabScroller.java#61 + private static final float MIN_VELOCITY = 750; + + private final int mSwipeThreshold; + private final int mMinFlingVelocity; + + private final int mMaxFlingVelocity; + private VelocityTracker mVelocityTracker; + + private int mTabWidth = 1; + + private View mSwipeView; + private Runnable mPendingCheckForTap; + + private float mSwipeStartX; + private boolean mSwiping; + private boolean mEnabled; + + public TabSwipeGestureListener() { + mEnabled = true; + + ViewConfiguration vc = ViewConfiguration.get(TabsGridLayout.this.getContext()); + mSwipeThreshold = vc.getScaledTouchSlop(); + mMinFlingVelocity = (int) (TabsGridLayout.this.getContext().getResources().getDisplayMetrics().density * MIN_VELOCITY); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + } + + public OnScrollListener makeScrollListener() { + return new OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + setEnabled(scrollState != GridView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + + } + }; + } + + @Override + public boolean onTouch(View view, MotionEvent e) { + if (!mEnabled) { + return false; + } + + switch (e.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + // Check if we should set pressed state on the + // touched view after a standard delay. + triggerCheckForTap(); + + final float x = e.getRawX(); + final float y = e.getRawY(); + + // Find out which view is being touched + mSwipeView = findViewAt(x, y); + + if (mSwipeView != null) { + if (mTabWidth < 2) { + mTabWidth = mSwipeView.getWidth(); + } + + mSwipeStartX = e.getRawX(); + + mVelocityTracker = VelocityTracker.obtain(); + mVelocityTracker.addMovement(e); + } + + view.onTouchEvent(e); + return true; + } + + case MotionEvent.ACTION_UP: { + if (mSwipeView == null) { + break; + } + + cancelCheckForTap(); + mSwipeView.setPressed(false); + + if (!mSwiping) { + final TabsLayoutItemView item = (TabsLayoutItemView) mSwipeView; + final int tabId = item.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab != null) { + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + + mVelocityTracker.recycle(); + mVelocityTracker = null; + break; + } + + mVelocityTracker.addMovement(e); + mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity); + + float velocityX = Math.abs(mVelocityTracker.getXVelocity()); + + boolean dismiss = false; + + float deltaX = mSwipeView.getTranslationX(); + + if (Math.abs(deltaX) > mTabWidth / 2) { + dismiss = true; + } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity) { + dismiss = mSwiping && (deltaX * mVelocityTracker.getYVelocity() > 0); + } + if (dismiss) { + closeTab(mSwipeView.findViewById(R.id.close)); + } else { + animateCancel(mSwipeView); + } + mVelocityTracker.recycle(); + mVelocityTracker = null; + mSwipeView = null; + + mSwipeStartX = 0; + mSwiping = false; + } + + case MotionEvent.ACTION_MOVE: { + if (mSwipeView == null || mVelocityTracker == null) { + break; + } + + mVelocityTracker.addMovement(e); + + float delta = e.getRawX() - mSwipeStartX; + + boolean isScrollingX = Math.abs(delta) > mSwipeThreshold; + boolean isSwipingToClose = isScrollingX; + + // If we're actually swiping, make sure we don't + // set pressed state on the swiped view. + if (isScrollingX) { + cancelCheckForTap(); + } + + if (isSwipingToClose) { + mSwiping = true; + TabsGridLayout.this.requestDisallowInterceptTouchEvent(true); + + ((TabsLayoutItemView) mSwipeView).setCloseVisible(false); + + // Stops listview from highlighting the touched item + // in the list when swiping. + MotionEvent cancelEvent = MotionEvent.obtain(e); + cancelEvent.setAction(MotionEvent.ACTION_CANCEL | + (e.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT)); + TabsGridLayout.this.onTouchEvent(cancelEvent); + cancelEvent.recycle(); + } + + if (mSwiping) { + mSwipeView.setTranslationX(delta); + + mSwipeView.setAlpha(Math.min(1f, 1f - 2f * Math.abs(delta) / mTabWidth)); + + return true; + } + + break; + } + } + return false; + } + + private View findViewAt(float rawX, float rawY) { + Rect rect = new Rect(); + + int[] listViewCoords = new int[2]; + TabsGridLayout.this.getLocationOnScreen(listViewCoords); + + int x = (int) rawX - listViewCoords[0]; + int y = (int) rawY - listViewCoords[1]; + + for (int i = 0; i < TabsGridLayout.this.getChildCount(); i++) { + View child = TabsGridLayout.this.getChildAt(i); + child.getHitRect(rect); + + if (rect.contains(x, y)) { + return child; + } + } + + return null; + } + + private void triggerCheckForTap() { + if (mPendingCheckForTap == null) { + mPendingCheckForTap = new CheckForTap(); + } + + TabsGridLayout.this.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); + } + + private void cancelCheckForTap() { + if (mPendingCheckForTap == null) { + return; + } + + TabsGridLayout.this.removeCallbacks(mPendingCheckForTap); + } + + private class CheckForTap implements Runnable { + @Override + public void run() { + if (!mSwiping && mSwipeView != null && mEnabled) { + mSwipeView.setPressed(true); + } + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java new file mode 100644 index 000000000..d5362f1f1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java @@ -0,0 +1,216 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import android.content.Context; +import android.content.res.TypedArray; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; + +import java.util.ArrayList; + +public abstract class TabsLayout extends RecyclerView + implements TabsPanel.TabsLayout, + Tabs.OnTabsChangedListener, + RecyclerViewClickSupport.OnItemClickListener, + TabsTouchHelperCallback.DismissListener { + + private static final String LOGTAG = "Gecko" + TabsLayout.class.getSimpleName(); + + private final boolean isPrivate; + private TabsPanel tabsPanel; + private final TabsLayoutRecyclerAdapter tabsAdapter; + + public TabsLayout(Context context, AttributeSet attrs, int itemViewLayoutResId) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout); + isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1); + a.recycle(); + + tabsAdapter = new TabsLayoutRecyclerAdapter(context, itemViewLayoutResId, isPrivate, + /* close on click listener */ + new Button.OnClickListener() { + @Override + public void onClick(View v) { + // The view here is the close button, which has a reference + // to the parent TabsLayoutItemView in its tag, hence the getTag() call. + TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag(); + closeTab(itemView); + } + }); + setAdapter(tabsAdapter); + + RecyclerViewClickSupport.addTo(this).setOnItemClickListener(this); + + setRecyclerListener(new RecyclerListener() { + @Override + public void onViewRecycled(RecyclerView.ViewHolder holder) { + final TabsLayoutItemView itemView = (TabsLayoutItemView) holder.itemView; + itemView.setThumbnail(null); + itemView.setCloseVisible(true); + } + }); + } + + @Override + public void setTabsPanel(TabsPanel panel) { + tabsPanel = panel; + } + + @Override + public void show() { + setVisibility(View.VISIBLE); + Tabs.getInstance().refreshThumbnails(); + Tabs.registerOnTabsChangedListener(this); + refreshTabsData(); + } + + @Override + public void hide() { + setVisibility(View.GONE); + Tabs.unregisterOnTabsChangedListener(this); + GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", ""); + tabsAdapter.clear(); + } + + @Override + public boolean shouldExpand() { + return true; + } + + protected void autoHidePanel() { + tabsPanel.autoHidePanel(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case ADDED: + final int tabIndex = Integer.parseInt(data); + tabsAdapter.notifyTabInserted(tab, tabIndex); + if (addAtIndexRequiresScroll(tabIndex)) { + // (The current Tabs implementation updates the SELECTED tab *after* this + // call to ADDED, so don't just call updateSelectedPosition().) + scrollToPosition(tabIndex); + } + break; + + case CLOSED: + if (tab.isPrivate() == isPrivate && tabsAdapter.getItemCount() > 0) { + tabsAdapter.removeTab(tab); + } + break; + + case SELECTED: + case UNSELECTED: + case THUMBNAIL: + case TITLE: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + tabsAdapter.notifyTabChanged(tab); + break; + } + } + + // Addition of a tab at selected positions (dependent on LayoutManager) will result in a tab + // being added out of view - return true if index is such a position. + abstract protected boolean addAtIndexRequiresScroll(int index); + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + final TabsLayoutItemView item = (TabsLayoutItemView) v; + final int tabId = item.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab == null) { + // The tab that was clicked no longer exists in the tabs list (which can happen if you + // tap on a tab while its remove animation is running), so ignore the click. + return; + } + + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + + // Updates the selected position in the list so that it will be scrolled to the right place. + private void updateSelectedPosition() { + final int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + if (selected != NO_POSITION) { + scrollToPosition(selected); + } + } + + private void refreshTabsData() { + // Store a different copy of the tabs, so that we don't have to worry about + // accidentally updating it on the wrong thread. + final ArrayList<Tab> tabData = new ArrayList<>(); + final Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder(); + + for (final Tab tab : allTabs) { + if (tab.isPrivate() == isPrivate) { + tabData.add(tab); + } + } + + tabsAdapter.setTabs(tabData); + updateSelectedPosition(); + } + + private void closeTab(View view) { + final TabsLayoutItemView itemView = (TabsLayoutItemView) view; + final Tab tab = getTabForView(itemView); + if (tab == null) { + // We can be null here if this is the second closeTab call resulting from a sufficiently + // fast double tap on the close tab button. + return; + } + + final boolean closingLastTab = tabsAdapter.getItemCount() == 1; + Tabs.getInstance().closeTab(tab, true); + if (closingLastTab) { + autoHidePanel(); + } + } + + protected void closeAllTabs() { + final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder(); + for (final Tab tab : tabs) { + // In the normal panel we want to close all tabs (both private and normal), + // but in the private panel we only want to close private tabs. + if (!isPrivate || tab.isPrivate()) { + Tabs.getInstance().closeTab(tab, false); + } + } + } + + @Override + public void onItemDismiss(View view) { + closeTab(view); + } + + private Tab getTabForView(View view) { + if (view == null) { + return null; + } + return Tabs.getInstance().getTab(((TabsLayoutItemView) view).getTabId()); + } + + @Override + public void setEmptyView(View emptyView) { + // We never display an empty view. + } + + @Override + abstract public void closeAll(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java new file mode 100644 index 000000000..367da640f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java @@ -0,0 +1,100 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import java.util.ArrayList; + +// Adapter to bind tabs into a list +public class TabsLayoutAdapter extends BaseAdapter { + public static final String LOGTAG = "Gecko" + TabsLayoutAdapter.class.getSimpleName(); + + private final Context mContext; + private final int mTabLayoutId; + private ArrayList<Tab> mTabs; + private final LayoutInflater mInflater; + + public TabsLayoutAdapter (Context context, int tabLayoutId) { + mContext = context; + mInflater = LayoutInflater.from(mContext); + mTabLayoutId = tabLayoutId; + } + + final void setTabs (ArrayList<Tab> tabs) { + mTabs = tabs; + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + + final boolean removeTab (Tab tab) { + boolean tabRemoved = mTabs.remove(tab); + if (tabRemoved) { + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + return tabRemoved; + } + + final void clear() { + mTabs = null; + + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + + @Override + public int getCount() { + return (mTabs == null ? 0 : mTabs.size()); + } + + @Override + public Tab getItem(int position) { + return mTabs.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + final int getPositionForTab(Tab tab) { + if (mTabs == null || tab == null) + return -1; + + return mTabs.indexOf(tab); + } + + @Override + public boolean isEnabled(int position) { + return true; + } + + @Override + final public TabsLayoutItemView getView(int position, View convertView, ViewGroup parent) { + final TabsLayoutItemView view; + if (convertView == null) { + view = newView(position, parent); + } else { + view = (TabsLayoutItemView) convertView; + } + final Tab tab = mTabs.get(position); + bindView(view, tab); + return view; + } + + TabsLayoutItemView newView(int position, ViewGroup parent) { + return (TabsLayoutItemView) mInflater.inflate(mTabLayoutId, parent, false); + } + + void bindView(TabsLayoutItemView view, Tab tab) { + view.assignValues(tab); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java new file mode 100644 index 000000000..975e779d6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java @@ -0,0 +1,172 @@ +/* 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.TabThumbnailWrapper; +import org.mozilla.gecko.widget.TouchDelegateWithReset; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + +import android.content.Context; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.Checkable; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +public class TabsLayoutItemView extends LinearLayout + implements Checkable { + private static final String LOGTAG = "Gecko" + TabsLayoutItemView.class.getSimpleName(); + private static final int[] STATE_CHECKED = { android.R.attr.state_checked }; + private boolean mChecked; + + private int mTabId; + private TextView mTitle; + private TabsPanelThumbnailView mThumbnail; + private ImageView mCloseButton; + private TabThumbnailWrapper mThumbnailWrapper; + + public TabsLayoutItemView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (mChecked) { + mergeDrawableStates(drawableState, STATE_CHECKED); + } + + return drawableState; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public void setChecked(boolean checked) { + if (mChecked == checked) { + return; + } + + mChecked = checked; + refreshDrawableState(); + + int count = getChildCount(); + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + if (child instanceof Checkable) { + ((Checkable) child).setChecked(checked); + } + } + } + + @Override + public void toggle() { + mChecked = !mChecked; + } + + public void setCloseOnClickListener(OnClickListener mOnClickListener) { + mCloseButton.setOnClickListener(mOnClickListener); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTitle = (TextView) findViewById(R.id.title); + mThumbnail = (TabsPanelThumbnailView) findViewById(R.id.thumbnail); + mCloseButton = (ImageView) findViewById(R.id.close); + mThumbnailWrapper = (TabThumbnailWrapper) findViewById(R.id.wrapper); + + growCloseButtonHitArea(); + } + + private void growCloseButtonHitArea() { + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + // Ideally we want the close button hit area to be 40x40dp but we are constrained by the height of the parent, so + // we make it as tall as the parent view and 40dp across. + final int targetHitArea = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40, getResources().getDisplayMetrics());; + + final Rect hitRect = new Rect(); + hitRect.top = 0; + hitRect.right = getWidth(); + hitRect.left = getWidth() - targetHitArea; + hitRect.bottom = targetHitArea; + + setTouchDelegate(new TouchDelegateWithReset(hitRect, mCloseButton)); + + return true; + } + }); + } + + protected void assignValues(Tab tab) { + if (tab == null) { + return; + } + + mTabId = tab.getId(); + + setChecked(Tabs.getInstance().isSelectedTab(tab)); + + Drawable thumbnailImage = tab.getThumbnail(); + mThumbnail.setImageDrawable(thumbnailImage); + + mThumbnail.setPrivateMode(tab.isPrivate()); + + if (mThumbnailWrapper != null) { + mThumbnailWrapper.setRecording(tab.isRecording()); + } + + final String tabTitle = tab.getDisplayTitle(); + mTitle.setText(tabTitle); + mCloseButton.setTag(this); + + if (tab.isAudioPlaying()) { + mTitle.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0); + final String tabTitleWithAudio = + getResources().getString(R.string.tab_title_prefix_is_playing_audio, tabTitle); + mTitle.setContentDescription(tabTitleWithAudio); + } else { + mTitle.setCompoundDrawables(null, null, null, null); + mTitle.setContentDescription(tabTitle); + } + } + + public int getTabId() { + return mTabId; + } + + public void setThumbnail(Drawable thumbnail) { + mThumbnail.setImageDrawable(thumbnail); + } + + public void setCloseVisible(boolean visible) { + mCloseButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + } + + public void setPrivateMode(boolean isPrivate) { + ((ThemedRelativeLayout) findViewById(R.id.wrapper)).setPrivateMode(isPrivate); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java new file mode 100644 index 000000000..090d74f9d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java @@ -0,0 +1,124 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.Tab; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import java.util.ArrayList; + +public class TabsLayoutRecyclerAdapter + extends RecyclerView.Adapter<TabsLayoutRecyclerAdapter.TabsListViewHolder> { + + private static final String LOGTAG = "Gecko" + TabsLayoutRecyclerAdapter.class.getSimpleName(); + + private final int tabLayoutId; + private @NonNull ArrayList<Tab> tabs; + private final LayoutInflater inflater; + private final boolean isPrivate; + // Click listener for the close button on itemViews. + private final Button.OnClickListener closeOnClickListener; + + // The TabsLayoutItemView takes care of caching its own Views, so we don't need to do anything + // here except not be abstract. + public static class TabsListViewHolder extends RecyclerView.ViewHolder { + public TabsListViewHolder(View itemView) { + super(itemView); + } + } + + public TabsLayoutRecyclerAdapter(Context context, int tabLayoutId, boolean isPrivate, + Button.OnClickListener closeOnClickListener) { + inflater = LayoutInflater.from(context); + this.tabLayoutId = tabLayoutId; + this.isPrivate = isPrivate; + this.closeOnClickListener = closeOnClickListener; + tabs = new ArrayList<>(0); + } + + /* package */ final void setTabs(@NonNull ArrayList<Tab> tabs) { + this.tabs = tabs; + notifyDataSetChanged(); + } + + /* package */ final void clear() { + tabs = new ArrayList<>(0); + notifyDataSetChanged(); + } + + /* package */ final boolean removeTab(Tab tab) { + final int position = getPositionForTab(tab); + if (position == -1) { + return false; + } + tabs.remove(position); + notifyItemRemoved(position); + return true; + } + + /* package */ final int getPositionForTab(Tab tab) { + if (tab == null) { + return -1; + } + + return tabs.indexOf(tab); + } + + /* package */ void notifyTabChanged(Tab tab) { + notifyItemChanged(getPositionForTab(tab)); + } + + /* package */ void notifyTabInserted(Tab tab, int index) { + if (index >= 0 && index <= tabs.size()) { + tabs.add(index, tab); + notifyItemInserted(index); + } else { + // Add to the end. + tabs.add(tab); + notifyItemInserted(tabs.size() - 1); + // index == -1 is a valid way to add to the end, the other cases are errors. + if (index != -1) { + Log.e(LOGTAG, "Tab was inserted at an invalid position: " + Integer.toString(index)); + } + } + } + + @Override + public int getItemCount() { + return tabs.size(); + } + + private Tab getItem(int position) { + return tabs.get(position); + } + + @Override + public void onBindViewHolder(TabsListViewHolder viewHolder, int position) { + final Tab tab = getItem(position); + final TabsLayoutItemView itemView = (TabsLayoutItemView) viewHolder.itemView; + itemView.assignValues(tab); + // Be careful (re)setting position values here: bind is called on each notifyItemChanged, + // so you could be stomping on values that have been set in support of other animations + // that are already underway. + } + + @Override + public TabsListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + final TabsLayoutItemView viewItem = (TabsLayoutItemView) inflater.inflate(tabLayoutId, parent, false); + viewItem.setPrivateMode(isPrivate); + viewItem.setCloseOnClickListener(closeOnClickListener); + + return new TabsListViewHolder(viewItem); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java new file mode 100644 index 000000000..8cf2f8ede --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java @@ -0,0 +1,118 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.AttributeSet; +import android.view.View; + +public class TabsListLayout extends TabsLayout { + // Time to animate non-flinged tabs of screen, in milliseconds + private static final int ANIMATION_DURATION = 250; + + // Time between starting successive tab animations in closeAllTabs. + private static final int ANIMATION_CASCADE_DELAY = 75; + + private int closeAllAnimationCount; + + public TabsListLayout(Context context, AttributeSet attrs) { + super(context, attrs, R.layout.tabs_list_item_view); + + setHasFixedSize(true); + + setLayoutManager(new LinearLayoutManager(context)); + + // A TouchHelper handler for swipe to close. + final TabsTouchHelperCallback callback = new TabsTouchHelperCallback(this); + final ItemTouchHelper touchHelper = new ItemTouchHelper(callback); + touchHelper.attachToRecyclerView(this); + + setItemAnimator(new TabsListLayoutAnimator(ANIMATION_DURATION)); + } + + @Override + public void closeAll() { + final int childCount = getChildCount(); + + // Just close the panel if there are no tabs to close. + if (childCount == 0) { + autoHidePanel(); + return; + } + + // Disable the view so that gestures won't interfere wth the tab close animation. + setEnabled(false); + + // Delay starting each successive animation to create a cascade effect. + int cascadeDelay = 0; + closeAllAnimationCount = 0; + for (int i = childCount - 1; i >= 0; i--) { + final View view = getChildAt(i); + if (view == null) { + continue; + } + + final PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION); + animator.attach(view, PropertyAnimator.Property.ALPHA, 0); + + animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, view.getWidth()); + + closeAllAnimationCount++; + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + closeAllAnimationCount--; + if (closeAllAnimationCount > 0) { + return; + } + + // Hide the panel after the animation is done. + autoHidePanel(); + + // Re-enable the view after the animation is done. + TabsListLayout.this.setEnabled(true); + + // Then actually close all the tabs. + closeAllTabs(); + } + }); + + ThreadUtils.postDelayedToUiThread(new Runnable() { + @Override + public void run() { + animator.start(); + } + }, cascadeDelay); + + cascadeDelay += ANIMATION_CASCADE_DELAY; + } + } + + @Override + protected boolean addAtIndexRequiresScroll(int index) { + return index == 0 || index == getAdapter().getItemCount() - 1; + } + + @Override + public void onChildAttachedToWindow(View child) { + // Make sure we reset any attributes that may have been animated in this child's previous + // incarnation. + child.setTranslationX(0); + child.setTranslationY(0); + child.setAlpha(1); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java new file mode 100644 index 000000000..471abf883 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java @@ -0,0 +1,65 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.widget.DefaultItemAnimatorBase; + +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +class TabsListLayoutAnimator extends DefaultItemAnimatorBase { + public TabsListLayoutAnimator(int animationDuration) { + setRemoveDuration(animationDuration); + setAddDuration(animationDuration); + // A fade in/out each time the title/thumbnail/etc. gets updated isn't helpful, so disable + // the change animation. + setSupportsChangeAnimations(false); + } + + @Override + protected boolean preAnimateRemoveImpl(final RecyclerView.ViewHolder holder) { + // If the view isn't at full alpha then we were closed by a swipe which an + // ItemTouchHelper is animating for us, so just return without animating the remove and + // let runPendingAnimations pick up the rest. + if (holder.itemView.getAlpha() < 1) { + return false; + } + resetAnimation(holder); + return true; + } + + @Override + protected void animateRemoveImpl(final RecyclerView.ViewHolder holder) { + final View itemView = holder.itemView; + ViewCompat.animate(itemView) + .setDuration(getRemoveDuration()) + .translationX(itemView.getWidth()) + .alpha(0) + .setListener(new DefaultRemoveVpaListener(holder)) + .start(); + } + + @Override + protected boolean preAnimateAddImpl(RecyclerView.ViewHolder holder) { + resetAnimation(holder); + final View itemView = holder.itemView; + itemView.setTranslationX(itemView.getWidth()); + itemView.setAlpha(0); + return true; + } + + @Override + protected void animateAddImpl(final RecyclerView.ViewHolder holder) { + final View itemView = holder.itemView; + ViewCompat.animate(itemView) + .setDuration(getAddDuration()) + .translationX(0) + .alpha(1) + .setListener(new DefaultAddVpaListener(holder)) + .start(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java new file mode 100644 index 000000000..2be127010 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java @@ -0,0 +1,456 @@ +/* -*- 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.tabs; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.lwt.LightweightThemeDrawable; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.widget.GeckoPopupMenu; +import org.mozilla.gecko.widget.IconTabWidget; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import org.mozilla.gecko.widget.themed.ThemedImageButton; + +public class TabsPanel extends LinearLayout + implements GeckoPopupMenu.OnMenuItemClickListener, + LightweightTheme.OnChangeListener, + IconTabWidget.OnTabChangedListener { + private static final String LOGTAG = "Gecko" + TabsPanel.class.getSimpleName(); + + public enum Panel { + NORMAL_TABS, + PRIVATE_TABS, + } + + public interface PanelView { + void setTabsPanel(TabsPanel panel); + void show(); + void hide(); + boolean shouldExpand(); + } + + public interface CloseAllPanelView extends PanelView { + void closeAll(); + } + + public interface TabsLayout extends CloseAllPanelView { + void setEmptyView(View view); + } + + public interface TabsLayoutChangeListener { + void onTabsLayoutChange(int width, int height); + } + + public static View createTabsLayout(final Context context, final AttributeSet attrs) { + final boolean isLandscape = context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + + if (HardwareUtils.isTablet() || isLandscape) { + return new TabsGridLayout(context, attrs); + } else { + return new TabsListLayout(context, attrs); + } + } + + private final Context mContext; + private final GeckoApp mActivity; + private final LightweightTheme mTheme; + private RelativeLayout mHeader; + private FrameLayout mTabsContainer; + private PanelView mPanel; + private PanelView mPanelNormal; + private PanelView mPanelPrivate; + private TabsLayoutChangeListener mLayoutChangeListener; + + private IconTabWidget mTabWidget; + private View mMenuButton; + private ImageButton mAddTab; + private ImageButton mNavBackButton; + + private Panel mCurrentPanel; + private boolean mVisible; + private boolean mHeaderVisible; + + private final GeckoPopupMenu mPopupMenu; + + public TabsPanel(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + mActivity = (GeckoApp) context; + mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme(); + + mCurrentPanel = Panel.NORMAL_TABS; + + mPopupMenu = new GeckoPopupMenu(context); + mPopupMenu.inflate(R.menu.tabs_menu); + mPopupMenu.setOnMenuItemClickListener(this); + + inflateLayout(context); + initialize(); + } + + private void inflateLayout(Context context) { + LayoutInflater.from(context).inflate(R.layout.tabs_panel_default, this); + } + + private void initialize() { + mHeader = (RelativeLayout) findViewById(R.id.tabs_panel_header); + mTabsContainer = (FrameLayout) findViewById(R.id.tabs_container); + + mPanelNormal = (PanelView) findViewById(R.id.normal_tabs); + mPanelNormal.setTabsPanel(this); + + mPanelPrivate = (PanelView) findViewById(R.id.private_tabs_panel); + mPanelPrivate.setTabsPanel(this); + + mAddTab = (ImageButton) findViewById(R.id.add_tab); + mAddTab.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + TabsPanel.this.addTab(); + } + }); + + mTabWidget = (IconTabWidget) findViewById(R.id.tab_widget); + + mTabWidget.addTab(R.drawable.tabs_normal, R.string.tabs_normal); + final ThemedImageButton privateTabsPanel = + (ThemedImageButton) mTabWidget.addTab(R.drawable.tabs_private, R.string.tabs_private); + privateTabsPanel.setPrivateMode(true); + + if (!Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING)) { + mTabWidget.setVisibility(View.GONE); + } + + mTabWidget.setTabSelectionListener(this); + + mMenuButton = findViewById(R.id.menu); + mMenuButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + showMenu(); + } + }); + + mNavBackButton = (ImageButton) findViewById(R.id.nav_back); + mNavBackButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + mActivity.onBackPressed(); + } + }); + } + + public void showMenu() { + final Menu menu = mPopupMenu.getMenu(); + + // Each panel has a "+" shortcut button, so don't show it for that panel. + menu.findItem(R.id.new_tab).setVisible(mCurrentPanel != Panel.NORMAL_TABS); + menu.findItem(R.id.new_private_tab).setVisible(mCurrentPanel != Panel.PRIVATE_TABS + && Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING)); + + // Only show "Clear * tabs" for current panel. + menu.findItem(R.id.close_all_tabs).setVisible(mCurrentPanel == Panel.NORMAL_TABS); + menu.findItem(R.id.close_private_tabs).setVisible(mCurrentPanel == Panel.PRIVATE_TABS); + + mPopupMenu.show(); + } + + private void addTab() { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "new_tab"); + + if (mCurrentPanel == Panel.NORMAL_TABS) { + mActivity.addTab(); + } else { + mActivity.addPrivateTab(); + } + + mActivity.autoHideTabs(); + } + + @Override + public void onTabChanged(int index) { + if (index == 0) { + show(Panel.NORMAL_TABS); + } else { + show(Panel.PRIVATE_TABS); + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + final int itemId = item.getItemId(); + + if (itemId == R.id.close_all_tabs) { + if (mCurrentPanel == Panel.NORMAL_TABS) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs"); + + // Disable the menu button so that the menu won't interfere with the tab close animation. + mMenuButton.setEnabled(false); + ((CloseAllPanelView) mPanelNormal).closeAll(); + } else { + Log.e(LOGTAG, "Close all tabs menu item should only be visible for normal tabs panel"); + } + return true; + } + + if (itemId == R.id.close_private_tabs) { + if (mCurrentPanel == Panel.PRIVATE_TABS) { + // Mask private browsing + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs"); + + ((CloseAllPanelView) mPanelPrivate).closeAll(); + } else { + Log.e(LOGTAG, "Close private tabs menu item should only be visible for private tabs panel"); + } + return true; + } + + if (itemId == R.id.new_tab || itemId == R.id.new_private_tab) { + hide(); + } + + return mActivity.onOptionsItemSelected(item); + } + + private static int getTabContainerHeight(FrameLayout tabsContainer) { + final Resources resources = tabsContainer.getContext().getResources(); + + final int screenHeight = resources.getDisplayMetrics().heightPixels; + final int actionBarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height); + + return screenHeight - actionBarHeight; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + mTheme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mTheme.removeListener(this); + } + + @Override + @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16 + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background, true); + if (drawable == null) + return; + + drawable.setAlpha(34, 0); + setBackgroundDrawable(drawable); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + // Tabs Panel Toolbar contains the Buttons + static class TabsPanelToolbar extends LinearLayout + implements LightweightTheme.OnChangeListener { + private final LightweightTheme mTheme; + + public TabsPanelToolbar(Context context, AttributeSet attrs) { + super(context, attrs); + mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + mTheme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mTheme.removeListener(this); + } + + @Override + @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16 + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background); + if (drawable == null) + return; + + drawable.setAlpha(34, 34); + setBackgroundDrawable(drawable); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + } + + public void show(Panel panelToShow) { + prepareToShow(panelToShow); + int height = getVerticalPanelHeight(); + dispatchLayoutChange(getWidth(), height); + mHeaderVisible = true; + } + + public void prepareToShow(Panel panelToShow) { + if (!isShown()) { + setVisibility(View.VISIBLE); + } + + if (mPanel != null) { + // Hide the old panel. + mPanel.hide(); + } + + mVisible = true; + mCurrentPanel = panelToShow; + + int index = panelToShow.ordinal(); + mTabWidget.setCurrentTab(index); + + switch (panelToShow) { + case NORMAL_TABS: + mPanel = mPanelNormal; + break; + case PRIVATE_TABS: + mPanel = mPanelPrivate; + break; + + default: + throw new IllegalArgumentException("Unknown panel type " + panelToShow); + } + mPanel.show(); + + mAddTab.setVisibility(View.VISIBLE); + + mMenuButton.setEnabled(true); + mPopupMenu.setAnchor(mMenuButton); + } + + public int getVerticalPanelHeight() { + final int actionBarHeight = mContext.getResources().getDimensionPixelSize(R.dimen.browser_toolbar_height); + final int height = actionBarHeight + getTabContainerHeight(mTabsContainer); + return height; + } + + public void hide() { + mHeaderVisible = false; + + if (mVisible) { + mVisible = false; + mPopupMenu.dismiss(); + dispatchLayoutChange(0, 0); + } + } + + public void refresh() { + removeAllViews(); + + inflateLayout(mContext); + initialize(); + + if (mVisible) + show(mCurrentPanel); + } + + public void autoHidePanel() { + mActivity.autoHideTabs(); + } + + @Override + public boolean isShown() { + return mVisible; + } + + public void setHWLayerEnabled(boolean enabled) { + if (enabled) { + mHeader.setLayerType(View.LAYER_TYPE_HARDWARE, null); + mTabsContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null); + } else { + mHeader.setLayerType(View.LAYER_TYPE_NONE, null); + mTabsContainer.setLayerType(View.LAYER_TYPE_NONE, null); + } + } + + public void prepareTabsAnimation(PropertyAnimator animator) { + if (!mHeaderVisible) { + final Resources resources = getContext().getResources(); + final int toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height); + final int translationY = (mVisible ? 0 : -toolbarHeight); + if (mVisible) { + ViewHelper.setTranslationY(mHeader, -toolbarHeight); + ViewHelper.setTranslationY(mTabsContainer, -toolbarHeight); + ViewHelper.setAlpha(mTabsContainer, 0.0f); + } + animator.attach(mTabsContainer, PropertyAnimator.Property.ALPHA, mVisible ? 1.0f : 0.0f); + animator.attach(mTabsContainer, PropertyAnimator.Property.TRANSLATION_Y, translationY); + animator.attach(mHeader, PropertyAnimator.Property.TRANSLATION_Y, translationY); + } + + setHWLayerEnabled(true); + } + + public void finishTabsAnimation() { + setHWLayerEnabled(false); + + // If the tray is now hidden, call hide() on current panel and unset it as the current panel + // to avoid hide() being called again when the layout is opened next. + if (!mVisible && mPanel != null) { + mPanel.hide(); + mPanel = null; + } + } + + public void setTabsLayoutChangeListener(TabsLayoutChangeListener listener) { + mLayoutChangeListener = listener; + } + + private void dispatchLayoutChange(int width, int height) { + if (mLayoutChangeListener != null) + mLayoutChangeListener.onTabsLayoutChange(width, height); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java new file mode 100644 index 000000000..09254bf76 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java @@ -0,0 +1,52 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.ThumbnailHelper; +import org.mozilla.gecko.widget.CropImageView; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +/** + * A width constrained ImageView to show thumbnails of open tabs in the tabs panel. + */ +public class TabsPanelThumbnailView extends CropImageView { + public static final String LOGTAG = "Gecko" + TabsPanelThumbnailView.class.getSimpleName(); + + + public TabsPanelThumbnailView(final Context context) { + this(context, null); + } + + public TabsPanelThumbnailView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public TabsPanelThumbnailView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected float getAspectRatio() { + return ThumbnailHelper.TABS_PANEL_THUMBNAIL_ASPECT_RATIO; + } + + @Override + public void setImageDrawable(Drawable drawable) { + boolean resize = true; + + if (drawable == null) { + drawable = getResources().getDrawable(R.drawable.tab_panel_tab_background); + resize = false; + setScaleType(ScaleType.FIT_XY); + } + + super.setImageDrawable(drawable, resize); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java new file mode 100644 index 000000000..36e9e4739 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java @@ -0,0 +1,69 @@ +/* -*- 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.tabs; + +import android.graphics.Canvas; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.view.View; + +class TabsTouchHelperCallback extends ItemTouchHelper.Callback { + private final DismissListener dismissListener; + + interface DismissListener { + void onItemDismiss(View view); + } + + public TabsTouchHelperCallback(DismissListener dismissListener) { + this.dismissListener = dismissListener; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) { + dismissListener.onItemDismiss(viewHolder.itemView); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, + RecyclerView.ViewHolder target) { + return false; + } + + // Alpha on an itemView being swiped should decrease to a min over a distance equal to the + // width of the item being swiped. + @Override + public void onChildDraw(Canvas c, + RecyclerView recyclerView, + RecyclerView.ViewHolder viewHolder, + float dX, + float dY, + int actionState, + boolean isCurrentlyActive) { + if (actionState != ItemTouchHelper.ACTION_STATE_SWIPE) { + return; + } + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + + viewHolder.itemView.setAlpha(Math.max(0.1f, + Math.min(1f, 1f - 2f * Math.abs(dX) / viewHolder.itemView.getWidth()))); + } + + public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.setAlpha(1); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java new file mode 100644 index 000000000..6ed4bb0d4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java @@ -0,0 +1,16 @@ +/* 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.telemetry; + +import org.mozilla.gecko.AppConstants; + +public class TelemetryConstants { + // To test, set this to true & change "toolkit.telemetry.server" in about:config. + public static final boolean UPLOAD_ENABLED = AppConstants.MOZILLA_OFFICIAL; // Disabled for developer builds. + + public static final String USER_AGENT = + "Firefox-Android-Telemetry/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")"; + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java new file mode 100644 index 000000000..fae674b2d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java @@ -0,0 +1,188 @@ +/* + * 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.telemetry; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.util.Log; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.adjust.AttributionHelperListener; +import org.mozilla.gecko.telemetry.measurements.CampaignIdMeasurements; +import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference; +import org.mozilla.gecko.distribution.DistributionStoreCallback; +import org.mozilla.gecko.search.SearchEngineManager; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.telemetry.measurements.SearchCountMeasurements; +import org.mozilla.gecko.telemetry.measurements.SessionMeasurements; +import org.mozilla.gecko.telemetry.pingbuilders.TelemetryCorePingBuilder; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.IOException; + +/** + * An activity-lifecycle delegate for uploading the core ping. + */ +public class TelemetryCorePingDelegate extends BrowserAppDelegateWithReference + implements SearchEngineManager.SearchEngineCallback, AttributionHelperListener { + private static final String LOGTAG = StringUtils.safeSubstring( + "Gecko" + TelemetryCorePingDelegate.class.getSimpleName(), 0, 23); + + private static final String PREF_IS_FIRST_RUN = "telemetry-isFirstRun"; + + private TelemetryDispatcher telemetryDispatcher; // lazy + private final SessionMeasurements sessionMeasurements = new SessionMeasurements(); + + @Override + public void onStart(final BrowserApp browserApp) { + TelemetryPreferences.initPreferenceObserver(browserApp, browserApp.getProfile().getName()); + + // We don't upload in onCreate because that's only called when the Activity needs to be instantiated + // and it's possible the system will never free the Activity from memory. + // + // We don't upload in onResume/onPause because that will be called each time the Activity is obscured, + // including by our own Activities/dialogs, and there is no reason to upload each time we're unobscured. + // + // We're left with onStart/onStop and we upload in onStart because onStop is not guaranteed to be called + // and we want to upload the first run ASAP (e.g. to get install data before the app may crash). + uploadPing(browserApp); + } + + @Override + public void onStop(final BrowserApp browserApp) { + // We've decided to upload primarily in onStart (see note there). However, if it's the first run, + // it's possible a user used fennec and decided never to return to it again - it'd be great to get + // their session information before they decided to give it up so we upload here on first run. + // + // Caveats: + // * onStop is not guaranteed to be called in low memory conditions so it's possible we won't upload, + // but it's better than it was before. + // * Besides first run (because of this call), we can never get the user's *last* session data. + // + // If we are really interested in the user's last session data, we could consider uploading in onStop + // but it's less robust (see discussion in bug 1277091). + final SharedPreferences sharedPrefs = getSharedPreferences(browserApp); + if (sharedPrefs.getBoolean(PREF_IS_FIRST_RUN, true)) { + sharedPrefs.edit() + .putBoolean(PREF_IS_FIRST_RUN, false) + .apply(); + uploadPing(browserApp); + } + } + + private void uploadPing(final BrowserApp browserApp) { + final SearchEngineManager searchEngineManager = browserApp.getSearchEngineManager(); + searchEngineManager.getEngine(this); + } + + @Override + public void onResume(BrowserApp browserApp) { + sessionMeasurements.recordSessionStart(); + } + + @Override + public void onPause(BrowserApp browserApp) { + // onStart/onStop is ideal over onResume/onPause. However, onStop is not guaranteed to be called and + // dealing with that possibility adds a lot of complexity that we don't want to handle at this point. + sessionMeasurements.recordSessionEnd(browserApp); + } + + @WorkerThread // via constructor + private TelemetryDispatcher getTelemetryDispatcher(final BrowserApp browserApp) { + if (telemetryDispatcher == null) { + final GeckoProfile profile = browserApp.getProfile(); + final String profilePath = profile.getDir().getAbsolutePath(); + final String profileName = profile.getName(); + telemetryDispatcher = new TelemetryDispatcher(profilePath, profileName); + } + return telemetryDispatcher; + } + + private SharedPreferences getSharedPreferences(final BrowserApp activity) { + return GeckoSharedPrefs.forProfileName(activity, activity.getProfile().getName()); + } + + // via SearchEngineCallback - may be called from any thread. + @Override + public void execute(@Nullable final org.mozilla.gecko.search.SearchEngine engine) { + // Don't waste resources queueing to the background thread if we don't have a reference. + if (getBrowserApp() == null) { + return; + } + + // The containing method can be called from onStart: queue this work so that + // the first launch of the activity doesn't trigger profile init too early. + // + // Additionally, getAndIncrementSequenceNumber must be called from a worker thread. + ThreadUtils.postToBackgroundThread(new Runnable() { + @WorkerThread + @Override + public void run() { + final BrowserApp activity = getBrowserApp(); + if (activity == null) { + return; + } + + final GeckoProfile profile = activity.getProfile(); + if (!TelemetryUploadService.isUploadEnabledByProfileConfig(activity, profile)) { + Log.d(LOGTAG, "Core ping upload disabled by profile config. Returning."); + return; + } + + final String clientID; + try { + clientID = profile.getClientId(); + } catch (final IOException e) { + Log.w(LOGTAG, "Unable to get client ID to generate core ping: " + e); + return; + } + + // Each profile can have different telemetry data so we intentionally grab the shared prefs for the profile. + final SharedPreferences sharedPrefs = getSharedPreferences(activity); + final SessionMeasurements.SessionMeasurementsContainer sessionMeasurementsContainer = + sessionMeasurements.getAndResetSessionMeasurements(activity); + final TelemetryCorePingBuilder pingBuilder = new TelemetryCorePingBuilder(activity) + .setClientID(clientID) + .setDefaultSearchEngine(TelemetryCorePingBuilder.getEngineIdentifier(engine)) + .setProfileCreationDate(TelemetryCorePingBuilder.getProfileCreationDate(activity, profile)) + .setSequenceNumber(TelemetryCorePingBuilder.getAndIncrementSequenceNumber(sharedPrefs)) + .setSessionCount(sessionMeasurementsContainer.sessionCount) + .setSessionDuration(sessionMeasurementsContainer.elapsedSeconds); + maybeSetOptionalMeasurements(activity, sharedPrefs, pingBuilder); + + getTelemetryDispatcher(activity).queuePingForUpload(activity, pingBuilder); + } + }); + } + + private void maybeSetOptionalMeasurements(final Context context, final SharedPreferences sharedPrefs, + final TelemetryCorePingBuilder pingBuilder) { + final String distributionId = sharedPrefs.getString(DistributionStoreCallback.PREF_DISTRIBUTION_ID, null); + if (distributionId != null) { + pingBuilder.setOptDistributionID(distributionId); + } + + final ExtendedJSONObject searchCounts = SearchCountMeasurements.getAndZeroSearch(sharedPrefs); + if (searchCounts.size() > 0) { + pingBuilder.setOptSearchCounts(searchCounts); + } + + final String campaignId = CampaignIdMeasurements.getCampaignIdFromPrefs(context); + if (campaignId != null) { + pingBuilder.setOptCampaignId(campaignId); + } + } + + @Override + public void onCampaignIdChanged(String campaignId) { + CampaignIdMeasurements.updateCampaignIdPref(getBrowserApp(), campaignId); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java new file mode 100644 index 000000000..c702bb92c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java @@ -0,0 +1,118 @@ +/* + * 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.telemetry; + +import android.content.Context; +import android.support.annotation.WorkerThread; +import android.util.Log; +import org.mozilla.gecko.telemetry.pingbuilders.TelemetryCorePingBuilder; +import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadScheduler; +import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadAllPingsImmediatelyScheduler; +import org.mozilla.gecko.telemetry.stores.TelemetryJSONFilePingStore; +import org.mozilla.gecko.telemetry.stores.TelemetryPingStore; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.File; +import java.io.IOException; + +/** + * The entry-point for Java-based telemetry. This class handles: + * * Initializing the Stores & Schedulers. + * * Queueing upload requests for a given ping. + * + * To test Telemetry , see {@link TelemetryConstants} & + * https://wiki.mozilla.org/Mobile/Fennec/Android/Java_telemetry. + * + * The full architecture is: + * + * Fennec -(PingBuilder)-> Dispatcher -2-> Scheduler -> UploadService + * | 1 | + * Store <-------------------------- + * + * The store acts as a single store of truth and contains a list of all + * pings waiting to be uploaded. The dispatcher will queue a ping to upload + * by writing it to the store. Later, the UploadService will try to upload + * this queued ping by reading directly from the store. + * + * To implement a new ping type, you should: + * 1) Implement a {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder} for your ping type. + * 2) Re-use a ping store in .../stores/ or implement a new one: {@link TelemetryPingStore}. The + * type of store may be affected by robustness requirements (e.g. do you have data in addition to + * pings that need to be atomically updated when a ping is stored?) and performance requirements. + * 3) Re-use an upload scheduler in .../schedulers/ or implement a new one: {@link TelemetryUploadScheduler}. + * 4) Initialize your Store & (if new) Scheduler in the constructor of this class + * 5) Add a queuePingForUpload method for your PingBuilder class (see + * {@link #queuePingForUpload(Context, TelemetryCorePingBuilder)}) + * 6) In Fennec, where you want to store a ping and attempt upload, create a PingBuilder and + * pass it to the new queuePingForUpload method. + */ +public class TelemetryDispatcher { + private static final String LOGTAG = "Gecko" + TelemetryDispatcher.class.getSimpleName(); + + private static final String STORE_CONTAINER_DIR_NAME = "telemetry_java"; + private static final String CORE_STORE_DIR_NAME = "core"; + + private final TelemetryJSONFilePingStore coreStore; + + private final TelemetryUploadAllPingsImmediatelyScheduler uploadAllPingsImmediatelyScheduler; + + @WorkerThread // via TelemetryJSONFilePingStore + public TelemetryDispatcher(final String profilePath, final String profileName) { + final String storePath = profilePath + File.separator + STORE_CONTAINER_DIR_NAME; + + // There are measurements in the core ping (e.g. seq #) that would ideally be atomically updated + // when the ping is stored. However, for simplicity, we use the json store and accept the possible + // loss of data (see bug 1243585 comment 16+ for more). + coreStore = new TelemetryJSONFilePingStore(new File(storePath, CORE_STORE_DIR_NAME), profileName); + + uploadAllPingsImmediatelyScheduler = new TelemetryUploadAllPingsImmediatelyScheduler(); + } + + private void queuePingForUpload(final Context context, final TelemetryPing ping, final TelemetryPingStore store, + final TelemetryUploadScheduler scheduler) { + final QueuePingRunnable runnable = new QueuePingRunnable(context, ping, store, scheduler); + ThreadUtils.postToBackgroundThread(runnable); // TODO: Investigate how busy this thread is. See if we want another. + } + + /** + * Queues the given ping for upload and potentially schedules upload. This method can be called from any thread. + */ + public void queuePingForUpload(final Context context, final TelemetryCorePingBuilder pingBuilder) { + final TelemetryPing ping = pingBuilder.build(); + queuePingForUpload(context, ping, coreStore, uploadAllPingsImmediatelyScheduler); + } + + private static class QueuePingRunnable implements Runnable { + private final Context applicationContext; + private final TelemetryPing ping; + private final TelemetryPingStore store; + private final TelemetryUploadScheduler scheduler; + + public QueuePingRunnable(final Context context, final TelemetryPing ping, final TelemetryPingStore store, + final TelemetryUploadScheduler scheduler) { + this.applicationContext = context.getApplicationContext(); + this.ping = ping; + this.store = store; + this.scheduler = scheduler; + } + + @Override + public void run() { + // We block while storing the ping so the scheduled upload is guaranteed to have the newly-stored value. + try { + store.storePing(ping); + } catch (final IOException e) { + // Don't log exception to avoid leaking profile path. + Log.e(LOGTAG, "Unable to write ping to disk. Continuing with upload attempt"); + } + + if (scheduler.isReadyToUpload(store)) { + scheduler.scheduleUpload(applicationContext, store); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java new file mode 100644 index 000000000..b6ee9c2d8 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java @@ -0,0 +1,34 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.telemetry; + +import org.mozilla.gecko.sync.ExtendedJSONObject; + +/** + * Container for telemetry data and the data necessary to upload it. + * + * The doc ID is used by a Store to manipulate its internal pings and should + * be the same value found in the urlPath. + * + * If you want to create one of these, consider extending + * {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder} + * or one of its descendants. + */ +public class TelemetryPing { + private final String urlPath; + private final ExtendedJSONObject payload; + private final String docID; + + public TelemetryPing(final String urlPath, final ExtendedJSONObject payload, final String docID) { + this.urlPath = urlPath; + this.payload = payload; + this.docID = docID; + } + + public String getURLPath() { return urlPath; } + public ExtendedJSONObject getPayload() { return payload; } + public String getDocID() { return docID; } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java new file mode 100644 index 000000000..329f5b803 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java @@ -0,0 +1,73 @@ +/* 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.telemetry; + +import android.content.Context; +import android.content.SharedPreferences; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.PrefsHelper.PrefHandler; + +import java.lang.ref.WeakReference; + +/** + * Manages getting and setting any preferences related to telemetry. + * + * This class persists any Gecko preferences beyond shutdown so that these values + * can be accessed on the next run before Gecko is started as we expect Telemetry + * to run before Gecko is available. + */ +public class TelemetryPreferences { + private TelemetryPreferences() {} + + private static final String GECKO_PREF_SERVER_URL = "toolkit.telemetry.server"; + private static final String SHARED_PREF_SERVER_URL = "telemetry-serverUrl"; + + // Defaults are a mirror of about:config defaults so we can access them before Gecko is available. + private static final String DEFAULT_SERVER_URL = "https://incoming.telemetry.mozilla.org"; + + private static final String[] OBSERVED_PREFS = { + GECKO_PREF_SERVER_URL, + }; + + public static String getServerSchemeHostPort(final Context context, final String profileName) { + return getSharedPrefs(context, profileName).getString(SHARED_PREF_SERVER_URL, DEFAULT_SERVER_URL); + } + + public static void initPreferenceObserver(final Context context, final String profileName) { + final PrefHandler prefHandler = new TelemetryPrefHandler(context, profileName); + PrefsHelper.addObserver(OBSERVED_PREFS, prefHandler); // gets preference value when gecko starts. + } + + private static SharedPreferences getSharedPrefs(final Context context, final String profileName) { + return GeckoSharedPrefs.forProfileName(context, profileName); + } + + private static class TelemetryPrefHandler extends PrefsHelper.PrefHandlerBase { + private final WeakReference<Context> contextWeakReference; + private final String profileName; + + private TelemetryPrefHandler(final Context context, final String profileName) { + contextWeakReference = new WeakReference<>(context); + this.profileName = profileName; + } + + @Override + public void prefValue(final String pref, final String value) { + final Context context = contextWeakReference.get(); + if (context == null) { + return; + } + + if (!pref.equals(GECKO_PREF_SERVER_URL)) { + throw new IllegalStateException("Unknown preference: " + pref); + } + + getSharedPrefs(context, profileName).edit() + .putString(SHARED_PREF_SERVER_URL, value) + .apply(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java new file mode 100644 index 000000000..543281174 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java @@ -0,0 +1,347 @@ +/* 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.telemetry; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.telemetry.stores.TelemetryPingStore; +import org.mozilla.gecko.util.DateUtil; +import org.mozilla.gecko.util.NetworkUtils; +import org.mozilla.gecko.util.StringUtils; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.Calendar; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * The service that handles retrieving a list of telemetry pings to upload from the given + * {@link TelemetryPingStore}, uploading those payloads to the associated server, and reporting + * back to the Store which uploads were a success. + */ +public class TelemetryUploadService extends IntentService { + private static final String LOGTAG = StringUtils.safeSubstring("Gecko" + TelemetryUploadService.class.getSimpleName(), 0, 23); + private static final String WORKER_THREAD_NAME = LOGTAG + "Worker"; + + public static final String ACTION_UPLOAD = "upload"; + public static final String EXTRA_STORE = "store"; + + // TelemetryUploadService can run in a background thread so for future proofing, we set it volatile. + private static volatile boolean isDisabled = false; + + public static void setDisabled(final boolean isDisabled) { + TelemetryUploadService.isDisabled = isDisabled; + if (isDisabled) { + Log.d(LOGTAG, "Telemetry upload disabled (env var?"); + } + } + + public TelemetryUploadService() { + super(WORKER_THREAD_NAME); + + // Intent redelivery can fail hard (e.g. we OOM as we try to upload, the Intent gets redelivered, repeat) + // so for simplicity, we avoid it. We expect the upload service to eventually get called again by the caller. + setIntentRedelivery(false); + } + + /** + * Handles a ping with the mandatory extras: + * * EXTRA_STORE: A {@link TelemetryPingStore} where the pings to upload are located + */ + @Override + public void onHandleIntent(final Intent intent) { + Log.d(LOGTAG, "Service started"); + + if (!isReadyToUpload(this, intent)) { + return; + } + + final TelemetryPingStore store = intent.getParcelableExtra(EXTRA_STORE); + final boolean wereAllUploadsSuccessful = uploadPendingPingsFromStore(this, store); + store.maybePrunePings(); + Log.d(LOGTAG, "Service finished: upload and prune attempts completed"); + + if (!wereAllUploadsSuccessful) { + // If we had an upload failure, we should stop the IntentService and drop any + // pending Intents in the queue so we don't waste resources (e.g. battery) + // trying to upload when there's likely to be another connection failure. + Log.d(LOGTAG, "Clearing Intent queue due to connection failures"); + stopSelf(); + } + } + + /** + * @return true if all pings were uploaded successfully, false otherwise. + */ + private static boolean uploadPendingPingsFromStore(final Context context, final TelemetryPingStore store) { + final List<TelemetryPing> pingsToUpload = store.getAllPings(); + if (pingsToUpload.isEmpty()) { + return true; + } + + final String serverSchemeHostPort = TelemetryPreferences.getServerSchemeHostPort(context, store.getProfileName()); + final HashSet<String> successfulUploadIDs = new HashSet<>(pingsToUpload.size()); // used for side effects. + final PingResultDelegate delegate = new PingResultDelegate(successfulUploadIDs); + for (final TelemetryPing ping : pingsToUpload) { + // TODO: It'd be great to re-use the same HTTP connection for each upload request. + delegate.setDocID(ping.getDocID()); + final String url = serverSchemeHostPort + "/" + ping.getURLPath(); + uploadPayload(url, ping.getPayload(), delegate); + + // There are minimal gains in trying to upload if we already failed one attempt. + if (delegate.hadConnectionError()) { + break; + } + } + + final boolean wereAllUploadsSuccessful = !delegate.hadConnectionError(); + if (wereAllUploadsSuccessful) { + // We don't log individual successful uploads to avoid log spam. + Log.d(LOGTAG, "Telemetry upload success!"); + } + store.onUploadAttemptComplete(successfulUploadIDs); + return wereAllUploadsSuccessful; + } + + private static void uploadPayload(final String url, final ExtendedJSONObject payload, final ResultDelegate delegate) { + final BaseResource resource; + try { + resource = new BaseResource(url); + } catch (final URISyntaxException e) { + Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning."); + return; + } + + delegate.setResource(resource); + resource.delegate = delegate; + resource.setShouldCompressUploadedEntity(true); + resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking. + + // We're in a background thread so we don't have any reason to do this asynchronously. + // If we tried, onStartCommand would return and IntentService might stop itself before we finish. + resource.postBlocking(payload); + } + + private static boolean isReadyToUpload(final Context context, final Intent intent) { + // Sanity check: is upload enabled? Generally, the caller should check this before starting the service. + // Since we don't have the profile here, we rely on the caller to check the enabled state for the profile. + if (!isUploadEnabledByAppConfig(context)) { + Log.w(LOGTAG, "Upload is not available by configuration; returning"); + return false; + } + + if (!NetworkUtils.isConnected(context)) { + Log.w(LOGTAG, "Network is not connected; returning"); + return false; + } + + if (!isIntentValid(intent)) { + Log.w(LOGTAG, "Received invalid Intent; returning"); + return false; + } + + if (!ACTION_UPLOAD.equals(intent.getAction())) { + Log.w(LOGTAG, "Unknown action: " + intent.getAction() + ". Returning"); + return false; + } + + return true; + } + + /** + * Determines if the telemetry upload feature is enabled via the application configuration. Prefer to use + * {@link #isUploadEnabledByProfileConfig(Context, GeckoProfile)} if the profile is available as it takes into + * account more information. + * + * You may wish to also check if the network is connected when calling this method. + * + * Note that this method logs debug statements when upload is disabled. + */ + public static boolean isUploadEnabledByAppConfig(final Context context) { + if (!TelemetryConstants.UPLOAD_ENABLED) { + Log.d(LOGTAG, "Telemetry upload feature is compile-time disabled"); + return false; + } + + if (isDisabled) { + Log.d(LOGTAG, "Telemetry upload feature is disabled by intent (in testing?)"); + return false; + } + + if (!GeckoPreferences.getBooleanPref(context, GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true)) { + Log.d(LOGTAG, "Telemetry upload opt-out"); + return false; + } + + if (Restrictions.isRestrictedProfile(context) && + !Restrictions.isAllowed(context, Restrictable.HEALTH_REPORT)) { + Log.d(LOGTAG, "Telemetry upload feature disabled by admin profile"); + return false; + } + + return true; + } + + /** + * Determines if the telemetry upload feature is enabled via profile & application level configurations. This is the + * preferred method. + * + * You may wish to also check if the network is connected when calling this method. + * + * Note that this method logs debug statements when upload is disabled. + */ + public static boolean isUploadEnabledByProfileConfig(final Context context, final GeckoProfile profile) { + if (profile.inGuestMode()) { + Log.d(LOGTAG, "Profile is in guest mode"); + return false; + } + + return isUploadEnabledByAppConfig(context); + } + + private static boolean isIntentValid(final Intent intent) { + // Intent can be null. Bug 1025937. + if (intent == null) { + Log.d(LOGTAG, "Received null intent"); + return false; + } + + if (intent.getParcelableExtra(EXTRA_STORE) == null) { + Log.d(LOGTAG, "Received invalid store in Intent"); + return false; + } + + return true; + } + + /** + * Logs on success & failure and appends the set ID to the given Set on success. + * + * Note: you *must* set the ping ID before attempting upload or we'll throw! + * + * We use mutation on the set ID and the successful upload array to avoid object allocation. + */ + private static class PingResultDelegate extends ResultDelegate { + // We persist pings and don't need to worry about losing data so we keep these + // durations short to save resources (e.g. battery). + private static final int SOCKET_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30); + private static final int CONNECTION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30); + + /** The store ID of the ping currently being uploaded. Use {@link #getDocID()} to access it. */ + private String docID = null; + private final Set<String> successfulUploadIDs; + + private boolean hadConnectionError = false; + + public PingResultDelegate(final Set<String> successfulUploadIDs) { + super(); + this.successfulUploadIDs = successfulUploadIDs; + } + + @Override + public int socketTimeout() { + return SOCKET_TIMEOUT_MILLIS; + } + + @Override + public int connectionTimeout() { + return CONNECTION_TIMEOUT_MILLIS; + } + + private String getDocID() { + if (docID == null) { + throw new IllegalStateException("Expected ping ID to have been updated before retrieval"); + } + return docID; + } + + public void setDocID(final String id) { + docID = id; + } + + @Override + public String getUserAgent() { + return TelemetryConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(final HttpResponse response) { + final int status = response.getStatusLine().getStatusCode(); + switch (status) { + case 200: + case 201: + successfulUploadIDs.add(getDocID()); + break; + default: + Log.w(LOGTAG, "Telemetry upload failure. HTTP status: " + status); + hadConnectionError = true; + } + } + + @Override + public void handleHttpProtocolException(final ClientProtocolException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "HttpProtocolException when trying to upload telemetry"); + hadConnectionError = true; + } + + @Override + public void handleHttpIOException(final IOException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "HttpIOException when trying to upload telemetry"); + hadConnectionError = true; + } + + @Override + public void handleTransportException(final GeneralSecurityException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "Transport exception when trying to upload telemetry"); + hadConnectionError = true; + } + + private boolean hadConnectionError() { + return hadConnectionError; + } + + @Override + public void addHeaders(final HttpRequestBase request, final DefaultHttpClient client) { + super.addHeaders(request, client); + request.addHeader(HttpHeaders.DATE, DateUtil.getDateInHTTPFormat(Calendar.getInstance().getTime())); + } + } + + /** + * A hack because I want to set the resource after the Delegate is constructed. + * Be sure to call {@link #setResource(Resource)}! + */ + private static abstract class ResultDelegate extends BaseResourceDelegate { + public ResultDelegate() { + super(null); + } + + protected void setResource(final Resource resource) { + this.resource = resource; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java new file mode 100644 index 000000000..61229b21b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java @@ -0,0 +1,37 @@ +/* + * 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.telemetry.measurements; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.adjust.AttributionHelperListener; + +/** + * A class to retrieve and store the campaign Id pref that is used when the Adjust SDK gives us + * new attribution from the {@link AttributionHelperListener}. + */ +public class CampaignIdMeasurements { + private static final String PREF_CAMPAIGN_ID = "measurements-campaignId"; + + public static String getCampaignIdFromPrefs(@NonNull final Context context) { + return GeckoSharedPrefs.forProfile(context) + .getString(PREF_CAMPAIGN_ID, null); + } + + public static void updateCampaignIdPref(@NonNull final Context context, @NonNull final String campaignId) { + if (TextUtils.isEmpty(campaignId)) { + return; + } + GeckoSharedPrefs.forProfile(context) + .edit() + .putString(PREF_CAMPAIGN_ID, campaignId) + .apply(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java new file mode 100644 index 000000000..c08ad6c02 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java @@ -0,0 +1,100 @@ +/* + * 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.telemetry.measurements; + +import android.content.SharedPreferences; +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import org.mozilla.gecko.sync.ExtendedJSONObject; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * A place to store and retrieve the number of times a user has searched with a specific engine from a + * specific location. This is designed for use as a telemetry core ping measurement. + * + * The implementation works by storing a preference for each engine-location pair and incrementing them + * each time {@link #incrementSearch(SharedPreferences, String, String)} is called. In order to + * retrieve the full set of keys later, we store all the available key names in another preference. + * + * When we retrieve the keys in {@link #getAndZeroSearch(SharedPreferences)} (using the set of keys + * preference), the values saved to the preferences are returned and the preferences are removed + * (i.e. zeroed) from Shared Preferences. The reason we remove the preferences (rather than actually + * zeroing them) is to avoid bloating shared preferences if 1) the set of engines ever changes or + * 2) we remove this feature. + * + * Since we increment a value on each successive search, which doesn't take up more space, we don't + * have to worry about using excess disk space if the measurements are never zeroed (e.g. telemetry + * upload is disabled). In the worst case, we overflow the integer and may return negative values. + * + * This class is thread-safe by locking access to its public methods. When this class was written, incrementing & + * retrieval were called from multiple threads so rather than enforcing the callers keep their threads straight, it + * was simpler to lock all access. + */ +public class SearchCountMeasurements { + /** The set of "engine + where" keys we've stored; used for retrieving stored engines. */ + @VisibleForTesting static final String PREF_SEARCH_KEYSET = "measurements-search-count-keyset"; + private static final String PREF_SEARCH_PREFIX = "measurements-search-count-engine-"; // + "engine.where" + + private SearchCountMeasurements() {} + + public static synchronized void incrementSearch(@NonNull final SharedPreferences prefs, + @NonNull final String engineIdentifier, @NonNull final String where) { + final String engineWhereStr = engineIdentifier + "." + where; + final String key = getEngineSearchCountKey(engineWhereStr); + + final int count = prefs.getInt(key, 0); + prefs.edit().putInt(key, count + 1).apply(); + + unionKeyToSearchKeyset(prefs, engineWhereStr); + } + + /** + * @param key Engine of the form, "engine.where" + */ + private static void unionKeyToSearchKeyset(@NonNull final SharedPreferences prefs, @NonNull final String key) { + final Set<String> keysFromPrefs = prefs.getStringSet(PREF_SEARCH_KEYSET, Collections.<String>emptySet()); + if (keysFromPrefs.contains(key)) { + return; + } + + // String set returned by shared prefs cannot be modified so we copy. + final Set<String> keysToSave = new HashSet<>(keysFromPrefs); + keysToSave.add(key); + prefs.edit().putStringSet(PREF_SEARCH_KEYSET, keysToSave).apply(); + } + + /** + * Gets and zeroes search counts. + * + * We return ExtendedJSONObject for now because that's the format needed by the core telemetry ping. + */ + public static synchronized ExtendedJSONObject getAndZeroSearch(@NonNull final SharedPreferences prefs) { + final ExtendedJSONObject out = new ExtendedJSONObject(); + final SharedPreferences.Editor editor = prefs.edit(); + + final Set<String> keysFromPrefs = prefs.getStringSet(PREF_SEARCH_KEYSET, Collections.<String>emptySet()); + for (final String engineWhereStr : keysFromPrefs) { + final String key = getEngineSearchCountKey(engineWhereStr); + out.put(engineWhereStr, prefs.getInt(key, 0)); + editor.remove(key); + } + editor.remove(PREF_SEARCH_KEYSET) + .apply(); + return out; + } + + /** + * @param engineWhereStr string of the form "engine.where" + * @return the key for the engines' search counts in shared preferences + */ + @VisibleForTesting static String getEngineSearchCountKey(final String engineWhereStr) { + return PREF_SEARCH_PREFIX + engineWhereStr; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java new file mode 100644 index 000000000..6f7d2127a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java @@ -0,0 +1,99 @@ +/* + * 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.telemetry.measurements; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.UiThread; +import android.support.annotation.VisibleForTesting; +import org.mozilla.gecko.GeckoSharedPrefs; + +import java.util.concurrent.TimeUnit; + +/** + * A class to measure the number of user sessions & their durations. It was created for use with the + * telemetry core ping. A session is the time between {@link #recordSessionStart()} and + * {@link #recordSessionEnd(Context)}. + * + * This class is thread-safe, provided the thread annotations are followed. Under the hood, this class uses + * SharedPreferences & because there is no atomic getAndSet operation, we synchronize access to it. + */ +public class SessionMeasurements { + @VisibleForTesting static final String PREF_SESSION_COUNT = "measurements-session-count"; + @VisibleForTesting static final String PREF_SESSION_DURATION = "measurements-session-duration"; + + private boolean sessionStarted = false; + private long timeAtSessionStartNano = -1; + + @UiThread // we assume this will be called on the same thread as session end so we don't have to synchronize sessionStarted. + public void recordSessionStart() { + if (sessionStarted) { + throw new IllegalStateException("Trying to start session but it is already started"); + } + sessionStarted = true; + timeAtSessionStartNano = getSystemTimeNano(); + } + + @UiThread // we assume this will be called on the same thread as session start so we don't have to synchronize sessionStarted. + public void recordSessionEnd(final Context context) { + if (!sessionStarted) { + throw new IllegalStateException("Expected session to be started before session end is called"); + } + sessionStarted = false; + + final long sessionElapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(getSystemTimeNano() - timeAtSessionStartNano); + final SharedPreferences sharedPrefs = getSharedPreferences(context); + synchronized (this) { + final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0); + final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0); + sharedPrefs.edit() + .putInt(PREF_SESSION_COUNT, sessionCount + 1) + .putLong(PREF_SESSION_DURATION, totalElapsedSeconds + sessionElapsedSeconds) + .apply(); + } + } + + /** + * Gets the session measurements since the last time the measurements were last retrieved. + */ + public synchronized SessionMeasurementsContainer getAndResetSessionMeasurements(final Context context) { + final SharedPreferences sharedPrefs = getSharedPreferences(context); + final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0); + final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0); + sharedPrefs.edit() + .putInt(PREF_SESSION_COUNT, 0) + .putLong(PREF_SESSION_DURATION, 0) + .apply(); + return new SessionMeasurementsContainer(sessionCount, totalElapsedSeconds); + } + + @VisibleForTesting SharedPreferences getSharedPreferences(final Context context) { + return GeckoSharedPrefs.forProfile(context); + } + + /** + * Returns (roughly) the system uptime in nanoseconds. A less coupled implementation would + * take this value from the caller of recordSession*, however, we do this internally to ensure + * the caller uses both a time system consistent between the start & end calls and uses the + * appropriate time system (i.e. not wall time, which can change when the clock is changed). + */ + @VisibleForTesting long getSystemTimeNano() { // TODO: necessary? + return System.nanoTime(); + } + + public static final class SessionMeasurementsContainer { + /** The number of sessions. */ + public final int sessionCount; + /** The number of seconds elapsed in ALL sessions included in {@link #sessionCount}. */ + public final long elapsedSeconds; + + private SessionMeasurementsContainer(final int sessionCount, final long elapsedSeconds) { + this.sessionCount = sessionCount; + this.elapsedSeconds = elapsedSeconds; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java new file mode 100644 index 000000000..3f5480f37 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java @@ -0,0 +1,247 @@ +/* + * 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.telemetry.pingbuilders; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; + +import android.util.Log; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.search.SearchEngine; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.telemetry.TelemetryPing; +import org.mozilla.gecko.util.DateUtil; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.util.StringUtils; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Builds a {@link TelemetryPing} representing a core ping. + * + * See https://gecko.readthedocs.org/en/latest/toolkit/components/telemetry/telemetry/core-ping.html + * for details on the core ping. + */ +public class TelemetryCorePingBuilder extends TelemetryPingBuilder { + private static final String LOGTAG = StringUtils.safeSubstring(TelemetryCorePingBuilder.class.getSimpleName(), 0, 23); + + // For legacy reasons, this preference key is not namespaced with "core". + private static final String PREF_SEQ_COUNT = "telemetry-seqCount"; + + private static final String NAME = "core"; + private static final int VERSION_VALUE = 7; // For version history, see toolkit/components/telemetry/docs/core-ping.rst + private static final String OS_VALUE = "Android"; + + private static final String ARCHITECTURE = "arch"; + private static final String CAMPAIGN_ID = "campaignId"; + private static final String CLIENT_ID = "clientId"; + private static final String DEFAULT_SEARCH_ENGINE = "defaultSearch"; + private static final String DEVICE = "device"; + private static final String DISTRIBUTION_ID = "distributionId"; + private static final String EXPERIMENTS = "experiments"; + private static final String LOCALE = "locale"; + private static final String OS_ATTR = "os"; + private static final String OS_VERSION = "osversion"; + private static final String PING_CREATION_DATE = "created"; + private static final String PROFILE_CREATION_DATE = "profileDate"; + private static final String SEARCH_COUNTS = "searches"; + private static final String SEQ = "seq"; + private static final String SESSION_COUNT = "sessions"; + private static final String SESSION_DURATION = "durations"; + private static final String TIMEZONE_OFFSET = "tz"; + private static final String VERSION_ATTR = "v"; + + public TelemetryCorePingBuilder(final Context context) { + initPayloadConstants(context); + } + + private void initPayloadConstants(final Context context) { + payload.put(VERSION_ATTR, VERSION_VALUE); + payload.put(OS_ATTR, OS_VALUE); + + // We limit the device descriptor to 32 characters because it can get long. We give fewer characters to the + // manufacturer because we're less likely to have manufacturers with similar names than we are for a + // manufacturer to have two devices with the similar names (e.g. Galaxy S6 vs. Galaxy Note 6). + final String deviceDescriptor = + StringUtils.safeSubstring(Build.MANUFACTURER, 0, 12) + '-' + StringUtils.safeSubstring(Build.MODEL, 0, 19); + + final Calendar nowCalendar = Calendar.getInstance(); + final DateFormat pingCreationDateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + + payload.put(ARCHITECTURE, AppConstants.ANDROID_CPU_ARCH); + payload.put(DEVICE, deviceDescriptor); + payload.put(LOCALE, Locales.getLanguageTag(Locale.getDefault())); + payload.put(OS_VERSION, Integer.toString(Build.VERSION.SDK_INT)); // A String for cross-platform reasons. + payload.put(PING_CREATION_DATE, pingCreationDateFormat.format(nowCalendar.getTime())); + payload.put(TIMEZONE_OFFSET, DateUtil.getTimezoneOffsetInMinutesForGivenDate(nowCalendar)); + payload.putArray(EXPERIMENTS, Experiments.getActiveExperiments(context)); + } + + @Override + public String getDocType() { + return NAME; + } + + @Override + public String[] getMandatoryFields() { + return new String[] { + ARCHITECTURE, + CLIENT_ID, + DEFAULT_SEARCH_ENGINE, + DEVICE, + LOCALE, + OS_ATTR, + OS_VERSION, + PING_CREATION_DATE, + PROFILE_CREATION_DATE, + SEQ, + TIMEZONE_OFFSET, + VERSION_ATTR, + }; + } + + public TelemetryCorePingBuilder setClientID(@NonNull final String clientID) { + if (clientID == null) { + throw new IllegalArgumentException("Expected non-null clientID"); + } + payload.put(CLIENT_ID, clientID); + return this; + } + + /** + * @param engine the default search engine identifier, or null if there is an error. + */ + public TelemetryCorePingBuilder setDefaultSearchEngine(@Nullable final String engine) { + if (engine != null && engine.isEmpty()) { + throw new IllegalArgumentException("Received empty string. Expected identifier or null."); + } + payload.put(DEFAULT_SEARCH_ENGINE, engine); + return this; + } + + public TelemetryCorePingBuilder setOptDistributionID(@NonNull final String distributionID) { + if (distributionID == null) { + throw new IllegalArgumentException("Expected non-null distribution ID"); + } + payload.put(DISTRIBUTION_ID, distributionID); + return this; + } + + /** + * @param searchCounts non-empty JSON with {"engine.where": <int-count>} + */ + public TelemetryCorePingBuilder setOptSearchCounts(@NonNull final ExtendedJSONObject searchCounts) { + if (searchCounts == null) { + throw new IllegalStateException("Expected non-null search counts"); + } else if (searchCounts.size() == 0) { + throw new IllegalStateException("Expected non-empty search counts"); + } + + payload.put(SEARCH_COUNTS, searchCounts); + return this; + } + + public TelemetryCorePingBuilder setOptCampaignId(final String campaignId) { + if (campaignId == null) { + throw new IllegalStateException("Expected non-null campaign ID."); + } + payload.put(CAMPAIGN_ID, campaignId); + return this; + } + + /** + * @param date The profile creation date in days to the unix epoch (not millis!), or null if there is an error. + */ + public TelemetryCorePingBuilder setProfileCreationDate(@Nullable final Long date) { + if (date != null && date < 0) { + throw new IllegalArgumentException("Expect positive date value. Received: " + date); + } + payload.put(PROFILE_CREATION_DATE, date); + return this; + } + + /** + * @param seq a positive sequence number. + */ + public TelemetryCorePingBuilder setSequenceNumber(final int seq) { + if (seq < 0) { + // Since this is an increasing value, it's possible we can overflow into negative values and get into a + // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server. + Log.w(LOGTAG, "Expected positive sequence number. Received: " + seq); + } + payload.put(SEQ, seq); + return this; + } + + public TelemetryCorePingBuilder setSessionCount(final int sessionCount) { + if (sessionCount < 0) { + // Since this is an increasing value, it's possible we can overflow into negative values and get into a + // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server. + Log.w(LOGTAG, "Expected positive session count. Received: " + sessionCount); + } + payload.put(SESSION_COUNT, sessionCount); + return this; + } + + public TelemetryCorePingBuilder setSessionDuration(final long sessionDuration) { + if (sessionDuration < 0) { + // Since this is an increasing value, it's possible we can overflow into negative values and get into a + // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server. + Log.w(LOGTAG, "Expected positive session duration. Received: " + sessionDuration); + } + payload.put(SESSION_DURATION, sessionDuration); + return this; + } + + /** + * Gets the sequence number from shared preferences and increments it in the prefs. This method + * is not thread safe. + */ + @WorkerThread // synchronous shared prefs write. + public static int getAndIncrementSequenceNumber(final SharedPreferences sharedPrefsForProfile) { + final int seq = sharedPrefsForProfile.getInt(PREF_SEQ_COUNT, 1); + + sharedPrefsForProfile.edit().putInt(PREF_SEQ_COUNT, seq + 1).apply(); + return seq; + } + + /** + * @return the profile creation date in the format expected by + * {@link TelemetryCorePingBuilder#setProfileCreationDate(Long)}. + */ + @WorkerThread + public static Long getProfileCreationDate(final Context context, final GeckoProfile profile) { + final long profileMillis = profile.getAndPersistProfileCreationDate(context); + if (profileMillis < 0) { + return null; + } + return (long) Math.floor((double) profileMillis / TimeUnit.DAYS.toMillis(1)); + } + + /** + * @return the search engine identifier in the format expected by the core ping. + */ + @Nullable + public static String getEngineIdentifier(@Nullable final SearchEngine searchEngine) { + if (searchEngine == null) { + return null; + } + final String identifier = searchEngine.getIdentifier(); + return TextUtils.isEmpty(identifier) ? null : identifier; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java new file mode 100644 index 000000000..57fa0fd8b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java @@ -0,0 +1,87 @@ +/* + * 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.telemetry.pingbuilders; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.telemetry.TelemetryPing; + +import java.util.Set; +import java.util.UUID; + +/** + * A generic Builder for {@link TelemetryPing} instances. Each overriding class is + * expected to create a specific type of ping (e.g. "core"). + * + * This base class handles the common ping operations under the hood: + * * Validating mandatory fields + * * Forming the server url + */ +abstract class TelemetryPingBuilder { + // In the server url, the initial path directly after the "scheme://host:port/" + private static final String SERVER_INITIAL_PATH = "submit/telemetry"; + + private final String serverPath; + protected final ExtendedJSONObject payload; + private final String docID; + + public TelemetryPingBuilder() { + docID = UUID.randomUUID().toString(); + serverPath = getTelemetryServerPath(getDocType(), docID); + payload = new ExtendedJSONObject(); + } + + /** + * @return the name of the ping (e.g. "core") + */ + public abstract String getDocType(); + + /** + * @return the fields that are mandatory for the resultant ping to be uploaded to + * the server. These will be validated before the ping is built. + */ + public abstract String[] getMandatoryFields(); + + public TelemetryPing build() { + validatePayload(); + return new TelemetryPing(serverPath, payload, docID); + } + + private void validatePayload() { + final Set<String> keySet = payload.keySet(); + for (final String mandatoryField : getMandatoryFields()) { + if (!keySet.contains(mandatoryField)) { + throw new IllegalArgumentException("Builder does not contain mandatory field: " + + mandatoryField); + } + } + } + + /** + * Returns a url of the format: + * http://hostname/submit/telemetry/docId/docType/appName/appVersion/appUpdateChannel/appBuildID + * + * @param docType The name of the ping (e.g. "main") + * @return a url at which to POST the telemetry data to + */ + private static String getTelemetryServerPath(final String docType, final String docID) { + final String appName = AppConstants.MOZ_APP_BASENAME; + final String appVersion = AppConstants.MOZ_APP_VERSION; + final String appUpdateChannel = AppConstants.MOZ_UPDATE_CHANNEL; + final String appBuildId = AppConstants.MOZ_APP_BUILDID; + + // The compiler will optimize a single String concatenation into a StringBuilder statement. + // If you change this `return`, be sure to keep it as a single statement to keep it optimized! + return SERVER_INITIAL_PATH + '/' + + docID + '/' + + docType + '/' + + appName + '/' + + appVersion + '/' + + appUpdateChannel + '/' + + appBuildId; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java new file mode 100644 index 000000000..047a646c3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java @@ -0,0 +1,32 @@ +/* + * 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.telemetry.schedulers; + +import android.content.Context; +import android.content.Intent; +import org.mozilla.gecko.telemetry.stores.TelemetryPingStore; +import org.mozilla.gecko.telemetry.TelemetryUploadService; + +/** + * Schedules an upload with all pings to be sent immediately. + */ +public class TelemetryUploadAllPingsImmediatelyScheduler implements TelemetryUploadScheduler { + + @Override + public boolean isReadyToUpload(final TelemetryPingStore store) { + // We're ready since we don't have any conditions to wait on (e.g. on wifi, accumulated X pings). + return true; + } + + @Override + public void scheduleUpload(final Context applicationContext, final TelemetryPingStore store) { + final Intent i = new Intent(TelemetryUploadService.ACTION_UPLOAD); + i.setClass(applicationContext, TelemetryUploadService.class); + i.putExtra(TelemetryUploadService.EXTRA_STORE, store); + applicationContext.startService(i); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java new file mode 100644 index 000000000..63305aad5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java @@ -0,0 +1,26 @@ +/* + * 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.telemetry.schedulers; + +import android.content.Context; +import org.mozilla.gecko.telemetry.stores.TelemetryPingStore; + +/** + * An implementation of this class can investigate the given {@link TelemetryPingStore} to + * decide if it's ready to upload the pings inside that Store (e.g. on wifi? have we + * accumulated X pings?) and can schedule that upload. Typically, the upload will be + * scheduled by sending an {@link android.content.Intent} to the + * {@link org.mozilla.gecko.telemetry.TelemetryUploadService}, either immediately or + * via an external scheduler (e.g. {@link android.app.job.JobScheduler}). + * + * N.B.: If the Store is not ready to upload, an implementation *should not* try to reschedule + * the check to see if it's time to upload - this is expected to be handled by the caller. + */ +public interface TelemetryUploadScheduler { + boolean isReadyToUpload(TelemetryPingStore store); + void scheduleUpload(Context applicationContext, TelemetryPingStore store); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java new file mode 100644 index 000000000..d52382146 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java @@ -0,0 +1,301 @@ +/* + * 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.telemetry.stores; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.VisibleForTesting; +import android.support.annotation.WorkerThread; +import android.util.Log; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.NonObjectJSONException; +import org.mozilla.gecko.telemetry.TelemetryPing; +import org.mozilla.gecko.util.FileUtils; +import org.mozilla.gecko.util.FileUtils.FileLastModifiedComparator; +import org.mozilla.gecko.util.FileUtils.FilenameRegexFilter; +import org.mozilla.gecko.util.FileUtils.FilenameWhitelistFilter; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.UUIDUtil; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.nio.channels.FileLock; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * An implementation of TelemetryPingStore that is backed by JSON files. + * + * This implementation seeks simplicity. Each ping to upload is stored in its own file with its doc ID + * as the filename. The doc ID is sent with a ping to be uploaded and is expected to be returned with + * {@link #onUploadAttemptComplete(Set)} so the associated file can be removed. + * + * During prune, the pings with the oldest modified time will be removed first. Different filesystems will + * handle clock skew (e.g. manual time changes, daylight savings time, changing timezones) in different ways + * and we accept that these modified times may not be consistent - newer data is not more important than + * older data and the choice to delete the oldest data first is largely arbitrary so we don't care if + * the timestamps are occasionally inconsistent. + * + * Using separate files for this store allows for less restrictive concurrency: + * * requires locking: {@link #storePing(TelemetryPing)} writes a new file + * * requires locking: {@link #getAllPings()} reads all files, including those potentially being written, + * hence locking + * * no locking: {@link #maybePrunePings()} deletes the least recently written pings, none of which should + * be currently written + * * no locking: {@link #onUploadAttemptComplete(Set)} deletes the given pings, none of which should be + * currently written + */ +public class TelemetryJSONFilePingStore extends TelemetryPingStore { + private static final String LOGTAG = StringUtils.safeSubstring( + "Gecko" + TelemetryJSONFilePingStore.class.getSimpleName(), 0, 23); + + @VisibleForTesting static final int MAX_PING_COUNT = 40; // TODO: value. + + // We keep the key names short to reduce storage size impact. + @VisibleForTesting static final String KEY_PAYLOAD = "p"; + @VisibleForTesting static final String KEY_URL_PATH = "u"; + + private final File storeDir; + private final FilenameFilter uuidFilenameFilter; + private final FileLastModifiedComparator fileLastModifiedComparator = new FileLastModifiedComparator(); + + @WorkerThread // Writes to disk + public TelemetryJSONFilePingStore(final File storeDir, final String profileName) { + super(profileName); + if (storeDir.exists() && !storeDir.isDirectory()) { + // An alternative is to create a new directory, but we wouldn't + // be able to access it later so it's better to throw. + throw new IllegalStateException("Store dir unexpectedly exists & is not a directory - cannot continue"); + } + + this.storeDir = storeDir; + this.storeDir.mkdirs(); + uuidFilenameFilter = new FilenameRegexFilter(UUIDUtil.UUID_PATTERN); + + if (!this.storeDir.canRead() || !this.storeDir.canWrite() || !this.storeDir.canExecute()) { + throw new IllegalStateException("Cannot read, write, or execute store dir: " + + this.storeDir.canRead() + " " + this.storeDir.canWrite() + " " + this.storeDir.canExecute()); + } + } + + @VisibleForTesting File getPingFile(final String docID) { + return new File(storeDir, docID); + } + + @Override + public void storePing(final TelemetryPing ping) throws IOException { + final String output; + try { + output = new JSONObject() + .put(KEY_PAYLOAD, ping.getPayload()) + .put(KEY_URL_PATH, ping.getURLPath()) + .toString(); + } catch (final JSONException e) { + // Do not log the exception to avoid leaking personal data. + throw new IOException("Unable to create JSON to store to disk"); + } + + final FileOutputStream outputStream = new FileOutputStream(getPingFile(ping.getDocID()), false); + blockForLockAndWriteFileAndCloseStream(outputStream, output); + } + + @Override + public void maybePrunePings() { + final File[] files = storeDir.listFiles(uuidFilenameFilter); + if (files == null) { + return; + } + + if (files.length < MAX_PING_COUNT) { + return; + } + + // It's possible that multiple files will have the same timestamp: in this case they are treated + // as equal by the fileLastModifiedComparator. We therefore have to use a sorted list (as + // opposed to a set, or map). + final ArrayList<File> sortedFiles = new ArrayList<>(Arrays.asList(files)); + Collections.sort(sortedFiles, fileLastModifiedComparator); + deleteSmallestFiles(sortedFiles, files.length - MAX_PING_COUNT); + } + + private void deleteSmallestFiles(final ArrayList<File> files, final int numFilesToRemove) { + final Iterator<File> it = files.iterator(); + int i = 0; + + while (i < numFilesToRemove) { + i += 1; + + // Sorted list so we're iterating over ascending files. + final File file = it.next(); // file count > files to remove so this should not throw. + file.delete(); + } + } + + @Override + public ArrayList<TelemetryPing> getAllPings() { + final File[] fileArray = storeDir.listFiles(uuidFilenameFilter); + if (fileArray == null) { + // Intentionally don't log all info for the store directory to prevent leaking the path. + Log.w(LOGTAG, "listFiles unexpectedly returned null - unable to retrieve pings. Debug: exists? " + + storeDir.exists() + "; directory? " + storeDir.isDirectory()); + return new ArrayList<>(1); + } + + final List<File> files = Arrays.asList(fileArray); + Collections.sort(files, fileLastModifiedComparator); // oldest to newest + final ArrayList<TelemetryPing> out = new ArrayList<>(files.size()); + for (final File file : files) { + final JSONObject obj = lockAndReadJSONFromFile(file); + if (obj == null) { + // We log in the method to get the JSONObject if we return null. + continue; + } + + try { + final String url = obj.getString(KEY_URL_PATH); + final ExtendedJSONObject payload = new ExtendedJSONObject(obj.getString(KEY_PAYLOAD)); + out.add(new TelemetryPing(url, payload, file.getName())); + } catch (final IOException | JSONException | NonObjectJSONException e) { + Log.w(LOGTAG, "Bad json in ping. Ignoring."); + continue; + } + } + return out; + } + + /** + * Logs if there is an error. + * + * @return the JSON object from the given file or null if there is an error. + */ + private JSONObject lockAndReadJSONFromFile(final File file) { + // lockAndReadFileAndCloseStream doesn't handle file size of 0. + if (file.length() == 0) { + Log.w(LOGTAG, "Unexpected empty file: " + file.getName() + ". Ignoring"); + return null; + } + + final FileInputStream inputStream; + try { + inputStream = new FileInputStream(file); + } catch (final FileNotFoundException e) { + // permission problem might also cause same exception. To get more debug information. + String fileInfo = String.format("existence: %b, can write: %b, size: %d.", + file.exists(), file.canWrite(), file.length()); + String msg = String.format( + "Expected file to exist but got exception in thread: %s. File info - %s", + Thread.currentThread().getName(), fileInfo); + throw new IllegalStateException(msg); + } + + final JSONObject obj; + try { + // Potential optimization: re-use the same buffer for reading from files. + obj = lockAndReadFileAndCloseStream(inputStream, (int) file.length()); + } catch (final IOException | JSONException e) { + // We couldn't read this file so let's just skip it. These potentially + // corrupted files should be removed when the data is pruned. + Log.w(LOGTAG, "Error when reading file: " + file.getName() + " Likely corrupted. Ignoring"); + return null; + } + + if (obj == null) { + Log.d(LOGTAG, "Could not read given file: " + file.getName() + " File is locked. Ignoring"); + } + return obj; + } + + @Override + public void onUploadAttemptComplete(final Set<String> successfulRemoveIDs) { + if (successfulRemoveIDs.isEmpty()) { + return; + } + + final File[] files = storeDir.listFiles(new FilenameWhitelistFilter(successfulRemoveIDs)); + for (final File file : files) { + file.delete(); + } + } + + /** + * Locks the given {@link FileOutputStream} and writes the given String. This method will close the given stream. + * + * Note: this method blocks until a file lock can be acquired. + */ + private static void blockForLockAndWriteFileAndCloseStream(final FileOutputStream outputStream, final String str) + throws IOException { + try { + final FileLock lock = outputStream.getChannel().lock(0, Long.MAX_VALUE, false); + if (lock != null) { + // The file lock is released when the stream is closed. If we try to redundantly close it, we get + // a ClosedChannelException. To be safe, we could catch that every time but there is a performance + // hit to exception handling so instead we assume the file lock will be closed. + FileUtils.writeStringToOutputStreamAndCloseStream(outputStream, str); + } + } finally { + outputStream.close(); // redundant: closed when the stream is closed, but let's be safe. + } + } + + /** + * Locks the given {@link FileInputStream} and reads the data. This method will close the given stream. + * + * Note: this method returns null when a lock could not be acquired. + */ + private static JSONObject lockAndReadFileAndCloseStream(final FileInputStream inputStream, final int fileSize) + throws IOException, JSONException { + try { + final FileLock lock = inputStream.getChannel().tryLock(0, Long.MAX_VALUE, true); // null when lock not acquired + if (lock == null) { + return null; + } + // The file lock is released when the stream is closed. If we try to redundantly close it, we get + // a ClosedChannelException. To be safe, we could catch that every time but there is a performance + // hit to exception handling so instead we assume the file lock will be closed. + return new JSONObject(FileUtils.readStringFromInputStreamAndCloseStream(inputStream, fileSize)); + } finally { + inputStream.close(); // redundant: closed when the stream is closed, but let's be safe. + } + } + + public static final Parcelable.Creator<TelemetryJSONFilePingStore> CREATOR = new Parcelable.Creator<TelemetryJSONFilePingStore>() { + @Override + public TelemetryJSONFilePingStore createFromParcel(final Parcel source) { + final String storeDirPath = source.readString(); + final String profileName = source.readString(); + return new TelemetryJSONFilePingStore(new File(storeDirPath), profileName); + } + + @Override + public TelemetryJSONFilePingStore[] newArray(final int size) { + return new TelemetryJSONFilePingStore[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(storeDir.getAbsolutePath()); + dest.writeString(getProfileName()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java new file mode 100644 index 000000000..7d781cf26 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java @@ -0,0 +1,66 @@ +/* + * 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.telemetry.stores; + +import android.os.Parcelable; +import org.mozilla.gecko.telemetry.TelemetryPing; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +/** + * Persistent storage for TelemetryPings that are queued for upload. + * + * An implementation of this class is expected to be thread-safe. Additionally, + * multiple instances can be created and run simultaneously so they must be able + * to synchronize state (or be stateless!). + * + * The pings in {@link #getAllPings()} and {@link #maybePrunePings()} are returned in the + * same order in order to guarantee consistent results. + */ +public abstract class TelemetryPingStore implements Parcelable { + private final String profileName; + + public TelemetryPingStore(final String profileName) { + this.profileName = profileName; + } + + /** + * @return the profile name associated with this store. + */ + public String getProfileName() { + return profileName; + } + + /** + * @return a list of all the telemetry pings in the store that are ready for upload, ascending oldest to newest. + */ + public abstract List<TelemetryPing> getAllPings(); + + /** + * Save a ping to the store. + * + * @param ping the ping to store + * @throws IOException for underlying store access errors + */ + public abstract void storePing(TelemetryPing ping) throws IOException; + + /** + * Removes telemetry pings from the store if there are too many pings or they take up too much space. + * Pings should be removed from oldest to newest. + */ + public abstract void maybePrunePings(); + + /** + * Removes the successfully uploaded pings from the database and performs another other actions necessary + * for when upload is completed. + * + * @param successfulRemoveIDs doc ids of pings that were successfully uploaded + */ + public abstract void onUploadAttemptComplete(Set<String> successfulRemoveIDs); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java b/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java new file mode 100644 index 000000000..07f17590d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java @@ -0,0 +1,69 @@ +/* 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.text; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.os.Build; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import org.mozilla.gecko.GeckoAppShell; + +import java.util.List; + +@TargetApi(Build.VERSION_CODES.M) +public class FloatingActionModeCallback extends ActionMode.Callback2 { + private FloatingToolbarTextSelection textSelection; + private List<TextAction> actions; + + public FloatingActionModeCallback(FloatingToolbarTextSelection textSelection, List<TextAction> actions) { + this.textSelection = textSelection; + this.actions = actions; + } + + public void updateActions(List<TextAction> actions) { + this.actions = actions; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + menu.clear(); + + for (int i = 0; i < actions.size(); i++) { + final TextAction action = actions.get(i); + menu.add(Menu.NONE, i, action.getFloatingOrder(), action.getLabel()); + } + + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + final TextAction action = actions.get(item.getItemId()); + + GeckoAppShell.notifyObservers("TextSelection:Action", action.getId()); + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) {} + + @Override + public void onGetContentRect(ActionMode mode, View view, Rect outRect) { + final Rect contentRect = textSelection.contentRect; + if (contentRect != null) { + outRect.set(contentRect); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java new file mode 100644 index 000000000..7a09624d4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java @@ -0,0 +1,206 @@ +/* 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.text; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.graphics.Rect; +import android.os.Build; +import android.util.Log; +import android.util.TypedValue; +import android.view.ActionMode; + +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.List; + +import ch.boye.httpclientandroidlib.util.TextUtils; + +/** + * Floating toolbar for text selection actions. Only on Android 6+. + */ +@TargetApi(Build.VERSION_CODES.M) +public class FloatingToolbarTextSelection implements TextSelection, GeckoEventListener { + private static final String LOGTAG = "GeckoFloatTextSelection"; + + // This is an additional offset we add to the height of the selection. This will avoid that the + // floating toolbar overlays the bottom handle(s). + private static final int HANDLES_OFFSET_DP = 20; + + private final Activity activity; + private final LayerView layerView; + private final int[] locationInWindow; + private final float handlesOffset; + + private ActionMode actionMode; + private FloatingActionModeCallback actionModeCallback; + private String selectionID; + /* package-private */ Rect contentRect; + + public FloatingToolbarTextSelection(Activity activity, LayerView layerView) { + this.activity = activity; + this.layerView = layerView; + this.locationInWindow = new int[2]; + + this.handlesOffset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + HANDLES_OFFSET_DP, activity.getResources().getDisplayMetrics()); + } + + @Override + public boolean dismiss() { + if (finishActionMode()) { + endTextSelection(); + return true; + } + + return false; + } + + private void endTextSelection() { + if (TextUtils.isEmpty(selectionID)) { + return; + } + + final JSONObject args = new JSONObject(); + try { + args.put("selectionID", selectionID); + } catch (JSONException e) { + Log.e(LOGTAG, "Error building JSON arguments for TextSelection:End", e); + return; + } + + GeckoAppShell.notifyObservers("TextSelection:End", args.toString()); + } + + @Override + public void create() { + registerForEvents(); + } + + @Override + public void destroy() { + unregisterFromEvents(); + } + + private void registerForEvents() { + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "TextSelection:ActionbarInit", + "TextSelection:ActionbarStatus", + "TextSelection:ActionbarUninit", + "TextSelection:Update", + "TextSelection:Visibility"); + } + + private void unregisterFromEvents() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "TextSelection:ActionbarInit", + "TextSelection:ActionbarStatus", + "TextSelection:ActionbarUninit", + "TextSelection:Update", + "TextSelection:Visibility"); + } + + @Override + public void handleMessage(final String event, final JSONObject message) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + handleOnMainThread(event, message); + } + }); + } + + private void handleOnMainThread(final String event, final JSONObject message) { + if ("TextSelection:ActionbarInit".equals(event)) { + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, + TelemetryContract.Method.CONTENT, "text_selection"); + + selectionID = message.optString("selectionID"); + } else if ("TextSelection:ActionbarStatus".equals(event)) { + // Ensure async updates from SearchService for example are valid. + if (selectionID != message.optString("selectionID")) { + return; + } + + updateRect(message); + + if (!isRectVisible()) { + finishActionMode(); + } else { + startActionMode(TextAction.fromEventMessage(message)); + } + } else if ("TextSelection:ActionbarUninit".equals(event)) { + finishActionMode(); + } else if ("TextSelection:Update".equals(event)) { + startActionMode(TextAction.fromEventMessage(message)); + } else if ("TextSelection:Visibility".equals(event)) { + finishActionMode(); + } + } + + private void startActionMode(List<TextAction> actions) { + if (actionMode != null) { + actionModeCallback.updateActions(actions); + actionMode.invalidate(); + return; + } + + actionModeCallback = new FloatingActionModeCallback(this, actions); + actionMode = activity.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING); + } + + private boolean finishActionMode() { + if (actionMode != null) { + actionMode.finish(); + actionMode = null; + actionModeCallback = null; + return true; + } + + return false; + } + + /** + * If the content rect is a point (left == right and top == bottom) then this means that the + * content rect is not in the currently visible part. + */ + private boolean isRectVisible() { + // There's another case of an empty rect where just left == right but not top == bottom. + // That's the rect for a collapsed selection. While technically this rect isn't visible too + // we are not interested in this case because we do not want to hide the toolbar. + return contentRect.left != contentRect.right || contentRect.top != contentRect.bottom; + } + + private void updateRect(JSONObject message) { + try { + final double x = message.getDouble("x"); + final double y = (int) message.getDouble("y"); + final double width = (int) message.getDouble("width"); + final double height = (int) message.getDouble("height"); + + final float zoomFactor = layerView.getZoomFactor(); + layerView.getLocationInWindow(locationInWindow); + + contentRect = new Rect( + (int) (x * zoomFactor + locationInWindow[0]), + (int) (y * zoomFactor + locationInWindow[1]), + (int) ((x + width) * zoomFactor + locationInWindow[0]), + (int) ((y + height) * zoomFactor + locationInWindow[1] + + (height > 0 ? handlesOffset : 0))); + } catch (JSONException e) { + Log.w(LOGTAG, "Could not calculate content rect", e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java b/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java new file mode 100644 index 000000000..9fcbce4a4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java @@ -0,0 +1,68 @@ +/* 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.text; + +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * Text selection action like "copy", "paste", .. + */ +public class TextAction { + private static final String LOGTAG = "GeckoTextAction"; + + private String id; + private String label; + private int order; + private int floatingOrder; + + private TextAction() {} + + public static List<TextAction> fromEventMessage(JSONObject message) { + final List<TextAction> actions = new ArrayList<>(); + + try { + final JSONArray array = message.getJSONArray("actions"); + + for (int i = 0; i < array.length(); i++) { + final JSONObject object = array.getJSONObject(i); + + final TextAction action = new TextAction(); + action.id = object.getString("id"); + action.label = object.getString("label"); + action.order = object.getInt("order"); + action.floatingOrder = object.optInt("floatingOrder", i); + + actions.add(action); + } + } catch (JSONException e) { + Log.w(LOGTAG, "Could not parse text actions", e); + } + + return actions; + } + + public String getId() { + return id; + } + + public String getLabel() { + return label; + } + + public int getOrder() { + return order; + } + + public int getFloatingOrder() { + return floatingOrder; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java b/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java new file mode 100644 index 000000000..29e8e43f5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java @@ -0,0 +1,13 @@ +/* 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.text; + +public interface TextSelection { + void create(); + + boolean dismiss(); + + void destroy(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java new file mode 100644 index 000000000..4a1559823 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java @@ -0,0 +1,10 @@ +/* -*- 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.toolbar; + +public interface AutocompleteHandler { + void onAutocomplete(String res); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java new file mode 100644 index 000000000..267c95e09 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java @@ -0,0 +1,26 @@ +/* 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.toolbar; + +import android.content.Context; +import android.graphics.Path; +import android.util.AttributeSet; + +public class BackButton extends NavButton { + public BackButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + mPath.reset(); + mPath.addCircle(width / 2, height / 2, width / 2, Path.Direction.CW); + + mBorderPath.reset(); + mBorderPath.addCircle(width / 2, height / 2, (width / 2) - (mBorderWidth / 2), Path.Direction.CW); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java new file mode 100644 index 000000000..b24e3b3ea --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java @@ -0,0 +1,960 @@ +/* -*- 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.toolbar; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SiteIdentity; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.TouchEventInterceptor; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.lwt.LightweightThemeDrawable; +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.MenuPopup; +import org.mozilla.gecko.tabs.TabHistoryController; +import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.OnStopListener; +import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.OnTitleChangeListener; +import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.UpdateFlags; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.MenuUtils; +import org.mozilla.gecko.widget.themed.ThemedFrameLayout; +import org.mozilla.gecko.widget.themed.ThemedImageButton; +import org.mozilla.gecko.widget.themed.ThemedImageView; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.PopupWindow; +import android.support.annotation.NonNull; + +/** +* {@code BrowserToolbar} is single entry point for users of the toolbar +* subsystem i.e. this should be the only import outside the 'toolbar' +* package. +* +* {@code BrowserToolbar} serves at the single event bus for all +* sub-components in the toolbar. It tracks tab events and gecko messages +* and update the state of its inner components accordingly. +* +* It has two states, display and edit, which are controlled by +* ToolbarEditLayout and ToolbarDisplayLayout. In display state, the toolbar +* displays the current state for the selected tab. In edit state, it shows +* a text entry for searching bookmarks/history. {@code BrowserToolbar} +* provides public API to enter, cancel, and commit the edit state as well +* as a set of listeners to allow {@code BrowserToolbar} users to react +* to state changes accordingly. +*/ +public abstract class BrowserToolbar extends ThemedRelativeLayout + implements Tabs.OnTabsChangedListener, + GeckoMenu.ActionItemBarPresenter { + private static final String LOGTAG = "GeckoToolbar"; + + private static final int LIGHTWEIGHT_THEME_INVERT_ALPHA = 34; // 255 - alpha = invert_alpha + + public interface OnActivateListener { + public void onActivate(); + } + + public interface OnCommitListener { + public void onCommit(); + } + + public interface OnDismissListener { + public void onDismiss(); + } + + public interface OnFilterListener { + public void onFilter(String searchText, AutocompleteHandler handler); + } + + public interface OnStartEditingListener { + public void onStartEditing(); + } + + public interface OnStopEditingListener { + public void onStopEditing(); + } + + protected enum UIMode { + EDIT, + DISPLAY + } + + protected final ToolbarDisplayLayout urlDisplayLayout; + protected final ToolbarEditLayout urlEditLayout; + protected final View urlBarEntry; + protected boolean isSwitchingTabs; + protected final ThemedImageButton tabsButton; + + private ToolbarProgressView progressBar; + protected final TabCounter tabsCounter; + protected final ThemedFrameLayout menuButton; + protected final ThemedImageView menuIcon; + private MenuPopup menuPopup; + protected final List<View> focusOrder; + + private OnActivateListener activateListener; + private OnFocusChangeListener focusChangeListener; + private OnStartEditingListener startEditingListener; + private OnStopEditingListener stopEditingListener; + private TouchEventInterceptor mTouchEventInterceptor; + + protected final BrowserApp activity; + + protected UIMode uiMode; + protected TabHistoryController tabHistoryController; + + private final Paint shadowPaint; + private final int shadowColor; + private final int shadowPrivateColor; + private final int shadowSize; + + private final ToolbarPrefs prefs; + + public abstract boolean isAnimating(); + + protected abstract boolean isTabsButtonOffscreen(); + + protected abstract void updateNavigationButtons(Tab tab); + + protected abstract void triggerStartEditingTransition(PropertyAnimator animator); + protected abstract void triggerStopEditingTransition(); + public abstract void triggerTabsPanelTransition(PropertyAnimator animator, boolean areTabsShown); + + /** + * Returns a Drawable overlaid with the theme's bitmap. + */ + protected Drawable getLWTDefaultStateSetDrawable() { + return getTheme().getDrawable(this); + } + + public static BrowserToolbar create(final Context context, final AttributeSet attrs) { + final boolean isLargeResource = context.getResources().getBoolean(R.bool.is_large_resource); + final BrowserToolbar toolbar; + if (isLargeResource) { + toolbar = new BrowserToolbarTablet(context, attrs); + } else { + toolbar = new BrowserToolbarPhone(context, attrs); + } + return toolbar; + } + + protected BrowserToolbar(final Context context, final AttributeSet attrs) { + super(context, attrs); + setWillNotDraw(false); + + // BrowserToolbar is attached to BrowserApp only. + activity = (BrowserApp) context; + + LayoutInflater.from(context).inflate(R.layout.browser_toolbar, this); + + Tabs.registerOnTabsChangedListener(this); + isSwitchingTabs = true; + + urlDisplayLayout = (ToolbarDisplayLayout) findViewById(R.id.display_layout); + urlBarEntry = findViewById(R.id.url_bar_entry); + urlEditLayout = (ToolbarEditLayout) findViewById(R.id.edit_layout); + + tabsButton = (ThemedImageButton) findViewById(R.id.tabs); + tabsCounter = (TabCounter) findViewById(R.id.tabs_counter); + tabsCounter.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + + menuButton = (ThemedFrameLayout) findViewById(R.id.menu); + menuIcon = (ThemedImageView) findViewById(R.id.menu_icon); + + // The focusOrder List should be filled by sub-classes. + focusOrder = new ArrayList<View>(); + + final Resources res = getResources(); + shadowSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_shadow_size); + + shadowPaint = new Paint(); + shadowColor = ContextCompat.getColor(context, R.color.url_bar_shadow); + shadowPrivateColor = ContextCompat.getColor(context, R.color.url_bar_shadow_private); + shadowPaint.setColor(shadowColor); + shadowPaint.setStrokeWidth(0.0f); + + setUIMode(UIMode.DISPLAY); + + prefs = new ToolbarPrefs(); + urlDisplayLayout.setToolbarPrefs(prefs); + urlEditLayout.setToolbarPrefs(prefs); + + setOnCreateContextMenuListener(new View.OnCreateContextMenuListener() { + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + // Do not show the context menu while editing + if (isEditing()) { + return; + } + + // NOTE: Use MenuUtils.safeSetVisible because some actions might + // be on the Page menu + MenuInflater inflater = activity.getMenuInflater(); + inflater.inflate(R.menu.titlebar_contextmenu, menu); + + String clipboard = Clipboard.getText(); + if (TextUtils.isEmpty(clipboard)) { + menu.findItem(R.id.pasteandgo).setVisible(false); + menu.findItem(R.id.paste).setVisible(false); + } + + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + String url = tab.getURL(); + if (url == null) { + menu.findItem(R.id.copyurl).setVisible(false); + menu.findItem(R.id.add_to_launcher).setVisible(false); + } + + MenuUtils.safeSetVisible(menu, R.id.subscribe, tab.hasFeeds()); + MenuUtils.safeSetVisible(menu, R.id.add_search_engine, tab.hasOpenSearch()); + } else { + // if there is no tab, remove anything tab dependent + menu.findItem(R.id.copyurl).setVisible(false); + menu.findItem(R.id.add_to_launcher).setVisible(false); + MenuUtils.safeSetVisible(menu, R.id.subscribe, false); + MenuUtils.safeSetVisible(menu, R.id.add_search_engine, false); + } + } + }); + + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (activateListener != null) { + activateListener.onActivate(); + } + } + }); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + prefs.open(); + + urlDisplayLayout.setOnStopListener(new OnStopListener() { + @Override + public Tab onStop() { + final Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + tab.doStop(); + return tab; + } + + return null; + } + }); + + urlDisplayLayout.setOnTitleChangeListener(new OnTitleChangeListener() { + @Override + public void onTitleChange(CharSequence title) { + final String contentDescription; + if (title != null) { + contentDescription = title.toString(); + } else { + contentDescription = activity.getString(R.string.url_bar_default_text); + } + + // The title and content description should + // always be sync. + setContentDescription(contentDescription); + } + }); + + urlEditLayout.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + // This will select the url bar when entering editing mode. + setSelected(hasFocus); + if (focusChangeListener != null) { + focusChangeListener.onFocusChange(v, hasFocus); + } + } + }); + + tabsButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + // Clear focus so a back press with the tabs + // panel open does not go to the editing field. + urlEditLayout.clearFocus(); + + toggleTabs(); + } + }); + tabsButton.setImageLevel(0); + + menuButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + // Drop the soft keyboard. + urlEditLayout.clearFocus(); + activity.openOptionsMenu(); + } + }); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + prefs.close(); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + final int height = getHeight(); + canvas.drawRect(0, height - shadowSize, getWidth(), height, shadowPaint); + } + + public void onParentFocus() { + urlEditLayout.onParentFocus(); + } + + public void setProgressBar(ToolbarProgressView progressBar) { + this.progressBar = progressBar; + } + + public void setTabHistoryController(TabHistoryController tabHistoryController) { + this.tabHistoryController = tabHistoryController; + } + + public void refresh() { + urlDisplayLayout.dismissSiteIdentityPopup(); + } + + public boolean onBackPressed() { + // If we exit editing mode during the animation, + // we're put into an inconsistent state (bug 1017276). + if (isEditing() && !isAnimating()) { + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, + TelemetryContract.Method.BACK); + cancelEdit(); + return true; + } + + return urlDisplayLayout.dismissSiteIdentityPopup(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + if (h != oldh) { + // Post this to happen outside of onSizeChanged, as this may cause + // a layout change and relayouts within a layout change don't work. + post(new Runnable() { + @Override + public void run() { + activity.refreshToolbarHeight(); + } + }); + } + } + + public void saveTabEditingState(final TabEditingState editingState) { + urlEditLayout.saveTabEditingState(editingState); + } + + public void restoreTabEditingState(final TabEditingState editingState) { + if (!isEditing()) { + throw new IllegalStateException("Expected to be editing"); + } + + urlEditLayout.restoreTabEditingState(editingState); + } + + @Override + public void onTabChanged(@Nullable Tab tab, Tabs.TabEvents msg, String data) { + Log.d(LOGTAG, "onTabChanged: " + msg); + final Tabs tabs = Tabs.getInstance(); + + // These conditions are split into three phases: + // * Always do first + // * Handling specific to the selected tab + // * Always do afterwards. + + switch (msg) { + case ADDED: + case CLOSED: + updateTabCount(tabs.getDisplayCount()); + break; + case RESTORED: + // TabCount fixup after OOM + case SELECTED: + urlDisplayLayout.dismissSiteIdentityPopup(); + updateTabCount(tabs.getDisplayCount()); + isSwitchingTabs = true; + break; + } + + if (tabs.isSelectedTab(tab)) { + final EnumSet<UpdateFlags> flags = EnumSet.noneOf(UpdateFlags.class); + + // Progress-related handling + switch (msg) { + case START: + updateProgressVisibility(tab, Tab.LOAD_PROGRESS_INIT); + // Fall through. + case ADDED: + case LOCATION_CHANGE: + case LOAD_ERROR: + case LOADED: + case STOP: + flags.add(UpdateFlags.PROGRESS); + if (progressBar.getVisibility() == View.VISIBLE) { + progressBar.animateProgress(tab.getLoadProgress()); + } + break; + + case SELECTED: + flags.add(UpdateFlags.PROGRESS); + updateProgressVisibility(); + break; + } + + switch (msg) { + case STOP: + // Reset the title in case we haven't navigated + // to a new page yet. + flags.add(UpdateFlags.TITLE); + // Fall through. + case START: + case CLOSED: + case ADDED: + updateNavigationButtons(tab); + break; + + case SELECTED: + flags.add(UpdateFlags.PRIVATE_MODE); + setPrivateMode(tab.isPrivate()); + // Fall through. + case LOAD_ERROR: + case LOCATION_CHANGE: + // We're displaying the tab URL in place of the title, + // so we always need to update our "title" here as well. + flags.add(UpdateFlags.TITLE); + flags.add(UpdateFlags.FAVICON); + flags.add(UpdateFlags.SITE_IDENTITY); + + updateNavigationButtons(tab); + break; + + case TITLE: + flags.add(UpdateFlags.TITLE); + break; + + case FAVICON: + flags.add(UpdateFlags.FAVICON); + break; + + case SECURITY_CHANGE: + flags.add(UpdateFlags.SITE_IDENTITY); + break; + } + + if (!flags.isEmpty() && tab != null) { + updateDisplayLayout(tab, flags); + } + } + + switch (msg) { + case SELECTED: + case LOAD_ERROR: + case LOCATION_CHANGE: + isSwitchingTabs = false; + } + } + + private void updateProgressVisibility() { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + // The selected tab may be null if GeckoApp (and thus the + // selected tab) are not yet initialized (bug 1090287). + if (selectedTab != null) { + updateProgressVisibility(selectedTab, selectedTab.getLoadProgress()); + } + } + + private void updateProgressVisibility(Tab selectedTab, int progress) { + if (!isEditing() && selectedTab.getState() == Tab.STATE_LOADING) { + progressBar.setProgress(progress); + progressBar.setPrivateMode(selectedTab.isPrivate()); + progressBar.setVisibility(View.VISIBLE); + } else { + progressBar.setVisibility(View.GONE); + } + } + + protected boolean isVisible() { + return ViewHelper.getTranslationY(this) == 0; + } + + @Override + public void setNextFocusDownId(int nextId) { + super.setNextFocusDownId(nextId); + tabsButton.setNextFocusDownId(nextId); + urlDisplayLayout.setNextFocusDownId(nextId); + menuButton.setNextFocusDownId(nextId); + } + + public boolean hideVirtualKeyboard() { + InputMethodManager imm = + (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.hideSoftInputFromWindow(tabsButton.getWindowToken(), 0); + } + + private void showSelectedTabs() { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + if (!tab.isPrivate()) + activity.showNormalTabs(); + else + activity.showPrivateTabs(); + } + } + + private void toggleTabs() { + if (activity.areTabsShown()) { + return; + } + + if (hideVirtualKeyboard()) { + getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + getViewTreeObserver().removeGlobalOnLayoutListener(this); + showSelectedTabs(); + } + }); + } else { + showSelectedTabs(); + } + } + + protected void updateTabCount(final int count) { + // If toolbar is in edit mode on a phone, this means the entry is expanded + // and the tabs button is translated offscreen. Don't trigger tabs counter + // updates until the tabs button is back on screen. + // See stopEditing() + if (isTabsButtonOffscreen()) { + return; + } + + // Set TabCounter based on visibility + if (isVisible() && ViewHelper.getAlpha(tabsCounter) != 0 && !isEditing()) { + tabsCounter.setCountWithAnimation(count); + } else { + tabsCounter.setCount(count); + } + + // Update A11y information + tabsButton.setContentDescription((count > 1) ? + activity.getString(R.string.num_tabs, count) : + activity.getString(R.string.one_tab)); + } + + private void updateDisplayLayout(@NonNull Tab tab, EnumSet<UpdateFlags> flags) { + if (isSwitchingTabs) { + flags.add(UpdateFlags.DISABLE_ANIMATIONS); + } + + urlDisplayLayout.updateFromTab(tab, flags); + + if (flags.contains(UpdateFlags.TITLE)) { + if (!isEditing()) { + urlEditLayout.setText(tab.getURL()); + } + } + + if (flags.contains(UpdateFlags.PROGRESS)) { + updateFocusOrder(); + } + } + + private void updateFocusOrder() { + if (focusOrder.size() == 0) { + throw new IllegalStateException("Expected focusOrder to be initialized in subclass"); + } + + View prevView = null; + + // If the element that has focus becomes disabled or invisible, focus + // is given to the URL bar. + boolean needsNewFocus = false; + + for (View view : focusOrder) { + if (view.getVisibility() != View.VISIBLE || !view.isEnabled()) { + if (view.hasFocus()) { + needsNewFocus = true; + } + continue; + } + + if (view.getId() == R.id.menu_items) { + final LinearLayout actionItemBar = (LinearLayout) view; + final int childCount = actionItemBar.getChildCount(); + for (int child = 0; child < childCount; child++) { + View childView = actionItemBar.getChildAt(child); + if (prevView != null) { + childView.setNextFocusLeftId(prevView.getId()); + prevView.setNextFocusRightId(childView.getId()); + } + prevView = childView; + } + } else { + if (prevView != null) { + view.setNextFocusLeftId(prevView.getId()); + prevView.setNextFocusRightId(view.getId()); + } + prevView = view; + } + } + + if (needsNewFocus) { + requestFocus(); + } + } + + public void setToolBarButtonsAlpha(float alpha) { + ViewHelper.setAlpha(tabsCounter, alpha); + if (!HardwareUtils.isTablet()) { + ViewHelper.setAlpha(menuIcon, alpha); + } + } + + public void onEditSuggestion(String suggestion) { + if (!isEditing()) { + return; + } + + urlEditLayout.onEditSuggestion(suggestion); + } + + public void setTitle(CharSequence title) { + urlDisplayLayout.setTitle(title); + } + + public void setOnActivateListener(final OnActivateListener listener) { + activateListener = listener; + } + + public void setOnCommitListener(OnCommitListener listener) { + urlEditLayout.setOnCommitListener(listener); + } + + public void setOnDismissListener(OnDismissListener listener) { + urlEditLayout.setOnDismissListener(listener); + } + + public void setOnFilterListener(OnFilterListener listener) { + urlEditLayout.setOnFilterListener(listener); + } + + @Override + public void setOnFocusChangeListener(OnFocusChangeListener listener) { + focusChangeListener = listener; + } + + public void setOnStartEditingListener(OnStartEditingListener listener) { + startEditingListener = listener; + } + + public void setOnStopEditingListener(OnStopEditingListener listener) { + stopEditingListener = listener; + } + + protected void showUrlEditLayout() { + setUrlEditLayoutVisibility(true, null); + } + + protected void showUrlEditLayout(final PropertyAnimator animator) { + setUrlEditLayoutVisibility(true, animator); + } + + protected void hideUrlEditLayout() { + setUrlEditLayoutVisibility(false, null); + } + + protected void hideUrlEditLayout(final PropertyAnimator animator) { + setUrlEditLayoutVisibility(false, animator); + } + + protected void setUrlEditLayoutVisibility(final boolean showEditLayout, PropertyAnimator animator) { + if (showEditLayout) { + urlEditLayout.prepareShowAnimation(animator); + } + + // If this view is GONE, we trigger a measure pass when setting the view to + // VISIBLE. Since this will occur during the toolbar open animation, it causes jank. + final int hiddenViewVisibility = View.INVISIBLE; + + if (animator == null) { + final View viewToShow = (showEditLayout ? urlEditLayout : urlDisplayLayout); + final View viewToHide = (showEditLayout ? urlDisplayLayout : urlEditLayout); + + viewToHide.setVisibility(hiddenViewVisibility); + viewToShow.setVisibility(View.VISIBLE); + return; + } + + animator.addPropertyAnimationListener(new PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + if (!showEditLayout) { + urlEditLayout.setVisibility(hiddenViewVisibility); + urlDisplayLayout.setVisibility(View.VISIBLE); + } + } + + @Override + public void onPropertyAnimationEnd() { + if (showEditLayout) { + urlDisplayLayout.setVisibility(hiddenViewVisibility); + urlEditLayout.setVisibility(View.VISIBLE); + } + } + }); + } + + private void setUIMode(final UIMode uiMode) { + this.uiMode = uiMode; + urlEditLayout.setEnabled(uiMode == UIMode.EDIT); + } + + /** + * Returns whether or not the URL bar is in editing mode (url bar is expanded, hiding the new + * tab button). Note that selection state is independent of editing mode. + */ + public boolean isEditing() { + return (uiMode == UIMode.EDIT); + } + + public void startEditing(String url, PropertyAnimator animator) { + if (isEditing()) { + return; + } + + urlEditLayout.setText(url != null ? url : ""); + + setUIMode(UIMode.EDIT); + + updateProgressVisibility(); + + if (startEditingListener != null) { + startEditingListener.onStartEditing(); + } + + triggerStartEditingTransition(animator); + } + + /** + * Exits edit mode without updating the toolbar title. + * + * @return the url that was entered + */ + public String cancelEdit() { + Telemetry.stopUISession(TelemetryContract.Session.AWESOMESCREEN); + return stopEditing(); + } + + /** + * Exits edit mode, updating the toolbar title with the url that was just entered. + * + * @return the url that was entered + */ + public String commitEdit() { + Tab tab = Tabs.getInstance().getSelectedTab(); + if (tab != null) { + tab.resetSiteIdentity(); + } + + final String url = stopEditing(); + if (!TextUtils.isEmpty(url)) { + setTitle(url); + } + return url; + } + + private String stopEditing() { + final String url = urlEditLayout.getText(); + if (!isEditing()) { + return url; + } + setUIMode(UIMode.DISPLAY); + + if (stopEditingListener != null) { + stopEditingListener.onStopEditing(); + } + + updateProgressVisibility(); + triggerStopEditingTransition(); + + return url; + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + + tabsButton.setPrivateMode(isPrivate); + menuButton.setPrivateMode(isPrivate); + urlEditLayout.setPrivateMode(isPrivate); + + shadowPaint.setColor(isPrivate ? shadowPrivateColor : shadowColor); + } + + public void show() { + setVisibility(View.VISIBLE); + } + + public void hide() { + setVisibility(View.GONE); + } + + public View getDoorHangerAnchor() { + return urlDisplayLayout; + } + + public void onDestroy() { + Tabs.unregisterOnTabsChangedListener(this); + urlDisplayLayout.destroy(); + } + + public boolean openOptionsMenu() { + // Initialize the popup. + if (menuPopup == null) { + View panel = activity.getMenuPanel(); + menuPopup = new MenuPopup(activity); + menuPopup.setPanelView(panel); + + menuPopup.setOnDismissListener(new PopupWindow.OnDismissListener() { + @Override + public void onDismiss() { + activity.onOptionsMenuClosed(null); + } + }); + } + + GeckoAppShell.getGeckoInterface().invalidateOptionsMenu(); + if (!menuPopup.isShowing()) { + menuPopup.showAsDropDown(menuButton); + } + + return true; + } + + public boolean closeOptionsMenu() { + if (menuPopup != null && menuPopup.isShowing()) { + menuPopup.dismiss(); + } + + return true; + } + + @Override + public void onLightweightThemeChanged() { + final Drawable drawable = getLWTDefaultStateSetDrawable(); + if (drawable == null) { + return; + } + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.tabs_tray_grey_pressed)); + stateList.addState(EMPTY_STATE_SET, drawable); + + setBackgroundDrawable(stateList); + } + + public void setTouchEventInterceptor(TouchEventInterceptor interceptor) { + mTouchEventInterceptor = interceptor; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (mTouchEventInterceptor != null && mTouchEventInterceptor.onInterceptTouchEvent(this, event)) { + return true; + } + return super.onInterceptTouchEvent(event); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundResource(R.drawable.url_bar_bg); + } + + public static LightweightThemeDrawable getLightweightThemeDrawable(final View view, + final LightweightTheme theme, final int colorResID) { + final int color = ContextCompat.getColor(view.getContext(), colorResID); + + final LightweightThemeDrawable drawable = theme.getColorDrawable(view, color); + if (drawable != null) { + drawable.setAlpha(LIGHTWEIGHT_THEME_INVERT_ALPHA, LIGHTWEIGHT_THEME_INVERT_ALPHA); + } + + return drawable; + } + + public static class TabEditingState { + // The edited text from the most recent time this tab was unselected. + protected String lastEditingText; + protected int selectionStart; + protected int selectionEnd; + + public boolean isBrowserSearchShown; + + public void copyFrom(final TabEditingState s2) { + lastEditingText = s2.lastEditingText; + selectionStart = s2.selectionStart; + selectionEnd = s2.selectionEnd; + + isBrowserSearchShown = s2.isBrowserSearchShown; + } + + public boolean isBrowserSearchShown() { + return isBrowserSearchShown; + } + + public void setIsBrowserSearchShown(final boolean isShown) { + isBrowserSearchShown = isShown; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java new file mode 100644 index 000000000..a5fc57f1a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java @@ -0,0 +1,128 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener; +import org.mozilla.gecko.util.HardwareUtils; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +/** + * A toolbar implementation for phones. + */ +class BrowserToolbarPhone extends BrowserToolbarPhoneBase { + + private final PropertyAnimationListener showEditingListener; + private final PropertyAnimationListener stopEditingListener; + + protected boolean isAnimatingEntry; + + protected BrowserToolbarPhone(final Context context, final AttributeSet attrs) { + super(context, attrs); + + // Create these listeners here, once, to avoid constructing new listeners + // each time they are set on an animator (i.e. each time the url bar is clicked). + showEditingListener = new PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { /* Do nothing */ } + + @Override + public void onPropertyAnimationEnd() { + isAnimatingEntry = false; + } + }; + + stopEditingListener = new PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { /* Do nothing */ } + + @Override + public void onPropertyAnimationEnd() { + urlBarTranslatingEdge.setVisibility(View.INVISIBLE); + + final PropertyAnimator buttonsAnimator = new PropertyAnimator(300); + urlDisplayLayout.prepareStopEditingAnimation(buttonsAnimator); + buttonsAnimator.start(); + + isAnimatingEntry = false; + + // Trigger animation to update the tabs counter once the + // tabs button is back on screen. + updateTabCountAndAnimate(Tabs.getInstance().getDisplayCount()); + } + }; + } + + @Override + public boolean isAnimating() { + return isAnimatingEntry; + } + + @Override + protected void triggerStartEditingTransition(final PropertyAnimator animator) { + if (isAnimatingEntry) { + return; + } + + // The animation looks cleaner if the text in the URL bar is + // not selected so clear the selection by clearing focus. + urlEditLayout.clearFocus(); + + urlDisplayLayout.prepareStartEditingAnimation(); + addAnimationsForEditing(animator, true); + showUrlEditLayout(animator); + urlBarTranslatingEdge.setVisibility(View.VISIBLE); + animator.addPropertyAnimationListener(showEditingListener); + + isAnimatingEntry = true; // To be correct, this should be called last. + } + + @Override + protected void triggerStopEditingTransition() { + final PropertyAnimator animator = new PropertyAnimator(250); + animator.setUseHardwareLayer(false); + + addAnimationsForEditing(animator, false); + hideUrlEditLayout(animator); + animator.addPropertyAnimationListener(stopEditingListener); + + isAnimatingEntry = true; + animator.start(); + } + + private void addAnimationsForEditing(final PropertyAnimator animator, final boolean isEditing) { + final int curveTranslation; + final int entryTranslation; + if (isEditing) { + curveTranslation = getUrlBarCurveTranslation(); + entryTranslation = getUrlBarEntryTranslation(); + } else { + curveTranslation = 0; + entryTranslation = 0; + } + + // Slide toolbar elements. + animator.attach(urlBarTranslatingEdge, + PropertyAnimator.Property.TRANSLATION_X, + entryTranslation); + animator.attach(tabsButton, + PropertyAnimator.Property.TRANSLATION_X, + curveTranslation); + animator.attach(tabsCounter, + PropertyAnimator.Property.TRANSLATION_X, + curveTranslation); + animator.attach(menuButton, + PropertyAnimator.Property.TRANSLATION_X, + curveTranslation); + animator.attach(menuIcon, + PropertyAnimator.Property.TRANSLATION_X, + curveTranslation); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java new file mode 100644 index 000000000..5588ddcd3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java @@ -0,0 +1,219 @@ +/* -*- 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.toolbar; + +import java.util.Arrays; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.widget.themed.ThemedImageView; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; + +/** + * A base implementations of the browser toolbar for phones. + * This class manages any Views, variables, etc. that are exclusive to phone. + */ +abstract class BrowserToolbarPhoneBase extends BrowserToolbar { + + protected final ImageView urlBarTranslatingEdge; + protected final ThemedImageView editCancel; + + private final Path roundCornerShape; + private final Paint roundCornerPaint; + + private final Interpolator buttonsInterpolator = new AccelerateInterpolator(); + + public BrowserToolbarPhoneBase(final Context context, final AttributeSet attrs) { + super(context, attrs); + final Resources res = context.getResources(); + + urlBarTranslatingEdge = (ImageView) findViewById(R.id.url_bar_translating_edge); + + // This will clip the translating edge's image at 60% of its width + urlBarTranslatingEdge.getDrawable().setLevel(6000); + + editCancel = (ThemedImageView) findViewById(R.id.edit_cancel); + + focusOrder.add(this); + focusOrder.addAll(urlDisplayLayout.getFocusOrder()); + focusOrder.addAll(Arrays.asList(tabsButton, menuButton)); + + roundCornerShape = new Path(); + roundCornerShape.moveTo(0, 0); + roundCornerShape.lineTo(30, 0); + roundCornerShape.cubicTo(0, 0, 0, 0, 0, 30); + roundCornerShape.lineTo(0, 0); + + roundCornerPaint = new Paint(); + roundCornerPaint.setAntiAlias(true); + roundCornerPaint.setColor(ContextCompat.getColor(context, R.color.text_and_tabs_tray_grey)); + roundCornerPaint.setStrokeWidth(0.0f); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + editCancel.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + // If we exit editing mode during the animation, + // we're put into an inconsistent state (bug 1017276). + if (!isAnimating()) { + Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, + TelemetryContract.Method.ACTIONBAR, + getResources().getResourceEntryName(editCancel.getId())); + cancelEdit(); + } + } + }); + } + + @Override + public void setPrivateMode(final boolean isPrivate) { + super.setPrivateMode(isPrivate); + editCancel.setPrivateMode(isPrivate); + } + + @Override + protected boolean isTabsButtonOffscreen() { + return isEditing(); + } + + @Override + public boolean addActionItem(final View actionItem) { + // We have no action item bar. + return false; + } + + @Override + public void removeActionItem(final View actionItem) { + // We have no action item bar. + } + + @Override + protected void updateNavigationButtons(final Tab tab) { + // We have no navigation buttons so do nothing. + } + + @Override + public void draw(final Canvas canvas) { + super.draw(canvas); + + if (uiMode == UIMode.DISPLAY) { + canvas.drawPath(roundCornerShape, roundCornerPaint); + } + } + + @Override + public void triggerTabsPanelTransition(final PropertyAnimator animator, final boolean areTabsShown) { + if (areTabsShown) { + ViewHelper.setAlpha(tabsCounter, 0.0f); + ViewHelper.setAlpha(menuIcon, 0.0f); + return; + } + + final PropertyAnimator buttonsAnimator = + new PropertyAnimator(animator.getDuration(), buttonsInterpolator); + buttonsAnimator.attach(tabsCounter, + PropertyAnimator.Property.ALPHA, + 1.0f); + buttonsAnimator.attach(menuIcon, + PropertyAnimator.Property.ALPHA, + 1.0f); + buttonsAnimator.start(); + } + + /** + * Returns the number of pixels the url bar translating edge + * needs to translate to the right to enter its editing mode state. + * A negative value means the edge must translate to the left. + */ + protected int getUrlBarEntryTranslation() { + // Find the distance from the right-edge of the url bar (where we're translating from) to + // the left-edge of the cancel button (where we're translating to; note that the cancel + // button must be laid out, i.e. not View.GONE). + return editCancel.getLeft() - urlBarEntry.getRight(); + } + + protected int getUrlBarCurveTranslation() { + return getWidth() - tabsButton.getLeft(); + } + + protected void updateTabCountAndAnimate(final int count) { + // Don't animate if the toolbar is hidden. + if (!isVisible()) { + updateTabCount(count); + return; + } + + // If toolbar is in edit mode on a phone, this means the entry is expanded + // and the tabs button is translated offscreen. Don't trigger tabs counter + // updates until the tabs button is back on screen. + // See stopEditing() + if (!isTabsButtonOffscreen()) { + tabsCounter.setCount(count); + + tabsButton.setContentDescription((count > 1) ? + activity.getString(R.string.num_tabs, count) : + activity.getString(R.string.one_tab)); + } + } + + @Override + protected void setUrlEditLayoutVisibility(final boolean showEditLayout, + final PropertyAnimator animator) { + super.setUrlEditLayoutVisibility(showEditLayout, animator); + + if (animator == null) { + editCancel.setVisibility(showEditLayout ? View.VISIBLE : View.INVISIBLE); + return; + } + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + if (!showEditLayout) { + editCancel.setVisibility(View.INVISIBLE); + } + } + + @Override + public void onPropertyAnimationEnd() { + if (showEditLayout) { + editCancel.setVisibility(View.VISIBLE); + } + } + }); + } + + @Override + public void onLightweightThemeChanged() { + super.onLightweightThemeChanged(); + editCancel.onLightweightThemeChanged(); + } + + @Override + public void onLightweightThemeReset() { + super.onLightweightThemeReset(); + editCancel.onLightweightThemeReset(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java new file mode 100644 index 000000000..215934161 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java @@ -0,0 +1,211 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +/** + * The toolbar implementation for tablet. + */ +class BrowserToolbarTablet extends BrowserToolbarTabletBase { + + private static final int FORWARD_ANIMATION_DURATION = 450; + + private enum ForwardButtonState { + HIDDEN, + DISPLAYED, + TRANSITIONING, + } + + private final int forwardButtonTranslationWidth; + + private ForwardButtonState forwardButtonState; + + private boolean backButtonWasEnabledOnStartEditing; + + public BrowserToolbarTablet(final Context context, final AttributeSet attrs) { + super(context, attrs); + + forwardButtonTranslationWidth = + getResources().getDimensionPixelOffset(R.dimen.tablet_nav_button_width); + + // The forward button is initially expanded (in the layout file) + // so translate it for start of the expansion animation; future + // iterations translate it to this position when hiding and will already be set up. + ViewHelper.setTranslationX(forwardButton, -forwardButtonTranslationWidth); + + // TODO: Move this to *TabletBase when old tablet is removed. + // We don't want users clicking the forward button in transitions, but we don't want it to + // look disabled to avoid flickering complications (e.g. disabled in editing mode), so undo + // the work of the super class' constructor. + forwardButton.setEnabled(true); + + updateForwardButtonState(ForwardButtonState.HIDDEN); + } + + private void updateForwardButtonState(final ForwardButtonState state) { + forwardButtonState = state; + forwardButton.setEnabled(forwardButtonState == ForwardButtonState.DISPLAYED); + } + + @Override + public boolean isAnimating() { + return false; + } + + @Override + protected void triggerStartEditingTransition(final PropertyAnimator animator) { + showUrlEditLayout(); + } + + @Override + protected void triggerStopEditingTransition() { + hideUrlEditLayout(); + } + + @Override + protected void animateForwardButton(final ForwardButtonAnimation animation) { + final boolean willShowForward = (animation == ForwardButtonAnimation.SHOW); + if ((forwardButtonState != ForwardButtonState.HIDDEN && willShowForward) || + (forwardButtonState != ForwardButtonState.DISPLAYED && !willShowForward)) { + return; + } + updateForwardButtonState(ForwardButtonState.TRANSITIONING); + + // We want the forward button to show immediately when switching tabs + final PropertyAnimator forwardAnim = + new PropertyAnimator(isSwitchingTabs ? 10 : FORWARD_ANIMATION_DURATION); + + forwardAnim.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + if (!willShowForward) { + // Set the margin before the transition when hiding the forward button. We + // have to do this so that the favicon isn't clipped during the transition + MarginLayoutParams layoutParams = + (MarginLayoutParams) urlDisplayLayout.getLayoutParams(); + layoutParams.leftMargin = 0; + + // Do the same on the URL edit container + layoutParams = (MarginLayoutParams) urlEditLayout.getLayoutParams(); + layoutParams.leftMargin = 0; + + requestLayout(); + // Note, we already translated the favicon, site security, and text field + // in prepareForwardAnimation, so they should appear to have not moved at + // all at this point. + } + } + + @Override + public void onPropertyAnimationEnd() { + final ForwardButtonState newForwardButtonState; + if (willShowForward) { + // Increase the margins to ensure the text does not run outside the View. + MarginLayoutParams layoutParams = + (MarginLayoutParams) urlDisplayLayout.getLayoutParams(); + layoutParams.leftMargin = forwardButtonTranslationWidth; + + layoutParams = (MarginLayoutParams) urlEditLayout.getLayoutParams(); + layoutParams.leftMargin = forwardButtonTranslationWidth; + + newForwardButtonState = ForwardButtonState.DISPLAYED; + } else { + newForwardButtonState = ForwardButtonState.HIDDEN; + } + + urlDisplayLayout.finishForwardAnimation(); + updateForwardButtonState(newForwardButtonState); + + requestLayout(); + } + }); + + prepareForwardAnimation(forwardAnim, animation, forwardButtonTranslationWidth); + forwardAnim.start(); + } + + private void prepareForwardAnimation(PropertyAnimator anim, ForwardButtonAnimation animation, int width) { + if (animation == ForwardButtonAnimation.HIDE) { + anim.attach(forwardButton, + PropertyAnimator.Property.TRANSLATION_X, + -width); + anim.attach(forwardButton, + PropertyAnimator.Property.ALPHA, + 0); + + } else { + anim.attach(forwardButton, + PropertyAnimator.Property.TRANSLATION_X, + 0); + anim.attach(forwardButton, + PropertyAnimator.Property.ALPHA, + 1); + } + + urlDisplayLayout.prepareForwardAnimation(anim, animation, width); + } + + @Override + public void triggerTabsPanelTransition(final PropertyAnimator animator, final boolean areTabsShown) { + // Do nothing. + } + + @Override + public void setToolBarButtonsAlpha(float alpha) { + // Do nothing. + } + + + @Override + public void startEditing(final String url, final PropertyAnimator animator) { + // We already know the forward button state - no need to store it here. + backButtonWasEnabledOnStartEditing = backButton.isEnabled(); + + backButton.setEnabled(false); + forwardButton.setEnabled(false); + + super.startEditing(url, animator); + } + + @Override + public String commitEdit() { + stopEditingNewTablet(); + return super.commitEdit(); + } + + @Override + public String cancelEdit() { + // This can get called when we're not editing but we only want + // to make these changes when leaving editing mode. + if (isEditing()) { + stopEditingNewTablet(); + + backButton.setEnabled(backButtonWasEnabledOnStartEditing); + updateForwardButtonState(forwardButtonState); + } + + return super.cancelEdit(); + } + + private void stopEditingNewTablet() { + // Undo the changes caused by calling setEnabled for forwardButton in startEditing. + // Note that this should be called first so the enabled state of the + // forward button is set to the proper value. + forwardButton.setEnabled(true); + } + + @Override + protected Drawable getLWTDefaultStateSetDrawable() { + return BrowserToolbar.getLightweightThemeDrawable(this, getTheme(), R.color.toolbar_grey); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java new file mode 100644 index 000000000..e818bb95c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java @@ -0,0 +1,182 @@ +/* -*- 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.toolbar; + +import java.util.Arrays; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.tabs.TabHistoryController; +import org.mozilla.gecko.menu.MenuItemActionBar; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.widget.themed.ThemedTextView; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +/** + * A base implementations of the browser toolbar for tablets. + * This class manages any Views, variables, etc. that are exclusive to tablet. + */ +abstract class BrowserToolbarTabletBase extends BrowserToolbar { + + protected enum ForwardButtonAnimation { + SHOW, + HIDE + } + + protected final LinearLayout actionItemBar; + + protected final BackButton backButton; + protected final ForwardButton forwardButton; + + protected final View menuButtonMarginView; + + private final PorterDuffColorFilter privateBrowsingTabletMenuItemColorFilter; + + protected abstract void animateForwardButton(ForwardButtonAnimation animation); + + public BrowserToolbarTabletBase(final Context context, final AttributeSet attrs) { + super(context, attrs); + + actionItemBar = (LinearLayout) findViewById(R.id.menu_items); + + backButton = (BackButton) findViewById(R.id.back); + backButton.setEnabled(false); + forwardButton = (ForwardButton) findViewById(R.id.forward); + forwardButton.setEnabled(false); + initButtonListeners(); + + focusOrder.addAll(Arrays.asList(tabsButton, (View) backButton, (View) forwardButton, this)); + focusOrder.addAll(urlDisplayLayout.getFocusOrder()); + focusOrder.addAll(Arrays.asList(actionItemBar, menuButton)); + + urlDisplayLayout.updateSiteIdentityAnchor(backButton); + + privateBrowsingTabletMenuItemColorFilter = new PorterDuffColorFilter( + ContextCompat.getColor(context, R.color.tabs_tray_icon_grey), PorterDuff.Mode.SRC_IN); + + menuButtonMarginView = findViewById(R.id.menu_margin); + if (menuButtonMarginView != null) { + menuButtonMarginView.setVisibility(View.VISIBLE); + } + } + + private void initButtonListeners() { + backButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + Tabs.getInstance().getSelectedTab().doBack(); + } + }); + backButton.setOnLongClickListener(new Button.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + return tabHistoryController.showTabHistory(Tabs.getInstance().getSelectedTab(), + TabHistoryController.HistoryAction.BACK); + } + }); + + forwardButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + Tabs.getInstance().getSelectedTab().doForward(); + } + }); + forwardButton.setOnLongClickListener(new Button.OnLongClickListener() { + @Override + public boolean onLongClick(View view) { + return tabHistoryController.showTabHistory(Tabs.getInstance().getSelectedTab(), + TabHistoryController.HistoryAction.FORWARD); + } + }); + } + + @Override + protected boolean isTabsButtonOffscreen() { + return false; + } + + @Override + public boolean addActionItem(final View actionItem) { + actionItemBar.addView(actionItem, LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + return true; + } + + @Override + public void removeActionItem(final View actionItem) { + actionItemBar.removeView(actionItem); + } + + @Override + protected void updateNavigationButtons(final Tab tab) { + backButton.setEnabled(canDoBack(tab)); + animateForwardButton( + canDoForward(tab) ? ForwardButtonAnimation.SHOW : ForwardButtonAnimation.HIDE); + } + + @Override + public void setNextFocusDownId(int nextId) { + super.setNextFocusDownId(nextId); + backButton.setNextFocusDownId(nextId); + forwardButton.setNextFocusDownId(nextId); + } + + @Override + public void setPrivateMode(final boolean isPrivate) { + super.setPrivateMode(isPrivate); + + // If we had backgroundTintList, we could remove the colorFilter + // code in favor of setPrivateMode (bug 1197432). + final PorterDuffColorFilter colorFilter = + isPrivate ? privateBrowsingTabletMenuItemColorFilter : null; + setTabsCounterPrivateMode(isPrivate, colorFilter); + + backButton.setPrivateMode(isPrivate); + forwardButton.setPrivateMode(isPrivate); + menuIcon.setPrivateMode(isPrivate); + for (int i = 0; i < actionItemBar.getChildCount(); ++i) { + final MenuItemActionBar child = (MenuItemActionBar) actionItemBar.getChildAt(i); + child.setPrivateMode(isPrivate); + } + } + + private void setTabsCounterPrivateMode(final boolean isPrivate, final PorterDuffColorFilter colorFilter) { + // The TabsCounter is a TextSwitcher which cycles two views + // to provide animations, hence looping over these two children. + for (int i = 0; i < 2; ++i) { + final ThemedTextView view = (ThemedTextView) tabsCounter.getChildAt(i); + view.setPrivateMode(isPrivate); + view.getBackground().mutate().setColorFilter(colorFilter); + } + + // To prevent animation of the background, + // it is set to a different Drawable. + tabsCounter.getBackground().mutate().setColorFilter(colorFilter); + } + + @Override + public View getDoorHangerAnchor() { + return backButton; + } + + protected boolean canDoBack(final Tab tab) { + return (tab.canDoBack() && !isEditing()); + } + + protected boolean canDoForward(final Tab tab) { + return (tab.canDoForward() && !isEditing()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java new file mode 100644 index 000000000..55567fba3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java @@ -0,0 +1,62 @@ +/* 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.toolbar; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff.Mode; +import android.graphics.PorterDuffXfermode; +import android.graphics.Shader; + +class CanvasDelegate { + Paint mPaint; + PorterDuffXfermode mMode; + DrawManager mDrawManager; + + // DrawManager would do a default draw of the background. + static interface DrawManager { + public void defaultDraw(Canvas canvas); + } + + CanvasDelegate(DrawManager drawManager, Mode mode, Paint paint) { + mDrawManager = drawManager; + + // DST_IN masks, DST_OUT clips. + mMode = new PorterDuffXfermode(mode); + + mPaint = paint; + } + + void draw(Canvas canvas, Path path, int width, int height) { + // Save the canvas. All PorterDuff operations should be done in a offscreen bitmap. + int count = canvas.saveLayer(0, 0, width, height, null, + Canvas.MATRIX_SAVE_FLAG | + Canvas.CLIP_SAVE_FLAG | + Canvas.HAS_ALPHA_LAYER_SAVE_FLAG | + Canvas.FULL_COLOR_LAYER_SAVE_FLAG | + Canvas.CLIP_TO_LAYER_SAVE_FLAG); + + // Do a default draw. + mDrawManager.defaultDraw(canvas); + + if (path != null && !path.isEmpty()) { + // ICS added double-buffering, which made it easier for drawing the Path directly over the DST. + // In pre-ICS, drawPath() doesn't seem to use ARGB_8888 mode for performance, hence transparency is not preserved. + mPaint.setXfermode(mMode); + canvas.drawPath(path, mPaint); + } + + // Restore the canvas. + canvas.restoreToCount(count); + } + + void setShader(Shader shader) { + mPaint.setShader(shader); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java new file mode 100644 index 000000000..f95bb5e8a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java @@ -0,0 +1,23 @@ +/* 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.toolbar; + +import android.content.Context; +import android.util.AttributeSet; + +public class ForwardButton extends NavButton { + public ForwardButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + mBorderPath.reset(); + mBorderPath.moveTo(width - mBorderWidth, 0); + mBorderPath.lineTo(width - mBorderWidth, height); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java new file mode 100644 index 000000000..68194e222 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java @@ -0,0 +1,85 @@ +/* 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.toolbar; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; + +abstract class NavButton extends ShapedButton { + protected final Path mBorderPath; + protected final Paint mBorderPaint; + protected final float mBorderWidth; + + protected final int mBorderColor; + protected final int mBorderColorPrivate; + + public NavButton(Context context, AttributeSet attrs) { + super(context, attrs); + + final Resources res = getResources(); + mBorderColor = ContextCompat.getColor(context, R.color.disabled_grey); + mBorderColorPrivate = ContextCompat.getColor(context, R.color.toolbar_icon_grey); + mBorderWidth = res.getDimension(R.dimen.nav_button_border_width); + + // Paint to draw the border. + mBorderPaint = new Paint(); + mBorderPaint.setAntiAlias(true); + mBorderPaint.setStrokeWidth(mBorderWidth); + mBorderPaint.setStyle(Paint.Style.STROKE); + + // Path is masked. + mBorderPath = new Path(); + + setPrivateMode(false); + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + mBorderPaint.setColor(isPrivate ? mBorderColorPrivate : mBorderColor); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + // Draw the border on top. + canvas.drawPath(mBorderPath, mBorderPaint); + } + + // The drawable is constructed as per @drawable/url_bar_nav_button. + @Override + public void onLightweightThemeChanged() { + final Drawable drawable = BrowserToolbar.getLightweightThemeDrawable(this, getTheme(), R.color.toolbar_grey); + + if (drawable == null) { + return; + } + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRIVATE_PRESSED_STATE_SET, getColorDrawable(R.color.placeholder_active_grey)); + stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.toolbar_grey_pressed)); + stateList.addState(PRIVATE_FOCUSED_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey)); + stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.tablet_highlight_focused)); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.tabs_tray_grey_pressed)); + stateList.addState(EMPTY_STATE_SET, drawable); + + setBackgroundDrawable(stateList); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundResource(R.drawable.url_bar_nav_button); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java new file mode 100644 index 000000000..9361d5907 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java @@ -0,0 +1,371 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.ResourceDrawableUtils; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.GeckoPopupMenu; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import java.util.Iterator; +import java.util.List; +import java.util.UUID; +import java.util.ArrayList; + +public class PageActionLayout extends LinearLayout implements NativeEventListener, + View.OnClickListener, + View.OnLongClickListener { + private static final String MENU_BUTTON_KEY = "MENU_BUTTON_KEY"; + private static final int DEFAULT_PAGE_ACTIONS_SHOWN = 2; + + private final Context mContext; + private final LinearLayout mLayout; + private final List<PageAction> mPageActionList; + + private GeckoPopupMenu mPageActionsMenu; + + // By default it's two, can be changed by calling setNumberShown(int) + private int mMaxVisiblePageActions; + + public PageActionLayout(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + mLayout = this; + + mPageActionList = new ArrayList<PageAction>(); + setNumberShown(DEFAULT_PAGE_ACTIONS_SHOWN); + refreshPageActionIcons(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "PageActions:Add", + "PageActions:Remove"); + } + + @Override + protected void onDetachedFromWindow() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "PageActions:Add", + "PageActions:Remove"); + + super.onDetachedFromWindow(); + } + + private void setNumberShown(int count) { + ThreadUtils.assertOnUiThread(); + + mMaxVisiblePageActions = count; + + for (int index = 0; index < count; index++) { + if ((getChildCount() - 1) < index) { + mLayout.addView(createImageButton()); + } + } + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + // NativeJSObject cannot be used off of the Gecko thread, so convert it to a Bundle. + final Bundle bundle = message.toBundle(); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + handleUiMessage(event, bundle); + } + }); + } + + private void handleUiMessage(final String event, final Bundle message) { + ThreadUtils.assertOnUiThread(); + + if (event.equals("PageActions:Add")) { + final String id = message.getString("id"); + final String title = message.getString("title"); + final String imageURL = message.getString("icon"); + final boolean important = message.getBoolean("important"); + + addPageAction(id, title, imageURL, new OnPageActionClickListeners() { + @Override + public void onClick(String id) { + GeckoAppShell.notifyObservers("PageActions:Clicked", id); + } + + @Override + public boolean onLongClick(String id) { + GeckoAppShell.notifyObservers("PageActions:LongClicked", id); + return true; + } + }, important); + } else if (event.equals("PageActions:Remove")) { + final String id = message.getString("id"); + + removePageAction(id); + } + } + + private void addPageAction(final String id, final String title, final String imageData, + final OnPageActionClickListeners onPageActionClickListeners, boolean important) { + ThreadUtils.assertOnUiThread(); + + final PageAction pageAction = new PageAction(id, title, null, onPageActionClickListeners, important); + + int insertAt = mPageActionList.size(); + while (insertAt > 0 && mPageActionList.get(insertAt - 1).isImportant()) { + insertAt--; + } + mPageActionList.add(insertAt, pageAction); + + ResourceDrawableUtils.getDrawable(mContext, imageData, new ResourceDrawableUtils.BitmapLoader() { + @Override + public void onBitmapFound(final Drawable d) { + if (mPageActionList.contains(pageAction)) { + pageAction.setDrawable(d); + refreshPageActionIcons(); + } + } + }); + } + + private void removePageAction(String id) { + ThreadUtils.assertOnUiThread(); + + final Iterator<PageAction> iter = mPageActionList.iterator(); + while (iter.hasNext()) { + final PageAction pageAction = iter.next(); + if (pageAction.getID().equals(id)) { + iter.remove(); + refreshPageActionIcons(); + return; + } + } + } + + private ImageButton createImageButton() { + ThreadUtils.assertOnUiThread(); + + final int width = mContext.getResources().getDimensionPixelSize(R.dimen.page_action_button_width); + ImageButton imageButton = new ImageButton(mContext, null, R.style.UrlBar_ImageButton); + imageButton.setLayoutParams(new LayoutParams(width, LayoutParams.MATCH_PARENT)); + imageButton.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + imageButton.setOnClickListener(this); + imageButton.setOnLongClickListener(this); + return imageButton; + } + + @Override + public void onClick(View v) { + String buttonClickedId = (String)v.getTag(); + if (buttonClickedId != null) { + if (buttonClickedId.equals(MENU_BUTTON_KEY)) { + showMenu(v, mPageActionList.size() - mMaxVisiblePageActions + 1); + } else { + getPageActionWithId(buttonClickedId).onClick(); + } + } + } + + @Override + public boolean onLongClick(View v) { + String buttonClickedId = (String)v.getTag(); + if (buttonClickedId.equals(MENU_BUTTON_KEY)) { + showMenu(v, mPageActionList.size() - mMaxVisiblePageActions + 1); + return true; + } else { + return getPageActionWithId(buttonClickedId).onLongClick(); + } + } + + private void setActionForView(final ImageButton view, final PageAction pageAction) { + ThreadUtils.assertOnUiThread(); + + if (pageAction == null) { + view.setTag(null); + view.setImageDrawable(null); + view.setVisibility(View.GONE); + view.setContentDescription(null); + return; + } + + view.setTag(pageAction.getID()); + view.setImageDrawable(pageAction.getDrawable()); + view.setVisibility(View.VISIBLE); + view.setContentDescription(pageAction.getTitle()); + } + + private void refreshPageActionIcons() { + ThreadUtils.assertOnUiThread(); + + final Resources resources = mContext.getResources(); + for (int i = 0; i < this.getChildCount(); i++) { + final ImageButton v = (ImageButton) this.getChildAt(i); + final PageAction pageAction = getPageActionForViewAt(i); + + // If there are more page actions than buttons, set the menu icon. + // Otherwise, set the page action's icon if there is a page action. + if ((i == this.getChildCount() - 1) && (mPageActionList.size() > mMaxVisiblePageActions)) { + v.setTag(MENU_BUTTON_KEY); + v.setImageDrawable(resources.getDrawable(R.drawable.icon_pageaction)); + v.setVisibility((pageAction != null) ? View.VISIBLE : View.GONE); + v.setContentDescription(resources.getString(R.string.page_action_dropmarker_description)); + } else { + setActionForView(v, pageAction); + } + } + } + + private PageAction getPageActionForViewAt(int index) { + ThreadUtils.assertOnUiThread(); + + /** + * We show the user the most recent pageaction added since this keeps the user aware of any new page actions being added + * Also, the order of the pageAction is important i.e. if a page action is added, instead of shifting the pagactions to the + * left to make space for the new one, it would be more visually appealing to have the pageaction appear in the blank space. + * + * buttonIndex is needed for this reason because every new View added to PageActionLayout gets added to the right of its neighbouring View. + * Hence the button on the very leftmost has the index 0. We want our pageactions to start from the rightmost + * and hence we maintain the insertion order of the child Views which is essentially the reverse of their index + */ + + final int buttonIndex = (this.getChildCount() - 1) - index; + + if (mPageActionList.size() > buttonIndex) { + // Return the pageactions starting from the end of the list for the number of visible pageactions. + final int buttonCount = Math.min(mPageActionList.size(), getChildCount()); + return mPageActionList.get((mPageActionList.size() - buttonCount) + buttonIndex); + } + return null; + } + + private PageAction getPageActionWithId(String id) { + ThreadUtils.assertOnUiThread(); + + for (PageAction pageAction : mPageActionList) { + if (pageAction.getID().equals(id)) { + return pageAction; + } + } + return null; + } + + private void showMenu(View pageActionButton, int toShow) { + ThreadUtils.assertOnUiThread(); + + if (mPageActionsMenu == null) { + mPageActionsMenu = new GeckoPopupMenu(pageActionButton.getContext(), pageActionButton); + mPageActionsMenu.inflate(0); + mPageActionsMenu.setOnMenuItemClickListener(new GeckoPopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + int id = item.getItemId(); + for (int i = 0; i < mPageActionList.size(); i++) { + PageAction pageAction = mPageActionList.get(i); + if (pageAction.key() == id) { + pageAction.onClick(); + return true; + } + } + return false; + } + }); + } + Menu menu = mPageActionsMenu.getMenu(); + menu.clear(); + + for (int i = 0; i < mPageActionList.size() && i < toShow; i++) { + PageAction pageAction = mPageActionList.get(i); + MenuItem item = menu.add(Menu.NONE, pageAction.key(), Menu.NONE, pageAction.getTitle()); + item.setIcon(pageAction.getDrawable()); + } + mPageActionsMenu.show(); + } + + private static interface OnPageActionClickListeners { + public void onClick(String id); + public boolean onLongClick(String id); + } + + private static class PageAction { + private final OnPageActionClickListeners mOnPageActionClickListeners; + private Drawable mDrawable; + private final String mTitle; + private final String mId; + private final int key; + private final boolean mImportant; + + public PageAction(String id, + String title, + Drawable image, + OnPageActionClickListeners onPageActionClickListeners, + boolean important) { + mId = id; + mTitle = title; + mDrawable = image; + mOnPageActionClickListeners = onPageActionClickListeners; + mImportant = important; + + key = UUID.fromString(mId.subSequence(1, mId.length() - 2).toString()).hashCode(); + } + + public Drawable getDrawable() { + return mDrawable; + } + + public void setDrawable(Drawable d) { + mDrawable = d; + } + + public String getTitle() { + return mTitle; + } + + public String getID() { + return mId; + } + + public int key() { + return key; + } + + public boolean isImportant() { + return mImportant; + } + + public void onClick() { + if (mOnPageActionClickListeners != null) { + mOnPageActionClickListeners.onClick(mId); + } + } + + public boolean onLongClick() { + if (mOnPageActionClickListeners != null) { + return mOnPageActionClickListeners.onLongClick(mId); + } + return false; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java new file mode 100644 index 000000000..416485494 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java @@ -0,0 +1,29 @@ +/* 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.toolbar; + +import android.content.Context; +import android.util.AttributeSet; + +import org.mozilla.gecko.tabs.TabCurve; + +public class PhoneTabsButton extends ShapedButton { + public PhoneTabsButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + mPath.reset(); + + mPath.moveTo(0, 0); + TabCurve.drawFromTop(mPath, 0, height, TabCurve.Direction.RIGHT); + mPath.lineTo(width, height); + mPath.lineTo(width, 0); + mPath.lineTo(0, 0); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java new file mode 100644 index 000000000..003dada2d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java @@ -0,0 +1,109 @@ +/* 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.toolbar; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.lwt.LightweightThemeDrawable; +import org.mozilla.gecko.widget.themed.ThemedImageButton; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff.Mode; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; + +/** + * A ImageButton with a custom drawn path and lightweight theme support. Note that {@link ShapedButtonFrameLayout} + * copies the lwt support so if you change it here, you should probably change it there. + */ +public class ShapedButton extends ThemedImageButton + implements CanvasDelegate.DrawManager { + + protected final Path mPath; + protected final CanvasDelegate mCanvasDelegate; + + public ShapedButton(Context context, AttributeSet attrs) { + super(context, attrs); + + // Path is clipped. + mPath = new Path(); + + final Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setColor(ContextCompat.getColor(context, R.color.canvas_delegate_paint)); + paint.setStrokeWidth(0.0f); + mCanvasDelegate = new CanvasDelegate(this, Mode.DST_IN, paint); + + setWillNotDraw(false); + } + + @Override + @SuppressLint("MissingSuperCall") // Super gets called from defaultDraw(). + // It is intentionally not called in the other case. + public void draw(Canvas canvas) { + if (mCanvasDelegate != null) + mCanvasDelegate.draw(canvas, mPath, getWidth(), getHeight()); + else + defaultDraw(canvas); + } + + @Override + public void defaultDraw(Canvas canvas) { + super.draw(canvas); + } + + // The drawable is constructed as per @drawable/shaped_button. + @Override + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable lightWeight = getTheme().getColorDrawable(this, background); + + if (lightWeight == null) + return; + + lightWeight.setAlpha(34, 34); + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.highlight_shaped)); + stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.highlight_shaped_focused)); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey)); + stateList.addState(EMPTY_STATE_SET, lightWeight); + + setBackgroundDrawable(stateList); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundResource(R.drawable.shaped_button); + } + + @Override + public void setBackgroundDrawable(Drawable drawable) { + if (getBackground() == null || drawable == null) { + super.setBackgroundDrawable(drawable); + return; + } + + int[] padding = new int[] { getPaddingLeft(), + getPaddingTop(), + getPaddingRight(), + getPaddingBottom() + }; + drawable.setLevel(getBackground().getLevel()); + super.setBackgroundDrawable(drawable); + + setPadding(padding[0], padding[1], padding[2], padding[3]); + } + + @Override + public void setBackgroundResource(int resId) { + setBackgroundDrawable(getResources().getDrawable(resId)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java new file mode 100644 index 000000000..c14829aec --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java @@ -0,0 +1,74 @@ +/* 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.toolbar; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.R; +import org.mozilla.gecko.lwt.LightweightThemeDrawable; +import org.mozilla.gecko.widget.themed.ThemedFrameLayout; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; + +/** A FrameLayout with lightweight theme support. Note that {@link ShapedButton}'s lwt support is basically the same so + * if you change it here, you should probably change it there. Note also that this doesn't have ShapedButton's path code + * so shouldn't have "ShapedButton" in the name, but I wanted to make the connection apparent so I left it. + */ +public class ShapedButtonFrameLayout extends ThemedFrameLayout { + + public ShapedButtonFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + // The drawable is constructed as per @drawable/shaped_button. + @Override + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable lightWeight = getTheme().getColorDrawable(this, background); + + if (lightWeight == null) + return; + + lightWeight.setAlpha(34, 34); + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.highlight_shaped)); + stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.highlight_shaped_focused)); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey)); + stateList.addState(EMPTY_STATE_SET, lightWeight); + + setBackgroundDrawable(stateList); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundResource(R.drawable.shaped_button); + } + + @Override + public void setBackgroundDrawable(Drawable drawable) { + if (getBackground() == null || drawable == null) { + super.setBackgroundDrawable(drawable); + return; + } + + int[] padding = new int[] { getPaddingLeft(), + getPaddingTop(), + getPaddingRight(), + getPaddingBottom() + }; + drawable.setLevel(getBackground().getLevel()); + super.setBackgroundDrawable(drawable); + + setPadding(padding[0], padding[1], padding[2], padding[3]); + } + + @Override + public void setBackgroundResource(int resId) { + setBackgroundDrawable(getResources().getDrawable(resId)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java new file mode 100644 index 000000000..14230a2ec --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java @@ -0,0 +1,571 @@ +/* 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.toolbar; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.widget.ImageView; +import android.widget.Toast; +import org.json.JSONException; +import org.json.JSONArray; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.R; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.SiteIdentity; +import org.mozilla.gecko.SiteIdentity.SecurityMode; +import org.mozilla.gecko.SiteIdentity.MixedMode; +import org.mozilla.gecko.SiteIdentity.TrackingMode; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.AnchoredPopup; +import org.mozilla.gecko.widget.DoorHanger; +import org.mozilla.gecko.widget.DoorHanger.OnButtonClickListener; +import org.json.JSONObject; + +import android.app.Activity; +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import org.mozilla.gecko.widget.DoorhangerConfig; +import org.mozilla.gecko.widget.SiteLogins; + +/** + * SiteIdentityPopup is a singleton class that displays site identity data in + * an arrow panel popup hanging from the lock icon in the browser toolbar. + * + * A site identity icon may be displayed in the url, and is set in <code>ToolbarDisplayLayout</code>. + */ +public class SiteIdentityPopup extends AnchoredPopup implements GeckoEventListener { + + public static enum ButtonType { DISABLE, ENABLE, KEEP_BLOCKING, CANCEL, COPY } + + private static final String LOGTAG = "GeckoSiteIdentityPopup"; + + private static final String MIXED_CONTENT_SUPPORT_URL = + "https://support.mozilla.org/kb/how-does-insecure-content-affect-safety-android"; + private static final String TRACKING_CONTENT_SUPPORT_URL = + "https://support.mozilla.org/kb/firefox-android-tracking-protection"; + + // Placeholder string. + private final static String FORMAT_S = "%s"; + + private final Resources mResources; + private SiteIdentity mSiteIdentity; + + private LinearLayout mIdentity; + + private LinearLayout mIdentityKnownContainer; + + private ImageView mIcon; + private TextView mTitle; + private TextView mSecurityState; + private TextView mMixedContentActivity; + private TextView mOwner; + private TextView mOwnerSupplemental; + private TextView mVerifier; + private TextView mLink; + private TextView mSiteSettingsLink; + + private View mDivider; + + private DoorHanger mTrackingContentNotification; + private DoorHanger mSelectLoginDoorhanger; + + private final OnButtonClickListener mContentButtonClickListener; + + public SiteIdentityPopup(Context context) { + super(context); + + mResources = mContext.getResources(); + + mContentButtonClickListener = new ContentNotificationButtonListener(); + + GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, + "Doorhanger:Logins", + "Permissions:CheckResult"); + } + + @Override + protected void init() { + super.init(); + + // Make the popup focusable so it doesn't inadvertently trigger click events elsewhere + // which may reshow the popup (see bug 785156) + setFocusable(true); + + LayoutInflater inflater = LayoutInflater.from(mContext); + mIdentity = (LinearLayout) inflater.inflate(R.layout.site_identity, null); + mContent.addView(mIdentity); + + mIdentityKnownContainer = + (LinearLayout) mIdentity.findViewById(R.id.site_identity_known_container); + + mIcon = (ImageView) mIdentity.findViewById(R.id.site_identity_icon); + mTitle = (TextView) mIdentity.findViewById(R.id.site_identity_title); + mSecurityState = (TextView) mIdentity.findViewById(R.id.site_identity_state); + mMixedContentActivity = (TextView) mIdentity.findViewById(R.id.mixed_content_activity); + + mOwner = (TextView) mIdentityKnownContainer.findViewById(R.id.owner); + mOwnerSupplemental = (TextView) mIdentityKnownContainer.findViewById(R.id.owner_supplemental); + mVerifier = (TextView) mIdentityKnownContainer.findViewById(R.id.verifier); + mDivider = mIdentity.findViewById(R.id.divider_doorhanger); + + mLink = (TextView) mIdentity.findViewById(R.id.site_identity_link); + mLink.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Tabs.getInstance().loadUrlInTab(MIXED_CONTENT_SUPPORT_URL); + } + }); + + mSiteSettingsLink = (TextView) mIdentity.findViewById(R.id.site_settings_link); + } + + private void updateIdentity(final SiteIdentity siteIdentity) { + if (!mInflated) { + init(); + } + + final boolean isIdentityKnown = (siteIdentity.getSecurityMode() == SecurityMode.IDENTIFIED || + siteIdentity.getSecurityMode() == SecurityMode.VERIFIED); + updateConnectionState(siteIdentity); + toggleIdentityKnownContainerVisibility(isIdentityKnown); + + if (isIdentityKnown) { + updateIdentityInformation(siteIdentity); + } + + GeckoAppShell.notifyObservers("Permissions:Check", null); + } + + @Override + public void handleMessage(String event, JSONObject geckoObject) { + if ("Doorhanger:Logins".equals(event)) { + try { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null) { + final JSONObject data = geckoObject.getJSONObject("data"); + addLoginsToTab(data); + } + if (isShowing()) { + addSelectLoginDoorhanger(selectedTab); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error accessing logins in Doorhanger:Logins message", e); + } + } else if ("Permissions:CheckResult".equals(event)) { + final boolean hasPermissions = geckoObject.optBoolean("hasPermissions", false); + if (hasPermissions) { + mSiteSettingsLink.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + GeckoAppShell.notifyObservers("Permissions:Get", null); + dismiss(); + } + }); + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mSiteSettingsLink.setVisibility(hasPermissions ? View.VISIBLE : View.GONE); + } + }); + } + } + + private void addLoginsToTab(JSONObject data) throws JSONException { + final JSONArray logins = data.getJSONArray("logins"); + + final SiteLogins siteLogins = new SiteLogins(logins); + Tabs.getInstance().getSelectedTab().setSiteLogins(siteLogins); + } + + private void addSelectLoginDoorhanger(Tab tab) throws JSONException { + final SiteLogins siteLogins = tab.getSiteLogins(); + if (siteLogins == null) { + return; + } + + final JSONArray logins = siteLogins.getLogins(); + if (logins.length() == 0) { + return; + } + + final JSONObject login = (JSONObject) logins.get(0); + + // Create button click listener for copying a password to the clipboard. + final OnButtonClickListener buttonClickListener = new OnButtonClickListener() { + Activity activity = (Activity) mContext; + @Override + public void onButtonClick(JSONObject response, DoorHanger doorhanger) { + try { + final int buttonId = response.getInt("callback"); + if (buttonId == ButtonType.COPY.ordinal()) { + final ClipboardManager manager = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + String password; + if (response.has("password")) { + // Click listener being called from List Dialog. + password = response.optString("password"); + } else { + password = login.getString("password"); + } + + manager.setPrimaryClip(ClipData.newPlainText("password", password)); + + SnackbarBuilder.builder(activity) + .message(R.string.doorhanger_login_select_toast_copy) + .duration(Snackbar.LENGTH_SHORT) + .buildAndShow(); + } + dismiss(); + } catch (JSONException e) { + Log.e(LOGTAG, "Error handling Select login button click", e); + SnackbarBuilder.builder(activity) + .message(R.string.doorhanger_login_select_toast_copy_error) + .duration(Snackbar.LENGTH_SHORT) + .buildAndShow(); + } + } + }; + + final DoorhangerConfig config = new DoorhangerConfig(DoorHanger.Type.LOGIN, buttonClickListener); + + // Set buttons. + config.setButton(mContext.getString(R.string.button_cancel), ButtonType.CANCEL.ordinal(), false); + config.setButton(mContext.getString(R.string.button_copy), ButtonType.COPY.ordinal(), true); + + // Set message. + String username = ((JSONObject) logins.get(0)).getString("username"); + if (TextUtils.isEmpty(username)) { + username = mContext.getString(R.string.doorhanger_login_no_username); + } + + final String message = mContext.getString(R.string.doorhanger_login_select_message).replace(FORMAT_S, username); + config.setMessage(message); + + // Set options. + final JSONObject options = new JSONObject(); + + // Add action text only if there are other logins to select. + if (logins.length() > 1) { + + final JSONObject actionText = new JSONObject(); + actionText.put("type", "SELECT"); + + final JSONObject bundle = new JSONObject(); + bundle.put("logins", logins); + + actionText.put("bundle", bundle); + options.put("actionText", actionText); + } + + config.setOptions(options); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (!mInflated) { + init(); + } + + removeSelectLoginDoorhanger(); + + mSelectLoginDoorhanger = DoorHanger.Get(mContext, config); + mContent.addView(mSelectLoginDoorhanger); + mDivider.setVisibility(View.VISIBLE); + } + }); + } + + private void removeSelectLoginDoorhanger() { + if (mSelectLoginDoorhanger != null) { + mContent.removeView(mSelectLoginDoorhanger); + mSelectLoginDoorhanger = null; + } + } + + private void toggleIdentityKnownContainerVisibility(final boolean isIdentityKnown) { + final int identityInfoVisibility = isIdentityKnown ? View.VISIBLE : View.GONE; + mIdentityKnownContainer.setVisibility(identityInfoVisibility); + } + + /** + * Update the Site Identity content to reflect connection state. + * + * The connection state should reflect the combination of: + * a) Connection encryption + * b) Mixed Content state (Active/Display Mixed content, loaded, blocked, none, etc) + * and update the icons and strings to inform the user of that state. + * + * @param siteIdentity SiteIdentity information about the connection. + */ + private void updateConnectionState(final SiteIdentity siteIdentity) { + if (siteIdentity.getSecurityMode() == SecurityMode.CHROMEUI) { + mSecurityState.setText(R.string.identity_connection_chromeui); + mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.placeholder_active_grey)); + + mIcon.setImageResource(R.drawable.icon); + clearSecurityStateIcon(); + + mMixedContentActivity.setVisibility(View.GONE); + mLink.setVisibility(View.GONE); + } else if (!siteIdentity.isSecure()) { + if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_LOADED) { + // Active Mixed Content loaded because user has disabled blocking. + mIcon.setImageResource(R.drawable.lock_disabled); + clearSecurityStateIcon(); + mMixedContentActivity.setVisibility(View.VISIBLE); + mMixedContentActivity.setText(R.string.mixed_content_protection_disabled); + + mLink.setVisibility(View.VISIBLE); + } else if (siteIdentity.getMixedModeDisplay() == MixedMode.MIXED_CONTENT_LOADED) { + // Passive Mixed Content loaded. + mIcon.setImageResource(R.drawable.lock_inactive); + setSecurityStateIcon(R.drawable.warning_major, 1); + mMixedContentActivity.setVisibility(View.VISIBLE); + if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_BLOCKED) { + mMixedContentActivity.setText(R.string.mixed_content_blocked_some); + } else { + mMixedContentActivity.setText(R.string.mixed_content_display_loaded); + } + mLink.setVisibility(View.VISIBLE); + + } else { + // Unencrypted connection with no mixed content. + mIcon.setImageResource(R.drawable.globe_light); + clearSecurityStateIcon(); + + mMixedContentActivity.setVisibility(View.GONE); + mLink.setVisibility(View.GONE); + } + + mSecurityState.setText(R.string.identity_connection_insecure); + mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.placeholder_active_grey)); + } else { + // Connection is secure. + mIcon.setImageResource(R.drawable.lock_secure); + + setSecurityStateIcon(R.drawable.img_check, 2); + mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.affirmative_green)); + mSecurityState.setText(R.string.identity_connection_secure); + + // Mixed content has been blocked, if present. + if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_BLOCKED || + siteIdentity.getMixedModeDisplay() == MixedMode.MIXED_CONTENT_BLOCKED) { + mMixedContentActivity.setVisibility(View.VISIBLE); + mMixedContentActivity.setText(R.string.mixed_content_blocked_all); + mLink.setVisibility(View.VISIBLE); + } else { + mMixedContentActivity.setVisibility(View.GONE); + mLink.setVisibility(View.GONE); + } + } + } + + private void clearSecurityStateIcon() { + mSecurityState.setCompoundDrawablePadding(0); + mSecurityState.setCompoundDrawables(null, null, null, null); + } + + private void setSecurityStateIcon(int resource, int factor) { + final Drawable stateIcon = ContextCompat.getDrawable(mContext, resource); + stateIcon.setBounds(0, 0, stateIcon.getIntrinsicWidth() / factor, stateIcon.getIntrinsicHeight() / factor); + mSecurityState.setCompoundDrawables(stateIcon, null, null, null); + mSecurityState.setCompoundDrawablePadding((int) mResources.getDimension(R.dimen.doorhanger_drawable_padding)); + } + private void updateIdentityInformation(final SiteIdentity siteIdentity) { + String owner = siteIdentity.getOwner(); + if (owner == null) { + mOwner.setVisibility(View.GONE); + mOwnerSupplemental.setVisibility(View.GONE); + } else { + mOwner.setVisibility(View.VISIBLE); + mOwner.setText(owner); + + // Supplemental data is optional. + final String supplemental = siteIdentity.getSupplemental(); + if (!TextUtils.isEmpty(supplemental)) { + mOwnerSupplemental.setText(supplemental); + mOwnerSupplemental.setVisibility(View.VISIBLE); + } else { + mOwnerSupplemental.setVisibility(View.GONE); + } + } + + final String verifier = siteIdentity.getVerifier(); + mVerifier.setText(verifier); + } + + private void addTrackingContentNotification(boolean blocked) { + // Remove any existing tracking content notification. + removeTrackingContentNotification(); + + final DoorhangerConfig config = new DoorhangerConfig(DoorHanger.Type.TRACKING, mContentButtonClickListener); + + final int icon = blocked ? R.drawable.shield_enabled : R.drawable.shield_disabled; + + final JSONObject options = new JSONObject(); + final JSONObject tracking = new JSONObject(); + try { + tracking.put("enabled", blocked); + options.put("tracking_protection", tracking); + } catch (JSONException e) { + Log.e(LOGTAG, "Error adding tracking protection options", e); + } + config.setOptions(options); + + config.setLink(mContext.getString(R.string.learn_more), TRACKING_CONTENT_SUPPORT_URL); + + addNotificationButtons(config, blocked); + + mTrackingContentNotification = DoorHanger.Get(mContext, config); + + mTrackingContentNotification.setIcon(icon); + + mContent.addView(mTrackingContentNotification); + mDivider.setVisibility(View.VISIBLE); + } + + private void removeTrackingContentNotification() { + if (mTrackingContentNotification != null) { + mContent.removeView(mTrackingContentNotification); + mTrackingContentNotification = null; + } + } + + private void addNotificationButtons(DoorhangerConfig config, boolean blocked) { + if (blocked) { + config.setButton(mContext.getString(R.string.disable_protection), ButtonType.DISABLE.ordinal(), false); + } else { + config.setButton(mContext.getString(R.string.enable_protection), ButtonType.ENABLE.ordinal(), true); + } + } + + /* + * @param identityData A JSONObject that holds the current tab's identity data. + */ + void setSiteIdentity(SiteIdentity siteIdentity) { + mSiteIdentity = siteIdentity; + } + + @Override + public void show() { + if (mSiteIdentity == null) { + Log.e(LOGTAG, "Can't show site identity popup for undefined state"); + return; + } + + // Verified about: pages have the CHROMEUI SiteIdentity, however there can also + // be unverified about: pages for which "This site's identity is unknown" or + // "This is a secure Firefox page" are both misleading, so don't show a popup. + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab != null && + AboutPages.isAboutPage(selectedTab.getURL()) && + mSiteIdentity.getSecurityMode() != SecurityMode.CHROMEUI) { + Log.d(LOGTAG, "We don't show site identity popups for unverified about: pages"); + return; + } + + updateIdentity(mSiteIdentity); + + final TrackingMode trackingMode = mSiteIdentity.getTrackingMode(); + if (trackingMode != TrackingMode.UNKNOWN) { + addTrackingContentNotification(trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED); + } + + try { + addSelectLoginDoorhanger(selectedTab); + } catch (JSONException e) { + Log.e(LOGTAG, "Error adding selectLogin doorhanger", e); + } + + if (mSiteIdentity.getSecurityMode() == SecurityMode.CHROMEUI) { + // For about: pages we display the product icon in place of the verified/globe + // image, hence we don't also set the favicon (for most about pages the + // favicon is the product icon, hence we'd be showing the same icon twice). + mTitle.setText(R.string.moz_app_displayname); + } else { + mTitle.setText(selectedTab.getBaseDomain()); + + final Bitmap favicon = selectedTab.getFavicon(); + if (favicon != null) { + final Drawable faviconDrawable = new BitmapDrawable(mResources, favicon); + final int dimen = (int) mResources.getDimension(R.dimen.browser_toolbar_favicon_size); + faviconDrawable.setBounds(0, 0, dimen, dimen); + + mTitle.setCompoundDrawables(faviconDrawable, null, null, null); + mTitle.setCompoundDrawablePadding((int) mContext.getResources().getDimension(R.dimen.doorhanger_drawable_padding)); + } + } + + showDividers(); + + super.show(); + } + + // Show the right dividers + private void showDividers() { + final int count = mContent.getChildCount(); + DoorHanger lastVisibleDoorHanger = null; + + for (int i = 0; i < count; i++) { + final View child = mContent.getChildAt(i); + + if (!(child instanceof DoorHanger)) { + continue; + } + + DoorHanger dh = (DoorHanger) child; + dh.showDivider(); + if (dh.getVisibility() == View.VISIBLE) { + lastVisibleDoorHanger = dh; + } + } + + if (lastVisibleDoorHanger != null) { + lastVisibleDoorHanger.hideDivider(); + } + } + + void destroy() { + GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, + "Doorhanger:Logins", + "Permissions:CheckResult"); + } + + @Override + public void dismiss() { + super.dismiss(); + removeTrackingContentNotification(); + removeSelectLoginDoorhanger(); + mTitle.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + mDivider.setVisibility(View.GONE); + } + + private class ContentNotificationButtonListener implements OnButtonClickListener { + @Override + public void onButtonClick(JSONObject response, DoorHanger doorhanger) { + GeckoAppShell.notifyObservers("Session:Reload", response.toString()); + dismiss(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java new file mode 100644 index 000000000..1e0ca516b --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java @@ -0,0 +1,154 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.mozilla.gecko.animation.Rotate3DAnimation; +import org.mozilla.gecko.widget.themed.ThemedTextSwitcher; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.animation.AlphaAnimation; +import android.view.animation.AnimationSet; +import android.widget.ViewSwitcher; + +public class TabCounter extends ThemedTextSwitcher + implements ViewSwitcher.ViewFactory { + + private static final float CENTER_X = 0.5f; + private static final float CENTER_Y = 1.25f; + private static final int DURATION = 500; + private static final float Z_DISTANCE = 200; + + private final AnimationSet mFlipInForward; + private final AnimationSet mFlipInBackward; + private final AnimationSet mFlipOutForward; + private final AnimationSet mFlipOutBackward; + private final LayoutInflater mInflater; + private final int mLayoutId; + + private int mCount; + public static final int MAX_VISIBLE_TABS = 99; + public static final String SO_MANY_TABS_OPEN = "∞"; + + private enum FadeMode { + FADE_IN, + FADE_OUT + } + + public TabCounter(Context context, AttributeSet attrs) { + super(context, attrs); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabCounter); + mLayoutId = a.getResourceId(R.styleable.TabCounter_android_layout, R.layout.tabs_counter); + a.recycle(); + + mInflater = LayoutInflater.from(context); + + mFlipInForward = createAnimation(-90, 0, FadeMode.FADE_IN, -1 * Z_DISTANCE, false); + mFlipInBackward = createAnimation(90, 0, FadeMode.FADE_IN, Z_DISTANCE, false); + mFlipOutForward = createAnimation(0, -90, FadeMode.FADE_OUT, -1 * Z_DISTANCE, true); + mFlipOutBackward = createAnimation(0, 90, FadeMode.FADE_OUT, Z_DISTANCE, true); + + removeAllViews(); + setFactory(this); + + if (Versions.feature16Plus) { + // This adds the TextSwitcher to the a11y node tree, where we in turn + // could make it return an empty info node. If we don't do this the + // TextSwitcher's child TextViews get picked up, and we don't want + // that since the tabs ImageButton is already properly labeled for + // accessibility. + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + setAccessibilityDelegate(new View.AccessibilityDelegate() { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {} + }); + } + } + + void setCountWithAnimation(int count) { + // Don't animate from initial state + if (mCount == 0) { + setCount(count); + return; + } + + if (mCount == count) { + return; + } + + // don't animate if there are still over MAX_VISIBLE_TABS tabs open + if (mCount > MAX_VISIBLE_TABS && count > MAX_VISIBLE_TABS) { + mCount = count; + return; + } + + if (count < mCount) { + setInAnimation(mFlipInBackward); + setOutAnimation(mFlipOutForward); + } else { + setInAnimation(mFlipInForward); + setOutAnimation(mFlipOutBackward); + } + + // Eliminate screen artifact. Set explicit In/Out animation pair order. This will always + // animate pair in In->Out child order, prevent alternating use of the Out->In case. + setDisplayedChild(0); + + // Set In value, trigger animation to Out value + setCurrentText(formatForDisplay(mCount)); + setText(formatForDisplay(count)); + + mCount = count; + } + + private String formatForDisplay(int count) { + if (count > MAX_VISIBLE_TABS) { + return SO_MANY_TABS_OPEN; + } + return String.valueOf(count); + } + + void setCount(int count) { + setCurrentText(formatForDisplay(count)); + mCount = count; + } + + // Alpha animations in editing mode cause action bar corruption on the + // Nexus 7 (bug 961749). As a workaround, skip these animations in editing + // mode. + void onEnterEditingMode() { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + getChildAt(i).clearAnimation(); + } + } + + private AnimationSet createAnimation(float startAngle, float endAngle, + FadeMode fadeMode, + float zEnd, boolean reverse) { + final Context context = getContext(); + AnimationSet set = new AnimationSet(context, null); + set.addAnimation(new Rotate3DAnimation(startAngle, endAngle, CENTER_X, CENTER_Y, zEnd, reverse)); + set.addAnimation(fadeMode == FadeMode.FADE_IN ? new AlphaAnimation(0.0f, 1.0f) : + new AlphaAnimation(1.0f, 0.0f)); + set.setDuration(DURATION); + set.setInterpolator(context, android.R.anim.accelerate_interpolator); + return set; + } + + @Override + public View makeView() { + return mInflater.inflate(mLayoutId, null); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java new file mode 100644 index 000000000..163ed4a51 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java @@ -0,0 +1,530 @@ +/* -*- 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.toolbar; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.R; +import org.mozilla.gecko.reader.ReaderModeUtils; +import org.mozilla.gecko.SiteIdentity; +import org.mozilla.gecko.SiteIdentity.MixedMode; +import org.mozilla.gecko.SiteIdentity.SecurityMode; +import org.mozilla.gecko.SiteIdentity.TrackingMode; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.toolbar.BrowserToolbarTabletBase.ForwardButtonAnimation; +import org.mozilla.gecko.Experiments; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; +import org.mozilla.gecko.widget.themed.ThemedTextView; + +import android.content.Context; +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; + +import com.keepsafe.switchboard.SwitchBoard; + +/** +* {@code ToolbarDisplayLayout} is the UI for when the toolbar is in +* display state. It's used to display the state of the currently selected +* tab. It should always be updated through a single entry point +* (updateFromTab) and should never track any tab events or gecko messages +* on its own to keep it as dumb as possible. +* +* The UI has two possible modes: progress and display which are triggered +* when UpdateFlags.PROGRESS is used depending on the current tab state. +* The progress mode is triggered when the tab is loading a page. Display mode +* is used otherwise. +* +* {@code ToolbarDisplayLayout} is meant to be owned by {@code BrowserToolbar} +* which is the main event bus for the toolbar subsystem. +*/ +public class ToolbarDisplayLayout extends ThemedLinearLayout { + + private static final String LOGTAG = "GeckoToolbarDisplayLayout"; + private boolean mTrackingProtectionEnabled; + + // To be used with updateFromTab() to allow the caller + // to give enough context for the requested state change. + enum UpdateFlags { + TITLE, + FAVICON, + PROGRESS, + SITE_IDENTITY, + PRIVATE_MODE, + + // Disable any animation that might be + // triggered from this state change. Mostly + // used on tab switches, see BrowserToolbar. + DISABLE_ANIMATIONS + } + + private enum UIMode { + PROGRESS, + DISPLAY + } + + interface OnStopListener { + Tab onStop(); + } + + interface OnTitleChangeListener { + void onTitleChange(CharSequence title); + } + + private final BrowserApp mActivity; + + private UIMode mUiMode; + + private boolean mIsAttached; + + private final ThemedTextView mTitle; + private final int mTitlePadding; + private ToolbarPrefs mPrefs; + private OnTitleChangeListener mTitleChangeListener; + + private final ImageButton mSiteSecurity; + + private final ImageButton mStop; + private OnStopListener mStopListener; + + private final PageActionLayout mPageActionLayout; + + private final SiteIdentityPopup mSiteIdentityPopup; + private int mSecurityImageLevel; + + // Security level constants, which map to the icons / levels defined in: + // http://dxr.mozilla.org/mozilla-central/source/mobile/android/base/java/org/mozilla/gecko/resources/drawable/site_security_level.xml + // Default level (unverified pages) - globe icon: + private static final int LEVEL_DEFAULT_GLOBE = 0; + // Levels for displaying Mixed Content state icons. + private static final int LEVEL_WARNING_MINOR = 3; + private static final int LEVEL_LOCK_DISABLED = 4; + // Levels for displaying Tracking Protection state icons. + private static final int LEVEL_SHIELD_ENABLED = 5; + private static final int LEVEL_SHIELD_DISABLED = 6; + // Icon used for about:home + private static final int LEVEL_SEARCH_ICON = 999; + + private final ForegroundColorSpan mUrlColorSpan; + private final ForegroundColorSpan mPrivateUrlColorSpan; + private final ForegroundColorSpan mBlockedColorSpan; + private final ForegroundColorSpan mDomainColorSpan; + private final ForegroundColorSpan mPrivateDomainColorSpan; + private final ForegroundColorSpan mCertificateOwnerColorSpan; + + public ToolbarDisplayLayout(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + + mActivity = (BrowserApp) context; + + LayoutInflater.from(context).inflate(R.layout.toolbar_display_layout, this); + + mTitle = (ThemedTextView) findViewById(R.id.url_bar_title); + mTitlePadding = mTitle.getPaddingRight(); + + mUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext)); + mPrivateUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext_private)); + mBlockedColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_blockedtext)); + mDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext)); + mPrivateDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext_private)); + mCertificateOwnerColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.affirmative_green)); + + mSiteSecurity = (ImageButton) findViewById(R.id.site_security); + + mSiteIdentityPopup = new SiteIdentityPopup(mActivity); + mSiteIdentityPopup.setAnchor(this); + mSiteIdentityPopup.setOnVisibilityChangeListener(mActivity); + + mStop = (ImageButton) findViewById(R.id.stop); + mPageActionLayout = (PageActionLayout) findViewById(R.id.page_action_layout); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + mIsAttached = true; + + mSiteSecurity.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + mSiteIdentityPopup.show(); + } + }); + + mStop.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + if (mStopListener != null) { + // Force toolbar to switch to Display mode + // immediately based on the stopped tab. + final Tab tab = mStopListener.onStop(); + if (tab != null) { + updateUiMode(UIMode.DISPLAY); + } + } + } + }); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mIsAttached = false; + } + + @Override + public void setNextFocusDownId(int nextId) { + mStop.setNextFocusDownId(nextId); + mSiteSecurity.setNextFocusDownId(nextId); + mPageActionLayout.setNextFocusDownId(nextId); + } + + void setToolbarPrefs(final ToolbarPrefs prefs) { + mPrefs = prefs; + } + + void updateFromTab(@NonNull Tab tab, EnumSet<UpdateFlags> flags) { + // Several parts of ToolbarDisplayLayout's state depends + // on the views being attached to the view tree. + if (!mIsAttached) { + return; + } + + if (flags.contains(UpdateFlags.TITLE)) { + updateTitle(tab); + } + + if (flags.contains(UpdateFlags.SITE_IDENTITY)) { + updateSiteIdentity(tab); + } + + if (flags.contains(UpdateFlags.PROGRESS)) { + updateProgress(tab); + } + + if (flags.contains(UpdateFlags.PRIVATE_MODE)) { + mTitle.setPrivateMode(tab.isPrivate()); + } + } + + void setTitle(CharSequence title) { + mTitle.setText(title); + + if (mTitleChangeListener != null) { + mTitleChangeListener.onTitleChange(title); + } + } + + private void updateTitle(@NonNull Tab tab) { + // Keep the title unchanged if there's no selected tab, + // or if the tab is entering reader mode. + if (tab.isEnteringReaderMode()) { + return; + } + + final String url = tab.getURL(); + + // Setting a null title will ensure we just see the + // "Enter Search or Address" placeholder text. + if (AboutPages.isTitlelessAboutPage(url)) { + setTitle(null); + setContentDescription(null); + return; + } + + // Show the about:blocked page title in red, regardless of prefs + if (tab.getErrorType() == Tab.ErrorType.BLOCKED) { + final String title = tab.getDisplayTitle(); + + final SpannableStringBuilder builder = new SpannableStringBuilder(title); + builder.setSpan(mBlockedColorSpan, 0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + setTitle(builder); + setContentDescription(null); + return; + } + + final String baseDomain = tab.getBaseDomain(); + + String strippedURL = stripAboutReaderURL(url); + + final boolean isHttpOrHttps = StringUtils.isHttpOrHttps(strippedURL); + + if (mPrefs.shouldTrimUrls()) { + strippedURL = StringUtils.stripCommonSubdomains(StringUtils.stripScheme(strippedURL)); + } + + // The URL bar does not support RTL currently (See bug 928688 and meta bug 702845). + // Displaying a URL using RTL (or mixed) characters can lead to an undesired reordering + // of elements of the URL. That's why we are forcing the URL to use LTR (bug 1284372). + strippedURL = StringUtils.forceLTR(strippedURL); + + // This value is not visible to screen readers but we rely on it when running UI tests. Screen + // readers will instead focus BrowserToolbar and read the "base domain" from there. UI tests + // will read the content description to obtain the full URL for performing assertions. + setContentDescription(strippedURL); + + final SiteIdentity siteIdentity = tab.getSiteIdentity(); + if (siteIdentity.hasOwner() && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_EV_CERT_OWNER)) { + // Show Owner of EV certificate as title + updateTitleFromSiteIdentity(siteIdentity); + } else if (isHttpOrHttps && !HardwareUtils.isTablet() && !TextUtils.isEmpty(baseDomain) + && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_ORIGIN_ONLY)) { + // Show just the base domain as title + setTitle(baseDomain); + } else { + // Display full URL with base domain highlighted as title + updateAndColorTitleFromFullURL(strippedURL, baseDomain, tab.isPrivate()); + } + } + + private void updateTitleFromSiteIdentity(SiteIdentity siteIdentity) { + final String title; + + if (siteIdentity.hasCountry()) { + title = String.format("%s (%s)", siteIdentity.getOwner(), siteIdentity.getCountry()); + } else { + title = siteIdentity.getOwner(); + } + + final SpannableString spannable = new SpannableString(title); + spannable.setSpan(mCertificateOwnerColorSpan, 0, title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + setTitle(spannable); + } + + private void updateAndColorTitleFromFullURL(String url, String baseDomain, boolean isPrivate) { + if (TextUtils.isEmpty(baseDomain)) { + setTitle(url); + return; + } + + int index = url.indexOf(baseDomain); + if (index == -1) { + setTitle(url); + return; + } + + final SpannableStringBuilder builder = new SpannableStringBuilder(url); + + builder.setSpan(isPrivate ? mPrivateUrlColorSpan : mUrlColorSpan, 0, url.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + builder.setSpan(isPrivate ? mPrivateDomainColorSpan : mDomainColorSpan, + index, index + baseDomain.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + setTitle(builder); + } + + private String stripAboutReaderURL(final String url) { + if (!AboutPages.isAboutReader(url)) { + return url; + } + + return ReaderModeUtils.stripAboutReaderUrl(url); + } + + private void updateSiteIdentity(@NonNull Tab tab) { + final SiteIdentity siteIdentity = tab.getSiteIdentity(); + + mSiteIdentityPopup.setSiteIdentity(siteIdentity); + + final SecurityMode securityMode; + final MixedMode activeMixedMode; + final MixedMode displayMixedMode; + final TrackingMode trackingMode; + if (siteIdentity == null) { + securityMode = SecurityMode.UNKNOWN; + activeMixedMode = MixedMode.UNKNOWN; + displayMixedMode = MixedMode.UNKNOWN; + trackingMode = TrackingMode.UNKNOWN; + } else { + securityMode = siteIdentity.getSecurityMode(); + activeMixedMode = siteIdentity.getMixedModeActive(); + displayMixedMode = siteIdentity.getMixedModeDisplay(); + trackingMode = siteIdentity.getTrackingMode(); + } + + // This is a bit tricky, but we have one icon and three potential indicators. + // Default to the identity level + int imageLevel = securityMode.ordinal(); + + // about: pages should default to having no icon too (the same as SecurityMode.UNKNOWN), however + // SecurityMode.CHROMEUI has a different ordinal - hence we need to manually reset it here. + // (We then continue and process the tracking / mixed content icons as usual, even for about: pages, as they + // can still load external sites.) + if (securityMode == SecurityMode.CHROMEUI) { + imageLevel = LEVEL_DEFAULT_GLOBE; // == SecurityMode.UNKNOWN.ordinal() + } + + // Check to see if any protection was overridden first + if (AboutPages.isTitlelessAboutPage(tab.getURL())) { + // We always want to just show a search icon on about:home + imageLevel = LEVEL_SEARCH_ICON; + } else if (trackingMode == TrackingMode.TRACKING_CONTENT_LOADED) { + imageLevel = LEVEL_SHIELD_DISABLED; + } else if (trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED) { + imageLevel = LEVEL_SHIELD_ENABLED; + } else if (activeMixedMode == MixedMode.MIXED_CONTENT_LOADED) { + imageLevel = LEVEL_LOCK_DISABLED; + } else if (displayMixedMode == MixedMode.MIXED_CONTENT_LOADED) { + imageLevel = LEVEL_WARNING_MINOR; + } + + if (mSecurityImageLevel != imageLevel) { + mSecurityImageLevel = imageLevel; + mSiteSecurity.setImageLevel(mSecurityImageLevel); + updatePageActions(); + } + + mTrackingProtectionEnabled = trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED; + } + + private void updateProgress(@NonNull Tab tab) { + final boolean shouldShowThrobber = tab.getState() == Tab.STATE_LOADING; + + updateUiMode(shouldShowThrobber ? UIMode.PROGRESS : UIMode.DISPLAY); + + if (Tab.STATE_SUCCESS == tab.getState() && mTrackingProtectionEnabled) { + mActivity.showTrackingProtectionPromptIfApplicable(); + } + } + + private void updateUiMode(UIMode uiMode) { + if (mUiMode == uiMode) { + return; + } + + mUiMode = uiMode; + + // The "Throbber start" and "Throbber stop" log messages in this method + // are needed by S1/S2 tests (http://mrcote.info/phonedash/#). + // See discussion in Bug 804457. Bug 805124 tracks paring these down. + if (mUiMode == UIMode.PROGRESS) { + Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber start"); + } else { + Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber stop"); + } + + updatePageActions(); + } + + private void updatePageActions() { + final boolean isShowingProgress = (mUiMode == UIMode.PROGRESS); + + mStop.setVisibility(isShowingProgress ? View.VISIBLE : View.GONE); + mPageActionLayout.setVisibility(!isShowingProgress ? View.VISIBLE : View.GONE); + + // We want title to fill the whole space available for it when there are icons + // being shown on the right side of the toolbar as the icons already have some + // padding in them. This is just to avoid wasting space when icons are shown. + mTitle.setPadding(0, 0, (!isShowingProgress ? mTitlePadding : 0), 0); + } + + List<View> getFocusOrder() { + return Arrays.asList(mSiteSecurity, mPageActionLayout, mStop); + } + + void setOnStopListener(OnStopListener listener) { + mStopListener = listener; + } + + void setOnTitleChangeListener(OnTitleChangeListener listener) { + mTitleChangeListener = listener; + } + + /** + * Update the Site Identity popup anchor. + * + * Tablet UI has a tablet-specific doorhanger anchor, so update it after all the views + * are inflated. + * @param view View to use as the anchor for the Site Identity popup. + */ + void updateSiteIdentityAnchor(View view) { + mSiteIdentityPopup.setAnchor(view); + } + + void prepareForwardAnimation(PropertyAnimator anim, ForwardButtonAnimation animation, int width) { + if (animation == ForwardButtonAnimation.HIDE) { + // We animate these items individually, rather than this entire view, + // so that we don't animate certain views, e.g. the stop button. + anim.attach(mTitle, + PropertyAnimator.Property.TRANSLATION_X, + 0); + anim.attach(mSiteSecurity, + PropertyAnimator.Property.TRANSLATION_X, + 0); + + // We're hiding the forward button. We're going to reset the margin before + // the animation starts, so we shift these items to the right so that they don't + // appear to move initially. + ViewHelper.setTranslationX(mTitle, width); + ViewHelper.setTranslationX(mSiteSecurity, width); + } else { + anim.attach(mTitle, + PropertyAnimator.Property.TRANSLATION_X, + width); + anim.attach(mSiteSecurity, + PropertyAnimator.Property.TRANSLATION_X, + width); + } + } + + void finishForwardAnimation() { + ViewHelper.setTranslationX(mTitle, 0); + ViewHelper.setTranslationX(mSiteSecurity, 0); + } + + void prepareStartEditingAnimation() { + // Hide page actions/stop buttons immediately + ViewHelper.setAlpha(mPageActionLayout, 0); + ViewHelper.setAlpha(mStop, 0); + } + + void prepareStopEditingAnimation(PropertyAnimator anim) { + // Fade toolbar buttons (page actions, stop) after the entry + // is shrunk back to its original size. + anim.attach(mPageActionLayout, + PropertyAnimator.Property.ALPHA, + 1); + + anim.attach(mStop, + PropertyAnimator.Property.ALPHA, + 1); + } + + boolean dismissSiteIdentityPopup() { + if (mSiteIdentityPopup != null && mSiteIdentityPopup.isShowing()) { + mSiteIdentityPopup.dismiss(); + return true; + } + + return false; + } + + void destroy() { + mSiteIdentityPopup.destroy(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java new file mode 100644 index 000000000..c9731a401 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java @@ -0,0 +1,348 @@ +/* -*- 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.toolbar; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.speech.RecognizerIntent; +import android.widget.Button; +import android.widget.ImageButton; +import org.mozilla.gecko.ActivityHandlerHelper; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnCommitListener; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener; +import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState; +import org.mozilla.gecko.util.ActivityResultHandler; +import org.mozilla.gecko.util.DrawableUtil; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.StringUtils; +import org.mozilla.gecko.util.InputOptionsUtils; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.ImageView; + +import java.util.List; + +/** +* {@code ToolbarEditLayout} is the UI for when the toolbar is in +* edit state. It controls a text entry ({@code ToolbarEditText}) +* and its matching 'go' button which changes depending on the +* current type of text in the entry. +*/ +public class ToolbarEditLayout extends ThemedLinearLayout { + + public interface OnSearchStateChangeListener { + public void onSearchStateChange(boolean isActive); + } + + private final ImageView mSearchIcon; + + private final ToolbarEditText mEditText; + + private final ImageButton mVoiceInput; + private final ImageButton mQrCode; + + private OnFocusChangeListener mFocusChangeListener; + + private boolean showKeyboardOnFocus = false; // Indicates if we need to show the keyboard after the app resumes + + public ToolbarEditLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + setOrientation(HORIZONTAL); + + LayoutInflater.from(context).inflate(R.layout.toolbar_edit_layout, this); + mSearchIcon = (ImageView) findViewById(R.id.search_icon); + + mEditText = (ToolbarEditText) findViewById(R.id.url_edit_text); + + mVoiceInput = (ImageButton) findViewById(R.id.mic); + mQrCode = (ImageButton) findViewById(R.id.qrcode); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (HardwareUtils.isTablet()) { + mSearchIcon.setVisibility(View.VISIBLE); + } + + mEditText.setOnFocusChangeListener(new OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (mFocusChangeListener != null) { + mFocusChangeListener.onFocusChange(ToolbarEditLayout.this, hasFocus); + + // Checking if voice and QR code input are enabled each time the user taps on the URL bar + if (hasFocus) { + if (voiceIsEnabled(getContext(), getResources().getString(R.string.voicesearch_prompt))) { + mVoiceInput.setVisibility(View.VISIBLE); + } else { + mVoiceInput.setVisibility(View.GONE); + } + + if (qrCodeIsEnabled(getContext())) { + mQrCode.setVisibility(View.VISIBLE); + } else { + mQrCode.setVisibility(View.GONE); + } + } + } + } + }); + + mEditText.setOnSearchStateChangeListener(new OnSearchStateChangeListener() { + @Override + public void onSearchStateChange(boolean isActive) { + updateSearchIcon(isActive); + } + }); + + mVoiceInput.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + launchVoiceRecognizer(); + } + }); + + mQrCode.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + launchQRCodeReader(); + } + }); + + // Set an inactive search icon on tablet devices when in editing mode + updateSearchIcon(false); + } + + /** + * Update the search icon at the left of the edittext based + * on its state. + * + * @param isActive The state of the edittext. Active is when the initialized + * text has changed and is not empty. + */ + void updateSearchIcon(boolean isActive) { + if (!HardwareUtils.isTablet()) { + return; + } + + // When on tablet show a magnifying glass in editing mode + final int searchDrawableId = R.drawable.search_icon_active; + final Drawable searchDrawable; + if (!isActive) { + searchDrawable = DrawableUtil.tintDrawableWithColorRes(getContext(), searchDrawableId, R.color.placeholder_grey); + } else { + if (isPrivateMode()) { + searchDrawable = DrawableUtil.tintDrawableWithColorRes(getContext(), searchDrawableId, R.color.tabs_tray_icon_grey); + } else { + searchDrawable = getResources().getDrawable(searchDrawableId); + } + } + + mSearchIcon.setImageDrawable(searchDrawable); + } + + @Override + public void setOnFocusChangeListener(OnFocusChangeListener listener) { + mFocusChangeListener = listener; + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + mEditText.setEnabled(enabled); + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + mEditText.setPrivateMode(isPrivate); + } + + /** + * Called when the parent gains focus (on app launch and resume) + */ + public void onParentFocus() { + if (showKeyboardOnFocus) { + showKeyboardOnFocus = false; + + Activity activity = GeckoAppShell.getGeckoInterface().getActivity(); + activity.runOnUiThread(new Runnable() { + public void run() { + mEditText.requestFocus(); + showSoftInput(); + } + }); + } + + // Checking if qr code is supported after resuming the app + if (qrCodeIsEnabled(getContext())) { + mQrCode.setVisibility(View.VISIBLE); + } else { + mQrCode.setVisibility(View.GONE); + } + } + + void setToolbarPrefs(final ToolbarPrefs prefs) { + mEditText.setToolbarPrefs(prefs); + } + + private void showSoftInput() { + InputMethodManager imm = + (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mEditText, InputMethodManager.SHOW_IMPLICIT); + } + + void prepareShowAnimation(final PropertyAnimator animator) { + if (animator == null) { + mEditText.requestFocus(); + showSoftInput(); + return; + } + + animator.addPropertyAnimationListener(new PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + mEditText.requestFocus(); + } + + @Override + public void onPropertyAnimationEnd() { + showSoftInput(); + } + }); + } + + void setOnCommitListener(OnCommitListener listener) { + mEditText.setOnCommitListener(listener); + } + + void setOnDismissListener(OnDismissListener listener) { + mEditText.setOnDismissListener(listener); + } + + void setOnFilterListener(OnFilterListener listener) { + mEditText.setOnFilterListener(listener); + } + + void onEditSuggestion(String suggestion) { + mEditText.setText(suggestion); + mEditText.setSelection(mEditText.getText().length()); + mEditText.requestFocus(); + + showSoftInput(); + } + + void setText(String text) { + mEditText.setText(text); + } + + String getText() { + return mEditText.getText().toString(); + } + + protected void saveTabEditingState(final TabEditingState editingState) { + editingState.lastEditingText = mEditText.getNonAutocompleteText(); + editingState.selectionStart = mEditText.getSelectionStart(); + editingState.selectionEnd = mEditText.getSelectionEnd(); + } + + protected void restoreTabEditingState(final TabEditingState editingState) { + mEditText.setText(editingState.lastEditingText); + mEditText.setSelection(editingState.selectionStart, editingState.selectionEnd); + } + + private boolean voiceIsEnabled(Context context, String prompt) { + final boolean voiceIsSupported = InputOptionsUtils.supportsVoiceRecognizer(context, prompt); + if (!voiceIsSupported) { + return false; + } + return GeckoSharedPrefs.forApp(context) + .getBoolean(GeckoPreferences.PREFS_VOICE_INPUT_ENABLED, true); + } + + private void launchVoiceRecognizer() { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "voice_input_launch"); + final Intent intent = InputOptionsUtils.createVoiceRecognizerIntent(getResources().getString(R.string.voicesearch_prompt)); + + Activity activity = GeckoAppShell.getGeckoInterface().getActivity(); + ActivityHandlerHelper.startIntentForActivity(activity, intent, new ActivityResultHandler() { + @Override + public void onActivityResult(int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK) { + return; + } + + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "voice_input_success"); + // We have RESULT_OK, not RESULT_NO_MATCH so it should be safe to assume that + // we have at least one match. We only need one: this will be + // used for showing the user search engines with this search term in it. + List<String> voiceStrings = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS); + String text = voiceStrings.get(0); + mEditText.setText(text); + mEditText.setSelection(0, text.length()); + + final InputMethodManager imm = + (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mEditText, InputMethodManager.SHOW_IMPLICIT); + } + }); + } + + private boolean qrCodeIsEnabled(Context context) { + final boolean qrCodeIsSupported = InputOptionsUtils.supportsQrCodeReader(context); + if (!qrCodeIsSupported) { + return false; + } + return GeckoSharedPrefs.forApp(context) + .getBoolean(GeckoPreferences.PREFS_QRCODE_ENABLED, true); + } + + private void launchQRCodeReader() { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "qrcode_input_launch"); + final Intent intent = InputOptionsUtils.createQRCodeReaderIntent(); + + Activity activity = GeckoAppShell.getGeckoInterface().getActivity(); + ActivityHandlerHelper.startIntentForActivity(activity, intent, new ActivityResultHandler() { + @Override + public void onActivityResult(int resultCode, Intent intent) { + if (resultCode == Activity.RESULT_OK) { + String text = intent.getStringExtra("SCAN_RESULT"); + if (!StringUtils.isSearchQuery(text, false)) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "qrcode_input_success"); + mEditText.setText(text); + mEditText.selectAll(); + + // Queuing up the keyboard show action. + // At this point the app has not resumed yet, and trying to show + // the keyboard will fail. + showKeyboardOnFocus = true; + } + } + // We can get the SCAN_RESULT_FORMAT, SCAN_RESULT_BYTES, + // SCAN_RESULT_ORIENTATION and SCAN_RESULT_ERROR_CORRECTION_LEVEL + // as well as the actual result, which may hold a URL. + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java new file mode 100644 index 000000000..b385f815a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java @@ -0,0 +1,630 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.CustomEditText; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.R; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnCommitListener; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener; +import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener; +import org.mozilla.gecko.toolbar.ToolbarEditLayout.OnSearchStateChangeListener; +import org.mozilla.gecko.util.GamepadUtils; +import org.mozilla.gecko.util.StringUtils; + +import android.content.Context; +import android.graphics.Rect; +import android.text.Editable; +import android.text.NoCopySpan; +import android.text.Selection; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.style.BackgroundColorSpan; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import android.view.inputmethod.InputMethodManager; +import android.view.accessibility.AccessibilityEvent; +import android.widget.TextView; + +/** +* {@code ToolbarEditText} is the text entry used when the toolbar +* is in edit state. It handles all the necessary input method machinery. +* It's meant to be owned by {@code ToolbarEditLayout}. +*/ +public class ToolbarEditText extends CustomEditText + implements AutocompleteHandler { + + private static final String LOGTAG = "GeckoToolbarEditText"; + private static final NoCopySpan AUTOCOMPLETE_SPAN = new NoCopySpan.Concrete(); + + private final Context mContext; + + private OnCommitListener mCommitListener; + private OnDismissListener mDismissListener; + private OnFilterListener mFilterListener; + private OnSearchStateChangeListener mSearchStateChangeListener; + + private ToolbarPrefs mPrefs; + + // The previous autocomplete result returned to us + private String mAutoCompleteResult = ""; + // Length of the user-typed portion of the result + private int mAutoCompletePrefixLength; + // If text change is due to us setting autocomplete + private boolean mSettingAutoComplete; + // Spans used for marking the autocomplete text + private Object[] mAutoCompleteSpans; + // Do not process autocomplete result + private boolean mDiscardAutoCompleteResult; + + public ToolbarEditText(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + } + + void setOnCommitListener(OnCommitListener listener) { + mCommitListener = listener; + } + + void setOnDismissListener(OnDismissListener listener) { + mDismissListener = listener; + } + + void setOnFilterListener(OnFilterListener listener) { + mFilterListener = listener; + } + + void setOnSearchStateChangeListener(OnSearchStateChangeListener listener) { + mSearchStateChangeListener = listener; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + setOnKeyListener(new KeyListener()); + setOnKeyPreImeListener(new KeyPreImeListener()); + setOnSelectionChangedListener(new SelectionChangeListener()); + addTextChangedListener(new TextChangeListener()); + } + + @Override + public void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + + // Make search icon inactive when edit toolbar search term isn't a user entered + // search term + final boolean isActive = !TextUtils.isEmpty(getText()); + if (mSearchStateChangeListener != null) { + mSearchStateChangeListener.onSearchStateChange(isActive); + } + + if (gainFocus) { + resetAutocompleteState(); + return; + } + + removeAutocomplete(getText()); + + final InputMethodManager imm = InputMethods.getInputMethodManager(mContext); + try { + imm.restartInput(this); + imm.hideSoftInputFromWindow(getWindowToken(), 0); + } catch (NullPointerException e) { + Log.e(LOGTAG, "InputMethodManagerService, why are you throwing" + + " a NullPointerException? See bug 782096", e); + } + } + + @Override + public void setText(final CharSequence text, final TextView.BufferType type) { + final String textString = (text == null) ? "" : text.toString(); + + // If we're on the home or private browsing page, we don't set the "about" url. + final CharSequence finalText; + if (AboutPages.isAboutHome(textString) || AboutPages.isAboutPrivateBrowsing(textString)) { + finalText = ""; + } else { + finalText = text; + } + + super.setText(finalText, type); + + // Any autocomplete text would have been overwritten, so reset our autocomplete states. + resetAutocompleteState(); + } + + @Override + public void sendAccessibilityEventUnchecked(AccessibilityEvent event) { + // We need to bypass the isShown() check in the default implementation + // for TYPE_VIEW_TEXT_SELECTION_CHANGED events so that accessibility + // services could detect a url change. + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED && + getParent() != null && !isShown()) { + onInitializeAccessibilityEvent(event); + dispatchPopulateAccessibilityEvent(event); + getParent().requestSendAccessibilityEvent(this, event); + } else { + super.sendAccessibilityEventUnchecked(event); + } + } + + void setToolbarPrefs(final ToolbarPrefs prefs) { + mPrefs = prefs; + } + + /** + * Mark the start of autocomplete changes so our text change + * listener does not react to changes in autocomplete text + */ + private void beginSettingAutocomplete() { + beginBatchEdit(); + mSettingAutoComplete = true; + } + + /** + * Mark the end of autocomplete changes + */ + private void endSettingAutocomplete() { + mSettingAutoComplete = false; + endBatchEdit(); + } + + /** + * Reset autocomplete states to their initial values + */ + private void resetAutocompleteState() { + mAutoCompleteSpans = new Object[] { + // Span to mark the autocomplete text + AUTOCOMPLETE_SPAN, + // Span to change the autocomplete text color + new BackgroundColorSpan(getHighlightColor()) + }; + + mAutoCompleteResult = ""; + + // Pretend we already autocompleted the existing text, + // so that actions like backspacing don't trigger autocompletion. + mAutoCompletePrefixLength = getText().length(); + + // Show the cursor. + setCursorVisible(true); + } + + protected String getNonAutocompleteText() { + return getNonAutocompleteText(getText()); + } + + /** + * Get the portion of text that is not marked as autocomplete text. + * + * @param text Current text content that may include autocomplete text + */ + private static String getNonAutocompleteText(final Editable text) { + final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); + if (start < 0) { + // No autocomplete text; return the whole string. + return text.toString(); + } + + // Only return the portion that's not autocomplete text + return TextUtils.substring(text, 0, start); + } + + /** + * Remove any autocomplete text + * + * @param text Current text content that may include autocomplete text + */ + private boolean removeAutocomplete(final Editable text) { + final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); + if (start < 0) { + // No autocomplete text + return false; + } + + beginSettingAutocomplete(); + + // When we call delete() here, the autocomplete spans we set are removed as well. + text.delete(start, text.length()); + + // Keep mAutoCompletePrefixLength the same because the prefix has not changed. + // Clear mAutoCompleteResult to make sure we get fresh autocomplete text next time. + mAutoCompleteResult = ""; + + // Reshow the cursor. + setCursorVisible(true); + + endSettingAutocomplete(); + return true; + } + + /** + * Convert any autocomplete text to regular text + * + * @param text Current text content that may include autocomplete text + */ + private boolean commitAutocomplete(final Editable text) { + final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); + if (start < 0) { + // No autocomplete text + return false; + } + + beginSettingAutocomplete(); + + // Remove all spans here to convert from autocomplete text to regular text + for (final Object span : mAutoCompleteSpans) { + text.removeSpan(span); + } + + // Keep mAutoCompleteResult the same because the result has not changed. + // Reset mAutoCompletePrefixLength because the prefix now includes the autocomplete text. + mAutoCompletePrefixLength = text.length(); + + // Reshow the cursor. + setCursorVisible(true); + + endSettingAutocomplete(); + + // Filter on the new text + if (mFilterListener != null) { + mFilterListener.onFilter(text.toString(), null); + } + return true; + } + + /** + * Add autocomplete text based on the result URI. + * + * @param result Result URI to be turned into autocomplete text + */ + @Override + public final void onAutocomplete(final String result) { + // If mDiscardAutoCompleteResult is true, we temporarily disabled + // autocomplete (due to backspacing, etc.) and we should bail early. + if (mDiscardAutoCompleteResult) { + return; + } + + if (!isEnabled() || result == null) { + mAutoCompleteResult = ""; + return; + } + + final Editable text = getText(); + final int textLength = text.length(); + final int resultLength = result.length(); + final int autoCompleteStart = text.getSpanStart(AUTOCOMPLETE_SPAN); + mAutoCompleteResult = result; + + if (autoCompleteStart > -1) { + // Autocomplete text already exists; we should replace existing autocomplete text. + + // If the result and the current text don't have the same prefixes, + // the result is stale and we should wait for the another result to come in. + if (!TextUtils.regionMatches(result, 0, text, 0, autoCompleteStart)) { + return; + } + + beginSettingAutocomplete(); + + // Replace the existing autocomplete text with new one. + // replace() preserves the autocomplete spans that we set before. + text.replace(autoCompleteStart, textLength, result, autoCompleteStart, resultLength); + + // Reshow the cursor if there is no longer any autocomplete text. + if (autoCompleteStart == resultLength) { + setCursorVisible(true); + } + + endSettingAutocomplete(); + + } else { + // No autocomplete text yet; we should add autocomplete text + + // If the result prefix doesn't match the current text, + // the result is stale and we should wait for the another result to come in. + if (resultLength <= textLength || + !TextUtils.regionMatches(result, 0, text, 0, textLength)) { + return; + } + + final Object[] spans = text.getSpans(textLength, textLength, Object.class); + final int[] spanStarts = new int[spans.length]; + final int[] spanEnds = new int[spans.length]; + final int[] spanFlags = new int[spans.length]; + + // Save selection/composing span bounds so we can restore them later. + for (int i = 0; i < spans.length; i++) { + final Object span = spans[i]; + final int spanFlag = text.getSpanFlags(span); + + // We don't care about spans that are not selection or composing spans. + // For those spans, spanFlag[i] will be 0 and we don't restore them. + if ((spanFlag & Spanned.SPAN_COMPOSING) == 0 && + (span != Selection.SELECTION_START) && + (span != Selection.SELECTION_END)) { + continue; + } + + spanStarts[i] = text.getSpanStart(span); + spanEnds[i] = text.getSpanEnd(span); + spanFlags[i] = spanFlag; + } + + beginSettingAutocomplete(); + + // First add trailing text. + text.append(result, textLength, resultLength); + + // Restore selection/composing spans. + for (int i = 0; i < spans.length; i++) { + final int spanFlag = spanFlags[i]; + if (spanFlag == 0) { + // Skip if the span was ignored before. + continue; + } + text.setSpan(spans[i], spanStarts[i], spanEnds[i], spanFlag); + } + + // Mark added text as autocomplete text. + for (final Object span : mAutoCompleteSpans) { + text.setSpan(span, textLength, resultLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + // Hide the cursor. + setCursorVisible(false); + + // Make sure the autocomplete text is visible. If the autocomplete text is too + // long, it would appear the cursor will be scrolled out of view. However, this + // is not the case in practice, because EditText still makes sure the cursor is + // still in view. + bringPointIntoView(resultLength); + + endSettingAutocomplete(); + } + } + + private static boolean hasCompositionString(Editable content) { + Object[] spans = content.getSpans(0, content.length(), Object.class); + + if (spans != null) { + for (Object span : spans) { + if ((content.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + // Found composition string. + return true; + } + } + } + + return false; + } + + /** + * Code to handle deleting autocomplete first when backspacing. + * If there is no autocomplete text, both removeAutocomplete() and commitAutocomplete() + * are no-ops and return false. Therefore we can use them here without checking explicitly + * if we have autocomplete text or not. + */ + @Override + public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + final InputConnection ic = super.onCreateInputConnection(outAttrs); + if (ic == null) { + return null; + } + + return new InputConnectionWrapper(ic, false) { + @Override + public boolean deleteSurroundingText(final int beforeLength, final int afterLength) { + if (removeAutocomplete(getText())) { + // If we have autocomplete text, the cursor is at the boundary between + // regular and autocomplete text. So regardless of which direction we + // are deleting, we should delete the autocomplete text first. + // Make the IME aware that we interrupted the deleteSurroundingText call, + // by restarting the IME. + final InputMethodManager imm = InputMethods.getInputMethodManager(mContext); + if (imm != null) { + imm.restartInput(ToolbarEditText.this); + } + return false; + } + return super.deleteSurroundingText(beforeLength, afterLength); + } + + private boolean removeAutocompleteOnComposing(final CharSequence text) { + final Editable editable = getText(); + final int composingStart = BaseInputConnection.getComposingSpanStart(editable); + final int composingEnd = BaseInputConnection.getComposingSpanEnd(editable); + // We only delete the autocomplete text when the user is backspacing, + // i.e. when the composing text is getting shorter. + if (composingStart >= 0 && + composingEnd >= 0 && + (composingEnd - composingStart) > text.length() && + removeAutocomplete(editable)) { + // Make the IME aware that we interrupted the setComposingText call, + // by having finishComposingText() send change notifications to the IME. + finishComposingText(); + setComposingRegion(composingStart, composingEnd); + return true; + } + return false; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (removeAutocompleteOnComposing(text)) { + return false; + } + return super.commitText(text, newCursorPosition); + } + + @Override + public boolean setComposingText(final CharSequence text, final int newCursorPosition) { + if (removeAutocompleteOnComposing(text)) { + return false; + } + return super.setComposingText(text, newCursorPosition); + } + }; + } + + private class SelectionChangeListener implements OnSelectionChangedListener { + @Override + public void onSelectionChanged(final int selStart, final int selEnd) { + // The user has repositioned the cursor somewhere. We need to adjust + // the autocomplete text depending on where the new cursor is. + + final Editable text = getText(); + final int start = text.getSpanStart(AUTOCOMPLETE_SPAN); + + if (mSettingAutoComplete || start < 0 || (start == selStart && start == selEnd)) { + // Do not commit autocomplete text if there is no autocomplete text + // or if selection is still at start of autocomplete text + return; + } + + if (selStart <= start && selEnd <= start) { + // The cursor is in user-typed text; remove any autocomplete text. + removeAutocomplete(text); + } else { + // The cursor is in the autocomplete text; commit it so it becomes regular text. + commitAutocomplete(text); + } + } + } + + private class TextChangeListener implements TextWatcher { + @Override + public void afterTextChanged(final Editable editable) { + if (!isEnabled() || mSettingAutoComplete) { + return; + } + + final String text = getNonAutocompleteText(editable); + final int textLength = text.length(); + boolean doAutocomplete = mPrefs.shouldAutocomplete(); + + if (StringUtils.isSearchQuery(text, false)) { + doAutocomplete = false; + } else if (mAutoCompletePrefixLength > textLength) { + // If you're hitting backspace (the string is getting smaller), don't autocomplete + doAutocomplete = false; + } + + mAutoCompletePrefixLength = textLength; + + // If we are not autocompleting, we set mDiscardAutoCompleteResult to true + // to discard any autocomplete results that are in-flight, and vice versa. + mDiscardAutoCompleteResult = !doAutocomplete; + + if (doAutocomplete && mAutoCompleteResult.startsWith(text)) { + // If this text already matches our autocomplete text, autocomplete likely + // won't change. Just reuse the old autocomplete value. + onAutocomplete(mAutoCompleteResult); + doAutocomplete = false; + } else { + // Otherwise, remove the old autocomplete text + // until any new autocomplete text gets added. + removeAutocomplete(editable); + } + + // Update search icon with an active state since user is typing + if (mSearchStateChangeListener != null) { + mSearchStateChangeListener.onSearchStateChange(textLength > 0); + } + + if (mFilterListener != null) { + mFilterListener.onFilter(text, doAutocomplete ? ToolbarEditText.this : null); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + // do nothing + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + // do nothing + } + } + + private class KeyPreImeListener implements OnKeyPreImeListener { + @Override + public boolean onKeyPreIme(View v, int keyCode, KeyEvent event) { + // We only want to process one event per tap + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // If the edit text has a composition string, don't submit the text yet. + // ENTER is needed to commit the composition string. + final Editable content = getText(); + if (!hasCompositionString(content)) { + if (mCommitListener != null) { + mCommitListener.onCommit(); + } + + return true; + } + } + + if (keyCode == KeyEvent.KEYCODE_BACK) { + // Drop the virtual keyboard. + clearFocus(); + return true; + } + + return false; + } + } + + private class KeyListener implements View.OnKeyListener { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_ENTER || GamepadUtils.isActionKey(event)) { + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return true; + } + + if (mCommitListener != null) { + mCommitListener.onCommit(); + } + + return true; + } + + if (GamepadUtils.isBackKey(event)) { + if (mDismissListener != null) { + mDismissListener.onDismiss(); + } + + return true; + } + + if ((keyCode == KeyEvent.KEYCODE_DEL || + (keyCode == KeyEvent.KEYCODE_FORWARD_DEL)) && + removeAutocomplete(getText())) { + // Delete autocomplete text when backspacing or forward deleting. + return true; + } + + return false; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java new file mode 100644 index 000000000..f881de154 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java @@ -0,0 +1,78 @@ +/* -*- 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.toolbar; + +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.util.ThreadUtils; + +class ToolbarPrefs { + private static final String PREF_AUTOCOMPLETE_ENABLED = "browser.urlbar.autocomplete.enabled"; + private static final String PREF_TRIM_URLS = "browser.urlbar.trimURLs"; + + private static final String[] PREFS = { + PREF_AUTOCOMPLETE_ENABLED, + PREF_TRIM_URLS + }; + + private final TitlePrefsHandler HANDLER = new TitlePrefsHandler(); + + private volatile boolean enableAutocomplete; + private volatile boolean trimUrls; + + ToolbarPrefs() { + // Skip autocompletion while Gecko is loading. + // We will get the correct pref value once Gecko is loaded. + enableAutocomplete = false; + trimUrls = true; + } + + boolean shouldAutocomplete() { + return enableAutocomplete; + } + + boolean shouldTrimUrls() { + return trimUrls; + } + + void open() { + PrefsHelper.addObserver(PREFS, HANDLER); + } + + void close() { + PrefsHelper.removeObserver(HANDLER); + } + + private void triggerTitleChangeListener() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + final Tabs tabs = Tabs.getInstance(); + final Tab tab = tabs.getSelectedTab(); + if (tab != null) { + tabs.notifyListeners(tab, Tabs.TabEvents.TITLE); + } + } + }); + } + + private class TitlePrefsHandler extends PrefsHelper.PrefHandlerBase { + @Override + public void prefValue(String pref, boolean value) { + if (PREF_AUTOCOMPLETE_ENABLED.equals(pref)) { + enableAutocomplete = value; + + } else if (PREF_TRIM_URLS.equals(pref)) { + // Handles PREF_TRIM_URLS, which should usually be a boolean. + if (value != trimUrls) { + trimUrls = value; + triggerTitleChangeListener(); + } + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java new file mode 100644 index 000000000..43181cbef --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.toolbar; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; +import org.mozilla.gecko.widget.themed.ThemedImageView; +import org.mozilla.gecko.util.WeakReferenceHandler; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.os.Message; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.Animation; + +/** + * Progress view used for page loads. + * + * Because we're given limited information about the page load progress, the + * bar also includes incremental animation between each step to improve + * perceived performance. + */ +public class ToolbarProgressView extends ThemedImageView { + private static final int MAX_PROGRESS = 10000; + private static final int MSG_UPDATE = 0; + private static final int MSG_HIDE = 1; + private static final int STEPS = 10; + private static final int DELAY = 40; + + private int mTargetProgress; + private int mIncrement; + private Rect mBounds; + private Handler mHandler; + private int mCurrentProgress; + + private PorterDuffColorFilter mPrivateBrowsingColorFilter; + + public ToolbarProgressView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + public ToolbarProgressView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context ctx) { + mBounds = new Rect(0, 0, 0, 0); + mTargetProgress = 0; + + mPrivateBrowsingColorFilter = new PorterDuffColorFilter( + ContextCompat.getColor(ctx, R.color.private_browsing_purple), PorterDuff.Mode.SRC_IN); + + mHandler = new ToolbarProgressHandler(this); + } + + @Override + public void onLayout(boolean f, int l, int t, int r, int b) { + mBounds.left = 0; + mBounds.right = (r - l) * mCurrentProgress / MAX_PROGRESS; + mBounds.top = 0; + mBounds.bottom = b - t; + } + + @Override + public void onDraw(Canvas canvas) { + final Drawable d = getDrawable(); + d.setBounds(mBounds); + d.draw(canvas); + } + + /** + * Immediately sets the progress bar to the given progress percentage. + * + * @param progress Percentage (0-100) to which progress bar should be set + */ + void setProgress(int progressPercentage) { + mCurrentProgress = mTargetProgress = getAbsoluteProgress(progressPercentage); + updateBounds(); + + clearMessages(); + } + + /** + * Animates the progress bar from the current progress value to the given + * progress percentage. + * + * @param progress Percentage (0-100) to which progress bar should be animated + */ + void animateProgress(int progressPercentage) { + final int absoluteProgress = getAbsoluteProgress(progressPercentage); + if (absoluteProgress <= mTargetProgress) { + // After we manually click stop, we can still receive page load + // events (e.g., DOMContentLoaded). Updating for other updates + // after a STOP event can freeze the progress bar, so guard against + // that here. + return; + } + + mTargetProgress = absoluteProgress; + mIncrement = (mTargetProgress - mCurrentProgress) / STEPS; + + clearMessages(); + mHandler.sendEmptyMessage(MSG_UPDATE); + } + + private void clearMessages() { + mHandler.removeMessages(MSG_UPDATE); + mHandler.removeMessages(MSG_HIDE); + } + + private int getAbsoluteProgress(int progressPercentage) { + if (progressPercentage < 0) { + return 0; + } + + if (progressPercentage > 100) { + return 100; + } + + return progressPercentage * MAX_PROGRESS / 100; + } + + private void updateBounds() { + mBounds.right = getWidth() * mCurrentProgress / MAX_PROGRESS; + invalidate(); + } + + @Override + public void setPrivateMode(final boolean isPrivate) { + super.setPrivateMode(isPrivate); + + // Note: android:tint is better but ColorStateLists are not supported until API 21. + if (isPrivate) { + setColorFilter(mPrivateBrowsingColorFilter); + } else { + clearColorFilter(); + } + } + + private static class ToolbarProgressHandler extends WeakReferenceHandler<ToolbarProgressView> { + public ToolbarProgressHandler(final ToolbarProgressView that) { + super(that); + } + + @Override + public void handleMessage(Message msg) { + final ToolbarProgressView that = mTarget.get(); + if (that == null) { + return; + } + + switch (msg.what) { + case MSG_UPDATE: + that.mCurrentProgress = Math.min(that.mTargetProgress, that.mCurrentProgress + that.mIncrement); + + that.updateBounds(); + + if (that.mCurrentProgress < that.mTargetProgress) { + final int delay = (that.mTargetProgress < MAX_PROGRESS) ? DELAY : DELAY / 4; + sendMessageDelayed(that.mHandler.obtainMessage(msg.what), delay); + } else if (that.mCurrentProgress == MAX_PROGRESS) { + sendMessageDelayed(that.mHandler.obtainMessage(MSG_HIDE), DELAY); + } + break; + + case MSG_HIDE: + that.setVisibility(View.GONE); + break; + } + } + }; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java b/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java new file mode 100644 index 000000000..dcc62b6d4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.trackingprotection; + +import org.mozilla.gecko.Locales; +import org.mozilla.gecko.R; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.HardwareUtils; + +import android.content.Intent; +import android.os.Bundle; +import android.view.MotionEvent; +import android.view.View; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +public class TrackingProtectionPrompt extends Locales.LocaleAwareActivity { + public static final String LOGTAG = "Gecko" + TrackingProtectionPrompt.class.getSimpleName(); + + // Flag set during animation to prevent animation multiple-start. + private boolean isAnimating; + + private View containerView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + showPrompt(); + } + + private void showPrompt() { + setContentView(R.layout.tracking_protection_prompt); + + findViewById(R.id.ok_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onConfirmButtonPressed(); + } + }); + findViewById(R.id.link_text).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + slideOut(); + final Intent settingsIntent = new Intent(TrackingProtectionPrompt.this, GeckoPreferences.class); + GeckoPreferences.setResourceToOpen(settingsIntent, "preferences_privacy"); + startActivity(settingsIntent); + + // Don't use a transition to settings if we're on a device where that + // would look bad. + if (HardwareUtils.IS_KINDLE_DEVICE) { + overridePendingTransition(0, 0); + } + } + }); + + containerView = findViewById(R.id.tracking_protection_inner_container); + + containerView.setTranslationY(500); + containerView.setAlpha(0); + + final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0); + translateAnimator.setDuration(400); + + final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1); + alphaAnimator.setStartDelay(200); + alphaAnimator.setDuration(600); + + final AnimatorSet set = new AnimatorSet(); + set.playTogether(alphaAnimator, translateAnimator); + set.setStartDelay(400); + + set.start(); + } + + @Override + public void finish() { + super.finish(); + + // Don't perform an activity-dismiss animation. + overridePendingTransition(0, 0); + } + + private void onConfirmButtonPressed() { + slideOut(); + } + + /** + * Slide the overlay down off the screen and destroy it. + */ + private void slideOut() { + if (isAnimating) { + return; + } + + isAnimating = true; + + ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight()); + animator.addListener(new AnimatorListenerAdapter() { + + @Override + public void onAnimationEnd(Animator animation) { + finish(); + } + + }); + animator.start(); + } + + /** + * Close the dialog if back is pressed. + */ + @Override + public void onBackPressed() { + slideOut(); + } + + /** + * Close the dialog if the anything that isn't a button is tapped. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + slideOut(); + return true; + } + } diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java b/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java new file mode 100644 index 000000000..f0ad78e77 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java @@ -0,0 +1,120 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.updater; + +import android.content.Context; +import android.content.res.AssetManager; +import android.content.SharedPreferences; +import android.util.Log; + +import com.keepsafe.switchboard.SwitchBoard; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.BrowserApp; +import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.util.IOUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Perform tasks in the background after the app has been installed/updated. + */ +public class PostUpdateHandler extends BrowserAppDelegateWithReference { + private static final String LOGTAG = "PostUpdateHandler"; + + @Override + public void onStart(final BrowserApp browserApp) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp); + + // Check if this is a new installation or if the app has been updated since the last start. + if (!AppConstants.MOZ_APP_BUILDID.equals(prefs.getString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, null))) { + Log.d(LOGTAG, "Build ID changed since last start: '" + AppConstants.MOZ_APP_BUILDID + "', '" + prefs.getString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, null) + "'"); + + // Copy the bundled system add-ons from the APK to the data directory. + copyFeaturesFromAPK(browserApp); + } + } + }); + } + + /** + * Copies the /assets/features folder out of the APK and into the app's data directory. + */ + private void copyFeaturesFromAPK(BrowserApp browserApp) { + Log.d(LOGTAG, "Copying system add-ons from APK to dataDir"); + + final String dataDir = browserApp.getApplicationInfo().dataDir; + final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp); + final AssetManager assetManager = browserApp.getContext().getAssets(); + + try { + final String[] assetNames = assetManager.list("features"); + + for (int i = 0; i < assetNames.length; i++) { + final String assetPath = "features/" + assetNames[i]; + + Log.d(LOGTAG, "Copying '" + assetPath + "' from APK to dataDir"); + + final InputStream assetStream = assetManager.open(assetPath); + final File outFile = getDataFile(dataDir, assetPath); + + if (outFile == null) { + continue; + } + + final OutputStream outStream = new FileOutputStream(outFile); + + try { + IOUtils.copy(assetStream, outStream); + } catch (IOException e) { + Log.e(LOGTAG, "Error copying '" + assetPath + "' from APK to dataDir"); + } finally { + outStream.close(); + } + } + } catch (IOException e) { + Log.e(LOGTAG, "Error retrieving packaged system add-ons from APK", e); + } + + // Save the Build ID so we don't perform post-update operations again until the app is updated. + prefs.edit().putString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, AppConstants.MOZ_APP_BUILDID).apply(); + } + + /** + * Return a File instance in the data directory, ensuring + * that the parent exists. + * + * @return null if the parents could not be created. + */ + private File getDataFile(final String dataDir, final String name) { + File outFile = new File(dataDir, name); + File dir = outFile.getParentFile(); + + if (!dir.exists()) { + Log.d(LOGTAG, "Creating " + dir.getAbsolutePath()); + if (!dir.mkdirs()) { + Log.e(LOGTAG, "Unable to create directories: " + dir.getAbsolutePath()); + return null; + } + } + + return outFile; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java new file mode 100644 index 000000000..7ccc43e28 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java @@ -0,0 +1,795 @@ +/* -*- 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.updater; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.CrashHandler; +import org.mozilla.gecko.R; + +import org.mozilla.apache.commons.codec.binary.Hex; + +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.util.ProxySelector; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.IntentService; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.WifiLock; +import android.os.Environment; +import android.provider.Settings; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.content.ContextCompat; +import android.support.v4.net.ConnectivityManagerCompat; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.Builder; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.security.MessageDigest; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.TimeZone; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +public class UpdateService extends IntentService { + private static final int BUFSIZE = 8192; + private static final int NOTIFICATION_ID = 0x3e40ddbd; + + private static final String LOGTAG = "UpdateService"; + + private static final int INTERVAL_LONG = 86400000; // in milliseconds + private static final int INTERVAL_SHORT = 14400000; // again, in milliseconds + private static final int INTERVAL_RETRY = 3600000; + + private static final String PREFS_NAME = "UpdateService"; + private static final String KEY_LAST_BUILDID = "UpdateService.lastBuildID"; + private static final String KEY_LAST_HASH_FUNCTION = "UpdateService.lastHashFunction"; + private static final String KEY_LAST_HASH_VALUE = "UpdateService.lastHashValue"; + private static final String KEY_LAST_FILE_NAME = "UpdateService.lastFileName"; + private static final String KEY_LAST_ATTEMPT_DATE = "UpdateService.lastAttemptDate"; + private static final String KEY_AUTODOWNLOAD_POLICY = "UpdateService.autoDownloadPolicy"; + private static final String KEY_UPDATE_URL = "UpdateService.updateUrl"; + + private SharedPreferences mPrefs; + + private NotificationManagerCompat mNotificationManager; + private ConnectivityManager mConnectivityManager; + private Builder mBuilder; + + private volatile WifiLock mWifiLock; + + private boolean mDownloading; + private boolean mCancelDownload; + private boolean mApplyImmediately; + + private CrashHandler mCrashHandler; + + public enum AutoDownloadPolicy { + NONE(-1), + WIFI(0), + DISABLED(1), + ENABLED(2); + + public final int value; + + private AutoDownloadPolicy(int value) { + this.value = value; + } + + private final static AutoDownloadPolicy[] sValues = AutoDownloadPolicy.values(); + + public static AutoDownloadPolicy get(int value) { + for (AutoDownloadPolicy id: sValues) { + if (id.value == value) { + return id; + } + } + return NONE; + } + + public static AutoDownloadPolicy get(String name) { + for (AutoDownloadPolicy id: sValues) { + if (name.equalsIgnoreCase(id.toString())) { + return id; + } + } + return NONE; + } + } + + private enum CheckUpdateResult { + // Keep these in sync with mobile/android/chrome/content/about.xhtml + NOT_AVAILABLE, + AVAILABLE, + DOWNLOADING, + DOWNLOADED + } + + + public UpdateService() { + super("updater"); + } + + @Override + public void onCreate () { + mCrashHandler = CrashHandler.createDefaultCrashHandler(getApplicationContext()); + + super.onCreate(); + + mPrefs = getSharedPreferences(PREFS_NAME, 0); + mNotificationManager = NotificationManagerCompat.from(this); + mConnectivityManager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE); + mWifiLock = ((WifiManager)getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, PREFS_NAME); + mCancelDownload = false; + } + + @Override + public void onDestroy() { + mCrashHandler.unregister(); + mCrashHandler = null; + + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + } + + @Override + public synchronized int onStartCommand (Intent intent, int flags, int startId) { + // If we are busy doing a download, the new Intent here would normally be queued for + // execution once that is done. In this case, however, we want to flip the boolean + // while that is running, so handle that now. + if (mDownloading && UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) { + Log.i(LOGTAG, "will apply update when download finished"); + + mApplyImmediately = true; + showDownloadNotification(); + } else if (UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD.equals(intent.getAction())) { + mCancelDownload = true; + } else { + super.onStartCommand(intent, flags, startId); + } + + return Service.START_REDELIVER_INTENT; + } + + @Override + protected void onHandleIntent (final Intent intent) { + if (UpdateServiceHelper.ACTION_REGISTER_FOR_UPDATES.equals(intent.getAction())) { + AutoDownloadPolicy policy = AutoDownloadPolicy.get( + intent.getIntExtra(UpdateServiceHelper.EXTRA_AUTODOWNLOAD_NAME, + AutoDownloadPolicy.NONE.value)); + + if (policy != AutoDownloadPolicy.NONE) { + setAutoDownloadPolicy(policy); + } + + String url = intent.getStringExtra(UpdateServiceHelper.EXTRA_UPDATE_URL_NAME); + if (url != null) { + setUpdateUrl(url); + } + + registerForUpdates(false); + } else if (UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE.equals(intent.getAction())) { + startUpdate(intent.getIntExtra(UpdateServiceHelper.EXTRA_UPDATE_FLAGS_NAME, 0)); + // Use this instead for forcing a download from about:fennec + // startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD | UpdateServiceHelper.FLAG_REINSTALL); + } else if (UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE.equals(intent.getAction())) { + // We always want to do the download and apply it here + mApplyImmediately = true; + startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD); + } else if (UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) { + applyUpdate(intent.getStringExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME)); + } + } + + private static boolean hasFlag(int flags, int flag) { + return (flags & flag) == flag; + } + + private void sendCheckUpdateResult(CheckUpdateResult result) { + Intent resultIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT); + resultIntent.putExtra("result", result.toString()); + sendBroadcast(resultIntent); + } + + private int getUpdateInterval(boolean isRetry) { + int interval; + if (isRetry) { + interval = INTERVAL_RETRY; + } else if (!AppConstants.RELEASE_OR_BETA) { + interval = INTERVAL_SHORT; + } else { + interval = INTERVAL_LONG; + } + + return interval; + } + + private void registerForUpdates(boolean isRetry) { + Calendar lastAttempt = getLastAttemptDate(); + Calendar now = new GregorianCalendar(TimeZone.getTimeZone("GMT")); + + int interval = getUpdateInterval(isRetry); + + if (lastAttempt == null || (now.getTimeInMillis() - lastAttempt.getTimeInMillis()) > interval) { + // We've either never attempted an update, or we are passed the desired + // time. Start an update now. + Log.i(LOGTAG, "no update has ever been attempted, checking now"); + startUpdate(0); + return; + } + + AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (manager == null) + return; + + PendingIntent pending = PendingIntent.getService(this, 0, new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE, null, this, UpdateService.class), PendingIntent.FLAG_UPDATE_CURRENT); + manager.cancel(pending); + + lastAttempt.setTimeInMillis(lastAttempt.getTimeInMillis() + interval); + Log.i(LOGTAG, "next update will be at: " + lastAttempt.getTime()); + + manager.set(AlarmManager.RTC_WAKEUP, lastAttempt.getTimeInMillis(), pending); + } + + private void startUpdate(final int flags) { + setLastAttemptDate(); + + NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo(); + if (netInfo == null || !netInfo.isConnected()) { + Log.i(LOGTAG, "not connected to the network"); + registerForUpdates(true); + sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE); + return; + } + + registerForUpdates(false); + + final UpdateInfo info = findUpdate(hasFlag(flags, UpdateServiceHelper.FLAG_REINSTALL)); + boolean haveUpdate = (info != null); + + if (!haveUpdate) { + Log.i(LOGTAG, "no update available"); + sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE); + return; + } + + Log.i(LOGTAG, "update available, buildID = " + info.buildID); + + Permissions.from(this) + .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .doNotPrompt() + .andFallback(new Runnable() { + @Override + public void run() { + showPermissionNotification(); + sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE); + }}) + .run(new Runnable() { + @Override + public void run() { + startDownload(info, flags); + }}); + } + + private void startDownload(UpdateInfo info, int flags) { + AutoDownloadPolicy policy = getAutoDownloadPolicy(); + + // We only start a download automatically if one of following criteria are met: + // + // - We have a FORCE_DOWNLOAD flag passed in + // - The preference is set to 'always' + // - The preference is set to 'wifi' and we are using a non-metered network (i.e. the user + // is OK with large data transfers occurring) + // + boolean shouldStartDownload = hasFlag(flags, UpdateServiceHelper.FLAG_FORCE_DOWNLOAD) || + policy == AutoDownloadPolicy.ENABLED || + (policy == AutoDownloadPolicy.WIFI && !ConnectivityManagerCompat.isActiveNetworkMetered(mConnectivityManager)); + + if (!shouldStartDownload) { + Log.i(LOGTAG, "not initiating automatic update download due to policy " + policy.toString()); + sendCheckUpdateResult(CheckUpdateResult.AVAILABLE); + + // We aren't autodownloading here, so prompt to start the update + Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE); + notificationIntent.setClass(this, UpdateService.class); + PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setSmallIcon(R.drawable.ic_status_logo); + builder.setWhen(System.currentTimeMillis()); + builder.setAutoCancel(true); + builder.setContentTitle(getString(R.string.updater_start_title)); + builder.setContentText(getString(R.string.updater_start_select)); + builder.setContentIntent(contentIntent); + + mNotificationManager.notify(NOTIFICATION_ID, builder.build()); + + return; + } + + File pkg = downloadUpdatePackage(info, hasFlag(flags, UpdateServiceHelper.FLAG_OVERWRITE_EXISTING)); + if (pkg == null) { + sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE); + return; + } + + Log.i(LOGTAG, "have update package at " + pkg); + + saveUpdateInfo(info, pkg); + sendCheckUpdateResult(CheckUpdateResult.DOWNLOADED); + + if (mApplyImmediately) { + applyUpdate(pkg); + } else { + // Prompt to apply the update + + Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE); + notificationIntent.setClass(this, UpdateService.class); + notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, pkg.getAbsolutePath()); + PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setSmallIcon(R.drawable.ic_status_logo); + builder.setWhen(System.currentTimeMillis()); + builder.setAutoCancel(true); + builder.setContentTitle(getString(R.string.updater_apply_title)); + builder.setContentText(getString(R.string.updater_apply_select)); + builder.setContentIntent(contentIntent); + + mNotificationManager.notify(NOTIFICATION_ID, builder.build()); + } + } + + private UpdateInfo findUpdate(boolean force) { + try { + URI uri = getUpdateURI(force); + + if (uri == null) { + Log.e(LOGTAG, "failed to get update URI"); + return null; + } + + DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + Document dom = builder.parse(ProxySelector.openConnectionWithProxy(uri).getInputStream()); + + NodeList nodes = dom.getElementsByTagName("update"); + if (nodes == null || nodes.getLength() == 0) + return null; + + Node updateNode = nodes.item(0); + Node buildIdNode = updateNode.getAttributes().getNamedItem("buildID"); + if (buildIdNode == null) + return null; + + nodes = dom.getElementsByTagName("patch"); + if (nodes == null || nodes.getLength() == 0) + return null; + + Node patchNode = nodes.item(0); + Node urlNode = patchNode.getAttributes().getNamedItem("URL"); + Node hashFunctionNode = patchNode.getAttributes().getNamedItem("hashFunction"); + Node hashValueNode = patchNode.getAttributes().getNamedItem("hashValue"); + Node sizeNode = patchNode.getAttributes().getNamedItem("size"); + + if (urlNode == null || hashFunctionNode == null || + hashValueNode == null || sizeNode == null) { + return null; + } + + // Fill in UpdateInfo from the XML data + UpdateInfo info = new UpdateInfo(); + info.uri = new URI(urlNode.getTextContent()); + info.buildID = buildIdNode.getTextContent(); + info.hashFunction = hashFunctionNode.getTextContent(); + info.hashValue = hashValueNode.getTextContent(); + + try { + info.size = Integer.parseInt(sizeNode.getTextContent()); + } catch (NumberFormatException e) { + Log.e(LOGTAG, "Failed to find APK size: ", e); + return null; + } + + // Make sure we have all the stuff we need to apply the update + if (!info.isValid()) { + Log.e(LOGTAG, "missing some required update information, have: " + info); + return null; + } + + return info; + } catch (Exception e) { + Log.e(LOGTAG, "failed to check for update: ", e); + return null; + } + } + + private MessageDigest createMessageDigest(String hashFunction) { + String javaHashFunction = null; + + if ("sha512".equalsIgnoreCase(hashFunction)) { + javaHashFunction = "SHA-512"; + } else { + Log.e(LOGTAG, "Unhandled hash function: " + hashFunction); + return null; + } + + try { + return MessageDigest.getInstance(javaHashFunction); + } catch (java.security.NoSuchAlgorithmException e) { + Log.e(LOGTAG, "Couldn't find algorithm " + javaHashFunction, e); + return null; + } + } + + private void showDownloadNotification() { + showDownloadNotification(null); + } + + private void showDownloadNotification(File downloadFile) { + + Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE); + notificationIntent.setClass(this, UpdateService.class); + + Intent cancelIntent = new Intent(UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD); + cancelIntent.setClass(this, UpdateService.class); + + if (downloadFile != null) + notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, downloadFile.getAbsolutePath()); + + PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent deleteIntent = PendingIntent.getService(this, 0, cancelIntent, PendingIntent.FLAG_CANCEL_CURRENT); + + mBuilder = new NotificationCompat.Builder(this); + mBuilder.setContentTitle(getResources().getString(R.string.updater_downloading_title)) + .setContentText(mApplyImmediately ? "" : getResources().getString(R.string.updater_downloading_select)) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentIntent(contentIntent) + .setDeleteIntent(deleteIntent); + + mBuilder.setProgress(100, 0, true); + mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build()); + } + + private void showDownloadFailure() { + Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE); + notificationIntent.setClass(this, UpdateService.class); + PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + builder.setSmallIcon(R.drawable.ic_status_logo); + builder.setWhen(System.currentTimeMillis()); + builder.setContentTitle(getString(R.string.updater_downloading_title_failed)); + builder.setContentText(getString(R.string.updater_downloading_retry)); + builder.setContentIntent(contentIntent); + + mNotificationManager.notify(NOTIFICATION_ID, builder.build()); + } + + private boolean deleteUpdatePackage(String path) { + if (path == null) { + return false; + } + + File pkg = new File(path); + if (!pkg.exists()) { + return false; + } + + pkg.delete(); + Log.i(LOGTAG, "deleted update package: " + path); + + return true; + } + + private File downloadUpdatePackage(UpdateInfo info, boolean overwriteExisting) { + URL url = null; + try { + url = info.uri.toURL(); + } catch (java.net.MalformedURLException e) { + Log.e(LOGTAG, "failed to read URL: ", e); + return null; + } + + File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + path.mkdirs(); + String fileName = new File(url.getFile()).getName(); + File downloadFile = new File(path, fileName); + + if (!overwriteExisting && info.buildID.equals(getLastBuildID()) && downloadFile.exists()) { + // The last saved buildID is the same as the one for the current update. We also have a file + // already downloaded, so it's probably the package we want. Verify it to be sure and just + // return that if it matches. + + if (verifyDownloadedPackage(downloadFile)) { + Log.i(LOGTAG, "using existing update package"); + return downloadFile; + } else { + // Didn't match, so we're going to download a new one. + downloadFile.delete(); + } + } + + if (!info.buildID.equals(getLastBuildID())) { + // Delete the previous package when a new version becomes available. + deleteUpdatePackage(getLastFileName()); + } + + Log.i(LOGTAG, "downloading update package"); + sendCheckUpdateResult(CheckUpdateResult.DOWNLOADING); + + OutputStream output = null; + InputStream input = null; + + mDownloading = true; + mCancelDownload = false; + showDownloadNotification(downloadFile); + + try { + NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo(); + if (netInfo != null && netInfo.isConnected() && + netInfo.getType() == ConnectivityManager.TYPE_WIFI) { + mWifiLock.acquire(); + } + + URLConnection conn = ProxySelector.openConnectionWithProxy(info.uri); + int length = conn.getContentLength(); + + output = new BufferedOutputStream(new FileOutputStream(downloadFile)); + input = new BufferedInputStream(conn.getInputStream()); + + byte[] buf = new byte[BUFSIZE]; + int len = 0; + + int bytesRead = 0; + int lastNotify = 0; + + while ((len = input.read(buf, 0, BUFSIZE)) > 0 && !mCancelDownload) { + output.write(buf, 0, len); + bytesRead += len; + // Updating the notification takes time so only do it every 1MB + if (bytesRead - lastNotify > 1048576) { + mBuilder.setProgress(length, bytesRead, false); + mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build()); + lastNotify = bytesRead; + } + } + + mNotificationManager.cancel(NOTIFICATION_ID); + + // if the download was canceled by the user + // delete the update package + if (mCancelDownload) { + Log.i(LOGTAG, "download canceled by user!"); + downloadFile.delete(); + + return null; + } else { + Log.i(LOGTAG, "completed update download!"); + return downloadFile; + } + } catch (Exception e) { + downloadFile.delete(); + showDownloadFailure(); + + Log.e(LOGTAG, "failed to download update: ", e); + return null; + } finally { + try { + if (input != null) + input.close(); + } catch (java.io.IOException e) { } + + try { + if (output != null) + output.close(); + } catch (java.io.IOException e) { } + + mDownloading = false; + + if (mWifiLock.isHeld()) { + mWifiLock.release(); + } + } + } + + private boolean verifyDownloadedPackage(File updateFile) { + MessageDigest digest = createMessageDigest(getLastHashFunction()); + if (digest == null) + return false; + + InputStream input = null; + + try { + input = new BufferedInputStream(new FileInputStream(updateFile)); + + byte[] buf = new byte[BUFSIZE]; + int len; + while ((len = input.read(buf, 0, BUFSIZE)) > 0) { + digest.update(buf, 0, len); + } + } catch (java.io.IOException e) { + Log.e(LOGTAG, "Failed to verify update package: ", e); + return false; + } finally { + try { + if (input != null) + input.close(); + } catch (java.io.IOException e) { } + } + + String hex = Hex.encodeHexString(digest.digest()); + if (!hex.equals(getLastHashValue())) { + Log.e(LOGTAG, "Package hash does not match"); + return false; + } + + return true; + } + + private void applyUpdate(String updatePath) { + if (updatePath == null) { + updatePath = getLastFileName(); + } + + if (updatePath != null) { + applyUpdate(new File(updatePath)); + } + } + + private void applyUpdate(File updateFile) { + mApplyImmediately = false; + + if (!updateFile.exists()) + return; + + Log.i(LOGTAG, "Verifying package: " + updateFile); + + if (!verifyDownloadedPackage(updateFile)) { + Log.e(LOGTAG, "Not installing update, failed verification"); + return; + } + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.fromFile(updateFile), "application/vnd.android.package-archive"); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + + private void showPermissionNotification() { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", getPackageName(), null)); + + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); + + NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle() + .bigText(getString(R.string.updater_permission_text)); + + Notification notification = new NotificationCompat.Builder(this) + .setContentTitle(getString(R.string.updater_permission_title)) + .setContentText(getString(R.string.updater_permission_text)) + .setStyle(bigTextStyle) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_status_logo) + .setColor(ContextCompat.getColor(this, R.color.rejection_red)) + .setContentIntent(pendingIntent) + .build(); + + NotificationManagerCompat.from(this) + .notify(R.id.updateServicePermissionNotification, notification); + } + + private String getLastBuildID() { + return mPrefs.getString(KEY_LAST_BUILDID, null); + } + + private String getLastHashFunction() { + return mPrefs.getString(KEY_LAST_HASH_FUNCTION, null); + } + + private String getLastHashValue() { + return mPrefs.getString(KEY_LAST_HASH_VALUE, null); + } + + private String getLastFileName() { + return mPrefs.getString(KEY_LAST_FILE_NAME, null); + } + + private Calendar getLastAttemptDate() { + long lastAttempt = mPrefs.getLong(KEY_LAST_ATTEMPT_DATE, -1); + if (lastAttempt < 0) + return null; + + GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT")); + cal.setTimeInMillis(lastAttempt); + return cal; + } + + private void setLastAttemptDate() { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putLong(KEY_LAST_ATTEMPT_DATE, System.currentTimeMillis()); + editor.commit(); + } + + private AutoDownloadPolicy getAutoDownloadPolicy() { + return AutoDownloadPolicy.get(mPrefs.getInt(KEY_AUTODOWNLOAD_POLICY, AutoDownloadPolicy.WIFI.value)); + } + + private void setAutoDownloadPolicy(AutoDownloadPolicy policy) { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putInt(KEY_AUTODOWNLOAD_POLICY, policy.value); + editor.commit(); + } + + private URI getUpdateURI(boolean force) { + return UpdateServiceHelper.expandUpdateURI(this, mPrefs.getString(KEY_UPDATE_URL, null), force); + } + + private void setUpdateUrl(String url) { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putString(KEY_UPDATE_URL, url); + editor.commit(); + } + + private void saveUpdateInfo(UpdateInfo info, File downloaded) { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putString(KEY_LAST_BUILDID, info.buildID); + editor.putString(KEY_LAST_HASH_FUNCTION, info.hashFunction); + editor.putString(KEY_LAST_HASH_VALUE, info.hashValue); + editor.putString(KEY_LAST_FILE_NAME, downloaded.toString()); + editor.commit(); + } + + private class UpdateInfo { + public URI uri; + public String buildID; + public String hashFunction; + public String hashValue; + public int size; + + private boolean isNonEmpty(String s) { + return s != null && s.length() > 0; + } + + public boolean isValid() { + return uri != null && isNonEmpty(buildID) && + isNonEmpty(hashFunction) && isNonEmpty(hashValue) && size > 0; + } + + @Override + public String toString() { + return "uri = " + uri + ", buildID = " + buildID + ", hashFunction = " + hashFunction + ", hashValue = " + hashValue + ", size = " + size; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java new file mode 100644 index 000000000..c4d198ae7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java @@ -0,0 +1,213 @@ +/* -*- 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.updater; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.util.ContextUtils; +import org.mozilla.gecko.util.GeckoJarReader; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ApplicationInfo; +import android.os.Build; +import android.util.Log; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; + +public class UpdateServiceHelper { + public static final String ACTION_REGISTER_FOR_UPDATES = AppConstants.ANDROID_PACKAGE_NAME + ".REGISTER_FOR_UPDATES"; + public static final String ACTION_UNREGISTER_FOR_UPDATES = AppConstants.ANDROID_PACKAGE_NAME + ".UNREGISTER_FOR_UPDATES"; + public static final String ACTION_CHECK_FOR_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".CHECK_FOR_UPDATE"; + public static final String ACTION_CHECK_UPDATE_RESULT = AppConstants.ANDROID_PACKAGE_NAME + ".CHECK_UPDATE_RESULT"; + public static final String ACTION_DOWNLOAD_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".DOWNLOAD_UPDATE"; + public static final String ACTION_APPLY_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".APPLY_UPDATE"; + public static final String ACTION_CANCEL_DOWNLOAD = AppConstants.ANDROID_PACKAGE_NAME + ".CANCEL_DOWNLOAD"; + + // Flags for ACTION_CHECK_FOR_UPDATE + protected static final int FLAG_FORCE_DOWNLOAD = 1; + protected static final int FLAG_OVERWRITE_EXISTING = 1 << 1; + protected static final int FLAG_REINSTALL = 1 << 2; + protected static final int FLAG_RETRY = 1 << 3; + + // Name of the Intent extra for the autodownload policy, used with ACTION_REGISTER_FOR_UPDATES + protected static final String EXTRA_AUTODOWNLOAD_NAME = "autodownload"; + + // Name of the Intent extra that holds the flags for ACTION_CHECK_FOR_UPDATE + protected static final String EXTRA_UPDATE_FLAGS_NAME = "updateFlags"; + + // Name of the Intent extra that holds the APK path, used with ACTION_APPLY_UPDATE + protected static final String EXTRA_PACKAGE_PATH_NAME = "packagePath"; + + // Name of the Intent extra for the update URL, used with ACTION_REGISTER_FOR_UPDATES + protected static final String EXTRA_UPDATE_URL_NAME = "updateUrl"; + + private static final String LOGTAG = "UpdateServiceHelper"; + private static final String DEFAULT_UPDATE_LOCALE = "en-US"; + + // So that updates can be disabled by tests. + private static volatile boolean isEnabled = true; + + private enum Pref { + AUTO_DOWNLOAD_POLICY("app.update.autodownload"), + UPDATE_URL("app.update.url.android"); + + public final String name; + + private Pref(String name) { + this.name = name; + } + + public final static String[] names; + + @Override + public String toString() { + return this.name; + } + + static { + ArrayList<String> nameList = new ArrayList<String>(); + + for (Pref id: Pref.values()) { + nameList.add(id.toString()); + } + + names = nameList.toArray(new String[0]); + } + } + + @RobocopTarget + public static void setEnabled(final boolean enabled) { + isEnabled = enabled; + } + + public static URI expandUpdateURI(Context context, String updateUri, boolean force) { + if (updateUri == null) { + return null; + } + + PackageManager pm = context.getPackageManager(); + + String pkgSpecial = AppConstants.MOZ_PKG_SPECIAL != null ? + "-" + AppConstants.MOZ_PKG_SPECIAL : + ""; + String locale = DEFAULT_UPDATE_LOCALE; + + try { + ApplicationInfo info = pm.getApplicationInfo(AppConstants.ANDROID_PACKAGE_NAME, 0); + String updateLocaleUrl = "jar:jar:file://" + info.sourceDir + "!/" + AppConstants.OMNIJAR_NAME + "!/update.locale"; + + final String jarLocale = GeckoJarReader.getText(context, updateLocaleUrl); + if (jarLocale != null) { + locale = jarLocale.trim(); + } + } catch (android.content.pm.PackageManager.NameNotFoundException e) { + // Shouldn't really be possible, but fallback to default locale + Log.i(LOGTAG, "Failed to read update locale file, falling back to " + locale); + } + + String url = updateUri.replace("%PRODUCT%", AppConstants.MOZ_APP_BASENAME) + .replace("%VERSION%", AppConstants.MOZ_APP_VERSION) + .replace("%BUILD_ID%", force ? "0" : AppConstants.MOZ_APP_BUILDID) + .replace("%BUILD_TARGET%", "Android_" + AppConstants.MOZ_APP_ABI + pkgSpecial) + .replace("%LOCALE%", locale) + .replace("%CHANNEL%", AppConstants.MOZ_UPDATE_CHANNEL) + .replace("%OS_VERSION%", Build.VERSION.RELEASE) + .replace("%DISTRIBUTION%", "default") + .replace("%DISTRIBUTION_VERSION%", "default") + .replace("%MOZ_VERSION%", AppConstants.MOZILLA_VERSION); + + try { + return new URI(url); + } catch (java.net.URISyntaxException e) { + Log.e(LOGTAG, "Failed to create update url: ", e); + return null; + } + } + + public static boolean isUpdaterEnabled(final Context context) { + return AppConstants.MOZ_UPDATER && isEnabled && !ContextUtils.isInstalledFromGooglePlay(context); + } + + public static void setUpdateUrl(Context context, String url) { + registerForUpdates(context, null, url); + } + + public static void setAutoDownloadPolicy(Context context, UpdateService.AutoDownloadPolicy policy) { + registerForUpdates(context, policy, null); + } + + public static void checkForUpdate(Context context) { + if (context == null) { + return; + } + + context.startService(createIntent(context, ACTION_CHECK_FOR_UPDATE)); + } + + public static void downloadUpdate(Context context) { + if (context == null) { + return; + } + + context.startService(createIntent(context, ACTION_DOWNLOAD_UPDATE)); + } + + public static void applyUpdate(Context context) { + if (context == null) { + return; + } + + context.startService(createIntent(context, ACTION_APPLY_UPDATE)); + } + + public static void registerForUpdates(final Context context) { + if (!isUpdaterEnabled(context)) { + return; + } + + final HashMap<String, Object> prefs = new HashMap<String, Object>(); + + PrefsHelper.getPrefs(Pref.names, new PrefsHelper.PrefHandlerBase() { + @Override public void prefValue(String pref, String value) { + prefs.put(pref, value); + } + + @Override public void finish() { + UpdateServiceHelper.registerForUpdates(context, + UpdateService.AutoDownloadPolicy.get( + (String) prefs.get(Pref.AUTO_DOWNLOAD_POLICY.toString())), + (String) prefs.get(Pref.UPDATE_URL.toString())); + } + }); + } + + public static void registerForUpdates(Context context, UpdateService.AutoDownloadPolicy policy, String url) { + if (!isUpdaterEnabled(context)) { + return; + } + + Intent intent = createIntent(context, ACTION_REGISTER_FOR_UPDATES); + + if (policy != null) { + intent.putExtra(EXTRA_AUTODOWNLOAD_NAME, policy.value); + } + + if (url != null) { + intent.putExtra(EXTRA_UPDATE_URL_NAME, url); + } + + context.startService(intent); + } + + private static Intent createIntent(Context context, String action) { + return new Intent(action, null, context, UpdateService.class); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java new file mode 100644 index 000000000..ec227d1ce --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java @@ -0,0 +1,44 @@ +/* -*- 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.util; + +import android.graphics.Color; + +public class ColorUtil { + public static int darken(final int color, final double fraction) { + int red = Color.red(color); + int green = Color.green(color); + int blue = Color.blue(color); + red = darkenColor(red, fraction); + green = darkenColor(green, fraction); + blue = darkenColor(blue, fraction); + final int alpha = Color.alpha(color); + return Color.argb(alpha, red, green, blue); + } + + public static int getReadableTextColor(final int backgroundColor) { + final int greyValue = grayscaleFromRGB(backgroundColor); + // 186 chosen rather than the seemingly obvious 128 because of gamma. + if (greyValue < 186) { + return Color.WHITE; + } else { + return Color.BLACK; + } + } + + private static int darkenColor(final int color, final double fraction) { + return (int) Math.max(color - (color * fraction), 0); + } + + private static int grayscaleFromRGB(final int color) { + final int red = Color.red(color); + final int green = Color.green(color); + final int blue = Color.blue(color); + // Magic weighting taken from a stackoverflow post, supposedly related to how + // humans perceive color. + return (int) (0.299 * red + 0.587 * green + 0.114 * blue); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java new file mode 100644 index 000000000..f3c9eef83 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java @@ -0,0 +1,66 @@ +/* -*- 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.util; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.support.annotation.CheckResult; +import android.support.annotation.ColorInt; +import android.support.annotation.ColorRes; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; +import android.support.v4.graphics.drawable.DrawableCompat; + +import org.mozilla.gecko.AppConstants; + +public class DrawableUtil { + + /** + * Tints the given drawable with the given color and returns it. + */ + @CheckResult + public static Drawable tintDrawable(@NonNull final Context context, + @DrawableRes final int drawableID, + @ColorInt final int color) { + final Drawable icon = DrawableCompat.wrap(ResourceDrawableUtils.getDrawable(context, drawableID) + .mutate()); + DrawableCompat.setTint(icon, color); + return icon; + } + + /** + * Tints the given drawable with the given color and returns it. + */ + @CheckResult + public static Drawable tintDrawableWithColorRes(@NonNull final Context context, + @DrawableRes final int drawableID, + @ColorRes final int colorID) { + return tintDrawable(context, drawableID, ContextCompat.getColor(context, colorID)); + } + + /** + * Tints the given drawable with the given tint list and returns it. Note that you + * should no longer use the argument Drawable because the argument is not mutated + * on pre-Lollipop devices but is mutated on L+ due to differences in the Support + * Library implementation (bug 1193950). + */ + @CheckResult + public static Drawable tintDrawableWithStateList(@NonNull final Drawable drawable, + @NonNull final ColorStateList colorList) { + final Drawable wrappedDrawable = DrawableCompat.wrap(drawable.mutate()); + DrawableCompat.setTintList(wrappedDrawable, colorList); + + // DrawableCompat on pre-L doesn't handle its bounds correctly, and by default therefore won't + // be rendered - we need to manually copy the bounds as a workaround: + if (AppConstants.Versions.preMarshmallow) { + wrappedDrawable.setBounds(0, 0, wrappedDrawable.getIntrinsicHeight(), wrappedDrawable.getIntrinsicHeight()); + } + + return wrappedDrawable; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java b/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java new file mode 100644 index 000000000..1e5c2a723 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java @@ -0,0 +1,136 @@ +/* -*- 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.util; + +import android.content.Context; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.support.v7.widget.AppCompatDrawableManager; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.util.GeckoJarReader; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import java.io.InputStream; +import java.net.URL; + +import static org.mozilla.gecko.gfx.BitmapUtils.getBitmapFromDataURI; +import static org.mozilla.gecko.gfx.BitmapUtils.getResource; + +public class ResourceDrawableUtils { + private static final String LOGTAG = "ResourceDrawableUtils"; + + public static Drawable getDrawable(@NonNull final Context context, + @DrawableRes final int drawableID) { + // TODO: upgrade this call to use AppCompatResources when upgrading to support library >= 24.2 + // https://developer.android.com/reference/android/support/v7/content/res/AppCompatResources.html#getDrawable(android.content.Context,%20int) + return AppCompatDrawableManager.get().getDrawable(context, drawableID); + } + + public interface BitmapLoader { + public void onBitmapFound(Drawable d); + } + + public static void runOnBitmapFoundOnUiThread(final BitmapLoader loader, final Drawable d) { + if (ThreadUtils.isOnUiThread()) { + loader.onBitmapFound(d); + return; + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + loader.onBitmapFound(d); + } + }); + } + + /** + * Attempts to find a drawable associated with a given string, using its URI scheme to determine + * how to load the drawable. The BitmapLoader's `onBitmapFound` method is always called, and + * will be called with `null` if no drawable is found. + * + * The BitmapLoader `onBitmapFound` method always runs on the UI thread. + */ + public static void getDrawable(final Context context, final String data, final BitmapLoader loader) { + if (TextUtils.isEmpty(data)) { + runOnBitmapFoundOnUiThread(loader, null); + return; + } + + if (data.startsWith("data")) { + final BitmapDrawable d = new BitmapDrawable(context.getResources(), getBitmapFromDataURI(data)); + runOnBitmapFoundOnUiThread(loader, d); + return; + } + + if (data.startsWith("jar:") || data.startsWith("file://")) { + (new UIAsyncTask.WithoutParams<Drawable>(ThreadUtils.getBackgroundHandler()) { + @Override + public Drawable doInBackground() { + try { + if (data.startsWith("jar:jar")) { + return GeckoJarReader.getBitmapDrawable( + context, context.getResources(), data); + } + + // Don't attempt to validate the JAR signature when loading an add-on icon + if (data.startsWith("jar:file")) { + return GeckoJarReader.getBitmapDrawable( + context, context.getResources(), Uri.decode(data)); + } + + final URL url = new URL(data); + final InputStream is = (InputStream) url.getContent(); + try { + return Drawable.createFromStream(is, "src"); + } finally { + is.close(); + } + } catch (Exception e) { + Log.w(LOGTAG, "Unable to set icon", e); + } + return null; + } + + @Override + public void onPostExecute(Drawable drawable) { + loader.onBitmapFound(drawable); + } + }).execute(); + return; + } + + if (data.startsWith("-moz-icon://")) { + final Uri imageUri = Uri.parse(data); + final String ssp = imageUri.getSchemeSpecificPart(); + final String resource = ssp.substring(ssp.lastIndexOf('/') + 1); + + try { + final Drawable d = context.getPackageManager().getApplicationIcon(resource); + runOnBitmapFoundOnUiThread(loader, d); + } catch (Exception ex) { } + + return; + } + + if (data.startsWith("drawable://")) { + final Uri imageUri = Uri.parse(data); + final int id = getResource(context, imageUri); + final Drawable d = getDrawable(context, id); + + runOnBitmapFoundOnUiThread(loader, d); + return; + } + + runOnBitmapFoundOnUiThread(loader, null); + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java new file mode 100644 index 000000000..6414dec9f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java @@ -0,0 +1,48 @@ +/* -*- 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.util; + +import android.graphics.Rect; +import android.view.TouchDelegate; +import android.view.View; + +import org.mozilla.gecko.R; + +public class TouchTargetUtil { + /** + * Ensures that a given targetView has a large enough touch area to ensure it can be selected. + * A TouchDelegate will be added to the enclosingView as necessary. + * + * @param targetView + * @param enclosingView + */ + public static void ensureTargetHitArea(final View targetView, final View enclosingView) { + enclosingView.post(new Runnable() { + @Override + public void run() { + Rect delegateArea = new Rect(); + targetView.getHitRect(delegateArea); + + final int targetHitArea = enclosingView.getContext().getResources().getDimensionPixelSize(R.dimen.touch_target_size); + + final int widthDelta = (targetHitArea - delegateArea.width()) / 2; + delegateArea.right += widthDelta; + delegateArea.left -= widthDelta; + + final int heightDelta = (targetHitArea - delegateArea.height()) / 2; + delegateArea.bottom += heightDelta; + delegateArea.top -= heightDelta; + + if (heightDelta <= 0 && widthDelta <= 0) { + return; + } + + TouchDelegate touchDelegate = new TouchDelegate(delegateArea, targetView); + enclosingView.setTouchDelegate(touchDelegate); + } + }); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java new file mode 100644 index 000000000..0033e72a0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java @@ -0,0 +1,128 @@ +package org.mozilla.gecko.util; + +import org.mozilla.gecko.R; + +/** + * (linter: UnusedResources) We use resources in places Android Lint can't check (e.g. JS) - this is + * a set of those references so Android Lint stops complaining. + */ +@SuppressWarnings("unused") +final class UnusedResourcesUtil { + public static final int[] CONSTANTS = { + R.dimen.match_parent, + R.dimen.wrap_content, + }; + + public static final int[] USED_IN_BRANDING = { + R.drawable.large_icon + }; + + public static final int[] USED_IN_COLOR_PALETTE = { + R.color.private_browsing_purple, // This will be used eventually, then this item removed. + }; + + public static final int[] USED_IN_CRASH_REPORTER = { + R.string.crash_allow_contact2, + R.string.crash_close_label, + R.string.crash_comment, + R.string.crash_email, + R.string.crash_include_url2, + R.string.crash_message2, + R.string.crash_restart_label, + R.string.crash_send_report_message3, + R.string.crash_sorry, + }; + + public static final int[] USED_IN_JS = { + R.drawable.ab_search, + R.drawable.alert_camera, + R.drawable.alert_download, + R.drawable.alert_download_animation, + R.drawable.alert_mic, + R.drawable.alert_mic_camera, + R.drawable.casting, + R.drawable.casting_active, + R.drawable.close, + R.drawable.homepage_banner_firstrun, + R.drawable.icon_openinapp, + R.drawable.pause, + R.drawable.phone, + R.drawable.play, + R.drawable.reader, + R.drawable.reader_active, + R.drawable.sync_promo, + R.drawable.undo_button_icon, + }; + + public static final int[] USED_IN_MANIFEST = { + R.drawable.search_launcher, + R.string.crash_reporter_title, + R.xml.fxaccount_authenticator, + R.xml.fxaccount_syncadapter, + R.xml.search_widget_info, + R.xml.searchable, + }; + + public static final int[] USED_IN_SUGGESTEDSITES = { + R.drawable.suggestedsites_amazon, + R.drawable.suggestedsites_facebook, + R.drawable.suggestedsites_restricted_fxsupport, + R.drawable.suggestedsites_restricted_mozilla, + R.drawable.suggestedsites_twitter, + R.drawable.suggestedsites_webmaker, + R.drawable.suggestedsites_wikipedia, + R.drawable.suggestedsites_youtube, + }; + + public static final int[] USED_IN_BOOKMARKDEFAULTS = { + R.raw.bookmarkdefaults_favicon_addons, + R.raw.bookmarkdefaults_favicon_support, + R.raw.bookmarkdefaults_favicon_restricted_support, + R.raw.bookmarkdefaults_favicon_restricted_webmaker, + R.string.bookmarkdefaults_title_restricted_support, + R.string.bookmarkdefaults_url_restricted_support, + R.string.bookmarkdefaults_title_restricted_webmaker, + R.string.bookmarkdefaults_url_restricted_webmaker, + }; + + public static final int[] USED_IN_PREFS = { + R.xml.preferences_advanced, + R.xml.preferences_accessibility, + R.xml.preferences_home, + R.xml.preferences_privacy, + R.xml.preferences_privacy_clear_tablet, + R.xml.preferences_default_browser_tablet + }; + + // We are migrating to Gradle 2.10 and the Android Gradle plugin 2.0. The new plugin does find + // more unused resources but we are not ready to remove them yet. Some of the resources are going + // to be reused soon. This is a temporary solution so that the gradle migration is not blocked. + // See bug 1263390 / bug 1268414. + public static final int[] TEMPORARY_UNUSED_WHILE_MIGRATING_GRADLE = { + R.color.remote_tabs_setup_button_background_hit, + + R.drawable.remote_tabs_setup_button_background, + + R.style.TabsPanelSectionBase, + R.style.TabsPanelSection, + R.style.TabsPanelItemBase, + R.style.TabsPanelItem, + R.style.TabsPanelItem_TextAppearance, + R.style.TabsPanelItem_TextAppearance_Header, + R.style.TabsPanelItem_TextAppearance_Linkified, + R.style.TabWidget, + R.style.GeckoDialogTitle, + R.style.GeckoDialogTitle_SubTitle, + R.style.RemoteTabsPanelItem, + R.style.RemoteTabsPanelItem_TextAppearance, + R.style.RemoteTabsPanelItem_TextAppearance_Header, + R.style.RemoteTabsPanelItem_TextAppearance_Linkified, + R.style.RemoteTabsPanelItem_Button, + }; + + // String resources that are used in the full-pane Activity Stream that are temporarily + // not needed while Activity Stream is part of the HomePager + public static final int[] TEMPORARY_UNUSED_ACTIVITY_STREAM = { + R.string.activity_stream_topsites + }; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java new file mode 100644 index 000000000..180e821e7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java @@ -0,0 +1,33 @@ +/* -*- 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.util; + +import android.content.res.TypedArray; +import android.view.View; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; + +public class ViewUtil { + + /** + * Enable a circular touch ripple for a given view. This is intended for borderless views, + * such as (3-dot) menu buttons. + * + * Because of platform limitations a square ripple is used on Android 4. + */ + public static void enableTouchRipple(View view) { + final TypedArray backgroundDrawableArray; + if (AppConstants.Versions.feature21Plus) { + backgroundDrawableArray = view.getContext().obtainStyledAttributes(new int[] { R.attr.selectableItemBackgroundBorderless }); + } else { + backgroundDrawableArray = view.getContext().obtainStyledAttributes(new int[] { R.attr.selectableItemBackground }); + } + + // This call is deprecated, but the replacement setBackground(Drawable) isn't available + // until API 16. + view.setBackgroundDrawable(backgroundDrawableArray.getDrawable(0)); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java b/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java new file mode 100644 index 000000000..8cde1ee05 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java @@ -0,0 +1,1359 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Mozilla: Changing the package. + */ +//package android.widget; +package org.mozilla.gecko.widget; + +// Mozilla: New import +import android.accounts.Account; +import android.content.pm.PackageManager; + +import org.mozilla.gecko.db.BrowserDB; +import org.mozilla.gecko.db.TabsAccessor; +import org.mozilla.gecko.distribution.Distribution; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.fxa.FirefoxAccounts; +import org.mozilla.gecko.fxa.SyncStatusListener; +import org.mozilla.gecko.overlays.ui.ShareDialog; +import org.mozilla.gecko.R; +import java.io.File; + +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ResolveInfo; +import android.database.Cursor; +import android.database.DataSetObservable; +import android.os.AsyncTask; +import android.text.TextUtils; +import android.util.Log; +import android.util.Xml; + +/** + * Mozilla: Unused import. + */ +//import com.android.internal.content.PackageMonitor; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlSerializer; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * <p> + * This class represents a data model for choosing a component for handing a + * given {@link Intent}. The model is responsible for querying the system for + * activities that can handle the given intent and order found activities + * based on historical data of previous choices. The historical data is stored + * in an application private file. If a client does not want to have persistent + * choice history the file can be omitted, thus the activities will be ordered + * based on historical usage for the current session. + * <p> + * </p> + * For each backing history file there is a singleton instance of this class. Thus, + * several clients that specify the same history file will share the same model. Note + * that if multiple clients are sharing the same model they should implement semantically + * equivalent functionality since setting the model intent will change the found + * activities and they may be inconsistent with the functionality of some of the clients. + * For example, choosing a share activity can be implemented by a single backing + * model and two different views for performing the selection. If however, one of the + * views is used for sharing but the other for importing, for example, then each + * view should be backed by a separate model. + * </p> + * <p> + * The way clients interact with this class is as follows: + * </p> + * <p> + * <pre> + * <code> + * // Get a model and set it to a couple of clients with semantically similar function. + * ActivityChooserModel dataModel = + * ActivityChooserModel.get(context, "task_specific_history_file_name.xml"); + * + * ActivityChooserModelClient modelClient1 = getActivityChooserModelClient1(); + * modelClient1.setActivityChooserModel(dataModel); + * + * ActivityChooserModelClient modelClient2 = getActivityChooserModelClient2(); + * modelClient2.setActivityChooserModel(dataModel); + * + * // Set an intent to choose a an activity for. + * dataModel.setIntent(intent); + * <pre> + * <code> + * </p> + * <p> + * <strong>Note:</strong> This class is thread safe. + * </p> + * + * @hide + */ +public class ActivityChooserModel extends DataSetObservable { + + /** + * Client that utilizes an {@link ActivityChooserModel}. + */ + public interface ActivityChooserModelClient { + + /** + * Sets the {@link ActivityChooserModel}. + * + * @param dataModel The model. + */ + public void setActivityChooserModel(ActivityChooserModel dataModel); + } + + /** + * Defines a sorter that is responsible for sorting the activities + * based on the provided historical choices and an intent. + */ + public interface ActivitySorter { + + /** + * Sorts the <code>activities</code> in descending order of relevance + * based on previous history and an intent. + * + * @param intent The {@link Intent}. + * @param activities Activities to be sorted. + * @param historicalRecords Historical records. + */ + // This cannot be done by a simple comparator since an Activity weight + // is computed from history. Note that Activity implements Comparable. + public void sort(Intent intent, List<ActivityResolveInfo> activities, + List<HistoricalRecord> historicalRecords); + } + + /** + * Listener for choosing an activity. + */ + public interface OnChooseActivityListener { + + /** + * Called when an activity has been chosen. The client can decide whether + * an activity can be chosen and if so the caller of + * {@link ActivityChooserModel#chooseActivity(int)} will receive and {@link Intent} + * for launching it. + * <p> + * <strong>Note:</strong> Modifying the intent is not permitted and + * any changes to the latter will be ignored. + * </p> + * + * @param host The listener's host model. + * @param intent The intent for launching the chosen activity. + * @return Whether the intent is handled and should not be delivered to clients. + * + * @see ActivityChooserModel#chooseActivity(int) + */ + public boolean onChooseActivity(ActivityChooserModel host, Intent intent); + } + + /** + * Flag for selecting debug mode. + */ + private static final boolean DEBUG = false; + + /** + * Tag used for logging. + */ + static final String LOG_TAG = ActivityChooserModel.class.getSimpleName(); + + /** + * The root tag in the history file. + */ + private static final String TAG_HISTORICAL_RECORDS = "historical-records"; + + /** + * The tag for a record in the history file. + */ + private static final String TAG_HISTORICAL_RECORD = "historical-record"; + + /** + * Attribute for the activity. + */ + private static final String ATTRIBUTE_ACTIVITY = "activity"; + + /** + * Attribute for the choice time. + */ + private static final String ATTRIBUTE_TIME = "time"; + + /** + * Attribute for the choice weight. + */ + private static final String ATTRIBUTE_WEIGHT = "weight"; + + /** + * The default maximal length of the choice history. + */ + public static final int DEFAULT_HISTORY_MAX_LENGTH = 50; + + /** + * The amount with which to inflate a chosen activity when set as default. + */ + private static final int DEFAULT_ACTIVITY_INFLATION = 5; + + /** + * Default weight for a choice record. + */ + private static final float DEFAULT_HISTORICAL_RECORD_WEIGHT = 1.0f; + + /** + * The extension of the history file. + */ + private static final String HISTORY_FILE_EXTENSION = ".xml"; + + /** + * An invalid item index. + */ + private static final int INVALID_INDEX = -1; + + /** + * Lock to guard the model registry. + */ + private static final Object sRegistryLock = new Object(); + + /** + * This the registry for data models. + */ + private static final Map<String, ActivityChooserModel> sDataModelRegistry = + new HashMap<String, ActivityChooserModel>(); + + /** + * Lock for synchronizing on this instance. + */ + private final Object mInstanceLock = new Object(); + + /** + * List of activities that can handle the current intent. + */ + private final List<ActivityResolveInfo> mActivities = new ArrayList<ActivityResolveInfo>(); + + /** + * List with historical choice records. + */ + private final List<HistoricalRecord> mHistoricalRecords = new ArrayList<HistoricalRecord>(); + + /** + * Monitor for added and removed packages. + */ + /** + * Mozilla: Converted from a PackageMonitor to a DataModelPackageMonitor to avoid importing a new class. + */ + private final DataModelPackageMonitor mPackageMonitor = new DataModelPackageMonitor(); + + /** + * Context for accessing resources. + */ + final Context mContext; + + /** + * The name of the history file that backs this model. + */ + final String mHistoryFileName; + + /** + * The intent for which a activity is being chosen. + */ + private Intent mIntent; + + /** + * The sorter for ordering activities based on intent and past choices. + */ + private ActivitySorter mActivitySorter = new DefaultSorter(); + + /** + * The maximal length of the choice history. + */ + private int mHistoryMaxSize = DEFAULT_HISTORY_MAX_LENGTH; + + /** + * Flag whether choice history can be read. In general many clients can + * share the same data model and {@link #readHistoricalDataIfNeeded()} may be called + * by arbitrary of them any number of times. Therefore, this class guarantees + * that the very first read succeeds and subsequent reads can be performed + * only after a call to {@link #persistHistoricalDataIfNeeded()} followed by change + * of the share records. + */ + boolean mCanReadHistoricalData = true; + + /** + * Flag whether the choice history was read. This is used to enforce that + * before calling {@link #persistHistoricalDataIfNeeded()} a call to + * {@link #persistHistoricalDataIfNeeded()} has been made. This aims to avoid a + * scenario in which a choice history file exits, it is not read yet and + * it is overwritten. Note that always all historical records are read in + * full and the file is rewritten. This is necessary since we need to + * purge old records that are outside of the sliding window of past choices. + */ + private boolean mReadShareHistoryCalled; + + /** + * Flag whether the choice records have changed. In general many clients can + * share the same data model and {@link #persistHistoricalDataIfNeeded()} may be called + * by arbitrary of them any number of times. Therefore, this class guarantees + * that choice history will be persisted only if it has changed. + */ + private boolean mHistoricalRecordsChanged = true; + + /** + * Flag whether to reload the activities for the current intent. + */ + boolean mReloadActivities; + + /** + * Policy for controlling how the model handles chosen activities. + */ + private OnChooseActivityListener mActivityChooserModelPolicy; + + /** + * Mozilla: Share overlay variables. + */ + private final SyncStatusListener mSyncStatusListener = new SyncStatusDelegate(); + + /** + * Gets the data model backed by the contents of the provided file with historical data. + * Note that only one data model is backed by a given file, thus multiple calls with + * the same file name will return the same model instance. If no such instance is present + * it is created. + * + * <p> + * <strong>Always use difference historical data files for semantically different actions. + * For example, sharing is different from importing.</strong> + * </p> + * + * @param context Context for loading resources. + * @param historyFileName File name with choice history, <code>null</code> + * if the model should not be backed by a file. In this case the activities + * will be ordered only by data from the current session. + * + * @return The model. + */ + public static ActivityChooserModel get(Context context, String historyFileName) { + synchronized (sRegistryLock) { + ActivityChooserModel dataModel = sDataModelRegistry.get(historyFileName); + if (dataModel == null) { + dataModel = new ActivityChooserModel(context, historyFileName); + sDataModelRegistry.put(historyFileName, dataModel); + } + return dataModel; + } + } + + /** + * Creates a new instance. + * + * @param context Context for loading resources. + * @param historyFileName The history XML file. + */ + private ActivityChooserModel(Context context, String historyFileName) { + mContext = context.getApplicationContext(); + if (!TextUtils.isEmpty(historyFileName) + && !historyFileName.endsWith(HISTORY_FILE_EXTENSION)) { + mHistoryFileName = historyFileName + HISTORY_FILE_EXTENSION; + } else { + mHistoryFileName = historyFileName; + } + + /** + * Mozilla: Uses modified receiver + */ + mPackageMonitor.register(mContext); + + /** + * Mozilla: Add Sync Status Listener. + */ + // TODO: We only need to add a sync status listener if the ShareDialog passes the intent filter. + FirefoxAccounts.addSyncStatusListener(mSyncStatusListener); + } + + /** + * Sets an intent for which to choose a activity. + * <p> + * <strong>Note:</strong> Clients must set only semantically similar + * intents for each data model. + * <p> + * + * @param intent The intent. + */ + public void setIntent(Intent intent) { + synchronized (mInstanceLock) { + if (mIntent == intent) { + return; + } + mIntent = intent; + mReloadActivities = true; + ensureConsistentState(); + } + } + + /** + * Gets the intent for which a activity is being chosen. + * + * @return The intent. + */ + public Intent getIntent() { + synchronized (mInstanceLock) { + return mIntent; + } + } + + /** + * Gets the number of activities that can handle the intent. + * + * @return The activity count. + * + * @see #setIntent(Intent) + */ + public int getActivityCount() { + synchronized (mInstanceLock) { + ensureConsistentState(); + return mActivities.size(); + } + } + + /** + * Gets an activity at a given index. + * + * @return The activity. + * + * @see ActivityResolveInfo + * @see #setIntent(Intent) + */ + public ResolveInfo getActivity(int index) { + synchronized (mInstanceLock) { + ensureConsistentState(); + return mActivities.get(index).resolveInfo; + } + } + + /** + * Gets the index of a the given activity. + * + * @param activity The activity index. + * + * @return The index if found, -1 otherwise. + */ + public int getActivityIndex(ResolveInfo activity) { + synchronized (mInstanceLock) { + ensureConsistentState(); + List<ActivityResolveInfo> activities = mActivities; + final int activityCount = activities.size(); + for (int i = 0; i < activityCount; i++) { + ActivityResolveInfo currentActivity = activities.get(i); + if (currentActivity.resolveInfo == activity) { + return i; + } + } + return INVALID_INDEX; + } + } + + /** + * Chooses a activity to handle the current intent. This will result in + * adding a historical record for that action and construct intent with + * its component name set such that it can be immediately started by the + * client. + * <p> + * <strong>Note:</strong> By calling this method the client guarantees + * that the returned intent will be started. This intent is returned to + * the client solely to let additional customization before the start. + * </p> + * + * @return An {@link Intent} for launching the activity or null if the + * policy has consumed the intent or there is not current intent + * set via {@link #setIntent(Intent)}. + * + * @see HistoricalRecord + * @see OnChooseActivityListener + */ + public Intent chooseActivity(int index) { + synchronized (mInstanceLock) { + if (mIntent == null) { + return null; + } + + ensureConsistentState(); + + ActivityResolveInfo chosenActivity = mActivities.get(index); + + ComponentName chosenName = new ComponentName( + chosenActivity.resolveInfo.activityInfo.packageName, + chosenActivity.resolveInfo.activityInfo.name); + + Intent choiceIntent = new Intent(mIntent); + choiceIntent.setComponent(chosenName); + + if (mActivityChooserModelPolicy != null) { + // Do not allow the policy to change the intent. + Intent choiceIntentCopy = new Intent(choiceIntent); + final boolean handled = mActivityChooserModelPolicy.onChooseActivity(this, + choiceIntentCopy); + if (handled) { + return null; + } + } + + HistoricalRecord historicalRecord = new HistoricalRecord(chosenName, + System.currentTimeMillis(), DEFAULT_HISTORICAL_RECORD_WEIGHT); + addHistoricalRecord(historicalRecord); + + return choiceIntent; + } + } + + /** + * Sets the listener for choosing an activity. + * + * @param listener The listener. + */ + public void setOnChooseActivityListener(OnChooseActivityListener listener) { + synchronized (mInstanceLock) { + mActivityChooserModelPolicy = listener; + } + } + + /** + * Gets the default activity, The default activity is defined as the one + * with highest rank i.e. the first one in the list of activities that can + * handle the intent. + * + * @return The default activity, <code>null</code> id not activities. + * + * @see #getActivity(int) + */ + public ResolveInfo getDefaultActivity() { + synchronized (mInstanceLock) { + ensureConsistentState(); + if (!mActivities.isEmpty()) { + return mActivities.get(0).resolveInfo; + } + } + return null; + } + + /** + * Sets the default activity. The default activity is set by adding a + * historical record with weight high enough that this activity will + * become the highest ranked. Such a strategy guarantees that the default + * will eventually change if not used. Also the weight of the record for + * setting a default is inflated with a constant amount to guarantee that + * it will stay as default for awhile. + * + * @param index The index of the activity to set as default. + */ + public void setDefaultActivity(int index) { + synchronized (mInstanceLock) { + ensureConsistentState(); + + ActivityResolveInfo newDefaultActivity = mActivities.get(index); + ActivityResolveInfo oldDefaultActivity = mActivities.get(0); + + final float weight; + if (oldDefaultActivity != null) { + // Add a record with weight enough to boost the chosen at the top. + weight = oldDefaultActivity.weight - newDefaultActivity.weight + + DEFAULT_ACTIVITY_INFLATION; + } else { + weight = DEFAULT_HISTORICAL_RECORD_WEIGHT; + } + + ComponentName defaultName = new ComponentName( + newDefaultActivity.resolveInfo.activityInfo.packageName, + newDefaultActivity.resolveInfo.activityInfo.name); + HistoricalRecord historicalRecord = new HistoricalRecord(defaultName, + System.currentTimeMillis(), weight); + addHistoricalRecord(historicalRecord); + } + } + + /** + * Persists the history data to the backing file if the latter + * was provided. Calling this method before a call to {@link #readHistoricalDataIfNeeded()} + * throws an exception. Calling this method more than one without choosing an + * activity has not effect. + * + * @throws IllegalStateException If this method is called before a call to + * {@link #readHistoricalDataIfNeeded()}. + */ + private void persistHistoricalDataIfNeeded() { + if (!mReadShareHistoryCalled) { + throw new IllegalStateException("No preceding call to #readHistoricalData"); + } + if (!mHistoricalRecordsChanged) { + return; + } + mHistoricalRecordsChanged = false; + if (!TextUtils.isEmpty(mHistoryFileName)) { + /** + * Mozilla: Converted to a normal task.execute call so that this works on < ICS phones. + */ + new PersistHistoryAsyncTask().execute(new ArrayList<HistoricalRecord>(mHistoricalRecords), mHistoryFileName); + } + } + + /** + * Sets the sorter for ordering activities based on historical data and an intent. + * + * @param activitySorter The sorter. + * + * @see ActivitySorter + */ + public void setActivitySorter(ActivitySorter activitySorter) { + synchronized (mInstanceLock) { + if (mActivitySorter == activitySorter) { + return; + } + mActivitySorter = activitySorter; + if (sortActivitiesIfNeeded()) { + notifyChanged(); + } + } + } + + /** + * Sets the maximal size of the historical data. Defaults to + * {@link #DEFAULT_HISTORY_MAX_LENGTH} + * <p> + * <strong>Note:</strong> Setting this property will immediately + * enforce the specified max history size by dropping enough old + * historical records to enforce the desired size. Thus, any + * records that exceed the history size will be discarded and + * irreversibly lost. + * </p> + * + * @param historyMaxSize The max history size. + */ + public void setHistoryMaxSize(int historyMaxSize) { + synchronized (mInstanceLock) { + if (mHistoryMaxSize == historyMaxSize) { + return; + } + mHistoryMaxSize = historyMaxSize; + pruneExcessiveHistoricalRecordsIfNeeded(); + if (sortActivitiesIfNeeded()) { + notifyChanged(); + } + } + } + + /** + * Gets the history max size. + * + * @return The history max size. + */ + public int getHistoryMaxSize() { + synchronized (mInstanceLock) { + return mHistoryMaxSize; + } + } + + /** + * Gets the history size. + * + * @return The history size. + */ + public int getHistorySize() { + synchronized (mInstanceLock) { + ensureConsistentState(); + return mHistoricalRecords.size(); + } + } + + public int getDistinctActivityCountInHistory() { + synchronized (mInstanceLock) { + ensureConsistentState(); + final List<String> packages = new ArrayList<String>(); + for (HistoricalRecord record : mHistoricalRecords) { + String activity = record.activity.flattenToString(); + if (!packages.contains(activity)) { + packages.add(activity); + } + } + return packages.size(); + } + } + + @Override + protected void finalize() throws Throwable { + super.finalize(); + + /** + * Mozilla: Not needed for the application. + */ + mPackageMonitor.unregister(); + FirefoxAccounts.removeSyncStatusListener(mSyncStatusListener); + } + + /** + * Ensures the model is in a consistent state which is the + * activities for the current intent have been loaded, the + * most recent history has been read, and the activities + * are sorted. + */ + private void ensureConsistentState() { + boolean stateChanged = loadActivitiesIfNeeded(); + stateChanged |= readHistoricalDataIfNeeded(); + pruneExcessiveHistoricalRecordsIfNeeded(); + if (stateChanged) { + sortActivitiesIfNeeded(); + notifyChanged(); + } + } + + /** + * Sorts the activities if necessary which is if there is a + * sorter, there are some activities to sort, and there is some + * historical data. + * + * @return Whether sorting was performed. + */ + private boolean sortActivitiesIfNeeded() { + if (mActivitySorter != null && mIntent != null + && !mActivities.isEmpty() && !mHistoricalRecords.isEmpty()) { + mActivitySorter.sort(mIntent, mActivities, + Collections.unmodifiableList(mHistoricalRecords)); + return true; + } + return false; + } + + /** + * Loads the activities for the current intent if needed which is + * if they are not already loaded for the current intent. + * + * @return Whether loading was performed. + */ + private boolean loadActivitiesIfNeeded() { + if (mReloadActivities && mIntent != null) { + mReloadActivities = false; + mActivities.clear(); + List<ResolveInfo> resolveInfos = mContext.getPackageManager() + .queryIntentActivities(mIntent, 0); + final int resolveInfoCount = resolveInfos.size(); + + /** + * Mozilla: Temporary variables to prevent performance degradation in the loop. + */ + final PackageManager packageManager = mContext.getPackageManager(); + final String channelToRemoveLabel = mContext.getResources().getString(R.string.overlay_share_label); + final String shareDialogClassName = ShareDialog.class.getCanonicalName(); + + for (int i = 0; i < resolveInfoCount; i++) { + ResolveInfo resolveInfo = resolveInfos.get(i); + + /** + * Mozilla: We want "Add to Firefox" to appear differently inside of Firefox than + * from external applications - override the name and icon here. + * + * Do not display the menu item if there are no devices to share to. + * + * Note: we check both the class name and the label to ensure we only change the + * label of the current channel. + */ + if (shareDialogClassName.equals(resolveInfo.activityInfo.name) && + channelToRemoveLabel.equals(resolveInfo.loadLabel(packageManager))) { + // Don't add the menu item if there are no devices to share to. + if (!hasOtherSyncClients()) { + continue; + } + + resolveInfo.labelRes = R.string.overlay_share_send_other; + resolveInfo.icon = R.drawable.icon_shareplane; + } + + mActivities.add(new ActivityResolveInfo(resolveInfo)); + } + return true; + } + return false; + } + + /** + * Reads the historical data if necessary which is it has + * changed, there is a history file, and there is not persist + * in progress. + * + * @return Whether reading was performed. + */ + private boolean readHistoricalDataIfNeeded() { + if (mCanReadHistoricalData && mHistoricalRecordsChanged && + !TextUtils.isEmpty(mHistoryFileName)) { + mCanReadHistoricalData = false; + mReadShareHistoryCalled = true; + readHistoricalDataImpl(); + return true; + } + return false; + } + + /** + * Adds a historical record. + * + * @param historicalRecord The record to add. + * @return True if the record was added. + */ + private boolean addHistoricalRecord(HistoricalRecord historicalRecord) { + final boolean added = mHistoricalRecords.add(historicalRecord); + if (added) { + mHistoricalRecordsChanged = true; + pruneExcessiveHistoricalRecordsIfNeeded(); + persistHistoricalDataIfNeeded(); + sortActivitiesIfNeeded(); + notifyChanged(); + } + return added; + } + + /** + * Removes all historical records for this pkg. + * + * @param historicalRecord The pkg to delete records for. + * @return True if the record was added. + */ + boolean removeHistoricalRecordsForPackage(final String pkg) { + boolean removed = false; + + for (Iterator<HistoricalRecord> i = mHistoricalRecords.iterator(); i.hasNext();) { + final HistoricalRecord record = i.next(); + if (record.activity.getPackageName().equals(pkg)) { + i.remove(); + removed = true; + } + } + + if (removed) { + mHistoricalRecordsChanged = true; + pruneExcessiveHistoricalRecordsIfNeeded(); + persistHistoricalDataIfNeeded(); + sortActivitiesIfNeeded(); + notifyChanged(); + } + + return removed; + } + + /** + * Prunes older excessive records to guarantee maxHistorySize. + */ + private void pruneExcessiveHistoricalRecordsIfNeeded() { + final int pruneCount = mHistoricalRecords.size() - mHistoryMaxSize; + if (pruneCount <= 0) { + return; + } + mHistoricalRecordsChanged = true; + for (int i = 0; i < pruneCount; i++) { + HistoricalRecord prunedRecord = mHistoricalRecords.remove(0); + if (DEBUG) { + Log.i(LOG_TAG, "Pruned: " + prunedRecord); + } + } + } + + /** + * Represents a record in the history. + */ + public final static class HistoricalRecord { + + /** + * The activity name. + */ + public final ComponentName activity; + + /** + * The choice time. + */ + public final long time; + + /** + * The record weight. + */ + public final float weight; + + /** + * Creates a new instance. + * + * @param activityName The activity component name flattened to string. + * @param time The time the activity was chosen. + * @param weight The weight of the record. + */ + public HistoricalRecord(String activityName, long time, float weight) { + this(ComponentName.unflattenFromString(activityName), time, weight); + } + + /** + * Creates a new instance. + * + * @param activityName The activity name. + * @param time The time the activity was chosen. + * @param weight The weight of the record. + */ + public HistoricalRecord(ComponentName activityName, long time, float weight) { + this.activity = activityName; + this.time = time; + this.weight = weight; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((activity == null) ? 0 : activity.hashCode()); + result = prime * result + (int) (time ^ (time >>> 32)); + result = prime * result + Float.floatToIntBits(weight); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + HistoricalRecord other = (HistoricalRecord) obj; + if (activity == null) { + if (other.activity != null) { + return false; + } + } else if (!activity.equals(other.activity)) { + return false; + } + if (time != other.time) { + return false; + } + if (Float.floatToIntBits(weight) != Float.floatToIntBits(other.weight)) { + return false; + } + return true; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("["); + builder.append("; activity:").append(activity); + builder.append("; time:").append(time); + builder.append("; weight:").append(new BigDecimal(weight)); + builder.append("]"); + return builder.toString(); + } + } + + /** + * Represents an activity. + */ + public final class ActivityResolveInfo implements Comparable<ActivityResolveInfo> { + + /** + * The {@link ResolveInfo} of the activity. + */ + public final ResolveInfo resolveInfo; + + /** + * Weight of the activity. Useful for sorting. + */ + public float weight; + + /** + * Creates a new instance. + * + * @param resolveInfo activity {@link ResolveInfo}. + */ + public ActivityResolveInfo(ResolveInfo resolveInfo) { + this.resolveInfo = resolveInfo; + } + + @Override + public int hashCode() { + return 31 + Float.floatToIntBits(weight); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ActivityResolveInfo other = (ActivityResolveInfo) obj; + if (Float.floatToIntBits(weight) != Float.floatToIntBits(other.weight)) { + return false; + } + return true; + } + + @Override + public int compareTo(ActivityResolveInfo another) { + return Float.floatToIntBits(another.weight) - Float.floatToIntBits(weight); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("["); + builder.append("resolveInfo:").append(resolveInfo.toString()); + builder.append("; weight:").append(new BigDecimal(weight)); + builder.append("]"); + return builder.toString(); + } + } + + /** + * Default activity sorter implementation. + */ + private final class DefaultSorter implements ActivitySorter { + private static final float WEIGHT_DECAY_COEFFICIENT = 0.95f; + + private final Map<String, ActivityResolveInfo> mPackageNameToActivityMap = + new HashMap<String, ActivityResolveInfo>(); + + @Override + public void sort(Intent intent, List<ActivityResolveInfo> activities, + List<HistoricalRecord> historicalRecords) { + Map<String, ActivityResolveInfo> packageNameToActivityMap = + mPackageNameToActivityMap; + packageNameToActivityMap.clear(); + + final int activityCount = activities.size(); + for (int i = 0; i < activityCount; i++) { + ActivityResolveInfo activity = activities.get(i); + activity.weight = 0.0f; + + // Make sure we're using a non-ambiguous name here + ComponentName chosenName = new ComponentName( + activity.resolveInfo.activityInfo.packageName, + activity.resolveInfo.activityInfo.name); + String packageName = chosenName.flattenToString(); + packageNameToActivityMap.put(packageName, activity); + } + + final int lastShareIndex = historicalRecords.size() - 1; + float nextRecordWeight = 1; + for (int i = lastShareIndex; i >= 0; i--) { + HistoricalRecord historicalRecord = historicalRecords.get(i); + String packageName = historicalRecord.activity.flattenToString(); + ActivityResolveInfo activity = packageNameToActivityMap.get(packageName); + if (activity != null) { + activity.weight += historicalRecord.weight * nextRecordWeight; + nextRecordWeight = nextRecordWeight * WEIGHT_DECAY_COEFFICIENT; + } + } + + Collections.sort(activities); + + if (DEBUG) { + for (int i = 0; i < activityCount; i++) { + Log.i(LOG_TAG, "Sorted: " + activities.get(i)); + } + } + } + } + + /** + * Command for reading the historical records from a file off the UI thread. + */ + private void readHistoricalDataImpl() { + try { + GeckoProfile profile = GeckoProfile.get(mContext); + File f = profile.getFile(mHistoryFileName); + if (!f.exists()) { + // Fall back to the non-profile aware file if it exists... + File oldFile = new File(mHistoryFileName); + oldFile.renameTo(f); + } + readHistoricalDataFromStream(new FileInputStream(f)); + } catch (FileNotFoundException fnfe) { + final Distribution dist = Distribution.getInstance(mContext); + dist.addOnDistributionReadyCallback(new Distribution.ReadyCallback() { + @Override + public void distributionNotFound() { + } + + @Override + public void distributionFound(Distribution distribution) { + try { + File distFile = dist.getDistributionFile("quickshare/" + mHistoryFileName); + if (distFile == null) { + if (DEBUG) { + Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName); + } + return; + } + readHistoricalDataFromStream(new FileInputStream(distFile)); + } catch (Exception ex) { + if (DEBUG) { + Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName); + } + return; + } + } + + @Override + public void distributionArrivedLate(Distribution distribution) { + distributionFound(distribution); + } + }); + } + } + + void readHistoricalDataFromStream(FileInputStream fis) { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(fis, null); + + int type = XmlPullParser.START_DOCUMENT; + while (type != XmlPullParser.END_DOCUMENT && type != XmlPullParser.START_TAG) { + type = parser.next(); + } + + if (!TAG_HISTORICAL_RECORDS.equals(parser.getName())) { + throw new XmlPullParserException("Share records file does not start with " + + TAG_HISTORICAL_RECORDS + " tag."); + } + + List<HistoricalRecord> historicalRecords = mHistoricalRecords; + historicalRecords.clear(); + + while (true) { + type = parser.next(); + if (type == XmlPullParser.END_DOCUMENT) { + break; + } + if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { + continue; + } + String nodeName = parser.getName(); + if (!TAG_HISTORICAL_RECORD.equals(nodeName)) { + throw new XmlPullParserException("Share records file not well-formed."); + } + + String activity = parser.getAttributeValue(null, ATTRIBUTE_ACTIVITY); + final long time = + Long.parseLong(parser.getAttributeValue(null, ATTRIBUTE_TIME)); + final float weight = + Float.parseFloat(parser.getAttributeValue(null, ATTRIBUTE_WEIGHT)); + HistoricalRecord readRecord = new HistoricalRecord(activity, time, weight); + historicalRecords.add(readRecord); + + if (DEBUG) { + Log.i(LOG_TAG, "Read " + readRecord.toString()); + } + } + + if (DEBUG) { + Log.i(LOG_TAG, "Read " + historicalRecords.size() + " historical records."); + } + } catch (XmlPullParserException | IOException xppe) { + Log.e(LOG_TAG, "Error reading historical record file: " + mHistoryFileName, xppe); + } finally { + if (fis != null) { + try { + fis.close(); + } catch (IOException ioe) { + /* ignore */ + } + } + } + } + + /** + * Command for persisting the historical records to a file off the UI thread. + */ + private final class PersistHistoryAsyncTask extends AsyncTask<Object, Void, Void> { + + @Override + @SuppressWarnings("unchecked") + public Void doInBackground(Object... args) { + List<HistoricalRecord> historicalRecords = (List<HistoricalRecord>) args[0]; + String historyFileName = (String) args[1]; + + FileOutputStream fos = null; + + try { + // Mozilla - Update the location we save files to + GeckoProfile profile = GeckoProfile.get(mContext); + File file = profile.getFile(historyFileName); + fos = new FileOutputStream(file); + } catch (FileNotFoundException fnfe) { + Log.e(LOG_TAG, "Error writing historical record file: " + historyFileName, fnfe); + return null; + } + + XmlSerializer serializer = Xml.newSerializer(); + + try { + serializer.setOutput(fos, null); + serializer.startDocument("UTF-8", true); + serializer.startTag(null, TAG_HISTORICAL_RECORDS); + + final int recordCount = historicalRecords.size(); + for (int i = 0; i < recordCount; i++) { + HistoricalRecord record = historicalRecords.remove(0); + serializer.startTag(null, TAG_HISTORICAL_RECORD); + serializer.attribute(null, ATTRIBUTE_ACTIVITY, + record.activity.flattenToString()); + serializer.attribute(null, ATTRIBUTE_TIME, String.valueOf(record.time)); + serializer.attribute(null, ATTRIBUTE_WEIGHT, String.valueOf(record.weight)); + serializer.endTag(null, TAG_HISTORICAL_RECORD); + if (DEBUG) { + Log.i(LOG_TAG, "Wrote " + record.toString()); + } + } + + serializer.endTag(null, TAG_HISTORICAL_RECORDS); + serializer.endDocument(); + + if (DEBUG) { + Log.i(LOG_TAG, "Wrote " + recordCount + " historical records."); + } + } catch (IllegalArgumentException | IOException | IllegalStateException e) { + Log.e(LOG_TAG, "Error writing historical record file: " + mHistoryFileName, e); + } finally { + mCanReadHistoricalData = true; + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + /* ignore */ + } + } + } + return null; + } + } + + /** + * Keeps in sync the historical records and activities with the installed applications. + */ + /** + * Mozilla: Adapted significantly + */ + private static final String LOGTAG = "GeckoActivityChooserModel"; + private final class DataModelPackageMonitor extends BroadcastReceiver { + Context mContext; + + public DataModelPackageMonitor() { } + + public void register(Context context) { + mContext = context; + + String[] intents = new String[] { + Intent.ACTION_PACKAGE_REMOVED, + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_CHANGED + }; + + for (String intent : intents) { + IntentFilter removeFilter = new IntentFilter(intent); + removeFilter.addDataScheme("package"); + context.registerReceiver(this, removeFilter); + } + } + + public void unregister() { + mContext.unregisterReceiver(this); + mContext = null; + } + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) { + String packageName = intent.getData().getSchemeSpecificPart(); + removeHistoricalRecordsForPackage(packageName); + } + + mReloadActivities = true; + } + } + + /** + * Mozilla: Return whether or not there are other synced clients. + */ + private boolean hasOtherSyncClients() { + // ClientsDatabaseAccessor returns stale data (bug 1145896) so we work around this by + // checking if we have accounts set up - if not, we can't have any clients. + if (!FirefoxAccounts.firefoxAccountsExist(mContext)) { + return false; + } + + final BrowserDB browserDB = BrowserDB.from(mContext); + final TabsAccessor tabsAccessor = browserDB.getTabsAccessor(); + final Cursor remoteClientsCursor = tabsAccessor + .getRemoteClientsByRecencyCursor(mContext); + if (remoteClientsCursor == null) { + return false; + } + + try { + return remoteClientsCursor.getCount() > 0; + } finally { + remoteClientsCursor.close(); + } + } + + /** + * Mozilla: Reload activities on sync. + */ + private class SyncStatusDelegate implements SyncStatusListener { + @Override + public Context getContext() { + return mContext; + } + + @Override + public Account getAccount() { + return FirefoxAccounts.getFirefoxAccount(getContext()); + } + + @Override + public void onSyncStarted() { + } + + @Override + public void onSyncFinished() { + // TODO: We only need to reload activities when the number of devices changes. + // This may not be worth it if we have to touch the DB to get the client count. + synchronized (mInstanceLock) { + mReloadActivities = true; + } + } + } +} + diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java new file mode 100644 index 000000000..6bd1e36e4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java @@ -0,0 +1,21 @@ +/* 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.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; + +public class AllCapsTextView extends TextView { + + public AllCapsTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void setText(CharSequence text, BufferType type) { + super.setText(text.toString().toUpperCase(), type); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java b/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java new file mode 100644 index 000000000..a504c5832 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java @@ -0,0 +1,130 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; + +import android.app.Activity; +import android.content.Context; +import android.graphics.drawable.BitmapDrawable; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.PopupWindow; +import org.mozilla.gecko.util.HardwareUtils; + +/** + * AnchoredPopup is the base class for doorhanger notifications, and is anchored to the urlbar. + */ +public abstract class AnchoredPopup extends PopupWindow { + public interface OnVisibilityChangeListener { + public void onDoorHangerShow(); + public void onDoorHangerHide(); + } + + private View mAnchor; + private OnVisibilityChangeListener onVisibilityChangeListener; + + protected RoundedCornerLayout mContent; + protected boolean mInflated; + + protected final Context mContext; + + public AnchoredPopup(Context context) { + super(context); + + mContext = context; + + setAnimationStyle(R.style.PopupAnimation); + } + + protected void init() { + // Hide the default window background. Passing null prevents the below setOutTouchable() + // call from working, so use an empty BitmapDrawable instead. + setBackgroundDrawable(new BitmapDrawable(mContext.getResources())); + + // Allow the popup to be dismissed when touching outside. + setOutsideTouchable(true); + + // PopupWindow has a default width and height of 0, so set the width here. + int width = (int) mContext.getResources().getDimension(R.dimen.doorhanger_width); + setWindowLayoutMode(0, ViewGroup.LayoutParams.WRAP_CONTENT); + setWidth(width); + + final LayoutInflater inflater = LayoutInflater.from(mContext); + final View layout = inflater.inflate(R.layout.anchored_popup, null); + setContentView(layout); + + mContent = (RoundedCornerLayout) layout.findViewById(R.id.content); + + mInflated = true; + } + + /** + * Sets the anchor for this popup. + * + * @param anchor Anchor view for positioning the arrow. + */ + public void setAnchor(View anchor) { + mAnchor = anchor; + } + + public void setOnVisibilityChangeListener(OnVisibilityChangeListener listener) { + onVisibilityChangeListener = listener; + } + + /** + * Shows the popup with the arrow pointing to the center of the anchor view. If the anchor + * isn't visible, the popup will just be shown at the top of the root view. + */ + public void show() { + if (!mInflated) { + throw new IllegalStateException("ArrowPopup#init() must be called before ArrowPopup#show()"); + } + + if (onVisibilityChangeListener != null) { + onVisibilityChangeListener.onDoorHangerShow(); + } + + final int[] anchorLocation = new int[2]; + if (mAnchor != null) { + mAnchor.getLocationInWindow(anchorLocation); + } + + // The doorhanger should overlap the bottom of the urlbar. + int offsetY = mContext.getResources().getDimensionPixelOffset(R.dimen.doorhanger_offsetY); + final View decorView = ((Activity) mContext).getWindow().getDecorView(); + + final boolean validAnchor = (mAnchor != null) && (anchorLocation[1] > 0); + if (HardwareUtils.isTablet()) { + if (validAnchor) { + showAsDropDown(mAnchor, 0, 0); + } else { + // The anchor will be offscreen if the dynamic toolbar is hidden, so anticipate the re-shown position + // of the toolbar. + final int offsetX = mContext.getResources().getDimensionPixelOffset(R.dimen.doorhanger_offsetX); + showAtLocation(decorView, Gravity.TOP | Gravity.LEFT, offsetX, offsetY); + } + } else { + // If the anchor is null or out of the window bounds, just show the popup at the top of the + // root view. + final View anchor = validAnchor ? mAnchor : decorView; + + showAtLocation(anchor, Gravity.TOP | Gravity.CENTER_HORIZONTAL, 0, offsetY); + } + } + + @Override + public void dismiss() { + super.dismiss(); + if (onVisibilityChangeListener != null) { + onVisibilityChangeListener.onDoorHangerHide(); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java new file mode 100644 index 000000000..f1343b0fb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java @@ -0,0 +1,77 @@ +/* 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.widget; + +import org.mozilla.gecko.animation.HeightChangeAnimation; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.widget.RelativeLayout; + +public class AnimatedHeightLayout extends RelativeLayout { + private static final String LOGTAG = "GeckoAnimatedHeightLayout"; + private static final int ANIMATION_DURATION = 100; + private boolean mAnimating; + + public AnimatedHeightLayout(Context context) { + super(context, null); + } + + public AnimatedHeightLayout(Context context, AttributeSet attrs) { + super(context, attrs, 0); + } + + public AnimatedHeightLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int oldHeight = getMeasuredHeight(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int newHeight = getMeasuredHeight(); + + if (!mAnimating && oldHeight != 0 && oldHeight != newHeight) { + mAnimating = true; + setMeasuredDimension(getMeasuredWidth(), oldHeight); + + // Animate the difference of suggestion row height + Animation anim = new HeightChangeAnimation(this, oldHeight, newHeight); + anim.setDuration(ANIMATION_DURATION); + anim.setInterpolator(new DecelerateInterpolator()); + anim.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + @Override + public void onAnimationRepeat(Animation animation) {} + @Override + public void onAnimationEnd(Animation animation) { + post(new Runnable() { + @Override + public void run() { + finishAnimation(); + } + }); + } + }); + startAnimation(anim); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + finishAnimation(); + } + + void finishAnimation() { + if (mAnimating) { + getLayoutParams().height = LayoutParams.WRAP_CONTENT; + mAnimating = false; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java b/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java new file mode 100644 index 000000000..4f1468203 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java @@ -0,0 +1,140 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.AdapterView; +import android.widget.CheckedTextView; +import android.widget.ListView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; + +public class BasicColorPicker extends ListView { + private final static String LOGTAG = "GeckoBasicColorPicker"; + private final static List<Integer> DEFAULT_COLORS = Arrays.asList(Color.rgb(215, 57, 32), + Color.rgb(255, 134, 5), + Color.rgb(255, 203, 19), + Color.rgb(95, 173, 71), + Color.rgb(84, 201, 168), + Color.rgb(33, 161, 222), + Color.rgb(16, 36, 87), + Color.rgb(91, 32, 103), + Color.rgb(212, 221, 228), + Color.BLACK); + + private static Drawable mCheckDrawable; + int mSelected; + final ColorPickerListAdapter mAdapter; + + public BasicColorPicker(Context context) { + this(context, null); + } + + public BasicColorPicker(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public BasicColorPicker(Context context, AttributeSet attrs, int style) { + this(context, attrs, style, DEFAULT_COLORS); + } + + public BasicColorPicker(Context context, AttributeSet attrs, int style, List<Integer> colors) { + super(context, attrs, style); + mAdapter = new ColorPickerListAdapter(context, new ArrayList<Integer>(colors)); + setAdapter(mAdapter); + + setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + mSelected = position; + mAdapter.notifyDataSetChanged(); + } + }); + } + + public int getColor() { + return mAdapter.getItem(mSelected); + } + + public void setColor(int color) { + if (!DEFAULT_COLORS.contains(color)) { + mSelected = mAdapter.getCount(); + mAdapter.add(color); + } else { + mSelected = DEFAULT_COLORS.indexOf(color); + } + + setSelection(mSelected); + mAdapter.notifyDataSetChanged(); + } + + Drawable getCheckDrawable() { + if (mCheckDrawable == null) { + Resources res = getContext().getResources(); + + TypedValue typedValue = new TypedValue(); + getContext().getTheme().resolveAttribute(android.R.attr.listPreferredItemHeight, typedValue, true); + DisplayMetrics metrics = new android.util.DisplayMetrics(); + ((WindowManager)getContext().getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay().getMetrics(metrics); + int height = (int) typedValue.getDimension(metrics); + + Drawable background = res.getDrawable(R.drawable.color_picker_row_bg); + Rect r = new Rect(); + background.getPadding(r); + height -= r.top + r.bottom; + + mCheckDrawable = res.getDrawable(R.drawable.color_picker_checkmark); + mCheckDrawable.setBounds(0, 0, height, height); + } + + return mCheckDrawable; + } + + private class ColorPickerListAdapter extends ArrayAdapter<Integer> { + private final List<Integer> mColors; + + public ColorPickerListAdapter(Context context, List<Integer> colors) { + super(context, R.layout.color_picker_row, colors); + mColors = colors; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = super.getView(position, convertView, parent); + + Drawable d = v.getBackground(); + d.setColorFilter(getItem(position), PorterDuff.Mode.MULTIPLY); + v.setBackgroundDrawable(d); + + Drawable check = null; + CheckedTextView checked = ((CheckedTextView) v); + if (mSelected == position) { + check = getCheckDrawable(); + } + + checked.setCompoundDrawables(check, null, null, null); + checked.setText(""); + + return v; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java new file mode 100644 index 000000000..b740592fe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java @@ -0,0 +1,52 @@ +/* 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.CheckBox; +import android.widget.Checkable; +import android.widget.LinearLayout; + + +public class CheckableLinearLayout extends LinearLayout implements Checkable { + + private CheckBox mCheckBox; + + public CheckableLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean isChecked() { + return mCheckBox != null && mCheckBox.isChecked(); + } + + @Override + public void setChecked(boolean isChecked) { + if (mCheckBox != null) { + mCheckBox.setChecked(isChecked); + } + } + + @Override + public void toggle() { + if (mCheckBox != null) { + mCheckBox.toggle(); + } + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setClickable(false); + } +} + + diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java b/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java new file mode 100644 index 000000000..206341212 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java @@ -0,0 +1,25 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.EditText; + +public class ClickableWhenDisabledEditText extends EditText { + public ClickableWhenDisabledEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled() && event.getAction() == MotionEvent.ACTION_UP) { + return performClick(); + } + return super.onTouchEvent(event); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java new file mode 100644 index 000000000..96b20a6c3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java @@ -0,0 +1,127 @@ +/* -*- 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.widget; + +import android.support.v4.content.ContextCompat; +import android.util.Log; +import android.widget.Button; +import android.widget.TextView; +import org.mozilla.gecko.R; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.view.View; + +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.toolbar.SiteIdentityPopup; + +import java.util.Locale; + +public class ContentSecurityDoorHanger extends DoorHanger { + private static final String LOGTAG = "GeckoSecurityDoorHanger"; + + private final TextView mTitle; + private final TextView mSecurityState; + private final TextView mMessage; + + public ContentSecurityDoorHanger(Context context, DoorhangerConfig config, Type type) { + super(context, config, type); + + mTitle = (TextView) findViewById(R.id.security_title); + mSecurityState = (TextView) findViewById(R.id.security_state); + mMessage = (TextView) findViewById(R.id.security_message); + + loadConfig(config); + } + + @Override + protected void loadConfig(DoorhangerConfig config) { + final String message = config.getMessage(); + if (message != null) { + mMessage.setText(message); + } + + final JSONObject options = config.getOptions(); + if (options != null) { + setOptions(options); + } + + final DoorhangerConfig.Link link = config.getLink(); + if (link != null) { + addLink(link.label, link.url); + } + + addButtonsToLayout(config); + } + + @Override + protected int getContentResource() { + return R.layout.doorhanger_security; + } + + @Override + public void setOptions(final JSONObject options) { + super.setOptions(options); + final JSONObject link = options.optJSONObject("link"); + if (link != null) { + try { + final String linkLabel = link.getString("label"); + final String linkUrl = link.getString("url"); + addLink(linkLabel, linkUrl); + } catch (JSONException e) { } + } + + final JSONObject trackingProtection = options.optJSONObject("tracking_protection"); + if (trackingProtection != null) { + mTitle.setVisibility(VISIBLE); + mTitle.setText(R.string.doorhanger_tracking_title); + try { + final boolean enabled = trackingProtection.getBoolean("enabled"); + if (enabled) { + mMessage.setText(R.string.doorhanger_tracking_message_enabled); + mSecurityState.setText(R.string.doorhanger_tracking_state_enabled); + mSecurityState.setTextColor(ContextCompat.getColor(getContext(), R.color.affirmative_green)); + } else { + mMessage.setText(R.string.doorhanger_tracking_message_disabled); + mSecurityState.setText(R.string.doorhanger_tracking_state_disabled); + mSecurityState.setTextColor(ContextCompat.getColor(getContext(), R.color.rejection_red)); + } + mMessage.setVisibility(VISIBLE); + mSecurityState.setVisibility(VISIBLE); + } catch (JSONException e) { } + } + } + + @Override + protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) { + return new Button.OnClickListener() { + @Override + public void onClick(View v) { + final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra; + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra); + + final JSONObject response = new JSONObject(); + try { + switch (mType) { + case TRACKING: + response.put("allowContent", (id == SiteIdentityPopup.ButtonType.DISABLE.ordinal())); + response.put("contentType", ("tracking")); + break; + default: + Log.w(LOGTAG, "Unknown doorhanger type " + mType.toString()); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating onClick response", e); + } + + mOnButtonClickListener.onButtonClick(response, ContentSecurityDoorHanger.this); + } + }; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java new file mode 100644 index 000000000..63cb84c5a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java @@ -0,0 +1,143 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.widget.ImageView; + +import org.mozilla.gecko.widget.themed.ThemedImageView; + +/** + * An ImageView which will always display at the given width and calculated height (based on the width and + * the supplied aspect ratio), drawn starting from the top left hand corner. A supplied drawable will be resized to fit + * the width of the view; if the resized drawable is too tall for the view then the drawable will be cropped at the + * bottom, however if the resized drawable is too short for the view to display whilst honouring it's given width and + * height then the drawable will be displayed at full height with the right hand side cropped. + */ +public abstract class CropImageView extends ThemedImageView { + public static final String LOGTAG = "Gecko" + CropImageView.class.getSimpleName(); + + private int viewWidth; + private int viewHeight; + private int drawableWidth; + private int drawableHeight; + + private boolean resize = true; + private Matrix layoutCurrentMatrix = new Matrix(); + private Matrix layoutNextMatrix = new Matrix(); + + + public CropImageView(final Context context) { + this(context, null); + } + + public CropImageView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public CropImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + protected abstract float getAspectRatio(); + + protected void init() { + // Setting the pivots means that the image will be drawn from the top left hand corner. There are + // issues in Android 4.1 (16) which mean setting these values to 0 may not work. + // http://stackoverflow.com/questions/26658124/setpivotx-doesnt-work-on-android-4-1-1-nineoldandroids + setPivotX(1); + setPivotY(1); + } + + /** + * Measure the view to determine the measured width and height. + * The height is constrained by the measured width. + * + * @param widthMeasureSpec horizontal space requirements as imposed by the parent. + * @param heightMeasureSpec vertical space requirements as imposed by the parent, but ignored. + */ + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // Default measuring. + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + // Force the height based on the aspect ratio. + viewWidth = getMeasuredWidth(); + viewHeight = (int) (viewWidth * getAspectRatio()); + + setMeasuredDimension(viewWidth, viewHeight); + + updateImageMatrix(); + } + + protected void updateImageMatrix() { + if (!resize || getDrawable() == null) { + return; + } + + setScaleType(ImageView.ScaleType.MATRIX); + + getDrawable().setBounds(0, 0, viewWidth, viewHeight); + + final float horizontalScaleValue = (float) viewWidth / (float) drawableWidth; + final float verticalScaleValue = (float) viewHeight / (float) drawableHeight; + + final float scale = Math.max(verticalScaleValue, horizontalScaleValue); + + layoutNextMatrix.reset(); + layoutNextMatrix.setScale(scale, scale); + setImageMatrix(layoutNextMatrix); + + // You can't modify the matrix in place and we want to avoid allocation, so let's keep two references to two + // different matrix objects that we can swap when the values need to change + final Matrix swapReferenceMatrix = layoutCurrentMatrix; + layoutCurrentMatrix = layoutNextMatrix; + layoutNextMatrix = swapReferenceMatrix; + } + + public void setImageBitmap(final Bitmap bm, final boolean resize) { + super.setImageBitmap(bm); + + this.resize = resize; + updateImageMatrix(); + } + + @Override + public void setImageResource(final int resId) { + super.setImageResource(resId); + setImageMatrix(null); + resize = false; + } + + @Override + public void setImageDrawable(final Drawable drawable) { + this.setImageDrawable(drawable, false); + } + + public void setImageDrawable(final Drawable drawable, final boolean resize) { + super.setImageDrawable(drawable); + + if (drawable != null) { + // Reset the matrix to ensure that any previous changes aren't carried through. + setImageMatrix(null); + + drawableWidth = drawable.getIntrinsicWidth(); + drawableHeight = drawable.getIntrinsicHeight(); + } else { + drawableWidth = -1; + drawableHeight = -1; + } + + this.resize = resize; + + updateImageMatrix(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java b/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java new file mode 100644 index 000000000..67f1bcd1d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java @@ -0,0 +1,665 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.widget; + +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Locale; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.text.format.DateFormat; +import android.text.format.DateUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.InputMethodManager; +import android.widget.CalendarView; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.NumberPicker; + +public class DateTimePicker extends FrameLayout { + private static final boolean DEBUG = true; + private static final String LOGTAG = "GeckoDateTimePicker"; + private static final int DEFAULT_START_YEAR = 1; + private static final int DEFAULT_END_YEAR = 9999; + private static final char DATE_FORMAT_DAY = 'd'; + private static final char DATE_FORMAT_MONTH = 'M'; + private static final char DATE_FORMAT_YEAR = 'y'; + + boolean mYearEnabled = true; + boolean mMonthEnabled = true; + boolean mWeekEnabled; + boolean mDayEnabled = true; + boolean mHourEnabled = true; + boolean mMinuteEnabled = true; + boolean mIs12HourMode; + private boolean mCalendarEnabled; + + // Size of the screen in inches; + private final int mScreenWidth; + private final int mScreenHeight; + private final OnValueChangeListener mOnChangeListener; + private final LinearLayout mPickers; + private final LinearLayout mDateSpinners; + private final LinearLayout mTimeSpinners; + + final NumberPicker mDaySpinner; + final NumberPicker mMonthSpinner; + final NumberPicker mWeekSpinner; + final NumberPicker mYearSpinner; + final NumberPicker mHourSpinner; + final NumberPicker mMinuteSpinner; + final NumberPicker mAMPMSpinner; + private final CalendarView mCalendar; + private final EditText mDaySpinnerInput; + private final EditText mMonthSpinnerInput; + private final EditText mWeekSpinnerInput; + private final EditText mYearSpinnerInput; + private final EditText mHourSpinnerInput; + private final EditText mMinuteSpinnerInput; + private final EditText mAMPMSpinnerInput; + private Locale mCurrentLocale; + private String[] mShortMonths; + private String[] mShortAMPMs; + private int mNumberOfMonths; + + Calendar mTempDate; + Calendar mCurrentDate; + private Calendar mMinDate; + private Calendar mMaxDate; + private final PickersState mState; + + public static enum PickersState { DATE, MONTH, WEEK, TIME, DATETIME }; + + public class OnValueChangeListener implements NumberPicker.OnValueChangeListener { + @Override + public void onValueChange(NumberPicker picker, int oldVal, int newVal) { + updateInputState(); + mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis()); + if (DEBUG) { + Log.d(LOGTAG, "SDK version > 10, using new behavior"); + } + + // The native date picker widget on these SDKs increments + // the next field when one field reaches the maximum. + if (picker == mDaySpinner && mDayEnabled) { + int maxDayOfMonth = mTempDate.getActualMaximum(Calendar.DAY_OF_MONTH); + int old = mTempDate.get(Calendar.DAY_OF_MONTH); + setTempDate(Calendar.DAY_OF_MONTH, old, newVal, 1, maxDayOfMonth); + } else if (picker == mMonthSpinner && mMonthEnabled) { + int old = mTempDate.get(Calendar.MONTH); + setTempDate(Calendar.MONTH, old, newVal, Calendar.JANUARY, Calendar.DECEMBER); + } else if (picker == mWeekSpinner) { + int old = mTempDate.get(Calendar.WEEK_OF_YEAR); + int maxWeekOfYear = mTempDate.getActualMaximum(Calendar.WEEK_OF_YEAR); + setTempDate(Calendar.WEEK_OF_YEAR, old, newVal, 0, maxWeekOfYear); + } else if (picker == mYearSpinner && mYearEnabled) { + int month = mTempDate.get(Calendar.MONTH); + mTempDate.set(Calendar.YEAR, newVal); + // Changing the year shouldn't change the month. (in case of non-leap year a Feb 29) + // change the day instead; + if (month != mTempDate.get(Calendar.MONTH)) { + mTempDate.set(Calendar.MONTH, month); + mTempDate.set(Calendar.DAY_OF_MONTH, + mTempDate.getActualMaximum(Calendar.DAY_OF_MONTH)); + } + } else if (picker == mHourSpinner && mHourEnabled) { + if (mIs12HourMode) { + setTempDate(Calendar.HOUR, oldVal, newVal, 1, 12); + } else { + setTempDate(Calendar.HOUR_OF_DAY, oldVal, newVal, 0, 23); + } + } else if (picker == mMinuteSpinner && mMinuteEnabled) { + setTempDate(Calendar.MINUTE, oldVal, newVal, 0, 59); + } else if (picker == mAMPMSpinner && mHourEnabled) { + mTempDate.set(Calendar.AM_PM, newVal); + } else { + throw new IllegalArgumentException(); + } + setDate(mTempDate); + if (mDayEnabled) { + mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH)); + } + if (mWeekEnabled) { + mWeekSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.WEEK_OF_YEAR)); + } + updateCalendar(); + updateSpinners(); + notifyDateChanged(); + } + + private void setTempDate(int field, int oldVal, int newVal, int min, int max) { + if (oldVal == max && newVal == min) { + mTempDate.add(field, 1); + } else if (oldVal == min && newVal == max) { + mTempDate.add(field, -1); + } else { + mTempDate.add(field, newVal - oldVal); + } + } + } + + private static final NumberPicker.Formatter TWO_DIGIT_FORMATTER = new NumberPicker.Formatter() { + final StringBuilder mBuilder = new StringBuilder(); + + final java.util.Formatter mFmt = new java.util.Formatter(mBuilder, java.util.Locale.US); + + final Object[] mArgs = new Object[1]; + + @Override + public String format(int value) { + mArgs[0] = value; + mBuilder.delete(0, mBuilder.length()); + mFmt.format("%02d", mArgs); + return mFmt.toString(); + } + }; + + private void displayPickers() { + setWeekShown(false); + set12HourShown(mIs12HourMode); + if (mState == PickersState.DATETIME) { + return; + } + + setHourShown(false); + setMinuteShown(false); + if (mState == PickersState.WEEK) { + setDayShown(false); + setMonthShown(false); + setWeekShown(true); + } else if (mState == PickersState.MONTH) { + setDayShown(false); + } + } + + public DateTimePicker(Context context) { + this(context, "", "", PickersState.DATE, null, null); + } + + public DateTimePicker(Context context, String dateFormat, String dateTimeValue, PickersState state, String minDateValue, String maxDateValue) { + super(context); + + setCurrentLocale(Locale.getDefault()); + + mState = state; + LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.datetime_picker, this, true); + + mOnChangeListener = new OnValueChangeListener(); + + mDateSpinners = (LinearLayout)findViewById(R.id.date_spinners); + mTimeSpinners = (LinearLayout)findViewById(R.id.time_spinners); + mPickers = (LinearLayout)findViewById(R.id.datetime_picker); + + // We will display differently according to the screen size width. + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + DisplayMetrics dm = new DisplayMetrics(); + display.getMetrics(dm); + mScreenWidth = display.getWidth() / dm.densityDpi; + mScreenHeight = display.getHeight() / dm.densityDpi; + + if (DEBUG) { + Log.d(LOGTAG, "screen width: " + mScreenWidth + " screen height: " + mScreenHeight); + } + + // Set the min / max attribute. + try { + if (minDateValue != null && !minDateValue.equals("")) { + mMinDate.setTime(new SimpleDateFormat(dateFormat).parse(minDateValue)); + } else { + mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1); + } + } catch (Exception ex) { + Log.e(LOGTAG, "Error parsing format sting: " + ex); + mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1); + } + + try { + if (maxDateValue != null && !maxDateValue.equals("")) { + mMaxDate.setTime(new SimpleDateFormat(dateFormat).parse(maxDateValue)); + } else { + mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31); + } + } catch (Exception ex) { + Log.e(LOGTAG, "Error parsing format string: " + ex); + mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31); + } + + // Find the initial date from the constructor arguments. + try { + if (!dateTimeValue.equals("")) { + mTempDate.setTime(new SimpleDateFormat(dateFormat).parse(dateTimeValue)); + } else { + mTempDate.setTimeInMillis(System.currentTimeMillis()); + } + } catch (Exception ex) { + Log.e(LOGTAG, "Error parsing format string: " + ex); + mTempDate.setTimeInMillis(System.currentTimeMillis()); + } + + if (mMaxDate.before(mMinDate)) { + // If the input date range is illogical/garbage, we should not restrict the input range (i.e. allow the + // user to select any date). If we try to make any assumptions based on the illogical min/max date we could + // potentially prevent the user from selecting dates that are in the developers intended range, so it's best + // to allow anything. + mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1); + mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31); + } + + // mTempDate will either be a site-supplied value, or today's date otherwise. CalendarView implementations can + // crash if they're supplied an invalid date (i.e. a date not in the specified range), hence we need to set + // a sensible default date here. + if (mTempDate.before(mMinDate) || mTempDate.after(mMaxDate)) { + mTempDate.setTimeInMillis(mMinDate.getTimeInMillis()); + } + + // If we're displaying a date, the screen is wide enough + // (and if we're using an SDK where the calendar view exists) + // then display a calendar. + if (mState == PickersState.DATE || mState == PickersState.DATETIME) { + mCalendar = new CalendarView(context); + mCalendar.setVisibility(GONE); + + mCalendar.setFocusable(true); + mCalendar.setFocusableInTouchMode(true); + mCalendar.setMaxDate(mMaxDate.getTimeInMillis()); + mCalendar.setMinDate(mMinDate.getTimeInMillis()); + mCalendar.setDate(mTempDate.getTimeInMillis(), false, false); + + mCalendar.setOnDateChangeListener(new CalendarView.OnDateChangeListener() { + @Override + public void onSelectedDayChange( + CalendarView view, int year, int month, int monthDay) { + mTempDate.set(year, month, monthDay); + setDate(mTempDate); + notifyDateChanged(); + } + }); + + final int height; + if (Versions.preLollipop) { + // The 4.X version of CalendarView doesn't request any height, resulting in + // the whole dialog not appearing unless we manually request height. + height = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200, getResources().getDisplayMetrics());; + } else { + height = LayoutParams.WRAP_CONTENT; + } + + mPickers.addView(mCalendar, LayoutParams.MATCH_PARENT, height); + + } else { + // If the screen is more wide than high, we are displaying day and + // time spinners, and if there is no calendar displayed, we should + // display the fields in one row. + if (mScreenWidth > mScreenHeight && mState == PickersState.DATETIME) { + mPickers.setOrientation(LinearLayout.HORIZONTAL); + } + mCalendar = null; + } + + // Initialize all spinners. + mDaySpinner = setupSpinner(R.id.day, 1, + mTempDate.get(Calendar.DAY_OF_MONTH)); + mDaySpinner.setFormatter(TWO_DIGIT_FORMATTER); + mDaySpinnerInput = (EditText) mDaySpinner.getChildAt(1); + + mMonthSpinner = setupSpinner(R.id.month, 1, + mTempDate.get(Calendar.MONTH) + 1); // Month is 0-based + mMonthSpinner.setFormatter(TWO_DIGIT_FORMATTER); + mMonthSpinner.setDisplayedValues(mShortMonths); + mMonthSpinnerInput = (EditText) mMonthSpinner.getChildAt(1); + + mWeekSpinner = setupSpinner(R.id.week, 1, + mTempDate.get(Calendar.WEEK_OF_YEAR)); + mWeekSpinner.setFormatter(TWO_DIGIT_FORMATTER); + mWeekSpinnerInput = (EditText) mWeekSpinner.getChildAt(1); + + mYearSpinner = setupSpinner(R.id.year, DEFAULT_START_YEAR, + DEFAULT_END_YEAR); + mYearSpinnerInput = (EditText) mYearSpinner.getChildAt(1); + + mAMPMSpinner = setupSpinner(R.id.ampm, 0, 1); + mAMPMSpinner.setFormatter(TWO_DIGIT_FORMATTER); + + if (mIs12HourMode) { + mHourSpinner = setupSpinner(R.id.hour, 1, 12); + mAMPMSpinnerInput = (EditText) mAMPMSpinner.getChildAt(1); + mAMPMSpinner.setDisplayedValues(mShortAMPMs); + } else { + mHourSpinner = setupSpinner(R.id.hour, 0, 23); + mAMPMSpinnerInput = null; + } + + mHourSpinner.setFormatter(TWO_DIGIT_FORMATTER); + mHourSpinnerInput = (EditText) mHourSpinner.getChildAt(1); + + mMinuteSpinner = setupSpinner(R.id.minute, 0, 59); + mMinuteSpinner.setFormatter(TWO_DIGIT_FORMATTER); + mMinuteSpinnerInput = (EditText) mMinuteSpinner.getChildAt(1); + + // The order in which the spinners are displayed are locale-dependent + reorderDateSpinners(); + + // Set the date to the initial date. Since this date can come from the user, + // it can fire an exception (out-of-bound date) + try { + updateDate(mTempDate); + } catch (Exception ex) { + } + + // Display only the pickers needed for the current state. + displayPickers(); + } + + public NumberPicker setupSpinner(int id, int min, int max) { + NumberPicker mSpinner = (NumberPicker) findViewById(id); + mSpinner.setMinValue(min); + mSpinner.setMaxValue(max); + mSpinner.setOnValueChangedListener(mOnChangeListener); + mSpinner.setOnLongPressUpdateInterval(100); + return mSpinner; + } + + public long getTimeInMillis() { + return mCurrentDate.getTimeInMillis(); + } + + private void reorderDateSpinners() { + mDateSpinners.removeAllViews(); + char[] order = DateFormat.getDateFormatOrder(getContext()); + final int spinnerCount = order.length; + + for (int i = 0; i < spinnerCount; i++) { + switch (order[i]) { + case DATE_FORMAT_DAY: + mDateSpinners.addView(mDaySpinner); + break; + case DATE_FORMAT_MONTH: + mDateSpinners.addView(mMonthSpinner); + break; + case DATE_FORMAT_YEAR: + mDateSpinners.addView(mYearSpinner); + break; + default: + throw new IllegalArgumentException(); + } + } + + mDateSpinners.addView(mWeekSpinner); + } + + void setDate(Calendar calendar) { + mCurrentDate = mTempDate; + if (mCurrentDate.before(mMinDate)) { + mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis()); + } else if (mCurrentDate.after(mMaxDate)) { + mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis()); + } + } + + void updateInputState() { + InputMethodManager inputMethodManager = (InputMethodManager) + getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (mYearEnabled && inputMethodManager.isActive(mYearSpinnerInput)) { + mYearSpinnerInput.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } else if (mMonthEnabled && inputMethodManager.isActive(mMonthSpinnerInput)) { + mMonthSpinnerInput.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } else if (mDayEnabled && inputMethodManager.isActive(mDaySpinnerInput)) { + mDaySpinnerInput.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } else if (mHourEnabled && inputMethodManager.isActive(mHourSpinnerInput)) { + mHourSpinnerInput.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } else if (mMinuteEnabled && inputMethodManager.isActive(mMinuteSpinnerInput)) { + mMinuteSpinnerInput.clearFocus(); + inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } + } + + void updateSpinners() { + if (mDayEnabled) { + if (mCurrentDate.equals(mMinDate)) { + mDaySpinner.setMinValue(mCurrentDate.get(Calendar.DAY_OF_MONTH)); + mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH)); + } else if (mCurrentDate.equals(mMaxDate)) { + mDaySpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.DAY_OF_MONTH)); + mDaySpinner.setMaxValue(mCurrentDate.get(Calendar.DAY_OF_MONTH)); + } else { + mDaySpinner.setMinValue(1); + mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH)); + } + mDaySpinner.setValue(mCurrentDate.get(Calendar.DAY_OF_MONTH)); + } + + if (mWeekEnabled) { + mWeekSpinner.setMinValue(1); + mWeekSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.WEEK_OF_YEAR)); + mWeekSpinner.setValue(mCurrentDate.get(Calendar.WEEK_OF_YEAR)); + } + + if (mMonthEnabled) { + mMonthSpinner.setDisplayedValues(null); + if (mCurrentDate.equals(mMinDate)) { + mMonthSpinner.setMinValue(mCurrentDate.get(Calendar.MONTH)); + mMonthSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.MONTH)); + } else if (mCurrentDate.equals(mMaxDate)) { + mMonthSpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.MONTH)); + mMonthSpinner.setMaxValue(mCurrentDate.get(Calendar.MONTH)); + } else { + mMonthSpinner.setMinValue(Calendar.JANUARY); + mMonthSpinner.setMaxValue(Calendar.DECEMBER); + } + + String[] displayedValues = Arrays.copyOfRange(mShortMonths, + mMonthSpinner.getMinValue(), mMonthSpinner.getMaxValue() + 1); + mMonthSpinner.setDisplayedValues(displayedValues); + mMonthSpinner.setValue(mCurrentDate.get(Calendar.MONTH)); + } + + if (mYearEnabled) { + mYearSpinner.setMinValue(mMinDate.get(Calendar.YEAR)); + mYearSpinner.setMaxValue(mMaxDate.get(Calendar.YEAR)); + mYearSpinner.setValue(mCurrentDate.get(Calendar.YEAR)); + } + + if (mHourEnabled) { + if (mIs12HourMode) { + mHourSpinner.setValue(mCurrentDate.get(Calendar.HOUR)); + mAMPMSpinner.setValue(mCurrentDate.get(Calendar.AM_PM)); + mAMPMSpinner.setDisplayedValues(mShortAMPMs); + } else { + mHourSpinner.setValue(mCurrentDate.get(Calendar.HOUR_OF_DAY)); + } + } + if (mMinuteEnabled) { + mMinuteSpinner.setValue(mCurrentDate.get(Calendar.MINUTE)); + } + } + + void updateCalendar() { + if (mCalendarEnabled) { + mCalendar.setDate(mCurrentDate.getTimeInMillis(), false, false); + } + } + + void notifyDateChanged() { + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + } + + public void toggleCalendar(boolean shown) { + if ((mState != PickersState.DATE && mState != PickersState.DATETIME)) { + return; + } + + if (shown) { + mCalendarEnabled = true; + mCalendar.setVisibility(VISIBLE); + setYearShown(false); + setWeekShown(false); + setMonthShown(false); + setDayShown(false); + } else { + mCalendar.setVisibility(GONE); + setYearShown(true); + setMonthShown(true); + setDayShown(true); + mPickers.setOrientation(LinearLayout.HORIZONTAL); + mCalendarEnabled = false; + } + } + + private void setYearShown(boolean shown) { + if (shown) { + toggleCalendar(false); + mYearSpinner.setVisibility(VISIBLE); + mYearEnabled = true; + } else { + mYearSpinner.setVisibility(GONE); + mYearEnabled = false; + } + } + + private void setWeekShown(boolean shown) { + if (shown) { + toggleCalendar(false); + mWeekSpinner.setVisibility(VISIBLE); + mWeekEnabled = true; + } else { + mWeekSpinner.setVisibility(GONE); + mWeekEnabled = false; + } + } + + private void setMonthShown(boolean shown) { + if (shown) { + toggleCalendar(false); + mMonthSpinner.setVisibility(VISIBLE); + mMonthEnabled = true; + } else { + mMonthSpinner.setVisibility(GONE); + mMonthEnabled = false; + } + } + + private void setDayShown(boolean shown) { + if (shown) { + toggleCalendar(false); + mDaySpinner.setVisibility(VISIBLE); + mDayEnabled = true; + } else { + mDaySpinner.setVisibility(GONE); + mDayEnabled = false; + } + } + + private void set12HourShown(boolean shown) { + if (shown) { + mAMPMSpinner.setVisibility(VISIBLE); + } else { + mAMPMSpinner.setVisibility(GONE); + } + } + + private void setHourShown(boolean shown) { + if (shown) { + mHourSpinner.setVisibility(VISIBLE); + mHourEnabled = true; + } else { + mHourSpinner.setVisibility(GONE); + mAMPMSpinner.setVisibility(GONE); + mTimeSpinners.setVisibility(GONE); + mHourEnabled = false; + } + } + + private void setMinuteShown(boolean shown) { + if (shown) { + mMinuteSpinner.setVisibility(VISIBLE); + mTimeSpinners.findViewById(R.id.mincolon).setVisibility(VISIBLE); + mMinuteEnabled = true; + } else { + mMinuteSpinner.setVisibility(GONE); + mTimeSpinners.findViewById(R.id.mincolon).setVisibility(GONE); + mMinuteEnabled = false; + } + } + + private void setCurrentLocale(Locale locale) { + if (locale.equals(mCurrentLocale)) { + return; + } + + mCurrentLocale = locale; + mIs12HourMode = !DateFormat.is24HourFormat(getContext()); + mTempDate = getCalendarForLocale(mTempDate, locale); + mMinDate = getCalendarForLocale(mMinDate, locale); + mMaxDate = getCalendarForLocale(mMaxDate, locale); + mCurrentDate = getCalendarForLocale(mCurrentDate, locale); + + mNumberOfMonths = mTempDate.getActualMaximum(Calendar.MONTH) + 1; + + mShortAMPMs = new String[2]; + mShortAMPMs[0] = DateUtils.getAMPMString(Calendar.AM); + mShortAMPMs[1] = DateUtils.getAMPMString(Calendar.PM); + + mShortMonths = new String[mNumberOfMonths]; + for (int i = 0; i < mNumberOfMonths; i++) { + mShortMonths[i] = DateUtils.getMonthString(Calendar.JANUARY + i, + DateUtils.LENGTH_MEDIUM); + } + } + + private Calendar getCalendarForLocale(Calendar oldCalendar, Locale locale) { + if (oldCalendar == null) { + return Calendar.getInstance(locale); + } + + final long currentTimeMillis = oldCalendar.getTimeInMillis(); + Calendar newCalendar = Calendar.getInstance(locale); + newCalendar.setTimeInMillis(currentTimeMillis); + return newCalendar; + } + + public void updateDate(Calendar calendar) { + if (mCurrentDate.equals(calendar)) { + return; + } + mCurrentDate.setTimeInMillis(calendar.getTimeInMillis()); + if (mCurrentDate.before(mMinDate)) { + mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis()); + } else if (mCurrentDate.after(mMaxDate)) { + mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis()); + } + updateSpinners(); + notifyDateChanged(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java new file mode 100644 index 000000000..cb8716af7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java @@ -0,0 +1,190 @@ +/* -*- 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.widget; + +import android.support.v4.content.ContextCompat; +import android.text.Html; +import android.text.Spanned; +import android.util.Log; +import android.widget.Button; +import android.widget.TextView; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.prompts.PromptInput; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class DefaultDoorHanger extends DoorHanger { + private static final String LOGTAG = "GeckoDefaultDoorHanger"; + + private static int sSpinnerTextColor = -1; + + private final TextView mMessage; + private List<PromptInput> mInputs; + private CheckBox mCheckBox; + + public DefaultDoorHanger(Context context, DoorhangerConfig config, Type type) { + super(context, config, type); + + mMessage = (TextView) findViewById(R.id.doorhanger_message); + + if (sSpinnerTextColor == -1) { + sSpinnerTextColor = ContextCompat.getColor(context, R.color.text_color_primary_disable_only); + } + + switch (mType) { + case GEOLOCATION: + mIcon.setImageResource(R.drawable.location); + mIcon.setVisibility(VISIBLE); + break; + + case DESKTOPNOTIFICATION2: + mIcon.setImageResource(R.drawable.push_notification); + mIcon.setVisibility(VISIBLE); + break; + } + + loadConfig(config); + } + + @Override + protected void loadConfig(DoorhangerConfig config) { + final String message = config.getMessage(); + if (message != null) { + setMessage(message); + } + + final JSONObject options = config.getOptions(); + if (options != null) { + setOptions(options); + } + + final DoorhangerConfig.Link link = config.getLink(); + if (link != null) { + addLink(link.label, link.url); + } + + addButtonsToLayout(config); + } + + @Override + protected int getContentResource() { + return R.layout.default_doorhanger; + } + + private List<PromptInput> getInputs() { + return mInputs; + } + + private CheckBox getCheckBox() { + return mCheckBox; + } + + @Override + public void setOptions(final JSONObject options) { + super.setOptions(options); + + final JSONArray inputs = options.optJSONArray("inputs"); + if (inputs != null) { + mInputs = new ArrayList<PromptInput>(); + + final ViewGroup group = (ViewGroup) findViewById(R.id.doorhanger_inputs); + group.setVisibility(VISIBLE); + + for (int i = 0; i < inputs.length(); i++) { + try { + PromptInput input = PromptInput.getInput(inputs.getJSONObject(i)); + mInputs.add(input); + + final int padding = mResources.getDimensionPixelSize(R.dimen.doorhanger_section_padding_medium); + View v = input.getView(getContext()); + styleInput(input, v); + v.setPadding(0, 0, 0, padding); + group.addView(v); + } catch (JSONException ex) { } + } + } + + final String checkBoxText = options.optString("checkbox"); + if (!TextUtils.isEmpty(checkBoxText)) { + mCheckBox = (CheckBox) findViewById(R.id.doorhanger_checkbox); + mCheckBox.setText(checkBoxText); + if (options.has("checkboxState")) { + final boolean checkBoxState = options.optBoolean("checkboxState"); + mCheckBox.setChecked(checkBoxState); + } + mCheckBox.setVisibility(VISIBLE); + } + } + + @Override + protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) { + return new Button.OnClickListener() { + @Override + public void onClick(View v) { + final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra; + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra); + + final JSONObject response = new JSONObject(); + try { + response.put("callback", id); + + CheckBox checkBox = getCheckBox(); + // If the checkbox is being used, pass its value + if (checkBox != null) { + response.put("checked", checkBox.isChecked()); + } + + List<PromptInput> doorHangerInputs = getInputs(); + if (doorHangerInputs != null) { + JSONObject inputs = new JSONObject(); + for (PromptInput input : doorHangerInputs) { + inputs.put(input.getId(), input.getValue()); + } + response.put("inputs", inputs); + } + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating onClick response", e); + } + + mOnButtonClickListener.onButtonClick(response, DefaultDoorHanger.this); + } + }; + } + + private void setMessage(String message) { + Spanned markupMessage = Html.fromHtml(message); + mMessage.setText(markupMessage); + } + + private void styleInput(PromptInput input, View view) { + if (input instanceof PromptInput.MenulistInput) { + styleDropdownInputs(input, view); + } + view.setPadding(0, 0, 0, mResources.getDimensionPixelSize(R.dimen.doorhanger_subsection_padding)); + } + + private void styleDropdownInputs(PromptInput input, View view) { + PromptInput.MenulistInput spinInput = (PromptInput.MenulistInput) input; + + if (spinInput.textView != null) { + spinInput.textView.setTextColor(sSpinnerTextColor); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java new file mode 100644 index 000000000..5beec3a5c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java @@ -0,0 +1,685 @@ +/* -*- 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.widget; + +import android.support.annotation.NonNull; +import android.support.v4.animation.AnimatorCompatHelper; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewPropertyAnimatorCompat; +import android.support.v4.view.ViewPropertyAnimatorListener; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.SimpleItemAnimator; +import android.view.View; + +import java.util.ArrayList; +import java.util.List; + +/** + * This basically follows the approach taken by Wasabeef: + * <a href="https://github.com/wasabeef/recyclerview-animators">https://github.com/wasabeef/recyclerview-animators</a> + * based off of Android's DefaultItemAnimator from October 2016: + * <a href="https://github.com/android/platform_frameworks_support/blob/432f3317f8a9b8cf98277938ea5df4021e983055/v7/recyclerview/src/android/support/v7/widget/DefaultItemAnimator.java"> + * https://github.com/android/platform_frameworks_support/blob/432f3317f8a9b8cf98277938ea5df4021e983055/v7/recyclerview/src/android/support/v7/widget/DefaultItemAnimator.java + * </a> + * <p> + * Usage Notes: + * </p> + * <ul> + * <li>You <strong>must</strong> add a Default*VpaListener to your animate*Impl animation - the + * listener takes care of animation bookkeeping.</li> + * <li>You should call {@link #resetAnimation(RecyclerView.ViewHolder)} at some point in + * preAnimate*Impl if you choose to proceed with the animation. Some animations will want to + * know some or all of the current animation values for initializing their own animation + * values before resetting the current animation, so this class does not provide the reset + * service itself.</li> + * <li>{@link #resetViewProperties(View)} is used to reset a view any time an animation ends or + * gets canceled - you should redefine resetViewProperties if the version here doesn't reset + * all of the properties you're animating.</li> + * </ul> + */ +public class DefaultItemAnimatorBase extends SimpleItemAnimator { + private List<RecyclerView.ViewHolder> pendingRemovals = new ArrayList<>(); + private List<RecyclerView.ViewHolder> pendingAdditions = new ArrayList<>(); + private List<MoveInfo> pendingMoves = new ArrayList<>(); + private List<ChangeInfo> pendingChanges = new ArrayList<>(); + + private List<List<RecyclerView.ViewHolder>> additionsList = new ArrayList<>(); + private List<List<MoveInfo>> movesList = new ArrayList<>(); + private List<List<ChangeInfo>> changesList = new ArrayList<>(); + + private List<RecyclerView.ViewHolder> addAnimations = new ArrayList<>(); + private List<RecyclerView.ViewHolder> moveAnimations = new ArrayList<>(); + private List<RecyclerView.ViewHolder> removeAnimations = new ArrayList<>(); + private List<RecyclerView.ViewHolder> changeAnimations = new ArrayList<>(); + + protected static class MoveInfo { + public RecyclerView.ViewHolder holder; + public int fromX, fromY, toX, toY; + + public MoveInfo(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { + this.holder = holder; + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + } + + protected static class ChangeInfo { + public RecyclerView.ViewHolder oldHolder, newHolder; + public int fromX, fromY, toX, toY; + + public ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder) { + this.oldHolder = oldHolder; + this.newHolder = newHolder; + } + + public ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + this(oldHolder, newHolder); + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + + @Override + public String toString() { + return "ChangeInfo{" + + "oldHolder=" + oldHolder + + ", newHolder=" + newHolder + + ", fromX=" + fromX + + ", fromY=" + fromY + + ", toX=" + toX + + ", toY=" + toY + + '}'; + } + } + + @Override + public void runPendingAnimations() { + final boolean removalsPending = !pendingRemovals.isEmpty(); + final boolean movesPending = !pendingMoves.isEmpty(); + final boolean changesPending = !pendingChanges.isEmpty(); + final boolean additionsPending = !pendingAdditions.isEmpty(); + if (!removalsPending && !movesPending && !additionsPending && !changesPending) { + return; + } + // First, remove stuff. + for (final RecyclerView.ViewHolder holder : pendingRemovals) { + animateRemoveImpl(holder); + } + pendingRemovals.clear(); + // Next, move stuff. + if (movesPending) { + final List<MoveInfo> moves = new ArrayList<>(); + moves.addAll(pendingMoves); + movesList.add(moves); + pendingMoves.clear(); + final Runnable mover = new Runnable() { + @Override + public void run() { + for (final MoveInfo moveInfo : moves) { + animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY, + moveInfo.toX, moveInfo.toY); + } + moves.clear(); + movesList.remove(moves); + } + }; + if (removalsPending) { + final View view = moves.get(0).holder.itemView; + ViewCompat.postOnAnimationDelayed(view, mover, getRemoveDuration()); + } else { + mover.run(); + } + } + // Next, change stuff, to run in parallel with move animations. + if (changesPending) { + final List<ChangeInfo> changes = new ArrayList<>(); + changes.addAll(pendingChanges); + changesList.add(changes); + pendingChanges.clear(); + final Runnable changer = new Runnable() { + @Override + public void run() { + for (final ChangeInfo change : changes) { + animateChangeImpl(change); + } + changes.clear(); + changesList.remove(changes); + } + }; + if (removalsPending) { + RecyclerView.ViewHolder holder = changes.get(0).oldHolder; + ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDuration()); + } else { + changer.run(); + } + } + // Next, add stuff. + if (additionsPending) { + final List<RecyclerView.ViewHolder> additions = new ArrayList<>(); + additions.addAll(pendingAdditions); + additionsList.add(additions); + pendingAdditions.clear(); + final Runnable adder = new Runnable() { + public void run() { + for (final RecyclerView.ViewHolder holder : additions) { + animateAddImpl(holder); + } + additions.clear(); + additionsList.remove(additions); + } + }; + if (removalsPending || movesPending || changesPending) { + final long removeDuration = removalsPending ? getRemoveDuration() : 0; + final long moveDuration = movesPending ? getMoveDuration() : 0; + final long changeDuration = changesPending ? getChangeDuration() : 0; + final long totalDelay = removeDuration + Math.max(moveDuration, changeDuration); + final View view = additions.get(0).itemView; + ViewCompat.postOnAnimationDelayed(view, adder, totalDelay); + } else { + adder.run(); + } + } + } + + @Override + public boolean animateRemove(final RecyclerView.ViewHolder holder) { + if (!preAnimateRemoveImpl(holder)) { + dispatchRemoveFinished(holder); + return false; + } + pendingRemovals.add(holder); + return true; + } + + protected boolean preAnimateRemoveImpl(final RecyclerView.ViewHolder holder) { + resetAnimation(holder); + return true; + } + + protected void animateRemoveImpl(final RecyclerView.ViewHolder holder) { + ViewCompat.animate(holder.itemView) + .setDuration(getRemoveDuration()) + .alpha(0) + .setListener(new DefaultRemoveVpaListener(holder)) + .start(); + } + + @Override + public boolean animateAdd(final RecyclerView.ViewHolder holder) { + if (!preAnimateAddImpl(holder)) { + dispatchAddFinished(holder); + return false; + } + pendingAdditions.add(holder); + return true; + } + + protected boolean preAnimateAddImpl(RecyclerView.ViewHolder holder) { + resetAnimation(holder); + holder.itemView.setAlpha(0); + return true; + } + + protected void animateAddImpl(final RecyclerView.ViewHolder holder) { + ViewCompat.animate(holder.itemView) + .setDuration(getAddDuration()) + .alpha(1) + .setListener(new DefaultAddVpaListener(holder)) + .start(); + } + + @Override + public boolean animateMove(final RecyclerView.ViewHolder holder, + int fromX, int fromY, int toX, int toY) { + final View view = holder.itemView; + fromX += ViewCompat.getTranslationX(holder.itemView); + fromY += ViewCompat.getTranslationY(holder.itemView); + final int deltaX = toX - fromX; + final int deltaY = toY - fromY; + if (deltaX == 0 && deltaY == 0) { + dispatchMoveFinished(holder); + return false; + } + resetAnimation(holder); + if (deltaX != 0) { + ViewCompat.setTranslationX(view, -deltaX); + } + if (deltaY != 0) { + ViewCompat.setTranslationY(view, -deltaY); + } + pendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY)); + return true; + } + + protected void animateMoveImpl(final RecyclerView.ViewHolder holder, + int fromX, int fromY, int toX, int toY) { + final View view = holder.itemView; + final int deltaX = toX - fromX; + final int deltaY = toY - fromY; + if (deltaX != 0) { + ViewCompat.animate(view).translationX(0); + } + if (deltaY != 0) { + ViewCompat.animate(view).translationY(0); + } + // TODO: make EndActions end listeners instead, since end actions aren't called when + // vpas are canceled (and can't end them. why?) + // need listener functionality in VPACompat for this. Ick. + final ViewPropertyAnimatorCompat animation = ViewCompat.animate(view); + moveAnimations.add(holder); + animation.setDuration(getMoveDuration()).setListener(new VpaListenerAdapter() { + @Override + public void onAnimationStart(View view) { + dispatchMoveStarting(holder); + } + @Override + public void onAnimationCancel(View view) { + resetViewProperties(view); + } + @Override + public void onAnimationEnd(View view) { + animation.setListener(null); + dispatchMoveFinished(holder); + moveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + @Override + public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + if (oldHolder == newHolder) { + // Don't know how to run change animations when the same view holder is re-used. + // Run a move animation to handle position changes (if there are any). + if (fromX != toX || fromY != toY) { + // *Don't* call dispatchChangeFinished here, it leads to unbalanced isRecyclable calls. + return animateMove(oldHolder, fromX, fromY, toX, toY); + } + dispatchChangeFinished(oldHolder, true); + return false; + } + final float prevTranslationX = ViewCompat.getTranslationX(oldHolder.itemView); + final float prevTranslationY = ViewCompat.getTranslationY(oldHolder.itemView); + final float prevAlpha = ViewCompat.getAlpha(oldHolder.itemView); + resetAnimation(oldHolder); + final int deltaX = (int) (toX - fromX - prevTranslationX); + final int deltaY = (int) (toY - fromY - prevTranslationY); + // Recover previous translation state after ending animation. + ViewCompat.setTranslationX(oldHolder.itemView, prevTranslationX); + ViewCompat.setTranslationY(oldHolder.itemView, prevTranslationY); + ViewCompat.setAlpha(oldHolder.itemView, prevAlpha); + if (newHolder != null) { + // Carry over translation values. + resetAnimation(newHolder); + ViewCompat.setTranslationX(newHolder.itemView, -deltaX); + ViewCompat.setTranslationY(newHolder.itemView, -deltaY); + ViewCompat.setAlpha(newHolder.itemView, 0); + } + pendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); + return true; + } + + protected void animateChangeImpl(final ChangeInfo changeInfo) { + final RecyclerView.ViewHolder holder = changeInfo.oldHolder; + final View view = holder == null ? null : holder.itemView; + final RecyclerView.ViewHolder newHolder = changeInfo.newHolder; + final View newView = newHolder != null ? newHolder.itemView : null; + if (view != null) { + final ViewPropertyAnimatorCompat oldViewAnim = ViewCompat.animate(view).setDuration( + getChangeDuration()); + changeAnimations.add(changeInfo.oldHolder); + oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX); + oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY); + oldViewAnim.alpha(0).setListener(new VpaListenerAdapter() { + @Override + public void onAnimationStart(View view) { + dispatchChangeStarting(changeInfo.oldHolder, true); + } + + @Override + public void onAnimationEnd(View view) { + oldViewAnim.setListener(null); + resetViewProperties(view); + dispatchChangeFinished(changeInfo.oldHolder, true); + changeAnimations.remove(changeInfo.oldHolder); + dispatchFinishedWhenDone(); + } + }).start(); + } + if (newView != null) { + final ViewPropertyAnimatorCompat newViewAnimation = ViewCompat.animate(newView); + changeAnimations.add(changeInfo.newHolder); + newViewAnimation.translationX(0).translationY(0).setDuration(getChangeDuration()). + alpha(1).setListener(new VpaListenerAdapter() { + @Override + public void onAnimationStart(View view) { + dispatchChangeStarting(changeInfo.newHolder, false); + } + @Override + public void onAnimationEnd(View view) { + newViewAnimation.setListener(null); + resetViewProperties(view); + dispatchChangeFinished(changeInfo.newHolder, false); + changeAnimations.remove(changeInfo.newHolder); + dispatchFinishedWhenDone(); + } + }).start(); +} + } + + private void endChangeAnimation(List<ChangeInfo> infoList, RecyclerView.ViewHolder item) { + for (int i = infoList.size() - 1; i >= 0; i--) { + final ChangeInfo changeInfo = infoList.get(i); + if (endChangeAnimationIfNecessary(changeInfo, item)) { + if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { + infoList.remove(changeInfo); + } + } + } + } + + private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) { + if (changeInfo.oldHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder); + } + if (changeInfo.newHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder); + } + } + + private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, RecyclerView.ViewHolder item) { + boolean oldItem = false; + if (changeInfo.newHolder == item) { + changeInfo.newHolder = null; + } else if (changeInfo.oldHolder == item) { + changeInfo.oldHolder = null; + oldItem = true; + } else { + return false; + } + resetViewProperties(item.itemView); + dispatchChangeFinished(item, oldItem); + return true; + } + + /** + * Called to reset all properties possibly animated by any and all defined animations. + */ + protected void resetViewProperties(View view) { + view.setTranslationX(0); + view.setTranslationY(0); + view.setAlpha(1); + } + + @Override + public void endAnimation(RecyclerView.ViewHolder item) { + + final View view = item.itemView; + // This calls dispatch*Finished, resets view properties, and removes item from current + // animations list if the view is currently being animated. + ViewCompat.animate(view).cancel(); + // TODO if some other animations are chained to end, how do we cancel them as well? + for (int i = pendingMoves.size() - 1; i >= 0; i--) { + final MoveInfo moveInfo = pendingMoves.get(i); + if (moveInfo.holder == item) { + resetViewProperties(view); + dispatchMoveFinished(item); + pendingMoves.remove(i); + } + } + endChangeAnimation(pendingChanges, item); + if (pendingRemovals.remove(item)) { + resetViewProperties(view); + dispatchRemoveFinished(item); + } + if (pendingAdditions.remove(item)) { + resetViewProperties(view); + dispatchAddFinished(item); + } + + for (int i = changesList.size() - 1; i >= 0; i--) { + final List<ChangeInfo> changes = changesList.get(i); + endChangeAnimation(changes, item); + if (changes.isEmpty()) { + changesList.remove(i); + } + } + for (int i = movesList.size() - 1; i >= 0; i--) { + final List<MoveInfo> moves = movesList.get(i); + for (int j = moves.size() - 1; j >= 0; j--) { + final MoveInfo moveInfo = moves.get(j); + if (moveInfo.holder == item) { + resetViewProperties(view); + dispatchMoveFinished(item); + moves.remove(j); + if (moves.isEmpty()) { + movesList.remove(i); + } + break; + } + } + } + for (int i = additionsList.size() - 1; i >= 0; i--) { + final List<RecyclerView.ViewHolder> additions = additionsList.get(i); + if (additions.remove(item)) { + resetViewProperties(view); + dispatchAddFinished(item); + if (additions.isEmpty()) { + additionsList.remove(i); + } + } + } + dispatchFinishedWhenDone(); + } + + protected void resetAnimation(RecyclerView.ViewHolder holder) { + AnimatorCompatHelper.clearInterpolator(holder.itemView); + endAnimation(holder); + } + + @Override + public boolean isRunning() { + return (!pendingAdditions.isEmpty() || + !pendingChanges.isEmpty() || + !pendingMoves.isEmpty() || + !pendingRemovals.isEmpty() || + !moveAnimations.isEmpty() || + !removeAnimations.isEmpty() || + !addAnimations.isEmpty() || + !changeAnimations.isEmpty() || + !movesList.isEmpty() || + !additionsList.isEmpty() || + !changesList.isEmpty()); + } + + /** + * Check the state of currently pending and running animations. If there are none + * pending/running, call {@link #dispatchAnimationsFinished()} to notify any + * listeners. + */ + protected void dispatchFinishedWhenDone() { + if (!isRunning()) { + dispatchAnimationsFinished(); + } + } + + @Override + public void endAnimations() { + int count = pendingMoves.size(); + for (int i = count - 1; i >= 0; i--) { + final MoveInfo item = pendingMoves.get(i); + resetViewProperties(item.holder.itemView); + dispatchMoveFinished(item.holder); + pendingMoves.remove(i); + } + count = pendingRemovals.size(); + for (int i = count - 1; i >= 0; i--) { + final RecyclerView.ViewHolder item = pendingRemovals.get(i); + resetViewProperties(item.itemView); + dispatchRemoveFinished(item); + pendingRemovals.remove(i); + } + count = pendingAdditions.size(); + for (int i = count - 1; i >= 0; i--) { + final RecyclerView.ViewHolder item = pendingAdditions.get(i); + resetViewProperties(item.itemView); + dispatchAddFinished(item); + pendingAdditions.remove(i); + } + count = pendingChanges.size(); + for (int i = count - 1; i >= 0; i--) { + endChangeAnimationIfNecessary(pendingChanges.get(i)); + } + pendingChanges.clear(); + if (!isRunning()) { + return; + } + + int listCount = movesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + final List<MoveInfo> moves = movesList.get(i); + count = moves.size(); + for (int j = count - 1; j >= 0; j--) { + final MoveInfo moveInfo = moves.get(j); + final RecyclerView.ViewHolder item = moveInfo.holder; + resetViewProperties(item.itemView); + dispatchMoveFinished(item); + moves.remove(j); + if (moves.isEmpty()) { + movesList.remove(moves); + } + } + } + listCount = additionsList.size(); + for (int i = listCount - 1; i >= 0; i--) { + final List<RecyclerView.ViewHolder> additions = additionsList.get(i); + count = additions.size(); + for (int j = count - 1; j >= 0; j--) { + final RecyclerView.ViewHolder item = additions.get(j); + resetViewProperties(item.itemView); + dispatchAddFinished(item); + additions.remove(j); + if (additions.isEmpty()) { + additionsList.remove(additions); + } + } + } + listCount = changesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + final List<ChangeInfo> changes = changesList.get(i); + count = changes.size(); + for (int j = count - 1; j >= 0; j--) { + endChangeAnimationIfNecessary(changes.get(j)); + if (changes.isEmpty()) { + changesList.remove(changes); + } + } + } + + cancelAll(removeAnimations); + cancelAll(moveAnimations); + cancelAll(addAnimations); + cancelAll(changeAnimations); + + dispatchAnimationsFinished(); + } + + public void cancelAll(List<RecyclerView.ViewHolder> viewHolders) { + for (int i = viewHolders.size() - 1; i >= 0; i--) { + ViewCompat.animate(viewHolders.get(i).itemView).cancel(); + } + } + + /** + * {@inheritDoc} + * <p> + * If the payload list is not empty, DefaultItemAnimator returns <code>true</code>. + * When this is the case: + * <ul> + * <li>If you override + * {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, + * both ViewHolder arguments will be the same instance. + * </li> + * <li> + * If you are not overriding + * {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, + * then DefaultItemAnimator will call + * {@link #animateMove(RecyclerView.ViewHolder, int, int, int, int)} and run a move animation + * instead. + * </li> + * </ul> + */ + @Override + public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, + @NonNull List<Object> payloads) { + return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads); + } + + private class VpaListenerAdapter implements ViewPropertyAnimatorListener { + @Override + public void onAnimationStart(View view) {} + + // Note that onAnimationEnd is called (in addition to OnAnimationCancel) whenever an + // animation is canceled. + @Override + public void onAnimationEnd(View view) { + resetViewProperties(view); + view.animate().setListener(null); + } + + @Override + public void onAnimationCancel(View view) {} + } + + protected class DefaultRemoveVpaListener extends VpaListenerAdapter { + private final RecyclerView.ViewHolder viewHolder; + + public DefaultRemoveVpaListener(final RecyclerView.ViewHolder holder) { + viewHolder = holder; + } + + @Override + public void onAnimationStart(View view) { + removeAnimations.add(viewHolder); + dispatchRemoveStarting(viewHolder); + } + + @Override + public void onAnimationEnd(View view) { + removeAnimations.remove(viewHolder); + dispatchRemoveFinished(viewHolder); + dispatchFinishedWhenDone(); + super.onAnimationEnd(view); + } + } + + protected class DefaultAddVpaListener extends VpaListenerAdapter { + private final RecyclerView.ViewHolder viewHolder; + + public DefaultAddVpaListener(final RecyclerView.ViewHolder holder) { + viewHolder = holder; + } + + @Override + public void onAnimationStart(View view) { + addAnimations.add(viewHolder); + dispatchAddStarting(viewHolder); + } + + @Override + public void onAnimationEnd(View view) { + addAnimations.remove(viewHolder); + dispatchAddFinished(viewHolder); + dispatchFinishedWhenDone(); + super.onAnimationEnd(view); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java new file mode 100644 index 000000000..7d32278e4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java @@ -0,0 +1,220 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.support.v4.content.ContextCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewStub; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import org.json.JSONObject; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import java.util.Locale; + +public abstract class DoorHanger extends LinearLayout { + + public static DoorHanger Get(Context context, DoorhangerConfig config) { + final Type type = config.getType(); + switch (type) { + case LOGIN: + return new LoginDoorHanger(context, config); + case TRACKING: + return new ContentSecurityDoorHanger(context, config, type); + } + return new DefaultDoorHanger(context, config, type); + } + + // Doorhanger types created from Gecko are checked against enum strings to determine type. + public static enum Type { DEFAULT, LOGIN, TRACKING, GEOLOCATION, DESKTOPNOTIFICATION2, WEBRTC, VIBRATION } + + public interface OnButtonClickListener { + public void onButtonClick(JSONObject response, DoorHanger doorhanger); + } + + private static final String LOGTAG = "GeckoDoorHanger"; + + // Divider between doorhangers. + private final View mDivider; + + private final Button mNegativeButton; + private final Button mPositiveButton; + protected final OnButtonClickListener mOnButtonClickListener; + + // The tab this doorhanger is associated with. + private final int mTabId; + + // DoorHanger identifier. + private final String mIdentifier; + + protected final Type mType; + + protected final ImageView mIcon; + protected final TextView mLink; + protected final TextView mDoorhangerTitle; + + protected final Context mContext; + protected final Resources mResources; + + protected int mDividerColor; + + protected boolean mPersistWhileVisible; + protected int mPersistenceCount; + protected long mTimeout; + + protected DoorHanger(Context context, DoorhangerConfig config, Type type) { + super(context); + + mContext = context; + mResources = context.getResources(); + mTabId = config.getTabId(); + mIdentifier = config.getId(); + mType = type; + + LayoutInflater.from(context).inflate(R.layout.doorhanger, this); + setOrientation(VERTICAL); + + mDivider = findViewById(R.id.divider_doorhanger); + mIcon = (ImageView) findViewById(R.id.doorhanger_icon); + mLink = (TextView) findViewById(R.id.doorhanger_link); + mDoorhangerTitle = (TextView) findViewById(R.id.doorhanger_title); + + mNegativeButton = (Button) findViewById(R.id.doorhanger_button_negative); + mPositiveButton = (Button) findViewById(R.id.doorhanger_button_positive); + mOnButtonClickListener = config.getButtonClickListener(); + + mDividerColor = ContextCompat.getColor(context, R.color.toolbar_divider_grey); + + final ViewStub contentStub = (ViewStub) findViewById(R.id.content); + contentStub.setLayoutResource(getContentResource()); + contentStub.inflate(); + + final String typeExtra = mType.toString().toLowerCase(Locale.US); + Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.DOORHANGER, typeExtra); + } + + protected abstract int getContentResource(); + + protected abstract void loadConfig(DoorhangerConfig config); + + protected void setOptions(final JSONObject options) { + final int persistence = options.optInt("persistence"); + if (persistence > 0) { + mPersistenceCount = persistence; + } + + mPersistWhileVisible = options.optBoolean("persistWhileVisible"); + + final long timeout = options.optLong("timeout"); + if (timeout > 0) { + mTimeout = timeout; + } + } + + protected void addButtonsToLayout(DoorhangerConfig config) { + final DoorhangerConfig.ButtonConfig negativeButtonConfig = config.getNegativeButtonConfig(); + final DoorhangerConfig.ButtonConfig positiveButtonConfig = config.getPositiveButtonConfig(); + + if (negativeButtonConfig != null) { + mNegativeButton.setText(negativeButtonConfig.label); + mNegativeButton.setOnClickListener(makeOnButtonClickListener(negativeButtonConfig.callback, "negative")); + mNegativeButton.setVisibility(VISIBLE); + } + + if (positiveButtonConfig != null) { + mPositiveButton.setText(positiveButtonConfig.label); + mPositiveButton.setOnClickListener(makeOnButtonClickListener(positiveButtonConfig.callback, "positive")); + mPositiveButton.setVisibility(VISIBLE); + } + } + + public int getTabId() { + return mTabId; + } + + public String getIdentifier() { + return mIdentifier; + } + + public void showDivider() { + mDivider.setVisibility(View.VISIBLE); + } + + public void hideDivider() { + mDivider.setVisibility(View.GONE); + } + + public void setIcon(int resId) { + mIcon.setImageResource(resId); + mIcon.setVisibility(View.VISIBLE); + } + + protected void addLink(String label, final String url) { + mLink.setText(label); + mLink.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + final String typeExtra = mType.toString().toLowerCase(Locale.US); + Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.DOORHANGER, typeExtra); + Tabs.getInstance().loadUrlInTab(url); + } + }); + mLink.setVisibility(VISIBLE); + } + + protected abstract OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra); + + /* + * Checks with persistence and timeout options to see if it's okay to remove a doorhanger. + * + * @param isShowing Whether or not this doorhanger is currently visible to the user. + * (e.g. the DoorHanger view might be VISIBLE, but its parent could be hidden) + */ + public boolean shouldRemove(boolean isShowing) { + if (mPersistWhileVisible && isShowing) { + // We still want to decrement mPersistence, even if the popup is showing + if (mPersistenceCount != 0) + mPersistenceCount--; + return false; + } + + // If persistence is set to -1, the doorhanger will never be + // automatically removed. + if (mPersistenceCount != 0) { + mPersistenceCount--; + return false; + } + + if (System.currentTimeMillis() <= mTimeout) { + return false; + } + + return true; + } + + public void showTitle(Bitmap favicon, String title) { + mDoorhangerTitle.setText(title); + mDoorhangerTitle.setCompoundDrawablesWithIntrinsicBounds(new BitmapDrawable(getResources(), favicon), null, null, null); + if (favicon != null) { + mDoorhangerTitle.setCompoundDrawablePadding((int) mContext.getResources().getDimension(R.dimen.doorhanger_drawable_padding)); + } + mDoorhangerTitle.setVisibility(VISIBLE); + } + + public void hideTitle() { + mDoorhangerTitle.setVisibility(GONE); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java b/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java new file mode 100644 index 000000000..98f1e57e1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java @@ -0,0 +1,127 @@ +/* -*- 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.widget; + +import android.util.Log; +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.widget.DoorHanger.Type; + +public class DoorhangerConfig { + + public static class Link { + public final String label; + public final String url; + + private Link(String label, String url) { + this.label = label; + this.url = url; + } + } + + public static class ButtonConfig { + public final String label; + public final int callback; + + public ButtonConfig(String label, int callback) { + this.label = label; + this.callback = callback; + } + } + private static final String LOGTAG = "DoorhangerConfig"; + + private final int tabId; + private final String id; + private final DoorHanger.OnButtonClickListener buttonClickListener; + private final DoorHanger.Type type; + private String message; + private JSONObject options; + private Link link; + private ButtonConfig positiveButtonConfig; + private ButtonConfig negativeButtonConfig; + + public DoorhangerConfig(Type type, DoorHanger.OnButtonClickListener listener) { + // XXX: This should only be used by SiteIdentityPopup doorhangers which + // don't need tab or id references, until bug 1141904 unifies doorhangers. + + this(-1, null, type, listener); + } + + public DoorhangerConfig(int tabId, String id, DoorHanger.Type type, DoorHanger.OnButtonClickListener buttonClickListener) { + this.tabId = tabId; + this.id = id; + this.type = type; + this.buttonClickListener = buttonClickListener; + } + + public int getTabId() { + return tabId; + } + + public String getId() { + return id; + } + + public Type getType() { + return type; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setOptions(JSONObject options) { + this.options = options; + + // Set link if there is a link provided in options. + final JSONObject linkObj = options.optJSONObject("link"); + if (linkObj != null) { + try { + setLink(linkObj.getString("label"), linkObj.getString("url")); + } catch (JSONException e) { + Log.e(LOGTAG, "Malformed link object in options"); + } + } + } + + public JSONObject getOptions() { + return options; + } + + public void setButton(String label, int callbackId, boolean isPositive) { + final ButtonConfig buttonConfig = new ButtonConfig(label, callbackId); + if (isPositive) { + positiveButtonConfig = buttonConfig; + } else { + negativeButtonConfig = buttonConfig; + } + } + + public ButtonConfig getPositiveButtonConfig() { + return positiveButtonConfig; + } + + public ButtonConfig getNegativeButtonConfig() { + return negativeButtonConfig; + } + + public DoorHanger.OnButtonClickListener getButtonClickListener() { + return this.buttonClickListener; + } + + public void setLink(String label, String url) { + this.link = new Link(label, url); + } + + public Link getLink() { + return link; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java new file mode 100644 index 000000000..44f88e668 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java @@ -0,0 +1,65 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.TextView; + +/** + * Text view that correctly handles maxLines and ellipsizing for Android < 2.3. + */ +public class EllipsisTextView extends TextView { + private final String ellipsis; + + private final int maxLines; + private CharSequence originalText; + + public EllipsisTextView(Context context) { + this(context, null); + } + + public EllipsisTextView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.textViewStyle); + } + + public EllipsisTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + ellipsis = getResources().getString(R.string.ellipsis); + + TypedArray a = context.getTheme() + .obtainStyledAttributes(attrs, R.styleable.EllipsisTextView, 0, 0); + maxLines = a.getInteger(R.styleable.EllipsisTextView_ellipsizeAtLine, 1); + a.recycle(); + } + + public void setOriginalText(CharSequence text) { + originalText = text; + setText(text); + } + + @Override + public void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + // There is extra space, start over with the original text + if (getLineCount() < maxLines) { + setText(originalText); + } + + // If we are over the max line attribute, ellipsize + if (getLineCount() > maxLines) { + final int endIndex = getLayout().getLineEnd(maxLines - 1) - 1 - ellipsis.length(); + final String text = getText().subSequence(0, endIndex) + ellipsis; + // Make sure that we don't change originalText + setText(text); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java b/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java new file mode 100644 index 000000000..b4d1e13d9 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java @@ -0,0 +1,106 @@ +// 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.widget; + +import org.mozilla.gecko.ActivityHandlerHelper; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.FragmentManager; +import android.support.v7.app.AlertDialog; +import android.util.Log; + +import java.util.List; + +/** + * A DialogFragment to contain a dialog that appears when the user clicks an Intent:// URI during private browsing. The + * dialog appears to notify the user that a clicked link will open in an external application, potentially leaking their + * browsing history. + */ +public class ExternalIntentDuringPrivateBrowsingPromptFragment extends DialogFragment { + private static final String LOGTAG = ExternalIntentDuringPrivateBrowsingPromptFragment.class.getSimpleName(); + private static final String FRAGMENT_TAG = "ExternalIntentPB"; + + private static final String KEY_APPLICATION_NAME = "matchingApplicationName"; + private static final String KEY_INTENT = "intent"; + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Bundle args = getArguments(); + final CharSequence matchingApplicationName = args.getCharSequence(KEY_APPLICATION_NAME); + final Intent intent = args.getParcelable(KEY_INTENT); + + final Context context = getActivity(); + final String promptMessage = context.getString(R.string.intent_uri_private_browsing_prompt, matchingApplicationName); + + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(promptMessage) + .setTitle(intent.getDataString()) + .setPositiveButton(R.string.button_yes, new DialogInterface.OnClickListener() { + public void onClick(final DialogInterface dialog, final int id) { + context.startActivity(intent); + } + }) + .setNegativeButton(R.string.button_no, null /* we do nothing if the user rejects */ ); + return builder.create(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + GeckoApplication.watchReference(getActivity(), this); + } + + /** + * @return true if the Activity is started or a dialog is shown. false if the Activity fails to start. + */ + public static boolean showDialogOrAndroidChooser(final Context context, final FragmentManager fragmentManager, + final Intent intent) { + final Tab selectedTab = Tabs.getInstance().getSelectedTab(); + if (selectedTab == null || !selectedTab.isPrivate()) { + return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, intent); + } + + final PackageManager pm = context.getPackageManager(); + final List<ResolveInfo> matchingActivities = pm.queryIntentActivities(intent, 0); + if (matchingActivities.size() == 1) { + final ExternalIntentDuringPrivateBrowsingPromptFragment fragment = new ExternalIntentDuringPrivateBrowsingPromptFragment(); + + final Bundle args = new Bundle(2); + args.putCharSequence(KEY_APPLICATION_NAME, matchingActivities.get(0).loadLabel(pm)); + args.putParcelable(KEY_INTENT, intent); + fragment.setArguments(args); + + fragment.show(fragmentManager, FRAGMENT_TAG); + // We don't know the results of the user interaction with the fragment so just return true. + return true; + } else if (matchingActivities.size() > 1) { + // We want to show the Android Intent Chooser. However, we have no way of distinguishing regular tabs from + // private tabs to the chooser. Thus, if a user chooses "Always" in regular browsing mode, the chooser will + // not be shown and the URL will be opened. Therefore we explicitly show the chooser (which notably does not + // have an "Always" option). + final String androidChooserTitle = + context.getResources().getString(R.string.intent_uri_private_browsing_multiple_match_title); + final Intent chooserIntent = Intent.createChooser(intent, androidChooserTitle); + return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, chooserIntent); + } else { + // Normally, we show about:neterror when an Intent does not resolve + // but we don't have the references here to do that so log instead. + Log.w(LOGTAG, "showDialogOrAndroidChooser unexpectedly called with Intent that does not resolve"); + return false; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java new file mode 100644 index 000000000..08bb55ef6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java @@ -0,0 +1,108 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Shader; +import android.util.AttributeSet; + +/** + * Fades the end of the text by gecko:fadeWidth amount, + * if the text is too long and requires an ellipsis. + * + * This implementation is an improvement over Android's built-in fadingEdge + * but potentially slower than the {@link org.mozilla.gecko.widget.FadedSingleColorTextView}. + * It works for text of multiple colors but only one background color. It works by + * drawing a gradient rectangle with the background color over the text, fading it out. + */ +public class FadedMultiColorTextView extends FadedTextView { + private final ColorStateList fadeBackgroundColorList; + + private final Paint fadePaint; + private FadedTextGradient backgroundGradient; + + public FadedMultiColorTextView(Context context, AttributeSet attrs) { + super(context, attrs); + + fadePaint = new Paint(); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FadedMultiColorTextView); + fadeBackgroundColorList = + a.getColorStateList(R.styleable.FadedMultiColorTextView_fadeBackgroundColor); + a.recycle(); + } + + @Override + public void onDraw(Canvas canvas) { + super.onDraw(canvas); + + final boolean needsEllipsis = needsEllipsis(); + if (needsEllipsis) { + final int right = getWidth() - getCompoundPaddingRight(); + final float left = right - fadeWidth; + + updateGradientShader(needsEllipsis, right); + + // Shrink height of gradient to prevent it overlaying parent view border. + // The shrunk size just nee to cover the text itself. + final float density = getResources().getDisplayMetrics().density; + final float h = Math.abs(fadePaint.getFontMetrics().top) + 1; + final float l = fadePaint.getFontMetrics().bottom + 1; + final float top = getBaseline() - h * density; + final float bottom = getBaseline() + l * density; + + canvas.drawRect(left, top, right, bottom, fadePaint); + } + } + + private void updateGradientShader(final boolean needsEllipsis, final int gradientEndRight) { + final int backgroundColor = + fadeBackgroundColorList.getColorForState(getDrawableState(), Color.RED); + + final boolean needsNewGradient = (backgroundGradient == null || + backgroundGradient.getBackgroundColor() != backgroundColor || + backgroundGradient.getEndRight() != gradientEndRight); + + if (needsEllipsis && needsNewGradient) { + backgroundGradient = new FadedTextGradient(gradientEndRight, fadeWidth, backgroundColor); + fadePaint.setShader(backgroundGradient); + } + } + + private static class FadedTextGradient extends LinearGradient { + private final int endRight; + private final int backgroundColor; + + public FadedTextGradient(final int gradientEndRight, final int fadeWidth, + final int backgroundColor) { + super(gradientEndRight - fadeWidth, 0, gradientEndRight, 0, + getColorWithZeroedAlpha(backgroundColor), backgroundColor, Shader.TileMode.CLAMP); + + this.endRight = gradientEndRight; + this.backgroundColor = backgroundColor; + } + + private static int getColorWithZeroedAlpha(final int color) { + return Color.argb(0, Color.red(color), Color.green(color), Color.blue(color)); + } + + public int getEndRight() { + return endRight; + } + + public int getBackgroundColor() { + return backgroundColor; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java new file mode 100644 index 000000000..866b7ecbd --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java @@ -0,0 +1,74 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Shader; +import android.util.AttributeSet; + +/** + * Fades the end of the text by gecko:fadeWidth amount, + * if the text is too long and requires an ellipsis. + * + * This implementation is an improvement over Android's built-in fadingEdge + * and the fastest of Fennec's implementations. However, it only works for + * text of one color. It works by applying a linear gradient directly to the text. + */ +public class FadedSingleColorTextView extends FadedTextView { + // Shader for the fading edge. + private FadedTextGradient mTextGradient; + + public FadedSingleColorTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + private void updateGradientShader() { + final int color = getCurrentTextColor(); + final int width = getAvailableWidth(); + + final boolean needsNewGradient = (mTextGradient == null || + mTextGradient.getColor() != color || + mTextGradient.getWidth() != width); + + final boolean needsEllipsis = needsEllipsis(); + if (needsEllipsis && needsNewGradient) { + mTextGradient = new FadedTextGradient(width, fadeWidth, color); + } + + getPaint().setShader(needsEllipsis ? mTextGradient : null); + } + + @Override + public void onDraw(Canvas canvas) { + updateGradientShader(); + super.onDraw(canvas); + } + + private static class FadedTextGradient extends LinearGradient { + private final int mWidth; + private final int mColor; + + public FadedTextGradient(int width, int fadeWidth, int color) { + super(0, 0, width, 0, + new int[] { color, color, 0x0 }, + new float[] { 0, ((float) (width - fadeWidth) / width), 1.0f }, + Shader.TileMode.CLAMP); + + mWidth = width; + mColor = color; + } + + public int getWidth() { + return mWidth; + } + + public int getColor() { + return mColor; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java new file mode 100644 index 000000000..e10433083 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java @@ -0,0 +1,48 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.Layout; +import android.util.AttributeSet; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.widget.themed.ThemedTextView; + +/** + * An implementation of FadedTextView should fade the end of the text + * by gecko:fadeWidth amount, if the text is too long and requires an ellipsis. + */ +public abstract class FadedTextView extends ThemedTextView { + // Width of the fade effect from end of the view. + protected final int fadeWidth; + + public FadedTextView(final Context context, final AttributeSet attrs) { + super(context, attrs); + + setSingleLine(true); + setEllipsize(null); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FadedTextView); + fadeWidth = a.getDimensionPixelSize(R.styleable.FadedTextView_fadeWidth, 0); + a.recycle(); + } + + protected int getAvailableWidth() { + return getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight(); + } + + protected boolean needsEllipsis() { + final int width = getAvailableWidth(); + if (width <= 0) { + return false; + } + + final Layout layout = getLayout(); + return (layout != null && layout.getLineWidth(0) > width); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java new file mode 100644 index 000000000..4652345b4 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java @@ -0,0 +1,268 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.icons.IconCallback; +import org.mozilla.gecko.icons.IconResponse; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; + +/** + * Special version of ImageView for favicons. + * Displays solid colour background around Favicon to fill space not occupied by the icon. Colour + * selected is the dominant colour of the provided Favicon. + */ +public class FaviconView extends ImageView { + private static final String LOGTAG = "GeckoFaviconView"; + + private static String DEFAULT_FAVICON_KEY = FaviconView.class.getSimpleName() + "DefaultFavicon"; + + // Default x/y-radius of the oval used to round the corners of the background (dp) + private static final int DEFAULT_CORNER_RADIUS_DP = 4; + + private Bitmap mIconBitmap; + + // Reference to the unscaled bitmap, if any, to prevent repeated assignments of the same bitmap + // to the view from causing repeated rescalings (Some of the callers do this) + private Bitmap mUnscaledBitmap; + + private int mActualWidth; + private int mActualHeight; + + // Flag indicating if the most recently assigned image is considered likely to need scaling. + private boolean mScalingExpected; + + // Dominant color of the favicon. + private int mDominantColor; + + // Paint for drawing the background. + private static final Paint sBackgroundPaint; + + // Size of the background rectangle. + private final RectF mBackgroundRect; + + // The x/y-radius of the oval used to round the corners of the background (pixels) + private final float mBackgroundCornerRadius; + + // Type of the border whose value is defined in attrs.xml . + private final boolean isDominantBorderEnabled; + + // boolean switch for overriding scaletype, whose value is defined in attrs.xml . + private final boolean isOverrideScaleTypeEnabled; + + // boolean switch for disabling rounded corners, value defined in attrs.xml . + private final boolean areRoundCornersEnabled; + + // Initializing the static paints. + static { + sBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + sBackgroundPaint.setStyle(Paint.Style.FILL); + } + + public FaviconView(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.FaviconView, 0, 0); + + try { + isDominantBorderEnabled = a.getBoolean(R.styleable.FaviconView_dominantBorderEnabled, true); + isOverrideScaleTypeEnabled = a.getBoolean(R.styleable.FaviconView_overrideScaleType, true); + areRoundCornersEnabled = a.getBoolean(R.styleable.FaviconView_enableRoundCorners, true); + } finally { + a.recycle(); + } + + if (isOverrideScaleTypeEnabled) { + setScaleType(ImageView.ScaleType.CENTER); + } + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + + mBackgroundRect = new RectF(0, 0, 0, 0); + mBackgroundCornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_CORNER_RADIUS_DP, metrics); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + // No point rechecking the image if there hasn't really been any change. + if (w == mActualWidth && h == mActualHeight) { + return; + } + + mActualWidth = w; + mActualHeight = h; + + mBackgroundRect.right = w; + mBackgroundRect.bottom = h; + + formatImage(); + } + + @Override + public void onDraw(Canvas canvas) { + if (isDominantBorderEnabled) { + sBackgroundPaint.setColor(mDominantColor & 0x7FFFFFFF); + + if (areRoundCornersEnabled) { + canvas.drawRoundRect(mBackgroundRect, mBackgroundCornerRadius, mBackgroundCornerRadius, sBackgroundPaint); + } else { + canvas.drawRect(mBackgroundRect, sBackgroundPaint); + } + } + + super.onDraw(canvas); + } + + /** + * Formats the image for display, if the prerequisite data are available. Upscales tiny Favicons to + * normal sized ones, replaces null bitmaps with the default Favicon, and fills all remaining space + * in this view with the coloured background. + */ + private void formatImage() { + // We're waiting for both onSizeChanged and updateImage to be called before scaling. + if (mIconBitmap == null || mActualWidth == 0 || mActualHeight == 0) { + showNoImage(); + return; + } + + if (mScalingExpected && mActualWidth != mIconBitmap.getWidth()) { + scaleBitmap(); + // Don't scale the image every time something changes. + mScalingExpected = false; + } + + setImageBitmap(mIconBitmap); + + // After scaling, determine if we have empty space around the scaled image which we need to + // fill with the coloured background. If applicable, show it. + // We assume Favicons are still squares and only bother with the background if more than 3px + // of it would be displayed. + if (Math.abs(mIconBitmap.getWidth() - mActualWidth) < 3) { + mDominantColor = 0; + } + } + + private void scaleBitmap() { + // If the Favicon can be resized to fill the view exactly without an enlargment of more than + // a factor of two, do so. + int doubledSize = mIconBitmap.getWidth() * 2; + if (mActualWidth > doubledSize) { + // If the view is more than twice the size of the image, just double the image size + // and do the rest with padding. + mIconBitmap = Bitmap.createScaledBitmap(mIconBitmap, doubledSize, doubledSize, true); + } else { + // Otherwise, scale the image to fill the view. + mIconBitmap = Bitmap.createScaledBitmap(mIconBitmap, mActualWidth, mActualWidth, true); + } + } + + /** + * Sets the icon displayed in this Favicon view to the bitmap provided. If the size of the view + * has been set, the display will be updated right away, otherwise the update will be deferred + * until then. The key provided is used to cache the result of the calculation of the dominant + * colour of the provided image - this value is used to draw the coloured background in this view + * if the icon is not large enough to fill it. + * + * @param allowScaling If true, allows the provided bitmap to be scaled by this FaviconView. + * Typically, you should prefer using Favicons obtained via the caching system + * (Favicons class), so as to exploit caching. + */ + private void updateImageInternal(IconResponse response, boolean allowScaling) { + // Reassigning the same bitmap? Don't bother. + if (mUnscaledBitmap == response.getBitmap()) { + return; + } + mUnscaledBitmap = response.getBitmap(); + mIconBitmap = response.getBitmap(); + mDominantColor = response.getColor(); + mScalingExpected = allowScaling; + + // Possibly update the display. + formatImage(); + } + + private void showNoImage() { + setImageDrawable(null); + mDominantColor = 0; + } + + /** + * Clear image and background shown by this view. + */ + public void clearImage() { + showNoImage(); + mUnscaledBitmap = null; + mIconBitmap = null; + mDominantColor = 0; + mScalingExpected = false; + } + + /** + * Update the displayed image and apply the scaling logic. + * The scaling logic will attempt to resize the image to fit correctly inside the view in a way + * that avoids unreasonable levels of loss of quality. + * Scaling is necessary only when the icon being provided is not drawn from the Favicon cache + * introduced in Bug 914296. + * + * Due to Bug 913746, icons bundled for search engines are not available to the cache, so must + * always have the scaling logic applied here. At the time of writing, this is the only case in + * which the scaling logic here is applied. + */ + public void updateAndScaleImage(IconResponse response) { + updateImageInternal(response, true); + } + + /** + * Update the image displayed in the Favicon view without scaling. Images larger than the view + * will be centrally cropped. Images smaller than the view will be placed centrally and the + * extra space filled with the dominant colour of the provided image. + */ + public void updateImage(IconResponse response) { + updateImageInternal(response, false); + } + + public Bitmap getBitmap() { + return mIconBitmap; + } + + /** + * Create an IconCallback implementation that will update this view after an icon has been loaded. + */ + public IconCallback createIconCallback() { + return new Callback(this); + } + + private static class Callback implements IconCallback { + private final WeakReference<FaviconView> viewReference; + + private Callback(FaviconView view) { + this.viewReference = new WeakReference<FaviconView>(view); + } + + @Override + public void onIconResponse(IconResponse response) { + final FaviconView view = viewReference.get(); + if (view == null) { + return; + } + + view.updateImage(response); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java new file mode 100644 index 000000000..f1662896e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java @@ -0,0 +1,39 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.support.v7.widget.CardView; +import android.util.AttributeSet; + +import org.mozilla.gecko.AppConstants; + +/** + * CardView that ensures its content can fill the entire card. Use this instead of CardView + * if you want to fill the card with e.g. images, backgrounds, etc. + * + * On API < 21, CardView content isn't clipped for performance reasons. We work around this by disabling + * rounded corners on those devices. + */ +public class FilledCardView extends CardView { + + public FilledCardView(Context context, AttributeSet attrs) { + super(context, attrs); + + // Disable corners on < lollipop: + // CardView only supports clipping content on API >= 21 (for performance reasons). Without + // content clipping, any cards that provide their own content that fills the card will look + // ugly: by default there is a 2px white edge along the top and sides (i.e. an inset corresponding + // to the corner radius), if we disable the inset then the corners overlap. + // It's possible to implement custom clipping, however given that the support library + // chose not to support this for performance reasons, we too have chosen to just disable + // corners on < 21, see Bug 1271428. + if (AppConstants.Versions.preLollipop) { + setRadius(0); + } + + setUseCompatPadding(true); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java new file mode 100644 index 000000000..042e74851 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java @@ -0,0 +1,91 @@ +/* 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +public class FlowLayout extends ViewGroup { + private int mSpacing; + + public FlowLayout(Context context) { + super(context); + } + + public FlowLayout(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray a = context.obtainStyledAttributes(attrs, org.mozilla.gecko.R.styleable.FlowLayout); + mSpacing = a.getDimensionPixelSize(R.styleable.FlowLayout_spacing, (int) context.getResources().getDimension(R.dimen.flow_layout_spacing)); + a.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int parentWidth = MeasureSpec.getSize(widthMeasureSpec); + final int childCount = getChildCount(); + int rowWidth = 0; + int totalWidth = 0; + int totalHeight = 0; + boolean firstChild = true; + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (child.getVisibility() == GONE) + continue; + + measureChild(child, widthMeasureSpec, heightMeasureSpec); + + final int childWidth = child.getMeasuredWidth(); + final int childHeight = child.getMeasuredHeight(); + + if (firstChild || (rowWidth + childWidth > parentWidth)) { + rowWidth = 0; + totalHeight += childHeight; + if (!firstChild) + totalHeight += mSpacing; + firstChild = false; + } + + rowWidth += childWidth; + + if (rowWidth > totalWidth) + totalWidth = rowWidth; + + rowWidth += mSpacing; + } + + setMeasuredDimension(totalWidth, totalHeight); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int childCount = getChildCount(); + final int totalWidth = r - l; + int x = 0; + int y = 0; + int prevChildHeight = 0; + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + if (child.getVisibility() == GONE) + continue; + + final int childWidth = child.getMeasuredWidth(); + final int childHeight = child.getMeasuredHeight(); + if (x + childWidth > totalWidth) { + x = 0; + y += prevChildHeight + mSpacing; + } + prevChildHeight = childHeight; + child.layout(x, y, x + childWidth, y + childHeight); + x += childWidth + mSpacing; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java new file mode 100644 index 000000000..d864792a6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java @@ -0,0 +1,360 @@ +/* -*- 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.widget; + +import android.app.Activity; +import android.net.Uri; +import android.support.design.widget.Snackbar; +import android.util.Base64; +import android.view.Menu; + +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.R; +import org.mozilla.gecko.SnackbarBuilder; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.overlays.ui.ShareDialog; +import org.mozilla.gecko.menu.MenuItemSwitcherLayout; +import org.mozilla.gecko.util.IOUtils; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.view.MenuItem; +import android.view.MenuItem.OnMenuItemClickListener; +import android.view.SubMenu; +import android.view.View; +import android.view.View.OnClickListener; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; + +public class GeckoActionProvider { + private static final int MAX_HISTORY_SIZE_DEFAULT = 2; + + /** + * A listener to know when a target was selected. + * When setting a provider, the activity can listen to this, + * to close the menu. + */ + public interface OnTargetSelectedListener { + public void onTargetSelected(); + } + + final Context mContext; + + public final static String DEFAULT_MIME_TYPE = "text/plain"; + + public static final String DEFAULT_HISTORY_FILE_NAME = "history.xml"; + + // History file. + String mHistoryFileName = DEFAULT_HISTORY_FILE_NAME; + + OnTargetSelectedListener mOnTargetListener; + + private final Callbacks mCallbacks = new Callbacks(); + + private static final HashMap<String, GeckoActionProvider> mProviders = new HashMap<String, GeckoActionProvider>(); + + private static String getFilenameFromMimeType(String mimeType) { + String[] mime = mimeType.split("/"); + + // All text mimetypes use the default provider + if ("text".equals(mime[0])) { + return DEFAULT_HISTORY_FILE_NAME; + } + + return "history-" + mime[0] + ".xml"; + } + + // Gets the action provider for a particular mimetype + public static GeckoActionProvider getForType(String mimeType, Context context) { + if (!mProviders.keySet().contains(mimeType)) { + GeckoActionProvider provider = new GeckoActionProvider(context); + + // For empty types, we just return a default provider + if (TextUtils.isEmpty(mimeType)) { + return provider; + } + + provider.setHistoryFileName(getFilenameFromMimeType(mimeType)); + mProviders.put(mimeType, provider); + } + return mProviders.get(mimeType); + } + + public GeckoActionProvider(Context context) { + mContext = context; + } + + /** + * Creates the action view using the default history size. + */ + public View onCreateActionView(final ActionViewType viewType) { + return onCreateActionView(MAX_HISTORY_SIZE_DEFAULT, viewType); + } + + public View onCreateActionView(final int maxHistorySize, final ActionViewType viewType) { + // Create the view and set its data model. + ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + final MenuItemSwitcherLayout view; + switch (viewType) { + case DEFAULT: + view = new MenuItemSwitcherLayout(mContext, null); + break; + + case CONTEXT_MENU: + view = new MenuItemSwitcherLayout(mContext, null); + view.initContextMenuStyles(); + break; + + default: + throw new IllegalArgumentException( + "Unknown " + ActionViewType.class.getSimpleName() + ": " + viewType); + } + view.addActionButtonClickListener(mCallbacks); + + final PackageManager packageManager = mContext.getPackageManager(); + int historySize = dataModel.getDistinctActivityCountInHistory(); + if (historySize > maxHistorySize) { + historySize = maxHistorySize; + } + + // Historical data is dependent on past selection of activities. + // Activity count is determined by the number of activities that can handle + // the particular intent. When no intent is set, the activity count is 0, + // while the history count can be a valid number. + if (historySize > dataModel.getActivityCount()) { + return view; + } + + for (int i = 0; i < historySize; i++) { + view.addActionButton(dataModel.getActivity(i).loadIcon(packageManager), + dataModel.getActivity(i).loadLabel(packageManager)); + } + + return view; + } + + public boolean hasSubMenu() { + return true; + } + + public void onPrepareSubMenu(SubMenu subMenu) { + // Clear since the order of items may change. + subMenu.clear(); + + ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + PackageManager packageManager = mContext.getPackageManager(); + + // Populate the sub-menu with a sub set of the activities. + final String shareDialogClassName = ShareDialog.class.getCanonicalName(); + final String sendTabLabel = mContext.getResources().getString(R.string.overlay_share_send_other); + final int count = dataModel.getActivityCount(); + for (int i = 0; i < count; i++) { + ResolveInfo activity = dataModel.getActivity(i); + final CharSequence activityLabel = activity.loadLabel(packageManager); + + // Pin internal actions to the top. Note: + // the order here does not affect quick share. + final int order; + if (shareDialogClassName.equals(activity.activityInfo.name) && + sendTabLabel.equals(activityLabel)) { + order = Menu.FIRST + i; + } else { + order = Menu.FIRST + (i | Menu.CATEGORY_SECONDARY); + } + + subMenu.add(0, i, order, activityLabel) + .setIcon(activity.loadIcon(packageManager)) + .setOnMenuItemClickListener(mCallbacks); + } + } + + public void setHistoryFileName(String historyFile) { + mHistoryFileName = historyFile; + } + + public Intent getIntent() { + ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + return dataModel.getIntent(); + } + + public void setIntent(Intent intent) { + ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + dataModel.setIntent(intent); + + // Inform the target listener to refresh it's UI, if needed. + if (mOnTargetListener != null) { + mOnTargetListener.onTargetSelected(); + } + } + + public void setOnTargetSelectedListener(OnTargetSelectedListener listener) { + mOnTargetListener = listener; + } + + public ArrayList<ResolveInfo> getSortedActivities() { + ArrayList<ResolveInfo> infos = new ArrayList<ResolveInfo>(); + + ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + + // Populate the sub-menu with a sub set of the activities. + final int count = dataModel.getActivityCount(); + for (int i = 0; i < count; i++) { + infos.add(dataModel.getActivity(i)); + } + + return infos; + } + + public void chooseActivity(int position) { + mCallbacks.chooseActivity(position); + } + + /** + * Listener for handling default activity / menu item clicks. + */ + private class Callbacks implements OnMenuItemClickListener, + OnClickListener { + void chooseActivity(int index) { + final ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName); + final Intent launchIntent = dataModel.chooseActivity(index); + if (launchIntent != null) { + // This may cause a download to happen. Make sure we're on the background thread. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // Share image downloads the image before sharing it. + String type = launchIntent.getType(); + if (Intent.ACTION_SEND.equals(launchIntent.getAction()) && type != null && type.startsWith("image/")) { + downloadImageForIntent(launchIntent); + } + + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); + mContext.startActivity(launchIntent); + } + }); + } + + if (mOnTargetListener != null) { + mOnTargetListener.onTargetSelected(); + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + chooseActivity(item.getItemId()); + + // Context: Sharing via chrome mainmenu list (no explicit session is active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "actionprovider"); + return true; + } + + @Override + public void onClick(View view) { + Integer index = (Integer) view.getTag(); + chooseActivity(index); + + // Context: Sharing via chrome mainmenu and content contextmenu quickshare (no explicit session is active) + Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.BUTTON, "actionprovider"); + } + } + + public enum ActionViewType { + DEFAULT, + CONTEXT_MENU, + } + + + /** + * Downloads the URI pointed to by a share intent, and alters the intent to point to the + * locally stored file. + * + * @param intent share intent to alter in place. + */ + public void downloadImageForIntent(final Intent intent) { + final String src = IntentUtils.getStringExtraSafe(intent, Intent.EXTRA_TEXT); + final File dir = GeckoApp.getTempDirectory(); + + if (src == null || dir == null) { + // We should be, but currently aren't, statically guaranteed an Activity context. + // Try our best. + if (mContext instanceof Activity) { + SnackbarBuilder.builder((Activity) mContext) + .message(mContext.getApplicationContext().getString(R.string.share_image_failed)) + .duration(Snackbar.LENGTH_LONG) + .buildAndShow(); + } + return; + } + + GeckoApp.deleteTempFiles(); + + String type = intent.getType(); + OutputStream os = null; + try { + // Create a temporary file for the image + if (src.startsWith("data:")) { + final int dataStart = src.indexOf(","); + + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type); + + // If we weren't given an explicit mimetype, try to dig one out of the data uri. + if (TextUtils.isEmpty(extension) && dataStart > 5) { + type = src.substring(5, dataStart).replace(";base64", ""); + extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type); + } + + final File imageFile = File.createTempFile("image", "." + extension, dir); + os = new FileOutputStream(imageFile); + + byte[] buf = Base64.decode(src.substring(dataStart + 1), Base64.DEFAULT); + os.write(buf); + + // Only alter the intent when we're sure everything has worked + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(imageFile)); + } else { + InputStream is = null; + try { + final byte[] buf = new byte[2048]; + final URL url = new URL(src); + final String filename = URLUtil.guessFileName(src, null, type); + is = url.openStream(); + + final File imageFile = new File(dir, filename); + os = new FileOutputStream(imageFile); + + int length; + while ((length = is.read(buf)) != -1) { + os.write(buf, 0, length); + } + + // Only alter the intent when we're sure everything has worked + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(imageFile)); + } finally { + IOUtils.safeStreamClose(is); + } + } + } catch (IOException ex) { + // If something went wrong, we'll just leave the intent un-changed + } finally { + IOUtils.safeStreamClose(os); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java new file mode 100644 index 000000000..7e7f50662 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java @@ -0,0 +1,189 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.menu.GeckoMenu; +import org.mozilla.gecko.menu.GeckoMenuInflater; +import org.mozilla.gecko.menu.MenuPanel; +import org.mozilla.gecko.menu.MenuPopup; + +import android.content.Context; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +/** + * A PopupMenu that uses the custom GeckoMenu. This menu is + * usually tied to an anchor, and show as a dropdrown from the anchor. + */ +public class GeckoPopupMenu implements GeckoMenu.Callback, + GeckoMenu.MenuPresenter { + + // An interface for listeners for dismissal. + public static interface OnDismissListener { + public boolean onDismiss(GeckoMenu menu); + } + + // An interface for listeners for menu item click events. + public static interface OnMenuItemClickListener { + public boolean onMenuItemClick(MenuItem item); + } + + // An interface for listeners for menu item long click events. + public static interface OnMenuItemLongClickListener { + public boolean onMenuItemLongClick(MenuItem item); + } + + private View mAnchor; + + private MenuPopup mMenuPopup; + private MenuPanel mMenuPanel; + + private GeckoMenu mMenu; + private GeckoMenuInflater mMenuInflater; + + private OnDismissListener mDismissListener; + private OnMenuItemClickListener mClickListener; + private OnMenuItemLongClickListener mLongClickListener; + + public GeckoPopupMenu(Context context) { + initialize(context, null); + } + + public GeckoPopupMenu(Context context, View anchor) { + initialize(context, anchor); + } + + /** + * This method creates an empty menu and attaches the necessary listeners. + * If an anchor is supplied, it is stored as well. + */ + private void initialize(Context context, View anchor) { + mMenu = new GeckoMenu(context, null); + mMenu.setCallback(this); + mMenu.setMenuPresenter(this); + mMenuInflater = new GeckoMenuInflater(context); + + mMenuPopup = new MenuPopup(context); + mMenuPanel = new MenuPanel(context, null); + + mMenuPanel.addView(mMenu); + mMenuPopup.setPanelView(mMenuPanel); + + setAnchor(anchor); + } + + /** + * Returns the menu that is current being shown. + * + * @return The menu being shown. + */ + public GeckoMenu getMenu() { + return mMenu; + } + + /** + * Returns the menu inflater that was used to create the menu. + * + * @return The menu inflater used. + */ + public MenuInflater getMenuInflater() { + return mMenuInflater; + } + + /** + * Inflates a menu resource to the menu using the menu inflater. + * + * @param menuRes The menu resource to be inflated. + */ + public void inflate(int menuRes) { + if (menuRes > 0) { + mMenuInflater.inflate(menuRes, mMenu); + } + } + + /** + * Set a different anchor after the menu is inflated. + * + * @param anchor The new anchor for the popup. + */ + public void setAnchor(View anchor) { + mAnchor = anchor; + + // Reposition the popup if the anchor changes while it's showing. + if (mMenuPopup.isShowing()) { + mMenuPopup.dismiss(); + mMenuPopup.showAsDropDown(mAnchor); + } + } + + public void setOnDismissListener(OnDismissListener listener) { + mDismissListener = listener; + } + + public void setOnMenuItemClickListener(OnMenuItemClickListener listener) { + mClickListener = listener; + } + + public void setOnMenuItemLongClickListener(OnMenuItemLongClickListener listener) { + mLongClickListener = listener; + } + + /** + * Show the inflated menu. + */ + public void show() { + if (!mMenuPopup.isShowing()) + mMenuPopup.showAsDropDown(mAnchor); + } + + /** + * Hide the inflated menu. + */ + public void dismiss() { + if (mMenuPopup.isShowing()) { + mMenuPopup.dismiss(); + + if (mDismissListener != null) + mDismissListener.onDismiss(mMenu); + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + if (mClickListener != null) { + return mClickListener.onMenuItemClick(item); + } + return false; + } + + @Override + public boolean onMenuItemLongClick(MenuItem item) { + if (mLongClickListener != null) { + return mLongClickListener.onMenuItemLongClick(item); + } + return false; + } + + @Override + public void openMenu() { + show(); + } + + @Override + public void showMenu(View menu) { + mMenuPanel.removeAllViews(); + mMenuPanel.addView(menu); + + openMenu(); + } + + @Override + public void closeMenu() { + dismiss(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java b/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java new file mode 100644 index 000000000..9c98e8a0d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java @@ -0,0 +1,66 @@ +/* -*- 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.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import org.mozilla.gecko.R; +import org.mozilla.gecko.home.CombinedHistoryItem; + +public class HistoryDividerItemDecoration extends RecyclerView.ItemDecoration { + private final int mDividerHeight; + private final Paint mDividerPaint; + + public HistoryDividerItemDecoration(Context context) { + mDividerHeight = (int) context.getResources().getDimension(R.dimen.page_row_divider_height); + + mDividerPaint = new Paint(); + mDividerPaint.setColor(ContextCompat.getColor(context, R.color.toolbar_divider_grey)); + mDividerPaint.setStyle(Paint.Style.FILL_AND_STROKE); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + final int position = parent.getChildAdapterPosition(view); + if (position == RecyclerView.NO_POSITION) { + // This view is no longer corresponds to an adapter position (pending changes). + return; + } + + if (parent.getAdapter().getItemViewType(position) != + CombinedHistoryItem.ItemType.itemTypeToViewType(CombinedHistoryItem.ItemType.SECTION_HEADER)) { + outRect.set(0, 0, 0, mDividerHeight); + } + } + + @Override + public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { + if (parent.getChildCount() == 0) { + return; + } + + for (int i = 0; i < parent.getChildCount(); i++) { + final View child = parent.getChildAt(i); + final int position = parent.getChildAdapterPosition(child); + + if (position == RecyclerView.NO_POSITION) { + // This view is no longer corresponds to an adapter position (pending changes). + continue; + } + + if (parent.getAdapter().getItemViewType(position) != + CombinedHistoryItem.ItemType.itemTypeToViewType(CombinedHistoryItem.ItemType.SECTION_HEADER)) { + final float bottom = child.getBottom() + child.getTranslationY(); + c.drawRect(0, bottom, parent.getWidth(), bottom + mDividerHeight, mDividerPaint); + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java b/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java new file mode 100644 index 000000000..71987bf8c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java @@ -0,0 +1,111 @@ +/* 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TabWidget; +import android.widget.TextView; + +public class IconTabWidget extends TabWidget { + OnTabChangedListener mListener; + private final int mButtonLayoutId; + private final boolean mIsIcon; + + public static interface OnTabChangedListener { + public void onTabChanged(int tabIndex); + } + + public IconTabWidget(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.IconTabWidget); + mButtonLayoutId = a.getResourceId(R.styleable.IconTabWidget_android_layout, 0); + mIsIcon = (a.getInt(R.styleable.IconTabWidget_display, 0x00) == 0x00); + a.recycle(); + + if (mButtonLayoutId == 0) { + throw new RuntimeException("You must supply layout attribute"); + } + } + + public View addTab(final int imageResId, final int stringResId) { + View button = LayoutInflater.from(getContext()).inflate(mButtonLayoutId, this, false); + if (mIsIcon) { + ((ImageButton) button).setImageResource(imageResId); + button.setContentDescription(getContext().getString(stringResId)); + } else { + ((TextView) button).setText(getContext().getString(stringResId)); + } + + addView(button); + button.setOnClickListener(new TabClickListener(getTabCount() - 1)); + button.setOnFocusChangeListener(this); + return button; + } + + public void setTabSelectionListener(OnTabChangedListener listener) { + mListener = listener; + } + + @Override + public void onFocusChange(View view, boolean hasFocus) { + } + + private class TabClickListener implements OnClickListener { + private final int mIndex; + + public TabClickListener(int index) { + mIndex = index; + } + + @Override + public void onClick(View view) { + if (mListener != null) + mListener.onTabChanged(mIndex); + } + } + + /** + * Fetch the Drawable icon corresponding to the given panel. + * @param panel to fetch icon for. + * @return Drawable instance, or null if no icon is being displayed, or the icon does not exist. + */ + public Drawable getIconDrawable(int index) { + if (!mIsIcon) { + return null; + } + // We can have multiple views in the tabs panel for each child. This finds the + // first view corresponding to the given tab. This varies by Android + // version. The first view should always be our ImageButton, but let's + // be safe. + final View view = getChildTabViewAt(index); + if (view instanceof ImageButton) { + return ((ImageButton) view).getDrawable(); + } + return null; + } + + public void setIconDrawable(int index, int resource) { + if (!mIsIcon) { + return; + } + // We can have multiple views in the tabs panel for each child. This finds the + // first view corresponding to the given tab. This varies by Android + // version. The first view should always be our ImageButton, but let's + // be safe. + final View view = getChildTabViewAt(index); + if (view instanceof ImageButton) { + ((ImageButton) view).setImageResource(resource); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java new file mode 100644 index 000000000..232674813 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java @@ -0,0 +1,228 @@ +/* -*- 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.widget; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.PasswordTransformationMethod; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; + +import java.util.Locale; + +public class LoginDoorHanger extends DoorHanger { + private static final String LOGTAG = "LoginDoorHanger"; + private enum ActionType { EDIT, SELECT } + + private final TextView mMessage; + private final DoorhangerConfig.ButtonConfig mButtonConfig; + + public LoginDoorHanger(Context context, DoorhangerConfig config) { + super(context, config, Type.LOGIN); + + mMessage = (TextView) findViewById(R.id.doorhanger_message); + mIcon.setImageResource(R.drawable.icon_key); + mIcon.setVisibility(View.VISIBLE); + + mButtonConfig = config.getPositiveButtonConfig(); + + loadConfig(config); + } + + private void setMessage(String message) { + Spanned markupMessage = Html.fromHtml(message); + mMessage.setText(markupMessage); + } + + @Override + protected void loadConfig(DoorhangerConfig config) { + setOptions(config.getOptions()); + setMessage(config.getMessage()); + // Store the positive callback id for nested dialogs that need the same callback id. + addButtonsToLayout(config); + } + + @Override + protected int getContentResource() { + return R.layout.login_doorhanger; + } + + @Override + protected void setOptions(final JSONObject options) { + super.setOptions(options); + + final JSONObject actionText = options.optJSONObject("actionText"); + addActionText(actionText); + } + + @Override + protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) { + return new Button.OnClickListener() { + @Override + public void onClick(View v) { + final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra; + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra); + + final JSONObject response = new JSONObject(); + try { + response.put("callback", id); + } catch (JSONException e) { + Log.e(LOGTAG, "Error making doorhanger response message", e); + } + mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this); + } + }; + } + + /** + * Add sub-text to the doorhanger and add the click action. + * + * If the parsing the action from the JSON throws, the text is left visible, but there is no + * click action. + * @param actionTextObj JSONObject containing blob for making an action. + */ + private void addActionText(JSONObject actionTextObj) { + if (actionTextObj == null) { + mLink.setVisibility(View.GONE); + return; + } + + // Make action. + try { + final JSONObject bundle = actionTextObj.getJSONObject("bundle"); + final ActionType type = ActionType.valueOf(actionTextObj.getString("type")); + final AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + + switch (type) { + case EDIT: + builder.setTitle(mResources.getString(R.string.doorhanger_login_edit_title)); + + final View view = LayoutInflater.from(mContext).inflate(R.layout.login_edit_dialog, null); + final EditText username = (EditText) view.findViewById(R.id.username_edit); + username.setText(bundle.getString("username")); + final EditText password = (EditText) view.findViewById(R.id.password_edit); + password.setText(bundle.getString("password")); + final CheckBox passwordCheckbox = (CheckBox) view.findViewById(R.id.checkbox_toggle_password); + passwordCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + password.setTransformationMethod(null); + } else { + password.setTransformationMethod(PasswordTransformationMethod.getInstance()); + } + } + }); + builder.setView(view); + + builder.setPositiveButton(mButtonConfig.label, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + JSONObject response = new JSONObject(); + try { + response.put("callback", mButtonConfig.callback); + final JSONObject inputs = new JSONObject(); + inputs.put("username", username.getText()); + inputs.put("password", password.getText()); + response.put("inputs", inputs); + } catch (JSONException e) { + Log.e(LOGTAG, "Error creating doorhanger reply message"); + response = null; + Toast.makeText(mContext, mResources.getString(R.string.doorhanger_login_edit_toast_error), Toast.LENGTH_SHORT).show(); + } + mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this); + } + }); + builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + String text = actionTextObj.optString("text"); + if (TextUtils.isEmpty(text)) { + text = mResources.getString(R.string.doorhanger_login_no_username); + } + mLink.setText(text); + mLink.setVisibility(View.VISIBLE); + break; + + case SELECT: + try { + builder.setTitle(mResources.getString(R.string.doorhanger_login_select_title)); + final JSONArray logins = bundle.getJSONArray("logins"); + final int numLogins = logins.length(); + final CharSequence[] usernames = new CharSequence[numLogins]; + final String[] passwords = new String[numLogins]; + final String noUser = mResources.getString(R.string.doorhanger_login_no_username); + for (int i = 0; i < numLogins; i++) { + final JSONObject login = (JSONObject) logins.get(i); + String user = login.getString("username"); + if (TextUtils.isEmpty(user)) { + user = noUser; + } + usernames[i] = user; + passwords[i] = login.getString("password"); + } + builder.setItems(usernames, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final JSONObject response = new JSONObject(); + try { + response.put("callback", mButtonConfig.callback); + response.put("password", passwords[which]); + } catch (JSONException e) { + Log.e(LOGTAG, "Error making login select dialog JSON", e); + } + mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this); + } + }); + builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + mLink.setText(R.string.doorhanger_login_select_action_text); + mLink.setVisibility(View.VISIBLE); + } catch (JSONException e) { + Log.e(LOGTAG, "Problem creating list of logins"); + } + break; + } + + final Dialog dialog = builder.create(); + mLink.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + dialog.show(); + } + }); + + } catch (JSONException e) { + Log.e(LOGTAG, "Error fetching actionText from JSON", e); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java b/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java new file mode 100644 index 000000000..a0c6049c5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java @@ -0,0 +1,105 @@ +/* -*- 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.widget; + +import android.support.v7.widget.RecyclerView; +import android.view.View; + +import org.mozilla.gecko.R; + +/** + * {@link RecyclerViewClickSupport} implementation that will notify an OnClickListener about clicks and long clicks + * on items displayed by the RecyclerView. + * @see <a href="http://www.littlerobots.nl/blog/Handle-Android-RecyclerView-Clicks/">littlerobots.nl</a> + */ +public class RecyclerViewClickSupport { + private final RecyclerView mRecyclerView; + private OnItemClickListener mOnItemClickListener; + private OnItemLongClickListener mOnItemLongClickListener; + private View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mOnItemClickListener != null) { + RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v); + mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v); + } + } + }; + private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (mOnItemLongClickListener != null) { + RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v); + return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v); + } + return false; + } + }; + private RecyclerView.OnChildAttachStateChangeListener mAttachListener + = new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(View view) { + if (mOnItemClickListener != null) { + view.setOnClickListener(mOnClickListener); + } + if (mOnItemLongClickListener != null) { + view.setOnLongClickListener(mOnLongClickListener); + } + } + + @Override + public void onChildViewDetachedFromWindow(View view) { + + } + }; + + private RecyclerViewClickSupport(RecyclerView recyclerView) { + mRecyclerView = recyclerView; + mRecyclerView.setTag(R.id.recycler_view_click_support, this); + mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener); + } + + public static RecyclerViewClickSupport addTo(RecyclerView view) { + RecyclerViewClickSupport support = (RecyclerViewClickSupport) view.getTag(R.id.recycler_view_click_support); + if (support == null) { + support = new RecyclerViewClickSupport(view); + } + return support; + } + + public static RecyclerViewClickSupport removeFrom(RecyclerView view) { + RecyclerViewClickSupport support = (RecyclerViewClickSupport) view.getTag(R.id.recycler_view_click_support); + if (support != null) { + support.detach(view); + } + return support; + } + + public RecyclerViewClickSupport setOnItemClickListener(OnItemClickListener listener) { + mOnItemClickListener = listener; + return this; + } + + public RecyclerViewClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) { + mOnItemLongClickListener = listener; + return this; + } + + private void detach(RecyclerView view) { + view.removeOnChildAttachStateChangeListener(mAttachListener); + view.setTag(R.id.recycler_view_click_support, null); + } + + public interface OnItemClickListener { + + void onItemClicked(RecyclerView recyclerView, int position, View v); + } + + public interface OnItemLongClickListener { + + boolean onItemLongClicked(RecyclerView recyclerView, int position, View v); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java b/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java new file mode 100644 index 000000000..ff0709cb7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java @@ -0,0 +1,117 @@ +/* -*- 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.widget; + +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.Shape; + +public class ResizablePathDrawable extends ShapeDrawable { + // An attribute mirroring the super class' value. getAlpha() is only + // available in API 19+ so to use that alpha value, we have to mirror it. + private int alpha = 255; + + private final ColorStateList colorStateList; + private int currentColor; + + public ResizablePathDrawable(NonScaledPathShape shape, int color) { + this(shape, ColorStateList.valueOf(color)); + } + + public ResizablePathDrawable(NonScaledPathShape shape, ColorStateList colorStateList) { + super(shape); + this.colorStateList = colorStateList; + updateColor(getState()); + } + + private boolean updateColor(int[] stateSet) { + int newColor = colorStateList.getColorForState(stateSet, Color.WHITE); + if (newColor != currentColor) { + currentColor = newColor; + alpha = Color.alpha(currentColor); + invalidateSelf(); + return true; + } + + return false; + } + + public Path getPath() { + final NonScaledPathShape shape = (NonScaledPathShape) getShape(); + return shape.path; + } + + @Override + public boolean isStateful() { + return true; + } + + @Override + protected void onDraw(Shape shape, Canvas canvas, Paint paint) { + paint.setColor(currentColor); + // setAlpha overrides the alpha value in set color. Since we just set the color, + // the alpha value is reset: override the alpha value with the old value. We don't + // set alpha if the color is transparent. + // + // Note: We *should* be able to call Shape.setAlpha, rather than Paint.setAlpha, but + // then the opacity doesn't change - dunno why but probably not worth the time. + if (currentColor != Color.TRANSPARENT) { + paint.setAlpha(alpha); + } + + super.onDraw(shape, canvas, paint); + } + + @Override + public void setAlpha(final int alpha) { + super.setAlpha(alpha); + this.alpha = alpha; + } + + @Override + protected boolean onStateChange(int[] stateSet) { + return updateColor(stateSet); + } + + /** + * Path-based shape implementation that re-creates the path + * when it gets resized as opposed to PathShape's scaling + * behaviour. + */ + public static class NonScaledPathShape extends Shape { + private Path path; + + public NonScaledPathShape() { + path = new Path(); + } + + @Override + public void draw(Canvas canvas, Paint paint) { + // No point in drawing the shape if it's not + // going to be visible. + if (paint.getColor() == Color.TRANSPARENT) { + return; + } + + canvas.drawPath(path, paint); + } + + protected Path getPath() { + return path; + } + + @Override + public NonScaledPathShape clone() throws CloneNotSupportedException { + final NonScaledPathShape clonedShape = (NonScaledPathShape) super.clone(); + clonedShape.path = new Path(path); + return clonedShape; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java new file mode 100644 index 000000000..a102981ee --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java @@ -0,0 +1,79 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.widget.LinearLayout; + +public class RoundedCornerLayout extends LinearLayout { + private static final String LOGTAG = "Gecko" + RoundedCornerLayout.class.getSimpleName(); + private float cornerRadius; + + private Path path; + boolean cannotClipPath; + + public RoundedCornerLayout(Context context) { + super(context); + init(context); + } + + public RoundedCornerLayout(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public RoundedCornerLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + private void init(Context context) { + // Bug 1201081 - clipPath with hardware acceleration crashes on r11-18. + cannotClipPath = !AppConstants.Versions.feature19Plus; + + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + + cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimensionPixelSize(R.dimen.doorhanger_rounded_corner_radius), metrics); + + setWillNotDraw(false); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (cannotClipPath) { + return; + } + + final RectF r = new RectF(0, 0, w, h); + path = new Path(); + path.addRoundRect(r, cornerRadius, cornerRadius, Path.Direction.CW); + path.close(); + } + + @Override + public void draw(Canvas canvas) { + if (cannotClipPath) { + super.draw(canvas); + return; + } + + canvas.save(); + canvas.clipPath(path); + super.draw(canvas); + canvas.restore(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java b/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java new file mode 100644 index 000000000..4d4a92275 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java @@ -0,0 +1,16 @@ +package org.mozilla.gecko.widget; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class SiteLogins { + private final JSONArray logins; + + public SiteLogins(JSONArray logins) { + this.logins = logins; + } + + public JSONArray getLogins() { + return logins; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java new file mode 100644 index 000000000..0b77e9d1c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java @@ -0,0 +1,21 @@ +package org.mozilla.gecko.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +final class SquaredImageView extends ImageView { + public SquaredImageView(Context context) { + super(context); + } + + public SquaredImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + setMeasuredDimension(getMeasuredWidth(), getMeasuredWidth()); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java new file mode 100644 index 000000000..c0dca0bec --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java @@ -0,0 +1,33 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.RelativeLayout; + +public class SquaredRelativeLayout extends RelativeLayout { + public SquaredRelativeLayout(Context context) { + super(context); + } + + public SquaredRelativeLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SquaredRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + int squareMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY); + + super.onMeasure(squareMeasureSpec, squareMeasureSpec); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java b/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java new file mode 100644 index 000000000..8267fe8a3 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java @@ -0,0 +1,356 @@ +/* + * Copyright 2012 Roman Nurik + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.widget; + +import android.graphics.Rect; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.RecyclerListener; +import android.widget.ListView; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.view.ViewPropertyAnimator; + +import org.mozilla.gecko.R; + +/** + * This code is based off of Jake Wharton's NOA port (https://github.com/JakeWharton/SwipeToDismissNOA) + * of Roman Nurik's SwipeToDismiss library. It has been modified for better support with async + * adapters. + * + * A {@link android.view.View.OnTouchListener} that makes the list items in a {@link ListView} + * dismissable. {@link ListView} is given special treatment because by default it handles touches + * for its list items... i.e. it's in charge of drawing the pressed state (the list selector), + * handling list item clicks, etc. + * + * <p>After creating the listener, the caller should also call + * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}, passing + * in the scroll listener returned by {@link #makeScrollListener()}. If a scroll listener is + * already assigned, the caller should still pass scroll changes through to this listener. This will + * ensure that this {@link SwipeDismissListViewTouchListener} is paused during list view + * scrolling.</p> + * + * <p>Example usage:</p> + * + * <pre> + * SwipeDismissListViewTouchListener touchListener = + * new SwipeDismissListViewTouchListener( + * listView, + * new SwipeDismissListViewTouchListener.OnDismissCallback() { + * public void onDismiss(ListView listView, int[] reverseSortedPositions) { + * for (int position : reverseSortedPositions) { + * adapter.remove(adapter.getItem(position)); + * } + * adapter.notifyDataSetChanged(); + * } + * }); + * listView.setOnTouchListener(touchListener); + * listView.setOnScrollListener(touchListener.makeScrollListener()); + * </pre> + * + * <p>For a generalized {@link android.view.View.OnTouchListener} that makes any view dismissable, + * see {@link SwipeDismissTouchListener}.</p> + * + * @see SwipeDismissTouchListener + */ +public class SwipeDismissListViewTouchListener implements View.OnTouchListener { + // Cached ViewConfiguration and system-wide constant values + private final int mSlop; + private final int mMinFlingVelocity; + private final int mMaxFlingVelocity; + private final long mAnimationTime; + + // Fixed properties + private final ListView mListView; + private final OnDismissCallback mCallback; + private int mViewWidth = 1; // 1 and not 0 to prevent dividing by zero + + // Transient properties + private float mDownX; + private boolean mSwiping; + private VelocityTracker mVelocityTracker; + private int mDownPosition; + private View mDownView; + private boolean mPaused; + private boolean mDismissing; + + /** + * The callback interface used by {@link SwipeDismissListViewTouchListener} to inform its client + * about a successful dismissal of a list item. + */ + public interface OnDismissCallback { + /** + * Called when the user has indicated they she would like to dismiss one or more list item + * positions. + * + * @param listView The originating {@link ListView}. + * @param position The position being dismissed. + */ + void onDismiss(ListView listView, int position); + } + + /** + * Constructs a new swipe-to-dismiss touch listener for the given list view. + * + * @param listView The list view whose items should be dismissable. + * @param callback The callback to trigger when the user has indicated that she would like to + * dismiss one or more list items. + */ + public SwipeDismissListViewTouchListener(ListView listView, OnDismissCallback callback) { + ViewConfiguration vc = ViewConfiguration.get(listView.getContext()); + mSlop = vc.getScaledTouchSlop(); + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + mAnimationTime = listView.getContext().getResources().getInteger( + android.R.integer.config_shortAnimTime); + mListView = listView; + mCallback = callback; + } + + /** + * Enables or disables (pauses or resumes) watching for swipe-to-dismiss gestures. + * + * @param enabled Whether or not to watch for gestures. + */ + public void setEnabled(boolean enabled) { + mPaused = !enabled; + } + + /** + * Returns an {@link android.widget.AbsListView.OnScrollListener} to be added to the + * {@link ListView} using + * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}. + * If a scroll listener is already assigned, the caller should still pass scroll changes + * through to this listener. This will ensure that this + * {@link SwipeDismissListViewTouchListener} is paused during list view scrolling.</p> + * + * @see {@link SwipeDismissListViewTouchListener} + */ + public AbsListView.OnScrollListener makeScrollListener() { + return new AbsListView.OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView absListView, int scrollState) { + setEnabled(scrollState != AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + } + + @Override + public void onScroll(AbsListView absListView, int i, int i1, int i2) { + } + }; + } + + /** + * Returns a {@link android.widget.AbsListView.RecyclerListener} to be added to the + * {@link ListView} using {@link ListView#setRecyclerListener(RecyclerListener)}. + */ + public AbsListView.RecyclerListener makeRecyclerListener() { + return new AbsListView.RecyclerListener() { + @Override + public void onMovedToScrapHeap(View view) { + final Object tag = view.getTag(R.id.original_height); + + // To reset the view to the correct height after its animation, the view's height + // is stored in its tag. Reset the view here. + if (tag instanceof Integer) { + view.setAlpha(1f); + view.setTranslationX(0); + final ViewGroup.LayoutParams lp = view.getLayoutParams(); + lp.height = (int) tag; + view.setLayoutParams(lp); + view.setTag(R.id.original_height, null); + } + } + }; + } + + @Override + public boolean onTouch(View view, MotionEvent motionEvent) { + if (mViewWidth < 2) { + mViewWidth = mListView.getWidth(); + } + + switch (motionEvent.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + if (mPaused) { + return false; + } + + if (mDismissing) { + return true; + } + + // TODO: ensure this is a finger, and set a flag + + // Find the child view that was touched (perform a hit test) + Rect rect = new Rect(); + int childCount = mListView.getChildCount(); + int[] listViewCoords = new int[2]; + mListView.getLocationOnScreen(listViewCoords); + int x = (int) motionEvent.getRawX() - listViewCoords[0]; + int y = (int) motionEvent.getRawY() - listViewCoords[1]; + View child; + for (int i = 0; i < childCount; i++) { + child = mListView.getChildAt(i); + child.getHitRect(rect); + if (rect.contains(x, y)) { + mDownView = child; + break; + } + } + + if (mDownView != null) { + mDownX = motionEvent.getRawX(); + mDownPosition = mListView.getPositionForView(mDownView); + + mVelocityTracker = VelocityTracker.obtain(); + mVelocityTracker.addMovement(motionEvent); + } + view.onTouchEvent(motionEvent); + return true; + } + + case MotionEvent.ACTION_UP: { + if (mVelocityTracker == null) { + break; + } + + float deltaX = motionEvent.getRawX() - mDownX; + mVelocityTracker.addMovement(motionEvent); + mVelocityTracker.computeCurrentVelocity(1000); + float velocityX = Math.abs(mVelocityTracker.getXVelocity()); + float velocityY = Math.abs(mVelocityTracker.getYVelocity()); + boolean dismiss = false; + boolean dismissRight = false; + if (Math.abs(deltaX) > mViewWidth / 2) { + dismiss = true; + dismissRight = deltaX > 0; + } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity + && velocityY < velocityX) { + dismiss = true; + dismissRight = mVelocityTracker.getXVelocity() > 0; + } + if (dismiss) { + // dismiss + mDismissing = true; + final View downView = mDownView; // mDownView gets null'd before animation ends + final int downPosition = mDownPosition; + mDownView.animate() + .translationX(dismissRight ? mViewWidth : -mViewWidth) + .alpha(0) + .setDuration(mAnimationTime) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + performDismiss(downView, downPosition); + } + }); + } else { + // cancel + mDownView.animate() + .translationX(0) + .alpha(1) + .setDuration(mAnimationTime) + .setListener(null); + } + + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + + mDownX = 0; + mDownView = null; + mDownPosition = ListView.INVALID_POSITION; + mSwiping = false; + break; + } + + case MotionEvent.ACTION_MOVE: { + if (mVelocityTracker == null || mPaused) { + break; + } + + mVelocityTracker.addMovement(motionEvent); + float deltaX = motionEvent.getRawX() - mDownX; + if (Math.abs(deltaX) > mSlop) { + mSwiping = true; + mListView.requestDisallowInterceptTouchEvent(true); + + // Cancel ListView's touch (un-highlighting the item) + MotionEvent cancelEvent = MotionEvent.obtain(motionEvent); + cancelEvent.setAction(MotionEvent.ACTION_CANCEL | + (motionEvent.getActionIndex() + << MotionEvent.ACTION_POINTER_INDEX_SHIFT)); + mListView.onTouchEvent(cancelEvent); + cancelEvent.recycle(); + } + + if (mSwiping) { + mDownView.setTranslationX(deltaX); + mDownView.setAlpha(Math.max(0f, Math.min(1f, 1f - 2f * Math.abs(deltaX) / mViewWidth))); + return true; + } + break; + } + } + return false; + } + + /** + * Animate the dismissed list item to zero-height and fire the dismiss callback when it finishes. + * + * @param dismissView ListView item to dismiss + * @param dismissPosition Position of dismissed item + */ + private void performDismiss(final View dismissView, final int dismissPosition) { + final ViewGroup.LayoutParams lp = dismissView.getLayoutParams(); + final int originalHeight = lp.height; + + ValueAnimator animator = ValueAnimator.ofInt(dismissView.getHeight(), 1).setDuration(mAnimationTime); + + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // Since the view is still a part of the ListView, we can't reset the animated + // properties yet; otherwise, the view would briefly reappear. Store the original + // height in the view's tag to flag it for the recycler. This is racy since the user + // could scroll the dismissed view off the screen, then back on the screen, before + // it's removed from the adapter, causing the dismissed view to briefly reappear. + dismissView.setTag(R.id.original_height, originalHeight); + + mCallback.onDismiss(mListView, dismissPosition); + mDismissing = false; + } + }); + + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + lp.height = (Integer) valueAnimator.getAnimatedValue(); + dismissView.setLayoutParams(lp); + } + }); + + animator.start(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java b/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java new file mode 100644 index 000000000..848e2f6ed --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java @@ -0,0 +1,38 @@ +package org.mozilla.gecko.widget; + +import android.content.Context; +import android.util.AttributeSet; +import org.mozilla.gecko.R; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + + +public class TabThumbnailWrapper extends ThemedRelativeLayout { + private boolean mRecording; + private static final int[] STATE_RECORDING = { R.attr.state_recording }; + + public TabThumbnailWrapper(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public TabThumbnailWrapper(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (mRecording) { + mergeDrawableStates(drawableState, STATE_RECORDING); + } + return drawableState; + } + + public void setRecording(boolean recording) { + if (mRecording != recording) { + mRecording = recording; + refreshDrawableState(); + } + } + +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java new file mode 100644 index 000000000..5ab00ea7f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java @@ -0,0 +1,86 @@ +/* -*- 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.widget; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.widget.themed.ThemedImageView; + +/* Special version of ImageView for thumbnails. Scales a thumbnail so that it maintains its aspect + * ratio and so that the images width and height are the same size or greater than the view size + */ +public class ThumbnailView extends ThemedImageView { + private static final String LOGTAG = "GeckoThumbnailView"; + + final private Matrix mMatrix; + private int mWidthSpec = -1; + private int mHeightSpec = -1; + private boolean mLayoutChanged; + private boolean mScale = false; + + public ThumbnailView(Context context, AttributeSet attrs) { + super(context, attrs); + mMatrix = new Matrix(); + mLayoutChanged = true; + } + + @Override + public void onDraw(Canvas canvas) { + if (!mScale) { + super.onDraw(canvas); + return; + } + + Drawable d = getDrawable(); + if (mLayoutChanged) { + int w1 = d.getIntrinsicWidth(); + int h1 = d.getIntrinsicHeight(); + int w2 = getWidth(); + int h2 = getHeight(); + + float scale = ((w2 / h2) < (w1 / h1)) ? (float) h2 / h1 : (float) w2 / w1; + mMatrix.setScale(scale, scale); + } + + int saveCount = canvas.save(); + canvas.concat(mMatrix); + d.draw(canvas); + canvas.restoreToCount(saveCount); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // OnLayout.changed isn't a reliable measure of whether or not the size of this view has changed + // neither is onSizeChanged called often enough. Instead, we track changes in size ourselves, and + // only invalidate this matrix if we have a new width/height spec + if (widthMeasureSpec != mWidthSpec || heightMeasureSpec != mHeightSpec) { + mWidthSpec = widthMeasureSpec; + mHeightSpec = heightMeasureSpec; + mLayoutChanged = true; + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public void setImageDrawable(Drawable drawable) { + if (drawable == null) { + drawable = ContextCompat.getDrawable(getContext(), R.drawable.tab_panel_tab_background); + setScaleType(ScaleType.FIT_XY); + mScale = false; + } else { + mScale = true; + setScaleType(ScaleType.FIT_CENTER); + } + + super.setImageDrawable(drawable); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java b/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java new file mode 100644 index 000000000..52e0b1fd0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java @@ -0,0 +1,134 @@ +/* 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.widget; + +import android.graphics.Rect; +import android.view.MotionEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.view.ViewConfiguration; + +/** + * This is a copy of TouchDelegate from + * https://github.com/android/platform_frameworks_base/blob/4b1a8f46d6ec55796bf77fd8921a5a242a219278/core/java/android/view/TouchDelegate.java + * with a fix to reset mDelegateTargeted on each new gesture - the sole substantive change is a new + * else leg in the ACTION_DOWN case of onTouchEvent marked by "START|END BUG FIX" comments. + */ + +/** + * Helper class to handle situations where you want a view to have a larger touch area than its + * actual view bounds. The view whose touch area is changed is called the delegate view. This + * class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an + * instance that specifies the bounds that should be mapped to the delegate and the delegate + * view itself. + * <p> + * The ancestor should then forward all of its touch events received in its + * {@link android.view.View#onTouchEvent(MotionEvent)} to {@link #onTouchEvent(MotionEvent)}. + * </p> + */ +public class TouchDelegateWithReset extends TouchDelegate { + + /** + * View that should receive forwarded touch events + */ + private View mDelegateView; + + /** + * Bounds in local coordinates of the containing view that should be mapped to the delegate + * view. This rect is used for initial hit testing. + */ + private Rect mBounds; + + /** + * mBounds inflated to include some slop. This rect is to track whether the motion events + * should be considered to be be within the delegate view. + */ + private Rect mSlopBounds; + + /** + * True if the delegate had been targeted on a down event (intersected mBounds). + */ + private boolean mDelegateTargeted; + + private int mSlop; + + /** + * Constructor + * + * @param bounds Bounds in local coordinates of the containing view that should be mapped to + * the delegate view + * @param delegateView The view that should receive motion events + */ + public TouchDelegateWithReset(Rect bounds, View delegateView) { + super(bounds, delegateView); + + mBounds = bounds; + + mSlop = ViewConfiguration.get(delegateView.getContext()).getScaledTouchSlop(); + mSlopBounds = new Rect(bounds); + mSlopBounds.inset(-mSlop, -mSlop); + mDelegateView = delegateView; + } + + /** + * Will forward touch events to the delegate view if the event is within the bounds + * specified in the constructor. + * + * @param event The touch event to forward + * @return True if the event was forwarded to the delegate, false otherwise. + */ + @Override + public boolean onTouchEvent(MotionEvent event) { + int x = (int)event.getX(); + int y = (int)event.getY(); + boolean sendToDelegate = false; + boolean hit = true; + boolean handled = false; + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + Rect bounds = mBounds; + + if (bounds.contains(x, y)) { + mDelegateTargeted = true; + sendToDelegate = true; + } /* START BUG FIX */ + else { + mDelegateTargeted = false; + } + /* END BUG FIX */ + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_MOVE: + sendToDelegate = mDelegateTargeted; + if (sendToDelegate) { + Rect slopBounds = mSlopBounds; + if (!slopBounds.contains(x, y)) { + hit = false; + } + } + break; + case MotionEvent.ACTION_CANCEL: + sendToDelegate = mDelegateTargeted; + mDelegateTargeted = false; + break; + } + if (sendToDelegate) { + final View delegateView = mDelegateView; + + if (hit) { + // Offset event coordinates to be inside the target view + event.setLocation(delegateView.getWidth() / 2, delegateView.getHeight() / 2); + } else { + // Offset event coordinates to be outside the target view (in case it does + // something like tracking pressed state) + int slop = mSlop; + event.setLocation(-(slop * 2), -(slop * 2)); + } + handled = delegateView.dispatchTouchEvent(event); + } + return handled; + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java b/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java new file mode 100644 index 000000000..b5ad36ab7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java @@ -0,0 +1,7191 @@ +/* + * Copyright (C) 2013 Lucas Rocha + * + * This code is based on bits and pieces of Android's AbsListView, + * Listview, and StaggeredGridView. + * + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.widget; + +import org.mozilla.gecko.R; + +import java.util.ArrayList; +import java.util.List; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.SystemClock; +import android.support.v4.util.LongSparseArray; +import android.support.v4.util.SparseArrayCompat; +import android.support.v4.view.AccessibilityDelegateCompat; +import android.support.v4.view.KeyEventCompat; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.VelocityTrackerCompat; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; +import android.support.v4.widget.EdgeEffectCompat; +import android.util.AttributeSet; +import android.util.Log; +import android.util.SparseBooleanArray; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.FocusFinder; +import android.view.HapticFeedbackConstants; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.SoundEffectConstants; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.AdapterView; +import android.widget.Checkable; +import android.widget.ListAdapter; +import android.widget.Scroller; + +import static android.os.Build.VERSION_CODES.HONEYCOMB; + +/* + * Implementation Notes: + * + * Some terminology: + * + * index - index of the items that are currently visible + * position - index of the items in the cursor + * + * Given the bi-directional nature of this view, the source code + * usually names variables with 'start' to mean 'top' or 'left'; and + * 'end' to mean 'bottom' or 'right', depending on the current + * orientation of the widget. + */ + +/** + * A view that shows items in a vertical or horizontal scrolling list. + * The items come from the {@link ListAdapter} associated with this view. + */ +public class TwoWayView extends AdapterView<ListAdapter> implements + ViewTreeObserver.OnTouchModeChangeListener { + private static final String LOGTAG = "TwoWayView"; + + private static final int NO_POSITION = -1; + private static final int INVALID_POINTER = -1; + + public static final int[] STATE_NOTHING = new int[] { 0 }; + + private static final int TOUCH_MODE_REST = -1; + private static final int TOUCH_MODE_DOWN = 0; + private static final int TOUCH_MODE_TAP = 1; + private static final int TOUCH_MODE_DONE_WAITING = 2; + private static final int TOUCH_MODE_DRAGGING = 3; + private static final int TOUCH_MODE_FLINGING = 4; + private static final int TOUCH_MODE_OVERSCROLL = 5; + + private static final int TOUCH_MODE_UNKNOWN = -1; + private static final int TOUCH_MODE_ON = 0; + private static final int TOUCH_MODE_OFF = 1; + + private static final int LAYOUT_NORMAL = 0; + private static final int LAYOUT_FORCE_TOP = 1; + private static final int LAYOUT_SET_SELECTION = 2; + private static final int LAYOUT_FORCE_BOTTOM = 3; + private static final int LAYOUT_SPECIFIC = 4; + private static final int LAYOUT_SYNC = 5; + private static final int LAYOUT_MOVE_SELECTION = 6; + + private static final int SYNC_SELECTED_POSITION = 0; + private static final int SYNC_FIRST_POSITION = 1; + + private static final int SYNC_MAX_DURATION_MILLIS = 100; + + private static final int CHECK_POSITION_SEARCH_DISTANCE = 20; + + private static final float MAX_SCROLL_FACTOR = 0.33f; + + private static final int MIN_SCROLL_PREVIEW_PIXELS = 10; + + public static enum ChoiceMode { + NONE, + SINGLE, + MULTIPLE + } + + public static enum Orientation { + HORIZONTAL, + VERTICAL + } + + private final Context mContext; + + private ListAdapter mAdapter; + + private boolean mIsVertical; + + private int mItemMargin; + + private boolean mInLayout; + private boolean mBlockLayoutRequests; + + private boolean mIsAttached; + + private final RecycleBin mRecycler; + private AdapterDataSetObserver mDataSetObserver; + + private boolean mItemsCanFocus; + + final boolean[] mIsScrap = new boolean[1]; + + private boolean mDataChanged; + private int mItemCount; + private int mOldItemCount; + private boolean mHasStableIds; + private boolean mAreAllItemsSelectable; + + private int mFirstPosition; + private int mSpecificStart; + + private SavedState mPendingSync; + + private PositionScroller mPositionScroller; + private Runnable mPositionScrollAfterLayout; + + private final int mTouchSlop; + private final int mMaximumVelocity; + private final int mFlingVelocity; + private float mLastTouchPos; + private float mTouchRemainderPos; + private int mActivePointerId; + + private final Rect mTempRect; + + private final ArrowScrollFocusResult mArrowScrollFocusResult; + + private Rect mTouchFrame; + private int mMotionPosition; + private CheckForTap mPendingCheckForTap; + private CheckForLongPress mPendingCheckForLongPress; + private CheckForKeyLongPress mPendingCheckForKeyLongPress; + private PerformClick mPerformClick; + private Runnable mTouchModeReset; + private int mResurrectToPosition; + + private boolean mIsChildViewEnabled; + + private boolean mDrawSelectorOnTop; + private Drawable mSelector; + private int mSelectorPosition; + private final Rect mSelectorRect; + + private int mOverScroll; + private final int mOverscrollDistance; + + private boolean mDesiredFocusableState; + private boolean mDesiredFocusableInTouchModeState; + + private SelectionNotifier mSelectionNotifier; + + private boolean mNeedSync; + private int mSyncMode; + private int mSyncPosition; + private long mSyncRowId; + private long mSyncSize; + private int mSelectedStart; + + private int mNextSelectedPosition; + private long mNextSelectedRowId; + private int mSelectedPosition; + private long mSelectedRowId; + private int mOldSelectedPosition; + private long mOldSelectedRowId; + + private ChoiceMode mChoiceMode; + private int mCheckedItemCount; + private SparseBooleanArray mCheckStates; + LongSparseArray<Integer> mCheckedIdStates; + + private ContextMenuInfo mContextMenuInfo; + + private int mLayoutMode; + private int mTouchMode; + private int mLastTouchMode; + private VelocityTracker mVelocityTracker; + private final Scroller mScroller; + + private EdgeEffectCompat mStartEdge; + private EdgeEffectCompat mEndEdge; + + private OnScrollListener mOnScrollListener; + private int mLastScrollState; + + private View mEmptyView; + + private ListItemAccessibilityDelegate mAccessibilityDelegate; + + private int mLastAccessibilityScrollEventFromIndex; + private int mLastAccessibilityScrollEventToIndex; + + public interface OnScrollListener { + + /** + * The view is not scrolling. Note navigating the list using the trackball counts as + * being in the idle state since these transitions are not animated. + */ + public static int SCROLL_STATE_IDLE = 0; + + /** + * The user is scrolling using touch, and their finger is still on the screen + */ + public static int SCROLL_STATE_TOUCH_SCROLL = 1; + + /** + * The user had previously been scrolling using touch and had performed a fling. The + * animation is now coasting to a stop + */ + public static int SCROLL_STATE_FLING = 2; + + /** + * Callback method to be invoked while the list view or grid view is being scrolled. If the + * view is being scrolled, this method will be called before the next frame of the scroll is + * rendered. In particular, it will be called before any calls to + * {@link android.widget.Adapter#getView(int, View, ViewGroup)}. + * + * @param view The view whose scroll state is being reported + * + * @param scrollState The current scroll state. One of {@link #SCROLL_STATE_IDLE}, + * {@link #SCROLL_STATE_TOUCH_SCROLL} or {@link #SCROLL_STATE_IDLE}. + */ + public void onScrollStateChanged(TwoWayView view, int scrollState); + + /** + * Callback method to be invoked when the list or grid has been scrolled. This will be + * called after the scroll has completed + * @param view The view whose scroll state is being reported + * @param firstVisibleItem the index of the first visible cell (ignore if + * visibleItemCount == 0) + * @param visibleItemCount the number of visible cells + * @param totalItemCount the number of items in the list adaptor + */ + public void onScroll(TwoWayView view, int firstVisibleItem, int visibleItemCount, + int totalItemCount); + } + + /** + * A RecyclerListener is used to receive a notification whenever a View is placed + * inside the RecycleBin's scrap heap. This listener is used to free resources + * associated to Views placed in the RecycleBin. + * + * @see TwoWayView.RecycleBin + * @see TwoWayView#setRecyclerListener(TwoWayView.RecyclerListener) + */ + public static interface RecyclerListener { + /** + * Indicates that the specified View was moved into the recycler's scrap heap. + * The view is not displayed on screen any more and any expensive resource + * associated with the view should be discarded. + * + * @param view + */ + void onMovedToScrapHeap(View view); + } + + public TwoWayView(Context context) { + this(context, null); + } + + public TwoWayView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TwoWayView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + mContext = context; + + mLayoutMode = LAYOUT_NORMAL; + mTouchMode = TOUCH_MODE_REST; + mLastTouchMode = TOUCH_MODE_UNKNOWN; + + mLastScrollState = OnScrollListener.SCROLL_STATE_IDLE; + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + mMaximumVelocity = vc.getScaledMaximumFlingVelocity(); + mFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mOverscrollDistance = getScaledOverscrollDistance(vc); + + mScroller = new Scroller(context); + + mIsVertical = true; + + mTempRect = new Rect(); + + mArrowScrollFocusResult = new ArrowScrollFocusResult(); + + mSelectorPosition = INVALID_POSITION; + + mSelectorRect = new Rect(); + + mResurrectToPosition = INVALID_POSITION; + + mNextSelectedPosition = INVALID_POSITION; + mNextSelectedRowId = INVALID_ROW_ID; + mSelectedPosition = INVALID_POSITION; + mSelectedRowId = INVALID_ROW_ID; + mOldSelectedPosition = INVALID_POSITION; + mOldSelectedRowId = INVALID_ROW_ID; + + mChoiceMode = ChoiceMode.NONE; + + mRecycler = new RecycleBin(); + + mAreAllItemsSelectable = true; + + setClickable(true); + setFocusableInTouchMode(true); + setWillNotDraw(false); + setAlwaysDrawnWithCacheEnabled(false); + setWillNotDraw(false); + setClipToPadding(false); + + ViewCompat.setOverScrollMode(this, ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TwoWayView, defStyle, 0); + + mDrawSelectorOnTop = a.getBoolean( + R.styleable.TwoWayView_android_drawSelectorOnTop, false); + + Drawable d = a.getDrawable(R.styleable.TwoWayView_android_listSelector); + if (d != null) { + setSelector(d); + } + + int orientation = a.getInt(R.styleable.TwoWayView_android_orientation, -1); + if (orientation >= 0) { + setOrientation(Orientation.values()[orientation]); + } + + int choiceMode = a.getInt(R.styleable.TwoWayView_android_choiceMode, -1); + if (choiceMode >= 0) { + setChoiceMode(ChoiceMode.values()[choiceMode]); + } + + a.recycle(); + } + + public void setOrientation(Orientation orientation) { + final boolean isVertical = (orientation == Orientation.VERTICAL); + if (mIsVertical == isVertical) { + return; + } + + mIsVertical = isVertical; + + resetState(); + mRecycler.clear(); + + requestLayout(); + } + + public Orientation getOrientation() { + return (mIsVertical ? Orientation.VERTICAL : Orientation.HORIZONTAL); + } + + public void setItemMargin(int itemMargin) { + if (mItemMargin == itemMargin) { + return; + } + + mItemMargin = itemMargin; + requestLayout(); + } + + @SuppressWarnings("unused") + public int getItemMargin() { + return mItemMargin; + } + + /** + * Indicates that the views created by the ListAdapter can contain focusable + * items. + * + * @param itemsCanFocus true if items can get focus, false otherwise + */ + @SuppressWarnings("unused") + public void setItemsCanFocus(boolean itemsCanFocus) { + mItemsCanFocus = itemsCanFocus; + if (!itemsCanFocus) { + setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } + } + + /** + * @return Whether the views created by the ListAdapter can contain focusable + * items. + */ + @SuppressWarnings("unused") + public boolean getItemsCanFocus() { + return mItemsCanFocus; + } + + /** + * Set the listener that will receive notifications every time the list scrolls. + * + * @param l the scroll listener + */ + public void setOnScrollListener(OnScrollListener l) { + mOnScrollListener = l; + invokeOnItemScrollListener(); + } + + /** + * Sets the recycler listener to be notified whenever a View is set aside in + * the recycler for later reuse. This listener can be used to free resources + * associated to the View. + * + * @param l The recycler listener to be notified of views set aside + * in the recycler. + * + * @see TwoWayView.RecycleBin + * @see TwoWayView.RecyclerListener + */ + public void setRecyclerListener(RecyclerListener l) { + mRecycler.mRecyclerListener = l; + } + + /** + * Controls whether the selection highlight drawable should be drawn on top of the item or + * behind it. + * + * @param drawSelectorOnTop If true, the selector will be drawn on the item it is highlighting. + * The default is false. + * + * @attr ref android.R.styleable#AbsListView_drawSelectorOnTop + */ + @SuppressWarnings("unused") + public void setDrawSelectorOnTop(boolean drawSelectorOnTop) { + mDrawSelectorOnTop = drawSelectorOnTop; + } + + /** + * Set a Drawable that should be used to highlight the currently selected item. + * + * @param resID A Drawable resource to use as the selection highlight. + * + * @attr ref android.R.styleable#AbsListView_listSelector + */ + @SuppressWarnings("unused") + public void setSelector(int resID) { + setSelector(getResources().getDrawable(resID)); + } + + /** + * Set a Drawable that should be used to highlight the currently selected item. + * + * @param selector A Drawable to use as the selection highlight. + * + * @attr ref android.R.styleable#AbsListView_listSelector + */ + public void setSelector(Drawable selector) { + if (mSelector != null) { + mSelector.setCallback(null); + unscheduleDrawable(mSelector); + } + + mSelector = selector; + Rect padding = new Rect(); + selector.getPadding(padding); + + selector.setCallback(this); + updateSelectorState(); + } + + /** + * Returns the selector {@link android.graphics.drawable.Drawable} that is used to draw the + * selection in the list. + * + * @return the drawable used to display the selector + */ + @SuppressWarnings("unused") + public Drawable getSelector() { + return mSelector; + } + + /** + * {@inheritDoc} + */ + @Override + public int getSelectedItemPosition() { + return mNextSelectedPosition; + } + + /** + * {@inheritDoc} + */ + @Override + public long getSelectedItemId() { + return mNextSelectedRowId; + } + + /** + * Returns the number of items currently selected. This will only be valid + * if the choice mode is not {@link ChoiceMode#NONE} (default). + * + * <p>To determine the specific items that are currently selected, use one of + * the <code>getChecked*</code> methods. + * + * @return The number of items currently selected + * + * @see #getCheckedItemPosition() + * @see #getCheckedItemPositions() + * @see #getCheckedItemIds() + */ + @SuppressWarnings("unused") + public int getCheckedItemCount() { + return mCheckedItemCount; + } + + /** + * Returns the checked state of the specified position. The result is only + * valid if the choice mode has been set to {@link ChoiceMode#SINGLE} + * or {@link ChoiceMode#MULTIPLE}. + * + * @param position The item whose checked state to return + * @return The item's checked state or <code>false</code> if choice mode + * is invalid + * + * @see #setChoiceMode(ChoiceMode) + */ + public boolean isItemChecked(int position) { + if (mChoiceMode == ChoiceMode.NONE && mCheckStates != null) { + return mCheckStates.get(position); + } + + return false; + } + + /** + * Returns the currently checked item. The result is only valid if the choice + * mode has been set to {@link ChoiceMode#SINGLE}. + * + * @return The position of the currently checked item or + * {@link #INVALID_POSITION} if nothing is selected + * + * @see #setChoiceMode(ChoiceMode) + */ + public int getCheckedItemPosition() { + if (mChoiceMode == ChoiceMode.SINGLE && mCheckStates != null && mCheckStates.size() == 1) { + return mCheckStates.keyAt(0); + } + + return INVALID_POSITION; + } + + /** + * Returns the set of checked items in the list. The result is only valid if + * the choice mode has not been set to {@link ChoiceMode#NONE}. + * + * @return A SparseBooleanArray which will return true for each call to + * get(int position) where position is a position in the list, + * or <code>null</code> if the choice mode is set to + * {@link ChoiceMode#NONE}. + */ + public SparseBooleanArray getCheckedItemPositions() { + if (mChoiceMode != ChoiceMode.NONE) { + return mCheckStates; + } + + return null; + } + + /** + * Returns the set of checked items ids. The result is only valid if the + * choice mode has not been set to {@link ChoiceMode#NONE} and the adapter + * has stable IDs. ({@link ListAdapter#hasStableIds()} == {@code true}) + * + * @return A new array which contains the id of each checked item in the + * list. + */ + public long[] getCheckedItemIds() { + if (mChoiceMode == ChoiceMode.NONE || mCheckedIdStates == null || mAdapter == null) { + return new long[0]; + } + + final LongSparseArray<Integer> idStates = mCheckedIdStates; + final int count = idStates.size(); + final long[] ids = new long[count]; + + for (int i = 0; i < count; i++) { + ids[i] = idStates.keyAt(i); + } + + return ids; + } + + /** + * Sets the checked state of the specified position. The is only valid if + * the choice mode has been set to {@link ChoiceMode#SINGLE} or + * {@link ChoiceMode#MULTIPLE}. + * + * @param position The item whose checked state is to be checked + * @param value The new checked state for the item + */ + @SuppressWarnings("unused") + public void setItemChecked(int position, boolean value) { + if (mChoiceMode == ChoiceMode.NONE) { + return; + } + + if (mChoiceMode == ChoiceMode.MULTIPLE) { + boolean oldValue = mCheckStates.get(position); + mCheckStates.put(position, value); + + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + if (value) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } else { + mCheckedIdStates.delete(mAdapter.getItemId(position)); + } + } + + if (oldValue != value) { + if (value) { + mCheckedItemCount++; + } else { + mCheckedItemCount--; + } + } + } else { + boolean updateIds = mCheckedIdStates != null && mAdapter.hasStableIds(); + + // Clear all values if we're checking something, or unchecking the currently + // selected item + if (value || isItemChecked(position)) { + mCheckStates.clear(); + + if (updateIds) { + mCheckedIdStates.clear(); + } + } + + // This may end up selecting the value we just cleared but this way + // we ensure length of mCheckStates is 1, a fact getCheckedItemPosition relies on + if (value) { + mCheckStates.put(position, true); + + if (updateIds) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } + + mCheckedItemCount = 1; + } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) { + mCheckedItemCount = 0; + } + } + + // Do not generate a data change while we are in the layout phase + if (!mInLayout && !mBlockLayoutRequests) { + mDataChanged = true; + rememberSyncState(); + requestLayout(); + } + } + + /** + * Clear any choices previously set + */ + @SuppressWarnings("unused") + public void clearChoices() { + if (mCheckStates != null) { + mCheckStates.clear(); + } + + if (mCheckedIdStates != null) { + mCheckedIdStates.clear(); + } + + mCheckedItemCount = 0; + } + + /** + * @see #setChoiceMode(ChoiceMode) + * + * @return The current choice mode + */ + @SuppressWarnings("unused") + public ChoiceMode getChoiceMode() { + return mChoiceMode; + } + + /** + * Defines the choice behavior for the List. By default, Lists do not have any choice behavior + * ({@link ChoiceMode#NONE}). By setting the choiceMode to {@link ChoiceMode#SINGLE}, the + * List allows up to one item to be in a chosen state. By setting the choiceMode to + * {@link ChoiceMode#MULTIPLE}, the list allows any number of items to be chosen. + * + * @param choiceMode One of {@link ChoiceMode#NONE}, {@link ChoiceMode#SINGLE}, or + * {@link ChoiceMode#MULTIPLE} + */ + public void setChoiceMode(ChoiceMode choiceMode) { + mChoiceMode = choiceMode; + + if (mChoiceMode != ChoiceMode.NONE) { + if (mCheckStates == null) { + mCheckStates = new SparseBooleanArray(); + } + + if (mCheckedIdStates == null && mAdapter != null && mAdapter.hasStableIds()) { + mCheckedIdStates = new LongSparseArray<Integer>(); + } + } + } + + @Override + public ListAdapter getAdapter() { + return mAdapter; + } + + @Override + public void setAdapter(ListAdapter adapter) { + if (mAdapter != null && mDataSetObserver != null) { + mAdapter.unregisterDataSetObserver(mDataSetObserver); + } + + resetState(); + mRecycler.clear(); + + mAdapter = adapter; + mDataChanged = true; + + mOldSelectedPosition = INVALID_POSITION; + mOldSelectedRowId = INVALID_ROW_ID; + + if (mCheckStates != null) { + mCheckStates.clear(); + } + + if (mCheckedIdStates != null) { + mCheckedIdStates.clear(); + } + + if (mAdapter != null) { + mOldItemCount = mItemCount; + mItemCount = adapter.getCount(); + + mDataSetObserver = new AdapterDataSetObserver(); + mAdapter.registerDataSetObserver(mDataSetObserver); + + mRecycler.setViewTypeCount(adapter.getViewTypeCount()); + + mHasStableIds = adapter.hasStableIds(); + mAreAllItemsSelectable = adapter.areAllItemsEnabled(); + + if (mChoiceMode != ChoiceMode.NONE && mHasStableIds && mCheckedIdStates == null) { + mCheckedIdStates = new LongSparseArray<Integer>(); + } + + final int position = lookForSelectablePosition(0); + setSelectedPositionInt(position); + setNextSelectedPositionInt(position); + + if (mItemCount == 0) { + checkSelectionChanged(); + } + } else { + mItemCount = 0; + mHasStableIds = false; + mAreAllItemsSelectable = true; + + checkSelectionChanged(); + } + + checkFocus(); + requestLayout(); + } + + @Override + public int getFirstVisiblePosition() { + return mFirstPosition; + } + + @Override + public int getLastVisiblePosition() { + return mFirstPosition + getChildCount() - 1; + } + + @Override + public int getCount() { + return mItemCount; + } + + @Override + public int getPositionForView(View view) { + View child = view; + try { + View v; + while (!(v = (View) child.getParent()).equals(this)) { + child = v; + } + } catch (ClassCastException e) { + // We made it up to the window without find this list view + return INVALID_POSITION; + } + + // Search the children for the list item + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + if (getChildAt(i).equals(child)) { + return mFirstPosition + i; + } + } + + // Child not found! + return INVALID_POSITION; + } + + @Override + public void getFocusedRect(Rect r) { + View view = getSelectedView(); + + if (view != null && view.getParent() == this) { + // The focused rectangle of the selected view offset into the + // coordinate space of this view. + view.getFocusedRect(r); + offsetDescendantRectToMyCoords(view, r); + } else { + super.getFocusedRect(r); + } + } + + @Override + protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + + if (gainFocus && mSelectedPosition < 0 && !isInTouchMode()) { + if (!mIsAttached && mAdapter != null) { + // Data may have changed while we were detached and it's valid + // to change focus while detached. Refresh so we don't die. + mDataChanged = true; + mOldItemCount = mItemCount; + mItemCount = mAdapter.getCount(); + } + + resurrectSelection(); + } + + final ListAdapter adapter = mAdapter; + int closetChildIndex = INVALID_POSITION; + int closestChildStart = 0; + + if (adapter != null && gainFocus && previouslyFocusedRect != null) { + previouslyFocusedRect.offset(getScrollX(), getScrollY()); + + // Don't cache the result of getChildCount or mFirstPosition here, + // it could change in layoutChildren. + if (adapter.getCount() < getChildCount() + mFirstPosition) { + mLayoutMode = LAYOUT_NORMAL; + layoutChildren(); + } + + // Figure out which item should be selected based on previously + // focused rect. + Rect otherRect = mTempRect; + int minDistance = Integer.MAX_VALUE; + final int childCount = getChildCount(); + final int firstPosition = mFirstPosition; + + for (int i = 0; i < childCount; i++) { + // Only consider selectable views + if (!adapter.isEnabled(firstPosition + i)) { + continue; + } + + View other = getChildAt(i); + other.getDrawingRect(otherRect); + offsetDescendantRectToMyCoords(other, otherRect); + int distance = getDistance(previouslyFocusedRect, otherRect, direction); + + if (distance < minDistance) { + minDistance = distance; + closetChildIndex = i; + closestChildStart = getChildStartEdge(other); + } + } + } + + if (closetChildIndex >= 0) { + setSelectionFromOffset(closetChildIndex + mFirstPosition, closestChildStart); + } else { + requestLayout(); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + final ViewTreeObserver treeObserver = getViewTreeObserver(); + treeObserver.addOnTouchModeChangeListener(this); + + if (mAdapter != null && mDataSetObserver == null) { + mDataSetObserver = new AdapterDataSetObserver(); + mAdapter.registerDataSetObserver(mDataSetObserver); + + // Data may have changed while we were detached. Refresh. + mDataChanged = true; + mOldItemCount = mItemCount; + mItemCount = mAdapter.getCount(); + } + + mIsAttached = true; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + // Detach any view left in the scrap heap + mRecycler.clear(); + + final ViewTreeObserver treeObserver = getViewTreeObserver(); + treeObserver.removeOnTouchModeChangeListener(this); + + if (mAdapter != null) { + mAdapter.unregisterDataSetObserver(mDataSetObserver); + mDataSetObserver = null; + } + + if (mPerformClick != null) { + removeCallbacks(mPerformClick); + } + + if (mTouchModeReset != null) { + removeCallbacks(mTouchModeReset); + mTouchModeReset.run(); + } + + finishSmoothScrolling(); + + mIsAttached = false; + } + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + + final int touchMode = isInTouchMode() ? TOUCH_MODE_ON : TOUCH_MODE_OFF; + + if (!hasWindowFocus) { + if (!mScroller.isFinished()) { + finishSmoothScrolling(); + if (mOverScroll != 0) { + mOverScroll = 0; + finishEdgeGlows(); + invalidate(); + } + } + + if (touchMode == TOUCH_MODE_OFF) { + // Remember the last selected element + mResurrectToPosition = mSelectedPosition; + } + } else { + // If we changed touch mode since the last time we had focus + if (touchMode != mLastTouchMode && mLastTouchMode != TOUCH_MODE_UNKNOWN) { + // If we come back in trackball mode, we bring the selection back + if (touchMode == TOUCH_MODE_OFF) { + // This will trigger a layout + resurrectSelection(); + + // If we come back in touch mode, then we want to hide the selector + } else { + hideSelector(); + mLayoutMode = LAYOUT_NORMAL; + layoutChildren(); + } + } + } + + mLastTouchMode = touchMode; + } + + @Override + protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { + boolean needsInvalidate = false; + + if (mIsVertical && mOverScroll != scrollY) { + onScrollChanged(getScrollX(), scrollY, getScrollX(), mOverScroll); + mOverScroll = scrollY; + needsInvalidate = true; + } else if (!mIsVertical && mOverScroll != scrollX) { + onScrollChanged(scrollX, getScrollY(), mOverScroll, getScrollY()); + mOverScroll = scrollX; + needsInvalidate = true; + } + + if (needsInvalidate) { + invalidate(); + awakenScrollbarsInternal(); + } + } + + @TargetApi(9) + private boolean overScrollByInternal(int deltaX, int deltaY, + int scrollX, int scrollY, + int scrollRangeX, int scrollRangeY, + int maxOverScrollX, int maxOverScrollY, + boolean isTouchEvent) { + if (Build.VERSION.SDK_INT < 9) { + return false; + } + + return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, + scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent); + } + + @Override + @TargetApi(9) + public void setOverScrollMode(int mode) { + if (Build.VERSION.SDK_INT < 9) { + return; + } + + if (mode != ViewCompat.OVER_SCROLL_NEVER) { + if (mStartEdge == null) { + Context context = getContext(); + + mStartEdge = new EdgeEffectCompat(context); + mEndEdge = new EdgeEffectCompat(context); + } + } else { + mStartEdge = null; + mEndEdge = null; + } + + super.setOverScrollMode(mode); + } + + public int pointToPosition(int x, int y) { + Rect frame = mTouchFrame; + if (frame == null) { + mTouchFrame = new Rect(); + frame = mTouchFrame; + } + + final int count = getChildCount(); + for (int i = count - 1; i >= 0; i--) { + final View child = getChildAt(i); + + if (child.getVisibility() == View.VISIBLE) { + child.getHitRect(frame); + + if (frame.contains(x, y)) { + return mFirstPosition + i; + } + } + } + return INVALID_POSITION; + } + + @Override + protected float getTopFadingEdgeStrength() { + if (!mIsVertical) { + return 0f; + } + + final float fadingEdge = super.getTopFadingEdgeStrength(); + + final int childCount = getChildCount(); + if (childCount == 0) { + return fadingEdge; + } else { + if (mFirstPosition > 0) { + return 1.0f; + } + + final int top = getChildAt(0).getTop(); + final int paddingTop = getPaddingTop(); + + final float length = (float) getVerticalFadingEdgeLength(); + + return (top < paddingTop ? (float) -(top - paddingTop) / length : fadingEdge); + } + } + + @Override + protected float getBottomFadingEdgeStrength() { + if (!mIsVertical) { + return 0f; + } + + final float fadingEdge = super.getBottomFadingEdgeStrength(); + + final int childCount = getChildCount(); + if (childCount == 0) { + return fadingEdge; + } else { + if (mFirstPosition + childCount - 1 < mItemCount - 1) { + return 1.0f; + } + + final int bottom = getChildAt(childCount - 1).getBottom(); + final int paddingBottom = getPaddingBottom(); + + final int height = getHeight(); + final float length = (float) getVerticalFadingEdgeLength(); + + return (bottom > height - paddingBottom ? + (float) (bottom - height + paddingBottom) / length : fadingEdge); + } + } + + @Override + protected float getLeftFadingEdgeStrength() { + if (mIsVertical) { + return 0f; + } + + final float fadingEdge = super.getLeftFadingEdgeStrength(); + + final int childCount = getChildCount(); + if (childCount == 0) { + return fadingEdge; + } else { + if (mFirstPosition > 0) { + return 1.0f; + } + + final int left = getChildAt(0).getLeft(); + final int paddingLeft = getPaddingLeft(); + + final float length = (float) getHorizontalFadingEdgeLength(); + + return (left < paddingLeft ? (float) -(left - paddingLeft) / length : fadingEdge); + } + } + + @Override + protected float getRightFadingEdgeStrength() { + if (mIsVertical) { + return 0f; + } + + final float fadingEdge = super.getRightFadingEdgeStrength(); + + final int childCount = getChildCount(); + if (childCount == 0) { + return fadingEdge; + } else { + if (mFirstPosition + childCount - 1 < mItemCount - 1) { + return 1.0f; + } + + final int right = getChildAt(childCount - 1).getRight(); + final int paddingRight = getPaddingRight(); + + final int width = getWidth(); + final float length = (float) getHorizontalFadingEdgeLength(); + + return (right > width - paddingRight ? + (float) (right - width + paddingRight) / length : fadingEdge); + } + } + + @Override + protected int computeVerticalScrollExtent() { + final int count = getChildCount(); + if (count == 0) { + return 0; + } + + int extent = count * 100; + + View child = getChildAt(0); + final int childTop = child.getTop(); + + int childHeight = child.getHeight(); + if (childHeight > 0) { + extent += (childTop * 100) / childHeight; + } + + child = getChildAt(count - 1); + final int childBottom = child.getBottom(); + + childHeight = child.getHeight(); + if (childHeight > 0) { + extent -= ((childBottom - getHeight()) * 100) / childHeight; + } + + return extent; + } + + @Override + protected int computeHorizontalScrollExtent() { + final int count = getChildCount(); + if (count == 0) { + return 0; + } + + int extent = count * 100; + + View child = getChildAt(0); + final int childLeft = child.getLeft(); + + int childWidth = child.getWidth(); + if (childWidth > 0) { + extent += (childLeft * 100) / childWidth; + } + + child = getChildAt(count - 1); + final int childRight = child.getRight(); + + childWidth = child.getWidth(); + if (childWidth > 0) { + extent -= ((childRight - getWidth()) * 100) / childWidth; + } + + return extent; + } + + @Override + protected int computeVerticalScrollOffset() { + final int firstPosition = mFirstPosition; + final int childCount = getChildCount(); + + if (firstPosition < 0 || childCount == 0) { + return 0; + } + + final View child = getChildAt(0); + final int childTop = child.getTop(); + + int childHeight = child.getHeight(); + if (childHeight > 0) { + return Math.max(firstPosition * 100 - (childTop * 100) / childHeight, 0); + } + + return 0; + } + + @Override + protected int computeHorizontalScrollOffset() { + final int firstPosition = mFirstPosition; + final int childCount = getChildCount(); + + if (firstPosition < 0 || childCount == 0) { + return 0; + } + + final View child = getChildAt(0); + final int childLeft = child.getLeft(); + + int childWidth = child.getWidth(); + if (childWidth > 0) { + return Math.max(firstPosition * 100 - (childLeft * 100) / childWidth, 0); + } + + return 0; + } + + @Override + protected int computeVerticalScrollRange() { + int result = Math.max(mItemCount * 100, 0); + + if (mIsVertical && mOverScroll != 0) { + // Compensate for overscroll + result += Math.abs((int) ((float) mOverScroll / getHeight() * mItemCount * 100)); + } + + return result; + } + + @Override + protected int computeHorizontalScrollRange() { + int result = Math.max(mItemCount * 100, 0); + + if (!mIsVertical && mOverScroll != 0) { + // Compensate for overscroll + result += Math.abs((int) ((float) mOverScroll / getWidth() * mItemCount * 100)); + } + + return result; + } + + @Override + public boolean showContextMenuForChild(View originalView) { + final int longPressPosition = getPositionForView(originalView); + if (longPressPosition >= 0) { + final long longPressId = mAdapter.getItemId(longPressPosition); + boolean handled = false; + + OnItemLongClickListener listener = getOnItemLongClickListener(); + if (listener != null) { + handled = listener.onItemLongClick(TwoWayView.this, originalView, + longPressPosition, longPressId); + } + + if (!handled) { + mContextMenuInfo = createContextMenuInfo( + getChildAt(longPressPosition - mFirstPosition), + longPressPosition, longPressId); + + handled = super.showContextMenuForChild(originalView); + } + + return handled; + } + + return false; + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (disallowIntercept) { + recycleVelocityTracker(); + } + + super.requestDisallowInterceptTouchEvent(disallowIntercept); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (!mIsAttached || mAdapter == null) { + return false; + } + + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + switch (action) { + case MotionEvent.ACTION_DOWN: + initOrResetVelocityTracker(); + mVelocityTracker.addMovement(ev); + + mScroller.abortAnimation(); + if (mPositionScroller != null) { + mPositionScroller.stop(); + } + + final float x = ev.getX(); + final float y = ev.getY(); + + mLastTouchPos = (mIsVertical ? y : x); + + final int motionPosition = findMotionRowOrColumn((int) mLastTouchPos); + + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mTouchRemainderPos = 0; + + if (mTouchMode == TOUCH_MODE_FLINGING) { + return true; + } else if (motionPosition >= 0) { + mMotionPosition = motionPosition; + mTouchMode = TOUCH_MODE_DOWN; + } + + break; + + case MotionEvent.ACTION_MOVE: { + if (mTouchMode != TOUCH_MODE_DOWN) { + break; + } + + initVelocityTrackerIfNotExists(); + mVelocityTracker.addMovement(ev); + + final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (index < 0) { + Log.e(LOGTAG, "onInterceptTouchEvent could not find pointer with id " + + mActivePointerId + " - did TwoWayView receive an inconsistent " + + "event stream?"); + return false; + } + + final float pos; + if (mIsVertical) { + pos = MotionEventCompat.getY(ev, index); + } else { + pos = MotionEventCompat.getX(ev, index); + } + + final float diff = pos - mLastTouchPos + mTouchRemainderPos; + final int delta = (int) diff; + mTouchRemainderPos = diff - delta; + + if (maybeStartScrolling(delta)) { + return true; + } + + break; + } + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + mActivePointerId = INVALID_POINTER; + mTouchMode = TOUCH_MODE_REST; + recycleVelocityTracker(); + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + + break; + } + + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (!isEnabled()) { + // A disabled view that is clickable still consumes the touch + // events, it just doesn't respond to them. + return isClickable() || isLongClickable(); + } + + if (!mIsAttached || mAdapter == null) { + return false; + } + + boolean needsInvalidate = false; + + initVelocityTrackerIfNotExists(); + mVelocityTracker.addMovement(ev); + + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + switch (action) { + case MotionEvent.ACTION_DOWN: { + if (mDataChanged) { + break; + } + + mVelocityTracker.clear(); + mScroller.abortAnimation(); + if (mPositionScroller != null) { + mPositionScroller.stop(); + } + + final float x = ev.getX(); + final float y = ev.getY(); + + mLastTouchPos = (mIsVertical ? y : x); + + int motionPosition = pointToPosition((int) x, (int) y); + + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mTouchRemainderPos = 0; + + if (mDataChanged) { + break; + } + + if (mTouchMode == TOUCH_MODE_FLINGING) { + mTouchMode = TOUCH_MODE_DRAGGING; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + motionPosition = findMotionRowOrColumn((int) mLastTouchPos); + } else if (mMotionPosition >= 0 && mAdapter.isEnabled(mMotionPosition)) { + mTouchMode = TOUCH_MODE_DOWN; + triggerCheckForTap(); + } + + mMotionPosition = motionPosition; + + break; + } + + case MotionEvent.ACTION_MOVE: { + final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (index < 0) { + Log.e(LOGTAG, "onInterceptTouchEvent could not find pointer with id " + + mActivePointerId + " - did TwoWayView receive an inconsistent " + + "event stream?"); + return false; + } + + final float pos; + if (mIsVertical) { + pos = MotionEventCompat.getY(ev, index); + } else { + pos = MotionEventCompat.getX(ev, index); + } + + if (mDataChanged) { + // Re-sync everything if data has been changed + // since the scroll operation can query the adapter. + layoutChildren(); + } + + final float diff = pos - mLastTouchPos + mTouchRemainderPos; + final int delta = (int) diff; + mTouchRemainderPos = diff - delta; + + switch (mTouchMode) { + case TOUCH_MODE_DOWN: + case TOUCH_MODE_TAP: + case TOUCH_MODE_DONE_WAITING: + // Check if we have moved far enough that it looks more like a + // scroll than a tap + maybeStartScrolling(delta); + break; + + case TOUCH_MODE_DRAGGING: + case TOUCH_MODE_OVERSCROLL: + mLastTouchPos = pos; + maybeScroll(delta); + break; + } + + break; + } + + case MotionEvent.ACTION_CANCEL: + cancelCheckForTap(); + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + + setPressed(false); + View motionView = this.getChildAt(mMotionPosition - mFirstPosition); + if (motionView != null) { + motionView.setPressed(false); + } + + if (mStartEdge != null && mEndEdge != null) { + needsInvalidate = mStartEdge.onRelease() | mEndEdge.onRelease(); + } + + recycleVelocityTracker(); + + break; + + case MotionEvent.ACTION_UP: { + switch (mTouchMode) { + case TOUCH_MODE_DOWN: + case TOUCH_MODE_TAP: + case TOUCH_MODE_DONE_WAITING: { + final int motionPosition = mMotionPosition; + final View child = getChildAt(motionPosition - mFirstPosition); + + final float x = ev.getX(); + final float y = ev.getY(); + + final boolean inList; + if (mIsVertical) { + inList = x > getPaddingLeft() && x < getWidth() - getPaddingRight(); + } else { + inList = y > getPaddingTop() && y < getHeight() - getPaddingBottom(); + } + + if (child != null && !child.hasFocusable() && inList) { + if (mTouchMode != TOUCH_MODE_DOWN) { + child.setPressed(false); + } + + if (mPerformClick == null) { + mPerformClick = new PerformClick(); + } + + final PerformClick performClick = mPerformClick; + performClick.mClickMotionPosition = motionPosition; + performClick.rememberWindowAttachCount(); + + mResurrectToPosition = motionPosition; + + if (mTouchMode == TOUCH_MODE_DOWN || mTouchMode == TOUCH_MODE_TAP) { + if (mTouchMode == TOUCH_MODE_DOWN) { + cancelCheckForTap(); + } else { + cancelCheckForLongPress(); + } + + mLayoutMode = LAYOUT_NORMAL; + + if (!mDataChanged && mAdapter.isEnabled(motionPosition)) { + mTouchMode = TOUCH_MODE_TAP; + + setPressed(true); + positionSelector(mMotionPosition, child); + child.setPressed(true); + + if (mSelector != null) { + Drawable d = mSelector.getCurrent(); + if (d != null && d instanceof TransitionDrawable) { + ((TransitionDrawable) d).resetTransition(); + } + } + + if (mTouchModeReset != null) { + removeCallbacks(mTouchModeReset); + } + + mTouchModeReset = new Runnable() { + @Override + public void run() { + mTouchMode = TOUCH_MODE_REST; + + setPressed(false); + child.setPressed(false); + + if (!mDataChanged) { + performClick.run(); + } + + mTouchModeReset = null; + } + }; + + postDelayed(mTouchModeReset, + ViewConfiguration.getPressedStateDuration()); + } else { + mTouchMode = TOUCH_MODE_REST; + updateSelectorState(); + } + } else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) { + performClick.run(); + } + } + + mTouchMode = TOUCH_MODE_REST; + + finishSmoothScrolling(); + updateSelectorState(); + + break; + } + + case TOUCH_MODE_DRAGGING: + if (contentFits()) { + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + break; + } + + mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + + final float velocity; + if (mIsVertical) { + velocity = VelocityTrackerCompat.getYVelocity(mVelocityTracker, + mActivePointerId); + } else { + velocity = VelocityTrackerCompat.getXVelocity(mVelocityTracker, + mActivePointerId); + } + + if (Math.abs(velocity) >= mFlingVelocity) { + mTouchMode = TOUCH_MODE_FLINGING; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING); + + mScroller.fling(0, 0, + (int) (mIsVertical ? 0 : velocity), + (int) (mIsVertical ? velocity : 0), + (mIsVertical ? 0 : Integer.MIN_VALUE), + (mIsVertical ? 0 : Integer.MAX_VALUE), + (mIsVertical ? Integer.MIN_VALUE : 0), + (mIsVertical ? Integer.MAX_VALUE : 0)); + + mLastTouchPos = 0; + needsInvalidate = true; + } else { + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + } + + break; + + case TOUCH_MODE_OVERSCROLL: + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + break; + } + + cancelCheckForTap(); + cancelCheckForLongPress(); + setPressed(false); + + if (mStartEdge != null && mEndEdge != null) { + needsInvalidate |= mStartEdge.onRelease() | mEndEdge.onRelease(); + } + + recycleVelocityTracker(); + + break; + } + } + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(this); + } + + return true; + } + + @Override + public void onTouchModeChanged(boolean isInTouchMode) { + if (isInTouchMode) { + // Get rid of the selection when we enter touch mode + hideSelector(); + + // Layout, but only if we already have done so previously. + // (Otherwise may clobber a LAYOUT_SYNC layout that was requested to restore + // state.) + if (getWidth() > 0 && getHeight() > 0 && getChildCount() > 0) { + layoutChildren(); + } + + updateSelectorState(); + } else { + final int touchMode = mTouchMode; + if (touchMode == TOUCH_MODE_OVERSCROLL) { + finishSmoothScrolling(); + if (mOverScroll != 0) { + mOverScroll = 0; + finishEdgeGlows(); + invalidate(); + } + } + } + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return handleKeyEvent(keyCode, 1, event); + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + return handleKeyEvent(keyCode, repeatCount, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return handleKeyEvent(keyCode, 1, event); + } + + @Override + public void sendAccessibilityEvent(int eventType) { + // Since this class calls onScrollChanged even if the mFirstPosition and the + // child count have not changed we will avoid sending duplicate accessibility + // events. + if (eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) { + final int firstVisiblePosition = getFirstVisiblePosition(); + final int lastVisiblePosition = getLastVisiblePosition(); + + if (mLastAccessibilityScrollEventFromIndex == firstVisiblePosition + && mLastAccessibilityScrollEventToIndex == lastVisiblePosition) { + return; + } else { + mLastAccessibilityScrollEventFromIndex = firstVisiblePosition; + mLastAccessibilityScrollEventToIndex = lastVisiblePosition; + } + } + + super.sendAccessibilityEvent(eventType); + } + + @Override + @TargetApi(14) + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + event.setClassName(TwoWayView.class.getName()); + } + + @Override + @TargetApi(14) + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(TwoWayView.class.getName()); + + AccessibilityNodeInfoCompat infoCompat = new AccessibilityNodeInfoCompat(info); + + if (isEnabled()) { + if (getFirstVisiblePosition() > 0) { + infoCompat.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); + } + + if (getLastVisiblePosition() < getCount() - 1) { + infoCompat.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); + } + } + } + + @Override + @TargetApi(16) + public boolean performAccessibilityAction(int action, Bundle arguments) { + if (super.performAccessibilityAction(action, arguments)) { + return true; + } + + switch (action) { + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + if (isEnabled() && getLastVisiblePosition() < getCount() - 1) { + // TODO: Use some form of smooth scroll instead + scrollListItemsBy(getAvailableSize()); + return true; + } + return false; + + case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: + if (isEnabled() && mFirstPosition > 0) { + // TODO: Use some form of smooth scroll instead + scrollListItemsBy(-getAvailableSize()); + return true; + } + return false; + } + + return false; + } + + /** + * Return true if child is an ancestor of parent, (or equal to the parent). + */ + private boolean isViewAncestorOf(View child, View parent) { + if (child == parent) { + return true; + } + + final ViewParent theParent = child.getParent(); + + return (theParent instanceof ViewGroup) && + isViewAncestorOf((View) theParent, parent); + } + + private void forceValidFocusDirection(int direction) { + if (mIsVertical && direction != View.FOCUS_UP && direction != View.FOCUS_DOWN) { + throw new IllegalArgumentException("Focus direction must be one of" + + " {View.FOCUS_UP, View.FOCUS_DOWN} for vertical orientation"); + } else if (!mIsVertical && direction != View.FOCUS_LEFT && direction != View.FOCUS_RIGHT) { + throw new IllegalArgumentException("Focus direction must be one of" + + " {View.FOCUS_LEFT, View.FOCUS_RIGHT} for vertical orientation"); + } + } + + private void forceValidInnerFocusDirection(int direction) { + if (mIsVertical && direction != View.FOCUS_LEFT && direction != View.FOCUS_RIGHT) { + throw new IllegalArgumentException("Direction must be one of" + + " {View.FOCUS_LEFT, View.FOCUS_RIGHT} for vertical orientation"); + } else if (!mIsVertical && direction != View.FOCUS_UP && direction != View.FOCUS_DOWN) { + throw new IllegalArgumentException("direction must be one of" + + " {View.FOCUS_UP, View.FOCUS_DOWN} for horizontal orientation"); + } + } + + /** + * Scrolls up or down by the number of items currently present on screen. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return whether selection was moved + */ + boolean pageScroll(int direction) { + forceValidFocusDirection(direction); + + boolean forward = false; + int nextPage = -1; + + if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) { + nextPage = Math.max(0, mSelectedPosition - getChildCount() - 1); + } else if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) { + nextPage = Math.min(mItemCount - 1, mSelectedPosition + getChildCount() - 1); + forward = true; + } + + if (nextPage < 0) { + return false; + } + + final int position = lookForSelectablePosition(nextPage, forward); + if (position >= 0) { + mLayoutMode = LAYOUT_SPECIFIC; + mSpecificStart = getStartEdge() + getFadingEdgeLength(); + + if (forward && position > mItemCount - getChildCount()) { + mLayoutMode = LAYOUT_FORCE_BOTTOM; + } + + if (!forward && position < getChildCount()) { + mLayoutMode = LAYOUT_FORCE_TOP; + } + + setSelectionInt(position); + invokeOnItemScrollListener(); + + if (!awakenScrollbarsInternal()) { + invalidate(); + } + + return true; + } + + return false; + } + + /** + * Go to the last or first item if possible (not worrying about panning across or navigating + * within the internal focus of the currently selected item.) + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return whether selection was moved + */ + boolean fullScroll(int direction) { + forceValidFocusDirection(direction); + + boolean moved = false; + if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) { + if (mSelectedPosition != 0) { + int position = lookForSelectablePosition(0, true); + if (position >= 0) { + mLayoutMode = LAYOUT_FORCE_TOP; + setSelectionInt(position); + invokeOnItemScrollListener(); + } + + moved = true; + } + } else if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) { + if (mSelectedPosition < mItemCount - 1) { + int position = lookForSelectablePosition(mItemCount - 1, true); + if (position >= 0) { + mLayoutMode = LAYOUT_FORCE_BOTTOM; + setSelectionInt(position); + invokeOnItemScrollListener(); + } + + moved = true; + } + } + + if (moved && !awakenScrollbarsInternal()) { + awakenScrollbarsInternal(); + invalidate(); + } + + return moved; + } + + /** + * To avoid horizontal/vertical focus searches changing the selected item, + * we manually focus search within the selected item (as applicable), and + * prevent focus from jumping to something within another item. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return Whether this consumes the key event. + */ + private boolean handleFocusWithinItem(int direction) { + forceValidInnerFocusDirection(direction); + + final int numChildren = getChildCount(); + + if (mItemsCanFocus && numChildren > 0 && mSelectedPosition != INVALID_POSITION) { + final View selectedView = getSelectedView(); + + if (selectedView != null && selectedView.hasFocus() && + selectedView instanceof ViewGroup) { + + final View currentFocus = selectedView.findFocus(); + final View nextFocus = FocusFinder.getInstance().findNextFocus( + (ViewGroup) selectedView, currentFocus, direction); + + if (nextFocus != null) { + // Do the math to get interesting rect in next focus' coordinates + currentFocus.getFocusedRect(mTempRect); + offsetDescendantRectToMyCoords(currentFocus, mTempRect); + offsetRectIntoDescendantCoords(nextFocus, mTempRect); + + if (nextFocus.requestFocus(direction, mTempRect)) { + return true; + } + } + + // We are blocking the key from being handled (by returning true) + // if the global result is going to be some other view within this + // list. This is to achieve the overall goal of having horizontal/vertical + // d-pad navigation remain in the current item depending on the current + // orientation in this view. + final View globalNextFocus = FocusFinder.getInstance().findNextFocus( + (ViewGroup) getRootView(), currentFocus, direction); + + if (globalNextFocus != null) { + return isViewAncestorOf(globalNextFocus, this); + } + } + } + + return false; + } + + /** + * Scrolls to the next or previous item if possible. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return whether selection was moved + */ + private boolean arrowScroll(int direction) { + forceValidFocusDirection(direction); + + try { + mInLayout = true; + + final boolean handled = arrowScrollImpl(direction); + if (handled) { + playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction)); + } + + return handled; + } finally { + mInLayout = false; + } + } + + /** + * When selection changes, it is possible that the previously selected or the + * next selected item will change its size. If so, we need to offset some folks, + * and re-layout the items as appropriate. + * + * @param selectedView The currently selected view (before changing selection). + * should be <code>null</code> if there was no previous selection. + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * @param newSelectedPosition The position of the next selection. + * @param newFocusAssigned whether new focus was assigned. This matters because + * when something has focus, we don't want to show selection (ugh). + */ + private void handleNewSelectionChange(View selectedView, int direction, int newSelectedPosition, + boolean newFocusAssigned) { + forceValidFocusDirection(direction); + + if (newSelectedPosition == INVALID_POSITION) { + throw new IllegalArgumentException("newSelectedPosition needs to be valid"); + } + + // Whether or not we are moving down/right or up/left, we want to preserve the + // top/left of whatever view is at the start: + // - moving down/right: the view that had selection + // - moving up/left: the view that is getting selection + final int selectedIndex = mSelectedPosition - mFirstPosition; + final int nextSelectedIndex = newSelectedPosition - mFirstPosition; + int startViewIndex, endViewIndex; + boolean topSelected = false; + View startView; + View endView; + + if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) { + startViewIndex = nextSelectedIndex; + endViewIndex = selectedIndex; + startView = getChildAt(startViewIndex); + endView = selectedView; + topSelected = true; + } else { + startViewIndex = selectedIndex; + endViewIndex = nextSelectedIndex; + startView = selectedView; + endView = getChildAt(endViewIndex); + } + + final int numChildren = getChildCount(); + + // start with top view: is it changing size? + if (startView != null) { + startView.setSelected(!newFocusAssigned && topSelected); + measureAndAdjustDown(startView, startViewIndex, numChildren); + } + + // is the bottom view changing size? + if (endView != null) { + endView.setSelected(!newFocusAssigned && !topSelected); + measureAndAdjustDown(endView, endViewIndex, numChildren); + } + } + + /** + * Re-measure a child, and if its height changes, lay it out preserving its + * top, and adjust the children below it appropriately. + * + * @param child The child + * @param childIndex The view group index of the child. + * @param numChildren The number of children in the view group. + */ + private void measureAndAdjustDown(View child, int childIndex, int numChildren) { + int oldSize = getChildSize(child); + measureChild(child); + + if (getChildMeasuredSize(child) == oldSize) { + return; + } + + // lay out the view, preserving its top + relayoutMeasuredChild(child); + + // adjust views below appropriately + final int sizeDelta = getChildMeasuredSize(child) - oldSize; + for (int i = childIndex + 1; i < numChildren; i++) { + getChildAt(i).offsetTopAndBottom(sizeDelta); + } + } + + /** + * Do an arrow scroll based on focus searching. If a new view is + * given focus, return the selection delta and amount to scroll via + * an {@link ArrowScrollFocusResult}, otherwise, return null. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return The result if focus has changed, or <code>null</code>. + */ + private ArrowScrollFocusResult arrowScrollFocused(final int direction) { + forceValidFocusDirection(direction); + + final View selectedView = getSelectedView(); + final View newFocus; + final int searchPoint; + + if (selectedView != null && selectedView.hasFocus()) { + View oldFocus = selectedView.findFocus(); + newFocus = FocusFinder.getInstance().findNextFocus(this, oldFocus, direction); + } else { + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) { + boolean fadingEdgeShowing = (mFirstPosition > 0); + final int start = getStartEdge() + + (fadingEdgeShowing ? getArrowScrollPreviewLength() : 0); + + final int selectedStart; + if (selectedView != null) { + selectedStart = getChildStartEdge(selectedView); + } else { + selectedStart = start; + } + + searchPoint = Math.max(selectedStart, start); + } else { + final boolean fadingEdgeShowing = + (mFirstPosition + getChildCount() - 1) < mItemCount; + final int end = getEndEdge() - (fadingEdgeShowing ? getArrowScrollPreviewLength() : 0); + + final int selectedEnd; + if (selectedView != null) { + selectedEnd = getChildEndEdge(selectedView); + } else { + selectedEnd = end; + } + + searchPoint = Math.min(selectedEnd, end); + } + + final int x = (mIsVertical ? 0 : searchPoint); + final int y = (mIsVertical ? searchPoint : 0); + mTempRect.set(x, y, x, y); + + newFocus = FocusFinder.getInstance().findNextFocusFromRect(this, mTempRect, direction); + } + + if (newFocus != null) { + final int positionOfNewFocus = positionOfNewFocus(newFocus); + + // If the focus change is in a different new position, make sure + // we aren't jumping over another selectable position. + if (mSelectedPosition != INVALID_POSITION && positionOfNewFocus != mSelectedPosition) { + final int selectablePosition = lookForSelectablePositionOnScreen(direction); + + final boolean movingForward = + (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT); + final boolean movingBackward = + (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT); + + if (selectablePosition != INVALID_POSITION && + ((movingForward && selectablePosition < positionOfNewFocus) || + (movingBackward && selectablePosition > positionOfNewFocus))) { + return null; + } + } + + int focusScroll = amountToScrollToNewFocus(direction, newFocus, positionOfNewFocus); + + final int maxScrollAmount = getMaxScrollAmount(); + if (focusScroll < maxScrollAmount) { + // Not moving too far, safe to give next view focus + newFocus.requestFocus(direction); + mArrowScrollFocusResult.populate(positionOfNewFocus, focusScroll); + return mArrowScrollFocusResult; + } else if (distanceToView(newFocus) < maxScrollAmount) { + // Case to consider: + // Too far to get entire next focusable on screen, but by going + // max scroll amount, we are getting it at least partially in view, + // so give it focus and scroll the max amount. + newFocus.requestFocus(direction); + mArrowScrollFocusResult.populate(positionOfNewFocus, maxScrollAmount); + return mArrowScrollFocusResult; + } + } + + return null; + } + + /** + * @return The maximum amount a list view will scroll in response to + * an arrow event. + */ + public int getMaxScrollAmount() { + return (int) (MAX_SCROLL_FACTOR * getSize()); + } + + /** + * @return The amount to preview next items when arrow scrolling. + */ + private int getArrowScrollPreviewLength() { + return mItemMargin + Math.max(MIN_SCROLL_PREVIEW_PIXELS, getFadingEdgeLength()); + } + + /** + * @param newFocus The view that would have focus. + * @return the position that contains newFocus + */ + private int positionOfNewFocus(View newFocus) { + final int numChildren = getChildCount(); + + for (int i = 0; i < numChildren; i++) { + final View child = getChildAt(i); + if (isViewAncestorOf(newFocus, child)) { + return mFirstPosition + i; + } + } + + throw new IllegalArgumentException("newFocus is not a child of any of the" + + " children of the list!"); + } + + /** + * Handle an arrow scroll going up or down. Take into account whether items are selectable, + * whether there are focusable items, etc. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return Whether any scrolling, selection or focus change occurred. + */ + private boolean arrowScrollImpl(int direction) { + forceValidFocusDirection(direction); + + if (getChildCount() <= 0) { + return false; + } + + View selectedView = getSelectedView(); + int selectedPos = mSelectedPosition; + + int nextSelectedPosition = lookForSelectablePositionOnScreen(direction); + int amountToScroll = amountToScroll(direction, nextSelectedPosition); + + // If we are moving focus, we may OVERRIDE the default behaviour + final ArrowScrollFocusResult focusResult = (mItemsCanFocus ? arrowScrollFocused(direction) : null); + if (focusResult != null) { + nextSelectedPosition = focusResult.getSelectedPosition(); + amountToScroll = focusResult.getAmountToScroll(); + } + + boolean needToRedraw = (focusResult != null); + if (nextSelectedPosition != INVALID_POSITION) { + handleNewSelectionChange(selectedView, direction, nextSelectedPosition, focusResult != null); + + setSelectedPositionInt(nextSelectedPosition); + setNextSelectedPositionInt(nextSelectedPosition); + + selectedView = getSelectedView(); + selectedPos = nextSelectedPosition; + + if (mItemsCanFocus && focusResult == null) { + // There was no new view found to take focus, make sure we + // don't leave focus with the old selection. + final View focused = getFocusedChild(); + if (focused != null) { + focused.clearFocus(); + } + } + + needToRedraw = true; + checkSelectionChanged(); + } + + if (amountToScroll > 0) { + scrollListItemsBy(direction == View.FOCUS_UP || direction == View.FOCUS_LEFT ? + amountToScroll : -amountToScroll); + needToRedraw = true; + } + + // If we didn't find a new focusable, make sure any existing focused + // item that was panned off screen gives up focus. + if (mItemsCanFocus && focusResult == null && + selectedView != null && selectedView.hasFocus()) { + final View focused = selectedView.findFocus(); + if (!isViewAncestorOf(focused, this) || distanceToView(focused) > 0) { + focused.clearFocus(); + } + } + + // If the current selection is panned off, we need to remove the selection + if (nextSelectedPosition == INVALID_POSITION && selectedView != null + && !isViewAncestorOf(selectedView, this)) { + selectedView = null; + hideSelector(); + + // But we don't want to set the ressurect position (that would make subsequent + // unhandled key events bring back the item we just scrolled off) + mResurrectToPosition = INVALID_POSITION; + } + + if (needToRedraw) { + if (selectedView != null) { + positionSelector(selectedPos, selectedView); + mSelectedStart = getChildStartEdge(selectedView); + } + + if (!awakenScrollbarsInternal()) { + invalidate(); + } + + invokeOnItemScrollListener(); + return true; + } + + return false; + } + + /** + * Determine how much we need to scroll in order to get the next selected view + * visible. The amount is capped at {@link #getMaxScrollAmount()}. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * @param nextSelectedPosition The position of the next selection, or + * {@link #INVALID_POSITION} if there is no next selectable position + * + * @return The amount to scroll. Note: this is always positive! Direction + * needs to be taken into account when actually scrolling. + */ + private int amountToScroll(int direction, int nextSelectedPosition) { + forceValidFocusDirection(direction); + + final int numChildren = getChildCount(); + + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) { + final int end = getEndEdge(); + + int indexToMakeVisible = numChildren - 1; + if (nextSelectedPosition != INVALID_POSITION) { + indexToMakeVisible = nextSelectedPosition - mFirstPosition; + } + + final int positionToMakeVisible = mFirstPosition + indexToMakeVisible; + final View viewToMakeVisible = getChildAt(indexToMakeVisible); + + int goalEnd = end; + if (positionToMakeVisible < mItemCount - 1) { + goalEnd -= getArrowScrollPreviewLength(); + } + + final int viewToMakeVisibleStart = getChildStartEdge(viewToMakeVisible); + final int viewToMakeVisibleEnd = getChildEndEdge(viewToMakeVisible); + + if (viewToMakeVisibleEnd <= goalEnd) { + // Target item is fully visible + return 0; + } + + if (nextSelectedPosition != INVALID_POSITION && + (goalEnd - viewToMakeVisibleStart) >= getMaxScrollAmount()) { + // Item already has enough of it visible, changing selection is good enough + return 0; + } + + int amountToScroll = (viewToMakeVisibleEnd - goalEnd); + + if (mFirstPosition + numChildren == mItemCount) { + final int lastChildEnd = getChildEndEdge(getChildAt(numChildren - 1)); + + // Last is last in list -> Make sure we don't scroll past it + final int max = lastChildEnd - end; + amountToScroll = Math.min(amountToScroll, max); + } + + return Math.min(amountToScroll, getMaxScrollAmount()); + } else { + final int start = getStartEdge(); + + int indexToMakeVisible = 0; + if (nextSelectedPosition != INVALID_POSITION) { + indexToMakeVisible = nextSelectedPosition - mFirstPosition; + } + + final int positionToMakeVisible = mFirstPosition + indexToMakeVisible; + final View viewToMakeVisible = getChildAt(indexToMakeVisible); + + int goalStart = start; + if (positionToMakeVisible > 0) { + goalStart += getArrowScrollPreviewLength(); + } + + final int viewToMakeVisibleStart = getChildStartEdge(viewToMakeVisible); + final int viewToMakeVisibleEnd = getChildEndEdge(viewToMakeVisible); + + if (viewToMakeVisibleStart >= goalStart) { + // Item is fully visible + return 0; + } + + if (nextSelectedPosition != INVALID_POSITION && + (viewToMakeVisibleEnd - goalStart) >= getMaxScrollAmount()) { + // Item already has enough of it visible, changing selection is good enough + return 0; + } + + int amountToScroll = (goalStart - viewToMakeVisibleStart); + + if (mFirstPosition == 0) { + final int firstChildStart = getChildStartEdge(getChildAt(0)); + + // First is first in list -> make sure we don't scroll past it + final int max = start - firstChildStart; + amountToScroll = Math.min(amountToScroll, max); + } + + return Math.min(amountToScroll, getMaxScrollAmount()); + } + } + + /** + * Determine how much we need to scroll in order to get newFocus in view. + * + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * @param newFocus The view that would take focus. + * @param positionOfNewFocus The position of the list item containing newFocus + * + * @return The amount to scroll. Note: this is always positive! Direction + * needs to be taken into account when actually scrolling. + */ + private int amountToScrollToNewFocus(int direction, View newFocus, int positionOfNewFocus) { + forceValidFocusDirection(direction); + + int amountToScroll = 0; + + newFocus.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(newFocus, mTempRect); + + if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) { + final int start = getStartEdge(); + final int newFocusStart = (mIsVertical ? mTempRect.top : mTempRect.left); + + if (newFocusStart < start) { + amountToScroll = start - newFocusStart; + if (positionOfNewFocus > 0) { + amountToScroll += getArrowScrollPreviewLength(); + } + } + } else { + final int end = getEndEdge(); + final int newFocusEnd = (mIsVertical ? mTempRect.bottom : mTempRect.right); + + if (newFocusEnd > end) { + amountToScroll = newFocusEnd - end; + if (positionOfNewFocus < mItemCount - 1) { + amountToScroll += getArrowScrollPreviewLength(); + } + } + } + + return amountToScroll; + } + + /** + * Determine the distance to the nearest edge of a view in a particular + * direction. + * + * @param descendant A descendant of this list. + * @return The distance, or 0 if the nearest edge is already on screen. + */ + private int distanceToView(View descendant) { + descendant.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(descendant, mTempRect); + + final int start = getStartEdge(); + final int end = getEndEdge(); + + final int viewStart = (mIsVertical ? mTempRect.top : mTempRect.left); + final int viewEnd = (mIsVertical ? mTempRect.bottom : mTempRect.right); + + int distance = 0; + if (viewEnd < start) { + distance = start - viewEnd; + } else if (viewStart > end) { + distance = viewStart - end; + } + + return distance; + } + + private boolean handleKeyScroll(KeyEvent event, int count, int direction) { + boolean handled = false; + + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded(); + if (!handled) { + while (count-- > 0) { + if (arrowScroll(direction)) { + handled = true; + } else { + break; + } + } + } + } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) { + handled = resurrectSelectionIfNeeded() || fullScroll(direction); + } + + return handled; + } + + private boolean handleKeyEvent(int keyCode, int count, KeyEvent event) { + if (mAdapter == null || !mIsAttached) { + return false; + } + + if (mDataChanged) { + layoutChildren(); + } + + boolean handled = false; + final int action = event.getAction(); + + if (action != KeyEvent.ACTION_UP) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + if (mIsVertical) { + handled = handleKeyScroll(event, count, View.FOCUS_UP); + } else if (KeyEventCompat.hasNoModifiers(event)) { + handled = handleFocusWithinItem(View.FOCUS_UP); + } + break; + + case KeyEvent.KEYCODE_DPAD_DOWN: { + if (mIsVertical) { + handled = handleKeyScroll(event, count, View.FOCUS_DOWN); + } else if (KeyEventCompat.hasNoModifiers(event)) { + handled = handleFocusWithinItem(View.FOCUS_DOWN); + } + break; + } + + case KeyEvent.KEYCODE_DPAD_LEFT: + if (!mIsVertical) { + handled = handleKeyScroll(event, count, View.FOCUS_LEFT); + } else if (KeyEventCompat.hasNoModifiers(event)) { + handled = handleFocusWithinItem(View.FOCUS_LEFT); + } + break; + + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (!mIsVertical) { + handled = handleKeyScroll(event, count, View.FOCUS_RIGHT); + } else if (KeyEventCompat.hasNoModifiers(event)) { + handled = handleFocusWithinItem(View.FOCUS_RIGHT); + } + break; + + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded(); + if (!handled + && event.getRepeatCount() == 0 && getChildCount() > 0) { + keyPressed(); + handled = true; + } + } + break; + + case KeyEvent.KEYCODE_SPACE: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded() || + pageScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT); + } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_SHIFT_ON)) { + handled = resurrectSelectionIfNeeded() || + fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT); + } + + handled = true; + break; + + case KeyEvent.KEYCODE_PAGE_UP: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded() || + pageScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT); + } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) { + handled = resurrectSelectionIfNeeded() || + fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT); + } + break; + + case KeyEvent.KEYCODE_PAGE_DOWN: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded() || + pageScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT); + } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) { + handled = resurrectSelectionIfNeeded() || + fullScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT); + } + break; + + case KeyEvent.KEYCODE_MOVE_HOME: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded() || + fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT); + } + break; + + case KeyEvent.KEYCODE_MOVE_END: + if (KeyEventCompat.hasNoModifiers(event)) { + handled = resurrectSelectionIfNeeded() || + fullScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT); + } + break; + } + } + + if (handled) { + return true; + } + + switch (action) { + case KeyEvent.ACTION_DOWN: + return super.onKeyDown(keyCode, event); + + case KeyEvent.ACTION_UP: + if (!isEnabled()) { + return true; + } + + if (isClickable() && isPressed() && + mSelectedPosition >= 0 && mAdapter != null && + mSelectedPosition < mAdapter.getCount()) { + + final View child = getChildAt(mSelectedPosition - mFirstPosition); + if (child != null) { + performItemClick(child, mSelectedPosition, mSelectedRowId); + child.setPressed(false); + } + + setPressed(false); + return true; + } + + return false; + + case KeyEvent.ACTION_MULTIPLE: + return super.onKeyMultiple(keyCode, count, event); + + default: + return false; + } + } + + private void initOrResetVelocityTracker() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } else { + mVelocityTracker.clear(); + } + } + + private void initVelocityTrackerIfNotExists() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + } + + private void recycleVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + /** + * Notify our scroll listener (if there is one) of a change in scroll state + */ + private void invokeOnItemScrollListener() { + if (mOnScrollListener != null) { + mOnScrollListener.onScroll(this, mFirstPosition, getChildCount(), mItemCount); + } + + // Dummy values, View's implementation does not use these. + onScrollChanged(0, 0, 0, 0); + } + + private void reportScrollStateChange(int newState) { + if (newState == mLastScrollState) { + return; + } + + if (mOnScrollListener != null) { + mLastScrollState = newState; + mOnScrollListener.onScrollStateChanged(this, newState); + } + } + + private boolean maybeStartScrolling(int delta) { + final boolean isOverScroll = (mOverScroll != 0); + if (Math.abs(delta) <= mTouchSlop && !isOverScroll) { + return false; + } + + if (isOverScroll) { + mTouchMode = TOUCH_MODE_OVERSCROLL; + } else { + mTouchMode = TOUCH_MODE_DRAGGING; + } + + // Time to start stealing events! Once we've stolen them, don't + // let anyone steal from us. + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + + cancelCheckForLongPress(); + + setPressed(false); + View motionView = getChildAt(mMotionPosition - mFirstPosition); + if (motionView != null) { + motionView.setPressed(false); + } + + reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + + return true; + } + + private void maybeScroll(int delta) { + if (mTouchMode == TOUCH_MODE_DRAGGING) { + handleDragChange(delta); + } else if (mTouchMode == TOUCH_MODE_OVERSCROLL) { + handleOverScrollChange(delta); + } + } + + private void handleDragChange(int delta) { + // Time to start stealing events! Once we've stolen them, don't + // let anyone steal from us. + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + + final int motionIndex; + if (mMotionPosition >= 0) { + motionIndex = mMotionPosition - mFirstPosition; + } else { + // If we don't have a motion position that we can reliably track, + // pick something in the middle to make a best guess at things below. + motionIndex = getChildCount() / 2; + } + + int motionViewPrevStart = 0; + View motionView = this.getChildAt(motionIndex); + if (motionView != null) { + motionViewPrevStart = getChildStartEdge(motionView); + } + + boolean atEdge = scrollListItemsBy(delta); + + motionView = this.getChildAt(motionIndex); + if (motionView != null) { + final int motionViewRealStart = getChildStartEdge(motionView); + + if (atEdge) { + final int overscroll = -delta - (motionViewRealStart - motionViewPrevStart); + updateOverScrollState(delta, overscroll); + } + } + } + + private void updateOverScrollState(int delta, int overscroll) { + overScrollByInternal((mIsVertical ? 0 : overscroll), + (mIsVertical ? overscroll : 0), + (mIsVertical ? 0 : mOverScroll), + (mIsVertical ? mOverScroll : 0), + 0, 0, + (mIsVertical ? 0 : mOverscrollDistance), + (mIsVertical ? mOverscrollDistance : 0), + true); + + if (Math.abs(mOverscrollDistance) == Math.abs(mOverScroll)) { + // Break fling velocity if we impacted an edge + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + + final int overscrollMode = ViewCompat.getOverScrollMode(this); + if (overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS || + (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits())) { + mTouchMode = TOUCH_MODE_OVERSCROLL; + + float pull = (float) overscroll / getSize(); + if (delta > 0) { + mStartEdge.onPull(pull); + + if (!mEndEdge.isFinished()) { + mEndEdge.onRelease(); + } + } else if (delta < 0) { + mEndEdge.onPull(pull); + + if (!mStartEdge.isFinished()) { + mStartEdge.onRelease(); + } + } + + if (delta != 0) { + ViewCompat.postInvalidateOnAnimation(this); + } + } + } + + private void handleOverScrollChange(int delta) { + final int oldOverScroll = mOverScroll; + final int newOverScroll = oldOverScroll - delta; + + int overScrollDistance = -delta; + if ((newOverScroll < 0 && oldOverScroll >= 0) || + (newOverScroll > 0 && oldOverScroll <= 0)) { + overScrollDistance = -oldOverScroll; + delta += overScrollDistance; + } else { + delta = 0; + } + + if (overScrollDistance != 0) { + updateOverScrollState(delta, overScrollDistance); + } + + if (delta != 0) { + if (mOverScroll != 0) { + mOverScroll = 0; + ViewCompat.postInvalidateOnAnimation(this); + } + + scrollListItemsBy(delta); + mTouchMode = TOUCH_MODE_DRAGGING; + + // We did not scroll the full amount. Treat this essentially like the + // start of a new touch scroll + mMotionPosition = findClosestMotionRowOrColumn((int) mLastTouchPos); + mTouchRemainderPos = 0; + } + } + + /** + * What is the distance between the source and destination rectangles given the direction of + * focus navigation between them? The direction basically helps figure out more quickly what is + * self evident by the relationship between the rects... + * + * @param source the source rectangle + * @param dest the destination rectangle + * @param direction the direction + * @return the distance between the rectangles + */ + private static int getDistance(Rect source, Rect dest, int direction) { + int sX, sY; // source x, y + int dX, dY; // dest x, y + + switch (direction) { + case View.FOCUS_RIGHT: + sX = source.right; + sY = source.top + source.height() / 2; + dX = dest.left; + dY = dest.top + dest.height() / 2; + break; + + case View.FOCUS_DOWN: + sX = source.left + source.width() / 2; + sY = source.bottom; + dX = dest.left + dest.width() / 2; + dY = dest.top; + break; + + case View.FOCUS_LEFT: + sX = source.left; + sY = source.top + source.height() / 2; + dX = dest.right; + dY = dest.top + dest.height() / 2; + break; + + case View.FOCUS_UP: + sX = source.left + source.width() / 2; + sY = source.top; + dX = dest.left + dest.width() / 2; + dY = dest.bottom; + break; + + case View.FOCUS_FORWARD: + case View.FOCUS_BACKWARD: + sX = source.right + source.width() / 2; + sY = source.top + source.height() / 2; + dX = dest.left + dest.width() / 2; + dY = dest.top + dest.height() / 2; + break; + + default: + throw new IllegalArgumentException("direction must be one of " + + "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT, " + + "FOCUS_FORWARD, FOCUS_BACKWARD}."); + } + + int deltaX = dX - sX; + int deltaY = dY - sY; + + return deltaY * deltaY + deltaX * deltaX; + } + + private int findMotionRowOrColumn(int motionPos) { + int childCount = getChildCount(); + if (childCount == 0) { + return INVALID_POSITION; + } + + for (int i = 0; i < childCount; i++) { + final View v = getChildAt(i); + if (motionPos <= getChildEndEdge(v)) { + return mFirstPosition + i; + } + } + + return INVALID_POSITION; + } + + private int findClosestMotionRowOrColumn(int motionPos) { + final int childCount = getChildCount(); + if (childCount == 0) { + return INVALID_POSITION; + } + + final int motionRow = findMotionRowOrColumn(motionPos); + if (motionRow != INVALID_POSITION) { + return motionRow; + } else { + return mFirstPosition + childCount - 1; + } + } + + @TargetApi(9) + private int getScaledOverscrollDistance(ViewConfiguration vc) { + if (Build.VERSION.SDK_INT < 9) { + return 0; + } + + return vc.getScaledOverscrollDistance(); + } + + private int getStartEdge() { + return (mIsVertical ? getPaddingTop() : getPaddingLeft()); + } + + private int getEndEdge() { + if (mIsVertical) { + return (getHeight() - getPaddingBottom()); + } else { + return (getWidth() - getPaddingRight()); + } + } + + private int getSize() { + return (mIsVertical ? getHeight() : getWidth()); + } + + private int getAvailableSize() { + if (mIsVertical) { + return getHeight() - getPaddingBottom() - getPaddingTop(); + } else { + return getWidth() - getPaddingRight() - getPaddingLeft(); + } + } + + private int getChildStartEdge(View child) { + return (mIsVertical ? child.getTop() : child.getLeft()); + } + + private int getChildEndEdge(View child) { + return (mIsVertical ? child.getBottom() : child.getRight()); + } + + private int getChildSize(View child) { + return (mIsVertical ? child.getHeight() : child.getWidth()); + } + + private int getChildMeasuredSize(View child) { + return (mIsVertical ? child.getMeasuredHeight() : child.getMeasuredWidth()); + } + + private int getFadingEdgeLength() { + return (mIsVertical ? getVerticalFadingEdgeLength() : getHorizontalFadingEdgeLength()); + } + + private int getMinSelectionPixel(int start, int fadingEdgeLength, int selectedPosition) { + // First pixel we can draw the selection into. + int selectionPixelStart = start; + if (selectedPosition > 0) { + selectionPixelStart += fadingEdgeLength; + } + + return selectionPixelStart; + } + + private int getMaxSelectionPixel(int end, int fadingEdgeLength, + int selectedPosition) { + int selectionPixelEnd = end; + if (selectedPosition != mItemCount - 1) { + selectionPixelEnd -= fadingEdgeLength; + } + + return selectionPixelEnd; + } + + private boolean contentFits() { + final int childCount = getChildCount(); + if (childCount == 0) { + return true; + } + + if (childCount != mItemCount) { + return false; + } + + View first = getChildAt(0); + View last = getChildAt(childCount - 1); + + return (getChildStartEdge(first) >= getStartEdge() && + getChildEndEdge(last) <= getEndEdge()); + } + + private void triggerCheckForTap() { + if (mPendingCheckForTap == null) { + mPendingCheckForTap = new CheckForTap(); + } + + postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); + } + + private void cancelCheckForTap() { + if (mPendingCheckForTap == null) { + return; + } + + removeCallbacks(mPendingCheckForTap); + } + + private void triggerCheckForLongPress() { + if (mPendingCheckForLongPress == null) { + mPendingCheckForLongPress = new CheckForLongPress(); + } + + mPendingCheckForLongPress.rememberWindowAttachCount(); + + postDelayed(mPendingCheckForLongPress, + ViewConfiguration.getLongPressTimeout()); + } + + private void cancelCheckForLongPress() { + if (mPendingCheckForLongPress == null) { + return; + } + + removeCallbacks(mPendingCheckForLongPress); + } + + private boolean scrollListItemsBy(int incrementalDelta) { + final int childCount = getChildCount(); + if (childCount == 0) { + return true; + } + + final int firstStart = getChildStartEdge(getChildAt(0)); + final int lastEnd = getChildEndEdge(getChildAt(childCount - 1)); + + final int paddingTop = getPaddingTop(); + final int paddingLeft = getPaddingLeft(); + + final int paddingStart = (mIsVertical ? paddingTop : paddingLeft); + + final int spaceBefore = paddingStart - firstStart; + final int end = getEndEdge(); + final int spaceAfter = lastEnd - end; + + final int size = getAvailableSize(); + + if (incrementalDelta < 0) { + incrementalDelta = Math.max(-(size - 1), incrementalDelta); + } else { + incrementalDelta = Math.min(size - 1, incrementalDelta); + } + + final int firstPosition = mFirstPosition; + + final boolean cannotScrollDown = (firstPosition == 0 && + firstStart >= paddingStart && incrementalDelta >= 0); + final boolean cannotScrollUp = (firstPosition + childCount == mItemCount && + lastEnd <= end && incrementalDelta <= 0); + + if (cannotScrollDown || cannotScrollUp) { + return incrementalDelta != 0; + } + + final boolean inTouchMode = isInTouchMode(); + if (inTouchMode) { + hideSelector(); + } + + int start = 0; + int count = 0; + + final boolean down = (incrementalDelta < 0); + if (down) { + int childrenStart = -incrementalDelta + paddingStart; + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final int childEnd = getChildEndEdge(child); + + if (childEnd >= childrenStart) { + break; + } + + count++; + mRecycler.addScrapView(child, firstPosition + i); + } + } else { + int childrenEnd = end - incrementalDelta; + + for (int i = childCount - 1; i >= 0; i--) { + final View child = getChildAt(i); + final int childStart = getChildStartEdge(child); + + if (childStart <= childrenEnd) { + break; + } + + start = i; + count++; + mRecycler.addScrapView(child, firstPosition + i); + } + } + + mBlockLayoutRequests = true; + + if (count > 0) { + detachViewsFromParent(start, count); + } + + // invalidate before moving the children to avoid unnecessary invalidate + // calls to bubble up from the children all the way to the top + if (!awakenScrollbarsInternal()) { + invalidate(); + } + + offsetChildren(incrementalDelta); + + if (down) { + mFirstPosition += count; + } + + final int absIncrementalDelta = Math.abs(incrementalDelta); + if (spaceBefore < absIncrementalDelta || spaceAfter < absIncrementalDelta) { + fillGap(down); + } + + if (!inTouchMode && mSelectedPosition != INVALID_POSITION) { + final int childIndex = mSelectedPosition - mFirstPosition; + if (childIndex >= 0 && childIndex < getChildCount()) { + positionSelector(mSelectedPosition, getChildAt(childIndex)); + } + } else if (mSelectorPosition != INVALID_POSITION) { + final int childIndex = mSelectorPosition - mFirstPosition; + if (childIndex >= 0 && childIndex < getChildCount()) { + positionSelector(INVALID_POSITION, getChildAt(childIndex)); + } + } else { + mSelectorRect.setEmpty(); + } + + mBlockLayoutRequests = false; + + invokeOnItemScrollListener(); + + return false; + } + + @TargetApi(14) + private final float getCurrVelocity() { + if (Build.VERSION.SDK_INT >= 14) { + return mScroller.getCurrVelocity(); + } + + return 0; + } + + @TargetApi(5) + private boolean awakenScrollbarsInternal() { + return (Build.VERSION.SDK_INT >= 5) && super.awakenScrollBars(); + } + + @Override + public void computeScroll() { + if (!mScroller.computeScrollOffset()) { + return; + } + + final int pos; + if (mIsVertical) { + pos = mScroller.getCurrY(); + } else { + pos = mScroller.getCurrX(); + } + + final int diff = (int) (pos - mLastTouchPos); + mLastTouchPos = pos; + + final boolean stopped = scrollListItemsBy(diff); + + if (!stopped && !mScroller.isFinished()) { + ViewCompat.postInvalidateOnAnimation(this); + } else { + if (stopped) { + final int overScrollMode = ViewCompat.getOverScrollMode(this); + if (overScrollMode != ViewCompat.OVER_SCROLL_NEVER) { + final EdgeEffectCompat edge = + (diff > 0 ? mStartEdge : mEndEdge); + + boolean needsInvalidate = + edge.onAbsorb(Math.abs((int) getCurrVelocity())); + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(this); + } + } + + finishSmoothScrolling(); + } + + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + } + } + + private void finishEdgeGlows() { + if (mStartEdge != null) { + mStartEdge.finish(); + } + + if (mEndEdge != null) { + mEndEdge.finish(); + } + } + + private boolean drawStartEdge(Canvas canvas) { + if (mStartEdge.isFinished()) { + return false; + } + + if (mIsVertical) { + return mStartEdge.draw(canvas); + } + + final int restoreCount = canvas.save(); + final int height = getHeight(); + + canvas.translate(0, height); + canvas.rotate(270); + + final boolean needsInvalidate = mStartEdge.draw(canvas); + canvas.restoreToCount(restoreCount); + return needsInvalidate; + } + + private boolean drawEndEdge(Canvas canvas) { + if (mEndEdge.isFinished()) { + return false; + } + + final int restoreCount = canvas.save(); + final int width = getWidth(); + final int height = getHeight(); + + if (mIsVertical) { + canvas.translate(-width, height); + canvas.rotate(180, width, 0); + } else { + canvas.translate(width, 0); + canvas.rotate(90); + } + + final boolean needsInvalidate = mEndEdge.draw(canvas); + canvas.restoreToCount(restoreCount); + return needsInvalidate; + } + + private void finishSmoothScrolling() { + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + + mScroller.abortAnimation(); + if (mPositionScroller != null) { + mPositionScroller.stop(); + } + } + + private void drawSelector(Canvas canvas) { + if (!mSelectorRect.isEmpty()) { + final Drawable selector = mSelector; + selector.setBounds(mSelectorRect); + selector.draw(canvas); + } + } + + private void useDefaultSelector() { + setSelector(getResources().getDrawable( + android.R.drawable.list_selector_background)); + } + + private boolean shouldShowSelector() { + return (hasFocus() && !isInTouchMode()) || touchModeDrawsInPressedState(); + } + + private void positionSelector(int position, View selected) { + if (position != INVALID_POSITION) { + mSelectorPosition = position; + } + + mSelectorRect.set(selected.getLeft(), selected.getTop(), selected.getRight(), + selected.getBottom()); + + final boolean isChildViewEnabled = mIsChildViewEnabled; + if (selected.isEnabled() != isChildViewEnabled) { + mIsChildViewEnabled = !isChildViewEnabled; + + if (getSelectedItemPosition() != INVALID_POSITION) { + refreshDrawableState(); + } + } + } + + private void hideSelector() { + if (mSelectedPosition != INVALID_POSITION) { + if (mLayoutMode != LAYOUT_SPECIFIC) { + mResurrectToPosition = mSelectedPosition; + } + + if (mNextSelectedPosition >= 0 && mNextSelectedPosition != mSelectedPosition) { + mResurrectToPosition = mNextSelectedPosition; + } + + setSelectedPositionInt(INVALID_POSITION); + setNextSelectedPositionInt(INVALID_POSITION); + + mSelectedStart = 0; + } + } + + private void setSelectedPositionInt(int position) { + mSelectedPosition = position; + mSelectedRowId = getItemIdAtPosition(position); + } + + private void setSelectionInt(int position) { + setNextSelectedPositionInt(position); + boolean awakeScrollbars = false; + + final int selectedPosition = mSelectedPosition; + if (selectedPosition >= 0) { + if (position == selectedPosition - 1) { + awakeScrollbars = true; + } else if (position == selectedPosition + 1) { + awakeScrollbars = true; + } + } + + layoutChildren(); + + if (awakeScrollbars) { + awakenScrollbarsInternal(); + } + } + + private void setNextSelectedPositionInt(int position) { + mNextSelectedPosition = position; + mNextSelectedRowId = getItemIdAtPosition(position); + + // If we are trying to sync to the selection, update that too + if (mNeedSync && mSyncMode == SYNC_SELECTED_POSITION && position >= 0) { + mSyncPosition = position; + mSyncRowId = mNextSelectedRowId; + } + } + + private boolean touchModeDrawsInPressedState() { + switch (mTouchMode) { + case TOUCH_MODE_TAP: + case TOUCH_MODE_DONE_WAITING: + return true; + default: + return false; + } + } + + /** + * Sets the selector state to "pressed" and posts a CheckForKeyLongPress to see if + * this is a long press. + */ + private void keyPressed() { + if (!isEnabled() || !isClickable()) { + return; + } + + final Drawable selector = mSelector; + final Rect selectorRect = mSelectorRect; + + if (selector != null && (isFocused() || touchModeDrawsInPressedState()) + && !selectorRect.isEmpty()) { + + final View child = getChildAt(mSelectedPosition - mFirstPosition); + + if (child != null) { + if (child.hasFocusable()) { + return; + } + + child.setPressed(true); + } + + setPressed(true); + + final boolean longClickable = isLongClickable(); + final Drawable d = selector.getCurrent(); + if (d != null && d instanceof TransitionDrawable) { + if (longClickable) { + ((TransitionDrawable) d).startTransition( + ViewConfiguration.getLongPressTimeout()); + } else { + ((TransitionDrawable) d).resetTransition(); + } + } + + if (longClickable && !mDataChanged) { + if (mPendingCheckForKeyLongPress == null) { + mPendingCheckForKeyLongPress = new CheckForKeyLongPress(); + } + + mPendingCheckForKeyLongPress.rememberWindowAttachCount(); + postDelayed(mPendingCheckForKeyLongPress, ViewConfiguration.getLongPressTimeout()); + } + } + } + + private void updateSelectorState() { + if (mSelector != null) { + if (shouldShowSelector()) { + mSelector.setState(getDrawableState()); + } else { + mSelector.setState(STATE_NOTHING); + } + } + } + + private void checkSelectionChanged() { + if ((mSelectedPosition != mOldSelectedPosition) || (mSelectedRowId != mOldSelectedRowId)) { + selectionChanged(); + mOldSelectedPosition = mSelectedPosition; + mOldSelectedRowId = mSelectedRowId; + } + } + + private void selectionChanged() { + OnItemSelectedListener listener = getOnItemSelectedListener(); + if (listener == null) { + return; + } + + if (mInLayout || mBlockLayoutRequests) { + // If we are in a layout traversal, defer notification + // by posting. This ensures that the view tree is + // in a consistent state and is able to accommodate + // new layout or invalidate requests. + if (mSelectionNotifier == null) { + mSelectionNotifier = new SelectionNotifier(); + } + + post(mSelectionNotifier); + } else { + fireOnSelected(); + performAccessibilityActionsOnSelected(); + } + } + + private void fireOnSelected() { + OnItemSelectedListener listener = getOnItemSelectedListener(); + if (listener == null) { + return; + } + + final int selection = getSelectedItemPosition(); + if (selection >= 0) { + View v = getSelectedView(); + listener.onItemSelected(this, v, selection, + mAdapter.getItemId(selection)); + } else { + listener.onNothingSelected(this); + } + } + + private void performAccessibilityActionsOnSelected() { + final int position = getSelectedItemPosition(); + if (position >= 0) { + // We fire selection events here not in View + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + } + } + + private int lookForSelectablePosition(int position) { + return lookForSelectablePosition(position, true); + } + + private int lookForSelectablePosition(int position, boolean lookDown) { + final ListAdapter adapter = mAdapter; + if (adapter == null || isInTouchMode()) { + return INVALID_POSITION; + } + + final int itemCount = mItemCount; + if (!mAreAllItemsSelectable) { + if (lookDown) { + position = Math.max(0, position); + while (position < itemCount && !adapter.isEnabled(position)) { + position++; + } + } else { + position = Math.min(position, itemCount - 1); + while (position >= 0 && !adapter.isEnabled(position)) { + position--; + } + } + + if (position < 0 || position >= itemCount) { + return INVALID_POSITION; + } + + return position; + } else { + if (position < 0 || position >= itemCount) { + return INVALID_POSITION; + } + + return position; + } + } + + /** + * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or + * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the + * current view orientation. + * + * @return The position of the next selectable position of the views that + * are currently visible, taking into account the fact that there might + * be no selection. Returns {@link #INVALID_POSITION} if there is no + * selectable view on screen in the given direction. + */ + private int lookForSelectablePositionOnScreen(int direction) { + forceValidFocusDirection(direction); + + final int firstPosition = mFirstPosition; + final ListAdapter adapter = getAdapter(); + + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) { + int startPos = (mSelectedPosition != INVALID_POSITION ? + mSelectedPosition + 1 : firstPosition); + + if (startPos >= adapter.getCount()) { + return INVALID_POSITION; + } + + if (startPos < firstPosition) { + startPos = firstPosition; + } + + final int lastVisiblePos = getLastVisiblePosition(); + + for (int pos = startPos; pos <= lastVisiblePos; pos++) { + if (adapter.isEnabled(pos) + && getChildAt(pos - firstPosition).getVisibility() == View.VISIBLE) { + return pos; + } + } + } else { + final int last = firstPosition + getChildCount() - 1; + + int startPos = (mSelectedPosition != INVALID_POSITION) ? + mSelectedPosition - 1 : firstPosition + getChildCount() - 1; + + if (startPos < 0 || startPos >= adapter.getCount()) { + return INVALID_POSITION; + } + + if (startPos > last) { + startPos = last; + } + + for (int pos = startPos; pos >= firstPosition; pos--) { + if (adapter.isEnabled(pos) + && getChildAt(pos - firstPosition).getVisibility() == View.VISIBLE) { + return pos; + } + } + } + + return INVALID_POSITION; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + updateSelectorState(); + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + // If the child view is enabled then do the default behavior. + if (mIsChildViewEnabled) { + // Common case + return super.onCreateDrawableState(extraSpace); + } + + // The selector uses this View's drawable state. The selected child view + // is disabled, so we need to remove the enabled state from the drawable + // states. + final int enabledState = ENABLED_STATE_SET[0]; + + // If we don't have any extra space, it will return one of the static state arrays, + // and clearing the enabled state on those arrays is a bad thing! If we specify + // we need extra space, it will create+copy into a new array that safely mutable. + int[] state = super.onCreateDrawableState(extraSpace + 1); + int enabledPos = -1; + for (int i = state.length - 1; i >= 0; i--) { + if (state[i] == enabledState) { + enabledPos = i; + break; + } + } + + // Remove the enabled state + if (enabledPos >= 0) { + System.arraycopy(state, enabledPos + 1, state, enabledPos, + state.length - enabledPos - 1); + } + + return state; + } + + @Override + protected boolean canAnimate() { + return (super.canAnimate() && mItemCount > 0); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + final boolean drawSelectorOnTop = mDrawSelectorOnTop; + if (!drawSelectorOnTop) { + drawSelector(canvas); + } + + super.dispatchDraw(canvas); + + if (drawSelectorOnTop) { + drawSelector(canvas); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + boolean needsInvalidate = false; + + if (mStartEdge != null) { + needsInvalidate |= drawStartEdge(canvas); + } + + if (mEndEdge != null) { + needsInvalidate |= drawEndEdge(canvas); + } + + if (needsInvalidate) { + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + public void requestLayout() { + if (!mInLayout && !mBlockLayoutRequests) { + super.requestLayout(); + } + } + + @Override + public View getSelectedView() { + if (mItemCount > 0 && mSelectedPosition >= 0) { + return getChildAt(mSelectedPosition - mFirstPosition); + } else { + return null; + } + } + + @Override + public void setSelection(int position) { + setSelectionFromOffset(position, 0); + } + + public void setSelectionFromOffset(int position, int offset) { + if (mAdapter == null) { + return; + } + + if (!isInTouchMode()) { + position = lookForSelectablePosition(position); + if (position >= 0) { + setNextSelectedPositionInt(position); + } + } else { + mResurrectToPosition = position; + } + + if (position >= 0) { + mLayoutMode = LAYOUT_SPECIFIC; + + if (mIsVertical) { + mSpecificStart = getPaddingTop() + offset; + } else { + mSpecificStart = getPaddingLeft() + offset; + } + + if (mNeedSync) { + mSyncPosition = position; + mSyncRowId = mAdapter.getItemId(position); + } + + requestLayout(); + } + } + + public void scrollBy(int offset) { + scrollListItemsBy(-offset); + } + + /** + * Smoothly scroll to the specified adapter position. The view will + * scroll such that the indicated position is displayed. + * @param position Scroll to this adapter position. + */ + public void smoothScrollToPosition(int position) { + if (mPositionScroller == null) { + mPositionScroller = new PositionScroller(); + } + mPositionScroller.start(position); + } + + /** + * Smoothly scroll to the specified adapter position. The view will scroll + * such that the indicated position is displayed <code>offset</code> pixels from + * the top/left edge of the view, according to the orientation. If this is + * impossible, (e.g. the offset would scroll the first or last item beyond the boundaries + * of the list) it will get as close as possible. The scroll will take + * <code>duration</code> milliseconds to complete. + * + * @param position Position to scroll to + * @param offset Desired distance in pixels of <code>position</code> from the top/left + * of the view when scrolling is finished + * @param duration Number of milliseconds to use for the scroll + */ + public void smoothScrollToPositionFromOffset(int position, int offset, int duration) { + if (mPositionScroller == null) { + mPositionScroller = new PositionScroller(); + } + mPositionScroller.startWithOffset(position, offset, duration); + } + + /** + * Smoothly scroll to the specified adapter position. The view will scroll + * such that the indicated position is displayed <code>offset</code> pixels from + * the top edge of the view. If this is impossible, (e.g. the offset would scroll + * the first or last item beyond the boundaries of the list) it will get as close + * as possible. + * + * @param position Position to scroll to + * @param offset Desired distance in pixels of <code>position</code> from the top + * of the view when scrolling is finished + */ + public void smoothScrollToPositionFromOffset(int position, int offset) { + if (mPositionScroller == null) { + mPositionScroller = new PositionScroller(); + } + mPositionScroller.startWithOffset(position, offset); + } + + /** + * Smoothly scroll to the specified adapter position. The view will + * scroll such that the indicated position is displayed, but it will + * stop early if scrolling further would scroll boundPosition out of + * view. + * + * @param position Scroll to this adapter position. + * @param boundPosition Do not scroll if it would move this adapter + * position out of view. + */ + public void smoothScrollToPosition(int position, int boundPosition) { + if (mPositionScroller == null) { + mPositionScroller = new PositionScroller(); + } + mPositionScroller.start(position, boundPosition); + } + + /** + * Smoothly scroll by distance pixels over duration milliseconds. + * @param distance Distance to scroll in pixels. + * @param duration Duration of the scroll animation in milliseconds. + */ + public void smoothScrollBy(int distance, int duration) { + // No sense starting to scroll if we're not going anywhere + final int firstPosition = mFirstPosition; + final int childCount = getChildCount(); + final int lastPosition = firstPosition + childCount; + final int start = getStartEdge(); + final int end = getEndEdge(); + + if (distance == 0 || mItemCount == 0 || childCount == 0 || + (firstPosition == 0 && getChildStartEdge(getChildAt(0)) == start && distance < 0) || + (lastPosition == mItemCount && + getChildEndEdge(getChildAt(childCount - 1)) == end && distance > 0)) { + finishSmoothScrolling(); + } else { + mScroller.startScroll(0, 0, + mIsVertical ? 0 : -distance, + mIsVertical ? -distance : 0, + duration); + + mLastTouchPos = 0; + + mTouchMode = TOUCH_MODE_FLINGING; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING); + + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Dispatch in the normal way + boolean handled = super.dispatchKeyEvent(event); + if (!handled) { + // If we didn't handle it... + final View focused = getFocusedChild(); + if (focused != null && event.getAction() == KeyEvent.ACTION_DOWN) { + // ... and our focused child didn't handle it + // ... give it to ourselves so we can scroll if necessary + handled = onKeyDown(event.getKeyCode(), event); + } + } + + return handled; + } + + @Override + protected void dispatchSetPressed(boolean pressed) { + // Don't dispatch setPressed to our children. We call setPressed on ourselves to + // get the selector in the right state, but we don't want to press each child. + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mSelector == null) { + useDefaultSelector(); + } + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int childWidth = 0; + int childHeight = 0; + + mItemCount = (mAdapter == null ? 0 : mAdapter.getCount()); + if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED || + heightMode == MeasureSpec.UNSPECIFIED)) { + final View child = obtainView(0, mIsScrap); + + final int secondaryMeasureSpec = + (mIsVertical ? widthMeasureSpec : heightMeasureSpec); + + measureScrapChild(child, 0, secondaryMeasureSpec); + + childWidth = child.getMeasuredWidth(); + childHeight = child.getMeasuredHeight(); + + if (recycleOnMeasure()) { + mRecycler.addScrapView(child, -1); + } + } + + if (widthMode == MeasureSpec.UNSPECIFIED) { + widthSize = getPaddingLeft() + getPaddingRight() + childWidth; + if (mIsVertical) { + widthSize += getVerticalScrollbarWidth(); + } + } + + if (heightMode == MeasureSpec.UNSPECIFIED) { + heightSize = getPaddingTop() + getPaddingBottom() + childHeight; + if (!mIsVertical) { + heightSize += getHorizontalScrollbarHeight(); + } + } + + if (mIsVertical && heightMode == MeasureSpec.AT_MOST) { + heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1); + } + + if (!mIsVertical && widthMode == MeasureSpec.AT_MOST) { + widthSize = measureWidthOfChildren(heightMeasureSpec, 0, NO_POSITION, widthSize, -1); + } + + setMeasuredDimension(widthSize, heightSize); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + mInLayout = true; + + if (changed) { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + getChildAt(i).forceLayout(); + } + + mRecycler.markChildrenDirty(); + } + + layoutChildren(); + + mInLayout = false; + + final int width = r - l - getPaddingLeft() - getPaddingRight(); + final int height = b - t - getPaddingTop() - getPaddingBottom(); + + if (mStartEdge != null && mEndEdge != null) { + if (mIsVertical) { + mStartEdge.setSize(width, height); + mEndEdge.setSize(width, height); + } else { + mStartEdge.setSize(height, width); + mEndEdge.setSize(height, width); + } + } + } + + private void layoutChildren() { + if (getWidth() == 0 || getHeight() == 0) { + return; + } + + final boolean blockLayoutRequests = mBlockLayoutRequests; + if (!blockLayoutRequests) { + mBlockLayoutRequests = true; + } else { + return; + } + + try { + invalidate(); + + if (mAdapter == null) { + resetState(); + return; + } + + final int start = getStartEdge(); + final int end = getEndEdge(); + + int childCount = getChildCount(); + int index = 0; + int delta = 0; + + View focusLayoutRestoreView = null; + + View selected = null; + View oldSelected = null; + View newSelected = null; + View oldFirstChild = null; + + switch (mLayoutMode) { + case LAYOUT_SET_SELECTION: + index = mNextSelectedPosition - mFirstPosition; + if (index >= 0 && index < childCount) { + newSelected = getChildAt(index); + } + + break; + + case LAYOUT_FORCE_TOP: + case LAYOUT_FORCE_BOTTOM: + case LAYOUT_SPECIFIC: + case LAYOUT_SYNC: + break; + + case LAYOUT_MOVE_SELECTION: + default: + // Remember the previously selected view + index = mSelectedPosition - mFirstPosition; + if (index >= 0 && index < childCount) { + oldSelected = getChildAt(index); + } + + // Remember the previous first child + oldFirstChild = getChildAt(0); + + if (mNextSelectedPosition >= 0) { + delta = mNextSelectedPosition - mSelectedPosition; + } + + // Caution: newSelected might be null + newSelected = getChildAt(index + delta); + } + + final boolean dataChanged = mDataChanged; + if (dataChanged) { + handleDataChanged(); + } + + // Handle the empty set by removing all views that are visible + // and calling it a day + if (mItemCount == 0) { + resetState(); + return; + } else if (mItemCount != mAdapter.getCount()) { + throw new IllegalStateException("The content of the adapter has changed but " + + "TwoWayView did not receive a notification. Make sure the content of " + + "your adapter is not modified from a background thread, but only " + + "from the UI thread. [in TwoWayView(" + getId() + ", " + getClass() + + ") with Adapter(" + mAdapter.getClass() + ")]"); + } + + setSelectedPositionInt(mNextSelectedPosition); + + // Reset the focus restoration + View focusLayoutRestoreDirectChild = null; + + // Pull all children into the RecycleBin. + // These views will be reused if possible + final int firstPosition = mFirstPosition; + final RecycleBin recycleBin = mRecycler; + + if (dataChanged) { + for (int i = 0; i < childCount; i++) { + recycleBin.addScrapView(getChildAt(i), firstPosition + i); + } + } else { + recycleBin.fillActiveViews(childCount, firstPosition); + } + + // Take focus back to us temporarily to avoid the eventual + // call to clear focus when removing the focused child below + // from messing things up when ViewAncestor assigns focus back + // to someone else. + final View focusedChild = getFocusedChild(); + if (focusedChild != null) { + // We can remember the focused view to restore after relayout if the + // data hasn't changed, or if the focused position is a header or footer. + if (!dataChanged) { + focusLayoutRestoreDirectChild = focusedChild; + + // Remember the specific view that had focus + focusLayoutRestoreView = findFocus(); + if (focusLayoutRestoreView != null) { + // Tell it we are going to mess with it + focusLayoutRestoreView.onStartTemporaryDetach(); + } + } + + requestFocus(); + } + + // FIXME: We need a way to save current accessibility focus here + // so that it can be restored after we re-attach the children on each + // layout round. + + detachAllViewsFromParent(); + + switch (mLayoutMode) { + case LAYOUT_SET_SELECTION: + if (newSelected != null) { + final int newSelectedStart = getChildStartEdge(newSelected); + selected = fillFromSelection(newSelectedStart, start, end); + } else { + selected = fillFromMiddle(start, end); + } + + break; + + case LAYOUT_SYNC: + selected = fillSpecific(mSyncPosition, mSpecificStart); + break; + + case LAYOUT_FORCE_BOTTOM: + selected = fillBefore(mItemCount - 1, end); + adjustViewsStartOrEnd(); + break; + + case LAYOUT_FORCE_TOP: + mFirstPosition = 0; + selected = fillFromOffset(start); + adjustViewsStartOrEnd(); + break; + + case LAYOUT_SPECIFIC: + selected = fillSpecific(reconcileSelectedPosition(), mSpecificStart); + break; + + case LAYOUT_MOVE_SELECTION: + selected = moveSelection(oldSelected, newSelected, delta, start, end); + break; + + default: + if (childCount == 0) { + final int position = lookForSelectablePosition(0); + setSelectedPositionInt(position); + selected = fillFromOffset(start); + } else { + if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) { + int offset = start; + if (oldSelected != null) { + offset = getChildStartEdge(oldSelected); + } + selected = fillSpecific(mSelectedPosition, offset); + } else if (mFirstPosition < mItemCount) { + int offset = start; + if (oldFirstChild != null) { + offset = getChildStartEdge(oldFirstChild); + } + + selected = fillSpecific(mFirstPosition, offset); + } else { + selected = fillSpecific(0, start); + } + } + + break; + + } + + recycleBin.scrapActiveViews(); + + if (selected != null) { + if (mItemsCanFocus && hasFocus() && !selected.hasFocus()) { + final boolean focusWasTaken = (selected == focusLayoutRestoreDirectChild && + focusLayoutRestoreView != null && + focusLayoutRestoreView.requestFocus()) || selected.requestFocus(); + + if (!focusWasTaken) { + // Selected item didn't take focus, fine, but still want + // to make sure something else outside of the selected view + // has focus + final View focused = getFocusedChild(); + if (focused != null) { + focused.clearFocus(); + } + + positionSelector(INVALID_POSITION, selected); + } else { + selected.setSelected(false); + mSelectorRect.setEmpty(); + } + } else { + positionSelector(INVALID_POSITION, selected); + } + + mSelectedStart = getChildStartEdge(selected); + } else { + if (mTouchMode > TOUCH_MODE_DOWN && mTouchMode < TOUCH_MODE_DRAGGING) { + View child = getChildAt(mMotionPosition - mFirstPosition); + + if (child != null) { + positionSelector(mMotionPosition, child); + } + } else { + mSelectedStart = 0; + mSelectorRect.setEmpty(); + } + + // Even if there is not selected position, we may need to restore + // focus (i.e. something focusable in touch mode) + if (hasFocus() && focusLayoutRestoreView != null) { + focusLayoutRestoreView.requestFocus(); + } + } + + // Tell focus view we are done mucking with it, if it is still in + // our view hierarchy. + if (focusLayoutRestoreView != null + && focusLayoutRestoreView.getWindowToken() != null) { + focusLayoutRestoreView.onFinishTemporaryDetach(); + } + + mLayoutMode = LAYOUT_NORMAL; + mDataChanged = false; + mNeedSync = false; + + setNextSelectedPositionInt(mSelectedPosition); + if (mItemCount > 0) { + checkSelectionChanged(); + } + + invokeOnItemScrollListener(); + } finally { + if (!blockLayoutRequests) { + mBlockLayoutRequests = false; + mDataChanged = false; + } + } + } + + protected boolean recycleOnMeasure() { + return true; + } + + private void offsetChildren(int offset) { + final int childCount = getChildCount(); + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + if (mIsVertical) { + child.offsetTopAndBottom(offset); + } else { + child.offsetLeftAndRight(offset); + } + } + } + + private View moveSelection(View oldSelected, View newSelected, int delta, int start, + int end) { + final int fadingEdgeLength = getFadingEdgeLength(); + final int selectedPosition = mSelectedPosition; + + final int oldSelectedStart = getChildStartEdge(oldSelected); + final int oldSelectedEnd = getChildEndEdge(oldSelected); + + final int minStart = getMinSelectionPixel(start, fadingEdgeLength, selectedPosition); + final int maxEnd = getMaxSelectionPixel(end, fadingEdgeLength, selectedPosition); + + View selected = null; + + if (delta > 0) { + /* + * Case 1: Scrolling down. + */ + + /* + * Before After + * | | | | + * +-------+ +-------+ + * | A | | A | + * | 1 | => +-------+ + * +-------+ | B | + * | B | | 2 | + * +-------+ +-------+ + * | | | | + * + * Try to keep the top of the previously selected item where it was. + * oldSelected = A + * selected = B + */ + + // Put oldSelected (A) where it belongs + oldSelected = makeAndAddView(selectedPosition - 1, oldSelectedStart, true, false); + + final int itemMargin = mItemMargin; + + // Now put the new selection (B) below that + selected = makeAndAddView(selectedPosition, oldSelectedEnd + itemMargin, true, true); + + final int selectedStart = getChildStartEdge(selected); + final int selectedEnd = getChildEndEdge(selected); + + // Some of the newly selected item extends below the bottom of the list + if (selectedEnd > end) { + // Find space available above the selection into which we can scroll upwards + final int spaceBefore = selectedStart - minStart; + + // Find space required to bring the bottom of the selected item fully into view + final int spaceAfter = selectedEnd - maxEnd; + + // Don't scroll more than half the size of the list + final int halfSpace = (end - start) / 2; + int offset = Math.min(spaceBefore, spaceAfter); + offset = Math.min(offset, halfSpace); + + if (mIsVertical) { + oldSelected.offsetTopAndBottom(-offset); + selected.offsetTopAndBottom(-offset); + } else { + oldSelected.offsetLeftAndRight(-offset); + selected.offsetLeftAndRight(-offset); + } + } + + // Fill in views before and after + fillBefore(mSelectedPosition - 2, selectedStart - itemMargin); + adjustViewsStartOrEnd(); + fillAfter(mSelectedPosition + 1, selectedEnd + itemMargin); + } else if (delta < 0) { + /* + * Case 2: Scrolling up. + */ + + /* + * Before After + * | | | | + * +-------+ +-------+ + * | A | | A | + * +-------+ => | 1 | + * | B | +-------+ + * | 2 | | B | + * +-------+ +-------+ + * | | | | + * + * Try to keep the top of the item about to become selected where it was. + * newSelected = A + * olSelected = B + */ + + if (newSelected != null) { + // Try to position the top of newSel (A) where it was before it was selected + final int newSelectedStart = getChildStartEdge(newSelected); + selected = makeAndAddView(selectedPosition, newSelectedStart, true, true); + } else { + // If (A) was not on screen and so did not have a view, position + // it above the oldSelected (B) + selected = makeAndAddView(selectedPosition, oldSelectedStart, false, true); + } + + final int selectedStart = getChildStartEdge(selected); + final int selectedEnd = getChildEndEdge(selected); + + // Some of the newly selected item extends above the top of the list + if (selectedStart < minStart) { + // Find space required to bring the top of the selected item fully into view + final int spaceBefore = minStart - selectedStart; + + // Find space available below the selection into which we can scroll downwards + final int spaceAfter = maxEnd - selectedEnd; + + // Don't scroll more than half the height of the list + final int halfSpace = (end - start) / 2; + int offset = Math.min(spaceBefore, spaceAfter); + offset = Math.min(offset, halfSpace); + + if (mIsVertical) { + selected.offsetTopAndBottom(offset); + } else { + selected.offsetLeftAndRight(offset); + } + } + + // Fill in views above and below + fillBeforeAndAfter(selected, selectedPosition); + } else { + /* + * Case 3: Staying still + */ + + selected = makeAndAddView(selectedPosition, oldSelectedStart, true, true); + + final int selectedStart = getChildStartEdge(selected); + final int selectedEnd = getChildEndEdge(selected); + + // We're staying still... + if (oldSelectedStart < start) { + // ... but the top of the old selection was off screen. + // (This can happen if the data changes size out from under us) + int newEnd = selectedEnd; + if (newEnd < start + 20) { + // Not enough visible -- bring it onscreen + if (mIsVertical) { + selected.offsetTopAndBottom(start - selectedStart); + } else { + selected.offsetLeftAndRight(start - selectedStart); + } + } + } + + // Fill in views above and below + fillBeforeAndAfter(selected, selectedPosition); + } + + return selected; + } + + void confirmCheckedPositionsById() { + // Clear out the positional check states, we'll rebuild it below from IDs. + mCheckStates.clear(); + + for (int checkedIndex = 0; checkedIndex < mCheckedIdStates.size(); checkedIndex++) { + final long id = mCheckedIdStates.keyAt(checkedIndex); + final int lastPos = mCheckedIdStates.valueAt(checkedIndex); + + final long lastPosId = mAdapter.getItemId(lastPos); + if (id != lastPosId) { + // Look around to see if the ID is nearby. If not, uncheck it. + final int start = Math.max(0, lastPos - CHECK_POSITION_SEARCH_DISTANCE); + final int end = Math.min(lastPos + CHECK_POSITION_SEARCH_DISTANCE, mItemCount); + boolean found = false; + + for (int searchPos = start; searchPos < end; searchPos++) { + final long searchId = mAdapter.getItemId(searchPos); + if (id == searchId) { + found = true; + mCheckStates.put(searchPos, true); + mCheckedIdStates.setValueAt(checkedIndex, searchPos); + break; + } + } + + if (!found) { + mCheckedIdStates.delete(id); + checkedIndex--; + mCheckedItemCount--; + } + } else { + mCheckStates.put(lastPos, true); + } + } + } + + private void handleDataChanged() { + if (mChoiceMode != ChoiceMode.NONE && mAdapter != null && mAdapter.hasStableIds()) { + confirmCheckedPositionsById(); + } + + mRecycler.clearTransientStateViews(); + + final int itemCount = mItemCount; + if (itemCount > 0) { + int newPos; + int selectablePos; + + // Find the row we are supposed to sync to + if (mNeedSync) { + // Update this first, since setNextSelectedPositionInt inspects it + mNeedSync = false; + mPendingSync = null; + + switch (mSyncMode) { + case SYNC_SELECTED_POSITION: + if (isInTouchMode()) { + // We saved our state when not in touch mode. (We know this because + // mSyncMode is SYNC_SELECTED_POSITION.) Now we are trying to + // restore in touch mode. Just leave mSyncPosition as it is (possibly + // adjusting if the available range changed) and return. + mLayoutMode = LAYOUT_SYNC; + mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1); + + return; + } else { + // See if we can find a position in the new data with the same + // id as the old selection. This will change mSyncPosition. + newPos = findSyncPosition(); + if (newPos >= 0) { + // Found it. Now verify that new selection is still selectable + selectablePos = lookForSelectablePosition(newPos, true); + if (selectablePos == newPos) { + // Same row id is selected + mSyncPosition = newPos; + + if (mSyncSize == getSize()) { + // If we are at the same height as when we saved state, try + // to restore the scroll position too. + mLayoutMode = LAYOUT_SYNC; + } else { + // We are not the same height as when the selection was saved, so + // don't try to restore the exact position + mLayoutMode = LAYOUT_SET_SELECTION; + } + + // Restore selection + setNextSelectedPositionInt(newPos); + return; + } + } + } + break; + + case SYNC_FIRST_POSITION: + // Leave mSyncPosition as it is -- just pin to available range + mLayoutMode = LAYOUT_SYNC; + mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1); + + return; + } + } + + if (!isInTouchMode()) { + // We couldn't find matching data -- try to use the same position + newPos = getSelectedItemPosition(); + + // Pin position to the available range + if (newPos >= itemCount) { + newPos = itemCount - 1; + } + if (newPos < 0) { + newPos = 0; + } + + // Make sure we select something selectable -- first look down + selectablePos = lookForSelectablePosition(newPos, true); + + if (selectablePos >= 0) { + setNextSelectedPositionInt(selectablePos); + return; + } else { + // Looking down didn't work -- try looking up + selectablePos = lookForSelectablePosition(newPos, false); + if (selectablePos >= 0) { + setNextSelectedPositionInt(selectablePos); + return; + } + } + } else { + // We already know where we want to resurrect the selection + if (mResurrectToPosition >= 0) { + return; + } + } + } + + // Nothing is selected. Give up and reset everything. + mLayoutMode = LAYOUT_FORCE_TOP; + mSelectedPosition = INVALID_POSITION; + mSelectedRowId = INVALID_ROW_ID; + mNextSelectedPosition = INVALID_POSITION; + mNextSelectedRowId = INVALID_ROW_ID; + mNeedSync = false; + mPendingSync = null; + mSelectorPosition = INVALID_POSITION; + + checkSelectionChanged(); + } + + private int reconcileSelectedPosition() { + int position = mSelectedPosition; + if (position < 0) { + position = mResurrectToPosition; + } + + position = Math.max(0, position); + position = Math.min(position, mItemCount - 1); + + return position; + } + + boolean resurrectSelection() { + final int childCount = getChildCount(); + if (childCount <= 0) { + return false; + } + + int selectedStart = 0; + int selectedPosition; + + int start = getStartEdge(); + int end = getEndEdge(); + + final int firstPosition = mFirstPosition; + final int toPosition = mResurrectToPosition; + boolean down = true; + + if (toPosition >= firstPosition && toPosition < firstPosition + childCount) { + selectedPosition = toPosition; + + final View selected = getChildAt(selectedPosition - mFirstPosition); + selectedStart = getChildStartEdge(selected); + + final int selectedEnd = getChildEndEdge(selected); + + // We are scrolled, don't get in the fade + if (selectedStart < start) { + selectedStart = start + getFadingEdgeLength(); + } else if (selectedEnd > end) { + selectedStart = end - getChildMeasuredSize(selected) - getFadingEdgeLength(); + } + } else if (toPosition < firstPosition) { + // Default to selecting whatever is first + selectedPosition = firstPosition; + + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + final int childStart = getChildStartEdge(child); + + if (i == 0) { + // Remember the position of the first item + selectedStart = childStart; + + // See if we are scrolled at all + if (firstPosition > 0 || childStart < start) { + // If we are scrolled, don't select anything that is + // in the fade region + start += getFadingEdgeLength(); + } + } + + if (childStart >= start) { + // Found a view whose top is fully visible + selectedPosition = firstPosition + i; + selectedStart = childStart; + break; + } + } + } else { + final int itemCount = mItemCount; + selectedPosition = firstPosition + childCount - 1; + down = false; + + for (int i = childCount - 1; i >= 0; i--) { + final View child = getChildAt(i); + final int childStart = getChildStartEdge(child); + final int childEnd = getChildEndEdge(child); + + if (i == childCount - 1) { + selectedStart = childStart; + + if (firstPosition + childCount < itemCount || childEnd > end) { + end -= getFadingEdgeLength(); + } + } + + if (childEnd <= end) { + selectedPosition = firstPosition + i; + selectedStart = childStart; + break; + } + } + } + + mResurrectToPosition = INVALID_POSITION; + + finishSmoothScrolling(); + + mTouchMode = TOUCH_MODE_REST; + reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE); + + mSpecificStart = selectedStart; + + selectedPosition = lookForSelectablePosition(selectedPosition, down); + if (selectedPosition >= firstPosition && selectedPosition <= getLastVisiblePosition()) { + mLayoutMode = LAYOUT_SPECIFIC; + updateSelectorState(); + setSelectionInt(selectedPosition); + invokeOnItemScrollListener(); + } else { + selectedPosition = INVALID_POSITION; + } + + return selectedPosition >= 0; + } + + /** + * If there is a selection returns false. + * Otherwise resurrects the selection and returns true if resurrected. + */ + boolean resurrectSelectionIfNeeded() { + if (mSelectedPosition < 0 && resurrectSelection()) { + updateSelectorState(); + return true; + } + + return false; + } + + private int getChildWidthMeasureSpec(LayoutParams lp) { + if (!mIsVertical && lp.width == LayoutParams.WRAP_CONTENT) { + return MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + } else if (mIsVertical) { + final int maxWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + return MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY); + } else { + return MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY); + } + } + + private int getChildHeightMeasureSpec(LayoutParams lp) { + if (mIsVertical && lp.height == LayoutParams.WRAP_CONTENT) { + return MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + } else if (!mIsVertical) { + final int maxHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + return MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.EXACTLY); + } else { + return MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); + } + } + + private void measureChild(View child) { + measureChild(child, (LayoutParams) child.getLayoutParams()); + } + + private void measureChild(View child, LayoutParams lp) { + final int widthSpec = getChildWidthMeasureSpec(lp); + final int heightSpec = getChildHeightMeasureSpec(lp); + child.measure(widthSpec, heightSpec); + } + + private void relayoutMeasuredChild(View child) { + final int w = child.getMeasuredWidth(); + final int h = child.getMeasuredHeight(); + + final int childLeft = getPaddingLeft(); + final int childRight = childLeft + w; + final int childTop = child.getTop(); + final int childBottom = childTop + h; + + child.layout(childLeft, childTop, childRight, childBottom); + } + + private void measureScrapChild(View scrapChild, int position, int secondaryMeasureSpec) { + LayoutParams lp = (LayoutParams) scrapChild.getLayoutParams(); + if (lp == null) { + lp = generateDefaultLayoutParams(); + scrapChild.setLayoutParams(lp); + } + + lp.viewType = mAdapter.getItemViewType(position); + lp.forceAdd = true; + + final int widthMeasureSpec; + final int heightMeasureSpec; + if (mIsVertical) { + widthMeasureSpec = secondaryMeasureSpec; + heightMeasureSpec = getChildHeightMeasureSpec(lp); + } else { + widthMeasureSpec = getChildWidthMeasureSpec(lp); + heightMeasureSpec = secondaryMeasureSpec; + } + + scrapChild.measure(widthMeasureSpec, heightMeasureSpec); + } + + /** + * Measures the height of the given range of children (inclusive) and + * returns the height with this TwoWayView's padding and item margin heights + * included. If maxHeight is provided, the measuring will stop when the + * current height reaches maxHeight. + * + * @param widthMeasureSpec The width measure spec to be given to a child's + * {@link View#measure(int, int)}. + * @param startPosition The position of the first child to be shown. + * @param endPosition The (inclusive) position of the last child to be + * shown. Specify {@link #NO_POSITION} if the last child should be + * the last available child from the adapter. + * @param maxHeight The maximum height that will be returned (if all the + * children don't fit in this value, this value will be + * returned). + * @param disallowPartialChildPosition In general, whether the returned + * height should only contain entire children. This is more + * powerful--it is the first inclusive position at which partial + * children will not be allowed. Example: it looks nice to have + * at least 3 completely visible children, and in portrait this + * will most likely fit; but in landscape there could be times + * when even 2 children can not be completely shown, so a value + * of 2 (remember, inclusive) would be good (assuming + * startPosition is 0). + * @return The height of this TwoWayView with the given children. + */ + private int measureHeightOfChildren(int widthMeasureSpec, int startPosition, int endPosition, + final int maxHeight, int disallowPartialChildPosition) { + + final int paddingTop = getPaddingTop(); + final int paddingBottom = getPaddingBottom(); + + final ListAdapter adapter = mAdapter; + if (adapter == null) { + return paddingTop + paddingBottom; + } + + // Include the padding of the list + int returnedHeight = paddingTop + paddingBottom; + final int itemMargin = mItemMargin; + + // The previous height value that was less than maxHeight and contained + // no partial children + int prevHeightWithoutPartialChild = 0; + int i; + View child; + + // mItemCount - 1 since endPosition parameter is inclusive + endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition; + final RecycleBin recycleBin = mRecycler; + final boolean shouldRecycle = recycleOnMeasure(); + final boolean[] isScrap = mIsScrap; + + for (i = startPosition; i <= endPosition; ++i) { + child = obtainView(i, isScrap); + + measureScrapChild(child, i, widthMeasureSpec); + + if (i > 0) { + // Count the item margin for all but one child + returnedHeight += itemMargin; + } + + // Recycle the view before we possibly return from the method + if (shouldRecycle) { + recycleBin.addScrapView(child, -1); + } + + returnedHeight += child.getMeasuredHeight(); + + if (returnedHeight >= maxHeight) { + // We went over, figure out which height to return. If returnedHeight > maxHeight, + // then the i'th position did not fit completely. + return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1) + && (i > disallowPartialChildPosition) // We've past the min pos + && (prevHeightWithoutPartialChild > 0) // We have a prev height + && (returnedHeight != maxHeight) // i'th child did not fit completely + ? prevHeightWithoutPartialChild + : maxHeight; + } + + if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) { + prevHeightWithoutPartialChild = returnedHeight; + } + } + + // At this point, we went through the range of children, and they each + // completely fit, so return the returnedHeight + return returnedHeight; + } + + /** + * Measures the width of the given range of children (inclusive) and + * returns the width with this TwoWayView's padding and item margin widths + * included. If maxWidth is provided, the measuring will stop when the + * current width reaches maxWidth. + * + * @param heightMeasureSpec The height measure spec to be given to a child's + * {@link View#measure(int, int)}. + * @param startPosition The position of the first child to be shown. + * @param endPosition The (inclusive) position of the last child to be + * shown. Specify {@link #NO_POSITION} if the last child should be + * the last available child from the adapter. + * @param maxWidth The maximum width that will be returned (if all the + * children don't fit in this value, this value will be + * returned). + * @param disallowPartialChildPosition In general, whether the returned + * width should only contain entire children. This is more + * powerful--it is the first inclusive position at which partial + * children will not be allowed. Example: it looks nice to have + * at least 3 completely visible children, and in portrait this + * will most likely fit; but in landscape there could be times + * when even 2 children can not be completely shown, so a value + * of 2 (remember, inclusive) would be good (assuming + * startPosition is 0). + * @return The width of this TwoWayView with the given children. + */ + private int measureWidthOfChildren(int heightMeasureSpec, int startPosition, int endPosition, + final int maxWidth, int disallowPartialChildPosition) { + + final int paddingLeft = getPaddingLeft(); + final int paddingRight = getPaddingRight(); + + final ListAdapter adapter = mAdapter; + if (adapter == null) { + return paddingLeft + paddingRight; + } + + // Include the padding of the list + int returnedWidth = paddingLeft + paddingRight; + final int itemMargin = mItemMargin; + + // The previous height value that was less than maxHeight and contained + // no partial children + int prevWidthWithoutPartialChild = 0; + int i; + View child; + + // mItemCount - 1 since endPosition parameter is inclusive + endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition; + final RecycleBin recycleBin = mRecycler; + final boolean shouldRecycle = recycleOnMeasure(); + final boolean[] isScrap = mIsScrap; + + for (i = startPosition; i <= endPosition; ++i) { + child = obtainView(i, isScrap); + + measureScrapChild(child, i, heightMeasureSpec); + + if (i > 0) { + // Count the item margin for all but one child + returnedWidth += itemMargin; + } + + // Recycle the view before we possibly return from the method + if (shouldRecycle) { + recycleBin.addScrapView(child, -1); + } + + returnedWidth += child.getMeasuredWidth(); + + if (returnedWidth >= maxWidth) { + // We went over, figure out which width to return. If returnedWidth > maxWidth, + // then the i'th position did not fit completely. + return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1) + && (i > disallowPartialChildPosition) // We've past the min pos + && (prevWidthWithoutPartialChild > 0) // We have a prev width + && (returnedWidth != maxWidth) // i'th child did not fit completely + ? prevWidthWithoutPartialChild + : maxWidth; + } + + if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) { + prevWidthWithoutPartialChild = returnedWidth; + } + } + + // At this point, we went through the range of children, and they each + // completely fit, so return the returnedWidth + return returnedWidth; + } + + private View makeAndAddView(int position, int offset, boolean flow, boolean selected) { + final int top; + final int left; + + // Compensate item margin on the first item of the list if the item margin + // is negative to avoid incorrect offset for the very first child. + if (mIsVertical) { + top = offset; + left = getPaddingLeft(); + } else { + top = getPaddingTop(); + left = offset; + } + + if (!mDataChanged) { + // Try to use an existing view for this position + final View activeChild = mRecycler.getActiveView(position); + if (activeChild != null) { + // Found it -- we're using an existing child + // This just needs to be positioned + setupChild(activeChild, position, top, left, flow, selected, true); + + return activeChild; + } + } + + // Make a new view for this position, or convert an unused view if possible + final View child = obtainView(position, mIsScrap); + + // This needs to be positioned and measured + setupChild(child, position, top, left, flow, selected, mIsScrap[0]); + + return child; + } + + @TargetApi(11) + private void setupChild(View child, int position, int top, int left, + boolean flow, boolean selected, boolean recycled) { + final boolean isSelected = selected && shouldShowSelector(); + final boolean updateChildSelected = isSelected != child.isSelected(); + final int touchMode = mTouchMode; + + final boolean isPressed = touchMode > TOUCH_MODE_DOWN && touchMode < TOUCH_MODE_DRAGGING && + mMotionPosition == position; + + final boolean updateChildPressed = isPressed != child.isPressed(); + final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested(); + + // Respect layout params that are already in the view. Otherwise make some up... + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (lp == null) { + lp = generateDefaultLayoutParams(); + } + + lp.viewType = mAdapter.getItemViewType(position); + + if (recycled && !lp.forceAdd) { + attachViewToParent(child, (flow ? -1 : 0), lp); + } else { + lp.forceAdd = false; + addViewInLayout(child, (flow ? -1 : 0), lp, true); + } + + if (updateChildSelected) { + child.setSelected(isSelected); + } + + if (updateChildPressed) { + child.setPressed(isPressed); + } + + if (mChoiceMode != ChoiceMode.NONE && mCheckStates != null) { + if (child instanceof Checkable) { + ((Checkable) child).setChecked(mCheckStates.get(position)); + } else if (Build.VERSION.SDK_INT >= HONEYCOMB) { + child.setActivated(mCheckStates.get(position)); + } + } + + if (needToMeasure) { + measureChild(child, lp); + } else { + cleanupLayoutState(child); + } + + final int w = child.getMeasuredWidth(); + final int h = child.getMeasuredHeight(); + + final int childTop = (mIsVertical && !flow ? top - h : top); + final int childLeft = (!mIsVertical && !flow ? left - w : left); + + if (needToMeasure) { + final int childRight = childLeft + w; + final int childBottom = childTop + h; + + child.layout(childLeft, childTop, childRight, childBottom); + } else { + child.offsetLeftAndRight(childLeft - child.getLeft()); + child.offsetTopAndBottom(childTop - child.getTop()); + } + } + + void fillGap(boolean down) { + final int childCount = getChildCount(); + + if (down) { + final int start = getStartEdge(); + final int lastEnd = getChildEndEdge(getChildAt(childCount - 1)); + final int offset = (childCount > 0 ? lastEnd + mItemMargin : start); + fillAfter(mFirstPosition + childCount, offset); + correctTooHigh(getChildCount()); + } else { + final int end = getEndEdge(); + final int firstStart = getChildStartEdge(getChildAt(0)); + final int offset = (childCount > 0 ? firstStart - mItemMargin : end); + fillBefore(mFirstPosition - 1, offset); + correctTooLow(getChildCount()); + } + } + + private View fillBefore(int pos, int nextOffset) { + View selectedView = null; + + final int start = getStartEdge(); + + while (nextOffset > start && pos >= 0) { + boolean isSelected = (pos == mSelectedPosition); + + View child = makeAndAddView(pos, nextOffset, false, isSelected); + nextOffset = getChildStartEdge(child) - mItemMargin; + + if (isSelected) { + selectedView = child; + } + + pos--; + } + + mFirstPosition = pos + 1; + + return selectedView; + } + + private View fillAfter(int pos, int nextOffset) { + View selectedView = null; + + final int end = getEndEdge(); + + while (nextOffset < end && pos < mItemCount) { + boolean selected = (pos == mSelectedPosition); + + View child = makeAndAddView(pos, nextOffset, true, selected); + nextOffset = getChildEndEdge(child) + mItemMargin; + + if (selected) { + selectedView = child; + } + + pos++; + } + + return selectedView; + } + + private View fillSpecific(int position, int offset) { + final boolean tempIsSelected = (position == mSelectedPosition); + View temp = makeAndAddView(position, offset, true, tempIsSelected); + + // Possibly changed again in fillBefore if we add rows above this one. + mFirstPosition = position; + + final int offsetBefore = getChildStartEdge(temp) - mItemMargin; + final View before = fillBefore(position - 1, offsetBefore); + + // This will correct for the top of the first view not touching the top of the list + adjustViewsStartOrEnd(); + + final int offsetAfter = getChildEndEdge(temp) + mItemMargin; + final View after = fillAfter(position + 1, offsetAfter); + + final int childCount = getChildCount(); + if (childCount > 0) { + correctTooHigh(childCount); + } + + if (tempIsSelected) { + return temp; + } else if (before != null) { + return before; + } else { + return after; + } + } + + private View fillFromOffset(int nextOffset) { + mFirstPosition = Math.min(mFirstPosition, mSelectedPosition); + mFirstPosition = Math.min(mFirstPosition, mItemCount - 1); + + if (mFirstPosition < 0) { + mFirstPosition = 0; + } + + return fillAfter(mFirstPosition, nextOffset); + } + + private View fillFromMiddle(int start, int end) { + final int size = end - start; + int position = reconcileSelectedPosition(); + + View selected = makeAndAddView(position, start, true, true); + mFirstPosition = position; + + if (mIsVertical) { + int selectedHeight = selected.getMeasuredHeight(); + if (selectedHeight <= size) { + selected.offsetTopAndBottom((size - selectedHeight) / 2); + } + } else { + int selectedWidth = selected.getMeasuredWidth(); + if (selectedWidth <= size) { + selected.offsetLeftAndRight((size - selectedWidth) / 2); + } + } + + fillBeforeAndAfter(selected, position); + correctTooHigh(getChildCount()); + + return selected; + } + + private void fillBeforeAndAfter(View selected, int position) { + final int offsetBefore = getChildStartEdge(selected) + mItemMargin; + fillBefore(position - 1, offsetBefore); + + adjustViewsStartOrEnd(); + + final int offsetAfter = getChildEndEdge(selected) + mItemMargin; + fillAfter(position + 1, offsetAfter); + } + + private View fillFromSelection(int selectedTop, int start, int end) { + int fadingEdgeLength = getFadingEdgeLength(); + final int selectedPosition = mSelectedPosition; + + final int minStart = getMinSelectionPixel(start, fadingEdgeLength, selectedPosition); + final int maxEnd = getMaxSelectionPixel(end, fadingEdgeLength, selectedPosition); + + View selected = makeAndAddView(selectedPosition, selectedTop, true, true); + + final int selectedStart = getChildStartEdge(selected); + final int selectedEnd = getChildEndEdge(selected); + + // Some of the newly selected item extends below the bottom of the list + if (selectedEnd > maxEnd) { + // Find space available above the selection into which we can scroll + // upwards + final int spaceAbove = selectedStart - minStart; + + // Find space required to bring the bottom of the selected item + // fully into view + final int spaceBelow = selectedEnd - maxEnd; + + final int offset = Math.min(spaceAbove, spaceBelow); + + // Now offset the selected item to get it into view + selected.offsetTopAndBottom(-offset); + } else if (selectedStart < minStart) { + // Find space required to bring the top of the selected item fully + // into view + final int spaceAbove = minStart - selectedStart; + + // Find space available below the selection into which we can scroll + // downwards + final int spaceBelow = maxEnd - selectedEnd; + + final int offset = Math.min(spaceAbove, spaceBelow); + + // Offset the selected item to get it into view + selected.offsetTopAndBottom(offset); + } + + // Fill in views above and below + fillBeforeAndAfter(selected, selectedPosition); + correctTooHigh(getChildCount()); + + return selected; + } + + private void correctTooHigh(int childCount) { + // First see if the last item is visible. If it is not, it is OK for the + // top of the list to be pushed up. + final int lastPosition = mFirstPosition + childCount - 1; + if (lastPosition != mItemCount - 1 || childCount == 0) { + return; + } + + // Get the last child end edge + final int lastEnd = getChildEndEdge(getChildAt(childCount - 1)); + + // This is bottom of our drawable area + final int start = getStartEdge(); + final int end = getEndEdge(); + + // This is how far the end edge of the last view is from the end of the + // drawable area + int endOffset = end - lastEnd; + + View firstChild = getChildAt(0); + int firstStart = getChildStartEdge(firstChild); + + // Make sure we are 1) Too high, and 2) Either there are more rows above the + // first row or the first row is scrolled off the top of the drawable area + if (endOffset > 0 && (mFirstPosition > 0 || firstStart < start)) { + if (mFirstPosition == 0) { + // Don't pull the top too far down + endOffset = Math.min(endOffset, start - firstStart); + } + + // Move everything down + offsetChildren(endOffset); + + if (mFirstPosition > 0) { + firstStart = getChildStartEdge(firstChild); + + // Fill the gap that was opened above mFirstPosition with more rows, if + // possible + fillBefore(mFirstPosition - 1, firstStart - mItemMargin); + + // Close up the remaining gap + adjustViewsStartOrEnd(); + } + } + } + + private void correctTooLow(int childCount) { + // First see if the first item is visible. If it is not, it is OK for the + // bottom of the list to be pushed down. + if (mFirstPosition != 0 || childCount == 0) { + return; + } + + final int firstStart = getChildStartEdge(getChildAt(0)); + + final int start = getStartEdge(); + final int end = getEndEdge(); + + // This is how far the start edge of the first view is from the start of the + // drawable area + int startOffset = firstStart - start; + + View last = getChildAt(childCount - 1); + int lastEnd = getChildEndEdge(last); + + int lastPosition = mFirstPosition + childCount - 1; + + // Make sure we are 1) Too low, and 2) Either there are more columns/rows below the + // last column/row or the last column/row is scrolled off the end of the + // drawable area + if (startOffset > 0) { + if (lastPosition < mItemCount - 1 || lastEnd > end) { + if (lastPosition == mItemCount - 1) { + // Don't pull the bottom too far up + startOffset = Math.min(startOffset, lastEnd - end); + } + + // Move everything up + offsetChildren(-startOffset); + + if (lastPosition < mItemCount - 1) { + lastEnd = getChildEndEdge(last); + + // Fill the gap that was opened below the last position with more rows, if + // possible + fillAfter(lastPosition + 1, lastEnd + mItemMargin); + + // Close up the remaining gap + adjustViewsStartOrEnd(); + } + } else if (lastPosition == mItemCount - 1) { + adjustViewsStartOrEnd(); + } + } + } + + private void adjustViewsStartOrEnd() { + if (getChildCount() == 0) { + return; + } + + int delta = getChildStartEdge(getChildAt(0)) - getStartEdge(); + + // If item margin is negative we shouldn't apply it in the + // first item of the list to avoid offsetting it incorrectly. + if (mItemMargin >= 0 || mFirstPosition != 0) { + delta -= mItemMargin; + } + + if (delta < 0) { + // We only are looking to see if we are too low, not too high + delta = 0; + } + + if (delta != 0) { + offsetChildren(-delta); + } + } + + @TargetApi(14) + private SparseBooleanArray cloneCheckStates() { + if (mCheckStates == null) { + return null; + } + + SparseBooleanArray checkedStates; + + if (Build.VERSION.SDK_INT >= 14) { + checkedStates = mCheckStates.clone(); + } else { + checkedStates = new SparseBooleanArray(); + + for (int i = 0; i < mCheckStates.size(); i++) { + checkedStates.put(mCheckStates.keyAt(i), mCheckStates.valueAt(i)); + } + } + + return checkedStates; + } + + private int findSyncPosition() { + int itemCount = mItemCount; + + if (itemCount == 0) { + return INVALID_POSITION; + } + + final long idToMatch = mSyncRowId; + + // If there isn't a selection don't hunt for it + if (idToMatch == INVALID_ROW_ID) { + return INVALID_POSITION; + } + + // Pin seed to reasonable values + int seed = mSyncPosition; + seed = Math.max(0, seed); + seed = Math.min(itemCount - 1, seed); + + long endTime = SystemClock.uptimeMillis() + SYNC_MAX_DURATION_MILLIS; + + long rowId; + + // first position scanned so far + int first = seed; + + // last position scanned so far + int last = seed; + + // True if we should move down on the next iteration + boolean next = false; + + // True when we have looked at the first item in the data + boolean hitFirst; + + // True when we have looked at the last item in the data + boolean hitLast; + + // Get the item ID locally (instead of getItemIdAtPosition), so + // we need the adapter + final ListAdapter adapter = mAdapter; + if (adapter == null) { + return INVALID_POSITION; + } + + while (SystemClock.uptimeMillis() <= endTime) { + rowId = adapter.getItemId(seed); + if (rowId == idToMatch) { + // Found it! + return seed; + } + + hitLast = (last == itemCount - 1); + hitFirst = (first == 0); + + if (hitLast && hitFirst) { + // Looked at everything + break; + } + + if (hitFirst || (next && !hitLast)) { + // Either we hit the top, or we are trying to move down + last++; + seed = last; + + // Try going up next time + next = false; + } else if (hitLast || (!next && !hitFirst)) { + // Either we hit the bottom, or we are trying to move up + first--; + seed = first; + + // Try going down next time + next = true; + } + } + + return INVALID_POSITION; + } + + @TargetApi(16) + private View obtainView(int position, boolean[] isScrap) { + isScrap[0] = false; + + View scrapView = mRecycler.getTransientStateView(position); + if (scrapView != null) { + return scrapView; + } + + scrapView = mRecycler.getScrapView(position); + + final View child; + if (scrapView != null) { + child = mAdapter.getView(position, scrapView, this); + + if (child != scrapView) { + mRecycler.addScrapView(scrapView, position); + } else { + isScrap[0] = true; + } + } else { + child = mAdapter.getView(position, null, this); + } + + if (ViewCompat.getImportantForAccessibility(child) == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + + if (mHasStableIds) { + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + + if (lp == null) { + lp = generateDefaultLayoutParams(); + } else if (!checkLayoutParams(lp)) { + lp = generateLayoutParams(lp); + } + + lp.id = mAdapter.getItemId(position); + + child.setLayoutParams(lp); + } + + if (mAccessibilityDelegate == null) { + mAccessibilityDelegate = new ListItemAccessibilityDelegate(); + } + + ViewCompat.setAccessibilityDelegate(child, mAccessibilityDelegate); + + return child; + } + + void resetState() { + mScroller.forceFinished(true); + + removeAllViewsInLayout(); + + mSelectedStart = 0; + mFirstPosition = 0; + mDataChanged = false; + mNeedSync = false; + mPendingSync = null; + mOldSelectedPosition = INVALID_POSITION; + mOldSelectedRowId = INVALID_ROW_ID; + + mOverScroll = 0; + + setSelectedPositionInt(INVALID_POSITION); + setNextSelectedPositionInt(INVALID_POSITION); + + mSelectorPosition = INVALID_POSITION; + mSelectorRect.setEmpty(); + + invalidate(); + } + + private void rememberSyncState() { + if (getChildCount() == 0) { + return; + } + + mNeedSync = true; + + if (mSelectedPosition >= 0) { + View child = getChildAt(mSelectedPosition - mFirstPosition); + + mSyncRowId = mNextSelectedRowId; + mSyncPosition = mNextSelectedPosition; + + if (child != null) { + mSpecificStart = getChildStartEdge(child); + } + + mSyncMode = SYNC_SELECTED_POSITION; + } else { + // Sync the based on the offset of the first view + View child = getChildAt(0); + ListAdapter adapter = getAdapter(); + + if (mFirstPosition >= 0 && mFirstPosition < adapter.getCount()) { + mSyncRowId = adapter.getItemId(mFirstPosition); + } else { + mSyncRowId = NO_ID; + } + + mSyncPosition = mFirstPosition; + + if (child != null) { + mSpecificStart = getChildStartEdge(child); + } + + mSyncMode = SYNC_FIRST_POSITION; + } + } + + private ContextMenuInfo createContextMenuInfo(View view, int position, long id) { + return new AdapterContextMenuInfo(view, position, id); + } + + @TargetApi(11) + private void updateOnScreenCheckedViews() { + final int firstPos = mFirstPosition; + final int count = getChildCount(); + + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + final int position = firstPos + i; + + if (child instanceof Checkable) { + ((Checkable) child).setChecked(mCheckStates.get(position)); + } else if (Build.VERSION.SDK_INT >= HONEYCOMB) { + child.setActivated(mCheckStates.get(position)); + } + } + } + + @Override + public boolean performItemClick(View view, int position, long id) { + boolean checkedStateChanged = false; + + if (mChoiceMode == ChoiceMode.MULTIPLE) { + boolean checked = !mCheckStates.get(position, false); + mCheckStates.put(position, checked); + + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + if (checked) { + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } else { + mCheckedIdStates.delete(mAdapter.getItemId(position)); + } + } + + if (checked) { + mCheckedItemCount++; + } else { + mCheckedItemCount--; + } + + checkedStateChanged = true; + } else if (mChoiceMode == ChoiceMode.SINGLE) { + boolean checked = !mCheckStates.get(position, false); + if (checked) { + mCheckStates.clear(); + mCheckStates.put(position, true); + + if (mCheckedIdStates != null && mAdapter.hasStableIds()) { + mCheckedIdStates.clear(); + mCheckedIdStates.put(mAdapter.getItemId(position), position); + } + + mCheckedItemCount = 1; + } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) { + mCheckedItemCount = 0; + } + + checkedStateChanged = true; + } + + if (checkedStateChanged) { + updateOnScreenCheckedViews(); + } + + return super.performItemClick(view, position, id); + } + + private boolean performLongPress(final View child, + final int longPressPosition, final long longPressId) { + // CHOICE_MODE_MULTIPLE_MODAL takes over long press. + boolean handled = false; + + OnItemLongClickListener listener = getOnItemLongClickListener(); + if (listener != null) { + handled = listener.onItemLongClick(TwoWayView.this, child, + longPressPosition, longPressId); + } + + if (!handled) { + mContextMenuInfo = createContextMenuInfo(child, longPressPosition, longPressId); + handled = super.showContextMenuForChild(TwoWayView.this); + } + + if (handled) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + } + + return handled; + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + if (mIsVertical) { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } else { + return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + } + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { + return new LayoutParams(lp); + } + + @Override + protected boolean checkLayoutParams(ViewGroup.LayoutParams lp) { + return lp instanceof LayoutParams; + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected ContextMenuInfo getContextMenuInfo() { + return mContextMenuInfo; + } + + @Override + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + SavedState ss = new SavedState(superState); + + if (mPendingSync != null) { + ss.selectedId = mPendingSync.selectedId; + ss.firstId = mPendingSync.firstId; + ss.viewStart = mPendingSync.viewStart; + ss.position = mPendingSync.position; + ss.size = mPendingSync.size; + + return ss; + } + + boolean haveChildren = (getChildCount() > 0 && mItemCount > 0); + long selectedId = getSelectedItemId(); + ss.selectedId = selectedId; + ss.size = getSize(); + + if (selectedId >= 0) { + ss.viewStart = mSelectedStart; + ss.position = getSelectedItemPosition(); + ss.firstId = INVALID_POSITION; + } else if (haveChildren && mFirstPosition > 0) { + // Remember the position of the first child. + // We only do this if we are not currently at the top of + // the list, for two reasons: + // + // (1) The list may be in the process of becoming empty, in + // which case mItemCount may not be 0, but if we try to + // ask for any information about position 0 we will crash. + // + // (2) Being "at the top" seems like a special case, anyway, + // and the user wouldn't expect to end up somewhere else when + // they revisit the list even if its content has changed. + + ss.viewStart = getChildStartEdge(getChildAt(0)); + + int firstPos = mFirstPosition; + if (firstPos >= mItemCount) { + firstPos = mItemCount - 1; + } + + ss.position = firstPos; + ss.firstId = mAdapter.getItemId(firstPos); + } else { + ss.viewStart = 0; + ss.firstId = INVALID_POSITION; + ss.position = 0; + } + + if (mCheckStates != null) { + ss.checkState = cloneCheckStates(); + } + + if (mCheckedIdStates != null) { + final LongSparseArray<Integer> idState = new LongSparseArray<Integer>(); + + final int count = mCheckedIdStates.size(); + for (int i = 0; i < count; i++) { + idState.put(mCheckedIdStates.keyAt(i), mCheckedIdStates.valueAt(i)); + } + + ss.checkIdState = idState; + } + + ss.checkedItemCount = mCheckedItemCount; + + return ss; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + + mDataChanged = true; + mSyncSize = ss.size; + + if (ss.selectedId >= 0) { + mNeedSync = true; + mPendingSync = ss; + mSyncRowId = ss.selectedId; + mSyncPosition = ss.position; + mSpecificStart = ss.viewStart; + mSyncMode = SYNC_SELECTED_POSITION; + } else if (ss.firstId >= 0) { + setSelectedPositionInt(INVALID_POSITION); + + // Do this before setting mNeedSync since setNextSelectedPosition looks at mNeedSync + setNextSelectedPositionInt(INVALID_POSITION); + + mSelectorPosition = INVALID_POSITION; + mNeedSync = true; + mPendingSync = ss; + mSyncRowId = ss.firstId; + mSyncPosition = ss.position; + mSpecificStart = ss.viewStart; + mSyncMode = SYNC_FIRST_POSITION; + } + + if (ss.checkState != null) { + mCheckStates = ss.checkState; + } + + if (ss.checkIdState != null) { + mCheckedIdStates = ss.checkIdState; + } + + mCheckedItemCount = ss.checkedItemCount; + + requestLayout(); + } + + public static class LayoutParams extends ViewGroup.LayoutParams { + /** + * Type of this view as reported by the adapter + */ + int viewType; + + /** + * The stable ID of the item this view displays + */ + long id = -1; + + /** + * The position the view was removed from when pulled out of the + * scrap heap. + * @hide + */ + int scrappedFromPosition; + + /** + * When a TwoWayView is measured with an AT_MOST measure spec, it needs + * to obtain children views to measure itself. When doing so, the children + * are not attached to the window, but put in the recycler which assumes + * they've been attached before. Setting this flag will force the reused + * view to be attached to the window rather than just attached to the + * parent. + */ + boolean forceAdd; + + public LayoutParams(int width, int height) { + super(width, height); + + if (this.width == MATCH_PARENT) { + Log.w(LOGTAG, "Constructing LayoutParams with width FILL_PARENT " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.width = WRAP_CONTENT; + } + + if (this.height == MATCH_PARENT) { + Log.w(LOGTAG, "Constructing LayoutParams with height FILL_PARENT " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + } + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + + if (this.width == MATCH_PARENT) { + Log.w(LOGTAG, "Inflation setting LayoutParams width to MATCH_PARENT - " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.width = MATCH_PARENT; + } + + if (this.height == MATCH_PARENT) { + Log.w(LOGTAG, "Inflation setting LayoutParams height to MATCH_PARENT - " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + } + + public LayoutParams(ViewGroup.LayoutParams other) { + super(other); + + if (this.width == MATCH_PARENT) { + Log.w(LOGTAG, "Constructing LayoutParams with width MATCH_PARENT - " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.width = WRAP_CONTENT; + } + + if (this.height == MATCH_PARENT) { + Log.w(LOGTAG, "Constructing LayoutParams with height MATCH_PARENT - " + + "does not make much sense as the view might change orientation. " + + "Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + } + } + + class RecycleBin { + private RecyclerListener mRecyclerListener; + private int mFirstActivePosition; + private View[] mActiveViews = new View[0]; + private ArrayList<View>[] mScrapViews; + private int mViewTypeCount; + private ArrayList<View> mCurrentScrap; + private SparseArrayCompat<View> mTransientStateViews; + + public void setViewTypeCount(int viewTypeCount) { + if (viewTypeCount < 1) { + throw new IllegalArgumentException("Can't have a viewTypeCount < 1"); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount]; + for (int i = 0; i < viewTypeCount; i++) { + scrapViews[i] = new ArrayList<View>(); + } + + mViewTypeCount = viewTypeCount; + mCurrentScrap = scrapViews[0]; + mScrapViews = scrapViews; + } + + public void markChildrenDirty() { + if (mViewTypeCount == 1) { + final ArrayList<View> scrap = mCurrentScrap; + final int scrapCount = scrap.size(); + + for (int i = 0; i < scrapCount; i++) { + scrap.get(i).forceLayout(); + } + } else { + final int typeCount = mViewTypeCount; + for (int i = 0; i < typeCount; i++) { + for (View scrap : mScrapViews[i]) { + scrap.forceLayout(); + } + } + } + + if (mTransientStateViews != null) { + final int count = mTransientStateViews.size(); + for (int i = 0; i < count; i++) { + mTransientStateViews.valueAt(i).forceLayout(); + } + } + } + + public boolean shouldRecycleViewType(int viewType) { + return viewType >= 0; + } + + void clear() { + if (mViewTypeCount == 1) { + final ArrayList<View> scrap = mCurrentScrap; + final int scrapCount = scrap.size(); + + for (int i = 0; i < scrapCount; i++) { + removeDetachedView(scrap.remove(scrapCount - 1 - i), false); + } + } else { + final int typeCount = mViewTypeCount; + for (int i = 0; i < typeCount; i++) { + final ArrayList<View> scrap = mScrapViews[i]; + final int scrapCount = scrap.size(); + + for (int j = 0; j < scrapCount; j++) { + removeDetachedView(scrap.remove(scrapCount - 1 - j), false); + } + } + } + + if (mTransientStateViews != null) { + mTransientStateViews.clear(); + } + } + + void fillActiveViews(int childCount, int firstActivePosition) { + if (mActiveViews.length < childCount) { + mActiveViews = new View[childCount]; + } + + mFirstActivePosition = firstActivePosition; + + final View[] activeViews = mActiveViews; + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + + // Note: We do place AdapterView.ITEM_VIEW_TYPE_IGNORE in active views. + // However, we will NOT place them into scrap views. + activeViews[i] = child; + } + } + + View getActiveView(int position) { + final int index = position - mFirstActivePosition; + final View[] activeViews = mActiveViews; + + if (index >= 0 && index < activeViews.length) { + final View match = activeViews[index]; + activeViews[index] = null; + + return match; + } + + return null; + } + + View getTransientStateView(int position) { + if (mTransientStateViews == null) { + return null; + } + + final int index = mTransientStateViews.indexOfKey(position); + if (index < 0) { + return null; + } + + final View result = mTransientStateViews.valueAt(index); + mTransientStateViews.removeAt(index); + + return result; + } + + void clearTransientStateViews() { + if (mTransientStateViews != null) { + mTransientStateViews.clear(); + } + } + + View getScrapView(int position) { + if (mViewTypeCount == 1) { + return retrieveFromScrap(mCurrentScrap, position); + } else { + int whichScrap = mAdapter.getItemViewType(position); + if (whichScrap >= 0 && whichScrap < mScrapViews.length) { + return retrieveFromScrap(mScrapViews[whichScrap], position); + } + } + + return null; + } + + @TargetApi(14) + void addScrapView(View scrap, int position) { + LayoutParams lp = (LayoutParams) scrap.getLayoutParams(); + if (lp == null) { + return; + } + + lp.scrappedFromPosition = position; + + final int viewType = lp.viewType; + final boolean scrapHasTransientState = ViewCompat.hasTransientState(scrap); + + // Don't put views that should be ignored into the scrap heap + if (!shouldRecycleViewType(viewType) || scrapHasTransientState) { + if (scrapHasTransientState) { + if (mTransientStateViews == null) { + mTransientStateViews = new SparseArrayCompat<View>(); + } + + mTransientStateViews.put(position, scrap); + } + + return; + } + + if (mViewTypeCount == 1) { + mCurrentScrap.add(scrap); + } else { + mScrapViews[viewType].add(scrap); + } + + // FIXME: Unfortunately, ViewCompat.setAccessibilityDelegate() doesn't accept + // null delegates. + if (Build.VERSION.SDK_INT >= 14) { + scrap.setAccessibilityDelegate(null); + } + + if (mRecyclerListener != null) { + mRecyclerListener.onMovedToScrapHeap(scrap); + } + } + + @TargetApi(14) + void scrapActiveViews() { + final View[] activeViews = mActiveViews; + final boolean multipleScraps = (mViewTypeCount > 1); + + ArrayList<View> scrapViews = mCurrentScrap; + final int count = activeViews.length; + + for (int i = count - 1; i >= 0; i--) { + final View victim = activeViews[i]; + if (victim != null) { + final LayoutParams lp = (LayoutParams) victim.getLayoutParams(); + int whichScrap = lp.viewType; + + activeViews[i] = null; + + final boolean scrapHasTransientState = ViewCompat.hasTransientState(victim); + if (!shouldRecycleViewType(whichScrap) || scrapHasTransientState) { + if (scrapHasTransientState) { + removeDetachedView(victim, false); + + if (mTransientStateViews == null) { + mTransientStateViews = new SparseArrayCompat<View>(); + } + + mTransientStateViews.put(mFirstActivePosition + i, victim); + } + + continue; + } + + if (multipleScraps) { + scrapViews = mScrapViews[whichScrap]; + } + + lp.scrappedFromPosition = mFirstActivePosition + i; + scrapViews.add(victim); + + // FIXME: Unfortunately, ViewCompat.setAccessibilityDelegate() doesn't accept + // null delegates. + if (Build.VERSION.SDK_INT >= 14) { + victim.setAccessibilityDelegate(null); + } + + if (mRecyclerListener != null) { + mRecyclerListener.onMovedToScrapHeap(victim); + } + } + } + + pruneScrapViews(); + } + + private void pruneScrapViews() { + final int maxViews = mActiveViews.length; + final int viewTypeCount = mViewTypeCount; + final ArrayList<View>[] scrapViews = mScrapViews; + + for (int i = 0; i < viewTypeCount; ++i) { + final ArrayList<View> scrapPile = scrapViews[i]; + int size = scrapPile.size(); + final int extras = size - maxViews; + + size--; + + for (int j = 0; j < extras; j++) { + removeDetachedView(scrapPile.remove(size--), false); + } + } + + if (mTransientStateViews != null) { + for (int i = 0; i < mTransientStateViews.size(); i++) { + final View v = mTransientStateViews.valueAt(i); + if (!ViewCompat.hasTransientState(v)) { + mTransientStateViews.removeAt(i); + i--; + } + } + } + } + + void reclaimScrapViews(List<View> views) { + if (mViewTypeCount == 1) { + views.addAll(mCurrentScrap); + } else { + final int viewTypeCount = mViewTypeCount; + final ArrayList<View>[] scrapViews = mScrapViews; + + for (int i = 0; i < viewTypeCount; ++i) { + final ArrayList<View> scrapPile = scrapViews[i]; + views.addAll(scrapPile); + } + } + } + + View retrieveFromScrap(ArrayList<View> scrapViews, int position) { + int size = scrapViews.size(); + if (size <= 0) { + return null; + } + + for (int i = 0; i < size; i++) { + final View scrapView = scrapViews.get(i); + final LayoutParams lp = (LayoutParams) scrapView.getLayoutParams(); + + if (lp.scrappedFromPosition == position) { + scrapViews.remove(i); + return scrapView; + } + } + + return scrapViews.remove(size - 1); + } + } + + @Override + public void setEmptyView(View emptyView) { + super.setEmptyView(emptyView); + mEmptyView = emptyView; + updateEmptyStatus(); + } + + @Override + public void setFocusable(boolean focusable) { + final ListAdapter adapter = getAdapter(); + final boolean empty = (adapter == null || adapter.getCount() == 0); + + mDesiredFocusableState = focusable; + if (!focusable) { + mDesiredFocusableInTouchModeState = false; + } + + super.setFocusable(focusable && !empty); + } + + @Override + public void setFocusableInTouchMode(boolean focusable) { + final ListAdapter adapter = getAdapter(); + final boolean empty = (adapter == null || adapter.getCount() == 0); + + mDesiredFocusableInTouchModeState = focusable; + if (focusable) { + mDesiredFocusableState = true; + } + + super.setFocusableInTouchMode(focusable && !empty); + } + + private void checkFocus() { + final ListAdapter adapter = getAdapter(); + final boolean focusable = (adapter != null && adapter.getCount() > 0); + + // The order in which we set focusable in touch mode/focusable may matter + // for the client, see View.setFocusableInTouchMode() comments for more + // details + super.setFocusableInTouchMode(focusable && mDesiredFocusableInTouchModeState); + super.setFocusable(focusable && mDesiredFocusableState); + + if (mEmptyView != null) { + updateEmptyStatus(); + } + } + + private void updateEmptyStatus() { + final boolean isEmpty = (mAdapter == null || mAdapter.isEmpty()); + + if (isEmpty) { + if (mEmptyView != null) { + mEmptyView.setVisibility(View.VISIBLE); + setVisibility(View.GONE); + } else { + // If the caller just removed our empty view, make sure the list + // view is visible + setVisibility(View.VISIBLE); + } + + // We are now GONE, so pending layouts will not be dispatched. + // Force one here to make sure that the state of the list matches + // the state of the adapter. + if (mDataChanged) { + layout(getLeft(), getTop(), getRight(), getBottom()); + } + } else { + if (mEmptyView != null) { + mEmptyView.setVisibility(View.GONE); + } + + setVisibility(View.VISIBLE); + } + } + + private class AdapterDataSetObserver extends DataSetObserver { + private Parcelable mInstanceState = null; + + @Override + public void onChanged() { + mDataChanged = true; + mOldItemCount = mItemCount; + mItemCount = getAdapter().getCount(); + + // Detect the case where a cursor that was previously invalidated has + // been re-populated with new data. + if (TwoWayView.this.mHasStableIds && mInstanceState != null + && mOldItemCount == 0 && mItemCount > 0) { + TwoWayView.this.onRestoreInstanceState(mInstanceState); + mInstanceState = null; + } else { + rememberSyncState(); + } + + checkFocus(); + requestLayout(); + } + + @Override + public void onInvalidated() { + mDataChanged = true; + + if (TwoWayView.this.mHasStableIds) { + // Remember the current state for the case where our hosting activity is being + // stopped and later restarted + mInstanceState = TwoWayView.this.onSaveInstanceState(); + } + + // Data is invalid so we should reset our state + mOldItemCount = mItemCount; + mItemCount = 0; + + mSelectedPosition = INVALID_POSITION; + mSelectedRowId = INVALID_ROW_ID; + + mNextSelectedPosition = INVALID_POSITION; + mNextSelectedRowId = INVALID_ROW_ID; + + mNeedSync = false; + + checkFocus(); + requestLayout(); + } + } + + static class SavedState extends BaseSavedState { + long selectedId; + long firstId; + int viewStart; + int position; + int size; + int checkedItemCount; + SparseBooleanArray checkState; + LongSparseArray<Integer> checkIdState; + + /** + * Constructor called from {@link TwoWayView#onSaveInstanceState()} + */ + SavedState(Parcelable superState) { + super(superState); + } + + /** + * Constructor called from {@link #CREATOR} + */ + private SavedState(Parcel in) { + super(in); + + selectedId = in.readLong(); + firstId = in.readLong(); + viewStart = in.readInt(); + position = in.readInt(); + size = in.readInt(); + + checkedItemCount = in.readInt(); + checkState = in.readSparseBooleanArray(); + + final int N = in.readInt(); + if (N > 0) { + checkIdState = new LongSparseArray<Integer>(); + for (int i = 0; i < N; i++) { + final long key = in.readLong(); + final int value = in.readInt(); + checkIdState.put(key, value); + } + } + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + + out.writeLong(selectedId); + out.writeLong(firstId); + out.writeInt(viewStart); + out.writeInt(position); + out.writeInt(size); + + out.writeInt(checkedItemCount); + out.writeSparseBooleanArray(checkState); + + final int N = checkIdState != null ? checkIdState.size() : 0; + out.writeInt(N); + + for (int i = 0; i < N; i++) { + out.writeLong(checkIdState.keyAt(i)); + out.writeInt(checkIdState.valueAt(i)); + } + } + + @Override + public String toString() { + return "TwoWayView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " selectedId=" + selectedId + + " firstId=" + firstId + + " viewStart=" + viewStart + + " size=" + size + + " position=" + position + + " checkState=" + checkState + "}"; + } + + public static final Parcelable.Creator<SavedState> CREATOR + = new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + private class SelectionNotifier implements Runnable { + @Override + public void run() { + if (mDataChanged) { + // Data has changed between when this SelectionNotifier + // was posted and now. We need to wait until the AdapterView + // has been synched to the new data. + if (mAdapter != null) { + post(this); + } + } else { + fireOnSelected(); + performAccessibilityActionsOnSelected(); + } + } + } + + private class WindowRunnable { + private int mOriginalAttachCount; + + public void rememberWindowAttachCount() { + mOriginalAttachCount = getWindowAttachCount(); + } + + public boolean sameWindow() { + return hasWindowFocus() && getWindowAttachCount() == mOriginalAttachCount; + } + } + + private class PerformClick extends WindowRunnable implements Runnable { + int mClickMotionPosition; + + @Override + public void run() { + if (mDataChanged) { + return; + } + + final ListAdapter adapter = mAdapter; + final int motionPosition = mClickMotionPosition; + + if (adapter != null && mItemCount > 0 && + motionPosition != INVALID_POSITION && + motionPosition < adapter.getCount() && sameWindow()) { + + final View child = getChildAt(motionPosition - mFirstPosition); + if (child != null) { + performItemClick(child, motionPosition, adapter.getItemId(motionPosition)); + } + } + } + } + + private final class CheckForTap implements Runnable { + @Override + public void run() { + if (mTouchMode != TOUCH_MODE_DOWN) { + return; + } + + mTouchMode = TOUCH_MODE_TAP; + + final View child = getChildAt(mMotionPosition - mFirstPosition); + if (child != null && !child.hasFocusable()) { + mLayoutMode = LAYOUT_NORMAL; + + if (!mDataChanged) { + setPressed(true); + child.setPressed(true); + + layoutChildren(); + positionSelector(mMotionPosition, child); + refreshDrawableState(); + + positionSelector(mMotionPosition, child); + refreshDrawableState(); + + final boolean longClickable = isLongClickable(); + + if (mSelector != null) { + Drawable d = mSelector.getCurrent(); + + if (d != null && d instanceof TransitionDrawable) { + if (longClickable) { + final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); + ((TransitionDrawable) d).startTransition(longPressTimeout); + } else { + ((TransitionDrawable) d).resetTransition(); + } + } + } + + if (longClickable) { + triggerCheckForLongPress(); + } else { + mTouchMode = TOUCH_MODE_DONE_WAITING; + } + } else { + mTouchMode = TOUCH_MODE_DONE_WAITING; + } + } + } + } + + private class CheckForLongPress extends WindowRunnable implements Runnable { + @Override + public void run() { + final int motionPosition = mMotionPosition; + final View child = getChildAt(motionPosition - mFirstPosition); + + if (child != null) { + final long longPressId = mAdapter.getItemId(mMotionPosition); + + boolean handled = false; + if (sameWindow() && !mDataChanged) { + handled = performLongPress(child, motionPosition, longPressId); + } + + if (handled) { + mTouchMode = TOUCH_MODE_REST; + setPressed(false); + child.setPressed(false); + } else { + mTouchMode = TOUCH_MODE_DONE_WAITING; + } + } + } + } + + private class CheckForKeyLongPress extends WindowRunnable implements Runnable { + public void run() { + if (!isPressed() || mSelectedPosition < 0) { + return; + } + + final int index = mSelectedPosition - mFirstPosition; + final View v = getChildAt(index); + + if (!mDataChanged) { + boolean handled = false; + + if (sameWindow()) { + handled = performLongPress(v, mSelectedPosition, mSelectedRowId); + } + + if (handled) { + setPressed(false); + v.setPressed(false); + } + } else { + setPressed(false); + + if (v != null) { + v.setPressed(false); + } + } + } + } + + private static class ArrowScrollFocusResult { + private int mSelectedPosition; + private int mAmountToScroll; + + /** + * How {@link TwoWayView#arrowScrollFocused} returns its values. + */ + void populate(int selectedPosition, int amountToScroll) { + mSelectedPosition = selectedPosition; + mAmountToScroll = amountToScroll; + } + + public int getSelectedPosition() { + return mSelectedPosition; + } + + public int getAmountToScroll() { + return mAmountToScroll; + } + } + + private class ListItemAccessibilityDelegate extends AccessibilityDelegateCompat { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + + final int position = getPositionForView(host); + final ListAdapter adapter = getAdapter(); + + // Cannot perform actions on invalid items + if (position == INVALID_POSITION || adapter == null) { + return; + } + + // Cannot perform actions on disabled items + if (!isEnabled() || !adapter.isEnabled(position)) { + return; + } + + if (position == getSelectedItemPosition()) { + info.setSelected(true); + info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION); + } else { + info.addAction(AccessibilityNodeInfoCompat.ACTION_SELECT); + } + + if (isClickable()) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); + info.setClickable(true); + } + + if (isLongClickable()) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK); + info.setLongClickable(true); + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle arguments) { + if (super.performAccessibilityAction(host, action, arguments)) { + return true; + } + + final int position = getPositionForView(host); + final ListAdapter adapter = getAdapter(); + + // Cannot perform actions on invalid items + if (position == INVALID_POSITION || adapter == null) { + return false; + } + + // Cannot perform actions on disabled items + if (!isEnabled() || !adapter.isEnabled(position)) { + return false; + } + + final long id = getItemIdAtPosition(position); + + switch (action) { + case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION: + if (getSelectedItemPosition() == position) { + setSelection(INVALID_POSITION); + return true; + } + return false; + + case AccessibilityNodeInfoCompat.ACTION_SELECT: + if (getSelectedItemPosition() != position) { + setSelection(position); + return true; + } + return false; + + case AccessibilityNodeInfoCompat.ACTION_CLICK: + return isClickable() && performItemClick(host, position, id); + + case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK: + return isLongClickable() && performLongPress(host, position, id); + } + + return false; + } + } + + private class PositionScroller implements Runnable { + private static final int SCROLL_DURATION = 200; + + private static final int MOVE_AFTER_POS = 1; + private static final int MOVE_BEFORE_POS = 2; + private static final int MOVE_AFTER_BOUND = 3; + private static final int MOVE_BEFORE_BOUND = 4; + private static final int MOVE_OFFSET = 5; + + private int mMode; + private int mTargetPosition; + private int mBoundPosition; + private int mLastSeenPosition; + private int mScrollDuration; + private final int mExtraScroll; + + private int mOffsetFromStart; + + PositionScroller() { + mExtraScroll = ViewConfiguration.get(mContext).getScaledFadingEdgeLength(); + } + + void start(final int position) { + stop(); + + if (mDataChanged) { + // Wait until we're back in a stable state to try this. + mPositionScrollAfterLayout = new Runnable() { + @Override public void run() { + start(position); + } + }; + + return; + } + + final int childCount = getChildCount(); + if (childCount == 0) { + // Can't scroll without children. + return; + } + + final int firstPosition = mFirstPosition; + final int lastPosition = firstPosition + childCount - 1; + + final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position)); + + final int viewTravelCount; + if (clampedPosition < firstPosition) { + viewTravelCount = firstPosition - clampedPosition + 1; + mMode = MOVE_BEFORE_POS; + } else if (clampedPosition > lastPosition) { + viewTravelCount = clampedPosition - lastPosition + 1; + mMode = MOVE_AFTER_POS; + } else { + scrollToVisible(clampedPosition, INVALID_POSITION, SCROLL_DURATION); + return; + } + + if (viewTravelCount > 0) { + mScrollDuration = SCROLL_DURATION / viewTravelCount; + } else { + mScrollDuration = SCROLL_DURATION; + } + + mTargetPosition = clampedPosition; + mBoundPosition = INVALID_POSITION; + mLastSeenPosition = INVALID_POSITION; + + ViewCompat.postOnAnimation(TwoWayView.this, this); + } + + void start(final int position, final int boundPosition) { + stop(); + + if (boundPosition == INVALID_POSITION) { + start(position); + return; + } + + if (mDataChanged) { + // Wait until we're back in a stable state to try this. + mPositionScrollAfterLayout = new Runnable() { + @Override public void run() { + start(position, boundPosition); + } + }; + + return; + } + + final int childCount = getChildCount(); + if (childCount == 0) { + // Can't scroll without children. + return; + } + + final int firstPosition = mFirstPosition; + final int lastPosition = firstPosition + childCount - 1; + + final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position)); + + final int viewTravelCount; + if (clampedPosition < firstPosition) { + final int boundPositionFromLast = lastPosition - boundPosition; + if (boundPositionFromLast < 1) { + // Moving would shift our bound position off the screen. Abort. + return; + } + + final int positionTravel = firstPosition - clampedPosition + 1; + final int boundTravel = boundPositionFromLast - 1; + if (boundTravel < positionTravel) { + viewTravelCount = boundTravel; + mMode = MOVE_BEFORE_BOUND; + } else { + viewTravelCount = positionTravel; + mMode = MOVE_BEFORE_POS; + } + } else if (clampedPosition > lastPosition) { + final int boundPositionFromFirst = boundPosition - firstPosition; + if (boundPositionFromFirst < 1) { + // Moving would shift our bound position off the screen. Abort. + return; + } + + final int positionTravel = clampedPosition - lastPosition + 1; + final int boundTravel = boundPositionFromFirst - 1; + if (boundTravel < positionTravel) { + viewTravelCount = boundTravel; + mMode = MOVE_AFTER_BOUND; + } else { + viewTravelCount = positionTravel; + mMode = MOVE_AFTER_POS; + } + } else { + scrollToVisible(clampedPosition, boundPosition, SCROLL_DURATION); + return; + } + + if (viewTravelCount > 0) { + mScrollDuration = SCROLL_DURATION / viewTravelCount; + } else { + mScrollDuration = SCROLL_DURATION; + } + + mTargetPosition = clampedPosition; + mBoundPosition = boundPosition; + mLastSeenPosition = INVALID_POSITION; + + ViewCompat.postOnAnimation(TwoWayView.this, this); + } + + void startWithOffset(int position, int offset) { + startWithOffset(position, offset, SCROLL_DURATION); + } + + void startWithOffset(final int position, int offset, final int duration) { + stop(); + + if (mDataChanged) { + // Wait until we're back in a stable state to try this. + final int postOffset = offset; + mPositionScrollAfterLayout = new Runnable() { + @Override public void run() { + startWithOffset(position, postOffset, duration); + } + }; + + return; + } + + final int childCount = getChildCount(); + if (childCount == 0) { + // Can't scroll without children. + return; + } + + offset += getStartEdge(); + + mTargetPosition = Math.max(0, Math.min(getCount() - 1, position)); + mOffsetFromStart = offset; + mBoundPosition = INVALID_POSITION; + mLastSeenPosition = INVALID_POSITION; + mMode = MOVE_OFFSET; + + final int firstPosition = mFirstPosition; + final int lastPosition = firstPosition + childCount - 1; + + final int viewTravelCount; + if (mTargetPosition < firstPosition) { + viewTravelCount = firstPosition - mTargetPosition; + } else if (mTargetPosition > lastPosition) { + viewTravelCount = mTargetPosition - lastPosition; + } else { + // On-screen, just scroll. + final View targetView = getChildAt(mTargetPosition - firstPosition); + final int targetStart = getChildStartEdge(targetView); + smoothScrollBy(targetStart - offset, duration); + return; + } + + // Estimate how many screens we should travel + final float screenTravelCount = (float) viewTravelCount / childCount; + mScrollDuration = screenTravelCount < 1 ? + duration : (int) (duration / screenTravelCount); + mLastSeenPosition = INVALID_POSITION; + + ViewCompat.postOnAnimation(TwoWayView.this, this); + } + + /** + * Scroll such that targetPos is in the visible padded region without scrolling + * boundPos out of view. Assumes targetPos is onscreen. + */ + void scrollToVisible(int targetPosition, int boundPosition, int duration) { + final int childCount = getChildCount(); + final int firstPosition = mFirstPosition; + final int lastPosition = firstPosition + childCount - 1; + + final int start = getStartEdge(); + final int end = getEndEdge(); + + if (targetPosition < firstPosition || targetPosition > lastPosition) { + Log.w(LOGTAG, "scrollToVisible called with targetPosition " + targetPosition + + " not visible [" + firstPosition + ", " + lastPosition + "]"); + } + + if (boundPosition < firstPosition || boundPosition > lastPosition) { + // boundPos doesn't matter, it's already offscreen. + boundPosition = INVALID_POSITION; + } + + final View targetChild = getChildAt(targetPosition - firstPosition); + final int targetStart = getChildStartEdge(targetChild); + final int targetEnd = getChildEndEdge(targetChild); + + int scrollBy = 0; + if (targetEnd > end) { + scrollBy = targetEnd - end; + } + if (targetStart < start) { + scrollBy = targetStart - start; + } + + if (scrollBy == 0) { + return; + } + + if (boundPosition >= 0) { + final View boundChild = getChildAt(boundPosition - firstPosition); + final int boundStart = getChildStartEdge(boundChild); + final int boundEnd = getChildEndEdge(boundChild); + final int absScroll = Math.abs(scrollBy); + + if (scrollBy < 0 && boundEnd + absScroll > end) { + // Don't scroll the bound view off the end of the screen. + scrollBy = Math.max(0, boundEnd - end); + } else if (scrollBy > 0 && boundStart - absScroll < start) { + // Don't scroll the bound view off the top of the screen. + scrollBy = Math.min(0, boundStart - start); + } + } + + smoothScrollBy(scrollBy, duration); + } + + void stop() { + removeCallbacks(this); + } + + @Override + public void run() { + final int size = getAvailableSize(); + final int firstPosition = mFirstPosition; + + final int startPadding = (mIsVertical ? getPaddingTop() : getPaddingLeft()); + final int endPadding = (mIsVertical ? getPaddingBottom() : getPaddingRight()); + + switch (mMode) { + case MOVE_AFTER_POS: { + final int lastViewIndex = getChildCount() - 1; + if (lastViewIndex < 0) { + return; + } + + final int lastPosition = firstPosition + lastViewIndex; + if (lastPosition == mLastSeenPosition) { + // No new views, let things keep going. + ViewCompat.postOnAnimation(TwoWayView.this, this); + return; + } + + final View lastView = getChildAt(lastViewIndex); + final int lastViewSize = getChildSize(lastView); + final int lastViewStart = getChildStartEdge(lastView); + final int lastViewPixelsShowing = size - lastViewStart; + final int extraScroll = lastPosition < mItemCount - 1 ? + Math.max(endPadding, mExtraScroll) : endPadding; + + final int scrollBy = lastViewSize - lastViewPixelsShowing + extraScroll; + smoothScrollBy(scrollBy, mScrollDuration); + + mLastSeenPosition = lastPosition; + if (lastPosition < mTargetPosition) { + ViewCompat.postOnAnimation(TwoWayView.this, this); + } + + break; + } + + case MOVE_AFTER_BOUND: { + final int nextViewIndex = 1; + final int childCount = getChildCount(); + if (firstPosition == mBoundPosition || + childCount <= nextViewIndex || + firstPosition + childCount >= mItemCount) { + return; + } + + final int nextPosition = firstPosition + nextViewIndex; + + if (nextPosition == mLastSeenPosition) { + // No new views, let things keep going. + ViewCompat.postOnAnimation(TwoWayView.this, this); + return; + } + + final View nextView = getChildAt(nextViewIndex); + final int nextViewSize = getChildSize(nextView); + final int nextViewStart = getChildStartEdge(nextView); + final int extraScroll = Math.max(endPadding, mExtraScroll); + if (nextPosition < mBoundPosition) { + smoothScrollBy(Math.max(0, nextViewSize + nextViewStart - extraScroll), + mScrollDuration); + mLastSeenPosition = nextPosition; + ViewCompat.postOnAnimation(TwoWayView.this, this); + } else { + if (nextViewSize > extraScroll) { + smoothScrollBy(nextViewSize - extraScroll, mScrollDuration); + } + } + + break; + } + + case MOVE_BEFORE_POS: { + if (firstPosition == mLastSeenPosition) { + // No new views, let things keep going. + ViewCompat.postOnAnimation(TwoWayView.this, this); + return; + } + + final View firstView = getChildAt(0); + if (firstView == null) { + return; + } + + final int firstViewTop = getChildStartEdge(firstView); + final int extraScroll = firstPosition > 0 ? + Math.max(mExtraScroll, startPadding) : startPadding; + + smoothScrollBy(firstViewTop - extraScroll, mScrollDuration); + mLastSeenPosition = firstPosition; + + if (firstPosition > mTargetPosition) { + ViewCompat.postOnAnimation(TwoWayView.this, this); + } + + break; + } + + case MOVE_BEFORE_BOUND: { + final int lastViewIndex = getChildCount() - 2; + if (lastViewIndex < 0) { + return; + } + + final int lastPosition = firstPosition + lastViewIndex; + + if (lastPosition == mLastSeenPosition) { + // No new views, let things keep going. + ViewCompat.postOnAnimation(TwoWayView.this, this); + return; + } + + final View lastView = getChildAt(lastViewIndex); + final int lastViewSize = getChildSize(lastView); + final int lastViewStart = getChildStartEdge(lastView); + final int lastViewPixelsShowing = size - lastViewStart; + final int extraScroll = Math.max(startPadding, mExtraScroll); + + mLastSeenPosition = lastPosition; + + if (lastPosition > mBoundPosition) { + smoothScrollBy(-(lastViewPixelsShowing - extraScroll), mScrollDuration); + ViewCompat.postOnAnimation(TwoWayView.this, this); + } else { + final int end = size - extraScroll; + final int lastViewEnd = lastViewStart + lastViewSize; + if (end > lastViewEnd) { + smoothScrollBy(-(end - lastViewEnd), mScrollDuration); + } + } + + break; + } + + case MOVE_OFFSET: { + if (mLastSeenPosition == firstPosition) { + // No new views, let things keep going. + ViewCompat.postOnAnimation(TwoWayView.this, this); + return; + } + + mLastSeenPosition = firstPosition; + + final int childCount = getChildCount(); + final int position = mTargetPosition; + final int lastPos = firstPosition + childCount - 1; + + int viewTravelCount = 0; + if (position < firstPosition) { + viewTravelCount = firstPosition - position + 1; + } else if (position > lastPos) { + viewTravelCount = position - lastPos; + } + + // Estimate how many screens we should travel + final float screenTravelCount = (float) viewTravelCount / childCount; + + final float modifier = Math.min(Math.abs(screenTravelCount), 1.f); + if (position < firstPosition) { + final int distance = (int) (-getSize() * modifier); + final int duration = (int) (mScrollDuration * modifier); + smoothScrollBy(distance, duration); + ViewCompat.postOnAnimation(TwoWayView.this, this); + } else if (position > lastPos) { + final int distance = (int) (getSize() * modifier); + final int duration = (int) (mScrollDuration * modifier); + smoothScrollBy(distance, duration); + ViewCompat.postOnAnimation(TwoWayView.this, this); + } else { + // On-screen, just scroll. + final View targetView = getChildAt(position - firstPosition); + final int targetStart = getChildStartEdge(targetView); + final int distance = targetStart - mOffsetFromStart; + final int duration = (int) (mScrollDuration * + ((float) Math.abs(distance) / getSize())); + smoothScrollBy(distance, duration); + } + + break; + } + + default: + break; + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java new file mode 100644 index 000000000..c84686e90 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java @@ -0,0 +1,172 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedEditText extends android.widget.EditText + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedEditText(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedEditText(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java new file mode 100644 index 000000000..a95fe2d9f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java @@ -0,0 +1,172 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedFrameLayout extends android.widget.FrameLayout + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedFrameLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java new file mode 100644 index 000000000..88e94c6c7 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java @@ -0,0 +1,200 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedImageButton extends android.widget.ImageButton + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedImageButton(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedImageButton(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + + final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0); + drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList); + themedA.recycle(); + + // Apply the tint initially - the Drawable is + // initially set by XML via super's constructor. + setTintedImageDrawable(getDrawable()); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + @Override + public void setImageDrawable(final Drawable drawable) { + setTintedImageDrawable(drawable); + } + + private void setTintedImageDrawable(final Drawable drawable) { + final Drawable tintedDrawable; + if (drawableColors == null || R.id.bookmark == getId()) { + // NB: The bookmarked state uses a blue star, so this is a hack to keep it untinted. + // NB: If we tint a drawable with a null ColorStateList, it will override + // any existing colorFilters and tint... so don't! + tintedDrawable = drawable; + } else if (drawable == null) { + tintedDrawable = null; + } else { + tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors); + } + super.setImageDrawable(tintedDrawable); + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java new file mode 100644 index 000000000..befbe6fb5 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java @@ -0,0 +1,199 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedImageView extends android.widget.ImageView + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedImageView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + + final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0); + drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList); + themedA.recycle(); + + // Apply the tint initially - the Drawable is + // initially set by XML via super's constructor. + setTintedImageDrawable(getDrawable()); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + @Override + public void setImageDrawable(final Drawable drawable) { + setTintedImageDrawable(drawable); + } + + private void setTintedImageDrawable(final Drawable drawable) { + final Drawable tintedDrawable; + if (drawableColors == null) { + // NB: If we tint a drawable with a null ColorStateList, it will override + // any existing colorFilters and tint... so don't! + tintedDrawable = drawable; + } else if (drawable == null) { + tintedDrawable = null; + } else { + tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors); + } + super.setImageDrawable(tintedDrawable); + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java new file mode 100644 index 000000000..87ec58ce0 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java @@ -0,0 +1,167 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedLinearLayout extends android.widget.LinearLayout + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java new file mode 100644 index 000000000..14ef25c62 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java @@ -0,0 +1,172 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedRelativeLayout extends android.widget.RelativeLayout + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedRelativeLayout(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedRelativeLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java new file mode 100644 index 000000000..294abd9ba --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java @@ -0,0 +1,167 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedTextSwitcher extends android.widget.TextSwitcher + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedTextSwitcher(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java new file mode 100644 index 000000000..51a23a406 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java @@ -0,0 +1,172 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedTextView extends android.widget.TextView + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedTextView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java new file mode 100644 index 000000000..77ecfd271 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java @@ -0,0 +1,172 @@ +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class ThemedView extends android.view.View + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public ThemedView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + + public ThemedView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag new file mode 100644 index 000000000..e731a0ebe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag @@ -0,0 +1,211 @@ +//#filter substitution +// This file is generated by generate_themed_views.py; do not edit. + +/* 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.widget.themed; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.R; +import org.mozilla.gecko.util.DrawableUtil; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +public class Themed@VIEW_NAME_SUFFIX@ extends @BASE_TYPE@ + implements LightweightTheme.OnChangeListener { + private LightweightTheme theme; + + private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private }; + private static final int[] STATE_LIGHT = { R.attr.state_light }; + private static final int[] STATE_DARK = { R.attr.state_dark }; + + protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed }; + protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused }; + protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private }; + + private boolean isPrivate; + private boolean isLight; + private boolean isDark; + private boolean autoUpdateTheme; // always false if there's no theme. + + private ColorStateList drawableColors; + + public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs, 0); + } + +//#ifdef STYLE_CONSTRUCTOR + public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs, defStyle); + } + +//#endif + private void initialize(final Context context, final AttributeSet attrs, final int defStyle) { + // The theme can be null, particularly if we might be instantiating this + // View in an IDE, with no ambient GeckoApplication. + final Context applicationContext = context.getApplicationContext(); + if (applicationContext instanceof GeckoApplication) { + theme = ((GeckoApplication) applicationContext).getLightweightTheme(); + } + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme); + autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true); + a.recycle(); +//#if TINT_FOREGROUND_DRAWABLE + + final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0); + drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList); + themedA.recycle(); + + // Apply the tint initially - the Drawable is + // initially set by XML via super's constructor. + setTintedImageDrawable(getDrawable()); +//#endif + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (autoUpdateTheme) + theme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (autoUpdateTheme) + theme.removeListener(this); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (isPrivate) + mergeDrawableStates(drawableState, STATE_PRIVATE_MODE); + else if (isLight) + mergeDrawableStates(drawableState, STATE_LIGHT); + else if (isDark) + mergeDrawableStates(drawableState, STATE_DARK); + + return drawableState; + } + + @Override + public void onLightweightThemeChanged() { + if (autoUpdateTheme && theme.isEnabled()) + setTheme(theme.isLightTheme()); + } + + @Override + public void onLightweightThemeReset() { + if (autoUpdateTheme) + resetTheme(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + public boolean isPrivateMode() { + return isPrivate; + } + + public void setPrivateMode(boolean isPrivate) { + if (this.isPrivate != isPrivate) { + this.isPrivate = isPrivate; + refreshDrawableState(); + invalidate(); + } + } + + public void setTheme(boolean isLight) { + // Set the theme only if it is different from existing theme. + if ((isLight && this.isLight != isLight) || + (!isLight && this.isDark == isLight)) { + if (isLight) { + this.isLight = true; + this.isDark = false; + } else { + this.isLight = false; + this.isDark = true; + } + + refreshDrawableState(); + invalidate(); + } + } + + public void resetTheme() { + if (isLight || isDark) { + isLight = false; + isDark = false; + refreshDrawableState(); + invalidate(); + } + } + + public void setAutoUpdateTheme(boolean autoUpdateTheme) { + if (theme == null) { + return; + } + + if (this.autoUpdateTheme != autoUpdateTheme) { + this.autoUpdateTheme = autoUpdateTheme; + + if (autoUpdateTheme) + theme.addListener(this); + else + theme.removeListener(this); + } + } + +//#ifdef TINT_FOREGROUND_DRAWABLE + @Override + public void setImageDrawable(final Drawable drawable) { + setTintedImageDrawable(drawable); + } + + private void setTintedImageDrawable(final Drawable drawable) { + final Drawable tintedDrawable; +//#ifdef BOOKMARK_NO_TINT + if (drawableColors == null || R.id.bookmark == getId()) { + // NB: The bookmarked state uses a blue star, so this is a hack to keep it untinted. +//#else + if (drawableColors == null) { +//#endif + // NB: If we tint a drawable with a null ColorStateList, it will override + // any existing colorFilters and tint... so don't! + tintedDrawable = drawable; + } else if (drawable == null) { + tintedDrawable = null; + } else { + tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors); + } + super.setImageDrawable(tintedDrawable); + } + +//#endif + public ColorDrawable getColorDrawable(int id) { + return new ColorDrawable(ContextCompat.getColor(getContext(), id)); + } + + protected LightweightTheme getTheme() { + return theme; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py b/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py new file mode 100644 index 000000000..3b5a00b40 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py @@ -0,0 +1,72 @@ +#!/bin/python + +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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/. + +''' +Script to generate Themed*.java source files for Fennec. + +This script runs the preprocessor on a input template and writes +updated files into the source directory. + +To update the themed views, update the input template +(ThemedView.java.frag) and run the script using 'mach python <script.py>'. Use version control to +examine the differences, and don't forget to commit the changes to the +template and the outputs. +''' + +from __future__ import ( + print_function, + unicode_literals, +) + +import os + +from mozbuild.preprocessor import Preprocessor + +__DIR__ = os.path.dirname(os.path.abspath(__file__)) + +template = os.path.join(__DIR__, 'ThemedView.java.frag') +dest_format_string = 'Themed%(VIEW_NAME_SUFFIX)s.java' + +views = [ + dict(VIEW_NAME_SUFFIX='EditText', + BASE_TYPE='android.widget.EditText', + STYLE_CONSTRUCTOR=1), + dict(VIEW_NAME_SUFFIX='FrameLayout', + BASE_TYPE='android.widget.FrameLayout', + STYLE_CONSTRUCTOR=1), + dict(VIEW_NAME_SUFFIX='ImageButton', + BASE_TYPE='android.widget.ImageButton', + STYLE_CONSTRUCTOR=1, + TINT_FOREGROUND_DRAWABLE=1, + BOOKMARK_NO_TINT=1), + dict(VIEW_NAME_SUFFIX='ImageView', + BASE_TYPE='android.widget.ImageView', + STYLE_CONSTRUCTOR=1, + TINT_FOREGROUND_DRAWABLE=1), + dict(VIEW_NAME_SUFFIX='LinearLayout', + BASE_TYPE='android.widget.LinearLayout'), + dict(VIEW_NAME_SUFFIX='RelativeLayout', + BASE_TYPE='android.widget.RelativeLayout', + STYLE_CONSTRUCTOR=1), + dict(VIEW_NAME_SUFFIX='TextSwitcher', + BASE_TYPE='android.widget.TextSwitcher'), + dict(VIEW_NAME_SUFFIX='TextView', + BASE_TYPE='android.widget.TextView', + STYLE_CONSTRUCTOR=1), + dict(VIEW_NAME_SUFFIX='View', + BASE_TYPE='android.view.View', + STYLE_CONSTRUCTOR=1), +] + +for view in views: + pp = Preprocessor(defines=view, marker='//#') + + dest = os.path.join(__DIR__, dest_format_string % view) + with open(template, 'rU') as input: + with open(dest, 'wt') as output: + pp.processFile(input=input, output=output) + print('%s' % dest) |