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.
+ *
+ * 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 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.
+ *
+ * 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();
+ }
+
+ // 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()).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 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 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 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 0000000000..c5c041c7ac
--- /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 instance = new AtomicReference();
+
+ @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
+ * onConfigurationChanged(Configuration) 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 multilocale.json, returning the included list of
+ * locale codes.
+ *
+ * If multilocale.json is not present, returns
+ * null. In that case, consider {@link #getFallbackLocaleTag()}.
+ *
+ * multilocale.json currently looks like this:
+ *
+ *
+ * {"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"]}
+ *
+ */
+ public static Collection 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 out = new HashSet(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 0000000000..cff6ea6439
--- /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 0000000000..c531b8c377
--- /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,
+ 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() {
+ @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() {
+ @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() {
+ @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() {
+ @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() {
+ @Override
+ public void onResult(Status result) {
+ }
+ });
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception while sending message", e);
+ }
+ }
+ }
+ }
+ private class MirrorCallback implements ResultCallback {
+ 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 0000000000..ce2384a4d4
--- /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 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();
+ 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 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 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 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 responseMap = new HashMap();
+ 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 0000000000..98274b752b
--- /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 0000000000..725c25d6e8
--- /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 0000000000..44aaa14a0a
--- /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 0000000000..9aa3f96a44
--- /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 mDoorHangers;
+
+ // Whether or not the doorhanger popup is disabled.
+ private boolean mDisabled;
+
+ public DoorHangerPopup(Context context) {
+ super(context);
+
+ mDoorHangers = new HashSet();
+
+ 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 doorHangersToRemove = new HashSet();
+ 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 0000000000..ff3ac6110a
--- /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 UNKNOWN_MIME_TYPES;
+ static {
+ final ArrayList 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 0000000000..28f542d5c8
--- /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 0000000000..38c38a9eba
--- /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
+ *
+ * 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(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(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 0000000000..e71bb4c52f
--- /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 getActiveExperiments(Context c) {
+ final List 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 0000000000..8ac5428a44
--- /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 intents, HashMap filters) {
+ PackageManager pm = context.getPackageManager();
+ List 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 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 baseIntents = new HashMap();
+ // A list of other activities to shwo in the picker (and the intents to launch them).
+ HashMap intents = new HashMap ();
+
+ 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 vals = new ArrayList(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 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 0000000000..7629ea546e
--- /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 {
+ final private Uri uri;
+ public VideoLoaderCallbacks(Uri uri) {
+ this.uri = uri;
+ }
+
+ @Override
+ public Loader 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 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 loader) { }
+ }
+
+ /**
+ * This class's only dependency on FilePickerResultHandler is sendResult.
+ */
+ private class FileLoaderCallbacks implements LoaderCallbacks,
+ 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 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 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 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 0000000000..efa04a04e1
--- /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 0000000000..5c7f932c00
--- /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 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 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> {
+ 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(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 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 0000000000..774ca60249
--- /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 0000000000..ce6b8abd03
--- /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 0000000000..05fa2bbf81
--- /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 lastActiveGeckoApp;
+
+ protected RelativeLayout mRootLayout;
+ protected RelativeLayout mMainLayout;
+
+ protected RelativeLayout mGeckoLayout;
+ private OrientationEventListener mCameraOrientationEventListener;
+ public List mAppStateListeners = new LinkedList();
+ 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 mWakeLocks = new HashMap();
+
+ 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 clearSet =
+ PrefUtils.getStringSet(prefs, ClearOnShutdownPref.PREF, new HashSet());
+
+ 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> itemList =
+ new ArrayList>();
+ for (final NativeJSObject permObj : permissions) {
+ final HashMap map = new HashMap();
+ 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 .
+ 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();
+
+ 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(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 envMap = System.getenv();
+ Set> envSet = envMap.entrySet();
+ Iterator> envIter = envSet.iterator();
+ int c = 0;
+ while (envIter.hasNext()) {
+ Map.Entry 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 0000000000..18a6e6535c
--- /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 0000000000..319eccec11
--- /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 mSamples = new SparseArray();
+ 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 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 0000000000..c199aad554
--- /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 0000000000..b7f4870c21
--- /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 0000000000..df9844d7b0
--- /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 0000000000..8a9c461c54
--- /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 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 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 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 0000000000..3a99fd2a14
--- /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 0000000000..f73c42e405
--- /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 0000000000..c1d9c4939a
--- /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 mPendingUris; // URIs that need to be checked
+ SoftReference> 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 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();
+ if (c.moveToFirst()) {
+ do {
+ visitedSet.add(c.getString(0));
+ } while (c.moveToNext());
+ }
+ mVisitedCache = new SoftReference>(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();
+ mVisitedCache = new SoftReference>(null);
+ }
+
+ public void addToGeckoOnly(String uri) {
+ Set 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 0000000000..d9d12962ca
--- /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 queuedMetadata = Collections.synchronizedMap(new LimitedLinkedHashMap());
+
+ public static GlobalPageMetadata getInstance() {
+ return instance;
+ }
+
+ private static class LimitedLinkedHashMap extends LinkedHashMap {
+ private static final long serialVersionUID = 6359725112736360244L;
+
+ @Override
+ protected boolean removeEldestEntry(Entry 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 0000000000..69502f44a2
--- /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 0000000000..e2f34f926b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
@@ -0,0 +1,599 @@
+/* -*- 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 getOpenURIIntent, 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
+ * Intent.ACTION_SEND.
+ * @param title the title to use in ACTION_SEND 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 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 Context 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 ACTION_SEND intents.
+ * @return an Intent, or null 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 Context 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
+ * Intent.ACTION_SEND.
+ * @param title the title to use in ACTION_SEND intents.
+ * @return an Intent, or null 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;
+ }
+
+ final Uri data = intent.getData();
+ if (data != null && "file".equals(data.normalizeScheme().getScheme())) {
+ Log.w(LOGTAG, "Blocked intent with \"file://\" data scheme.");
+ 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 Uri instance which is equivalent to u,
+ * but with a guaranteed-lowercase scheme as if the API level 16 method
+ * u.normalizeScheme had been called.
+ *
+ * @param u the Uri to normalize.
+ * @return a Uri, which might be u.
+ */
+ 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 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 0000000000..4de8fa423c
--- /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 0000000000..795caa925a
--- /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 0000000000..e030b95e9e
--- /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
+ * onCreate method, or inherit from
+ * LocaleAwareFragmentActivity or LocaleAwareActivity.
+ */
+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
+ * Locale#toLanguageTag().
+ *
+ * @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 0000000000..bd109058cd
--- /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 0000000000..fc0ce82cfd
--- /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 players = new HashMap();
+ protected final Map displays = new HashMap(); // 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 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 0000000000..94ca761b96
--- /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 0000000000..814c09995c
--- /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 0000000000..37dd8c304d
--- /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 0000000000..e44096489b
--- /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 0000000000..3e5b5ffb30
--- /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 0000000000..077b2d29b4
--- /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 0000000000..39b6899d3f
--- /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 0000000000..b4aee9370a
--- /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.
+ *
+ * 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 target fragment (see
+ * {@link Fragment#setTargetFragment(Fragment, int)}), or the containing
+ * activity, 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 clients);
+ }
+
+ public enum ChoiceMode {
+ SINGLE,
+ MULTIPLE,
+ }
+
+ public static RemoteClientsDialogFragment newInstance(String title, String positiveButtonText, ChoiceMode choiceMode, ArrayList 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 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 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 checked = new ArrayList();
+ 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 checked = new ArrayList();
+ 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 0000000000..b5a5527c9e
--- /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 0000000000..b049f76278
--- /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 0000000000..5cb404ce8a
--- /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 0000000000..64f101e515
--- /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 0000000000..d29aaadc73
--- /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 sessionTabs = new LinkedList();
+ 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 0000000000..1066da0799
--- /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 mListeners;
+
+ public SharedPreferencesHelper(Context context) {
+ mContext = context;
+
+ mListeners = new HashMap();
+
+ 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 0000000000..e39d25dd87
--- /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 0000000000..3283e7c375
--- /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 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 0000000000..e43bbef1f4
--- /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 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 query(String query) {
+ if (query.equals(mPrevQuery))
+ return mPrevResults;
+
+ ArrayList suggestions = new ArrayList();
+ 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 0000000000..6010a3dd94
--- /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 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 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();
+ 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 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 0000000000..c7e024fe03
--- /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 mOrder = new CopyOnWriteArrayList();
+
+ // 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 mTabs = new HashMap();
+
+ 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 tabs;
+
+ public PersistTabsRunnable(final Context context, Iterable 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 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 TABS_CHANGED_LISTENERS = new CopyOnWriteArrayList();
+
+ 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 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 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 0000000000..342445bf21
--- /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 0000000000..0c2051a9da
--- /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:"
+ 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 0000000000..3a70124316
--- /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 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 0000000000..c0c9307dcf
--- /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 = (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 0000000000..d1c3f59169
--- /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 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 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() {
+ @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 0000000000..aee0bba63a
--- /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 0000000000..19399e735b
--- /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 0000000000..aeb7b4334e
--- /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 0000000000..6dadd2261d
--- /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 0000000000..ddfed84bd7
--- /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 0000000000..63e8e168ec
--- /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 0000000000..bf8007bbfa
--- /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 0000000000..dc2403bbd1
--- /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 mElementsList;
+ private List 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();
+ 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();
+ }
+
+ 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 0000000000..7e8377f55f
--- /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 0000000000..3ea2e84373
--- /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 0000000000..447b837e86
--- /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 getFilesToCleanup(final String profilePath) {
+ final ArrayList 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 0000000000..76aff733a6
--- /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 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 0000000000..b1bf567b0c
--- /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 0000000000..7960f78324
--- /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 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 0000000000..2e056cc1ea
--- /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 0000000000..7e289b76fd
--- /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 Boolean.TRUE 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 Boolean.FALSE 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 isInBatchOperation = new ThreadLocal();
+
+ 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 0000000000..418d547ed4
--- /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 0000000000..51c8d964fb
--- /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 0000000000..4219e45b17
--- /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.,
+ * BrowserDB.from(context).
+ */
+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 null. CursorLoader will handle this correctly.
+ */
+ public abstract Cursor filter(ContentResolver cr, CharSequence constraint,
+ int limit, EnumSet flags);
+
+ /**
+ * @return a cursor over top sites (high-ranking bookmarks and history).
+ * Can return null.
+ * Returns no more than limit results.
+ * Suggested sites will be limited to being within the first suggestedRangeLimit 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 null.
+ */
+ public abstract Cursor getAllVisitedHistory(ContentResolver cr);
+
+ /**
+ * Can return null.
+ */
+ 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 null.
+ */
+ 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 urls.
+ * 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 urls);
+
+ public abstract void removeThumbnails(ContentResolver cr);
+
+ // Utility function for updating existing history using batch operations
+ public abstract void updateHistoryInBatch(ContentResolver cr,
+ Collection operations, String url,
+ String title, long date, int visits);
+
+ public abstract void updateBookmarkInBatch(ContentResolver cr,
+ Collection 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 0000000000..f823d90609
--- /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 subFolders = new ArrayList();
+ List invalidSpecialEntries = new ArrayList();
+
+ 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 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 0000000000..eb75d0be96
--- /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 BOOKMARKS_PROJECTION_MAP;
+ static final Map HISTORY_PROJECTION_MAP;
+ static final Map COMBINED_PROJECTION_MAP;
+ static final Map SCHEMA_PROJECTION_MAP;
+ static final Map FAVICONS_PROJECTION_MAP;
+ static final Map THUMBNAILS_PROJECTION_MAP;
+ static final Map URL_ANNOTATIONS_PROJECTION_MAP;
+ static final Map VISIT_PROJECTION_MAP;
+ static final Map 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 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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 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 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 retain.
+ *
+ * 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 retain,
+ * removing no items with a modified time after keepAfter.
+ *
+ * Provide keepAfter 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 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 getHistoryGUIDsFromSelection(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs) {
+ final ArrayList 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 historyGUIDs) {
+ return bulkDeleteByHistoryGUID(db, historyGUIDs, PageMetadata.TABLE_NAME, PageMetadata.HISTORY_GUID);
+ }
+
+ private int deleteVisitsForHistory(SQLiteDatabase db, ArrayList historyGUIDs) {
+ return bulkDeleteByHistoryGUID(db, historyGUIDs, Visits.TABLE_NAME, Visits.HISTORY_GUID);
+ }
+
+ private int bulkDeleteByHistoryGUID(SQLiteDatabase db, ArrayList 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 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 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 0000000000..cfa2f870fb
--- /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. values may be null.
+ */
+ 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 between 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 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 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 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 0000000000..ff2f5238e6
--- /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 0000000000..1a241f9dac
--- /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 0000000000..8c219282fc
--- /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 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();
+
+ 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 names = new HashMap();
+ 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 bookmarkValues = new ArrayList();
+ final ArrayList faviconValues = new ArrayList();
+
+ // 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 bookmarkValues = new ArrayList();
+ final ArrayList faviconValues = new ArrayList();
+
+ // 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 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 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 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 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 urls.
+ * 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 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 ContentResolver 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 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 ContentResolver 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 operations,
+ ArrayList 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 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 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 0000000000..a9a55e51d0
--- /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 0000000000..c7bd9475c0
--- /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 getClientsWithoutTabsByRecencyFromCursor(Cursor cursor) {
+ final ArrayList 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.
+ *
+ * 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 getClientsFromCursor(final Cursor cursor) {
+ final ArrayList clients = new ArrayList();
+
+ 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>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ protected List 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 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 tabs) {
+ // Reuse this for serializing individual history URLs as JSON.
+ JSONArray history = new JSONArray();
+ ArrayList valuesToInsert = new ArrayList();
+
+ 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 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 0000000000..7f2c4a7367
--- /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 COLUMNS;
+ static {
+ final HashSet 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> cache = new LruCache>(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 fromJSON(JSONObject obj) {
+ Map data = new HashMap();
+
+ 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 keys = icons.keys();
+
+ ArrayList sizes = new ArrayList(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