summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /mobile/android/base/java
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ANRReporter.java596
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/AboutPages.java117
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java318
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java256
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java135
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java202
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/BootReceiver.java27
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/BrowserApp.java4261
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java439
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java112
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java509
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/CrashReporter.java480
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/CustomEditText.java89
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java133
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java361
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java235
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java218
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java252
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Experiments.java119
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/FilePicker.java227
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java282
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java256
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java459
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java100
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java10
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoApp.java2878
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java314
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java211
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java27
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java19
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java22
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java149
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoService.java236
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java25
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java178
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java182
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/GuestSession.java51
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/IntentHelper.java593
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java110
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/LocaleManager.java42
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Locales.java136
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java131
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java323
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java279
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java13
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java149
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/PresentationView.java27
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/PrintHelper.java124
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/PrivateTab.java28
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java133
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java150
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Restarter.java50
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java43
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java146
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/SessionParser.java140
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java311
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java249
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java257
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/SuggestClient.java142
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Tab.java843
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Tabs.java1021
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/Telemetry.java246
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java307
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java246
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/ZoomedView.java838
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java149
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java75
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java22
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java17
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java31
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java21
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java27
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java342
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java97
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java109
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java81
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java80
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java177
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java65
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java79
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java328
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java64
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java785
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java205
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java2237
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java2340
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java450
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java166
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java194
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java1938
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java28
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java320
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java240
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java253
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java520
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java348
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java55
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java94
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java69
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java90
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java471
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/Searches.java12
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java128
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java629
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/Table.java47
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java28
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java361
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java25
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java92
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java51
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java237
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java78
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java29
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java119
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java80
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java1046
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java322
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java43
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java64
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java107
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java166
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java49
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java325
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java144
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java81
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java263
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java63
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java189
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java161
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java238
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java303
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java89
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java31
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java110
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java168
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java281
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java101
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java58
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java146
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java79
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java109
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java29
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java29
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java33
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java26
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java70
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java49
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java367
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java130
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java47
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java94
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java174
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java107
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java80
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java92
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java35
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java131
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java40
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java138
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java53
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java147
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java67
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java352
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java218
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java316
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java1316
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java373
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java433
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java697
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java145
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java393
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java80
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java224
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java315
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java1694
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java83
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java663
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java82
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java68
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java498
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java138
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomePager.java564
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java368
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java57
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java164
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java100
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java82
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java63
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java48
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java28
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java162
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java136
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java747
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java83
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java178
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java137
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java90
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java113
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java59
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java256
-rwxr-xr-xmobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java454
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java163
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java122
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java148
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java494
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java114
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java147
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java20
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java246
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java312
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java169
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java968
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java324
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java145
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java73
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java196
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java135
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java239
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java102
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java76
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java568
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java105
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java117
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java124
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java13
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java96
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java67
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java181
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java143
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java152
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java167
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java222
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/Icons.java35
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java140
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java197
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java396
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java212
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java133
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java96
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java36
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java27
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java219
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java168
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java23
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java45
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java74
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java31
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java29
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java30
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java56
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java19
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java36
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java21
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java68
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java293
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java70
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java112
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java195
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java260
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java455
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java133
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java535
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java34
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java14
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java135
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/Codec.java366
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java191
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java133
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java35
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java627
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java44
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java405
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java162
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java431
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java307
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java44
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java224
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java152
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java247
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/Sample.java264
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java115
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java51
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java204
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java928
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java163
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java472
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java81
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java64
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java152
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java188
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java36
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java76
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java81
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java171
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java324
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java366
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java106
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java37
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java99
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java68
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java126
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java48
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java30
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java296
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java82
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java128
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java185
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java150
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java25
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java493
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java24
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java230
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java112
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java115
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java37
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java44
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java72
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java182
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java61
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java192
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java296
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java1520
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java35
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java58
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java316
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java67
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java271
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java116
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java255
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java261
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java67
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java183
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java145
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java124
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java103
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java237
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java237
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java103
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java194
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java59
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java171
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java158
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java12
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java586
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java398
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java281
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java128
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java72
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java107
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/Fetched.java71
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushClient.java110
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushManager.java354
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java126
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushService.java460
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushState.java137
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java81
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java72
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java154
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java247
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java34
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java83
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java112
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java129
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java99
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java31
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java84
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java304
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java764
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java357
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java215
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java342
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java130
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java63
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java70
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java87
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java69
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java60
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java55
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java170
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java98
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java254
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java449
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java712
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java216
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java100
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java124
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java118
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java65
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java456
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java69
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java16
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java188
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java118
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java34
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java73
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java347
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java37
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java100
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java99
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java247
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java87
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java32
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java26
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java301
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java66
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java69
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java206
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/text/TextAction.java68
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java13
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java10
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java26
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java960
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java128
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java219
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java211
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java182
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java62
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java23
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java85
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java371
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java29
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java109
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java74
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java571
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java154
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java530
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java348
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java630
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java78
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java195
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java131
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java120
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java795
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java213
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java44
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java66
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java136
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java48
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java128
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java33
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java1359
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java21
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java130
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java77
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java140
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java52
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java25
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java143
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java665
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java190
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java685
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java220
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java127
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java65
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java106
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java108
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java74
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java48
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java268
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java39
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java91
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java360
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java189
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java66
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java111
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java228
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java105
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java117
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java79
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java16
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java21
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java33
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java356
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java38
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java86
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java134
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java7191
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java200
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java199
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java167
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java167
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java172
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag211
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py72
516 files changed, 116063 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java b/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java
new file mode 100644
index 000000000..3c29edef3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ANRReporter.java
@@ -0,0 +1,596 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+public final class ANRReporter extends BroadcastReceiver
+{
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoANRReporter";
+
+ private static final String ANR_ACTION = "android.intent.action.ANR";
+ // Number of lines to search traces.txt to decide whether it's a Gecko ANR
+ private static final int LINES_TO_IDENTIFY_TRACES = 10;
+ // ANRs may happen because of memory pressure,
+ // so don't use up too much memory here
+ // Size of buffer to hold one line of text
+ private static final int TRACES_LINE_SIZE = 100;
+ // Size of block to use when processing traces.txt
+ private static final int TRACES_BLOCK_SIZE = 2000;
+ private static final String TRACES_CHARSET = "utf-8";
+ private static final String PING_CHARSET = "utf-8";
+
+ private static final ANRReporter sInstance = new ANRReporter();
+ private static int sRegisteredCount;
+ private Handler mHandler;
+ private volatile boolean mPendingANR;
+
+ @WrapForJNI
+ private static native boolean requestNativeStack(boolean unwind);
+ @WrapForJNI
+ private static native String getNativeStack();
+ @WrapForJNI
+ private static native void releaseNativeStack();
+
+ public static void register(Context context) {
+ if (sRegisteredCount++ != 0) {
+ // Already registered
+ return;
+ }
+ sInstance.start(context);
+ }
+
+ public static void unregister() {
+ if (sRegisteredCount == 0) {
+ Log.w(LOGTAG, "register/unregister mismatch");
+ return;
+ }
+ if (--sRegisteredCount != 0) {
+ // Should still be registered
+ return;
+ }
+ sInstance.stop();
+ }
+
+ private void start(final Context context) {
+
+ Thread receiverThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ Looper.prepare();
+ synchronized (ANRReporter.this) {
+ mHandler = new Handler();
+ ANRReporter.this.notify();
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "registering receiver");
+ }
+ context.registerReceiver(ANRReporter.this,
+ new IntentFilter(ANR_ACTION),
+ null,
+ mHandler);
+ Looper.loop();
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "unregistering receiver");
+ }
+ context.unregisterReceiver(ANRReporter.this);
+ mHandler = null;
+ }
+ }, LOGTAG);
+
+ receiverThread.setDaemon(true);
+ receiverThread.start();
+ }
+
+ private void stop() {
+ synchronized (this) {
+ while (mHandler == null) {
+ try {
+ wait(1000);
+ if (mHandler == null) {
+ // We timed out; just give up. The process is probably
+ // quitting anyways, so we let the OS do the clean up
+ Log.w(LOGTAG, "timed out waiting for handler");
+ return;
+ }
+ } catch (InterruptedException e) {
+ }
+ }
+ }
+ Looper looper = mHandler.getLooper();
+ looper.quit();
+ try {
+ looper.getThread().join();
+ } catch (InterruptedException e) {
+ }
+ }
+
+ private ANRReporter() {
+ }
+
+ // Return the "traces.txt" file, or null if there is no such file
+ private static File getTracesFile() {
+ // Check most common location first.
+ File tracesFile = new File("/data/anr/traces.txt");
+ if (tracesFile.isFile() && tracesFile.canRead()) {
+ return tracesFile;
+ }
+
+ // Find the traces file name if we can.
+ try {
+ // getprop [prop-name [default-value]]
+ Process propProc = (new ProcessBuilder())
+ .command("/system/bin/getprop", "dalvik.vm.stack-trace-file")
+ .redirectErrorStream(true)
+ .start();
+ try {
+ BufferedReader buf = new BufferedReader(
+ new InputStreamReader(propProc.getInputStream()), TRACES_LINE_SIZE);
+ String propVal = buf.readLine();
+ if (DEBUG) {
+ Log.d(LOGTAG, "getprop returned " + String.valueOf(propVal));
+ }
+ // getprop can return empty string when the prop value is empty
+ // or prop is undefined, treat both cases the same way
+ if (propVal != null && propVal.length() != 0) {
+ tracesFile = new File(propVal);
+ if (tracesFile.isFile() && tracesFile.canRead()) {
+ return tracesFile;
+ } else if (DEBUG) {
+ Log.d(LOGTAG, "cannot access traces file");
+ }
+ } else if (DEBUG) {
+ Log.d(LOGTAG, "empty getprop result");
+ }
+ } finally {
+ propProc.destroy();
+ }
+ } catch (IOException e) {
+ Log.w(LOGTAG, e);
+ } catch (ClassCastException e) {
+ Log.w(LOGTAG, e); // Bug 975436
+ }
+ return null;
+ }
+
+ private static File getPingFile() {
+ if (GeckoAppShell.getContext() == null) {
+ return null;
+ }
+ GeckoProfile profile = GeckoAppShell.getGeckoInterface().getProfile();
+ if (profile == null) {
+ return null;
+ }
+ File profDir = profile.getDir();
+ if (profDir == null) {
+ return null;
+ }
+ File pingDir = new File(profDir, "saved-telemetry-pings");
+ pingDir.mkdirs();
+ if (!(pingDir.exists() && pingDir.isDirectory())) {
+ return null;
+ }
+ return new File(pingDir, UUID.randomUUID().toString());
+ }
+
+ // Return true if the traces file corresponds to a Gecko ANR
+ private static boolean isGeckoTraces(String pkgName, File tracesFile) {
+ try {
+ final String END_OF_PACKAGE_NAME = "([^a-zA-Z0-9_]|$)";
+ // Regex for finding our package name in the traces file
+ Pattern pkgPattern = Pattern.compile(Pattern.quote(pkgName) + END_OF_PACKAGE_NAME);
+ Pattern mangledPattern = null;
+ if (!AppConstants.MANGLED_ANDROID_PACKAGE_NAME.equals(pkgName)) {
+ mangledPattern = Pattern.compile(Pattern.quote(
+ AppConstants.MANGLED_ANDROID_PACKAGE_NAME) + END_OF_PACKAGE_NAME);
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "trying to match package: " + pkgName);
+ }
+ BufferedReader traces = new BufferedReader(
+ new FileReader(tracesFile), TRACES_BLOCK_SIZE);
+ try {
+ for (int count = 0; count < LINES_TO_IDENTIFY_TRACES; count++) {
+ String line = traces.readLine();
+ if (DEBUG) {
+ Log.d(LOGTAG, "identifying line: " + String.valueOf(line));
+ }
+ if (line == null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "reached end of traces file");
+ }
+ return false;
+ }
+ if (pkgPattern.matcher(line).find()) {
+ // traces.txt file contains our package
+ return true;
+ }
+ if (mangledPattern != null && mangledPattern.matcher(line).find()) {
+ // traces.txt file contains our alternate package
+ return true;
+ }
+ }
+ } finally {
+ traces.close();
+ }
+ } catch (IOException e) {
+ // meh, can't even read from it right. just return false
+ }
+ return false;
+ }
+
+ private static long getUptimeMins() {
+
+ long uptimeMins = (new File("/proc/self/stat")).lastModified();
+ if (uptimeMins != 0L) {
+ uptimeMins = (System.currentTimeMillis() - uptimeMins) / 1000L / 60L;
+ if (DEBUG) {
+ Log.d(LOGTAG, "uptime " + String.valueOf(uptimeMins));
+ }
+ return uptimeMins;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "could not get uptime");
+ }
+ return 0L;
+ }
+
+ /*
+ a saved telemetry ping file consists of JSON in the following format,
+ {
+ "reason": "android-anr-report",
+ "slug": "<uuid-string>",
+ "payload": <json-object>
+ }
+ for Android ANR, our JSON payload should look like,
+ {
+ "ver": 1,
+ "simpleMeasurements": {
+ "uptime": <uptime>
+ },
+ "info": {
+ "reason": "android-anr-report",
+ "OS": "Android",
+ ...
+ },
+ "androidANR": "...",
+ "androidLogcat": "..."
+ }
+ */
+
+ private static int writePingPayload(OutputStream ping,
+ String payload) throws IOException {
+ byte [] data = payload.getBytes(PING_CHARSET);
+ ping.write(data);
+ return data.length;
+ }
+
+ private static void fillPingHeader(OutputStream ping, String slug)
+ throws IOException {
+
+ // ping file header
+ byte [] data = ("{" +
+ "\"reason\":\"android-anr-report\"," +
+ "\"slug\":" + JSONObject.quote(slug) + "," +
+ "\"payload\":").getBytes(PING_CHARSET);
+ ping.write(data);
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote ping header, size = " + String.valueOf(data.length));
+ }
+
+ // payload start
+ int size = writePingPayload(ping, ("{" +
+ "\"ver\":1," +
+ "\"simpleMeasurements\":{" +
+ "\"uptime\":" + String.valueOf(getUptimeMins()) +
+ "}," +
+ "\"info\":{" +
+ "\"reason\":\"android-anr-report\"," +
+ "\"OS\":" + JSONObject.quote(SysInfo.getName()) + "," +
+ "\"version\":\"" + String.valueOf(SysInfo.getVersion()) + "\"," +
+ "\"appID\":" + JSONObject.quote(AppConstants.MOZ_APP_ID) + "," +
+ "\"appVersion\":" + JSONObject.quote(AppConstants.MOZ_APP_VERSION) + "," +
+ "\"appName\":" + JSONObject.quote(AppConstants.MOZ_APP_BASENAME) + "," +
+ "\"appBuildID\":" + JSONObject.quote(AppConstants.MOZ_APP_BUILDID) + "," +
+ "\"appUpdateChannel\":" + JSONObject.quote(AppConstants.MOZ_UPDATE_CHANNEL) + "," +
+ // Technically the platform build ID may be different, but we'll never know
+ "\"platformBuildID\":" + JSONObject.quote(AppConstants.MOZ_APP_BUILDID) + "," +
+ "\"locale\":" + JSONObject.quote(Locales.getLanguageTag(Locale.getDefault())) + "," +
+ "\"cpucount\":" + String.valueOf(SysInfo.getCPUCount()) + "," +
+ "\"memsize\":" + String.valueOf(SysInfo.getMemSize()) + "," +
+ "\"arch\":" + JSONObject.quote(SysInfo.getArchABI()) + "," +
+ "\"kernel_version\":" + JSONObject.quote(SysInfo.getKernelVersion()) + "," +
+ "\"device\":" + JSONObject.quote(SysInfo.getDevice()) + "," +
+ "\"manufacturer\":" + JSONObject.quote(SysInfo.getManufacturer()) + "," +
+ "\"hardware\":" + JSONObject.quote(SysInfo.getHardware()) +
+ "}," +
+ "\"androidANR\":\""));
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote metadata, size = " + String.valueOf(size));
+ }
+
+ // We are at the start of ANR data
+ }
+
+ // Block is a section of the larger input stream, and we want to find pattern within
+ // the stream. This is straightforward if the entire pattern is within one block;
+ // however, if the pattern spans across two blocks, we have to match both the start of
+ // the pattern in the first block and the end of the pattern in the second block.
+ // * If pattern is found in block, this method returns the index at the end of the
+ // found pattern, which must always be > 0.
+ // * If pattern is not found, it returns 0.
+ // * If the start of the pattern matches the end of the block, it returns a number
+ // < 0, which equals the negated value of how many characters in pattern are already
+ // matched; when processing the next block, this number is passed in through
+ // prevIndex, and the rest of the characters in pattern are matched against the
+ // start of this second block. The method returns value > 0 if the rest of the
+ // characters match, or 0 if they do not.
+ private static int getEndPatternIndex(String block, String pattern, int prevIndex) {
+ if (pattern == null || block.length() < pattern.length()) {
+ // Nothing to do
+ return 0;
+ }
+ if (prevIndex < 0) {
+ // Last block ended with a partial start; now match start of block to rest of pattern
+ if (block.startsWith(pattern.substring(-prevIndex, pattern.length()))) {
+ // Rest of pattern matches; return index at end of pattern
+ return pattern.length() + prevIndex;
+ }
+ // Not a match; continue with normal search
+ }
+ // Did not find pattern in last block; see if entire pattern is inside this block
+ int index = block.indexOf(pattern);
+ if (index >= 0) {
+ // Found pattern; return index at end of the pattern
+ return index + pattern.length();
+ }
+ // Block does not contain the entire pattern, but see if the end of the block
+ // contains the start of pattern. To do that, we see if block ends with the
+ // first n-1 characters of pattern, the first n-2 characters of pattern, etc.
+ for (index = block.length() - pattern.length() + 1; index < block.length(); index++) {
+ // Using index as a start, see if the rest of block contains the start of pattern
+ if (block.charAt(index) == pattern.charAt(0) &&
+ block.endsWith(pattern.substring(0, block.length() - index))) {
+ // Found partial match; return -(number of characters matched),
+ // i.e. -1 for 1 character matched, -2 for 2 characters matched, etc.
+ return index - block.length();
+ }
+ }
+ return 0;
+ }
+
+ // Copy the content of reader to ping;
+ // copying stops when endPattern is found in the input stream
+ private static int fillPingBlock(OutputStream ping,
+ Reader reader, String endPattern)
+ throws IOException {
+
+ int total = 0;
+ int endIndex = 0;
+ char [] block = new char[TRACES_BLOCK_SIZE];
+ for (int size = reader.read(block); size >= 0; size = reader.read(block)) {
+ String stringBlock = new String(block, 0, size);
+ endIndex = getEndPatternIndex(stringBlock, endPattern, endIndex);
+ if (endIndex > 0) {
+ // Found end pattern; clip the string
+ stringBlock = stringBlock.substring(0, endIndex);
+ }
+ String quoted = JSONObject.quote(stringBlock);
+ total += writePingPayload(ping, quoted.substring(1, quoted.length() - 1));
+ if (endIndex > 0) {
+ // End pattern already found; return now
+ break;
+ }
+ }
+ return total;
+ }
+
+ private static void fillLogcat(final OutputStream ping) {
+ if (Versions.preJB) {
+ // Logcat retrieval is not supported on pre-JB devices.
+ return;
+ }
+
+ try {
+ // get the last 200 lines of logcat
+ Process proc = (new ProcessBuilder())
+ .command("/system/bin/logcat", "-v", "threadtime", "-t", "200", "-d", "*:D")
+ .redirectErrorStream(true)
+ .start();
+ try {
+ Reader procOut = new InputStreamReader(proc.getInputStream(), TRACES_CHARSET);
+ int size = fillPingBlock(ping, procOut, null);
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote logcat, size = " + String.valueOf(size));
+ }
+ } finally {
+ proc.destroy();
+ }
+ } catch (IOException e) {
+ // ignore because logcat is not essential
+ Log.w(LOGTAG, e);
+ }
+ }
+
+ private static void fillPingFooter(OutputStream ping,
+ boolean haveNativeStack)
+ throws IOException {
+
+ // We are at the end of ANR data
+
+ int total = writePingPayload(ping, ("\"," +
+ "\"androidLogcat\":\""));
+ fillLogcat(ping);
+
+ if (haveNativeStack) {
+ total += writePingPayload(ping, ("\"," +
+ "\"androidNativeStack\":"));
+
+ String nativeStack = String.valueOf(getNativeStack());
+ int size = writePingPayload(ping, nativeStack);
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote native stack, size = " + String.valueOf(size));
+ }
+ total += size + writePingPayload(ping, "}");
+ } else {
+ total += writePingPayload(ping, "\"}");
+ }
+
+ byte [] data = (
+ "}").getBytes(PING_CHARSET);
+ ping.write(data);
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote ping footer, size = " + String.valueOf(data.length + total));
+ }
+ }
+
+ private static void processTraces(Reader traces, File pingFile) {
+
+ // Only get native stack if Gecko is running.
+ // Also, unwinding is memory intensive, so only unwind if we have enough memory.
+ final boolean haveNativeStack =
+ GeckoThread.isRunning() ?
+ requestNativeStack(/* unwind */ SysInfo.getMemSize() >= 640) : false;
+
+ try {
+ OutputStream ping = new BufferedOutputStream(
+ new FileOutputStream(pingFile), TRACES_BLOCK_SIZE);
+ try {
+ fillPingHeader(ping, pingFile.getName());
+ // Traces file has the format
+ // ----- pid xxx at xxx -----
+ // Cmd line: org.mozilla.xxx
+ // * stack trace *
+ // ----- end xxx -----
+ // ----- pid xxx at xxx -----
+ // Cmd line: com.android.xxx
+ // * stack trace *
+ // ...
+ // If we end the stack dump at the first end marker,
+ // only Fennec stacks will be dumped
+ int size = fillPingBlock(ping, traces, "\n----- end");
+ if (DEBUG) {
+ Log.d(LOGTAG, "wrote traces, size = " + String.valueOf(size));
+ }
+ fillPingFooter(ping, haveNativeStack);
+ if (DEBUG) {
+ Log.d(LOGTAG, "finished creating ping file");
+ }
+ return;
+ } finally {
+ ping.close();
+ if (haveNativeStack) {
+ releaseNativeStack();
+ }
+ }
+ } catch (IOException e) {
+ Log.w(LOGTAG, e);
+ }
+ // exception; delete ping file
+ if (pingFile.exists()) {
+ pingFile.delete();
+ }
+ }
+
+ private static void processTraces(File tracesFile, File pingFile) {
+ try {
+ Reader traces = new InputStreamReader(
+ new FileInputStream(tracesFile), TRACES_CHARSET);
+ try {
+ processTraces(traces, pingFile);
+ } finally {
+ traces.close();
+ }
+ } catch (IOException e) {
+ Log.w(LOGTAG, e);
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (mPendingANR) {
+ // we already processed an ANR without getting unstuck; skip this one
+ if (DEBUG) {
+ Log.d(LOGTAG, "skipping duplicate ANR");
+ }
+ return;
+ }
+ if (ThreadUtils.getUiHandler() != null) {
+ mPendingANR = true;
+ // detect when the main thread gets unstuck
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // okay to reset mPendingANR on main thread
+ mPendingANR = false;
+ if (DEBUG) {
+ Log.d(LOGTAG, "yay we got unstuck!");
+ }
+ }
+ });
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "receiving " + String.valueOf(intent));
+ }
+ if (!ANR_ACTION.equals(intent.getAction())) {
+ return;
+ }
+
+ // make sure we have a good save location first
+ File pingFile = getPingFile();
+ if (DEBUG) {
+ Log.d(LOGTAG, "using ping file: " + String.valueOf(pingFile));
+ }
+ if (pingFile == null) {
+ return;
+ }
+
+ File tracesFile = getTracesFile();
+ if (DEBUG) {
+ Log.d(LOGTAG, "using traces file: " + String.valueOf(tracesFile));
+ }
+ if (tracesFile == null) {
+ return;
+ }
+
+ // We get ANR intents from all ANRs in the system, but we only want Gecko ANRs
+ if (!isGeckoTraces(context.getPackageName(), tracesFile)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "traces is not Gecko ANR");
+ }
+ return;
+ }
+ Log.i(LOGTAG, "processing Gecko ANR");
+ processTraces(tracesFile, pingFile);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/AboutPages.java b/mobile/android/base/java/org/mozilla/gecko/AboutPages.java
new file mode 100644
index 000000000..705d700af
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/AboutPages.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.home.HomeConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.util.StringUtils;
+
+public class AboutPages {
+ // All of our special pages.
+ public static final String ACCOUNTS = "about:accounts";
+ public static final String ADDONS = "about:addons";
+ public static final String CONFIG = "about:config";
+ public static final String DOWNLOADS = "about:downloads";
+ public static final String FIREFOX = "about:firefox";
+ public static final String HEALTHREPORT = "about:healthreport";
+ public static final String HOME = "about:home";
+ public static final String LOGINS = "about:logins";
+ public static final String PRIVATEBROWSING = "about:privatebrowsing";
+ public static final String READER = "about:reader";
+ public static final String UPDATER = "about:";
+
+ public static final String URL_FILTER = "about:%";
+
+ public static final String PANEL_PARAM = "panel";
+
+ public static final boolean isAboutPage(final String url) {
+ return url != null && url.startsWith("about:");
+ }
+
+ public static final boolean isTitlelessAboutPage(final String url) {
+ return isAboutHome(url) ||
+ PRIVATEBROWSING.equals(url);
+ }
+
+ public static final boolean isAboutHome(final String url) {
+ if (url == null || !url.startsWith(HOME)) {
+ return false;
+ }
+ // We sometimes append a parameter to "about:home" to specify which page to
+ // show when we open the home pager. Discard this parameter when checking
+ // whether or not this URL is "about:home".
+ return HOME.equals(url.split("\\?")[0]);
+ }
+
+ public static final String getPanelIdFromAboutHomeUrl(String aboutHomeUrl) {
+ return StringUtils.getQueryParameter(aboutHomeUrl, PANEL_PARAM);
+ }
+
+ public static boolean isAboutReader(final String url) {
+ return isAboutPage(READER, url);
+ }
+
+ public static boolean isAboutConfig(final String url) {
+ return isAboutPage(CONFIG, url);
+ }
+
+ public static boolean isAboutAddons(final String url) {
+ return isAboutPage(ADDONS, url);
+ }
+
+ public static boolean isAboutPrivateBrowsing(final String url) {
+ return isAboutPage(PRIVATEBROWSING, url);
+ }
+
+ public static boolean isAboutPage(String page, String url) {
+ return url != null && url.toLowerCase().startsWith(page);
+
+ }
+
+ public static final String[] DEFAULT_ICON_PAGES = new String[] {
+ HOME,
+ ACCOUNTS,
+ ADDONS,
+ CONFIG,
+ DOWNLOADS,
+ FIREFOX,
+ HEALTHREPORT,
+ UPDATER
+ };
+
+ public static boolean isBuiltinIconPage(final String url) {
+ if (url == null ||
+ !url.startsWith("about:")) {
+ return false;
+ }
+
+ // about:home uses a separate search built-in icon.
+ if (isAboutHome(url)) {
+ return true;
+ }
+
+ // TODO: it'd be quicker to not compare the "about:" part every time.
+ for (int i = 0; i < DEFAULT_ICON_PAGES.length; ++i) {
+ if (DEFAULT_ICON_PAGES[i].equals(url)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get a URL that navigates to the specified built-in Home Panel.
+ *
+ * @param panelType to navigate to.
+ * @return URL.
+ * @throws IllegalArgumentException if the built-in panel type is not a built-in panel.
+ */
+ @RobocopTarget
+ public static String getURLForBuiltinPanelType(PanelType panelType) throws IllegalArgumentException {
+ return HOME + "?panel=" + HomeConfig.getIdForBuiltinPanelType(panelType);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java b/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java
new file mode 100644
index 000000000..5892c16b6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/AccountsHelper.java
@@ -0,0 +1,318 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AccountManagerCallback;
+import android.accounts.AccountManagerFuture;
+import android.accounts.AuthenticatorException;
+import android.accounts.OperationCanceledException;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.background.fxa.FxAccountUtils;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.FxAccountDeviceRegistrator;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.Engaged;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Helper class to manage Android Accounts corresponding to Firefox Accounts.
+ */
+public class AccountsHelper implements NativeEventListener {
+ public static final String LOGTAG = "GeckoAccounts";
+
+ protected final Context mContext;
+ protected final GeckoProfile mProfile;
+
+ public AccountsHelper(Context context, GeckoProfile profile) {
+ mContext = context;
+ mProfile = profile;
+
+ EventDispatcher dispatcher = GeckoApp.getEventDispatcher();
+ if (dispatcher == null) {
+ Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException());
+ return;
+ }
+ dispatcher.registerGeckoThreadListener(this,
+ "Accounts:CreateFirefoxAccountFromJSON",
+ "Accounts:UpdateFirefoxAccountFromJSON",
+ "Accounts:Create",
+ "Accounts:DeleteFirefoxAccount",
+ "Accounts:Exist",
+ "Accounts:ProfileUpdated",
+ "Accounts:ShowSyncPreferences");
+ }
+
+ public synchronized void uninit() {
+ EventDispatcher dispatcher = GeckoApp.getEventDispatcher();
+ if (dispatcher == null) {
+ Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException());
+ return;
+ }
+ dispatcher.unregisterGeckoThreadListener(this,
+ "Accounts:CreateFirefoxAccountFromJSON",
+ "Accounts:UpdateFirefoxAccountFromJSON",
+ "Accounts:Create",
+ "Accounts:DeleteFirefoxAccount",
+ "Accounts:Exist",
+ "Accounts:ProfileUpdated",
+ "Accounts:ShowSyncPreferences");
+ }
+
+ @Override
+ public void handleMessage(String event, NativeJSObject message, final EventCallback callback) {
+ if (!Restrictions.isAllowed(mContext, Restrictable.MODIFY_ACCOUNTS)) {
+ // We register for messages in all contexts; we drop, with a log and an error to JavaScript,
+ // when the profile is restricted. It's better to return errors than silently ignore messages.
+ Log.e(LOGTAG, "Profile is not allowed to modify accounts! Ignoring event: " + event);
+ if (callback != null) {
+ callback.sendError("Profile is not allowed to modify accounts!");
+ }
+ return;
+ }
+
+ if ("Accounts:CreateFirefoxAccountFromJSON".equals(event)) {
+ // As we are about to create a new account, let's ensure our in-memory accounts cache
+ // is empty so that there are no undesired side-effects.
+ AndroidFxAccount.invalidateCaches();
+
+ AndroidFxAccount fxAccount = null;
+ try {
+ final NativeJSObject json = message.getObject("json");
+ final String email = json.getString("email");
+ final String uid = json.getString("uid");
+ final boolean verified = json.optBoolean("verified", false);
+ final byte[] unwrapkB = Utils.hex2Byte(json.getString("unwrapBKey"));
+ final byte[] sessionToken = Utils.hex2Byte(json.getString("sessionToken"));
+ final byte[] keyFetchToken = Utils.hex2Byte(json.getString("keyFetchToken"));
+ final String authServerEndpoint =
+ json.optString("authServerEndpoint", FxAccountConstants.DEFAULT_AUTH_SERVER_ENDPOINT);
+ final String tokenServerEndpoint =
+ json.optString("tokenServerEndpoint", FxAccountConstants.DEFAULT_TOKEN_SERVER_ENDPOINT);
+ final String profileServerEndpoint =
+ json.optString("profileServerEndpoint", FxAccountConstants.DEFAULT_PROFILE_SERVER_ENDPOINT);
+ // TODO: handle choose what to Sync.
+ State state = new Engaged(email, uid, verified, unwrapkB, sessionToken, keyFetchToken);
+ fxAccount = AndroidFxAccount.addAndroidAccount(mContext,
+ email,
+ mProfile.getName(),
+ authServerEndpoint,
+ tokenServerEndpoint,
+ profileServerEndpoint,
+ state,
+ AndroidFxAccount.DEFAULT_AUTHORITIES_TO_SYNC_AUTOMATICALLY_MAP);
+
+ final String[] declinedSyncEngines = json.optStringArray("declinedSyncEngines", null);
+ if (declinedSyncEngines != null) {
+ Log.i(LOGTAG, "User has selected engines; storing to prefs.");
+ final Map<String, Boolean> selectedEngines = new HashMap<String, Boolean>();
+ for (String enabledSyncEngine : SyncConfiguration.validEngineNames()) {
+ selectedEngines.put(enabledSyncEngine, true);
+ }
+ for (String declinedSyncEngine : declinedSyncEngines) {
+ selectedEngines.put(declinedSyncEngine, false);
+ }
+ // The "forms" engine has the same state as the "history" engine.
+ selectedEngines.put("forms", selectedEngines.get("history"));
+ FxAccountUtils.pii(LOGTAG, "User selected engines: " + selectedEngines.toString());
+ try {
+ SyncConfiguration.storeSelectedEnginesToPrefs(fxAccount.getSyncPrefs(), selectedEngines);
+ } catch (UnsupportedEncodingException | GeneralSecurityException e) {
+ Log.e(LOGTAG, "Got exception storing selected engines; ignoring.", e);
+ }
+ }
+ } catch (URISyntaxException | GeneralSecurityException | UnsupportedEncodingException e) {
+ Log.w(LOGTAG, "Got exception creating Firefox Account from JSON; ignoring.", e);
+ if (callback != null) {
+ callback.sendError("Could not create Firefox Account from JSON: " + e.toString());
+ return;
+ }
+ }
+ if (callback != null) {
+ callback.sendSuccess(fxAccount != null);
+ }
+
+ } else if ("Accounts:UpdateFirefoxAccountFromJSON".equals(event)) {
+ // We might be significantly changing state of the account; let's ensure our in-memory
+ // accounts cache is empty so that there are no undesired side-effects.
+ AndroidFxAccount.invalidateCaches();
+
+ try {
+ final Account account = FirefoxAccounts.getFirefoxAccount(mContext);
+ if (account == null) {
+ if (callback != null) {
+ callback.sendError("Could not update Firefox Account since none exists");
+ }
+ return;
+ }
+
+ final NativeJSObject json = message.getObject("json");
+ final String email = json.getString("email");
+ final String uid = json.getString("uid");
+
+ // Protect against cross-connecting accounts.
+ if (account.name == null || !account.name.equals(email)) {
+ final String errorMessage = "Cannot update Firefox Account from JSON: datum has different email address!";
+ Log.e(LOGTAG, errorMessage);
+ if (callback != null) {
+ callback.sendError(errorMessage);
+ }
+ return;
+ }
+
+ final boolean verified = json.optBoolean("verified", false);
+ final byte[] unwrapkB = Utils.hex2Byte(json.getString("unwrapBKey"));
+ final byte[] sessionToken = Utils.hex2Byte(json.getString("sessionToken"));
+ final byte[] keyFetchToken = Utils.hex2Byte(json.getString("keyFetchToken"));
+ final State state = new Engaged(email, uid, verified, unwrapkB, sessionToken, keyFetchToken);
+
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(mContext, account);
+ fxAccount.setState(state);
+
+ if (callback != null) {
+ callback.sendSuccess(true);
+ }
+ } catch (NativeJSObject.InvalidPropertyException e) {
+ Log.w(LOGTAG, "Got exception updating Firefox Account from JSON; ignoring.", e);
+ if (callback != null) {
+ callback.sendError("Could not update Firefox Account from JSON: " + e.toString());
+ return;
+ }
+ }
+
+ } else if ("Accounts:Create".equals(event)) {
+ // Do exactly the same thing as if you tapped 'Sync' in Settings.
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ final NativeJSObject extras = message.optObject("extras", null);
+ if (extras != null) {
+ intent.putExtra("extras", extras.toString());
+ }
+ mContext.startActivity(intent);
+
+ } else if ("Accounts:DeleteFirefoxAccount".equals(event)) {
+ try {
+ final Account account = FirefoxAccounts.getFirefoxAccount(mContext);
+ if (account == null) {
+ Log.w(LOGTAG, "Could not delete Firefox Account since none exists!");
+ if (callback != null) {
+ callback.sendError("Could not delete Firefox Account since none exists");
+ }
+ return;
+ }
+
+ final AccountManagerCallback<Boolean> accountManagerCallback = new AccountManagerCallback<Boolean>() {
+ @Override
+ public void run(AccountManagerFuture<Boolean> future) {
+ try {
+ final boolean result = future.getResult();
+ Log.i(LOGTAG, "Account named like " + Utils.obfuscateEmail(account.name) + " removed: " + result);
+ if (callback != null) {
+ callback.sendSuccess(result);
+ }
+ } catch (OperationCanceledException | IOException | AuthenticatorException e) {
+ if (callback != null) {
+ callback.sendError("Could not delete Firefox Account: " + e.toString());
+ }
+ }
+ }
+ };
+
+ AccountManager.get(mContext).removeAccount(account, accountManagerCallback, null);
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Got exception updating Firefox Account from JSON; ignoring.", e);
+ if (callback != null) {
+ callback.sendError("Could not update Firefox Account from JSON: " + e.toString());
+ return;
+ }
+ }
+
+ } else if ("Accounts:Exist".equals(event)) {
+ if (callback == null) {
+ Log.w(LOGTAG, "Accounts:Exist requires a callback");
+ return;
+ }
+
+ final String kind = message.optString("kind", null);
+ final JSONObject response = new JSONObject();
+
+ try {
+ if ("any".equals(kind)) {
+ response.put("exists", FirefoxAccounts.firefoxAccountsExist(mContext));
+ callback.sendSuccess(response);
+ } else if ("fxa".equals(kind)) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(mContext);
+ response.put("exists", account != null);
+ if (account != null) {
+ response.put("email", account.name);
+ // We should always be able to extract the server endpoints.
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(mContext, account);
+ response.put("authServerEndpoint", fxAccount.getAccountServerURI());
+ response.put("profileServerEndpoint", fxAccount.getProfileServerURI());
+ response.put("tokenServerEndpoint", fxAccount.getTokenServerURI());
+ try {
+ // It is possible for the state fetch to fail and us to not be able to provide a UID.
+ // Long term, the UID (and verification flag) will be attached to the Android account
+ // user data and not the internal state representation.
+ final State state = fxAccount.getState();
+ response.put("uid", state.uid);
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Got exception extracting account UID; ignoring.", e);
+ }
+ }
+
+ callback.sendSuccess(response);
+ } else {
+ callback.sendError("Could not query account existence: unknown kind.");
+ }
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Got exception querying account existence; ignoring.", e);
+ callback.sendError("Could not query account existence: " + e.toString());
+ return;
+ }
+ } else if ("Accounts:ProfileUpdated".equals(event)) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(mContext);
+ if (account == null) {
+ Log.w(LOGTAG, "Can't change profile of non-existent Firefox Account!; ignored");
+ return;
+ }
+ final AndroidFxAccount androidFxAccount = new AndroidFxAccount(mContext, account);
+ androidFxAccount.fetchProfileJSON();
+ } else if ("Accounts:ShowSyncPreferences".equals(event)) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(mContext);
+ if (account == null) {
+ Log.w(LOGTAG, "Can't change show Sync preferences of non-existent Firefox Account!; ignored");
+ return;
+ }
+ // We don't necessarily have an Activity context here, so we always start in a new task.
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_STATUS);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mContext.startActivity(intent);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
new file mode 100644
index 000000000..7f2eb219e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ActionBarTextSelection.java
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.GeckoMenuItem;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.text.TextSelection;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.ActionModeCompat.Callback;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.MenuItem;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+import android.util.Log;
+
+class ActionBarTextSelection implements TextSelection, GeckoEventListener {
+ private static final String LOGTAG = "GeckoTextSelection";
+ private static final int SHUTDOWN_DELAY_MS = 250;
+
+ private final Context context;
+
+ private boolean mDraggingHandles;
+
+ private String selectionID; // Unique ID provided for each selection action.
+
+ private String mCurrentItems;
+
+ private TextSelectionActionModeCallback mCallback;
+
+ // These timers are used to avoid flicker caused by selection handles showing/hiding quickly.
+ // For instance when moving between single handle caret mode and two handle selection mode.
+ private final Timer mActionModeTimer = new Timer("actionMode");
+ private class ActionModeTimerTask extends TimerTask {
+ @Override
+ public void run() {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ endActionMode();
+ }
+ });
+ }
+ };
+ private ActionModeTimerTask mActionModeTimerTask;
+
+ ActionBarTextSelection(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void create() {
+ // Only register listeners if we have valid start/middle/end handles
+ if (context == null) {
+ Log.e(LOGTAG, "Failed to initialize text selection because at least one context is null");
+ } else {
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "TextSelection:ActionbarInit",
+ "TextSelection:ActionbarStatus",
+ "TextSelection:ActionbarUninit",
+ "TextSelection:Update");
+ }
+ }
+
+ @Override
+ public boolean dismiss() {
+ // We do not call endActionMode() here because this is already handled by the activity.
+ return false;
+ }
+
+ @Override
+ public void destroy() {
+ if (context == null) {
+ Log.e(LOGTAG, "Do not unregister TextSelection:* listeners since context is null");
+ } else {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "TextSelection:ActionbarInit",
+ "TextSelection:ActionbarStatus",
+ "TextSelection:ActionbarUninit",
+ "TextSelection:Update");
+ }
+ }
+
+ @Override
+ public void handleMessage(final String event, final JSONObject message) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (event.equals("TextSelection:Update")) {
+ if (mActionModeTimerTask != null)
+ mActionModeTimerTask.cancel();
+ showActionMode(message.getJSONArray("actions"));
+ } else if (event.equals("TextSelection:ActionbarInit")) {
+ // Init / Open the action bar. Note the current selectionID,
+ // cancel any pending actionBar close.
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW,
+ TelemetryContract.Method.CONTENT, "text_selection");
+
+ selectionID = message.getString("selectionID");
+ mCurrentItems = null;
+ if (mActionModeTimerTask != null) {
+ mActionModeTimerTask.cancel();
+ }
+
+ } else if (event.equals("TextSelection:ActionbarStatus")) {
+ // Ensure async updates from SearchService for example are valid.
+ if (selectionID != message.optString("selectionID")) {
+ return;
+ }
+
+ // Update the actionBar actions as provided by Gecko.
+ showActionMode(message.getJSONArray("actions"));
+
+ } else if (event.equals("TextSelection:ActionbarUninit")) {
+ // Uninit the actionbar. Schedule a cancellable close
+ // action to avoid UI jank. (During SelectionAll for ex).
+ mCurrentItems = null;
+ mActionModeTimerTask = new ActionModeTimerTask();
+ mActionModeTimer.schedule(mActionModeTimerTask, SHUTDOWN_DELAY_MS);
+ }
+
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON exception", e);
+ }
+ }
+ });
+ }
+
+ private void showActionMode(final JSONArray items) {
+ String itemsString = items.toString();
+ if (itemsString.equals(mCurrentItems)) {
+ return;
+ }
+ mCurrentItems = itemsString;
+
+ if (mCallback != null) {
+ mCallback.updateItems(items);
+ return;
+ }
+
+ if (context instanceof ActionModeCompat.Presenter) {
+ final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context;
+ mCallback = new TextSelectionActionModeCallback(items);
+ presenter.startActionModeCompat(mCallback);
+ mCallback.animateIn();
+ }
+ }
+
+ private void endActionMode() {
+ if (context instanceof ActionModeCompat.Presenter) {
+ final ActionModeCompat.Presenter presenter = (ActionModeCompat.Presenter) context;
+ presenter.endActionModeCompat();
+ }
+ mCurrentItems = null;
+ }
+
+ private class TextSelectionActionModeCallback implements Callback {
+ private JSONArray mItems;
+ private ActionModeCompat mActionMode;
+
+ public TextSelectionActionModeCallback(JSONArray items) {
+ mItems = items;
+ }
+
+ public void updateItems(JSONArray items) {
+ mItems = items;
+ if (mActionMode != null) {
+ mActionMode.invalidate();
+ }
+ }
+
+ public void animateIn() {
+ if (mActionMode != null) {
+ mActionMode.animateIn();
+ }
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionModeCompat mode, final GeckoMenu menu) {
+ // Android would normally expect us to only update the state of menu items here
+ // To make the js-java interaction a bit simpler, we just wipe out the menu here and recreate all
+ // the javascript menu items in onPrepare instead. This will be called any time invalidate() is called on the
+ // action mode.
+ menu.clear();
+
+ int length = mItems.length();
+ for (int i = 0; i < length; i++) {
+ try {
+ final JSONObject obj = mItems.getJSONObject(i);
+ final GeckoMenuItem menuitem = (GeckoMenuItem) menu.add(0, i, 0, obj.optString("label"));
+ final int actionEnum = obj.optBoolean("showAsAction") ? GeckoMenuItem.SHOW_AS_ACTION_ALWAYS : GeckoMenuItem.SHOW_AS_ACTION_NEVER;
+ menuitem.setShowAsAction(actionEnum, R.attr.menuItemActionModeStyle);
+
+ final String iconString = obj.optString("icon");
+ ResourceDrawableUtils.getDrawable(context, iconString, new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(Drawable d) {
+ if (d != null) {
+ menuitem.setIcon(d);
+ }
+ }
+ });
+ } catch (Exception ex) {
+ Log.i(LOGTAG, "Exception building menu", ex);
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionModeCompat mode, GeckoMenu unused) {
+ mActionMode = mode;
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionModeCompat mode, MenuItem item) {
+ try {
+ final JSONObject obj = mItems.getJSONObject(item.getItemId());
+ GeckoAppShell.notifyObservers("TextSelection:Action", obj.optString("id"));
+ return true;
+ } catch (Exception ex) {
+ Log.i(LOGTAG, "Exception calling action", ex);
+ }
+ return false;
+ }
+
+ // Called when the user exits the action mode
+ @Override
+ public void onDestroyActionMode(ActionModeCompat mode) {
+ mActionMode = null;
+ mCallback = null;
+ final JSONObject args = new JSONObject();
+ try {
+ args.put("selectionID", selectionID);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error building JSON arguments for TextSelection:End", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("TextSelection:End", args.toString());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java
new file mode 100644
index 000000000..709c0056f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompat.java
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.GeckoMenuItem;
+import org.mozilla.gecko.widget.GeckoPopupMenu;
+
+import android.view.Gravity;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+class ActionModeCompat implements GeckoPopupMenu.OnMenuItemClickListener,
+ GeckoPopupMenu.OnMenuItemLongClickListener,
+ View.OnClickListener {
+ private final String LOGTAG = "GeckoActionModeCompat";
+
+ private final Callback mCallback;
+ private final ActionModeCompatView mView;
+ private final Presenter mPresenter;
+
+ /* A set of callbacks to be called during this ActionMode's lifecycle. These will control the
+ * creation, interaction with, and destruction of menuitems for the view */
+ public static interface Callback {
+ /* Called when action mode is first created. Implementors should use this to inflate menu resources. */
+ public boolean onCreateActionMode(ActionModeCompat mode, GeckoMenu menu);
+
+ /* Called to refresh an action mode's action menu. Called whenever the mode is invalidated. Implementors
+ * should use this to enable/disable/show/hide menu items. */
+ public boolean onPrepareActionMode(ActionModeCompat mode, GeckoMenu menu);
+
+ /* Called to report a user click on an action button. */
+ public boolean onActionItemClicked(ActionModeCompat mode, MenuItem item);
+
+ /* Called when an action mode is about to be exited and destroyed. */
+ public void onDestroyActionMode(ActionModeCompat mode);
+ }
+
+ /* Presenters handle the actual showing/hiding of the action mode UI in the app. Its their responsibility
+ * to create an action mode, and assign it Callbacks and ActionModeCompatView's. */
+ public static interface Presenter {
+ /* Called when an action mode should be shown */
+ public void startActionModeCompat(final Callback callback);
+
+ /* Called when whatever action mode is showing should be hidden */
+ public void endActionModeCompat();
+ }
+
+ public ActionModeCompat(Presenter presenter, Callback callback, ActionModeCompatView view) {
+ mPresenter = presenter;
+ mCallback = callback;
+
+ mView = view;
+ mView.initForMode(this);
+ }
+
+ public void finish() {
+ // Clearing the menu will also clear the ActionItemBar
+ final GeckoMenu menu = mView.getMenu();
+ menu.clear();
+ menu.close();
+
+ if (mCallback != null) {
+ mCallback.onDestroyActionMode(this);
+ }
+ }
+
+ public CharSequence getTitle() {
+ return mView.getTitle();
+ }
+
+ public void setTitle(CharSequence title) {
+ mView.setTitle(title);
+ }
+
+ public void setTitle(int resId) {
+ mView.setTitle(resId);
+ }
+
+ public GeckoMenu getMenu() {
+ return mView.getMenu();
+ }
+
+ public void invalidate() {
+ if (mCallback != null) {
+ mCallback.onPrepareActionMode(this, mView.getMenu());
+ }
+ mView.invalidate();
+ }
+
+ public void animateIn() {
+ mView.animateIn();
+ }
+
+ /* GeckoPopupMenu.OnMenuItemClickListener */
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ if (mCallback != null) {
+ return mCallback.onActionItemClicked(this, item);
+ }
+ return false;
+ }
+
+ /* GeckoPopupMenu.onMenuItemLongClickListener */
+ @Override
+ public boolean onMenuItemLongClick(MenuItem item) {
+ showTooltip((GeckoMenuItem) item);
+ return true;
+ }
+
+ /* View.OnClickListener*/
+ @Override
+ public void onClick(View v) {
+ mPresenter.endActionModeCompat();
+ }
+
+ private void showTooltip(GeckoMenuItem item) {
+ // Computes the tooltip toast screen position (shown when long-tapping the menu item) with regards to the
+ // menu item's position (i.e below the item and slightly to the left)
+ int[] location = new int[2];
+ final View view = item.getActionView();
+ view.getLocationOnScreen(location);
+
+ int xOffset = location[0] - view.getWidth();
+ int yOffset = location[1] + view.getHeight() / 2;
+
+ Toast toast = Toast.makeText(view.getContext(), item.getTitle(), Toast.LENGTH_SHORT);
+ toast.setGravity(Gravity.TOP | Gravity.LEFT, xOffset, yOffset);
+ toast.show();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java
new file mode 100644
index 000000000..c9021b710
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ActionModeCompatView.java
@@ -0,0 +1,202 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import org.mozilla.gecko.animation.AnimationUtils;
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.widget.GeckoPopupMenu;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.ScaleAnimation;
+import android.view.animation.TranslateAnimation;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+
+class ActionModeCompatView extends LinearLayout implements GeckoMenu.ActionItemBarPresenter {
+ private final String LOGTAG = "GeckoActionModeCompatPresenter";
+
+ private static final int SPEC = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+
+ private Button mTitleView;
+ private ImageButton mMenuButton;
+ private ViewGroup mActionButtonBar;
+ private GeckoPopupMenu mPopupMenu;
+
+ // Maximum number of items to show as actions
+ private static final int MAX_ACTION_ITEMS = 4;
+
+ private int mActionButtonsWidth;
+
+ private Paint mBottomDividerPaint;
+ private int mBottomDividerOffset;
+
+ public ActionModeCompatView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs, 0);
+ }
+
+ public ActionModeCompatView(Context context, AttributeSet attrs, int style) {
+ super(context, attrs, style);
+ init(context, attrs, style);
+ }
+
+ public void init(final Context context, final AttributeSet attrs, final int defStyle) {
+ LayoutInflater.from(context).inflate(R.layout.actionbar, this);
+
+ mTitleView = (Button) findViewById(R.id.actionmode_title);
+ mMenuButton = (ImageButton) findViewById(R.id.actionbar_menu);
+ mActionButtonBar = (ViewGroup) findViewById(R.id.actionbar_buttons);
+
+ mPopupMenu = new GeckoPopupMenu(getContext(), mMenuButton);
+ mPopupMenu.getMenu().setActionItemBarPresenter(this);
+
+ mMenuButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ openMenu();
+ }
+ });
+
+ // The built-in action bar uses colorAccent for the divider so we duplicate that here.
+ final TypedArray arr = context.obtainStyledAttributes(attrs, new int[] { R.attr.colorAccent }, defStyle, 0);
+ final int bottomDividerColor = arr.getColor(0, 0);
+ arr.recycle();
+
+ mBottomDividerPaint = new Paint();
+ mBottomDividerPaint.setColor(bottomDividerColor);
+ mBottomDividerOffset = getResources().getDimensionPixelSize(R.dimen.action_bar_divider_height);
+ }
+
+ public void initForMode(final ActionModeCompat mode) {
+ mTitleView.setOnClickListener(mode);
+ mPopupMenu.setOnMenuItemClickListener(mode);
+ mPopupMenu.setOnMenuItemLongClickListener(mode);
+ }
+
+ public CharSequence getTitle() {
+ return mTitleView.getText();
+ }
+
+ public void setTitle(CharSequence title) {
+ mTitleView.setText(title);
+ }
+
+ public void setTitle(int resId) {
+ mTitleView.setText(resId);
+ }
+
+ public GeckoMenu getMenu() {
+ return mPopupMenu.getMenu();
+ }
+
+ @Override
+ public void invalidate() {
+ // onFinishInflate may not have been called yet on some versions of Android
+ if (mPopupMenu != null && mMenuButton != null) {
+ mMenuButton.setVisibility(mPopupMenu.getMenu().hasVisibleItems() ? View.VISIBLE : View.GONE);
+ }
+ super.invalidate();
+ }
+
+ /* GeckoMenu.ActionItemBarPresenter */
+ @Override
+ public boolean addActionItem(View actionItem) {
+ final int count = mActionButtonBar.getChildCount();
+ if (count >= MAX_ACTION_ITEMS) {
+ return false;
+ }
+
+ int maxWidth = mActionButtonBar.getMeasuredWidth();
+ if (maxWidth == 0) {
+ mActionButtonBar.measure(SPEC, SPEC);
+ maxWidth = mActionButtonBar.getMeasuredWidth();
+ }
+
+ // If the menu button is already visible, no need to account for it
+ if (mMenuButton.getVisibility() == View.GONE) {
+ // Since we don't know how many items will be added, we always reserve space for the overflow menu
+ mMenuButton.measure(SPEC, SPEC);
+ maxWidth -= mMenuButton.getMeasuredWidth();
+ }
+
+ if (mActionButtonsWidth <= 0) {
+ mActionButtonsWidth = 0;
+
+ // Loop over child views, measure them, and add their width to the taken width
+ for (int i = 0; i < count; i++) {
+ View v = mActionButtonBar.getChildAt(i);
+ v.measure(SPEC, SPEC);
+ mActionButtonsWidth += v.getMeasuredWidth();
+ }
+ }
+
+ actionItem.measure(SPEC, SPEC);
+ int w = actionItem.getMeasuredWidth();
+ if (mActionButtonsWidth + w < maxWidth) {
+ // We cache the new width of our children.
+ mActionButtonsWidth += w;
+ mActionButtonBar.addView(actionItem);
+ return true;
+ }
+
+ return false;
+ }
+
+ /* GeckoMenu.ActionItemBarPresenter */
+ @Override
+ public void removeActionItem(View actionItem) {
+ actionItem.measure(SPEC, SPEC);
+ mActionButtonsWidth -= actionItem.getMeasuredWidth();
+ mActionButtonBar.removeView(actionItem);
+ }
+
+ public void openMenu() {
+ mPopupMenu.openMenu();
+ }
+
+ public void closeMenu() {
+ mPopupMenu.dismiss();
+ }
+
+ public void animateIn() {
+ long duration = AnimationUtils.getShortDuration(getContext());
+ TranslateAnimation t = new TranslateAnimation(Animation.RELATIVE_TO_SELF, -0.5f, Animation.RELATIVE_TO_SELF, 0f,
+ Animation.RELATIVE_TO_SELF, 0f, Animation.RELATIVE_TO_SELF, 0f);
+ t.setDuration(duration);
+
+ ScaleAnimation s = new ScaleAnimation(1f, 1f, 0f, 1f,
+ Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
+ s.setDuration((long) (duration * 1.5f));
+
+ mTitleView.startAnimation(t);
+ mActionButtonBar.startAnimation(s);
+
+ if ((mMenuButton.getVisibility() == View.VISIBLE) &&
+ (mPopupMenu.getMenu().size() > 0)) {
+ mMenuButton.startAnimation(s);
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ // Draw the divider at the bottom of the screen. We could do this with a layer-list
+ // but then we'd have overdraw (http://stackoverflow.com/a/13509472).
+ final int bottom = getHeight();
+ final int top = bottom - mBottomDividerOffset;
+ canvas.drawRect(0, top, getWidth(), bottom, mBottomDividerPaint);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java b/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java
new file mode 100644
index 000000000..7174c6580
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ActivityHandlerHelper.java
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.ActivityResultHandlerMap;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public class ActivityHandlerHelper {
+ private static final String LOGTAG = "GeckoActivityHandlerHelper";
+ private static final ActivityResultHandlerMap mActivityResultHandlerMap = new ActivityResultHandlerMap();
+
+ private static int makeRequestCode(ActivityResultHandler aHandler) {
+ return mActivityResultHandlerMap.put(aHandler);
+ }
+
+ public static void startIntent(Intent intent, ActivityResultHandler activityResultHandler) {
+ startIntentForActivity(GeckoAppShell.getGeckoInterface().getActivity(), intent, activityResultHandler);
+ }
+
+ /**
+ * Starts the Activity, catching & logging if the Activity fails to start.
+ *
+ * We catch to prevent callers from passing in invalid Intents and crashing the browser.
+ *
+ * @return true if the Activity is successfully started, false otherwise.
+ */
+ public static boolean startIntentAndCatch(final String logtag, final Context context, final Intent intent) {
+ try {
+ context.startActivity(intent);
+ return true;
+ } catch (final ActivityNotFoundException e) {
+ Log.w(logtag, "Activity not found.", e);
+ return false;
+ } catch (final SecurityException e) {
+ Log.w(logtag, "Forbidden to launch activity.", e);
+ return false;
+ }
+ }
+
+ public static void startIntentForActivity(Activity activity, Intent intent, ActivityResultHandler activityResultHandler) {
+ activity.startActivityForResult(intent, mActivityResultHandlerMap.put(activityResultHandler));
+ }
+
+
+ public static boolean handleActivityResult(int requestCode, int resultCode, Intent data) {
+ ActivityResultHandler handler = mActivityResultHandlerMap.getAndRemove(requestCode);
+ if (handler != null) {
+ handler.onActivityResult(resultCode, data);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java b/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java
new file mode 100644
index 000000000..39ca25b67
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/BootReceiver.java
@@ -0,0 +1,27 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.mozilla.gecko.feeds.FeedService;
+
+/**
+ * This broadcast receiver receives ACTION_BOOT_COMPLETED broadcasts and starts components that should
+ * run after the device has booted.
+ */
+public class BootReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+ return; // This is not the broadcast you are looking for.
+ }
+
+ FeedService.setup(context);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
new file mode 100644
index 000000000..5eddca3cf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -0,0 +1,4261 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.app.DownloadManager;
+import android.content.ContentProviderClient;
+import android.os.Environment;
+import android.os.Process;
+import android.support.annotation.NonNull;
+
+import android.graphics.Rect;
+
+import org.json.JSONArray;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.adjust.AdjustBrowserAppDelegate;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
+import org.mozilla.gecko.Tabs.TabEvents;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.cleanup.FileCleanupController;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.SuggestedSites;
+import org.mozilla.gecko.delegates.BrowserAppDelegate;
+import org.mozilla.gecko.delegates.OfflineTabStatusDelegate;
+import org.mozilla.gecko.delegates.ScreenshotDelegate;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.distribution.DistributionStoreCallback;
+import org.mozilla.gecko.distribution.PartnerBrowserCustomizationsClient;
+import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.icons.decoders.IconDirectoryEntry;
+import org.mozilla.gecko.feeds.ContentNotificationsDelegate;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.firstrun.FirstrunAnimationContainer;
+import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
+import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason;
+import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.home.BrowserSearch;
+import org.mozilla.gecko.home.HomeBanner;
+import org.mozilla.gecko.home.HomeConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.home.HomeConfigPrefsBackend;
+import org.mozilla.gecko.home.HomeFragment;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.HomePanelsManager;
+import org.mozilla.gecko.home.HomeScreen;
+import org.mozilla.gecko.home.SearchEngine;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.javaaddons.JavaAddonManager;
+import org.mozilla.gecko.media.VideoPlayer;
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.GeckoMenuItem;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.notifications.NotificationHelper;
+import org.mozilla.gecko.overlays.ui.ShareDialog;
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.preferences.ClearOnShutdownPref;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.promotion.AddToHomeScreenPromotion;
+import org.mozilla.gecko.delegates.BookmarkStateChangeDelegate;
+import org.mozilla.gecko.promotion.ReaderViewBookmarkPromotion;
+import org.mozilla.gecko.prompts.Prompt;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.reader.ReadingListHelper;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.RestrictedProfileConfiguration;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.search.SearchEngineManager;
+import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
+import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.tabqueue.TabQueuePrompt;
+import org.mozilla.gecko.tabs.TabHistoryController;
+import org.mozilla.gecko.tabs.TabHistoryController.OnShowTabHistory;
+import org.mozilla.gecko.tabs.TabHistoryFragment;
+import org.mozilla.gecko.tabs.TabHistoryPage;
+import org.mozilla.gecko.tabs.TabsPanel;
+import org.mozilla.gecko.telemetry.TelemetryUploadService;
+import org.mozilla.gecko.telemetry.TelemetryCorePingDelegate;
+import org.mozilla.gecko.telemetry.measurements.SearchCountMeasurements;
+import org.mozilla.gecko.toolbar.AutocompleteHandler;
+import org.mozilla.gecko.toolbar.BrowserToolbar;
+import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState;
+import org.mozilla.gecko.toolbar.ToolbarProgressView;
+import org.mozilla.gecko.trackingprotection.TrackingProtectionPrompt;
+import org.mozilla.gecko.updater.PostUpdateHandler;
+import org.mozilla.gecko.updater.UpdateServiceHelper;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.ContextUtils;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.FloatUtils;
+import org.mozilla.gecko.util.GamepadUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.util.MenuUtils;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.PrefUtils;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.AnchoredPopup;
+
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.NfcEvent;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.StrictMode;
+import android.support.design.widget.Snackbar;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.view.MenuItemCompat;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Base64;
+import android.util.Base64OutputStream;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.SubMenu;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.view.animation.Interpolator;
+import android.widget.Button;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.ViewFlipper;
+import com.keepsafe.switchboard.AsyncConfigLoader;
+import com.keepsafe.switchboard.SwitchBoard;
+import android.animation.Animator;
+import android.animation.ObjectAnimator;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.net.URLEncoder;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Vector;
+import java.util.regex.Pattern;
+
+public class BrowserApp extends GeckoApp
+ implements TabsPanel.TabsLayoutChangeListener,
+ PropertyAnimator.PropertyAnimationListener,
+ View.OnKeyListener,
+ LayerView.DynamicToolbarListener,
+ BrowserSearch.OnSearchListener,
+ BrowserSearch.OnEditSuggestionListener,
+ OnUrlOpenListener,
+ OnUrlOpenInBackgroundListener,
+ AnchoredPopup.OnVisibilityChangeListener,
+ ActionModeCompat.Presenter,
+ LayoutInflater.Factory {
+ private static final String LOGTAG = "GeckoBrowserApp";
+
+ private static final int TABS_ANIMATION_DURATION = 450;
+
+ // Intent String extras used to specify custom Switchboard configurations.
+ private static final String INTENT_KEY_SWITCHBOARD_SERVER = "switchboard-server";
+
+ // TODO: Replace with kinto endpoint.
+ private static final String SWITCHBOARD_SERVER = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/experiments/records";
+
+ private static final String STATE_ABOUT_HOME_TOP_PADDING = "abouthome_top_padding";
+
+ private static final String BROWSER_SEARCH_TAG = "browser_search";
+
+ // Request ID for startActivityForResult.
+ private static final int ACTIVITY_REQUEST_PREFERENCES = 1001;
+ private static final int ACTIVITY_REQUEST_TAB_QUEUE = 2001;
+ public static final int ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK = 3001;
+ public static final int ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS = 3002;
+ public static final int ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE = 3003;
+ public static final int ACTIVITY_REQUEST_TRIPLE_READERVIEW = 4001;
+ public static final int ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK = 4002;
+ public static final int ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE = 4003;
+
+ public static final String ACTION_VIEW_MULTIPLE = AppConstants.ANDROID_PACKAGE_NAME + ".action.VIEW_MULTIPLE";
+
+ @RobocopTarget
+ public static final String EXTRA_SKIP_STARTPANE = "skipstartpane";
+ private static final String EOL_NOTIFIED = "eol_notified";
+
+ private BrowserSearch mBrowserSearch;
+ private View mBrowserSearchContainer;
+
+ public ViewGroup mBrowserChrome;
+ public ViewFlipper mActionBarFlipper;
+ public ActionModeCompatView mActionBar;
+ private VideoPlayer mVideoPlayer;
+ private BrowserToolbar mBrowserToolbar;
+ private View mDoorhangerOverlay;
+ // We can't name the TabStrip class because it's not included on API 9.
+ private TabStripInterface mTabStrip;
+ private ToolbarProgressView mProgressView;
+ private FirstrunAnimationContainer mFirstrunAnimationContainer;
+ private HomeScreen mHomeScreen;
+ private TabsPanel mTabsPanel;
+ /**
+ * Container for the home screen implementation. This will be populated with any valid
+ * home screen implementation (currently that is just the HomePager, but that will be extended
+ * to permit further experimental replacement panels such as the activity-stream panel).
+ */
+ private ViewGroup mHomeScreenContainer;
+ private int mCachedRecentTabsCount;
+ private ActionModeCompat mActionMode;
+ private TabHistoryController tabHistoryController;
+ private ZoomedView mZoomedView;
+
+ private static final int GECKO_TOOLS_MENU = -1;
+ private static final int ADDON_MENU_OFFSET = 1000;
+ public static final String TAB_HISTORY_FRAGMENT_TAG = "tabHistoryFragment";
+
+ private static class MenuItemInfo {
+ public int id;
+ public String label;
+ public boolean checkable;
+ public boolean checked;
+ public boolean enabled = true;
+ public boolean visible = true;
+ public int parent;
+ public boolean added; // So we can re-add after a locale change.
+ }
+
+ // The types of guest mode dialogs we show.
+ public static enum GuestModeDialog {
+ ENTERING,
+ LEAVING
+ }
+
+ private Vector<MenuItemInfo> mAddonMenuItemsCache;
+ private PropertyAnimator mMainLayoutAnimator;
+
+ private static final Interpolator sTabsInterpolator = new Interpolator() {
+ @Override
+ public float getInterpolation(float t) {
+ t -= 1.0f;
+ return t * t * t * t * t + 1.0f;
+ }
+ };
+
+ private FindInPageBar mFindInPageBar;
+ private MediaCastingBar mMediaCastingBar;
+
+ // We'll ask for feedback after the user launches the app this many times.
+ private static final int FEEDBACK_LAUNCH_COUNT = 15;
+
+ // Stored value of the toolbar height, so we know when it's changed.
+ private int mToolbarHeight;
+
+ private SharedPreferencesHelper mSharedPreferencesHelper;
+
+ private ReadingListHelper mReadingListHelper;
+
+ private AccountsHelper mAccountsHelper;
+
+ // The tab to be selected on editing mode exit.
+ private Integer mTargetTabForEditingMode;
+
+ private final TabEditingState mLastTabEditingState = new TabEditingState();
+
+ // The animator used to toggle HomePager visibility has a race where if the HomePager is shown
+ // (starting the animation), the HomePager is hidden, and the HomePager animation completes,
+ // both the web content and the HomePager will be hidden. This flag is used to prevent the
+ // race by determining if the web content should be hidden at the animation's end.
+ private boolean mHideWebContentOnAnimationEnd;
+
+ private final DynamicToolbar mDynamicToolbar = new DynamicToolbar();
+
+ private final TelemetryCorePingDelegate mTelemetryCorePingDelegate = new TelemetryCorePingDelegate();
+
+ private final List<BrowserAppDelegate> delegates = Collections.unmodifiableList(Arrays.asList(
+ new AddToHomeScreenPromotion(),
+ new ScreenshotDelegate(),
+ new BookmarkStateChangeDelegate(),
+ new ReaderViewBookmarkPromotion(),
+ new ContentNotificationsDelegate(),
+ new PostUpdateHandler(),
+ mTelemetryCorePingDelegate,
+ new OfflineTabStatusDelegate(),
+ new AdjustBrowserAppDelegate(mTelemetryCorePingDelegate)
+ ));
+
+ @NonNull
+ private SearchEngineManager mSearchEngineManager; // Contains reference to Context - DO NOT LEAK!
+
+ private boolean mHasResumed;
+
+ @Override
+ public View onCreateView(final String name, final Context context, final AttributeSet attrs) {
+ final View view;
+ if (BrowserToolbar.class.getName().equals(name)) {
+ view = BrowserToolbar.create(context, attrs);
+ } else if (TabsPanel.TabsLayout.class.getName().equals(name)) {
+ view = TabsPanel.createTabsLayout(context, attrs);
+ } else {
+ view = super.onCreateView(name, context, attrs);
+ }
+ return view;
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, TabEvents msg, String data) {
+ if (tab == null) {
+ // Only RESTORED is allowed a null tab: it's the only event that
+ // isn't tied to a specific tab.
+ if (msg != Tabs.TabEvents.RESTORED) {
+ throw new IllegalArgumentException("onTabChanged:" + msg + " must specify a tab.");
+ }
+
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ // After restoring the tabs we want to update the home pager immediately. Otherwise we
+ // might wait for an event coming from Gecko and this can take several seconds. (Bug 1283627)
+ updateHomePagerForTab(selectedTab);
+ }
+
+ return;
+ }
+
+ Log.d(LOGTAG, "BrowserApp.onTabChanged: " + tab.getId() + ": " + msg);
+ switch (msg) {
+ case SELECTED:
+ if (mVideoPlayer.isPlaying()) {
+ mVideoPlayer.stop();
+ }
+
+ if (Tabs.getInstance().isSelectedTab(tab) && mDynamicToolbar.isEnabled()) {
+ final VisibilityTransition transition = (tab.getShouldShowToolbarWithoutAnimationOnFirstSelection()) ?
+ VisibilityTransition.IMMEDIATE : VisibilityTransition.ANIMATE;
+ mDynamicToolbar.setVisible(true, transition);
+
+ // The first selection has happened - reset the state.
+ tab.setShouldShowToolbarWithoutAnimationOnFirstSelection(false);
+ }
+ // fall through
+ case LOCATION_CHANGE:
+ if (mZoomedView != null) {
+ mZoomedView.stopZoomDisplay(false);
+ }
+ if (Tabs.getInstance().isSelectedTab(tab)) {
+ updateHomePagerForTab(tab);
+ }
+
+ mDynamicToolbar.persistTemporaryVisibility();
+ break;
+ case START:
+ if (Tabs.getInstance().isSelectedTab(tab)) {
+ invalidateOptionsMenu();
+
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ }
+ }
+ break;
+ case LOAD_ERROR:
+ case STOP:
+ case MENU_UPDATED:
+ if (Tabs.getInstance().isSelectedTab(tab)) {
+ invalidateOptionsMenu();
+ }
+ break;
+ case PAGE_SHOW:
+ tab.loadFavicon();
+ break;
+
+ case UNSELECTED:
+ // We receive UNSELECTED immediately after the SELECTED listeners run
+ // so we are ensured that the unselectedTabEditingText has not changed.
+ if (tab.isEditing()) {
+ // Copy to avoid constructing new objects.
+ tab.getEditingState().copyFrom(mLastTabEditingState);
+ }
+ break;
+ }
+
+ if (HardwareUtils.isTablet() && msg == TabEvents.SELECTED) {
+ updateEditingModeForTab(tab);
+ }
+
+ super.onTabChanged(tab, msg, data);
+ }
+
+ private void updateEditingModeForTab(final Tab selectedTab) {
+ // (bug 1086983 comment 11) Because the tab may be selected from the gecko thread and we're
+ // running this code on the UI thread, the selected tab argument may not still refer to the
+ // selected tab. However, that means this code should be run again and the initial state
+ // changes will be overridden. As an optimization, we can skip this update, but it may have
+ // unknown side-effects so we don't.
+ if (!Tabs.getInstance().isSelectedTab(selectedTab)) {
+ Log.w(LOGTAG, "updateEditingModeForTab: Given tab is expected to be selected tab");
+ }
+
+ saveTabEditingState(mLastTabEditingState);
+
+ if (selectedTab.isEditing()) {
+ enterEditingMode();
+ restoreTabEditingState(selectedTab.getEditingState());
+ } else {
+ mBrowserToolbar.cancelEdit();
+ }
+ }
+
+ private void saveTabEditingState(final TabEditingState editingState) {
+ mBrowserToolbar.saveTabEditingState(editingState);
+ editingState.setIsBrowserSearchShown(mBrowserSearch.getUserVisibleHint());
+ }
+
+ private void restoreTabEditingState(final TabEditingState editingState) {
+ mBrowserToolbar.restoreTabEditingState(editingState);
+
+ // Since changing the editing text will show/hide browser search, this
+ // must be called after we restore the editing state in the edit text View.
+ if (editingState.isBrowserSearchShown()) {
+ showBrowserSearch();
+ } else {
+ hideBrowserSearch();
+ }
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (AndroidGamepadManager.handleKeyEvent(event)) {
+ return true;
+ }
+
+ // Global onKey handler. This is called if the focused UI doesn't
+ // handle the key event, and before Gecko swallows the events.
+ if (event.getAction() != KeyEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ if ((event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_Y:
+ // Toggle/focus the address bar on gamepad-y button.
+ if (mBrowserChrome.getVisibility() == View.VISIBLE) {
+ if (mDynamicToolbar.isEnabled() && !isHomePagerVisible()) {
+ mDynamicToolbar.setVisible(false, VisibilityTransition.ANIMATE);
+ if (mLayerView != null) {
+ mLayerView.requestFocus();
+ }
+ } else {
+ // Just focus the address bar when about:home is visible
+ // or when the dynamic toolbar isn't enabled.
+ mBrowserToolbar.requestFocusFromTouch();
+ }
+ } else {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ mBrowserToolbar.requestFocusFromTouch();
+ }
+ return true;
+ case KeyEvent.KEYCODE_BUTTON_L1:
+ // Go back on L1
+ Tabs.getInstance().getSelectedTab().doBack();
+ return true;
+ case KeyEvent.KEYCODE_BUTTON_R1:
+ // Go forward on R1
+ Tabs.getInstance().getSelectedTab().doForward();
+ return true;
+ }
+ }
+
+ // Check if this was a shortcut. Meta keys exists only on 11+.
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null && event.isCtrlPressed()) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_LEFT_BRACKET:
+ tab.doBack();
+ return true;
+
+ case KeyEvent.KEYCODE_RIGHT_BRACKET:
+ tab.doForward();
+ return true;
+
+ case KeyEvent.KEYCODE_R:
+ tab.doReload(false);
+ return true;
+
+ case KeyEvent.KEYCODE_PERIOD:
+ tab.doStop();
+ return true;
+
+ case KeyEvent.KEYCODE_T:
+ addTab();
+ return true;
+
+ case KeyEvent.KEYCODE_W:
+ Tabs.getInstance().closeTab(tab);
+ return true;
+
+ case KeyEvent.KEYCODE_F:
+ mFindInPageBar.show();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private Runnable mCheckLongPress;
+ {
+ // Only initialise the runnable if we are >= N.
+ // See onKeyDown() for more details of the back-button long-press workaround
+ if (!Versions.preN) {
+ mCheckLongPress = new Runnable() {
+ public void run() {
+ handleBackLongPress();
+ }
+ };
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ // Bug 1304688: Android N has broken passing onKeyLongPress events for the back button, so we
+ // instead copy the long-press-handler technique from Android's KeyButtonView.
+ // - For short presses, we cancel the callback in onKeyUp
+ // - For long presses, the normal keypress is marked as cancelled, hence won't be handled elsewhere
+ // (but Android still provides the haptic feedback), and the runnable is run.
+ if (!Versions.preN &&
+ keyCode == KeyEvent.KEYCODE_BACK) {
+ ThreadUtils.getUiHandler().removeCallbacks(mCheckLongPress);
+ ThreadUtils.getUiHandler().postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout());
+ }
+
+ if (!mBrowserToolbar.isEditing() && onKey(null, keyCode, event)) {
+ return true;
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (!Versions.preN &&
+ keyCode == KeyEvent.KEYCODE_BACK) {
+ ThreadUtils.getUiHandler().removeCallbacks(mCheckLongPress);
+ }
+
+ if (AndroidGamepadManager.handleKeyEvent(event)) {
+ return true;
+ }
+ return super.onKeyUp(keyCode, event);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ if (!HardwareUtils.isSupportedSystem()) {
+ // This build does not support the Android version of the device; Exit early.
+ super.onCreate(savedInstanceState);
+ return;
+ }
+
+ final SafeIntent intent = new SafeIntent(getIntent());
+ final boolean isInAutomation = IntentUtils.getIsInAutomationFromEnvironment(intent);
+
+ // This has to be prepared prior to calling GeckoApp.onCreate, because
+ // widget code and BrowserToolbar need it, and they're created by the
+ // layout, which GeckoApp takes care of.
+ ((GeckoApplication) getApplication()).prepareLightweightTheme();
+
+ super.onCreate(savedInstanceState);
+
+ final Context appContext = getApplicationContext();
+
+ initSwitchboard(this, intent, isInAutomation);
+ initTelemetryUploader(isInAutomation);
+
+ mBrowserChrome = (ViewGroup) findViewById(R.id.browser_chrome);
+ mActionBarFlipper = (ViewFlipper) findViewById(R.id.browser_actionbar);
+ mActionBar = (ActionModeCompatView) findViewById(R.id.actionbar);
+
+ mVideoPlayer = (VideoPlayer) findViewById(R.id.video_player);
+ mVideoPlayer.setFullScreenListener(new VideoPlayer.FullScreenListener() {
+ @Override
+ public void onFullScreenChanged(boolean fullScreen) {
+ mVideoPlayer.setFullScreen(fullScreen);
+ setFullScreen(fullScreen);
+ }
+ });
+
+ mBrowserToolbar = (BrowserToolbar) findViewById(R.id.browser_toolbar);
+ mBrowserToolbar.setTouchEventInterceptor(new TouchEventInterceptor() {
+ @Override
+ public boolean onInterceptTouchEvent(View view, MotionEvent event) {
+ // Manually dismiss text selection bar if it's not overlaying the toolbar.
+ mTextSelection.dismiss();
+ return false;
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ return false;
+ }
+ });
+
+ mProgressView = (ToolbarProgressView) findViewById(R.id.progress);
+ mBrowserToolbar.setProgressBar(mProgressView);
+
+ // Initialize Tab History Controller.
+ tabHistoryController = new TabHistoryController(new OnShowTabHistory() {
+ @Override
+ public void onShowHistory(final List<TabHistoryPage> historyPageList, final int toIndex) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (BrowserApp.this.isFinishing()) {
+ // TabHistoryController is rather slow - and involves calling into Gecko
+ // to retrieve tab history. That means there can be a significant
+ // delay between the back-button long-press, and onShowHistory()
+ // being called. Hence we need to guard against the Activity being
+ // shut down (in which case trying to perform UI changes, such as showing
+ // fragments below, will crash).
+ return;
+ }
+
+ final TabHistoryFragment fragment = TabHistoryFragment.newInstance(historyPageList, toIndex);
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec));
+ fragment.show(R.id.tab_history_panel, fragmentManager.beginTransaction(), TAB_HISTORY_FRAGMENT_TAG);
+ }
+ });
+ }
+ });
+ mBrowserToolbar.setTabHistoryController(tabHistoryController);
+
+ final String action = intent.getAction();
+ if (Intent.ACTION_VIEW.equals(action)) {
+ // Show the target URL immediately in the toolbar.
+ mBrowserToolbar.setTitle(intent.getDataString());
+
+ showTabQueuePromptIfApplicable(intent);
+ } else if (ACTION_VIEW_MULTIPLE.equals(action) && savedInstanceState == null) {
+ // We only want to handle this intent if savedInstanceState is null. In the case where
+ // savedInstanceState is not null this activity is being re-created and we already
+ // opened tabs for the URLs the last time. Our session store will take care of restoring
+ // them.
+ openMultipleTabsFromIntent(intent);
+ } else if (GuestSession.NOTIFICATION_INTENT.equals(action)) {
+ GuestSession.onNotificationIntentReceived(this);
+ } else if (TabQueueHelper.LOAD_URLS_ACTION.equals(action)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, "tabqueue");
+ }
+
+ if (HardwareUtils.isTablet()) {
+ mTabStrip = (TabStripInterface) (((ViewStub) findViewById(R.id.tablet_tab_strip)).inflate());
+ }
+
+ ((GeckoApp.MainLayout) mMainLayout).setTouchEventInterceptor(new HideOnTouchListener());
+ ((GeckoApp.MainLayout) mMainLayout).setMotionEventInterceptor(new MotionEventInterceptor() {
+ @Override
+ public boolean onInterceptMotionEvent(View view, MotionEvent event) {
+ // If we get a gamepad panning MotionEvent while the focus is not on the layerview,
+ // put the focus on the layerview and carry on
+ if (mLayerView != null && !mLayerView.hasFocus() && GamepadUtils.isPanningControl(event)) {
+ if (mHomeScreen == null) {
+ return false;
+ }
+
+ if (isHomePagerVisible()) {
+ mLayerView.requestFocus();
+ } else {
+ mHomeScreen.requestFocus();
+ }
+ }
+ return false;
+ }
+ });
+
+ mHomeScreenContainer = (ViewGroup) findViewById(R.id.home_screen_container);
+
+ mBrowserSearchContainer = findViewById(R.id.search_container);
+ mBrowserSearch = (BrowserSearch) getSupportFragmentManager().findFragmentByTag(BROWSER_SEARCH_TAG);
+ if (mBrowserSearch == null) {
+ mBrowserSearch = BrowserSearch.newInstance();
+ mBrowserSearch.setUserVisibleHint(false);
+ }
+
+ setBrowserToolbarListeners();
+
+ mFindInPageBar = (FindInPageBar) findViewById(R.id.find_in_page);
+ mMediaCastingBar = (MediaCastingBar) findViewById(R.id.media_casting);
+
+ mDoorhangerOverlay = findViewById(R.id.doorhanger_overlay);
+
+ EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener)this,
+ "Gecko:DelayedStartup",
+ "Menu:Open",
+ "Menu:Update",
+ "LightweightTheme:Update",
+ "Search:Keyword",
+ "Prompt:ShowTop",
+ "Tab:Added",
+ "Video:Play");
+
+ EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this,
+ "CharEncoding:Data",
+ "CharEncoding:State",
+ "Download:AndroidDownloadManager",
+ "Experiments:GetActive",
+ "Experiments:SetOverride",
+ "Experiments:ClearOverride",
+ "Favicon:CacheLoad",
+ "Feedback:MaybeLater",
+ "Menu:Add",
+ "Menu:Remove",
+ "Sanitize:ClearHistory",
+ "Sanitize:ClearSyncedTabs",
+ "Settings:Show",
+ "Telemetry:Gather",
+ "Updater:Launch",
+ "Website:Metadata");
+
+ final GeckoProfile profile = getProfile();
+
+ // We want to upload the telemetry core ping as soon after startup as possible. It relies on the
+ // Distribution being initialized. If you move this initialization, ensure it plays well with telemetry.
+ final Distribution distribution = Distribution.init(getApplicationContext());
+ distribution.addOnDistributionReadyCallback(
+ new DistributionStoreCallback(getApplicationContext(), profile.getName()));
+
+ mSearchEngineManager = new SearchEngineManager(this, distribution);
+
+ // Init suggested sites engine in BrowserDB.
+ final SuggestedSites suggestedSites = new SuggestedSites(appContext, distribution);
+ final BrowserDB db = BrowserDB.from(profile);
+ db.setSuggestedSites(suggestedSites);
+
+ JavaAddonManager.getInstance().init(appContext);
+ mSharedPreferencesHelper = new SharedPreferencesHelper(appContext);
+ mReadingListHelper = new ReadingListHelper(appContext, profile);
+ mAccountsHelper = new AccountsHelper(appContext, profile);
+
+ if (AppConstants.MOZ_ANDROID_BEAM) {
+ NfcAdapter nfc = NfcAdapter.getDefaultAdapter(this);
+ if (nfc != null) {
+ nfc.setNdefPushMessageCallback(new NfcAdapter.CreateNdefMessageCallback() {
+ @Override
+ public NdefMessage createNdefMessage(NfcEvent event) {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab == null || tab.isPrivate()) {
+ return null;
+ }
+ return new NdefMessage(new NdefRecord[] { NdefRecord.createUri(tab.getURL()) });
+ }
+ }, this);
+ }
+ }
+
+ if (savedInstanceState != null) {
+ mDynamicToolbar.onRestoreInstanceState(savedInstanceState);
+ mHomeScreenContainer.setPadding(0, savedInstanceState.getInt(STATE_ABOUT_HOME_TOP_PADDING), 0, 0);
+ }
+
+ mDynamicToolbar.setEnabledChangedListener(new DynamicToolbar.OnEnabledChangedListener() {
+ @Override
+ public void onEnabledChanged(boolean enabled) {
+ setDynamicToolbarEnabled(enabled);
+ }
+ });
+
+ // Set the maximum bits-per-pixel the favicon system cares about.
+ IconDirectoryEntry.setMaxBPP(GeckoAppShell.getScreenDepth());
+
+ // The update service is enabled for RELEASE_OR_BETA, which includes the release and beta channels.
+ // However, no updates are served. Therefore, we don't trust the update service directly, and
+ // try to avoid prompting unnecessarily. See Bug 1232798.
+ if (!AppConstants.RELEASE_OR_BETA && UpdateServiceHelper.isUpdaterEnabled(this)) {
+ Permissions.from(this)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPrompt()
+ .andFallback(new Runnable() {
+ @Override
+ public void run() {
+ showUpdaterPermissionSnackbar();
+ }
+ })
+ .run();
+ }
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onCreate(this, savedInstanceState);
+ }
+
+ // We want to get an understanding of how our user base is spread (bug 1221646).
+ final String installerPackageName = getPackageManager().getInstallerPackageName(getPackageName());
+ Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, TelemetryContract.Method.SYSTEM, "installer_" + installerPackageName);
+ }
+
+ /**
+ * Initializes the default Switchboard URLs the first time.
+ * @param intent
+ */
+ private static void initSwitchboard(final Context context, final SafeIntent intent, final boolean isInAutomation) {
+ if (isInAutomation) {
+ Log.d(LOGTAG, "Switchboard disabled - in automation");
+ return;
+ } else if (!AppConstants.MOZ_SWITCHBOARD) {
+ Log.d(LOGTAG, "Switchboard compile-time disabled");
+ return;
+ }
+
+ final String serverExtra = intent.getStringExtra(INTENT_KEY_SWITCHBOARD_SERVER);
+ final String serverUrl = TextUtils.isEmpty(serverExtra) ? SWITCHBOARD_SERVER : serverExtra;
+ new AsyncConfigLoader(context, serverUrl).execute();
+ }
+
+ private static void initTelemetryUploader(final boolean isInAutomation) {
+ TelemetryUploadService.setDisabled(isInAutomation);
+ }
+
+ private void showUpdaterPermissionSnackbar() {
+ SnackbarBuilder.SnackbarCallback allowCallback = new SnackbarBuilder.SnackbarCallback() {
+ @Override
+ public void onClick(View v) {
+ Permissions.from(BrowserApp.this)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .run();
+ }
+ };
+
+ SnackbarBuilder.builder(this)
+ .message(R.string.updater_permission_text)
+ .duration(Snackbar.LENGTH_INDEFINITE)
+ .action(R.string.updater_permission_allow)
+ .callback(allowCallback)
+ .buildAndShow();
+ }
+
+ private void conditionallyNotifyEOL() {
+ final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+ try {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this);
+ if (!prefs.contains(EOL_NOTIFIED)) {
+
+ // Launch main App to load SUMO url on EOL notification.
+ final String link = getString(R.string.eol_notification_url,
+ AppConstants.MOZ_APP_VERSION,
+ AppConstants.OS_TARGET,
+ Locales.getLanguageTag(Locale.getDefault()));
+
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ intent.setData(Uri.parse(link));
+ final PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ final Notification notification = new NotificationCompat.Builder(this)
+ .setContentTitle(getString(R.string.eol_notification_title))
+ .setContentText(getString(R.string.eol_notification_summary))
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .build();
+
+ final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ final int notificationID = EOL_NOTIFIED.hashCode();
+ notificationManager.notify(notificationID, notification);
+
+ GeckoSharedPrefs.forProfile(this)
+ .edit()
+ .putBoolean(EOL_NOTIFIED, true)
+ .apply();
+ }
+ } finally {
+ StrictMode.setThreadPolicy(savedPolicy);
+ }
+ }
+
+ /**
+ * Check and show the firstrun pane if the browser has never been launched and
+ * is not opening an external link from another application.
+ *
+ * @param context Context of application; used to show firstrun pane if appropriate
+ * @param intent Intent that launched this activity
+ */
+ private void checkFirstrun(Context context, SafeIntent intent) {
+ if (getProfile().inGuestMode()) {
+ // We do not want to show any first run tour for guest profiles.
+ return;
+ }
+
+ if (intent.getBooleanExtra(EXTRA_SKIP_STARTPANE, false)) {
+ // Note that we don't set the pref, so subsequent launches can result
+ // in the firstrun pane being shown.
+ return;
+ }
+ final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+
+ try {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this);
+
+ if (prefs.getBoolean(FirstrunAnimationContainer.PREF_FIRSTRUN_ENABLED, true)) {
+ if (!Intent.ACTION_VIEW.equals(intent.getAction())) {
+ showFirstrunPager();
+
+ if (HardwareUtils.isTablet()) {
+ mTabStrip.setOnTabChangedListener(new TabStripInterface.OnTabAddedOrRemovedListener() {
+ @Override
+ public void onTabChanged() {
+ hideFirstrunPager(TelemetryContract.Method.BUTTON);
+ mTabStrip.setOnTabChangedListener(null);
+ }
+ });
+ }
+ }
+
+ // Don't bother trying again to show the v1 minimal first run.
+ prefs.edit().putBoolean(FirstrunAnimationContainer.PREF_FIRSTRUN_ENABLED, false).apply();
+
+ // We have no intention of stopping this session. The FIRSTRUN session
+ // ends when the browsing session/activity has ended. All events
+ // during firstrun will be tagged as FIRSTRUN.
+ Telemetry.startUISession(TelemetryContract.Session.FIRSTRUN);
+ }
+ } finally {
+ StrictMode.setThreadPolicy(savedPolicy);
+ }
+ }
+
+ private Class<?> getMediaPlayerManager() {
+ if (AppConstants.MOZ_MEDIA_PLAYER) {
+ try {
+ return Class.forName("org.mozilla.gecko.MediaPlayerManager");
+ } catch (Exception ex) {
+ // Ignore failures
+ Log.e(LOGTAG, "No native casting support", ex);
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (mTextSelection.dismiss()) {
+ return;
+ }
+
+ if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
+ super.onBackPressed();
+ return;
+ }
+
+ if (mBrowserToolbar.onBackPressed()) {
+ return;
+ }
+
+ if (mActionMode != null) {
+ endActionModeCompat();
+ return;
+ }
+
+ if (hideFirstrunPager(TelemetryContract.Method.BACK)) {
+ return;
+ }
+
+ if (mVideoPlayer.isFullScreen()) {
+ mVideoPlayer.setFullScreen(false);
+ setFullScreen(false);
+ return;
+ }
+
+ if (mVideoPlayer.isPlaying()) {
+ mVideoPlayer.stop();
+ return;
+ }
+
+ super.onBackPressed();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ // We can't show the first run experience until Gecko has finished initialization (bug 1077583).
+ checkFirstrun(this, new SafeIntent(getIntent()));
+ }
+
+ @Override
+ protected void processTabQueue() {
+ if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ if (TabQueueHelper.shouldOpenTabQueueUrls(BrowserApp.this)) {
+ openQueuedTabs();
+ }
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void openQueuedTabs() {
+ ThreadUtils.assertNotOnUiThread();
+
+ int queuedTabCount = TabQueueHelper.getTabQueueLength(BrowserApp.this);
+
+ Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-delayed");
+
+ TabQueueHelper.openQueuedUrls(BrowserApp.this, getProfile(), TabQueueHelper.FILE_NAME, false);
+
+ // If there's more than one tab then also show the tabs panel.
+ if (queuedTabCount > 1) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ showNormalTabs();
+ }
+ });
+ }
+ }
+
+ private void openMultipleTabsFromIntent(final SafeIntent intent) {
+ final List<String> urls = intent.getStringArrayListExtra("urls");
+ if (urls != null) {
+ openUrls(urls);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ if (!mHasResumed) {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) this,
+ "Prompt:ShowTop");
+ mHasResumed = true;
+ }
+
+ processTabQueue();
+
+ for (BrowserAppDelegate delegate : delegates) {
+ delegate.onResume(this);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ if (mHasResumed) {
+ // Register for Prompt:ShowTop so we can foreground this activity even if it's hidden.
+ EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this,
+ "Prompt:ShowTop");
+ mHasResumed = false;
+ }
+
+ for (BrowserAppDelegate delegate : delegates) {
+ delegate.onPause(this);
+ }
+ }
+
+ @Override
+ public void onRestart() {
+ super.onRestart();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onRestart(this);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ // Queue this work so that the first launch of the activity doesn't
+ // trigger profile init too early.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final GeckoProfile profile = getProfile();
+ if (profile.inGuestMode()) {
+ GuestSession.showNotification(BrowserApp.this);
+ } else {
+ // If we're restarting, we won't destroy the activity.
+ // Make sure we remove any guest notifications that might
+ // have been shown.
+ GuestSession.hideNotification(BrowserApp.this);
+ }
+
+ // It'd be better to launch this once, in onCreate, but there's ambiguity for when the
+ // profile is created so we run here instead. Don't worry, call start short-circuits pretty fast.
+ final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfileName(BrowserApp.this, profile.getName());
+ FileCleanupController.startIfReady(BrowserApp.this, sharedPrefs, profile.getDir().getAbsolutePath());
+ }
+ });
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onStart(this);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ // We only show the guest mode notification when our activity is in the foreground.
+ GuestSession.hideNotification(this);
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onStop(this);
+ }
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+
+ // Sending a message to the toolbar when the browser window gains focus
+ // This is needed for qr code input
+ if (hasFocus) {
+ mBrowserToolbar.onParentFocus();
+ }
+ }
+
+ private void setBrowserToolbarListeners() {
+ mBrowserToolbar.setOnActivateListener(new BrowserToolbar.OnActivateListener() {
+ @Override
+ public void onActivate() {
+ enterEditingMode();
+ }
+ });
+
+ mBrowserToolbar.setOnCommitListener(new BrowserToolbar.OnCommitListener() {
+ @Override
+ public void onCommit() {
+ commitEditingMode();
+ }
+ });
+
+ mBrowserToolbar.setOnDismissListener(new BrowserToolbar.OnDismissListener() {
+ @Override
+ public void onDismiss() {
+ mBrowserToolbar.cancelEdit();
+ }
+ });
+
+ mBrowserToolbar.setOnFilterListener(new BrowserToolbar.OnFilterListener() {
+ @Override
+ public void onFilter(String searchText, AutocompleteHandler handler) {
+ filterEditingMode(searchText, handler);
+ }
+ });
+
+ mBrowserToolbar.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (isHomePagerVisible()) {
+ mHomeScreen.onToolbarFocusChange(hasFocus);
+ }
+ }
+ });
+
+ mBrowserToolbar.setOnStartEditingListener(new BrowserToolbar.OnStartEditingListener() {
+ @Override
+ public void onStartEditing() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ selectedTab.setIsEditing(true);
+ }
+
+ // Temporarily disable doorhanger notifications.
+ if (mDoorHangerPopup != null) {
+ mDoorHangerPopup.disable();
+ }
+ }
+ });
+
+ mBrowserToolbar.setOnStopEditingListener(new BrowserToolbar.OnStopEditingListener() {
+ @Override
+ public void onStopEditing() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ selectedTab.setIsEditing(false);
+ }
+
+ selectTargetTabForEditingMode();
+
+ // Since the underlying LayerView is set visible in hideHomePager, we would
+ // ordinarily want to call it first. However, hideBrowserSearch changes the
+ // visibility of the HomePager and hideHomePager will take no action if the
+ // HomePager is hidden, so we want to call hideBrowserSearch to restore the
+ // HomePager visibility first.
+ hideBrowserSearch();
+ hideHomePager();
+
+ // Re-enable doorhanger notifications. They may trigger on the selected tab above.
+ if (mDoorHangerPopup != null) {
+ mDoorHangerPopup.enable();
+ }
+ }
+ });
+
+ // Intercept key events for gamepad shortcuts
+ mBrowserToolbar.setOnKeyListener(this);
+ }
+
+ private void setDynamicToolbarEnabled(boolean enabled) {
+ ThreadUtils.assertOnUiThread();
+
+ if (enabled) {
+ if (mLayerView != null) {
+ mLayerView.getDynamicToolbarAnimator().addTranslationListener(this);
+ }
+ setToolbarMargin(0);
+ mHomeScreenContainer.setPadding(0, mBrowserChrome.getHeight(), 0, 0);
+ } else {
+ // Immediately show the toolbar when disabling the dynamic
+ // toolbar.
+ if (mLayerView != null) {
+ mLayerView.getDynamicToolbarAnimator().removeTranslationListener(this);
+ }
+ mHomeScreenContainer.setPadding(0, 0, 0, 0);
+ if (mBrowserChrome != null) {
+ ViewHelper.setTranslationY(mBrowserChrome, 0);
+ }
+ if (mLayerView != null) {
+ mLayerView.setSurfaceTranslation(0);
+ }
+ }
+
+ refreshToolbarHeight();
+ }
+
+ private static boolean isAboutHome(final Tab tab) {
+ return AboutPages.isAboutHome(tab.getURL());
+ }
+
+ @Override
+ public boolean onSearchRequested() {
+ enterEditingMode();
+ return true;
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == R.id.pasteandgo) {
+ hideFirstrunPager(TelemetryContract.Method.CONTEXT_MENU);
+
+ String text = Clipboard.getText();
+ if (!TextUtils.isEmpty(text)) {
+ loadUrlOrKeywordSearch(text);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "pasteandgo");
+ }
+ return true;
+ }
+
+ if (itemId == R.id.paste) {
+ String text = Clipboard.getText();
+ if (!TextUtils.isEmpty(text)) {
+ enterEditingMode(text);
+ showBrowserSearch();
+ mBrowserSearch.filter(text, null);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "paste");
+ }
+ return true;
+ }
+
+ if (itemId == R.id.subscribe) {
+ // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone.
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null && tab.hasFeeds()) {
+ JSONObject args = new JSONObject();
+ try {
+ args.put("tabId", tab.getId());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "error building json arguments", e);
+ }
+ GeckoAppShell.notifyObservers("Feeds:Subscribe", args.toString());
+ }
+ return true;
+ }
+
+ if (itemId == R.id.add_search_engine) {
+ // This can be selected from either the browser menu or the contextmenu, depending on the size and version (v11+) of the phone.
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null && tab.hasOpenSearch()) {
+ JSONObject args = new JSONObject();
+ try {
+ args.put("tabId", tab.getId());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "error building json arguments", e);
+ return true;
+ }
+ GeckoAppShell.notifyObservers("SearchEngines:Add", args.toString());
+ }
+ return true;
+ }
+
+ if (itemId == R.id.copyurl) {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ String url = ReaderModeUtils.stripAboutReaderUrl(tab.getURL());
+ if (url != null) {
+ Clipboard.setText(url);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "copyurl");
+ }
+ }
+ return true;
+ }
+
+ if (itemId == R.id.add_to_launcher) {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab == null) {
+ return true;
+ }
+
+ final String url = tab.getURL();
+ final String title = tab.getDisplayTitle();
+ if (url == null || title == null) {
+ return true;
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.createShortcut(title, url);
+
+ }
+ });
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU,
+ getResources().getResourceEntryName(itemId));
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setAccessibilityEnabled(boolean enabled) {
+ super.setAccessibilityEnabled(enabled);
+ mDynamicToolbar.setAccessibilityEnabled(enabled);
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mIsAbortingAppLaunch) {
+ super.onDestroy();
+ return;
+ }
+
+ mDynamicToolbar.destroy();
+
+ if (mBrowserToolbar != null)
+ mBrowserToolbar.onDestroy();
+
+ if (mFindInPageBar != null) {
+ mFindInPageBar.onDestroy();
+ mFindInPageBar = null;
+ }
+
+ if (mMediaCastingBar != null) {
+ mMediaCastingBar.onDestroy();
+ mMediaCastingBar = null;
+ }
+
+ if (mSharedPreferencesHelper != null) {
+ mSharedPreferencesHelper.uninit();
+ mSharedPreferencesHelper = null;
+ }
+
+ if (mReadingListHelper != null) {
+ mReadingListHelper.uninit();
+ mReadingListHelper = null;
+ }
+
+ if (mAccountsHelper != null) {
+ mAccountsHelper.uninit();
+ mAccountsHelper = null;
+ }
+
+ if (mZoomedView != null) {
+ mZoomedView.destroy();
+ }
+
+ mSearchEngineManager.unregisterListeners();
+
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) this,
+ "Gecko:DelayedStartup",
+ "Menu:Open",
+ "Menu:Update",
+ "LightweightTheme:Update",
+ "Search:Keyword",
+ "Prompt:ShowTop",
+ "Tab:Added",
+ "Video:Play");
+
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) this,
+ "CharEncoding:Data",
+ "CharEncoding:State",
+ "Download:AndroidDownloadManager",
+ "Experiments:GetActive",
+ "Experiments:SetOverride",
+ "Experiments:ClearOverride",
+ "Favicon:CacheLoad",
+ "Feedback:MaybeLater",
+ "Menu:Add",
+ "Menu:Remove",
+ "Sanitize:ClearHistory",
+ "Sanitize:ClearSyncedTabs",
+ "Settings:Show",
+ "Telemetry:Gather",
+ "Updater:Launch",
+ "Website:Metadata");
+
+ if (AppConstants.MOZ_ANDROID_BEAM) {
+ NfcAdapter nfc = NfcAdapter.getDefaultAdapter(this);
+ if (nfc != null) {
+ // null this out even though the docs say it's not needed,
+ // because the source code looks like it will only do this
+ // automatically on API 14+
+ nfc.setNdefPushMessageCallback(null, this);
+ }
+ }
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onDestroy(this);
+ }
+
+ deleteTempFiles();
+
+ if (mDoorHangerPopup != null)
+ mDoorHangerPopup.destroy();
+ if (mFormAssistPopup != null)
+ mFormAssistPopup.destroy();
+ if (mTextSelection != null)
+ mTextSelection.destroy();
+ NotificationHelper.destroy();
+ IntentHelper.destroy();
+ GeckoNetworkManager.destroy();
+
+ super.onDestroy();
+
+ if (!isFinishing()) {
+ // GeckoApp was not intentionally destroyed, so keep our process alive.
+ return;
+ }
+
+ // Wait for Gecko to handle our pause event sent in onPause.
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoThread.waitOnGecko();
+ }
+
+ if (mRestartIntent != null) {
+ // Restarting, so let Restarter kill us.
+ final Intent intent = new Intent();
+ intent.setClass(getApplicationContext(), Restarter.class)
+ .putExtra("pid", Process.myPid())
+ .putExtra(Intent.EXTRA_INTENT, mRestartIntent);
+ startService(intent);
+ } else {
+ // Exiting, so kill our own process.
+ Process.killProcess(Process.myPid());
+ }
+ }
+
+ @Override
+ protected void initializeChrome() {
+ super.initializeChrome();
+
+ mDoorHangerPopup.setAnchor(mBrowserToolbar.getDoorHangerAnchor());
+ mDoorHangerPopup.setOnVisibilityChangeListener(this);
+
+ mDynamicToolbar.setLayerView(mLayerView);
+ setDynamicToolbarEnabled(mDynamicToolbar.isEnabled());
+
+ // Intercept key events for gamepad shortcuts
+ mLayerView.setOnKeyListener(this);
+
+ // Initialize the actionbar menu items on startup for both large and small tablets
+ if (HardwareUtils.isTablet()) {
+ onCreatePanelMenu(Window.FEATURE_OPTIONS_PANEL, null);
+ invalidateOptionsMenu();
+ }
+ }
+
+ @Override
+ public void onDoorHangerShow() {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(mDoorhangerOverlay, "alpha", 1);
+ alphaAnimator.setDuration(250);
+
+ alphaAnimator.start();
+ }
+
+ @Override
+ public void onDoorHangerHide() {
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(mDoorhangerOverlay, "alpha", 0);
+ alphaAnimator.setDuration(200);
+
+ alphaAnimator.start();
+ }
+
+ private void handleClearHistory(final boolean clearSearchHistory) {
+ final BrowserDB db = BrowserDB.from(getProfile());
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.clearHistory(getContentResolver(), clearSearchHistory);
+ }
+ });
+ }
+
+ private void handleClearSyncedTabs() {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ FennecTabsRepository.deleteNonLocalClientsAndTabs(getContext());
+ }
+ });
+ }
+
+ private void setToolbarMargin(int margin) {
+ ((RelativeLayout.LayoutParams) mGeckoLayout.getLayoutParams()).topMargin = margin;
+ mGeckoLayout.requestLayout();
+ }
+
+ @Override
+ public void onTranslationChanged(float aToolbarTranslation, float aLayerViewTranslation) {
+ if (mBrowserChrome == null) {
+ return;
+ }
+
+ final View browserChrome = mBrowserChrome;
+ final ToolbarProgressView progressView = mProgressView;
+
+ ViewHelper.setTranslationY(browserChrome, -aToolbarTranslation);
+ mLayerView.setSurfaceTranslation(mToolbarHeight - aLayerViewTranslation);
+
+ // Stop the progressView from moving all the way up so that we can still see a good chunk of it
+ // when the chrome is offscreen.
+ final float offset = getResources().getDimensionPixelOffset(R.dimen.progress_bar_scroll_offset);
+ final float progressTranslationY = Math.min(aToolbarTranslation, mToolbarHeight - offset);
+ ViewHelper.setTranslationY(progressView, -progressTranslationY);
+
+ if (mFormAssistPopup != null) {
+ mFormAssistPopup.onTranslationChanged();
+ }
+ }
+
+ @Override
+ public void onMetricsChanged(ImmutableViewportMetrics aMetrics) {
+ if (isHomePagerVisible() || mBrowserChrome == null) {
+ return;
+ }
+
+ if (mFormAssistPopup != null) {
+ mFormAssistPopup.onMetricsChanged(aMetrics);
+ }
+ }
+
+ @Override
+ public void onPanZoomStopped() {
+ if (!mDynamicToolbar.isEnabled() || isHomePagerVisible() ||
+ mBrowserChrome.getVisibility() != View.VISIBLE) {
+ return;
+ }
+
+ // Make sure the toolbar is fully hidden or fully shown when the user
+ // lifts their finger, depending on various conditions.
+ ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics();
+ float toolbarTranslation = mLayerView.getDynamicToolbarAnimator().getToolbarTranslation();
+
+ boolean shortPage = metrics.getPageHeight() < metrics.getHeight();
+ boolean atBottomOfLongPage =
+ FloatUtils.fuzzyEquals(metrics.pageRectBottom, metrics.viewportRectBottom())
+ && (metrics.pageRectBottom > 2 * metrics.getHeight());
+ Log.v(LOGTAG, "On pan/zoom stopped, short page: " + shortPage
+ + "; atBottomOfLongPage: " + atBottomOfLongPage);
+ if (shortPage || atBottomOfLongPage) {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ }
+ }
+
+ public void refreshToolbarHeight() {
+ ThreadUtils.assertOnUiThread();
+
+ int height = 0;
+ if (mBrowserChrome != null) {
+ height = mBrowserChrome.getHeight();
+ }
+
+ if (!mDynamicToolbar.isEnabled() || isHomePagerVisible()) {
+ // Use aVisibleHeight here so that when the dynamic toolbar is
+ // enabled, the padding will animate with the toolbar becoming
+ // visible.
+ if (mDynamicToolbar.isEnabled()) {
+ // When the dynamic toolbar is enabled, set the padding on the
+ // about:home widget directly - this is to avoid resizing the
+ // LayerView, which can cause visible artifacts.
+ mHomeScreenContainer.setPadding(0, height, 0, 0);
+ } else {
+ setToolbarMargin(height);
+ height = 0;
+ }
+ } else {
+ setToolbarMargin(0);
+ }
+
+ if (mLayerView != null && height != mToolbarHeight) {
+ mToolbarHeight = height;
+ mLayerView.setMaxTranslation(height);
+ mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE);
+ }
+ }
+
+ @Override
+ void toggleChrome(final boolean aShow) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (aShow) {
+ mBrowserChrome.setVisibility(View.VISIBLE);
+ } else {
+ mBrowserChrome.setVisibility(View.GONE);
+ }
+ }
+ });
+
+ super.toggleChrome(aShow);
+ }
+
+ @Override
+ void focusChrome() {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mBrowserChrome.setVisibility(View.VISIBLE);
+ mActionBarFlipper.requestFocusFromTouch();
+ }
+ });
+ }
+
+ @Override
+ public void refreshChrome() {
+ invalidateOptionsMenu();
+
+ if (mTabsPanel != null) {
+ mTabsPanel.refresh();
+ }
+
+ if (mTabStrip != null) {
+ mTabStrip.refresh();
+ }
+
+ mBrowserToolbar.refresh();
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message,
+ final EventCallback callback) {
+ switch (event) {
+ case "CharEncoding:Data":
+ final NativeJSObject[] charsets = message.getObjectArray("charsets");
+ final int selected = message.getInt("selected");
+
+ final String[] titleArray = new String[charsets.length];
+ final String[] codeArray = new String[charsets.length];
+ for (int i = 0; i < charsets.length; i++) {
+ final NativeJSObject charset = charsets[i];
+ titleArray[i] = charset.getString("title");
+ codeArray[i] = charset.getString("code");
+ }
+
+ final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
+ dialogBuilder.setSingleChoiceItems(titleArray, selected,
+ new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ GeckoAppShell.notifyObservers("CharEncoding:Set", codeArray[which]);
+ dialog.dismiss();
+ }
+ });
+ dialogBuilder.setNegativeButton(R.string.button_cancel,
+ new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+ }
+ });
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ dialogBuilder.show();
+ }
+ });
+ break;
+
+ case "CharEncoding:State":
+ final boolean visible = message.getString("visible").equals("true");
+ GeckoPreferences.setCharEncodingState(visible);
+ final Menu menu = mMenu;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (menu != null) {
+ menu.findItem(R.id.char_encoding).setVisible(visible);
+ }
+ }
+ });
+ break;
+
+ case "Experiments:GetActive":
+ final List<String> experiments = SwitchBoard.getActiveExperiments(this);
+ final JSONArray json = new JSONArray(experiments);
+ callback.sendSuccess(json.toString());
+ break;
+
+ case "Experiments:SetOverride":
+ Experiments.setOverride(getContext(), message.getString("name"), message.getBoolean("isEnabled"));
+ break;
+
+ case "Experiments:ClearOverride":
+ Experiments.clearOverride(getContext(), message.getString("name"));
+ break;
+
+ case "Favicon:CacheLoad":
+ final String url = message.getString("url");
+ getFaviconFromCache(callback, url);
+ break;
+
+ case "Feedback:MaybeLater":
+ resetFeedbackLaunchCount();
+ break;
+
+ case "Menu:Add":
+ final MenuItemInfo info = new MenuItemInfo();
+ info.label = message.getString("name");
+ info.id = message.getInt("id") + ADDON_MENU_OFFSET;
+ info.checked = message.optBoolean("checked", false);
+ info.enabled = message.optBoolean("enabled", true);
+ info.visible = message.optBoolean("visible", true);
+ info.checkable = message.optBoolean("checkable", false);
+ final int parent = message.optInt("parent", 0);
+ info.parent = parent <= 0 ? parent : parent + ADDON_MENU_OFFSET;
+ final MenuItemInfo menuItemInfo = info;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ addAddonMenuItem(menuItemInfo);
+ }
+ });
+ break;
+
+ case "Menu:Remove":
+ final int id = message.getInt("id") + ADDON_MENU_OFFSET;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ removeAddonMenuItem(id);
+ }
+ });
+ break;
+
+ case "Sanitize:ClearHistory":
+ handleClearHistory(message.optBoolean("clearSearchHistory", false));
+ callback.sendSuccess(true);
+ break;
+
+ case "Sanitize:ClearSyncedTabs":
+ handleClearSyncedTabs();
+ callback.sendSuccess(true);
+ break;
+
+ case "Settings:Show":
+ final String resource =
+ message.optString(GeckoPreferences.INTENT_EXTRA_RESOURCES, null);
+ final Intent settingsIntent = new Intent(this, GeckoPreferences.class);
+ GeckoPreferences.setResourceToOpen(settingsIntent, resource);
+ startActivityForResult(settingsIntent, ACTIVITY_REQUEST_PREFERENCES);
+
+ // Don't use a transition to settings if we're on a device where that
+ // would look bad.
+ if (HardwareUtils.IS_KINDLE_DEVICE) {
+ overridePendingTransition(0, 0);
+ }
+ break;
+
+ case "Telemetry:Gather":
+ final BrowserDB db = BrowserDB.from(getProfile());
+ final ContentResolver cr = getContentResolver();
+ Telemetry.addToHistogram("PLACES_PAGES_COUNT", db.getCount(cr, "history"));
+ Telemetry.addToHistogram("FENNEC_BOOKMARKS_COUNT", db.getCount(cr, "bookmarks"));
+ Telemetry.addToHistogram("BROWSER_IS_USER_DEFAULT", (isDefaultBrowser(Intent.ACTION_VIEW) ? 1 : 0));
+ Telemetry.addToHistogram("FENNEC_CUSTOM_HOMEPAGE", (TextUtils.isEmpty(getHomepage()) ? 0 : 1));
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext());
+ final boolean hasCustomHomepanels =
+ prefs.contains(HomeConfigPrefsBackend.PREFS_CONFIG_KEY) || prefs.contains(HomeConfigPrefsBackend.PREFS_CONFIG_KEY_OLD);
+ Telemetry.addToHistogram("FENNEC_HOMEPANELS_CUSTOM", hasCustomHomepanels ? 1 : 0);
+
+ Telemetry.addToHistogram("FENNEC_READER_VIEW_CACHE_SIZE",
+ SavedReaderViewHelper.getSavedReaderViewHelper(getContext()).getDiskSpacedUsedKB());
+
+ if (Versions.feature16Plus) {
+ Telemetry.addToHistogram("BROWSER_IS_ASSIST_DEFAULT", (isDefaultBrowser(Intent.ACTION_ASSIST) ? 1 : 0));
+ }
+
+ Telemetry.addToHistogram("FENNEC_ORBOT_INSTALLED",
+ ContextUtils.isPackageInstalled(getContext(), "org.torproject.android") ? 1 : 0);
+ break;
+
+ case "Updater:Launch":
+ handleUpdaterLaunch();
+ break;
+
+ case "Download:AndroidDownloadManager":
+ // Downloading via Android's download manager
+
+ final String uri = message.getString("uri");
+ final String filename = message.getString("filename");
+ final String mimeType = message.getString("mimeType");
+
+ final DownloadManager.Request request = new DownloadManager.Request(Uri.parse(uri));
+ request.setMimeType(mimeType);
+
+ try {
+ request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename);
+ } catch (IllegalStateException e) {
+ Log.e(LOGTAG, "Cannot create download directory");
+ return;
+ }
+
+ request.allowScanningByMediaScanner();
+ request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
+ request.addRequestHeader("User-Agent", HardwareUtils.isTablet() ?
+ AppConstants.USER_AGENT_FENNEC_TABLET :
+ AppConstants.USER_AGENT_FENNEC_MOBILE);
+
+ try {
+ DownloadManager manager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
+ manager.enqueue(request);
+
+ Log.d(LOGTAG, "Enqueued download (Download Manager)");
+ } catch (RuntimeException e) {
+ Log.e(LOGTAG, "Download failed: " + e);
+ }
+ break;
+
+ case "Website:Metadata":
+ final NativeJSObject metadata = message.getObject("metadata");
+ final String location = message.getString("location");
+
+ final boolean hasImage = !TextUtils.isEmpty(metadata.optString("image_url", null));
+ final String metadataJSON = metadata.toString();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final ContentProviderClient contentProviderClient = getContentResolver()
+ .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI);
+ if (contentProviderClient == null) {
+ Log.w(LOGTAG, "Failed to obtain content provider client for: " + BrowserContract.PageMetadata.CONTENT_URI);
+ return;
+ }
+ try {
+ GlobalPageMetadata.getInstance().add(
+ BrowserDB.from(getProfile()),
+ contentProviderClient,
+ location, hasImage, metadataJSON);
+ } finally {
+ contentProviderClient.release();
+ }
+ }
+ });
+
+ break;
+
+ default:
+ super.handleMessage(event, message, callback);
+ break;
+ }
+ }
+
+ private void getFaviconFromCache(final EventCallback callback, final String url) {
+ Icons.with(this)
+ .pageUrl(url)
+ .skipNetwork()
+ .executeCallbackOnBackgroundThread()
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ ByteArrayOutputStream out = null;
+ Base64OutputStream b64 = null;
+
+ try {
+ out = new ByteArrayOutputStream();
+ out.write("data:image/png;base64,".getBytes());
+ b64 = new Base64OutputStream(out, Base64.NO_WRAP);
+ response.getBitmap().compress(Bitmap.CompressFormat.PNG, 100, b64);
+ callback.sendSuccess(new String(out.toByteArray()));
+ } catch (IOException e) {
+ Log.w(LOGTAG, "Failed to convert to base64 data URI");
+ callback.sendError("Failed to convert favicon to a base64 data URI");
+ } finally {
+ try {
+ if (out != null) {
+ out.close();
+ }
+ if (b64 != null) {
+ b64.close();
+ }
+ } catch (IOException e) {
+ Log.w(LOGTAG, "Failed to close the streams");
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Use a dummy Intent to do a default browser check.
+ *
+ * @return true if this package is the default browser on this device, false otherwise.
+ */
+ private boolean isDefaultBrowser(String action) {
+ final Intent viewIntent = new Intent(action, Uri.parse("http://www.mozilla.org"));
+ final ResolveInfo info = getPackageManager().resolveActivity(viewIntent, PackageManager.MATCH_DEFAULT_ONLY);
+ if (info == null) {
+ // No default is set
+ return false;
+ }
+
+ final String packageName = info.activityInfo.packageName;
+ return (TextUtils.equals(packageName, getPackageName()));
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ switch (event) {
+ case "Menu:Open":
+ if (mBrowserToolbar.isEditing()) {
+ mBrowserToolbar.cancelEdit();
+ }
+
+ openOptionsMenu();
+ break;
+
+ case "Menu:Update":
+ final int id = message.getInt("id") + ADDON_MENU_OFFSET;
+ final JSONObject options = message.getJSONObject("options");
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ updateAddonMenuItem(id, options);
+ }
+ });
+ break;
+
+ case "Gecko:DelayedStartup":
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Force tabs panel inflation once the initial
+ // pageload is finished.
+ ensureTabsPanelExists();
+ if (AppConstants.NIGHTLY_BUILD && mZoomedView == null) {
+ ViewStub stub = (ViewStub) findViewById(R.id.zoomed_view_stub);
+ mZoomedView = (ZoomedView) stub.inflate();
+ }
+ }
+ });
+
+ if (AppConstants.MOZ_MEDIA_PLAYER) {
+ // Check if the fragment is already added. This should never be true here, but this is
+ // a nice safety check.
+ // If casting is disabled, these classes aren't built. We use reflection to initialize them.
+ final Class<?> mediaManagerClass = getMediaPlayerManager();
+
+ if (mediaManagerClass != null) {
+ try {
+ final String tag = "";
+ mediaManagerClass.getDeclaredField("MEDIA_PLAYER_TAG").get(tag);
+ Log.i(LOGTAG, "Found tag " + tag);
+ final Fragment frag = getSupportFragmentManager().findFragmentByTag(tag);
+ if (frag == null) {
+ final Method getInstance = mediaManagerClass.getMethod("getInstance", (Class[]) null);
+ final Fragment mpm = (Fragment) getInstance.invoke(null);
+ getSupportFragmentManager().beginTransaction().disallowAddToBackStack().add(mpm, tag).commit();
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Error initializing media manager", ex);
+ }
+ }
+ }
+
+ if (AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED && Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ // Start (this acts as ping if started already) the stumbler lib; if the stumbler has queued data it will upload it.
+ // Stumbler operates on its own thread, and startup impact is further minimized by delaying work (such as upload) a few seconds.
+ // Avoid any potential startup CPU/thread contention by delaying the pref broadcast.
+ final long oneSecondInMillis = 1000;
+ ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ GeckoPreferences.broadcastStumblerPref(BrowserApp.this);
+ }
+ }, oneSecondInMillis);
+ }
+
+ if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+ // TODO: Better scheduling of sync action (Bug 1257492)
+ DownloadContentService.startSync(this);
+
+ DownloadContentService.startVerification(this);
+ }
+
+ FeedService.setup(this);
+
+ super.handleMessage(event, message);
+ break;
+
+ case "Gecko:Ready":
+ // Handle this message in GeckoApp, but also enable the Settings
+ // menuitem, which is specific to BrowserApp.
+ super.handleMessage(event, message);
+ final Menu menu = mMenu;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (menu != null) {
+ menu.findItem(R.id.settings).setEnabled(true);
+ menu.findItem(R.id.help).setEnabled(true);
+ }
+ }
+ });
+
+ // Display notification for Mozilla data reporting, if data should be collected.
+ if (AppConstants.MOZ_DATA_REPORTING && Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ DataReportingNotification.checkAndNotifyPolicy(GeckoAppShell.getContext());
+ }
+ break;
+
+ case "Search:Keyword":
+ storeSearchQuery(message.getString("query"));
+ recordSearch(GeckoSharedPrefs.forProfile(this), message.getString("identifier"),
+ TelemetryContract.Method.ACTIONBAR);
+ break;
+
+ case "LightweightTheme:Update":
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ }
+ });
+ break;
+
+ case "Video:Play":
+ if (SwitchBoard.isInExperiment(this, Experiments.HLS_VIDEO_PLAYBACK)) {
+ final String uri = message.getString("uri");
+ final String uuid = message.getString("uuid");
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mVideoPlayer.start(Uri.parse(uri));
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.CONTENT, "playhls");
+ }
+ });
+ }
+ break;
+
+ case "Prompt:ShowTop":
+ // Bring this activity to front so the prompt is visible..
+ Intent bringToFrontIntent = new Intent();
+ bringToFrontIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ bringToFrontIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+ startActivity(bringToFrontIntent);
+ break;
+
+ case "Tab:Added":
+ if (message.getBoolean("cancelEditMode")) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Set the target tab to null so it does not get selected (on editing
+ // mode exit) in lieu of the tab that we're going to open and select.
+ mTargetTabForEditingMode = null;
+ mBrowserToolbar.cancelEdit();
+ }
+ });
+ }
+ break;
+
+ default:
+ super.handleMessage(event, message);
+ break;
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ @Override
+ public void addTab() {
+ Tabs.getInstance().addTab();
+ }
+
+ @Override
+ public void addPrivateTab() {
+ Tabs.getInstance().addPrivateTab();
+ }
+
+ public void showTrackingProtectionPromptIfApplicable() {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ final boolean hasTrackingProtectionPromptBeShownBefore = prefs.getBoolean(GeckoPreferences.PREFS_TRACKING_PROTECTION_PROMPT_SHOWN, false);
+
+ if (hasTrackingProtectionPromptBeShownBefore) {
+ return;
+ }
+
+ prefs.edit().putBoolean(GeckoPreferences.PREFS_TRACKING_PROTECTION_PROMPT_SHOWN, true).apply();
+
+ startActivity(new Intent(BrowserApp.this, TrackingProtectionPrompt.class));
+ }
+
+ @Override
+ public void showNormalTabs() {
+ showTabs(TabsPanel.Panel.NORMAL_TABS);
+ }
+
+ @Override
+ public void showPrivateTabs() {
+ showTabs(TabsPanel.Panel.PRIVATE_TABS);
+ }
+ /**
+ * Ensure the TabsPanel view is properly inflated and returns
+ * true when the view has been inflated, false otherwise.
+ */
+ private boolean ensureTabsPanelExists() {
+ if (mTabsPanel != null) {
+ return false;
+ }
+
+ ViewStub tabsPanelStub = (ViewStub) findViewById(R.id.tabs_panel);
+ mTabsPanel = (TabsPanel) tabsPanelStub.inflate();
+
+ mTabsPanel.setTabsLayoutChangeListener(this);
+
+ return true;
+ }
+
+ private void showTabs(final TabsPanel.Panel panel) {
+ if (Tabs.getInstance().getDisplayCount() == 0)
+ return;
+
+ hideFirstrunPager(TelemetryContract.Method.BUTTON);
+
+ if (ensureTabsPanelExists()) {
+ // If we've just inflated the tabs panel, only show it once the current
+ // layout pass is done to avoid displayed temporary UI states during
+ // relayout.
+ ViewTreeObserver vto = mTabsPanel.getViewTreeObserver();
+ if (vto.isAlive()) {
+ vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ mTabsPanel.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ showTabs(panel);
+ }
+ });
+ }
+ } else {
+ if (mDoorHangerPopup != null) {
+ mDoorHangerPopup.disable();
+ }
+ mTabsPanel.show(panel);
+
+ // Hide potentially visible "find in page" bar (Bug 1177338)
+ mFindInPageBar.hide();
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onTabsTrayShown(this, mTabsPanel);
+ }
+ }
+ }
+
+ @Override
+ public void hideTabs() {
+ mTabsPanel.hide();
+ if (mDoorHangerPopup != null) {
+ mDoorHangerPopup.enable();
+ }
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onTabsTrayHidden(this, mTabsPanel);
+ }
+ }
+
+ @Override
+ public boolean autoHideTabs() {
+ if (areTabsShown()) {
+ hideTabs();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean areTabsShown() {
+ return (mTabsPanel != null && mTabsPanel.isShown());
+ }
+
+ @Override
+ public String getHomepage() {
+ final SharedPreferences preferences = GeckoSharedPrefs.forProfile(this);
+ final String homepagePreference = preferences.getString(GeckoPreferences.PREFS_HOMEPAGE, null);
+
+ final boolean readFromPartnerProvider = preferences.getBoolean(
+ GeckoPreferences.PREFS_READ_PARTNER_CUSTOMIZATIONS_PROVIDER, false);
+
+ if (!readFromPartnerProvider) {
+ // Just return homepage as set by the user (or null).
+ return homepagePreference;
+ }
+
+
+ final String homepagePrevious = preferences.getString(GeckoPreferences.PREFS_HOMEPAGE_PARTNER_COPY, null);
+ if (homepagePrevious != null && !homepagePrevious.equals(homepagePreference)) {
+ // We have read the homepage once and the user has changed it since then. Just use the
+ // value the user has set.
+ return homepagePreference;
+ }
+
+ // This is the first time we read the partner provider or the value has not been altered by the user
+ final String homepagePartner = PartnerBrowserCustomizationsClient.getHomepage(this);
+
+ if (homepagePartner == null) {
+ // We didn't get anything from the provider. Let's just use what we have locally.
+ return homepagePreference;
+ }
+
+ if (!homepagePartner.equals(homepagePrevious)) {
+ // We have a new value. Update the preferences.
+ preferences.edit()
+ .putString(GeckoPreferences.PREFS_HOMEPAGE, homepagePartner)
+ .putString(GeckoPreferences.PREFS_HOMEPAGE_PARTNER_COPY, homepagePartner)
+ .apply();
+ }
+
+ return homepagePartner;
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ @Override
+ public void onTabsLayoutChange(int width, int height) {
+ int animationLength = TABS_ANIMATION_DURATION;
+
+ if (mMainLayoutAnimator != null) {
+ animationLength = Math.max(1, animationLength - (int)mMainLayoutAnimator.getRemainingTime());
+ mMainLayoutAnimator.stop(false);
+ }
+
+ if (areTabsShown()) {
+ mTabsPanel.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
+ // Hide the web content from accessibility tools even though it's visible
+ // so that you can't examine it as long as the tabs are being shown.
+ if (Versions.feature16Plus) {
+ mLayerView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+ } else {
+ if (Versions.feature16Plus) {
+ mLayerView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ }
+ }
+
+ mMainLayoutAnimator = new PropertyAnimator(animationLength, sTabsInterpolator);
+ mMainLayoutAnimator.addPropertyAnimationListener(this);
+ mMainLayoutAnimator.attach(mMainLayout,
+ PropertyAnimator.Property.SCROLL_Y,
+ -height);
+
+ mTabsPanel.prepareTabsAnimation(mMainLayoutAnimator);
+ mBrowserToolbar.triggerTabsPanelTransition(mMainLayoutAnimator, areTabsShown());
+
+ // If the tabs panel is animating onto the screen, pin the dynamic
+ // toolbar.
+ if (mDynamicToolbar.isEnabled()) {
+ if (width > 0 && height > 0) {
+ mDynamicToolbar.setPinned(true, PinReason.RELAYOUT);
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ } else {
+ mDynamicToolbar.setPinned(false, PinReason.RELAYOUT);
+ }
+ }
+
+ mMainLayoutAnimator.start();
+ }
+
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ if (!areTabsShown()) {
+ mTabsPanel.setVisibility(View.INVISIBLE);
+ mTabsPanel.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ } else {
+ // Cancel editing mode to return to page content when the TabsPanel closes. We cancel
+ // it here because there are graphical glitches if it's canceled while it's visible.
+ mBrowserToolbar.cancelEdit();
+ }
+
+ mTabsPanel.finishTabsAnimation();
+
+ mMainLayoutAnimator = null;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mDynamicToolbar.onSaveInstanceState(outState);
+ outState.putInt(STATE_ABOUT_HOME_TOP_PADDING, mHomeScreenContainer.getPaddingTop());
+ }
+
+ /**
+ * Attempts to switch to an open tab with the given URL.
+ * <p>
+ * If the tab exists, this method cancels any in-progress editing as well as
+ * calling {@link Tabs#selectTab(int)}.
+ *
+ * @param url of tab to switch to.
+ * @param flags to obey: if {@link OnUrlOpenListener.Flags#ALLOW_SWITCH_TO_TAB}
+ * is not present, return false.
+ * @return true if we successfully switched to a tab, false otherwise.
+ */
+ private boolean maybeSwitchToTab(String url, EnumSet<OnUrlOpenListener.Flags> flags) {
+ if (!flags.contains(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)) {
+ return false;
+ }
+
+ final Tabs tabs = Tabs.getInstance();
+ final Tab tab;
+
+ if (AboutPages.isAboutReader(url)) {
+ tab = tabs.getFirstReaderTabForUrl(url, tabs.getSelectedTab().isPrivate());
+ } else {
+ tab = tabs.getFirstTabForUrl(url, tabs.getSelectedTab().isPrivate());
+ }
+
+ if (tab == null) {
+ return false;
+ }
+
+ return maybeSwitchToTab(tab.getId());
+ }
+
+ /**
+ * Attempts to switch to an open tab with the given unique tab ID.
+ * <p>
+ * If the tab exists, this method cancels any in-progress editing as well as
+ * calling {@link Tabs#selectTab(int)}.
+ *
+ * @param id of tab to switch to.
+ * @return true if we successfully switched to the tab, false otherwise.
+ */
+ private boolean maybeSwitchToTab(int id) {
+ final Tabs tabs = Tabs.getInstance();
+ final Tab tab = tabs.getTab(id);
+
+ if (tab == null) {
+ return false;
+ }
+
+ final Tab oldTab = tabs.getSelectedTab();
+ if (oldTab != null) {
+ oldTab.setIsEditing(false);
+ }
+
+ // Set the target tab to null so it does not get selected (on editing
+ // mode exit) in lieu of the tab we are about to select.
+ mTargetTabForEditingMode = null;
+ tabs.selectTab(tab.getId());
+
+ mBrowserToolbar.cancelEdit();
+
+ return true;
+ }
+
+ public void openUrlAndStopEditing(String url) {
+ openUrlAndStopEditing(url, null, false);
+ }
+
+ private void openUrlAndStopEditing(String url, boolean newTab) {
+ openUrlAndStopEditing(url, null, newTab);
+ }
+
+ private void openUrlAndStopEditing(String url, String searchEngine) {
+ openUrlAndStopEditing(url, searchEngine, false);
+ }
+
+ private void openUrlAndStopEditing(String url, String searchEngine, boolean newTab) {
+ int flags = Tabs.LOADURL_NONE;
+ if (newTab) {
+ flags |= Tabs.LOADURL_NEW_TAB;
+ if (Tabs.getInstance().getSelectedTab().isPrivate()) {
+ flags |= Tabs.LOADURL_PRIVATE;
+ }
+ }
+
+ Tabs.getInstance().loadUrl(url, searchEngine, -1, flags);
+
+ mBrowserToolbar.cancelEdit();
+ }
+
+ private boolean isHomePagerVisible() {
+ return (mHomeScreen != null && mHomeScreen.isVisible()
+ && mHomeScreenContainer != null && mHomeScreenContainer.getVisibility() == View.VISIBLE);
+ }
+
+ private boolean isFirstrunVisible() {
+ return (mFirstrunAnimationContainer != null && mFirstrunAnimationContainer.isVisible()
+ && mHomeScreenContainer != null && mHomeScreenContainer.getVisibility() == View.VISIBLE);
+ }
+
+ /**
+ * Enters editing mode with the current tab's URL. There might be no
+ * tabs loaded by the time the user enters editing mode e.g. just after
+ * the app starts. In this case, we simply fallback to an empty URL.
+ */
+ private void enterEditingMode() {
+ String url = "";
+ String telemetryMsg = "urlbar-empty";
+
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ final String userSearchTerm = tab.getUserRequested();
+ final String tabURL = tab.getURL();
+
+ // Check to see if there's a user-entered search term,
+ // which we save whenever the user performs a search.
+ if (!TextUtils.isEmpty(userSearchTerm)) {
+ url = userSearchTerm;
+ telemetryMsg = "urlbar-userentered";
+ } else if (!TextUtils.isEmpty(tabURL)) {
+ url = tabURL;
+ telemetryMsg = "urlbar-url";
+ }
+ }
+
+ enterEditingMode(url);
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.ACTIONBAR, telemetryMsg);
+ }
+
+ /**
+ * Enters editing mode with the specified URL. If a null
+ * url is given, the empty String will be used instead.
+ */
+ private void enterEditingMode(@NonNull String url) {
+ hideFirstrunPager(TelemetryContract.Method.ACTIONBAR);
+
+ if (mBrowserToolbar.isEditing() || mBrowserToolbar.isAnimating()) {
+ return;
+ }
+
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ final String panelId;
+ if (selectedTab != null) {
+ mTargetTabForEditingMode = selectedTab.getId();
+ panelId = selectedTab.getMostRecentHomePanel();
+ } else {
+ mTargetTabForEditingMode = null;
+ panelId = null;
+ }
+
+ final PropertyAnimator animator = new PropertyAnimator(250);
+ animator.setUseHardwareLayer(false);
+
+ mBrowserToolbar.startEditing(url, animator);
+
+ showHomePagerWithAnimator(panelId, null, animator);
+
+ animator.start();
+ Telemetry.startUISession(TelemetryContract.Session.AWESOMESCREEN);
+ }
+
+ private void commitEditingMode() {
+ if (!mBrowserToolbar.isEditing()) {
+ return;
+ }
+
+ Telemetry.stopUISession(TelemetryContract.Session.AWESOMESCREEN,
+ TelemetryContract.Reason.COMMIT);
+
+ final String url = mBrowserToolbar.commitEdit();
+
+ // HACK: We don't know the url that will be loaded when hideHomePager is initially called
+ // in BrowserToolbar's onStopEditing listener so on the awesomescreen, hideHomePager will
+ // use the url "about:home" and return without taking any action. hideBrowserSearch is
+ // then called, but since hideHomePager changes both HomePager and LayerView visibility
+ // and exited without taking an action, no Views are displayed and graphical corruption is
+ // visible instead.
+ //
+ // Here we call hideHomePager for the second time with the URL to be loaded so that
+ // hideHomePager is called with the correct state for the upcoming page load.
+ //
+ // Expected to be fixed by bug 915825.
+ hideHomePager(url);
+ loadUrlOrKeywordSearch(url);
+ clearSelectedTabApplicationId();
+ }
+
+ private void clearSelectedTabApplicationId() {
+ final Tab selected = Tabs.getInstance().getSelectedTab();
+ if (selected != null) {
+ selected.setApplicationId(null);
+ }
+ }
+
+ private void loadUrlOrKeywordSearch(final String url) {
+ // Don't do anything if the user entered an empty URL.
+ if (TextUtils.isEmpty(url)) {
+ return;
+ }
+
+ // If the URL doesn't look like a search query, just load it.
+ if (!StringUtils.isSearchQuery(url, true)) {
+ Tabs.getInstance().loadUrl(url, Tabs.LOADURL_USER_ENTERED);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.ACTIONBAR, "user");
+ return;
+ }
+
+ // Otherwise, check for a bookmark keyword.
+ final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfile(this);
+ final BrowserDB db = BrowserDB.from(getProfile());
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final String keyword;
+ final String keywordSearch;
+
+ final int index = url.indexOf(" ");
+ if (index == -1) {
+ keyword = url;
+ keywordSearch = "";
+ } else {
+ keyword = url.substring(0, index);
+ keywordSearch = url.substring(index + 1);
+ }
+
+ final String keywordUrl = db.getUrlForKeyword(getContentResolver(), keyword);
+
+ // If there isn't a bookmark keyword, load the url. This may result in a query
+ // using the default search engine.
+ if (TextUtils.isEmpty(keywordUrl)) {
+ Tabs.getInstance().loadUrl(url, Tabs.LOADURL_USER_ENTERED);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.ACTIONBAR, "user");
+ return;
+ }
+
+ // Otherwise, construct a search query from the bookmark keyword.
+ // Replace lower case bookmark keywords with URLencoded search query or
+ // replace upper case bookmark keywords with un-encoded search query.
+ // This makes it match the same behaviour as on Firefox for the desktop.
+ final String searchUrl = keywordUrl.replace("%s", URLEncoder.encode(keywordSearch)).replace("%S", keywordSearch);
+
+ Tabs.getInstance().loadUrl(searchUrl, Tabs.LOADURL_USER_ENTERED);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL,
+ TelemetryContract.Method.ACTIONBAR,
+ "keyword");
+ }
+ });
+ }
+
+ /**
+ * Records in telemetry that a search has occurred.
+ *
+ * @param where where the search was started from
+ */
+ private static void recordSearch(@NonNull final SharedPreferences prefs, @NonNull final String engineIdentifier,
+ @NonNull final TelemetryContract.Method where) {
+ // We could include the engine identifier as an extra but we'll
+ // just capture that with core ping telemetry (bug 1253319).
+ Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH, where);
+ SearchCountMeasurements.incrementSearch(prefs, engineIdentifier, where.toString());
+ }
+
+ /**
+ * Store search query in SearchHistoryProvider.
+ *
+ * @param query
+ * a search query to store. We won't store empty queries.
+ */
+ private void storeSearchQuery(final String query) {
+ if (TextUtils.isEmpty(query)) {
+ return;
+ }
+
+ // Filter out URLs and long suggestions
+ if (query.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", query)) {
+ return;
+ }
+
+ final GeckoProfile profile = getProfile();
+ // Don't bother storing search queries in guest mode
+ if (profile.inGuestMode()) {
+ return;
+ }
+
+ final BrowserDB db = BrowserDB.from(profile);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.getSearches().insert(getContentResolver(), query);
+ }
+ });
+ }
+
+ void filterEditingMode(String searchTerm, AutocompleteHandler handler) {
+ if (TextUtils.isEmpty(searchTerm)) {
+ hideBrowserSearch();
+ } else {
+ showBrowserSearch();
+ mBrowserSearch.filter(searchTerm, handler);
+ }
+ }
+
+ /**
+ * Selects the target tab for editing mode. This is expected to be the tab selected on editing
+ * mode entry, unless it is subsequently overridden.
+ *
+ * A background tab may be selected while editing mode is active (e.g. popups), causing the
+ * new url to load in the newly selected tab. Call this method on editing mode exit to
+ * mitigate this.
+ *
+ * Note that this method is disabled for new tablets because we can see the selected tab in the
+ * tab strip and, when the selected tab changes during editing mode as in this hack, the
+ * temporarily selected tab is visible to users.
+ */
+ private void selectTargetTabForEditingMode() {
+ if (HardwareUtils.isTablet()) {
+ return;
+ }
+
+ if (mTargetTabForEditingMode != null) {
+ Tabs.getInstance().selectTab(mTargetTabForEditingMode);
+ }
+
+ mTargetTabForEditingMode = null;
+ }
+
+ /**
+ * Shows or hides the home pager for the given tab.
+ */
+ private void updateHomePagerForTab(Tab tab) {
+ // Don't change the visibility of the home pager if we're in editing mode.
+ if (mBrowserToolbar.isEditing()) {
+ return;
+ }
+
+ // History will only store that we were visiting about:home, however the specific panel
+ // isn't stored. (We are able to navigate directly to homepanels using an about:home?panel=...
+ // URL, but the reverse doesn't apply: manually switching panels doesn't update the URL.)
+ // Hence we need to restore the panel, in addition to panel state, here.
+ if (isAboutHome(tab)) {
+ String panelId = AboutPages.getPanelIdFromAboutHomeUrl(tab.getURL());
+ Bundle panelRestoreData = null;
+ if (panelId == null) {
+ // No panel was specified in the URL. Try loading the most recent
+ // home panel for this tab.
+ // Note: this isn't necessarily correct. We don't update the URL when we switch tabs.
+ // If a user explicitly navigated to about:reader?panel=FOO, and then switches
+ // to panel BAR, the history URL still contains FOO, and we restore to FOO. In most
+ // cases however we aren't supplying a panel ID in the URL so this code still works
+ // for most cases.
+ // We can't fix this directly since we can't ignore the panelId if we're explicitly
+ // loading a specific panel, and we currently can't distinguish between loading
+ // history, and loading new pages, see Bug 1268887
+ panelId = tab.getMostRecentHomePanel();
+ panelRestoreData = tab.getMostRecentHomePanelData();
+ } else if (panelId.equals(HomeConfig.getIdForBuiltinPanelType(PanelType.DEPRECATED_RECENT_TABS))) {
+ // Redirect to the Combined History panel.
+ panelId = HomeConfig.getIdForBuiltinPanelType(PanelType.COMBINED_HISTORY);
+ panelRestoreData = new Bundle();
+ // Jump directly to the Recent Tabs subview of the Combined History panel.
+ panelRestoreData.putBoolean("goToRecentTabs", true);
+ }
+ showHomePager(panelId, panelRestoreData);
+
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ }
+ } else {
+ hideHomePager();
+ }
+ }
+
+ @Override
+ public void onLocaleReady(final String locale) {
+ Log.d(LOGTAG, "onLocaleReady: " + locale);
+ super.onLocaleReady(locale);
+
+ HomePanelsManager.getInstance().onLocaleReady(locale);
+
+ if (mMenu != null) {
+ mMenu.clear();
+ onCreateOptionsMenu(mMenu);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ Log.d(LOGTAG, "onActivityResult: " + requestCode + ", " + resultCode + ", " + data);
+ switch (requestCode) {
+ case ACTIVITY_REQUEST_PREFERENCES:
+ // We just returned from preferences. If our locale changed,
+ // we need to redisplay at this point, and do any other browser-level
+ // bookkeeping that we associate with a locale change.
+ if (resultCode != GeckoPreferences.RESULT_CODE_LOCALE_DID_CHANGE) {
+ Log.d(LOGTAG, "No locale change returning from preferences; nothing to do.");
+ return;
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+ final Locale locale = localeManager.getCurrentLocale(getApplicationContext());
+ Log.d(LOGTAG, "Read persisted locale " + locale);
+ if (locale == null) {
+ return;
+ }
+ onLocaleChanged(Locales.getLanguageTag(locale));
+ }
+ });
+ break;
+
+ case ACTIVITY_REQUEST_TAB_QUEUE:
+ TabQueueHelper.processTabQueuePromptResponse(resultCode, this);
+ break;
+
+ default:
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onActivityResult(this, requestCode, resultCode, data);
+ }
+
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ private void showFirstrunPager() {
+ if (Experiments.isInExperimentLocal(getContext(), Experiments.ONBOARDING3_A)) {
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_A);
+ GeckoSharedPrefs.forProfile(getContext()).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_A).apply();
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_A);
+ return;
+ }
+
+ if (mFirstrunAnimationContainer == null) {
+ final ViewStub firstrunPagerStub = (ViewStub) findViewById(R.id.firstrun_pager_stub);
+ mFirstrunAnimationContainer = (FirstrunAnimationContainer) firstrunPagerStub.inflate();
+ mFirstrunAnimationContainer.load(getApplicationContext(), getSupportFragmentManager());
+ mFirstrunAnimationContainer.registerOnFinishListener(new FirstrunAnimationContainer.OnFinishListener() {
+ @Override
+ public void onFinish() {
+ if (mFirstrunAnimationContainer.showBrowserHint() &&
+ TextUtils.isEmpty(getHomepage())) {
+ enterEditingMode();
+ }
+ }
+ });
+ }
+
+ mHomeScreenContainer.setVisibility(View.VISIBLE);
+ }
+
+ private void showHomePager(String panelId, Bundle panelRestoreData) {
+ showHomePagerWithAnimator(panelId, panelRestoreData, null);
+ }
+
+ private void showHomePagerWithAnimator(String panelId, Bundle panelRestoreData, PropertyAnimator animator) {
+ if (isHomePagerVisible()) {
+ // Home pager already visible, make sure it shows the correct panel.
+ mHomeScreen.showPanel(panelId, panelRestoreData);
+ return;
+ }
+
+ // This must be called before the dynamic toolbar is set visible because it calls
+ // FormAssistPopup.onMetricsChanged, which queues a runnable that undoes the effect of hide.
+ // With hide first, onMetricsChanged will return early instead.
+ mFormAssistPopup.hide();
+ mFindInPageBar.hide();
+
+ // Refresh toolbar height to possibly restore the toolbar padding
+ refreshToolbarHeight();
+
+ // Show the toolbar before hiding about:home so the
+ // onMetricsChanged callback still works.
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE);
+ }
+
+ if (mHomeScreen == null) {
+ if (ActivityStream.isEnabled(this) &&
+ !ActivityStream.isHomePanel()) {
+ final ViewStub asStub = (ViewStub) findViewById(R.id.activity_stream_stub);
+ mHomeScreen = (HomeScreen) asStub.inflate();
+ } else {
+ final ViewStub homePagerStub = (ViewStub) findViewById(R.id.home_pager_stub);
+ mHomeScreen = (HomeScreen) homePagerStub.inflate();
+
+ // For now these listeners are HomePager specific. In future we might want
+ // to have a more abstracted data storage, with one Bundle containing all
+ // relevant restore data.
+ mHomeScreen.setOnPanelChangeListener(new HomeScreen.OnPanelChangeListener() {
+ @Override
+ public void onPanelSelected(String panelId) {
+ final Tab currentTab = Tabs.getInstance().getSelectedTab();
+ if (currentTab != null) {
+ currentTab.setMostRecentHomePanel(panelId);
+ }
+ }
+ });
+
+ // Set this listener to persist restore data (via the Tab) every time panel state changes.
+ mHomeScreen.setPanelStateChangeListener(new HomeFragment.PanelStateChangeListener() {
+ @Override
+ public void onStateChanged(Bundle bundle) {
+ final Tab currentTab = Tabs.getInstance().getSelectedTab();
+ if (currentTab != null) {
+ currentTab.setMostRecentHomePanelData(bundle);
+ }
+ }
+
+ @Override
+ public void setCachedRecentTabsCount(int count) {
+ mCachedRecentTabsCount = count;
+ }
+
+ @Override
+ public int getCachedRecentTabsCount() {
+ return mCachedRecentTabsCount;
+ }
+ });
+ }
+
+ // Don't show the banner in guest mode.
+ if (!Restrictions.isUserRestricted()) {
+ final ViewStub homeBannerStub = (ViewStub) findViewById(R.id.home_banner_stub);
+ final HomeBanner homeBanner = (HomeBanner) homeBannerStub.inflate();
+ mHomeScreen.setBanner(homeBanner);
+
+ // Remove the banner from the view hierarchy if it is dismissed.
+ homeBanner.setOnDismissListener(new HomeBanner.OnDismissListener() {
+ @Override
+ public void onDismiss() {
+ mHomeScreen.setBanner(null);
+ mHomeScreenContainer.removeView(homeBanner);
+ }
+ });
+ }
+ }
+
+ mHomeScreenContainer.setVisibility(View.VISIBLE);
+ mHomeScreen.load(getSupportLoaderManager(),
+ getSupportFragmentManager(),
+ panelId,
+ panelRestoreData,
+ animator);
+
+ // Hide the web content so it cannot be focused by screen readers.
+ hideWebContentOnPropertyAnimationEnd(animator);
+ }
+
+ private void hideWebContentOnPropertyAnimationEnd(final PropertyAnimator animator) {
+ if (animator == null) {
+ hideWebContent();
+ return;
+ }
+
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ mHideWebContentOnAnimationEnd = true;
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ if (mHideWebContentOnAnimationEnd) {
+ hideWebContent();
+ }
+ }
+ });
+ }
+
+ private void hideWebContent() {
+ // The view is set to INVISIBLE, rather than GONE, to avoid
+ // the additional requestLayout() call.
+ mLayerView.setVisibility(View.INVISIBLE);
+ }
+
+ /**
+ * Hide the Onboarding pager on user action, and don't show any onFinish hints.
+ * @param method TelemetryContract method by which action was taken
+ * @return boolean of whether pager was visible
+ */
+ private boolean hideFirstrunPager(TelemetryContract.Method method) {
+ if (!isFirstrunVisible()) {
+ return false;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, method, "firstrun-pane");
+
+ // Don't show any onFinish actions when hiding from this Activity.
+ mFirstrunAnimationContainer.registerOnFinishListener(null);
+ mFirstrunAnimationContainer.hide();
+ return true;
+ }
+
+ /**
+ * Hides the HomePager, using the url of the currently selected tab as the url to be
+ * loaded.
+ */
+ private void hideHomePager() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ final String url = (selectedTab != null) ? selectedTab.getURL() : null;
+
+ hideHomePager(url);
+ }
+
+ /**
+ * Hides the HomePager. The given url should be the url of the page to be loaded, or null
+ * if a new page is not being loaded.
+ */
+ private void hideHomePager(final String url) {
+ if (!isHomePagerVisible() || AboutPages.isAboutHome(url)) {
+ return;
+ }
+
+ // Prevent race in hiding web content - see declaration for more info.
+ mHideWebContentOnAnimationEnd = false;
+
+ // Display the previously hidden web content (which prevented screen reader access).
+ mLayerView.setVisibility(View.VISIBLE);
+ mHomeScreenContainer.setVisibility(View.GONE);
+
+ if (mHomeScreen != null) {
+ mHomeScreen.unload();
+ }
+
+ mBrowserToolbar.setNextFocusDownId(R.id.layer_view);
+
+ // Refresh toolbar height to possibly restore the toolbar padding
+ refreshToolbarHeight();
+ }
+
+ private void showBrowserSearchAfterAnimation(PropertyAnimator animator) {
+ if (animator == null) {
+ showBrowserSearch();
+ return;
+ }
+
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ showBrowserSearch();
+ }
+ });
+ }
+
+ private void showBrowserSearch() {
+ if (mBrowserSearch.getUserVisibleHint()) {
+ return;
+ }
+
+ mBrowserSearchContainer.setVisibility(View.VISIBLE);
+
+ // Prevent overdraw by hiding the underlying web content and HomePager View
+ hideWebContent();
+ mHomeScreenContainer.setVisibility(View.INVISIBLE);
+
+ final FragmentManager fm = getSupportFragmentManager();
+
+ // In certain situations, showBrowserSearch() can be called immediately after hideBrowserSearch()
+ // (see bug 925012). Because of an Android bug (http://code.google.com/p/android/issues/detail?id=61179),
+ // calling FragmentTransaction#add immediately after FragmentTransaction#remove won't add the fragment's
+ // view to the layout. Calling FragmentManager#executePendingTransactions before re-adding the fragment
+ // prevents this issue.
+ fm.executePendingTransactions();
+
+ Fragment f = fm.findFragmentById(R.id.search_container);
+
+ // checking if fragment is already present
+ if (f != null) {
+ fm.beginTransaction().show(f).commitAllowingStateLoss();
+ mBrowserSearch.resetScrollState();
+ } else {
+ // add fragment if not already present
+ fm.beginTransaction().add(R.id.search_container, mBrowserSearch, BROWSER_SEARCH_TAG).commitAllowingStateLoss();
+ }
+ mBrowserSearch.setUserVisibleHint(true);
+
+ // We want to adjust the window size when the keyboard appears to bring the
+ // SearchEngineBar above the keyboard. However, adjusting the window size
+ // when hiding the keyboard results in graphical glitches where the keyboard was
+ // because nothing was being drawn underneath (bug 933422). This can be
+ // prevented drawing content under the keyboard (i.e. in the Window).
+ //
+ // We do this here because there are glitches when unlocking a device with
+ // BrowserSearch in the foreground if we use BrowserSearch.onStart/Stop.
+ getActivity().getWindow().setBackgroundDrawableResource(android.R.color.white);
+ }
+
+ private void hideBrowserSearch() {
+ if (!mBrowserSearch.getUserVisibleHint()) {
+ return;
+ }
+
+ // To prevent overdraw, the HomePager is hidden when BrowserSearch is displayed:
+ // reverse that.
+ showHomePager(Tabs.getInstance().getSelectedTab().getMostRecentHomePanel(),
+ Tabs.getInstance().getSelectedTab().getMostRecentHomePanelData());
+
+ mBrowserSearchContainer.setVisibility(View.INVISIBLE);
+
+ getSupportFragmentManager().beginTransaction()
+ .hide(mBrowserSearch).commitAllowingStateLoss();
+ mBrowserSearch.setUserVisibleHint(false);
+
+ getWindow().setBackgroundDrawable(null);
+ }
+
+ /**
+ * Hides certain UI elements (e.g. button toast, tabs panel) when the
+ * user touches the main layout.
+ */
+ private class HideOnTouchListener implements TouchEventInterceptor {
+ private boolean mIsHidingTabs;
+ private final Rect mTempRect = new Rect();
+
+ @Override
+ public boolean onInterceptTouchEvent(View view, MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ SnackbarBuilder.dismissCurrentSnackbar();
+ }
+
+
+
+ // We need to account for scroll state for the touched view otherwise
+ // tapping on an "empty" part of the view will still be considered a
+ // valid touch event.
+ if (view.getScrollX() != 0 || view.getScrollY() != 0) {
+ view.getHitRect(mTempRect);
+ mTempRect.offset(-view.getScrollX(), -view.getScrollY());
+
+ int[] viewCoords = new int[2];
+ view.getLocationOnScreen(viewCoords);
+
+ int x = (int) event.getRawX() - viewCoords[0];
+ int y = (int) event.getRawY() - viewCoords[1];
+
+ if (!mTempRect.contains(x, y))
+ return false;
+ }
+
+ // If the tabs panel is showing, hide the tab panel and don't send the event to content.
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN && autoHideTabs()) {
+ mIsHidingTabs = true;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ if (mIsHidingTabs) {
+ // Keep consuming events until the gesture finishes.
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ mIsHidingTabs = false;
+ }
+ return true;
+ }
+ return false;
+ }
+ }
+
+ private static Menu findParentMenu(Menu menu, MenuItem item) {
+ final int itemId = item.getItemId();
+
+ final int count = (menu != null) ? menu.size() : 0;
+ for (int i = 0; i < count; i++) {
+ MenuItem menuItem = menu.getItem(i);
+ if (menuItem.getItemId() == itemId) {
+ return menu;
+ }
+ if (menuItem.hasSubMenu()) {
+ Menu parent = findParentMenu(menuItem.getSubMenu(), item);
+ if (parent != null) {
+ return parent;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Add the provided item to the provided menu, which should be
+ * the root (mMenu).
+ */
+ private void addAddonMenuItemToMenu(final Menu menu, final MenuItemInfo info) {
+ info.added = true;
+
+ final Menu destination;
+ if (info.parent == 0) {
+ destination = menu;
+ } else if (info.parent == GECKO_TOOLS_MENU) {
+ // The tools menu only exists in our -v11 resources.
+ final MenuItem tools = menu.findItem(R.id.tools);
+ destination = tools != null ? tools.getSubMenu() : menu;
+ } else {
+ final MenuItem parent = menu.findItem(info.parent);
+ if (parent == null) {
+ return;
+ }
+
+ Menu parentMenu = findParentMenu(menu, parent);
+
+ if (!parent.hasSubMenu()) {
+ parentMenu.removeItem(parent.getItemId());
+ destination = parentMenu.addSubMenu(Menu.NONE, parent.getItemId(), Menu.NONE, parent.getTitle());
+ if (parent.getIcon() != null) {
+ ((SubMenu) destination).getItem().setIcon(parent.getIcon());
+ }
+ } else {
+ destination = parent.getSubMenu();
+ }
+ }
+
+ final MenuItem item = destination.add(Menu.NONE, info.id, Menu.NONE, info.label);
+
+ item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ GeckoAppShell.notifyObservers("Menu:Clicked", Integer.toString(info.id - ADDON_MENU_OFFSET));
+ return true;
+ }
+ });
+
+ item.setCheckable(info.checkable);
+ item.setChecked(info.checked);
+ item.setEnabled(info.enabled);
+ item.setVisible(info.visible);
+ }
+
+ private void addAddonMenuItem(final MenuItemInfo info) {
+ if (mAddonMenuItemsCache == null) {
+ mAddonMenuItemsCache = new Vector<MenuItemInfo>();
+ }
+
+ // Mark it as added if the menu was ready.
+ info.added = (mMenu != null);
+
+ // Always cache so we can rebuild after a locale switch.
+ mAddonMenuItemsCache.add(info);
+
+ if (mMenu == null) {
+ return;
+ }
+
+ addAddonMenuItemToMenu(mMenu, info);
+ }
+
+ private void removeAddonMenuItem(int id) {
+ // Remove add-on menu item from cache, if available.
+ if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+ for (MenuItemInfo item : mAddonMenuItemsCache) {
+ if (item.id == id) {
+ mAddonMenuItemsCache.remove(item);
+ break;
+ }
+ }
+ }
+
+ if (mMenu == null)
+ return;
+
+ final MenuItem menuItem = mMenu.findItem(id);
+ if (menuItem != null)
+ mMenu.removeItem(id);
+ }
+
+ private void updateAddonMenuItem(int id, JSONObject options) {
+ // Set attribute for the menu item in cache, if available
+ if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+ for (MenuItemInfo item : mAddonMenuItemsCache) {
+ if (item.id == id) {
+ item.label = options.optString("name", item.label);
+ item.checkable = options.optBoolean("checkable", item.checkable);
+ item.checked = options.optBoolean("checked", item.checked);
+ item.enabled = options.optBoolean("enabled", item.enabled);
+ item.visible = options.optBoolean("visible", item.visible);
+ item.added = (mMenu != null);
+ break;
+ }
+ }
+ }
+
+ if (mMenu == null) {
+ return;
+ }
+
+ final MenuItem menuItem = mMenu.findItem(id);
+ if (menuItem != null) {
+ menuItem.setTitle(options.optString("name", menuItem.getTitle().toString()));
+ menuItem.setCheckable(options.optBoolean("checkable", menuItem.isCheckable()));
+ menuItem.setChecked(options.optBoolean("checked", menuItem.isChecked()));
+ menuItem.setEnabled(options.optBoolean("enabled", menuItem.isEnabled()));
+ menuItem.setVisible(options.optBoolean("visible", menuItem.isVisible()));
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ // Sets mMenu = menu.
+ super.onCreateOptionsMenu(menu);
+
+ // Inform the menu about the action-items bar.
+ if (menu instanceof GeckoMenu &&
+ HardwareUtils.isTablet()) {
+ ((GeckoMenu) menu).setActionItemBarPresenter(mBrowserToolbar);
+ }
+
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.browser_app_menu, mMenu);
+
+ // Add add-on menu items, if any exist.
+ if (mAddonMenuItemsCache != null && !mAddonMenuItemsCache.isEmpty()) {
+ for (MenuItemInfo item : mAddonMenuItemsCache) {
+ addAddonMenuItemToMenu(mMenu, item);
+ }
+ }
+
+ // Action providers are available only ICS+.
+ GeckoMenuItem share = (GeckoMenuItem) mMenu.findItem(R.id.share);
+
+ GeckoActionProvider provider = GeckoActionProvider.getForType(GeckoActionProvider.DEFAULT_MIME_TYPE, this);
+
+ share.setActionProvider(provider);
+
+ return true;
+ }
+
+ @Override
+ public void openOptionsMenu() {
+ hideFirstrunPager(TelemetryContract.Method.MENU);
+
+ // Disable menu access (for hardware buttons) when the software menu button is inaccessible.
+ // Note that the software button is always accessible on new tablet.
+ if (mBrowserToolbar.isEditing() && !HardwareUtils.isTablet()) {
+ return;
+ }
+
+ if (ActivityUtils.isFullScreen(this)) {
+ return;
+ }
+
+ if (areTabsShown()) {
+ mTabsPanel.showMenu();
+ return;
+ }
+
+ // Scroll custom menu to the top
+ if (mMenuPanel != null)
+ mMenuPanel.scrollTo(0, 0);
+
+ // Scroll menu ListView (potentially in MenuPanel ViewGroup) to top.
+ if (mMenu instanceof GeckoMenu) {
+ ((GeckoMenu) mMenu).setSelection(0);
+ }
+
+ if (!mBrowserToolbar.openOptionsMenu())
+ super.openOptionsMenu();
+
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setVisible(true, VisibilityTransition.ANIMATE);
+ }
+ }
+
+ @Override
+ public void closeOptionsMenu() {
+ if (!mBrowserToolbar.closeOptionsMenu())
+ super.closeOptionsMenu();
+ }
+
+ @Override
+ public void setFullScreen(final boolean fullscreen) {
+ super.setFullScreen(fullscreen);
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (fullscreen) {
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setVisible(false, VisibilityTransition.IMMEDIATE);
+ mDynamicToolbar.setPinned(true, PinReason.FULL_SCREEN);
+ } else {
+ setToolbarMargin(0);
+ }
+ mBrowserChrome.setVisibility(View.GONE);
+ } else {
+ mBrowserChrome.setVisibility(View.VISIBLE);
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setPinned(false, PinReason.FULL_SCREEN);
+ mDynamicToolbar.setVisible(true, VisibilityTransition.IMMEDIATE);
+ } else {
+ setToolbarMargin(mBrowserChrome.getHeight());
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu aMenu) {
+ if (aMenu == null)
+ return false;
+
+ // Hide the tab history panel when hardware menu button is pressed.
+ TabHistoryFragment frag = (TabHistoryFragment) getSupportFragmentManager().findFragmentByTag(TAB_HISTORY_FRAGMENT_TAG);
+ if (frag != null) {
+ frag.dismiss();
+ }
+
+ if (!GeckoThread.isRunning()) {
+ aMenu.findItem(R.id.settings).setEnabled(false);
+ aMenu.findItem(R.id.help).setEnabled(false);
+ }
+
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ // Unlike other menu items, the bookmark star is not tinted. See {@link ThemedImageButton#setTintedDrawable}.
+ final MenuItem bookmark = aMenu.findItem(R.id.bookmark);
+ final MenuItem back = aMenu.findItem(R.id.back);
+ final MenuItem forward = aMenu.findItem(R.id.forward);
+ final MenuItem share = aMenu.findItem(R.id.share);
+ final MenuItem bookmarksList = aMenu.findItem(R.id.bookmarks_list);
+ final MenuItem historyList = aMenu.findItem(R.id.history_list);
+ final MenuItem saveAsPDF = aMenu.findItem(R.id.save_as_pdf);
+ final MenuItem print = aMenu.findItem(R.id.print);
+ final MenuItem charEncoding = aMenu.findItem(R.id.char_encoding);
+ final MenuItem findInPage = aMenu.findItem(R.id.find_in_page);
+ final MenuItem desktopMode = aMenu.findItem(R.id.desktop_mode);
+ final MenuItem enterGuestMode = aMenu.findItem(R.id.new_guest_session);
+ final MenuItem exitGuestMode = aMenu.findItem(R.id.exit_guest_session);
+
+ // Only show the "Quit" menu item on pre-ICS, television devices,
+ // or if the user has explicitly enabled the clear on shutdown pref.
+ // (We check the pref last to save the pref read.)
+ // In ICS+, it's easy to kill an app through the task switcher.
+ final boolean visible = HardwareUtils.isTelevision() ||
+ !PrefUtils.getStringSet(GeckoSharedPrefs.forProfile(this),
+ ClearOnShutdownPref.PREF,
+ new HashSet<String>()).isEmpty();
+ aMenu.findItem(R.id.quit).setVisible(visible);
+
+ // If tab data is unavailable we disable most of the context menu and related items and
+ // return early.
+ if (tab == null || tab.getURL() == null) {
+ bookmark.setEnabled(false);
+ back.setEnabled(false);
+ forward.setEnabled(false);
+ share.setEnabled(false);
+ saveAsPDF.setEnabled(false);
+ print.setEnabled(false);
+ findInPage.setEnabled(false);
+
+ // NOTE: Use MenuUtils.safeSetEnabled because some actions might
+ // be on the BrowserToolbar context menu.
+ MenuUtils.safeSetEnabled(aMenu, R.id.page, false);
+ MenuUtils.safeSetEnabled(aMenu, R.id.subscribe, false);
+ MenuUtils.safeSetEnabled(aMenu, R.id.add_search_engine, false);
+ MenuUtils.safeSetEnabled(aMenu, R.id.add_to_launcher, false);
+
+ return true;
+ }
+
+ // If tab data IS available we need to manually enable items as necessary. They may have
+ // been disabled if returning early above, hence every item must be toggled, even if it's
+ // always expected to be enabled (e.g. the bookmark star is always enabled, except when
+ // we don't have tab data).
+
+ final boolean inGuestMode = GeckoProfile.get(this).inGuestMode();
+
+ bookmark.setEnabled(true); // Might have been disabled above, ensure it's reenabled
+ bookmark.setVisible(!inGuestMode);
+ bookmark.setCheckable(true);
+ bookmark.setChecked(tab.isBookmark());
+ bookmark.setTitle(resolveBookmarkTitleID(tab.isBookmark()));
+
+ // We don't use icons on GB builds so not resolving icons might conserve resources.
+ bookmark.setIcon(resolveBookmarkIconID(tab.isBookmark()));
+
+ back.setEnabled(tab.canDoBack());
+ forward.setEnabled(tab.canDoForward());
+ desktopMode.setChecked(tab.getDesktopMode());
+
+ View backButtonView = MenuItemCompat.getActionView(back);
+
+ if (backButtonView != null) {
+ backButtonView.setOnLongClickListener(new Button.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ closeOptionsMenu();
+ return tabHistoryController.showTabHistory(tab,
+ TabHistoryController.HistoryAction.BACK);
+ }
+ return false;
+ }
+ });
+ }
+
+ View forwardButtonView = MenuItemCompat.getActionView(forward);
+
+ if (forwardButtonView != null) {
+ forwardButtonView.setOnLongClickListener(new Button.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ closeOptionsMenu();
+ return tabHistoryController.showTabHistory(tab,
+ TabHistoryController.HistoryAction.FORWARD);
+ }
+ return false;
+ }
+ });
+ }
+
+ String url = tab.getURL();
+ if (AboutPages.isAboutReader(url)) {
+ url = ReaderModeUtils.stripAboutReaderUrl(url);
+ }
+
+ // Disable share menuitem for about:, chrome:, file:, and resource: URIs
+ final boolean shareVisible = Restrictions.isAllowed(this, Restrictable.SHARE);
+ share.setVisible(shareVisible);
+ final boolean shareEnabled = StringUtils.isShareableUrl(url) && shareVisible;
+ share.setEnabled(shareEnabled);
+ MenuUtils.safeSetEnabled(aMenu, R.id.downloads, Restrictions.isAllowed(this, Restrictable.DOWNLOAD));
+
+ // NOTE: Use MenuUtils.safeSetEnabled because some actions might
+ // be on the BrowserToolbar context menu.
+ MenuUtils.safeSetEnabled(aMenu, R.id.page, !isAboutHome(tab));
+ MenuUtils.safeSetEnabled(aMenu, R.id.subscribe, tab.hasFeeds());
+ MenuUtils.safeSetEnabled(aMenu, R.id.add_search_engine, tab.hasOpenSearch());
+ MenuUtils.safeSetEnabled(aMenu, R.id.add_to_launcher, !isAboutHome(tab));
+
+ // This provider also applies to the quick share menu item.
+ final GeckoActionProvider provider = ((GeckoMenuItem) share).getGeckoActionProvider();
+ if (provider != null) {
+ Intent shareIntent = provider.getIntent();
+
+ // For efficiency, the provider's intent is only set once
+ if (shareIntent == null) {
+ shareIntent = new Intent(Intent.ACTION_SEND);
+ shareIntent.setType("text/plain");
+ provider.setIntent(shareIntent);
+ }
+
+ // Replace the existing intent's extras
+ shareIntent.putExtra(Intent.EXTRA_TEXT, url);
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, tab.getDisplayTitle());
+ shareIntent.putExtra(Intent.EXTRA_TITLE, tab.getDisplayTitle());
+ shareIntent.putExtra(ShareDialog.INTENT_EXTRA_DEVICES_ONLY, true);
+
+ // Clear the existing thumbnail extras so we don't share an old thumbnail.
+ shareIntent.removeExtra("share_screenshot_uri");
+
+ // Include the thumbnail of the page being shared.
+ BitmapDrawable drawable = tab.getThumbnail();
+ if (drawable != null) {
+ Bitmap thumbnail = drawable.getBitmap();
+
+ // Kobo uses a custom intent extra for sharing thumbnails.
+ if (Build.MANUFACTURER.equals("Kobo") && thumbnail != null) {
+ File cacheDir = getExternalCacheDir();
+
+ if (cacheDir != null) {
+ File outFile = new File(cacheDir, "thumbnail.png");
+
+ try {
+ final java.io.FileOutputStream out = new java.io.FileOutputStream(outFile);
+ try {
+ thumbnail.compress(Bitmap.CompressFormat.PNG, 90, out);
+ } finally {
+ try {
+ out.close();
+ } catch (final IOException e) { /* Nothing to do here. */ }
+ }
+ } catch (FileNotFoundException e) {
+ Log.e(LOGTAG, "File not found", e);
+ }
+
+ shareIntent.putExtra("share_screenshot_uri", Uri.parse(outFile.getPath()));
+ }
+ }
+ }
+ }
+
+ final boolean privateTabVisible = Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING);
+ MenuUtils.safeSetVisible(aMenu, R.id.new_private_tab, privateTabVisible);
+
+ // Disable PDF generation (save and print) for about:home and xul pages.
+ boolean allowPDF = (!(isAboutHome(tab) ||
+ tab.getContentType().equals("application/vnd.mozilla.xul+xml") ||
+ tab.getContentType().startsWith("video/")));
+ saveAsPDF.setEnabled(allowPDF);
+ print.setEnabled(allowPDF);
+ print.setVisible(Versions.feature19Plus);
+
+ // Disable find in page for about:home, since it won't work on Java content.
+ findInPage.setEnabled(!isAboutHome(tab));
+
+ charEncoding.setVisible(GeckoPreferences.getCharEncodingState());
+
+ if (getProfile().inGuestMode()) {
+ exitGuestMode.setVisible(true);
+ } else {
+ enterGuestMode.setVisible(true);
+ }
+
+ if (!Restrictions.isAllowed(this, Restrictable.GUEST_BROWSING)) {
+ MenuUtils.safeSetVisible(aMenu, R.id.new_guest_session, false);
+ }
+
+ if (!Restrictions.isAllowed(this, Restrictable.INSTALL_EXTENSION)) {
+ MenuUtils.safeSetVisible(aMenu, R.id.addons, false);
+ }
+
+ // Hide panel menu items if the panels themselves are hidden.
+ // If we don't know whether the panels are hidden, just show the menu items.
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext());
+ bookmarksList.setVisible(prefs.getBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, true));
+ historyList.setVisible(prefs.getBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, true));
+
+ return true;
+ }
+
+ private int resolveBookmarkIconID(final boolean isBookmark) {
+ if (isBookmark) {
+ return R.drawable.star_blue;
+ } else {
+ return R.drawable.ic_menu_bookmark_add;
+ }
+ }
+
+ private int resolveBookmarkTitleID(final boolean isBookmark) {
+ return (isBookmark ? R.string.bookmark_remove : R.string.bookmark);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ Tab tab = null;
+ Intent intent = null;
+
+ final int itemId = item.getItemId();
+
+ // Track the menu action. We don't know much about the context, but we can use this to determine
+ // the frequency of use for various actions.
+ String extras = getResources().getResourceEntryName(itemId);
+ if (TextUtils.equals(extras, "new_private_tab")) {
+ // Mask private browsing
+ extras = "new_tab";
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, extras);
+
+ mBrowserToolbar.cancelEdit();
+
+ if (itemId == R.id.bookmark) {
+ tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ final String extra;
+ if (AboutPages.isAboutReader(tab.getURL())) {
+ extra = "bookmark_reader";
+ } else {
+ extra = "bookmark";
+ }
+
+ if (item.isChecked()) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.MENU, extra);
+ tab.removeBookmark();
+ item.setTitle(resolveBookmarkTitleID(false));
+ item.setIcon(resolveBookmarkIconID(false));
+ } else {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, extra);
+ tab.addBookmark();
+ item.setTitle(resolveBookmarkTitleID(true));
+ item.setIcon(resolveBookmarkIconID(true));
+ }
+ }
+ return true;
+ }
+
+ if (itemId == R.id.share) {
+ tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ String url = tab.getURL();
+ if (url != null) {
+ url = ReaderModeUtils.stripAboutReaderUrl(url);
+
+ // Context: Sharing via chrome list (no explicit session is active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "menu");
+
+ IntentHelper.openUriExternal(url, "text/plain", "", "", Intent.ACTION_SEND, tab.getDisplayTitle(), false);
+ }
+ }
+ return true;
+ }
+
+ if (itemId == R.id.reload) {
+ tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null)
+ tab.doReload(false);
+ return true;
+ }
+
+ if (itemId == R.id.back) {
+ tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null)
+ tab.doBack();
+ return true;
+ }
+
+ if (itemId == R.id.forward) {
+ tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null)
+ tab.doForward();
+ return true;
+ }
+
+ if (itemId == R.id.bookmarks_list) {
+ final String url = AboutPages.getURLForBuiltinPanelType(PanelType.BOOKMARKS);
+ Tabs.getInstance().loadUrl(url);
+ return true;
+ }
+
+ if (itemId == R.id.history_list) {
+ final String url = AboutPages.getURLForBuiltinPanelType(PanelType.COMBINED_HISTORY);
+ Tabs.getInstance().loadUrl(url);
+ return true;
+ }
+
+ if (itemId == R.id.save_as_pdf) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "pdf");
+ GeckoAppShell.notifyObservers("SaveAs:PDF", null);
+ return true;
+ }
+
+ if (itemId == R.id.print) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.MENU, "print");
+ PrintHelper.printPDF(this);
+ return true;
+ }
+
+ if (itemId == R.id.settings) {
+ intent = new Intent(this, GeckoPreferences.class);
+
+ // We want to know when the Settings activity returns, because
+ // we might need to redisplay based on a locale change.
+ startActivityForResult(intent, ACTIVITY_REQUEST_PREFERENCES);
+ return true;
+ }
+
+ if (itemId == R.id.help) {
+ final String VERSION = AppConstants.MOZ_APP_VERSION;
+ final String OS = AppConstants.OS_TARGET;
+ final String LOCALE = Locales.getLanguageTag(Locale.getDefault());
+
+ final String URL = getResources().getString(R.string.help_link, VERSION, OS, LOCALE);
+ Tabs.getInstance().loadUrlInTab(URL);
+ return true;
+ }
+
+ if (itemId == R.id.addons) {
+ Tabs.getInstance().loadUrlInTab(AboutPages.ADDONS);
+ return true;
+ }
+
+ if (itemId == R.id.logins) {
+ Tabs.getInstance().loadUrlInTab(AboutPages.LOGINS);
+ return true;
+ }
+
+ if (itemId == R.id.downloads) {
+ Tabs.getInstance().loadUrlInTab(AboutPages.DOWNLOADS);
+ return true;
+ }
+
+ if (itemId == R.id.char_encoding) {
+ GeckoAppShell.notifyObservers("CharEncoding:Get", null);
+ return true;
+ }
+
+ if (itemId == R.id.find_in_page) {
+ mFindInPageBar.show();
+ return true;
+ }
+
+ if (itemId == R.id.desktop_mode) {
+ Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab == null)
+ return true;
+ JSONObject args = new JSONObject();
+ try {
+ args.put("desktopMode", !item.isChecked());
+ args.put("tabId", selectedTab.getId());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "error building json arguments", e);
+ }
+ GeckoAppShell.notifyObservers("DesktopMode:Change", args.toString());
+ return true;
+ }
+
+ if (itemId == R.id.new_tab) {
+ addTab();
+ return true;
+ }
+
+ if (itemId == R.id.new_private_tab) {
+ addPrivateTab();
+ return true;
+ }
+
+ if (itemId == R.id.new_guest_session) {
+ showGuestModeDialog(GuestModeDialog.ENTERING);
+ return true;
+ }
+
+ if (itemId == R.id.exit_guest_session) {
+ showGuestModeDialog(GuestModeDialog.LEAVING);
+ return true;
+ }
+
+ // We have a few menu items that can also be in the context menu. If
+ // we have not already handled the item, give the context menu handler
+ // a chance.
+ if (onContextItemSelected(item)) {
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onMenuItemLongClick(MenuItem item) {
+ if (item.getItemId() == R.id.reload) {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ tab.doReload(true);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "reload_force");
+ }
+ return true;
+ }
+
+ return super.onMenuItemLongClick(item);
+ }
+
+ public void showGuestModeDialog(final GuestModeDialog type) {
+ if ((type == GuestModeDialog.ENTERING) == getProfile().inGuestMode()) {
+ // Don't show enter dialog if we are already in guest mode; same with leaving.
+ return;
+ }
+
+ final Prompt ps = new Prompt(this, new Prompt.PromptCallback() {
+ @Override
+ public void onPromptFinished(String result) {
+ try {
+ int itemId = new JSONObject(result).getInt("button");
+ if (itemId == 0) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ if (type == GuestModeDialog.ENTERING) {
+ GeckoProfile.enterGuestMode(context);
+ } else {
+ GeckoProfile.leaveGuestMode(context);
+ // Now's a good time to make sure we're not displaying the
+ // Guest Browsing notification.
+ GuestSession.hideNotification(context);
+ }
+ doRestart();
+ }
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Exception reading guest mode prompt result", ex);
+ }
+ }
+ });
+
+ Resources res = getResources();
+ ps.setButtons(new String[] {
+ res.getString(R.string.guest_session_dialog_continue),
+ res.getString(R.string.guest_session_dialog_cancel)
+ });
+
+ int titleString = 0;
+ int msgString = 0;
+ if (type == GuestModeDialog.ENTERING) {
+ titleString = R.string.new_guest_session_title;
+ msgString = R.string.new_guest_session_text;
+ } else {
+ titleString = R.string.exit_guest_session_title;
+ msgString = R.string.exit_guest_session_text;
+ }
+
+ ps.show(res.getString(titleString), res.getString(msgString), null, ListView.CHOICE_MODE_NONE);
+ }
+
+ /**
+ * Handle a long press on the back button
+ */
+ private boolean handleBackLongPress() {
+ // If the tab search history is already shown, do nothing.
+ TabHistoryFragment frag = (TabHistoryFragment) getSupportFragmentManager().findFragmentByTag(TAB_HISTORY_FRAGMENT_TAG);
+ if (frag != null) {
+ return true;
+ }
+
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null && !tab.isEditing()) {
+ return tabHistoryController.showTabHistory(tab, TabHistoryController.HistoryAction.ALL);
+ }
+
+ return false;
+ }
+
+ /**
+ * This will detect if the key pressed is back. If so, will show the history.
+ */
+ @Override
+ public boolean onKeyLongPress(int keyCode, KeyEvent event) {
+ // onKeyLongPress is broken in Android N, see onKeyDown() for more information. We add a version
+ // check here to match our fallback code in order to avoid handling a long press twice (which
+ // could happen if newer versions of android and/or other vendors were to fix this problem).
+ if (Versions.preN &&
+ keyCode == KeyEvent.KEYCODE_BACK) {
+ if (handleBackLongPress()) {
+ return true;
+ }
+
+ }
+ return super.onKeyLongPress(keyCode, event);
+ }
+
+ /*
+ * If the app has been launched a certain number of times, and we haven't asked for feedback before,
+ * open a new tab with about:feedback when launching the app from the icon shortcut.
+ */
+ @Override
+ protected void onNewIntent(Intent externalIntent) {
+ final SafeIntent intent = new SafeIntent(externalIntent);
+ String action = intent.getAction();
+
+ final boolean isViewAction = Intent.ACTION_VIEW.equals(action);
+ final boolean isBookmarkAction = GeckoApp.ACTION_HOMESCREEN_SHORTCUT.equals(action);
+ final boolean isTabQueueAction = TabQueueHelper.LOAD_URLS_ACTION.equals(action);
+ final boolean isViewMultipleAction = ACTION_VIEW_MULTIPLE.equals(action);
+
+ if (mInitialized && (isViewAction || isBookmarkAction)) {
+ // Dismiss editing mode if the user is loading a URL from an external app.
+ mBrowserToolbar.cancelEdit();
+
+ // Hide firstrun-pane if the user is loading a URL from an external app.
+ hideFirstrunPager(TelemetryContract.Method.NONE);
+
+ if (isBookmarkAction) {
+ // GeckoApp.ACTION_HOMESCREEN_SHORTCUT means we're opening a bookmark that
+ // was added to Android's homescreen.
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.HOMESCREEN);
+ }
+ }
+
+ showTabQueuePromptIfApplicable(intent);
+
+ // GeckoApp will wrap this unsafe external intent in a SafeIntent.
+ super.onNewIntent(externalIntent);
+
+ if (AppConstants.MOZ_ANDROID_BEAM && NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) {
+ String uri = intent.getDataString();
+ mLayerView.loadUri(uri, GeckoView.LOAD_NEW_TAB);
+ }
+
+ // Only solicit feedback when the app has been launched from the icon shortcut.
+ if (GuestSession.NOTIFICATION_INTENT.equals(action)) {
+ GuestSession.onNotificationIntentReceived(this);
+ }
+
+ // If the user has clicked the tab queue notification then load the tabs.
+ if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized && isTabQueueAction) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, "tabqueue");
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ openQueuedTabs();
+ }
+ });
+ }
+
+ // Custom intent action for opening multiple URLs at once
+ if (isViewMultipleAction) {
+ openMultipleTabsFromIntent(intent);
+ }
+
+ for (final BrowserAppDelegate delegate : delegates) {
+ delegate.onNewIntent(this, intent);
+ }
+
+ if (!mInitialized || !Intent.ACTION_MAIN.equals(action)) {
+ return;
+ }
+
+ // Check to see how many times the app has been launched.
+ final String keyName = getPackageName() + ".feedback_launch_count";
+ final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+
+ // Faster on main thread with an async apply().
+ try {
+ SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE);
+ int launchCount = settings.getInt(keyName, 0);
+ if (launchCount < FEEDBACK_LAUNCH_COUNT) {
+ // Increment the launch count and store the new value.
+ launchCount++;
+ settings.edit().putInt(keyName, launchCount).apply();
+
+ // If we've reached our magic number, show the feedback page.
+ if (launchCount == FEEDBACK_LAUNCH_COUNT) {
+ GeckoAppShell.notifyObservers("Feedback:Show", null);
+ }
+ }
+ } finally {
+ StrictMode.setThreadPolicy(savedPolicy);
+ }
+ }
+
+ public void openUrls(List<String> urls) {
+ try {
+ JSONArray array = new JSONArray();
+ for (String url : urls) {
+ array.put(url);
+ }
+
+ JSONObject object = new JSONObject();
+ object.put("urls", array);
+
+ GeckoAppShell.notifyObservers("Tabs:OpenMultiple", object.toString());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Unable to create JSON for opening multiple URLs");
+ }
+ }
+
+ private void showTabQueuePromptIfApplicable(final SafeIntent intent) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // We only want to show the prompt if the browser has been opened from an external url
+ if (TabQueueHelper.TAB_QUEUE_ENABLED && mInitialized
+ && Intent.ACTION_VIEW.equals(intent.getAction())
+ && !intent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false)
+ && TabQueueHelper.shouldShowTabQueuePrompt(BrowserApp.this)) {
+ Intent promptIntent = new Intent(BrowserApp.this, TabQueuePrompt.class);
+ startActivityForResult(promptIntent, ACTIVITY_REQUEST_TAB_QUEUE);
+ }
+ }
+ });
+ }
+
+ private void resetFeedbackLaunchCount() {
+ SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE);
+ settings.edit().putInt(getPackageName() + ".feedback_launch_count", 0).apply();
+ }
+
+ // HomePager.OnUrlOpenListener
+ @Override
+ public void onUrlOpen(String url, EnumSet<OnUrlOpenListener.Flags> flags) {
+ if (flags.contains(OnUrlOpenListener.Flags.OPEN_WITH_INTENT)) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(url));
+ startActivity(intent);
+ } else {
+ // By default this listener is used for lists where the offline reader-view icon
+ // is shown - hence we need to redirect to the reader-view page by default.
+ // However there are some cases where we might not want to use this, e.g.
+ // for topsites where we do not indicate that a page is an offline reader-view bookmark too.
+ final String pageURL;
+ if (!flags.contains(OnUrlOpenListener.Flags.NO_READER_VIEW)) {
+ pageURL = SavedReaderViewHelper.getReaderURLIfCached(getContext(), url);
+ } else {
+ pageURL = url;
+ }
+
+ if (!maybeSwitchToTab(pageURL, flags)) {
+ openUrlAndStopEditing(pageURL);
+ clearSelectedTabApplicationId();
+ }
+ }
+ }
+
+ // HomePager.OnUrlOpenInBackgroundListener
+ @Override
+ public void onUrlOpenInBackground(final String url, EnumSet<OnUrlOpenInBackgroundListener.Flags> flags) {
+ if (url == null) {
+ throw new IllegalArgumentException("url must not be null");
+ }
+ if (flags == null) {
+ throw new IllegalArgumentException("flags must not be null");
+ }
+
+ // We only use onUrlOpenInBackgroundListener for the homepanel context menus, hence
+ // we should always be checking whether we want the readermode version
+ final String pageURL = SavedReaderViewHelper.getReaderURLIfCached(getContext(), url);
+
+ final boolean isPrivate = flags.contains(OnUrlOpenInBackgroundListener.Flags.PRIVATE);
+
+ int loadFlags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_BACKGROUND;
+ if (isPrivate) {
+ loadFlags |= Tabs.LOADURL_PRIVATE;
+ }
+
+ final Tab newTab = Tabs.getInstance().loadUrl(pageURL, loadFlags);
+
+ // We switch to the desired tab by unique ID, which closes any window
+ // for a race between opening the tab and closing it, and switching to
+ // it. We could also switch to the Tab explicitly, but we don't want to
+ // hold a reference to the Tab itself in the anonymous listener class.
+ final int newTabId = newTab.getId();
+
+ final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.TOAST, "switchtab");
+
+ maybeSwitchToTab(newTabId);
+ }
+ };
+
+ final String message = isPrivate ?
+ getResources().getString(R.string.new_private_tab_opened) :
+ getResources().getString(R.string.new_tab_opened);
+ final String buttonMessage = getResources().getString(R.string.switch_button_message);
+
+ SnackbarBuilder.builder(this)
+ .message(message)
+ .duration(Snackbar.LENGTH_LONG)
+ .action(buttonMessage)
+ .callback(callback)
+ .buildAndShow();
+ }
+
+ // BrowserSearch.OnSearchListener
+ @Override
+ public void onSearch(SearchEngine engine, final String text, final TelemetryContract.Method method) {
+ // Don't store searches that happen in private tabs. This assumes the user can only
+ // perform a search inside the currently selected tab, which is true for searches
+ // that come from SearchEngineRow.
+ if (!Tabs.getInstance().getSelectedTab().isPrivate()) {
+ storeSearchQuery(text);
+ }
+
+ // We don't use SearchEngine.getEngineIdentifier because it can
+ // return a custom search engine name, which is a privacy concern.
+ final String identifierToRecord = (engine.identifier != null) ? engine.identifier : "other";
+ recordSearch(GeckoSharedPrefs.forProfile(this), identifierToRecord, method);
+ openUrlAndStopEditing(text, engine.name);
+ }
+
+ // BrowserSearch.OnEditSuggestionListener
+ @Override
+ public void onEditSuggestion(String suggestion) {
+ mBrowserToolbar.onEditSuggestion(suggestion);
+ }
+
+ @Override
+ public int getLayout() { return R.layout.gecko_app; }
+
+ public SearchEngineManager getSearchEngineManager() {
+ return mSearchEngineManager;
+ }
+
+ // For use from tests only.
+ @RobocopTarget
+ public ReadingListHelper getReadingListHelper() {
+ return mReadingListHelper;
+ }
+
+ /**
+ * Launch UI that lets the user update Firefox.
+ *
+ * This depends on the current channel: Release and Beta both direct to the
+ * Google Play Store. If updating is enabled, Aurora, Nightly, and custom
+ * builds open about:, which provides an update interface.
+ *
+ * If updating is not enabled, this simply logs an error.
+ *
+ * @return true if update UI was launched.
+ */
+ protected boolean handleUpdaterLaunch() {
+ if (AppConstants.RELEASE_OR_BETA) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("market://details?id=" + getPackageName()));
+ startActivity(intent);
+ return true;
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ Tabs.getInstance().loadUrlInTab(AboutPages.UPDATER);
+ return true;
+ }
+
+ Log.w(LOGTAG, "No candidate updater found; ignoring launch request.");
+ return false;
+ }
+
+ /* Implementing ActionModeCompat.Presenter */
+ @Override
+ public void startActionModeCompat(final ActionModeCompat.Callback callback) {
+ // If actionMode is null, we're not currently showing one. Flip to the action mode view
+ if (mActionMode == null) {
+ mActionBarFlipper.showNext();
+ DynamicToolbarAnimator toolbar = mLayerView.getDynamicToolbarAnimator();
+
+ // If the toolbar is dynamic and not currently showing, just slide it in
+ if (mDynamicToolbar.isEnabled() && toolbar.getToolbarTranslation() != 0) {
+ mDynamicToolbar.setTemporarilyVisible(true, VisibilityTransition.ANIMATE);
+ }
+ mDynamicToolbar.setPinned(true, PinReason.ACTION_MODE);
+
+ } else {
+ // Otherwise, we're already showing an action mode. Just finish it and show the new one
+ mActionMode.finish();
+ }
+
+ mActionMode = new ActionModeCompat(BrowserApp.this, callback, mActionBar);
+ if (callback.onCreateActionMode(mActionMode, mActionMode.getMenu())) {
+ mActionMode.invalidate();
+ }
+ }
+
+ /* Implementing ActionModeCompat.Presenter */
+ @Override
+ public void endActionModeCompat() {
+ if (mActionMode == null) {
+ return;
+ }
+
+ mActionMode.finish();
+ mActionMode = null;
+ mDynamicToolbar.setPinned(false, PinReason.ACTION_MODE);
+
+ mActionBarFlipper.showPrevious();
+
+ // Only slide the urlbar out if it was hidden when the action mode started
+ // Don't animate hiding it so that there's no flash as we switch back to url mode
+ mDynamicToolbar.setTemporarilyVisible(false, VisibilityTransition.IMMEDIATE);
+ }
+
+ public static interface TabStripInterface {
+ public void refresh();
+ void setOnTabChangedListener(OnTabAddedOrRemovedListener listener);
+ interface OnTabAddedOrRemovedListener {
+ void onTabChanged();
+ }
+ }
+
+ @Override
+ protected void recordStartupActionTelemetry(final String passedURL, final String action) {
+ final TelemetryContract.Method method;
+ if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) {
+ // This action is also recorded via "loadurl.1" > "homescreen".
+ method = TelemetryContract.Method.HOMESCREEN;
+ } else if (passedURL == null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, TelemetryContract.Method.HOMESCREEN, "launcher");
+ method = TelemetryContract.Method.HOMESCREEN;
+ } else {
+ // This is action is also recorded via "loadurl.1" > "intent".
+ method = TelemetryContract.Method.INTENT;
+ }
+
+ if (GeckoProfile.get(this).inGuestMode()) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, method, "guest");
+ } else if (Restrictions.isRestrictedProfile(this)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, method, "restricted");
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java b/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java
new file mode 100644
index 000000000..c5c041c7a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserLocaleManager.java
@@ -0,0 +1,439 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.util.Log;
+
+/**
+ * This class manages persistence, application, and otherwise handling of
+ * user-specified locales.
+ *
+ * Of note:
+ *
+ * * It's a singleton, because its scope extends to that of the application,
+ * and definitionally all changes to the locale of the app must go through
+ * this.
+ * * It's lazy.
+ * * It has ties into the Gecko event system, because it has to tell Gecko when
+ * to switch locale.
+ * * It relies on using the SharedPreferences file owned by the browser (in
+ * Fennec's case, "GeckoApp") for performance.
+ */
+public class BrowserLocaleManager implements LocaleManager {
+ private static final String LOG_TAG = "GeckoLocales";
+
+ private static final String EVENT_LOCALE_CHANGED = "Locale:Changed";
+ private static final String PREF_LOCALE = "locale";
+
+ private static final String FALLBACK_LOCALE_TAG = "en-US";
+
+ // These are volatile because we don't impose restrictions
+ // over which thread calls our methods.
+ private volatile Locale currentLocale;
+ private volatile Locale systemLocale = Locale.getDefault();
+
+ private final AtomicBoolean inited = new AtomicBoolean(false);
+ private boolean systemLocaleDidChange;
+ private BroadcastReceiver receiver;
+
+ private static final AtomicReference<LocaleManager> instance = new AtomicReference<LocaleManager>();
+
+ @ReflectionTarget
+ public static LocaleManager getInstance() {
+ LocaleManager localeManager = instance.get();
+ if (localeManager != null) {
+ return localeManager;
+ }
+
+ localeManager = new BrowserLocaleManager();
+ if (instance.compareAndSet(null, localeManager)) {
+ return localeManager;
+ } else {
+ return instance.get();
+ }
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return AppConstants.MOZ_LOCALE_SWITCHER;
+ }
+
+ /**
+ * Ensure that you call this early in your application startup,
+ * and with a context that's sufficiently long-lived (typically
+ * the application context).
+ *
+ * Calling multiple times is harmless.
+ */
+ @Override
+ public void initialize(final Context context) {
+ if (!inited.compareAndSet(false, true)) {
+ return;
+ }
+
+ receiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final Locale current = systemLocale;
+
+ // We don't trust Locale.getDefault() here, because we make a
+ // habit of mutating it! Use the one Android supplies, because
+ // that gets regularly reset.
+ // The default value of systemLocale is fine, because we haven't
+ // yet swizzled Locale during static initialization.
+ systemLocale = context.getResources().getConfiguration().locale;
+ systemLocaleDidChange = true;
+
+ Log.d(LOG_TAG, "System locale changed from " + current + " to " + systemLocale);
+ }
+ };
+ context.registerReceiver(receiver, new IntentFilter(Intent.ACTION_LOCALE_CHANGED));
+ }
+
+ @Override
+ public boolean systemLocaleDidChange() {
+ return systemLocaleDidChange;
+ }
+
+ /**
+ * Every time the system gives us a new configuration, it
+ * carries the external locale. Fix it.
+ */
+ @Override
+ public void correctLocale(Context context, Resources res, Configuration config) {
+ final Locale current = getCurrentLocale(context);
+ if (current == null) {
+ Log.d(LOG_TAG, "No selected locale. No correction needed.");
+ return;
+ }
+
+ // I know it's tempting to short-circuit here if the config seems to be
+ // up-to-date, but the rest is necessary.
+
+ config.locale = current;
+
+ // The following two lines are heavily commented in case someone
+ // decides to chase down performance improvements and decides to
+ // question what's going on here.
+ // Both lines should be cheap, *but*...
+
+ // This is unnecessary for basic string choice, but it almost
+ // certainly comes into play when rendering numbers, deciding on RTL,
+ // etc. Take it out if you can prove that's not the case.
+ Locale.setDefault(current);
+
+ // This seems to be a no-op, but every piece of documentation under the
+ // sun suggests that it's necessary, and it certainly makes sense.
+ res.updateConfiguration(config, null);
+ }
+
+ /**
+ * We can be in one of two states.
+ *
+ * If the user has not explicitly chosen a Firefox-specific locale, we say
+ * we are "mirroring" the system locale.
+ *
+ * When we are not mirroring, system locale changes do not impact Firefox
+ * and are essentially ignored; the user's locale selection is the only
+ * thing we care about, and we actively correct incoming configuration
+ * changes to reflect the user's chosen locale.
+ *
+ * By contrast, when we are mirroring, system locale changes cause Firefox
+ * to reflect the new system locale, as if the user picked the new locale.
+ *
+ * If we're currently mirroring the system locale, this method returns the
+ * supplied configuration's locale, unless the current activity locale is
+ * correct. If we're not currently mirroring, this method updates the
+ * configuration object to match the user's currently selected locale, and
+ * returns that, unless the current activity locale is correct.
+ *
+ * If the current activity locale is correct, returns null.
+ *
+ * The caller is expected to redisplay themselves accordingly.
+ *
+ * This method is intended to be called from inside
+ * <code>onConfigurationChanged(Configuration)</code> as part of a strategy
+ * to detect and either apply or undo system locale changes.
+ */
+ @Override
+ public Locale onSystemConfigurationChanged(final Context context, final Resources resources, final Configuration configuration, final Locale currentActivityLocale) {
+ if (!isMirroringSystemLocale(context)) {
+ correctLocale(context, resources, configuration);
+ }
+
+ final Locale changed = configuration.locale;
+ if (changed.equals(currentActivityLocale)) {
+ return null;
+ }
+
+ return changed;
+ }
+
+ /**
+ * Gecko needs to know the OS locale to compute a useful Accept-Language
+ * header. If it changed since last time, send a message to Gecko and
+ * persist the new value. If unchanged, returns immediately.
+ *
+ * @param prefs the SharedPreferences instance to use. Cannot be null.
+ * @param osLocale the new locale instance. Safe if null.
+ */
+ public static void storeAndNotifyOSLocale(final SharedPreferences prefs,
+ final Locale osLocale) {
+ if (osLocale == null) {
+ return;
+ }
+
+ final String lastOSLocale = prefs.getString("osLocale", null);
+ final String osLocaleString = osLocale.toString();
+
+ if (osLocaleString.equals(lastOSLocale)) {
+ return;
+ }
+
+ // Store the Java-native form.
+ prefs.edit().putString("osLocale", osLocaleString).apply();
+
+ // The value we send to Gecko should be a language tag, not
+ // a Java locale string.
+ final String osLanguageTag = Locales.getLanguageTag(osLocale);
+ GeckoAppShell.notifyObservers("Locale:OS", osLanguageTag);
+ }
+
+ @Override
+ public String getAndApplyPersistedLocale(Context context) {
+ initialize(context);
+
+ final long t1 = android.os.SystemClock.uptimeMillis();
+ final String localeCode = getPersistedLocale(context);
+ if (localeCode == null) {
+ return null;
+ }
+
+ // Note that we don't tell Gecko about this. We notify Gecko when the
+ // locale is set, not when we update Java.
+ final String resultant = updateLocale(context, localeCode);
+
+ if (resultant == null) {
+ // Update the configuration anyway.
+ updateConfiguration(context, currentLocale);
+ }
+
+ final long t2 = android.os.SystemClock.uptimeMillis();
+ Log.i(LOG_TAG, "Locale read and update took: " + (t2 - t1) + "ms.");
+ return resultant;
+ }
+
+ /**
+ * Returns the set locale if it changed.
+ *
+ * Always persists and notifies Gecko.
+ */
+ @Override
+ public String setSelectedLocale(Context context, String localeCode) {
+ final String resultant = updateLocale(context, localeCode);
+
+ // We always persist and notify Gecko, even if nothing seemed to
+ // change. This might happen if you're picking a locale that's the same
+ // as the current OS locale. The OS locale might change next time we
+ // launch, and we need the Gecko pref and persisted locale to have been
+ // set by the time that happens.
+ persistLocale(context, localeCode);
+
+ // Tell Gecko.
+ GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, Locales.getLanguageTag(getCurrentLocale(context)));
+
+ return resultant;
+ }
+
+ @Override
+ public void resetToSystemLocale(Context context) {
+ // Wipe the pref.
+ final SharedPreferences settings = getSharedPreferences(context);
+ settings.edit().remove(PREF_LOCALE).apply();
+
+ // Apply the system locale.
+ updateLocale(context, systemLocale);
+
+ // Tell Gecko.
+ GeckoAppShell.notifyObservers(EVENT_LOCALE_CHANGED, "");
+ }
+
+ /**
+ * This is public to allow for an activity to force the
+ * current locale to be applied if necessary (e.g., when
+ * a new activity launches).
+ */
+ @Override
+ public void updateConfiguration(Context context, Locale locale) {
+ Resources res = context.getResources();
+ Configuration config = res.getConfiguration();
+
+ // We should use setLocale, but it's unexpectedly missing
+ // on real devices.
+ config.locale = locale;
+ res.updateConfiguration(config, null);
+ }
+
+ private SharedPreferences getSharedPreferences(Context context) {
+ return GeckoSharedPrefs.forApp(context);
+ }
+
+ /**
+ * @return the persisted locale in Java format: "en_US".
+ */
+ private String getPersistedLocale(Context context) {
+ final SharedPreferences settings = getSharedPreferences(context);
+ final String locale = settings.getString(PREF_LOCALE, "");
+
+ if ("".equals(locale)) {
+ return null;
+ }
+ return locale;
+ }
+
+ private void persistLocale(Context context, String localeCode) {
+ final SharedPreferences settings = getSharedPreferences(context);
+ settings.edit().putString(PREF_LOCALE, localeCode).apply();
+ }
+
+ @Override
+ public Locale getCurrentLocale(Context context) {
+ if (currentLocale != null) {
+ return currentLocale;
+ }
+
+ final String current = getPersistedLocale(context);
+ if (current == null) {
+ return null;
+ }
+ return currentLocale = Locales.parseLocaleCode(current);
+ }
+
+ /**
+ * Updates the Java locale and the Android configuration.
+ *
+ * Returns the persisted locale if it differed.
+ *
+ * Does not notify Gecko.
+ *
+ * @param localeCode a locale string in Java format: "en_US".
+ * @return if it differed, a locale string in Java format: "en_US".
+ */
+ private String updateLocale(Context context, String localeCode) {
+ // Fast path.
+ final Locale defaultLocale = Locale.getDefault();
+ if (defaultLocale.toString().equals(localeCode)) {
+ return null;
+ }
+
+ final Locale locale = Locales.parseLocaleCode(localeCode);
+
+ return updateLocale(context, locale);
+ }
+
+ /**
+ * @return the Java locale string: e.g., "en_US".
+ */
+ private String updateLocale(Context context, final Locale locale) {
+ // Fast path.
+ if (Locale.getDefault().equals(locale)) {
+ return null;
+ }
+
+ Locale.setDefault(locale);
+ currentLocale = locale;
+
+ // Update resources.
+ updateConfiguration(context, locale);
+
+ return locale.toString();
+ }
+
+ private boolean isMirroringSystemLocale(final Context context) {
+ return getPersistedLocale(context) == null;
+ }
+
+ /**
+ * Examines <code>multilocale.json</code>, returning the included list of
+ * locale codes.
+ *
+ * If <code>multilocale.json</code> is not present, returns
+ * <code>null</code>. In that case, consider {@link #getFallbackLocaleTag()}.
+ *
+ * multilocale.json currently looks like this:
+ *
+ * <code>
+ * {"locales": ["en-US", "be", "ca", "cs", "da", "de", "en-GB",
+ * "en-ZA", "es-AR", "es-ES", "es-MX", "et", "fi",
+ * "fr", "ga-IE", "hu", "id", "it", "ja", "ko",
+ * "lt", "lv", "nb-NO", "nl", "pl", "pt-BR",
+ * "pt-PT", "ro", "ru", "sk", "sl", "sv-SE", "th",
+ * "tr", "uk", "zh-CN", "zh-TW", "en-US"]}
+ * </code>
+ */
+ public static Collection<String> getPackagedLocaleTags(final Context context) {
+ final String resPath = "res/multilocale.json";
+ final String jarURL = GeckoJarReader.getJarURL(context, resPath);
+
+ final String contents = GeckoJarReader.getText(context, jarURL);
+ if (contents == null) {
+ // GeckoJarReader logs and swallows exceptions.
+ return null;
+ }
+
+ try {
+ final JSONObject multilocale = new JSONObject(contents);
+ final JSONArray locales = multilocale.getJSONArray("locales");
+ if (locales == null) {
+ Log.e(LOG_TAG, "No 'locales' array in multilocales.json!");
+ return null;
+ }
+
+ final Set<String> out = new HashSet<String>(locales.length());
+ for (int i = 0; i < locales.length(); ++i) {
+ // If any item in the array is invalid, this will throw,
+ // and the entire clause will fail, being caught below
+ // and returning null.
+ out.add(locales.getString(i));
+ }
+
+ return out;
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Unable to parse multilocale.json.", e);
+ return null;
+ }
+ }
+
+ /**
+ * @return the single default locale baked into this application.
+ * Applicable when there is no multilocale.json present.
+ */
+ @SuppressWarnings("static-method")
+ public String getFallbackLocaleTag() {
+ return FALLBACK_LOCALE_TAG;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java b/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java
new file mode 100644
index 000000000..cff6ea643
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastDisplay.java
@@ -0,0 +1,112 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.EventCallback;
+
+import com.google.android.gms.cast.CastDevice;
+import com.google.android.gms.cast.CastRemoteDisplayLocalService;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GooglePlayServicesUtil;
+import com.google.android.gms.common.api.Status;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.support.v7.media.MediaRouter.RouteInfo;
+import android.util.Log;
+
+public class ChromeCastDisplay implements GeckoPresentationDisplay {
+
+ static final String REMOTE_DISPLAY_APP_ID = "4574A331";
+
+ private static final String LOGTAG = "GeckoChromeCastDisplay";
+ private final Context context;
+ private final RouteInfo route;
+ private CastDevice castDevice;
+
+ public ChromeCastDisplay(Context context, RouteInfo route) {
+ int status = GooglePlayServicesUtil.isGooglePlayServicesAvailable(context);
+ if (status != ConnectionResult.SUCCESS) {
+ throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")");
+ }
+
+ this.context = context;
+ this.route = route;
+ this.castDevice = CastDevice.getFromBundle(route.getExtras());
+ }
+
+ public JSONObject toJSON() {
+ final JSONObject obj = new JSONObject();
+ try {
+ if (castDevice == null) {
+ return null;
+ }
+ obj.put("uuid", route.getId());
+ obj.put("friendlyName", castDevice.getFriendlyName());
+ obj.put("type", "chromecast");
+ } catch (JSONException ex) {
+ Log.d(LOGTAG, "Error building route", ex);
+ }
+
+ return obj;
+ }
+
+ @Override
+ public void start(final EventCallback callback) {
+
+ if (CastRemoteDisplayLocalService.getInstance() != null) {
+ Log.d(LOGTAG, "CastRemoteDisplayLocalService already existed.");
+ GeckoAppShell.notifyObservers("presentation-view-ready", route.getId());
+ callback.sendSuccess("Succeed to start presentation.");
+ return;
+ }
+
+ Intent intent = new Intent(context, RemotePresentationService.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ PendingIntent notificationPendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
+
+ CastRemoteDisplayLocalService.NotificationSettings settings =
+ new CastRemoteDisplayLocalService.NotificationSettings.Builder()
+ .setNotificationPendingIntent(notificationPendingIntent).build();
+
+ CastRemoteDisplayLocalService.startService(
+ context,
+ RemotePresentationService.class,
+ REMOTE_DISPLAY_APP_ID,
+ castDevice,
+ settings,
+ new CastRemoteDisplayLocalService.Callbacks() {
+ @Override
+ public void onServiceCreated(CastRemoteDisplayLocalService service) {
+ ((RemotePresentationService) service).setDeviceId(route.getId());
+ }
+
+ @Override
+ public void onRemoteDisplaySessionStarted(CastRemoteDisplayLocalService service) {
+ Log.d(LOGTAG, "Remote presentation launched!");
+ callback.sendSuccess("Succeed to start presentation.");
+ }
+
+ @Override
+ public void onRemoteDisplaySessionError(Status errorReason) {
+ int code = errorReason.getStatusCode();
+ callback.sendError("Fail to start presentation. Error code: " + code);
+ }
+ });
+ }
+
+ @Override
+ public void stop(EventCallback callback) {
+ CastRemoteDisplayLocalService.stopService();
+ callback.sendSuccess("Succeed to stop presentation.");
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java b/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java
new file mode 100644
index 000000000..c531b8c37
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ChromeCastPlayer.java
@@ -0,0 +1,509 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.IOException;
+
+import org.mozilla.gecko.util.EventCallback;
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import com.google.android.gms.cast.Cast.MessageReceivedCallback;
+import com.google.android.gms.cast.ApplicationMetadata;
+import com.google.android.gms.cast.Cast;
+import com.google.android.gms.cast.Cast.ApplicationConnectionResult;
+import com.google.android.gms.cast.CastDevice;
+import com.google.android.gms.cast.CastMediaControlIntent;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaMetadata;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.RemoteMediaPlayer;
+import com.google.android.gms.cast.RemoteMediaPlayer.MediaChannelResult;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.common.api.ResultCallback;
+import com.google.android.gms.common.api.Status;
+import com.google.android.gms.common.GooglePlayServicesUtil;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v7.media.MediaRouter.RouteInfo;
+import android.util.Log;
+
+/* Implementation of GeckoMediaPlayer for talking to ChromeCast devices */
+class ChromeCastPlayer implements GeckoMediaPlayer {
+ private static final boolean SHOW_DEBUG = false;
+
+ static final String MIRROR_RECEIVER_APP_ID = "08FF1091";
+
+ private final Context context;
+ private final RouteInfo route;
+ private GoogleApiClient apiClient;
+ private RemoteMediaPlayer remoteMediaPlayer;
+ private final boolean canMirror;
+ private String mSessionId;
+ private MirrorChannel mMirrorChannel;
+ private boolean mApplicationStarted = false;
+
+ // EventCallback which is actually a GeckoEventCallback is sometimes being invoked more
+ // than once. That causes the IllegalStateException to be thrown. To prevent a crash,
+ // catch the exception and report it as an error to the log.
+ private static void sendSuccess(final EventCallback callback, final String msg) {
+ try {
+ callback.sendSuccess(msg);
+ } catch (final IllegalStateException e) {
+ Log.e(LOGTAG, "Attempting to invoke callback.sendSuccess more than once.", e);
+ }
+ }
+
+ private static void sendError(final EventCallback callback, final String msg) {
+ try {
+ callback.sendError(msg);
+ } catch (final IllegalStateException e) {
+ Log.e(LOGTAG, "Attempting to invoke callback.sendError more than once.", e);
+ }
+ }
+
+ // Callback to start playback of a url on a remote device
+ private class VideoPlayCallback implements ResultCallback<ApplicationConnectionResult>,
+ RemoteMediaPlayer.OnStatusUpdatedListener,
+ RemoteMediaPlayer.OnMetadataUpdatedListener {
+ private final String url;
+ private final String type;
+ private final String title;
+ private final EventCallback callback;
+
+ public VideoPlayCallback(String url, String type, String title, EventCallback callback) {
+ this.url = url;
+ this.type = type;
+ this.title = title;
+ this.callback = callback;
+ }
+
+ @Override
+ public void onStatusUpdated() {
+ MediaStatus mediaStatus = remoteMediaPlayer.getMediaStatus();
+
+ switch (mediaStatus.getPlayerState()) {
+ case MediaStatus.PLAYER_STATE_PLAYING:
+ GeckoAppShell.notifyObservers("MediaPlayer:Playing", null);
+ break;
+ case MediaStatus.PLAYER_STATE_PAUSED:
+ GeckoAppShell.notifyObservers("MediaPlayer:Paused", null);
+ break;
+ case MediaStatus.PLAYER_STATE_IDLE:
+ // TODO: Do we want to shutdown when there are errors?
+ if (mediaStatus.getIdleReason() == MediaStatus.IDLE_REASON_FINISHED) {
+ GeckoAppShell.notifyObservers("Casting:Stop", null);
+ }
+ break;
+ default:
+ // TODO: Do we need to handle other status such as buffering / unknown?
+ break;
+ }
+ }
+
+ @Override
+ public void onMetadataUpdated() { }
+
+ @Override
+ public void onResult(ApplicationConnectionResult result) {
+ Status status = result.getStatus();
+ debug("ApplicationConnectionResultCallback.onResult: statusCode" + status.getStatusCode());
+ if (status.isSuccess()) {
+ remoteMediaPlayer = new RemoteMediaPlayer();
+ remoteMediaPlayer.setOnStatusUpdatedListener(this);
+ remoteMediaPlayer.setOnMetadataUpdatedListener(this);
+ mSessionId = result.getSessionId();
+ if (!verifySession(callback)) {
+ return;
+ }
+
+ try {
+ Cast.CastApi.setMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace(), remoteMediaPlayer);
+ } catch (IOException e) {
+ debug("Exception while creating media channel", e);
+ }
+
+ startPlayback();
+ } else {
+ sendError(callback, status.toString());
+ }
+ }
+
+ private void startPlayback() {
+ MediaMetadata mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
+ mediaMetadata.putString(MediaMetadata.KEY_TITLE, title);
+ MediaInfo mediaInfo = new MediaInfo.Builder(url)
+ .setContentType(type)
+ .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
+ .setMetadata(mediaMetadata)
+ .build();
+ try {
+ remoteMediaPlayer.load(apiClient, mediaInfo, true).setResultCallback(new ResultCallback<RemoteMediaPlayer.MediaChannelResult>() {
+ @Override
+ public void onResult(MediaChannelResult result) {
+ if (result.getStatus().isSuccess()) {
+ sendSuccess(callback, null);
+ debug("Media loaded successfully");
+ return;
+ }
+
+ debug("Media load failed " + result.getStatus());
+ sendError(callback, result.getStatus().toString());
+ }
+ });
+
+ return;
+ } catch (IllegalStateException e) {
+ debug("Problem occurred with media during loading", e);
+ } catch (Exception e) {
+ debug("Problem opening media during loading", e);
+ }
+
+ sendError(callback, "");
+ }
+ }
+
+ public ChromeCastPlayer(Context context, RouteInfo route) {
+ int status = GooglePlayServicesUtil.isGooglePlayServicesAvailable(context);
+ if (status != ConnectionResult.SUCCESS) {
+ throw new IllegalStateException("Play services are required for Chromecast support (got status code " + status + ")");
+ }
+
+ this.context = context;
+ this.route = route;
+ this.canMirror = route.supportsControlCategory(CastMediaControlIntent.categoryForCast(MIRROR_RECEIVER_APP_ID));
+ }
+
+ /**
+ * This dumps everything we can find about the device into JSON. This will hopefully make it
+ * easier to filter out duplicate devices from different sources in JS.
+ * Returns null if the device can't be found.
+ */
+ @Override
+ public JSONObject toJSON() {
+ final JSONObject obj = new JSONObject();
+ try {
+ final CastDevice device = CastDevice.getFromBundle(route.getExtras());
+ if (device == null) {
+ return null;
+ }
+
+ obj.put("uuid", route.getId());
+ obj.put("version", device.getDeviceVersion());
+ obj.put("friendlyName", device.getFriendlyName());
+ obj.put("location", device.getIpAddress().toString());
+ obj.put("modelName", device.getModelName());
+ obj.put("mirror", canMirror);
+ // For now we just assume all of these are Google devices
+ obj.put("manufacturer", "Google Inc.");
+ } catch (JSONException ex) {
+ debug("Error building route", ex);
+ }
+
+ return obj;
+ }
+
+ @Override
+ public void load(final String title, final String url, final String type, final EventCallback callback) {
+ final CastDevice device = CastDevice.getFromBundle(route.getExtras());
+ Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
+ @Override
+ public void onApplicationStatusChanged() { }
+
+ @Override
+ public void onVolumeChanged() { }
+
+ @Override
+ public void onApplicationDisconnected(int errorCode) { }
+ });
+
+ apiClient = new GoogleApiClient.Builder(context)
+ .addApi(Cast.API, apiOptionsBuilder.build())
+ .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
+ @Override
+ public void onConnected(Bundle connectionHint) {
+ // Sometimes apiClient is null here. See bug 1061032
+ if (apiClient != null && !apiClient.isConnected()) {
+ debug("Connection failed");
+ sendError(callback, "Not connected");
+ return;
+ }
+
+ // Launch the media player app and launch this url once its loaded
+ try {
+ Cast.CastApi.launchApplication(apiClient, CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID, true)
+ .setResultCallback(new VideoPlayCallback(url, type, title, callback));
+ } catch (Exception e) {
+ debug("Failed to launch application", e);
+ }
+ }
+
+ @Override
+ public void onConnectionSuspended(int cause) {
+ debug("suspended");
+ }
+ }).build();
+
+ apiClient.connect();
+ }
+
+ @Override
+ public void start(final EventCallback callback) {
+ // Nothing to be done here
+ sendSuccess(callback, null);
+ }
+
+ @Override
+ public void stop(final EventCallback callback) {
+ // Nothing to be done here
+ sendSuccess(callback, null);
+ }
+
+ public boolean verifySession(final EventCallback callback) {
+ String msg = null;
+ if (apiClient == null || !apiClient.isConnected()) {
+ msg = "Not connected";
+ }
+
+ if (mSessionId == null) {
+ msg = "No session";
+ }
+
+ if (msg != null) {
+ debug(msg);
+ if (callback != null) {
+ sendError(callback, msg);
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void play(final EventCallback callback) {
+ if (!verifySession(callback)) {
+ return;
+ }
+
+ try {
+ remoteMediaPlayer.play(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() {
+ @Override
+ public void onResult(MediaChannelResult result) {
+ Status status = result.getStatus();
+ if (!status.isSuccess()) {
+ debug("Unable to play: " + status.getStatusCode());
+ sendError(callback, status.toString());
+ } else {
+ sendSuccess(callback, null);
+ }
+ }
+ });
+ } catch (IllegalStateException ex) {
+ // The media player may throw if the session has been killed. For now, we're just catching this here.
+ sendError(callback, "Error playing");
+ }
+ }
+
+ @Override
+ public void pause(final EventCallback callback) {
+ if (!verifySession(callback)) {
+ return;
+ }
+
+ try {
+ remoteMediaPlayer.pause(apiClient).setResultCallback(new ResultCallback<MediaChannelResult>() {
+ @Override
+ public void onResult(MediaChannelResult result) {
+ Status status = result.getStatus();
+ if (!status.isSuccess()) {
+ debug("Unable to pause: " + status.getStatusCode());
+ sendError(callback, status.toString());
+ } else {
+ sendSuccess(callback, null);
+ }
+ }
+ });
+ } catch (IllegalStateException ex) {
+ // The media player may throw if the session has been killed. For now, we're just catching this here.
+ sendError(callback, "Error pausing");
+ }
+ }
+
+ @Override
+ public void end(final EventCallback callback) {
+ if (!verifySession(callback)) {
+ return;
+ }
+
+ try {
+ Cast.CastApi.stopApplication(apiClient).setResultCallback(new ResultCallback<Status>() {
+ @Override
+ public void onResult(Status result) {
+ if (result.isSuccess()) {
+ try {
+ Cast.CastApi.removeMessageReceivedCallbacks(apiClient, remoteMediaPlayer.getNamespace());
+ remoteMediaPlayer = null;
+ mSessionId = null;
+ apiClient.disconnect();
+ apiClient = null;
+
+ if (callback != null) {
+ sendSuccess(callback, null);
+ }
+
+ return;
+ } catch (Exception ex) {
+ debug("Error ending", ex);
+ }
+ }
+
+ if (callback != null) {
+ sendError(callback, result.getStatus().toString());
+ }
+ }
+ });
+ } catch (IllegalStateException ex) {
+ // The media player may throw if the session has been killed. For now, we're just catching this here.
+ sendError(callback, "Error stopping");
+ }
+ }
+
+ class MirrorChannel implements MessageReceivedCallback {
+ /**
+ * @return custom namespace
+ */
+ public String getNamespace() {
+ return "urn:x-cast:org.mozilla.mirror";
+ }
+
+ /*
+ * Receive message from the receiver app
+ */
+ @Override
+ public void onMessageReceived(CastDevice castDevice, String namespace,
+ String message) {
+ GeckoAppShell.notifyObservers("MediaPlayer:Response", message);
+ }
+
+ public void sendMessage(String message) {
+ if (apiClient != null && mMirrorChannel != null) {
+ try {
+ Cast.CastApi.sendMessage(apiClient, mMirrorChannel.getNamespace(), message)
+ .setResultCallback(
+ new ResultCallback<Status>() {
+ @Override
+ public void onResult(Status result) {
+ }
+ });
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception while sending message", e);
+ }
+ }
+ }
+ }
+ private class MirrorCallback implements ResultCallback<ApplicationConnectionResult> {
+ final EventCallback callback;
+ MirrorCallback(final EventCallback callback) {
+ this.callback = callback;
+ }
+
+
+ @Override
+ public void onResult(ApplicationConnectionResult result) {
+ Status status = result.getStatus();
+ if (status.isSuccess()) {
+ ApplicationMetadata applicationMetadata = result.getApplicationMetadata();
+ mSessionId = result.getSessionId();
+ String applicationStatus = result.getApplicationStatus();
+ boolean wasLaunched = result.getWasLaunched();
+ mApplicationStarted = true;
+
+ // Create the custom message
+ // channel
+ mMirrorChannel = new MirrorChannel();
+ try {
+ Cast.CastApi.setMessageReceivedCallbacks(apiClient,
+ mMirrorChannel
+ .getNamespace(),
+ mMirrorChannel);
+ sendSuccess(callback, null);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Exception while creating channel", e);
+ }
+
+ GeckoAppShell.notifyObservers("Casting:Mirror", route.getId());
+ } else {
+ sendError(callback, status.toString());
+ }
+ }
+ }
+
+ @Override
+ public void message(String msg, final EventCallback callback) {
+ if (mMirrorChannel != null) {
+ mMirrorChannel.sendMessage(msg);
+ }
+ }
+
+ @Override
+ public void mirror(final EventCallback callback) {
+ final CastDevice device = CastDevice.getFromBundle(route.getExtras());
+ Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
+ @Override
+ public void onApplicationStatusChanged() { }
+
+ @Override
+ public void onVolumeChanged() { }
+
+ @Override
+ public void onApplicationDisconnected(int errorCode) { }
+ });
+
+ apiClient = new GoogleApiClient.Builder(context)
+ .addApi(Cast.API, apiOptionsBuilder.build())
+ .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
+ @Override
+ public void onConnected(Bundle connectionHint) {
+ // Sometimes apiClient is null here. See bug 1061032
+ if (apiClient == null || !apiClient.isConnected()) {
+ return;
+ }
+
+ // Launch the media player app and launch this url once its loaded
+ try {
+ Cast.CastApi.launchApplication(apiClient, MIRROR_RECEIVER_APP_ID, true)
+ .setResultCallback(new MirrorCallback(callback));
+ } catch (Exception e) {
+ debug("Failed to launch application", e);
+ }
+ }
+
+ @Override
+ public void onConnectionSuspended(int cause) {
+ debug("suspended");
+ }
+ }).build();
+
+ apiClient.connect();
+ }
+
+ private static final String LOGTAG = "GeckoChromeCastPlayer";
+ private void debug(String msg, Exception e) {
+ if (SHOW_DEBUG) {
+ Log.e(LOGTAG, msg, e);
+ }
+ }
+
+ private void debug(String msg) {
+ if (SHOW_DEBUG) {
+ Log.d(LOGTAG, msg);
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java b/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java
new file mode 100644
index 000000000..ce2384a4d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/CrashReporter.java
@@ -0,0 +1,480 @@
+/* -*- Mode: Java; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.util.zip.GZIPOutputStream;
+
+import org.mozilla.gecko.AppConstants.Versions;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v7.app.AppCompatActivity;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+
+@SuppressLint("Registered") // This activity is only registered in the manifest if MOZ_CRASHREPORTER is set
+public class CrashReporter extends AppCompatActivity
+{
+ private static final String LOGTAG = "GeckoCrashReporter";
+
+ private static final String PASSED_MINI_DUMP_KEY = "minidumpPath";
+ private static final String PASSED_MINI_DUMP_SUCCESS_KEY = "minidumpSuccess";
+ private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
+ private static final String PAGE_URL_KEY = "URL";
+ private static final String NOTES_KEY = "Notes";
+ private static final String SERVER_URL_KEY = "ServerURL";
+
+ private static final String CRASH_REPORT_SUFFIX = "/mozilla/Crash Reports/";
+ private static final String PENDING_SUFFIX = CRASH_REPORT_SUFFIX + "pending";
+ private static final String SUBMITTED_SUFFIX = CRASH_REPORT_SUFFIX + "submitted";
+
+ private static final String PREFS_SEND_REPORT = "sendReport";
+ private static final String PREFS_INCLUDE_URL = "includeUrl";
+ private static final String PREFS_ALLOW_CONTACT = "allowContact";
+ private static final String PREFS_CONTACT_EMAIL = "contactEmail";
+
+ private Handler mHandler;
+ private ProgressDialog mProgressDialog;
+ private File mPendingMinidumpFile;
+ private File mPendingExtrasFile;
+ private HashMap<String, String> mExtrasStringMap;
+ private boolean mMinidumpSucceeded;
+
+ private boolean moveFile(File inFile, File outFile) {
+ Log.i(LOGTAG, "moving " + inFile + " to " + outFile);
+ if (inFile.renameTo(outFile))
+ return true;
+ try {
+ outFile.createNewFile();
+ Log.i(LOGTAG, "couldn't rename minidump file");
+ // so copy it instead
+ FileChannel inChannel = new FileInputStream(inFile).getChannel();
+ FileChannel outChannel = new FileOutputStream(outFile).getChannel();
+ long transferred = inChannel.transferTo(0, inChannel.size(), outChannel);
+ inChannel.close();
+ outChannel.close();
+
+ if (transferred > 0)
+ inFile.delete();
+ } catch (Exception e) {
+ Log.e(LOGTAG, "exception while copying minidump file: ", e);
+ return false;
+ }
+ return true;
+ }
+
+ private void doFinish() {
+ if (mHandler != null) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ finish();
+ }
+ });
+ }
+ }
+
+ @Override
+ public void finish() {
+ try {
+ if (mProgressDialog.isShowing()) {
+ mProgressDialog.dismiss();
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "exception while closing progress dialog: ", e);
+ }
+ super.finish();
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // mHandler is created here so runnables can be run on the main thread
+ mHandler = new Handler();
+ setContentView(R.layout.crash_reporter);
+ mProgressDialog = new ProgressDialog(this);
+ mProgressDialog.setMessage(getString(R.string.sending_crash_report));
+
+ mMinidumpSucceeded = getIntent().getBooleanExtra(PASSED_MINI_DUMP_SUCCESS_KEY, false);
+ if (!mMinidumpSucceeded) {
+ Log.i(LOGTAG, "Failed to get minidump.");
+ }
+ String passedMinidumpPath = getIntent().getStringExtra(PASSED_MINI_DUMP_KEY);
+ File passedMinidumpFile = new File(passedMinidumpPath);
+ File pendingDir = new File(getFilesDir(), PENDING_SUFFIX);
+ pendingDir.mkdirs();
+ mPendingMinidumpFile = new File(pendingDir, passedMinidumpFile.getName());
+ moveFile(passedMinidumpFile, mPendingMinidumpFile);
+
+ File extrasFile = new File(passedMinidumpPath.replaceAll("\\.dmp", ".extra"));
+ mPendingExtrasFile = new File(pendingDir, extrasFile.getName());
+ moveFile(extrasFile, mPendingExtrasFile);
+
+ mExtrasStringMap = new HashMap<String, String>();
+ readStringsFromFile(mPendingExtrasFile.getPath(), mExtrasStringMap);
+
+ // Notify GeckoApp that we've crashed, so it can react appropriately during the next start.
+ try {
+ File crashFlag = new File(GeckoProfileDirectories.getMozillaDirectory(this), "CRASHED");
+ crashFlag.createNewFile();
+ } catch (GeckoProfileDirectories.NoMozillaDirectoryException | IOException e) {
+ Log.e(LOGTAG, "Cannot set crash flag: ", e);
+ }
+
+ final CheckBox allowContactCheckBox = (CheckBox) findViewById(R.id.allow_contact);
+ final CheckBox includeUrlCheckBox = (CheckBox) findViewById(R.id.include_url);
+ final CheckBox sendReportCheckBox = (CheckBox) findViewById(R.id.send_report);
+ final EditText commentsEditText = (EditText) findViewById(R.id.comment);
+ final EditText emailEditText = (EditText) findViewById(R.id.email);
+
+ // Load CrashReporter preferences to avoid redundant user input.
+ SharedPreferences prefs = GeckoSharedPrefs.forCrashReporter(this);
+ final boolean sendReport = prefs.getBoolean(PREFS_SEND_REPORT, true);
+ final boolean includeUrl = prefs.getBoolean(PREFS_INCLUDE_URL, false);
+ final boolean allowContact = prefs.getBoolean(PREFS_ALLOW_CONTACT, false);
+ final String contactEmail = prefs.getString(PREFS_CONTACT_EMAIL, "");
+
+ allowContactCheckBox.setChecked(allowContact);
+ includeUrlCheckBox.setChecked(includeUrl);
+ sendReportCheckBox.setChecked(sendReport);
+ emailEditText.setText(contactEmail);
+
+ sendReportCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
+ commentsEditText.setEnabled(isChecked);
+ commentsEditText.requestFocus();
+
+ includeUrlCheckBox.setEnabled(isChecked);
+ allowContactCheckBox.setEnabled(isChecked);
+ emailEditText.setEnabled(isChecked && allowContactCheckBox.isChecked());
+ }
+ });
+
+ allowContactCheckBox.setOnCheckedChangeListener(new CheckBox.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton checkbox, boolean isChecked) {
+ // We need to check isEnabled() here because this listener is
+ // fired on rotation -- even when the checkbox is disabled.
+ emailEditText.setEnabled(checkbox.isEnabled() && isChecked);
+ emailEditText.requestFocus();
+ }
+ });
+
+ emailEditText.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Even if the email EditText is disabled, allow it to be
+ // clicked and focused.
+ if (sendReportCheckBox.isChecked() && !v.isEnabled()) {
+ allowContactCheckBox.setChecked(true);
+ v.setEnabled(true);
+ v.requestFocus();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onBackPressed() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setMessage(R.string.crash_closing_alert);
+ builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+ });
+ builder.setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ CrashReporter.this.finish();
+ }
+ });
+ builder.show();
+ }
+
+ private void backgroundSendReport() {
+ final CheckBox sendReportCheckbox = (CheckBox) findViewById(R.id.send_report);
+ if (!sendReportCheckbox.isChecked()) {
+ doFinish();
+ return;
+ }
+
+ // Persist settings to avoid redundant user input.
+ savePrefs();
+
+ mProgressDialog.show();
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ sendReport(mPendingMinidumpFile, mExtrasStringMap, mPendingExtrasFile);
+ }
+ }, "CrashReporter Thread").start();
+ }
+
+ private void savePrefs() {
+ SharedPreferences.Editor editor = GeckoSharedPrefs.forCrashReporter(this).edit();
+
+ final boolean allowContact = ((CheckBox) findViewById(R.id.allow_contact)).isChecked();
+ final boolean includeUrl = ((CheckBox) findViewById(R.id.include_url)).isChecked();
+ final boolean sendReport = ((CheckBox) findViewById(R.id.send_report)).isChecked();
+ final String contactEmail = ((EditText) findViewById(R.id.email)).getText().toString();
+
+ editor.putBoolean(PREFS_ALLOW_CONTACT, allowContact);
+ editor.putBoolean(PREFS_INCLUDE_URL, includeUrl);
+ editor.putBoolean(PREFS_SEND_REPORT, sendReport);
+ editor.putString(PREFS_CONTACT_EMAIL, contactEmail);
+
+ // A slight performance improvement via async apply() vs. blocking on commit().
+ editor.apply();
+ }
+
+ public void onCloseClick(View v) { // bound via crash_reporter.xml
+ backgroundSendReport();
+ }
+
+ public void onRestartClick(View v) { // bound via crash_reporter.xml
+ doRestart();
+ backgroundSendReport();
+ }
+
+ private boolean readStringsFromFile(String filePath, Map<String, String> stringMap) {
+ try {
+ BufferedReader reader = new BufferedReader(new FileReader(filePath));
+ return readStringsFromReader(reader, stringMap);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "exception while reading strings: ", e);
+ return false;
+ }
+ }
+
+ private boolean readStringsFromReader(BufferedReader reader, Map<String, String> stringMap) throws IOException {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ int equalsPos = -1;
+ if ((equalsPos = line.indexOf('=')) != -1) {
+ String key = line.substring(0, equalsPos);
+ String val = unescape(line.substring(equalsPos + 1));
+ stringMap.put(key, val);
+ }
+ }
+ reader.close();
+ return true;
+ }
+
+ private String generateBoundary() {
+ // Generate some random numbers to fill out the boundary
+ int r0 = (int)(Integer.MAX_VALUE * Math.random());
+ int r1 = (int)(Integer.MAX_VALUE * Math.random());
+ return String.format("---------------------------%08X%08X", r0, r1);
+ }
+
+ private void sendPart(OutputStream os, String boundary, String name, String data) {
+ try {
+ os.write(("--" + boundary + "\r\n" +
+ "Content-Disposition: form-data; name=\"" + name + "\"\r\n" +
+ "\r\n" +
+ data + "\r\n"
+ ).getBytes());
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception when sending \"" + name + "\"", ex);
+ }
+ }
+
+ private void sendFile(OutputStream os, String boundary, String name, File file) throws IOException {
+ os.write(("--" + boundary + "\r\n" +
+ "Content-Disposition: form-data; name=\"" + name + "\"; " +
+ "filename=\"" + file.getName() + "\"\r\n" +
+ "Content-Type: application/octet-stream\r\n" +
+ "\r\n"
+ ).getBytes());
+ FileChannel fc = new FileInputStream(file).getChannel();
+ fc.transferTo(0, fc.size(), Channels.newChannel(os));
+ fc.close();
+ }
+
+ private String readLogcat() {
+ final String crashReporterProc = " " + android.os.Process.myPid() + ' ';
+ BufferedReader br = null;
+ try {
+ // get at most the last 400 lines of logcat
+ Process proc = Runtime.getRuntime().exec(new String[] {
+ "logcat", "-v", "threadtime", "-t", "400", "-d", "*:D"
+ });
+ StringBuilder sb = new StringBuilder();
+ br = new BufferedReader(new InputStreamReader(proc.getInputStream()));
+ for (String s = br.readLine(); s != null; s = br.readLine()) {
+ if (s.contains(crashReporterProc)) {
+ // Don't include logs from the crash reporter's process.
+ break;
+ }
+ sb.append(s).append('\n');
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return "Unable to get logcat: " + e.toString();
+ } finally {
+ if (br != null) {
+ try {
+ br.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ private void sendReport(File minidumpFile, Map<String, String> extras, File extrasFile) {
+ Log.i(LOGTAG, "sendReport: " + minidumpFile.getPath());
+ final CheckBox includeURLCheckbox = (CheckBox) findViewById(R.id.include_url);
+
+ String spec = extras.get(SERVER_URL_KEY);
+ if (spec == null) {
+ doFinish();
+ return;
+ }
+
+ Log.i(LOGTAG, "server url: " + spec);
+ try {
+ URL url = new URL(spec);
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestMethod("POST");
+ String boundary = generateBoundary();
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ conn.setRequestProperty("Content-Encoding", "gzip");
+
+ OutputStream os = new GZIPOutputStream(conn.getOutputStream());
+ for (String key : extras.keySet()) {
+ if (key.equals(PAGE_URL_KEY)) {
+ if (includeURLCheckbox.isChecked())
+ sendPart(os, boundary, key, extras.get(key));
+ } else if (!key.equals(SERVER_URL_KEY) && !key.equals(NOTES_KEY)) {
+ sendPart(os, boundary, key, extras.get(key));
+ }
+ }
+
+ // Add some extra information to notes so its displayed by
+ // crash-stats.mozilla.org. Remove this when bug 607942 is fixed.
+ StringBuilder sb = new StringBuilder();
+ sb.append(extras.containsKey(NOTES_KEY) ? extras.get(NOTES_KEY) + "\n" : "");
+ if (AppConstants.MOZ_MIN_CPU_VERSION < 7) {
+ sb.append("nothumb Build\n");
+ }
+ sb.append(Build.MANUFACTURER).append(' ')
+ .append(Build.MODEL).append('\n')
+ .append(Build.FINGERPRINT);
+ sendPart(os, boundary, NOTES_KEY, sb.toString());
+
+ sendPart(os, boundary, "Min_ARM_Version", Integer.toString(AppConstants.MOZ_MIN_CPU_VERSION));
+ sendPart(os, boundary, "Android_Manufacturer", Build.MANUFACTURER);
+ sendPart(os, boundary, "Android_Model", Build.MODEL);
+ sendPart(os, boundary, "Android_Board", Build.BOARD);
+ sendPart(os, boundary, "Android_Brand", Build.BRAND);
+ sendPart(os, boundary, "Android_Device", Build.DEVICE);
+ sendPart(os, boundary, "Android_Display", Build.DISPLAY);
+ sendPart(os, boundary, "Android_Fingerprint", Build.FINGERPRINT);
+ sendPart(os, boundary, "Android_APP_ABI", AppConstants.MOZ_APP_ABI);
+ sendPart(os, boundary, "Android_CPU_ABI", Build.CPU_ABI);
+ sendPart(os, boundary, "Android_MIN_SDK", Integer.toString(AppConstants.Versions.MIN_SDK_VERSION));
+ sendPart(os, boundary, "Android_MAX_SDK", Integer.toString(AppConstants.Versions.MAX_SDK_VERSION));
+ try {
+ sendPart(os, boundary, "Android_CPU_ABI2", Build.CPU_ABI2);
+ sendPart(os, boundary, "Android_Hardware", Build.HARDWARE);
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
+ }
+ sendPart(os, boundary, "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
+ if (Versions.feature16Plus && includeURLCheckbox.isChecked()) {
+ sendPart(os, boundary, "Android_Logcat", readLogcat());
+ }
+
+ String comment = ((EditText) findViewById(R.id.comment)).getText().toString();
+ if (!TextUtils.isEmpty(comment)) {
+ sendPart(os, boundary, "Comments", comment);
+ }
+
+ if (((CheckBox) findViewById(R.id.allow_contact)).isChecked()) {
+ String email = ((EditText) findViewById(R.id.email)).getText().toString();
+ sendPart(os, boundary, "Email", email);
+ }
+
+ sendPart(os, boundary, PASSED_MINI_DUMP_SUCCESS_KEY, mMinidumpSucceeded ? "True" : "False");
+ sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
+ os.write(("\r\n--" + boundary + "--\r\n").getBytes());
+ os.flush();
+ os.close();
+ BufferedReader br = new BufferedReader(
+ new InputStreamReader(conn.getInputStream()));
+ HashMap<String, String> responseMap = new HashMap<String, String>();
+ readStringsFromReader(br, responseMap);
+
+ if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ File submittedDir = new File(getFilesDir(),
+ SUBMITTED_SUFFIX);
+ submittedDir.mkdirs();
+ minidumpFile.delete();
+ extrasFile.delete();
+ String crashid = responseMap.get("CrashID");
+ File file = new File(submittedDir, crashid + ".txt");
+ FileOutputStream fos = new FileOutputStream(file);
+ fos.write("Crash ID: ".getBytes());
+ fos.write(crashid.getBytes());
+ fos.close();
+ } else {
+ Log.i(LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "exception during send: ", e);
+ }
+
+ doFinish();
+ }
+
+ private void doRestart() {
+ try {
+ String action = "android.intent.action.MAIN";
+ Intent intent = new Intent(action);
+ intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
+ AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ intent.putExtra("didRestart", true);
+ Log.i(LOGTAG, intent.toString());
+ startActivity(intent);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "error while trying to restart", e);
+ }
+ }
+
+ private String unescape(String string) {
+ return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java b/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java
new file mode 100644
index 000000000..98274b752
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/CustomEditText.java
@@ -0,0 +1,89 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.widget.themed.ThemedEditText;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+
+public class CustomEditText extends ThemedEditText {
+ private OnKeyPreImeListener mOnKeyPreImeListener;
+ private OnSelectionChangedListener mOnSelectionChangedListener;
+ private OnWindowFocusChangeListener mOnWindowFocusChangeListener;
+ private int mHighlightColor;
+
+ public CustomEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setPrivateMode(false); // Initialize mHighlightColor.
+ }
+
+ public interface OnKeyPreImeListener {
+ public boolean onKeyPreIme(View v, int keyCode, KeyEvent event);
+ }
+
+ public void setOnKeyPreImeListener(OnKeyPreImeListener listener) {
+ mOnKeyPreImeListener = listener;
+ }
+
+ @Override
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if (mOnKeyPreImeListener != null)
+ return mOnKeyPreImeListener.onKeyPreIme(this, keyCode, event);
+
+ return false;
+ }
+
+ public interface OnSelectionChangedListener {
+ public void onSelectionChanged(int selStart, int selEnd);
+ }
+
+ public void setOnSelectionChangedListener(OnSelectionChangedListener listener) {
+ mOnSelectionChangedListener = listener;
+ }
+
+ @Override
+ protected void onSelectionChanged(int selStart, int selEnd) {
+ if (mOnSelectionChangedListener != null)
+ mOnSelectionChangedListener.onSelectionChanged(selStart, selEnd);
+
+ super.onSelectionChanged(selStart, selEnd);
+ }
+
+ public interface OnWindowFocusChangeListener {
+ public void onWindowFocusChanged(boolean hasFocus);
+ }
+
+ public void setOnWindowFocusChangeListener(OnWindowFocusChangeListener listener) {
+ mOnWindowFocusChangeListener = listener;
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (mOnWindowFocusChangeListener != null)
+ mOnWindowFocusChangeListener.onWindowFocusChanged(hasFocus);
+ }
+
+ // Provide a getHighlightColor implementation for API level < 16.
+ @Override
+ public int getHighlightColor() {
+ return mHighlightColor;
+ }
+
+ @Override
+ public void setPrivateMode(boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+
+ mHighlightColor = ContextCompat.getColor(getContext(), isPrivate
+ ? R.color.url_bar_text_highlight_pb : R.color.fennec_ui_orange);
+ // android:textColorHighlight cannot support a ColorStateList.
+ setHighlightColor(mHighlightColor);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java b/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java
new file mode 100644
index 000000000..725c25d6e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/DataReportingNotification.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.Typeface;
+import android.support.v4.app.NotificationCompat;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+
+public class DataReportingNotification {
+
+ private static final String LOGTAG = "DataReportNotification";
+
+ public static final String ALERT_NAME_DATAREPORTING_NOTIFICATION = "datareporting-notification";
+
+ private static final String PREFS_POLICY_NOTIFIED_TIME = "datareporting.policy.dataSubmissionPolicyNotifiedTime";
+ private static final String PREFS_POLICY_VERSION = "datareporting.policy.dataSubmissionPolicyVersion";
+ private static final int DATA_REPORTING_VERSION = 2;
+
+ public static void checkAndNotifyPolicy(Context context) {
+ SharedPreferences dataPrefs = GeckoSharedPrefs.forApp(context);
+ final int currentVersion = dataPrefs.getInt(PREFS_POLICY_VERSION, -1);
+
+ if (currentVersion < 1) {
+ // This is a first run, so notify user about data policy.
+ notifyDataPolicy(context, dataPrefs);
+
+ // If healthreport is enabled, set default preference value.
+ if (AppConstants.MOZ_SERVICES_HEALTHREPORT) {
+ SharedPreferences.Editor editor = dataPrefs.edit();
+ editor.putBoolean(GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true);
+ editor.apply();
+ }
+ return;
+ }
+
+ if (currentVersion == 1) {
+ // Redisplay notification only for Beta because version 2 updates Beta policy and update version.
+ if (TextUtils.equals("beta", AppConstants.MOZ_UPDATE_CHANNEL)) {
+ notifyDataPolicy(context, dataPrefs);
+ } else {
+ // Silently update the version.
+ SharedPreferences.Editor editor = dataPrefs.edit();
+ editor.putInt(PREFS_POLICY_VERSION, DATA_REPORTING_VERSION);
+ editor.apply();
+ }
+ return;
+ }
+
+ if (currentVersion >= DATA_REPORTING_VERSION) {
+ // Do nothing, we're at a current (or future) version.
+ return;
+ }
+ }
+
+ /**
+ * Launch a notification of the data policy, and record notification time and version.
+ */
+ public static void notifyDataPolicy(Context context, SharedPreferences sharedPrefs) {
+ boolean result = false;
+ try {
+ // Launch main App to launch Data choices when notification is clicked.
+ Intent prefIntent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS);
+ prefIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+
+ GeckoPreferences.setResourceToOpen(prefIntent, "preferences_privacy");
+ prefIntent.putExtra(ALERT_NAME_DATAREPORTING_NOTIFICATION, true);
+
+ PendingIntent contentIntent = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ final Resources resources = context.getResources();
+
+ // Create and send notification.
+ String notificationTitle = resources.getString(R.string.datareporting_notification_title);
+ String notificationSummary;
+ if (Versions.preJB) {
+ notificationSummary = resources.getString(R.string.datareporting_notification_action);
+ } else {
+ // Display partial version of Big Style notification for supporting devices.
+ notificationSummary = resources.getString(R.string.datareporting_notification_summary);
+ }
+ String notificationAction = resources.getString(R.string.datareporting_notification_action);
+ String notificationBigSummary = resources.getString(R.string.datareporting_notification_summary);
+
+ // Make styled ticker text for display in notification bar.
+ String tickerString = resources.getString(R.string.datareporting_notification_ticker_text);
+ SpannableString tickerText = new SpannableString(tickerString);
+ // Bold the notification title of the ticker text, which is the same string as notificationTitle.
+ tickerText.setSpan(new StyleSpan(Typeface.BOLD), 0, notificationTitle.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+
+ Notification notification = new NotificationCompat.Builder(context)
+ .setContentTitle(notificationTitle)
+ .setContentText(notificationSummary)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setAutoCancel(true)
+ .setContentIntent(contentIntent)
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(notificationBigSummary))
+ .addAction(R.drawable.firefox_settings_alert, notificationAction, contentIntent)
+ .setTicker(tickerText)
+ .build();
+
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ int notificationID = ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode();
+ notificationManager.notify(notificationID, notification);
+
+ // Record version and notification time.
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+ long now = System.currentTimeMillis();
+ editor.putLong(PREFS_POLICY_NOTIFIED_TIME, now);
+ editor.putInt(PREFS_POLICY_VERSION, DATA_REPORTING_VERSION);
+ editor.apply();
+ result = true;
+ } finally {
+ // We want to track any errors, so record notification outcome.
+ Telemetry.sendUIEvent(TelemetryContract.Event.POLICY_NOTIFICATION_SUCCESS, result);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java b/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java
new file mode 100644
index 000000000..44aaa14a0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/DevToolsAuthHelper.java
@@ -0,0 +1,52 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.util.Log;
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.InputOptionsUtils;
+
+/**
+ * Supports the DevTools WiFi debugging authentication flow by invoking a QR decoder.
+ */
+public class DevToolsAuthHelper {
+
+ private static final String LOGTAG = "GeckoDevToolsAuthHelper";
+
+ public static void scan(Context context, final EventCallback callback) {
+ final Intent intent = InputOptionsUtils.createQRCodeReaderIntent();
+
+ intent.putExtra("PROMPT_MESSAGE", context.getString(R.string.devtools_auth_scan_header));
+
+ // Check ahead of time if an activity exists for the intent. This
+ // avoids a case where we get both an ActivityNotFoundException *and*
+ // an activity result when the activity is missing.
+ PackageManager pm = context.getPackageManager();
+ if (pm.resolveActivity(intent, 0) == null) {
+ Log.w(LOGTAG, "PackageManager can't resolve the activity.");
+ callback.sendError("PackageManager can't resolve the activity.");
+ return;
+ }
+
+ ActivityHandlerHelper.startIntent(intent, new ActivityResultHandler() {
+ @Override
+ public void onActivityResult(int resultCode, Intent intent) {
+ if (resultCode == Activity.RESULT_OK) {
+ String text = intent.getStringExtra("SCAN_RESULT");
+ callback.sendSuccess(text);
+ } else {
+ callback.sendError(resultCode);
+ }
+ }
+ });
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java b/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java
new file mode 100644
index 000000000..9aa3f96a4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/DoorHangerPopup.java
@@ -0,0 +1,361 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.HashSet;
+
+import android.text.TextUtils;
+import android.widget.PopupWindow;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONArray;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.AnchoredPopup;
+import org.mozilla.gecko.widget.DoorHanger;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.View;
+import org.mozilla.gecko.widget.DoorhangerConfig;
+
+public class DoorHangerPopup extends AnchoredPopup
+ implements GeckoEventListener,
+ Tabs.OnTabsChangedListener,
+ PopupWindow.OnDismissListener,
+ DoorHanger.OnButtonClickListener {
+ private static final String LOGTAG = "GeckoDoorHangerPopup";
+
+ // Stores a set of all active DoorHanger notifications. A DoorHanger is
+ // uniquely identified by its tabId and value.
+ private final HashSet<DoorHanger> mDoorHangers;
+
+ // Whether or not the doorhanger popup is disabled.
+ private boolean mDisabled;
+
+ public DoorHangerPopup(Context context) {
+ super(context);
+
+ mDoorHangers = new HashSet<DoorHanger>();
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "Doorhanger:Add",
+ "Doorhanger:Remove");
+ Tabs.registerOnTabsChangedListener(this);
+
+ setOnDismissListener(this);
+ }
+
+ void destroy() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "Doorhanger:Add",
+ "Doorhanger:Remove");
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ /**
+ * Temporarily disables the doorhanger popup. If the popup is disabled,
+ * it will not be shown to the user, but it will continue to process
+ * calls to add/remove doorhanger notifications.
+ */
+ void disable() {
+ mDisabled = true;
+ updatePopup();
+ }
+
+ /**
+ * Re-enables the doorhanger popup.
+ */
+ void enable() {
+ mDisabled = false;
+ updatePopup();
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject geckoObject) {
+ try {
+ if (event.equals("Doorhanger:Add")) {
+ final DoorhangerConfig config = makeConfigFromJSON(geckoObject);
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ addDoorHanger(config);
+ }
+ });
+ } else if (event.equals("Doorhanger:Remove")) {
+ final int tabId = geckoObject.getInt("tabID");
+ final String value = geckoObject.getString("value");
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ DoorHanger doorHanger = getDoorHanger(tabId, value);
+ if (doorHanger == null)
+ return;
+
+ removeDoorHanger(doorHanger);
+ updatePopup();
+ }
+ });
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ private DoorhangerConfig makeConfigFromJSON(JSONObject json) throws JSONException {
+ final int tabId = json.getInt("tabID");
+ final String id = json.getString("value");
+
+ final String typeString = json.optString("category");
+ DoorHanger.Type doorhangerType = DoorHanger.Type.DEFAULT;
+ if (DoorHanger.Type.LOGIN.toString().equals(typeString)) {
+ doorhangerType = DoorHanger.Type.LOGIN;
+ } else if (DoorHanger.Type.GEOLOCATION.toString().equals(typeString)) {
+ doorhangerType = DoorHanger.Type.GEOLOCATION;
+ } else if (DoorHanger.Type.DESKTOPNOTIFICATION2.toString().equals(typeString)) {
+ doorhangerType = DoorHanger.Type.DESKTOPNOTIFICATION2;
+ } else if (DoorHanger.Type.WEBRTC.toString().equals(typeString)) {
+ doorhangerType = DoorHanger.Type.WEBRTC;
+ } else if (DoorHanger.Type.VIBRATION.toString().equals(typeString)) {
+ doorhangerType = DoorHanger.Type.VIBRATION;
+ }
+
+ final DoorhangerConfig config = new DoorhangerConfig(tabId, id, doorhangerType, this);
+
+ config.setMessage(json.getString("message"));
+ config.setOptions(json.getJSONObject("options"));
+
+ final JSONArray buttonArray = json.getJSONArray("buttons");
+ int numButtons = buttonArray.length();
+ if (numButtons > 2) {
+ Log.e(LOGTAG, "Doorhanger can have a maximum of two buttons!");
+ numButtons = 2;
+ }
+
+ for (int i = 0; i < numButtons; i++) {
+ final JSONObject buttonJSON = buttonArray.getJSONObject(i);
+ final boolean isPositive = buttonJSON.optBoolean("positive", false);
+ config.setButton(buttonJSON.getString("label"), buttonJSON.getInt("callback"), isPositive);
+ }
+
+ return config;
+ }
+
+ // This callback is automatically executed on the UI thread.
+ @Override
+ public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) {
+ switch (msg) {
+ case CLOSED:
+ // Remove any doorhangers for a tab when it's closed (make
+ // a temporary set to avoid a ConcurrentModificationException)
+ removeTabDoorHangers(tab.getId(), true);
+ break;
+
+ case LOCATION_CHANGE:
+ // Only remove doorhangers if the popup is hidden or if we're navigating to a new URL
+ if (!isShowing() || !data.equals(tab.getURL()))
+ removeTabDoorHangers(tab.getId(), false);
+
+ // Update the popup if the location change was on the current tab
+ if (Tabs.getInstance().isSelectedTab(tab))
+ updatePopup();
+ break;
+
+ case SELECTED:
+ // Always update the popup when a new tab is selected. This will cover cases
+ // where a different tab was closed, since we always need to select a new tab.
+ updatePopup();
+ break;
+ }
+ }
+
+ /**
+ * Adds a doorhanger.
+ *
+ * This method must be called on the UI thread.
+ */
+ void addDoorHanger(DoorhangerConfig config) {
+ final int tabId = config.getTabId();
+ // Don't add a doorhanger for a tab that doesn't exist
+ if (Tabs.getInstance().getTab(tabId) == null) {
+ return;
+ }
+
+ // Replace the doorhanger if it already exists
+ DoorHanger oldDoorHanger = getDoorHanger(tabId, config.getId());
+ if (oldDoorHanger != null) {
+ removeDoorHanger(oldDoorHanger);
+ }
+
+ if (!mInflated) {
+ init();
+ }
+
+ final DoorHanger newDoorHanger = DoorHanger.Get(mContext, config);
+
+ mDoorHangers.add(newDoorHanger);
+ mContent.addView(newDoorHanger);
+
+ // Only update the popup if we're adding a notification to the selected tab
+ if (tabId == Tabs.getInstance().getSelectedTab().getId())
+ updatePopup();
+ }
+
+
+ /*
+ * DoorHanger.OnButtonClickListener implementation
+ */
+ @Override
+ public void onButtonClick(JSONObject response, DoorHanger doorhanger) {
+ GeckoAppShell.notifyObservers("Doorhanger:Reply", response.toString());
+ removeDoorHanger(doorhanger);
+ updatePopup();
+ }
+
+ /**
+ * Gets a doorhanger.
+ *
+ * This method must be called on the UI thread.
+ */
+ DoorHanger getDoorHanger(int tabId, String value) {
+ for (DoorHanger dh : mDoorHangers) {
+ if (dh.getTabId() == tabId && dh.getIdentifier().equals(value))
+ return dh;
+ }
+
+ // If there's no doorhanger for the given tabId and value, return null
+ return null;
+ }
+
+ /**
+ * Removes a doorhanger.
+ *
+ * This method must be called on the UI thread.
+ */
+ void removeDoorHanger(final DoorHanger doorHanger) {
+ mDoorHangers.remove(doorHanger);
+ mContent.removeView(doorHanger);
+ }
+
+ /**
+ * Removes doorhangers for a given tab.
+ * @param tabId identifier of the tab to remove doorhangers from
+ * @param forceRemove boolean for force-removing tabs. If true, all doorhangers associated
+ * with the tab specified are removed; if false, only remove the doorhangers
+ * that are not persistent, as specified by the doorhanger options.
+ *
+ * This method must be called on the UI thread.
+ */
+ void removeTabDoorHangers(int tabId, boolean forceRemove) {
+ // Make a temporary set to avoid a ConcurrentModificationException
+ HashSet<DoorHanger> doorHangersToRemove = new HashSet<DoorHanger>();
+ for (DoorHanger dh : mDoorHangers) {
+ // Only remove transient doorhangers for the given tab
+ if (dh.getTabId() == tabId
+ && (forceRemove || (!forceRemove && dh.shouldRemove(isShowing())))) {
+ doorHangersToRemove.add(dh);
+ }
+ }
+
+ for (DoorHanger dh : doorHangersToRemove) {
+ removeDoorHanger(dh);
+ }
+ }
+
+ /**
+ * Updates the popup state.
+ *
+ * This method must be called on the UI thread.
+ */
+ void updatePopup() {
+ // Bail if the selected tab is null, if there are no active doorhangers,
+ // if we haven't inflated the layout yet (this can happen if updatePopup()
+ // is called before the runnable from addDoorHanger() runs), or if the
+ // doorhanger popup is temporarily disabled.
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab == null || mDoorHangers.size() == 0 || !mInflated || mDisabled) {
+ dismiss();
+ return;
+ }
+
+ // Show doorhangers for the selected tab
+ int tabId = tab.getId();
+ boolean shouldShowPopup = false;
+ DoorHanger firstDoorhanger = null;
+ for (DoorHanger dh : mDoorHangers) {
+ if (dh.getTabId() == tabId) {
+ dh.setVisibility(View.VISIBLE);
+ shouldShowPopup = true;
+ if (firstDoorhanger == null) {
+ firstDoorhanger = dh;
+ } else {
+ dh.hideTitle();
+ }
+ } else {
+ dh.setVisibility(View.GONE);
+ }
+ }
+
+ // Dismiss the popup if there are no doorhangers to show for this tab
+ if (!shouldShowPopup) {
+ dismiss();
+ return;
+ }
+
+ showDividers();
+
+ final String baseDomain = tab.getBaseDomain();
+
+ if (TextUtils.isEmpty(baseDomain)) {
+ firstDoorhanger.hideTitle();
+ } else {
+ firstDoorhanger.showTitle(tab.getFavicon(), baseDomain);
+ }
+
+ if (isShowing()) {
+ show();
+ return;
+ }
+
+ setFocusable(true);
+
+ show();
+ }
+
+ //Show all inter-DoorHanger dividers (ie. Dividers on all visible DoorHangers except the last one)
+ private void showDividers() {
+ int count = mContent.getChildCount();
+ DoorHanger lastVisibleDoorHanger = null;
+
+ for (int i = 0; i < count; i++) {
+ DoorHanger dh = (DoorHanger) mContent.getChildAt(i);
+ dh.showDivider();
+ if (dh.getVisibility() == View.VISIBLE) {
+ lastVisibleDoorHanger = dh;
+ }
+ }
+ if (lastVisibleDoorHanger != null) {
+ lastVisibleDoorHanger.hideDivider();
+ }
+ }
+
+ @Override
+ public void onDismiss() {
+ final int tabId = Tabs.getInstance().getSelectedTab().getId();
+ removeTabDoorHangers(tabId, true);
+ }
+
+ @Override
+ public void dismiss() {
+ // If the popup is focusable while it is hidden, we run into crashes
+ // on pre-ICS devices when the popup gets focus before it is shown.
+ setFocusable(false);
+ super.dismiss();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java b/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java
new file mode 100644
index 000000000..ff3ac6110
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/DownloadsIntegration.java
@@ -0,0 +1,235 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.EventCallback;
+
+import java.io.File;
+import java.lang.IllegalArgumentException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import android.app.DownloadManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.MediaScannerConnectionClient;
+import android.net.Uri;
+import android.os.Environment;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class DownloadsIntegration implements NativeEventListener
+{
+ private static final String LOGTAG = "GeckoDownloadsIntegration";
+
+ private static final List<String> UNKNOWN_MIME_TYPES;
+ static {
+ final ArrayList<String> tempTypes = new ArrayList<>(3);
+ tempTypes.add("unknown/unknown"); // This will be used as a default mime type for unknown files
+ tempTypes.add("application/unknown");
+ tempTypes.add("application/octet-stream"); // Github uses this for APK files
+ UNKNOWN_MIME_TYPES = Collections.unmodifiableList(tempTypes);
+ }
+
+ private static final String DOWNLOAD_REMOVE = "Download:Remove";
+
+ private DownloadsIntegration() {
+ EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener)this, DOWNLOAD_REMOVE);
+ }
+
+ private static DownloadsIntegration sInstance;
+
+ private static class Download {
+ final File file;
+ final long id;
+
+ final private static int UNKNOWN_ID = -1;
+
+ public Download(final String path) {
+ this(path, UNKNOWN_ID);
+ }
+
+ public Download(final String path, final long id) {
+ file = new File(path);
+ this.id = id;
+ }
+
+ public static Download fromJSON(final NativeJSObject obj) {
+ final String path = obj.getString("path");
+ return new Download(path);
+ }
+
+ public static Download fromCursor(final Cursor c) {
+ final String path = c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_FILENAME));
+ final long id = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_ID));
+ return new Download(path, id);
+ }
+
+ public boolean equals(final Download download) {
+ return file.equals(download.file);
+ }
+ }
+
+ public static void init() {
+ if (sInstance == null) {
+ sInstance = new DownloadsIntegration();
+ }
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message,
+ final EventCallback callback) {
+ if (DOWNLOAD_REMOVE.equals(event)) {
+ final Download d = Download.fromJSON(message);
+ removeDownload(d);
+ }
+ }
+
+ private static boolean useSystemDownloadManager() {
+ if (!AppConstants.ANDROID_DOWNLOADS_INTEGRATION) {
+ return false;
+ }
+
+ int state = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
+ try {
+ state = GeckoAppShell.getContext().getPackageManager().getApplicationEnabledSetting("com.android.providers.downloads");
+ } catch (IllegalArgumentException e) {
+ // Download Manager package does not exist
+ return false;
+ }
+
+ return (PackageManager.COMPONENT_ENABLED_STATE_ENABLED == state ||
+ PackageManager.COMPONENT_ENABLED_STATE_DEFAULT == state);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getTemporaryDownloadDirectory() {
+ Context context = GeckoAppShell.getApplicationContext();
+
+ if (Permissions.has(context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ // We do have the STORAGE permission, so we can save the file directly to the public
+ // downloads directory.
+ return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ .getAbsolutePath();
+ } else {
+ // Without the permission we are going to start to download the file to the cache
+ // directory. Later in the process we will ask for the permission and the download
+ // process will move the file to the actual downloads directory. If we do not get the
+ // permission then the download will be cancelled.
+ return context.getCacheDir().getAbsolutePath();
+ }
+ }
+
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void scanMedia(final String aFile, String aMimeType) {
+ String mimeType = aMimeType;
+ if (UNKNOWN_MIME_TYPES.contains(mimeType)) {
+ // If this is a generic undefined mimetype, erase it so that we can try to determine
+ // one from the file extension below.
+ mimeType = "";
+ }
+
+ // If the platform didn't give us a mimetype, try to guess one from the filename
+ if (TextUtils.isEmpty(mimeType)) {
+ final int extPosition = aFile.lastIndexOf(".");
+ if (extPosition > 0 && extPosition < aFile.length() - 1) {
+ mimeType = GeckoAppShell.getMimeTypeFromExtension(aFile.substring(extPosition + 1));
+ }
+ }
+
+ // addCompletedDownload will throw if it received any null parameters. Use aMimeType or a default
+ // if we still don't have one.
+ if (TextUtils.isEmpty(mimeType)) {
+ if (TextUtils.isEmpty(aMimeType)) {
+ mimeType = UNKNOWN_MIME_TYPES.get(0);
+ } else {
+ mimeType = aMimeType;
+ }
+ }
+
+ if (useSystemDownloadManager()) {
+ final File f = new File(aFile);
+ final DownloadManager dm = (DownloadManager) GeckoAppShell.getContext().getSystemService(Context.DOWNLOAD_SERVICE);
+ dm.addCompletedDownload(f.getName(),
+ f.getName(),
+ true, // Media scanner should scan this
+ mimeType,
+ f.getAbsolutePath(),
+ Math.max(1, f.length()), // Some versions of Android require downloads to be at least length 1
+ false); // Don't show a notification.
+ } else {
+ final Context context = GeckoAppShell.getContext();
+ final GeckoMediaScannerClient client = new GeckoMediaScannerClient(context, aFile, mimeType);
+ client.connect();
+ }
+ }
+
+ public static void removeDownload(final Download download) {
+ if (!useSystemDownloadManager()) {
+ return;
+ }
+
+ final DownloadManager dm = (DownloadManager) GeckoAppShell.getContext().getSystemService(Context.DOWNLOAD_SERVICE);
+
+ Cursor c = null;
+ try {
+ c = dm.query((new DownloadManager.Query()).setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL));
+ if (c == null || !c.moveToFirst()) {
+ return;
+ }
+
+ do {
+ final Download d = Download.fromCursor(c);
+ // Try hard as we can to verify this download is the one we think it is
+ if (download.equals(d)) {
+ dm.remove(d.id);
+ }
+ } while (c.moveToNext());
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+
+ private static final class GeckoMediaScannerClient implements MediaScannerConnectionClient {
+ private final String mFile;
+ private final String mMimeType;
+ private MediaScannerConnection mScanner;
+
+ public GeckoMediaScannerClient(Context context, String file, String mimeType) {
+ mFile = file;
+ mMimeType = mimeType;
+ mScanner = new MediaScannerConnection(context, this);
+ }
+
+ public void connect() {
+ mScanner.connect();
+ }
+
+ @Override
+ public void onMediaScannerConnected() {
+ mScanner.scanFile(mFile, mMimeType);
+ }
+
+ @Override
+ public void onScanCompleted(String path, Uri uri) {
+ if (path.equals(mFile)) {
+ mScanner.disconnect();
+ mScanner = null;
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java b/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java
new file mode 100644
index 000000000..28f542d5c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/DynamicToolbar.java
@@ -0,0 +1,218 @@
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.PrefsHelper.PrefHandlerBase;
+import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+
+public class DynamicToolbar {
+ private static final String LOGTAG = "DynamicToolbar";
+
+ private static final String STATE_ENABLED = "dynamic_toolbar";
+ private static final String CHROME_PREF = "browser.chrome.dynamictoolbar";
+
+ // DynamicToolbar is enabled iff prefEnabled is true *and* accessibilityEnabled is false,
+ // so it is disabled by default on startup. We do not enable it until we explicitly get
+ // the pref from Gecko telling us to turn it on.
+ private volatile boolean prefEnabled;
+ private boolean accessibilityEnabled;
+ // On some device we have to force-disable the dynamic toolbar because of
+ // bugs in the Android code. See bug 1231554.
+ private final boolean forceDisabled;
+
+ private final PrefsHelper.PrefHandler prefObserver;
+ private LayerView layerView;
+ private OnEnabledChangedListener enabledChangedListener;
+ private boolean temporarilyVisible;
+
+ public enum VisibilityTransition {
+ IMMEDIATE,
+ ANIMATE
+ }
+
+ /**
+ * Listener for changes to the dynamic toolbar's enabled state.
+ */
+ public interface OnEnabledChangedListener {
+ /**
+ * This callback is executed on the UI thread.
+ */
+ public void onEnabledChanged(boolean enabled);
+ }
+
+ public DynamicToolbar() {
+ // Listen to the dynamic toolbar pref
+ prefObserver = new PrefHandler();
+ PrefsHelper.addObserver(new String[] { CHROME_PREF }, prefObserver);
+ forceDisabled = isForceDisabled();
+ if (forceDisabled) {
+ Log.i(LOGTAG, "Force-disabling dynamic toolbar for " + Build.MODEL + " (" + Build.DEVICE + "/" + Build.PRODUCT + ")");
+ }
+ }
+
+ public static boolean isForceDisabled() {
+ // Force-disable dynamic toolbar on the variants of the Galaxy Note 10.1
+ // and Note 8.0 running Android 4.1.2. (Bug 1231554). This includes
+ // the following model numbers:
+ // GT-N8000, GT-N8005, GT-N8010, GT-N8013, GT-N8020
+ // GT-N5100, GT-N5110, GT-N5120
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN
+ && (Build.MODEL.startsWith("GT-N80") ||
+ Build.MODEL.startsWith("GT-N51"))) {
+ return true;
+ }
+ // Also disable variants of the Galaxy Note 4 on Android 5.0.1 (Bug 1301593)
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP
+ && (Build.MODEL.startsWith("SM-N910"))) {
+ return true;
+ }
+ return false;
+ }
+
+ public void destroy() {
+ PrefsHelper.removeObserver(prefObserver);
+ }
+
+ public void setLayerView(LayerView layerView) {
+ ThreadUtils.assertOnUiThread();
+
+ this.layerView = layerView;
+ }
+
+ public void setEnabledChangedListener(OnEnabledChangedListener listener) {
+ ThreadUtils.assertOnUiThread();
+
+ enabledChangedListener = listener;
+ }
+
+ public void onSaveInstanceState(Bundle outState) {
+ ThreadUtils.assertOnUiThread();
+
+ outState.putBoolean(STATE_ENABLED, prefEnabled);
+ }
+
+ public void onRestoreInstanceState(Bundle savedInstanceState) {
+ ThreadUtils.assertOnUiThread();
+
+ if (savedInstanceState != null) {
+ prefEnabled = savedInstanceState.getBoolean(STATE_ENABLED);
+ }
+ }
+
+ public boolean isEnabled() {
+ ThreadUtils.assertOnUiThread();
+
+ if (forceDisabled) {
+ return false;
+ }
+
+ return prefEnabled && !accessibilityEnabled;
+ }
+
+ public void setAccessibilityEnabled(boolean enabled) {
+ ThreadUtils.assertOnUiThread();
+
+ if (accessibilityEnabled == enabled) {
+ return;
+ }
+
+ // Disable the dynamic toolbar when accessibility features are enabled,
+ // and re-read the preference when they're disabled.
+ accessibilityEnabled = enabled;
+ if (prefEnabled) {
+ triggerEnabledListener();
+ }
+ }
+
+ public void setVisible(boolean visible, VisibilityTransition transition) {
+ ThreadUtils.assertOnUiThread();
+
+ if (layerView == null) {
+ return;
+ }
+
+ // Don't hide the ActionBar/Toolbar, if it's pinned open by TextSelection.
+ if (visible == false &&
+ layerView.getDynamicToolbarAnimator().isPinnedBy(PinReason.ACTION_MODE)) {
+ return;
+ }
+
+ final boolean isImmediate = transition == VisibilityTransition.IMMEDIATE;
+ if (visible) {
+ layerView.getDynamicToolbarAnimator().showToolbar(isImmediate);
+ } else {
+ layerView.getDynamicToolbarAnimator().hideToolbar(isImmediate);
+ }
+ }
+
+ public void setTemporarilyVisible(boolean visible, VisibilityTransition transition) {
+ ThreadUtils.assertOnUiThread();
+
+ if (layerView == null) {
+ return;
+ }
+
+ if (visible == temporarilyVisible) {
+ // nothing to do
+ return;
+ }
+
+ temporarilyVisible = visible;
+ final boolean isImmediate = transition == VisibilityTransition.IMMEDIATE;
+ if (visible) {
+ layerView.getDynamicToolbarAnimator().showToolbar(isImmediate);
+ } else {
+ layerView.getDynamicToolbarAnimator().hideToolbar(isImmediate);
+ }
+ }
+
+ public void persistTemporaryVisibility() {
+ ThreadUtils.assertOnUiThread();
+
+ if (temporarilyVisible) {
+ temporarilyVisible = false;
+ setVisible(true, VisibilityTransition.IMMEDIATE);
+ }
+ }
+
+ public void setPinned(boolean pinned, PinReason reason) {
+ ThreadUtils.assertOnUiThread();
+ if (layerView == null) {
+ return;
+ }
+
+ layerView.getDynamicToolbarAnimator().setPinned(pinned, reason);
+ }
+
+ private void triggerEnabledListener() {
+ if (enabledChangedListener != null) {
+ enabledChangedListener.onEnabledChanged(isEnabled());
+ }
+ }
+
+ private class PrefHandler extends PrefHandlerBase {
+ @Override
+ public void prefValue(String pref, boolean value) {
+ if (value == prefEnabled) {
+ return;
+ }
+
+ prefEnabled = value;
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // If accessibility is enabled, the dynamic toolbar is
+ // forced to be off.
+ if (!accessibilityEnabled) {
+ triggerEnabledListener();
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java b/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java
new file mode 100644
index 000000000..38c38a9eb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/EditBookmarkDialog.java
@@ -0,0 +1,252 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.database.Cursor;
+import android.support.design.widget.Snackbar;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.EditText;
+
+/**
+ * A dialog that allows editing a bookmarks url, title, or keywords
+ * <p>
+ * Invoked by calling one of the {@link org.mozilla.gecko.EditBookmarkDialog#show(String)}
+ * methods.
+ */
+public class EditBookmarkDialog {
+ private final Context mContext;
+
+ public EditBookmarkDialog(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * A private struct to make it easier to pass bookmark data across threads
+ */
+ private class Bookmark {
+ final int id;
+ final String title;
+ final String url;
+ final String keyword;
+
+ public Bookmark(int aId, String aTitle, String aUrl, String aKeyword) {
+ id = aId;
+ title = aTitle;
+ url = aUrl;
+ keyword = aKeyword;
+ }
+ }
+
+ /**
+ * This text watcher to enable or disable the OK button if the dialog contains
+ * valid information. This class is overridden to do data checking on different fields.
+ * By itself, it always enables the button.
+ *
+ * Callers can also assign a paired partner to the TextWatcher, and callers will check
+ * that both are enabled before enabling the ok button.
+ */
+ private class EditBookmarkTextWatcher implements TextWatcher {
+ // A stored reference to the dialog containing the text field being watched
+ protected AlertDialog mDialog;
+
+ // A stored text watcher to do the real verification of a field
+ protected EditBookmarkTextWatcher mPairedTextWatcher;
+
+ // Whether or not the ok button should be enabled.
+ protected boolean mEnabled = true;
+
+ public EditBookmarkTextWatcher(AlertDialog aDialog) {
+ mDialog = aDialog;
+ }
+
+ public void setPairedTextWatcher(EditBookmarkTextWatcher aTextWatcher) {
+ mPairedTextWatcher = aTextWatcher;
+ }
+
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+
+ // Textwatcher interface
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // Disable if the we're disabled or the paired partner is disabled
+ boolean enabled = mEnabled && (mPairedTextWatcher == null || mPairedTextWatcher.isEnabled());
+ mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(enabled);
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {}
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ }
+
+ /**
+ * A version of the EditBookmarkTextWatcher for the url field of the dialog.
+ * Only checks if the field is empty or not.
+ */
+ private class LocationTextWatcher extends EditBookmarkTextWatcher {
+ public LocationTextWatcher(AlertDialog aDialog) {
+ super(aDialog);
+ }
+
+ // Disables the ok button if the location field is empty.
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ mEnabled = (s.toString().trim().length() > 0);
+ super.onTextChanged(s, start, before, count);
+ }
+ }
+
+ /**
+ * A version of the EditBookmarkTextWatcher for the keyword field of the dialog.
+ * Checks if the field has any (non leading or trailing) spaces.
+ */
+ private class KeywordTextWatcher extends EditBookmarkTextWatcher {
+ public KeywordTextWatcher(AlertDialog aDialog) {
+ super(aDialog);
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // Disable if the keyword contains spaces
+ mEnabled = (s.toString().trim().indexOf(' ') == -1);
+ super.onTextChanged(s, start, before, count);
+ }
+ }
+
+ /**
+ * Show the Edit bookmark dialog for a particular url. If the url is bookmarked multiple times
+ * this will just edit the first instance it finds.
+ *
+ * @param url The url of the bookmark to edit. The dialog will look up other information like the id,
+ * current title, or keywords associated with this url. If the url isn't bookmarked, the
+ * dialog will fail silently. If the url is bookmarked multiple times, this will only show
+ * information about the first it finds.
+ */
+ public void show(final String url) {
+ final ContentResolver cr = mContext.getContentResolver();
+ final BrowserDB db = BrowserDB.from(mContext);
+ (new UIAsyncTask.WithoutParams<Bookmark>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public Bookmark doInBackground() {
+ final Cursor cursor = db.getBookmarkForUrl(cr, url);
+ if (cursor == null) {
+ return null;
+ }
+
+ Bookmark bookmark = null;
+ try {
+ cursor.moveToFirst();
+ bookmark = new Bookmark(cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID)),
+ cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.TITLE)),
+ cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL)),
+ cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.KEYWORD)));
+ } finally {
+ cursor.close();
+ }
+ return bookmark;
+ }
+
+ @Override
+ public void onPostExecute(Bookmark bookmark) {
+ if (bookmark == null) {
+ return;
+ }
+
+ show(bookmark.id, bookmark.title, bookmark.url, bookmark.keyword);
+ }
+ }).execute();
+ }
+
+ /**
+ * Show the Edit bookmark dialog for a set of data. This will show the dialog whether
+ * a bookmark with this url exists or not, but the results will NOT be saved if the id
+ * is not a valid bookmark id.
+ *
+ * @param id The id of the bookmark to change. If there is no bookmark with this ID, the dialog
+ * will fail silently.
+ * @param title The initial title to show in the dialog
+ * @param url The initial url to show in the dialog
+ * @param keyword The initial keyword to show in the dialog
+ */
+ public void show(final int id, final String title, final String url, final String keyword) {
+ final Context context = mContext;
+
+ AlertDialog.Builder editPrompt = new AlertDialog.Builder(context);
+ final View editView = LayoutInflater.from(context).inflate(R.layout.bookmark_edit, null);
+ editPrompt.setTitle(R.string.bookmark_edit_title);
+ editPrompt.setView(editView);
+
+ final EditText nameText = ((EditText) editView.findViewById(R.id.edit_bookmark_name));
+ final EditText locationText = ((EditText) editView.findViewById(R.id.edit_bookmark_location));
+ final EditText keywordText = ((EditText) editView.findViewById(R.id.edit_bookmark_keyword));
+ nameText.setText(title);
+ locationText.setText(url);
+ keywordText.setText(keyword);
+
+ final BrowserDB db = BrowserDB.from(mContext);
+ editPrompt.setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int whichButton) {
+ (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public Void doInBackground() {
+ String newUrl = locationText.getText().toString().trim();
+ String newKeyword = keywordText.getText().toString().trim();
+
+ db.updateBookmark(context.getContentResolver(), id, newUrl, nameText.getText().toString(), newKeyword);
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(Void result) {
+ SnackbarBuilder.builder((Activity) context)
+ .message(R.string.bookmark_updated)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ }).execute();
+ }
+ });
+
+ editPrompt.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int whichButton) {
+ // do nothing
+ }
+ });
+
+ final AlertDialog dialog = editPrompt.create();
+
+ // Create our TextWatchers
+ LocationTextWatcher locationTextWatcher = new LocationTextWatcher(dialog);
+ KeywordTextWatcher keywordTextWatcher = new KeywordTextWatcher(dialog);
+
+ // Cross reference the TextWatchers
+ locationTextWatcher.setPairedTextWatcher(keywordTextWatcher);
+ keywordTextWatcher.setPairedTextWatcher(locationTextWatcher);
+
+ // Add the TextWatcher Listeners
+ locationText.addTextChangedListener(locationTextWatcher);
+ keywordText.addTextChangedListener(keywordTextWatcher);
+
+ dialog.show();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Experiments.java b/mobile/android/base/java/org/mozilla/gecko/Experiments.java
new file mode 100644
index 000000000..e71bb4c52
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Experiments.java
@@ -0,0 +1,119 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+
+import android.util.Log;
+import android.text.TextUtils;
+
+import com.keepsafe.switchboard.Preferences;
+import com.keepsafe.switchboard.SwitchBoard;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * This class should reflect the experiment names found in the Switchboard experiments config here:
+ * https://github.com/mozilla-services/switchboard-experiments
+ */
+public class Experiments {
+ private static final String LOGTAG = "GeckoExperiments";
+
+ // Show a system notification linking to a "What's New" page on app update.
+ public static final String WHATSNEW_NOTIFICATION = "whatsnew-notification";
+
+ // Subscribe to known, bookmarked sites and show a notification if new content is available.
+ public static final String CONTENT_NOTIFICATIONS_12HRS = "content-notifications-12hrs";
+ public static final String CONTENT_NOTIFICATIONS_8AM = "content-notifications-8am";
+ public static final String CONTENT_NOTIFICATIONS_5PM = "content-notifications-5pm";
+
+ // Onboarding: "Features and Story". These experiments are determined
+ // on the client, they are not part of the server config.
+ public static final String ONBOARDING3_A = "onboarding3-a"; // Control: No first run
+ public static final String ONBOARDING3_B = "onboarding3-b"; // 4 static Feature + 1 dynamic slides
+ public static final String ONBOARDING3_C = "onboarding3-c"; // Differentiating features slides
+
+ // Synchronizing the catalog of downloadable content from Kinto
+ public static final String DOWNLOAD_CONTENT_CATALOG_SYNC = "download-content-catalog-sync";
+
+ // Promotion for "Add to homescreen"
+ public static final String PROMOTE_ADD_TO_HOMESCREEN = "promote-add-to-homescreen";
+
+ public static final String PREF_ONBOARDING_VERSION = "onboarding_version";
+
+ // Promotion to bookmark reader-view items after entering reader view three times (Bug 1247689)
+ public static final String TRIPLE_READERVIEW_BOOKMARK_PROMPT = "triple-readerview-bookmark-prompt";
+
+ // Only show origin in URL bar instead of full URL (Bug 1236431)
+ public static final String URLBAR_SHOW_ORIGIN_ONLY = "urlbar-show-origin-only";
+
+ // Show name of organization (EV cert) instead of full URL in URL bar (Bug 1249594).
+ public static final String URLBAR_SHOW_EV_CERT_OWNER = "urlbar-show-ev-cert-owner";
+
+ // Play HLS videos in a VideoView (Bug 1313391)
+ public static final String HLS_VIDEO_PLAYBACK = "hls-video-playback";
+
+ // Make new activity stream panel available (to replace top sites) (Bug 1313316)
+ public static final String ACTIVITY_STREAM = "activity-stream";
+
+ /**
+ * Returns if a user is in certain local experiment.
+ * @param experiment Name of experiment to look up
+ * @return returns value for experiment or false if experiment does not exist.
+ */
+ public static boolean isInExperimentLocal(Context context, String experiment) {
+ if (SwitchBoard.isInBucket(context, 0, 20)) {
+ return Experiments.ONBOARDING3_A.equals(experiment);
+ } else if (SwitchBoard.isInBucket(context, 20, 60)) {
+ return Experiments.ONBOARDING3_B.equals(experiment);
+ } else if (SwitchBoard.isInBucket(context, 60, 100)) {
+ return Experiments.ONBOARDING3_C.equals(experiment);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Returns list of all active experiments, remote and local.
+ * @return List of experiment names Strings
+ */
+ public static List<String> getActiveExperiments(Context c) {
+ final List<String> experiments = new LinkedList<>();
+ experiments.addAll(SwitchBoard.getActiveExperiments(c));
+
+ // Add onboarding version.
+ final String onboardingExperiment = GeckoSharedPrefs.forProfile(c).getString(Experiments.PREF_ONBOARDING_VERSION, null);
+ if (!TextUtils.isEmpty(onboardingExperiment)) {
+ experiments.add(onboardingExperiment);
+ }
+
+ return experiments;
+ }
+
+ /**
+ * Sets an override to force an experiment to be enabled or disabled. This value
+ * will be read and used before reading the switchboard server configuration.
+ *
+ * @param c Context
+ * @param experimentName Experiment name
+ * @param isEnabled Whether or not the experiment should be enabled
+ */
+ public static void setOverride(Context c, String experimentName, boolean isEnabled) {
+ Log.d(LOGTAG, "setOverride: " + experimentName + " = " + isEnabled);
+ Preferences.setOverrideValue(c, experimentName, isEnabled);
+ }
+
+ /**
+ * Clears the override value for an experiment.
+ *
+ * @param c Context
+ * @param experimentName Experiment name
+ */
+ public static void clearOverride(Context c, String experimentName) {
+ Log.d(LOGTAG, "clearOverride: " + experimentName);
+ Preferences.clearOverrideValue(c, experimentName);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/FilePicker.java b/mobile/android/base/java/org/mozilla/gecko/FilePicker.java
new file mode 100644
index 000000000..8ac5428a4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/FilePicker.java
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.GeckoEventListener;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.Parcelable;
+import android.provider.MediaStore;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public class FilePicker implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoFilePicker";
+ private static FilePicker sFilePicker;
+ private final Context context;
+
+ public interface ResultHandler {
+ public void gotFile(String filename);
+ }
+
+ public static void init(Context context) {
+ if (sFilePicker == null) {
+ sFilePicker = new FilePicker(context.getApplicationContext());
+ }
+ }
+
+ protected FilePicker(Context context) {
+ this.context = context;
+ EventDispatcher.getInstance().registerGeckoThreadListener(this, "FilePicker:Show");
+ }
+
+ @Override
+ public void handleMessage(String event, final JSONObject message) {
+ if (event.equals("FilePicker:Show")) {
+ String mimeType = "*/*";
+ final String mode = message.optString("mode");
+ final int tabId = message.optInt("tabId", -1);
+ final String title = message.optString("title");
+
+ if ("mimeType".equals(mode))
+ mimeType = message.optString("mimeType");
+ else if ("extension".equals(mode))
+ mimeType = GeckoAppShell.getMimeTypeFromExtensions(message.optString("extensions"));
+
+ showFilePickerAsync(title, mimeType, new ResultHandler() {
+ @Override
+ public void gotFile(String filename) {
+ try {
+ message.put("file", filename);
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Can't add filename to message " + filename);
+ }
+
+
+ GeckoAppShell.notifyObservers("FilePicker:Result", message.toString());
+ }
+ }, tabId);
+ }
+ }
+
+ private void addActivities(Intent intent, HashMap<String, Intent> intents, HashMap<String, Intent> filters) {
+ PackageManager pm = context.getPackageManager();
+ List<ResolveInfo> lri = pm.queryIntentActivities(intent, 0);
+ for (ResolveInfo ri : lri) {
+ ComponentName cn = new ComponentName(ri.activityInfo.applicationInfo.packageName, ri.activityInfo.name);
+ if (filters != null && !filters.containsKey(cn.toString())) {
+ Intent rintent = new Intent(intent);
+ rintent.setComponent(cn);
+ intents.put(cn.toString(), rintent);
+ }
+ }
+ }
+
+ private Intent getIntent(String mimeType) {
+ Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
+ intent.setType(mimeType);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ return intent;
+ }
+
+ private List<Intent> getIntentsForFilePicker(final String mimeType,
+ final FilePickerResultHandler fileHandler) {
+ // The base intent to use for the file picker. Even if this is an implicit intent, Android will
+ // still show a list of Activities that match this action/type.
+ Intent baseIntent;
+ // A HashMap of Activities the base intent will show in the chooser. This is used
+ // to filter activities from other intents so that we don't show duplicates.
+ HashMap<String, Intent> baseIntents = new HashMap<String, Intent>();
+ // A list of other activities to shwo in the picker (and the intents to launch them).
+ HashMap<String, Intent> intents = new HashMap<String, Intent> ();
+
+ if ("audio/*".equals(mimeType)) {
+ // For audio the only intent is the mimetype
+ baseIntent = getIntent(mimeType);
+ addActivities(baseIntent, baseIntents, null);
+ } else if ("image/*".equals(mimeType)) {
+ // For images the base is a capture intent
+ baseIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ baseIntent.putExtra(MediaStore.EXTRA_OUTPUT,
+ Uri.fromFile(new File(Environment.getExternalStorageDirectory(),
+ fileHandler.generateImageName())));
+ addActivities(baseIntent, baseIntents, null);
+
+ // We also add the mimetype intent
+ addActivities(getIntent(mimeType), intents, baseIntents);
+ } else if ("video/*".equals(mimeType)) {
+ // For videos the base is a capture intent
+ baseIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+ addActivities(baseIntent, baseIntents, null);
+
+ // We also add the mimetype intent
+ addActivities(getIntent(mimeType), intents, baseIntents);
+ } else {
+ baseIntent = getIntent("*/*");
+ addActivities(baseIntent, baseIntents, null);
+
+ Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
+ intent.putExtra(MediaStore.EXTRA_OUTPUT,
+ Uri.fromFile(new File(Environment.getExternalStorageDirectory(),
+ fileHandler.generateImageName())));
+ addActivities(intent, intents, baseIntents);
+ intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
+ addActivities(intent, intents, baseIntents);
+ }
+
+ // If we didn't find any activities, we fall back to the */* mimetype intent
+ if (baseIntents.size() == 0 && intents.size() == 0) {
+ intents.clear();
+
+ baseIntent = getIntent("*/*");
+ addActivities(baseIntent, baseIntents, null);
+ }
+
+ ArrayList<Intent> vals = new ArrayList<Intent>(intents.values());
+ vals.add(0, baseIntent);
+ return vals;
+ }
+
+ private String getFilePickerTitle(String mimeType) {
+ if (mimeType.equals("audio/*")) {
+ return context.getString(R.string.filepicker_audio_title);
+ } else if (mimeType.equals("image/*")) {
+ return context.getString(R.string.filepicker_image_title);
+ } else if (mimeType.equals("video/*")) {
+ return context.getString(R.string.filepicker_video_title);
+ } else {
+ return context.getString(R.string.filepicker_title);
+ }
+ }
+
+ private interface IntentHandler {
+ public void gotIntent(Intent intent);
+ }
+
+ /* Gets an intent that can open a particular mimetype. Will show a prompt with a list
+ * of Activities that can handle the mietype. Asynchronously calls the handler when
+ * one of the intents is selected. If the caller passes in null for the handler, will still
+ * prompt for the activity, but will throw away the result.
+ */
+ private void getFilePickerIntentAsync(String title,
+ final String mimeType,
+ final FilePickerResultHandler fileHandler,
+ final IntentHandler handler) {
+ List<Intent> intents = getIntentsForFilePicker(mimeType, fileHandler);
+
+ if (intents.size() == 0) {
+ Log.i(LOGTAG, "no activities for the file picker!");
+ handler.gotIntent(null);
+ return;
+ }
+
+ Intent base = intents.remove(0);
+
+ if (intents.size() == 0) {
+ handler.gotIntent(base);
+ return;
+ }
+
+ if (TextUtils.isEmpty(title)) {
+ title = getFilePickerTitle(mimeType);
+ }
+ Intent chooser = Intent.createChooser(base, title);
+ chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[intents.size()]));
+ handler.gotIntent(chooser);
+ }
+
+ /* Allows the user to pick an activity to load files from using a list prompt. Then opens the activity and
+ * sends the file returned to the passed in handler. If a null handler is passed in, will still
+ * pick and launch the file picker, but will throw away the result.
+ */
+ protected void showFilePickerAsync(final String title, final String mimeType, final ResultHandler handler, final int tabId) {
+ final FilePickerResultHandler fileHandler = new FilePickerResultHandler(handler, context, tabId);
+ getFilePickerIntentAsync(title, mimeType, fileHandler, new IntentHandler() {
+ @Override
+ public void gotIntent(Intent intent) {
+ if (handler == null) {
+ return;
+ }
+
+ if (intent == null) {
+ handler.gotFile("");
+ return;
+ }
+
+ ActivityHandlerHelper.startIntent(intent, fileHandler);
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java b/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java
new file mode 100644
index 000000000..7629ea546
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Process;
+import android.provider.MediaStore;
+import android.provider.OpenableColumns;
+import android.support.v4.app.FragmentActivity;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.CursorLoader;
+import android.support.v4.content.Loader;
+import android.text.TextUtils;
+import android.text.format.Time;
+import android.util.Log;
+
+class FilePickerResultHandler implements ActivityResultHandler {
+ private static final String LOGTAG = "GeckoFilePickerResultHandler";
+ private static final String UPLOADS_DIR = "uploads";
+
+ private final FilePicker.ResultHandler handler;
+ private final int tabId;
+ private final File cacheDir;
+
+ // this code is really hacky and doesn't belong anywhere so I'm putting it here for now
+ // until I can come up with a better solution.
+ private String mImageName = "";
+
+ /* Use this constructor to asynchronously listen for results */
+ public FilePickerResultHandler(final FilePicker.ResultHandler handler, final Context context, final int tabId) {
+ this.tabId = tabId;
+ this.cacheDir = new File(context.getCacheDir(), UPLOADS_DIR);
+ this.handler = handler;
+ }
+
+ void sendResult(String res) {
+ if (handler != null) {
+ handler.gotFile(res);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int resultCode, Intent intent) {
+ if (resultCode != Activity.RESULT_OK) {
+ sendResult("");
+ return;
+ }
+
+ // Camera results won't return an Intent. Use the file name we passed to the original intent.
+ // In Android M, camera results return an empty Intent rather than null.
+ if (intent == null || (intent.getAction() == null && intent.getData() == null)) {
+ if (mImageName != null) {
+ File file = new File(Environment.getExternalStorageDirectory(), mImageName);
+ sendResult(file.getAbsolutePath());
+ } else {
+ sendResult("");
+ }
+ return;
+ }
+
+ Uri uri = intent.getData();
+ if (uri == null) {
+ sendResult("");
+ return;
+ }
+
+ // Some file pickers may return a file uri
+ if ("file".equals(uri.getScheme())) {
+ String path = uri.getPath();
+ sendResult(path == null ? "" : path);
+ return;
+ }
+
+ final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity();
+ final LoaderManager lm = fa.getSupportLoaderManager();
+
+ // Finally, Video pickers and some file pickers may return a content provider.
+ final ContentResolver cr = fa.getContentResolver();
+ final Cursor cursor = cr.query(uri, new String[] { MediaStore.Video.Media.DATA }, null, null, null);
+ if (cursor != null) {
+ try {
+ // Try a query to make sure the expected columns exist
+ int index = cursor.getColumnIndex(MediaStore.Video.Media.DATA);
+ if (index >= 0) {
+ lm.initLoader(intent.hashCode(), null, new VideoLoaderCallbacks(uri));
+ return;
+ }
+ } catch (Exception ex) {
+ // We'll try a different loader below
+ } finally {
+ cursor.close();
+ }
+ }
+
+ lm.initLoader(uri.hashCode(), null, new FileLoaderCallbacks(uri, cacheDir, tabId));
+ }
+
+ public String generateImageName() {
+ Time now = new Time();
+ now.setToNow();
+ mImageName = now.format("%Y-%m-%d %H.%M.%S") + ".jpg";
+ return mImageName;
+ }
+
+ private class VideoLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ final private Uri uri;
+ public VideoLoaderCallbacks(Uri uri) {
+ this.uri = uri;
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity();
+ return new CursorLoader(fa,
+ uri,
+ new String[] { MediaStore.Video.Media.DATA },
+ null, // selection
+ null, // selectionArgs
+ null); // sortOrder
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ if (cursor.moveToFirst()) {
+ String res = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA));
+
+ // Some pickers (the KitKat Documents one for instance) won't return a temporary file here.
+ // Fall back to the normal FileLoader if we didn't find anything.
+ if (TextUtils.isEmpty(res)) {
+ tryFileLoaderCallback();
+ return;
+ }
+
+ sendResult(res);
+ } else {
+ tryFileLoaderCallback();
+ }
+ }
+
+ private void tryFileLoaderCallback() {
+ final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity();
+ final LoaderManager lm = fa.getSupportLoaderManager();
+ lm.initLoader(uri.hashCode(), null, new FileLoaderCallbacks(uri, cacheDir, tabId));
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) { }
+ }
+
+ /**
+ * This class's only dependency on FilePickerResultHandler is sendResult.
+ */
+ private class FileLoaderCallbacks implements LoaderCallbacks<Cursor>,
+ Tabs.OnTabsChangedListener {
+ private final Uri uri;
+ private final File cacheDir;
+ private final int tabId;
+ String tempFile;
+
+ public FileLoaderCallbacks(Uri uri, File cacheDir, int tabId) {
+ this.uri = uri;
+ this.cacheDir = cacheDir;
+ this.tabId = tabId;
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity();
+ return new CursorLoader(fa,
+ uri,
+ new String[] { OpenableColumns.DISPLAY_NAME },
+ null, // selection
+ null, // selectionArgs
+ null); // sortOrder
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ if (cursor.moveToFirst()) {
+ String name = cursor.getString(0);
+ // tmp filenames must be at least 3 characters long. Add a prefix to make sure that happens
+ String fileName = "tmp_" + Process.myPid() + "-";
+ String fileExt;
+ int period;
+
+ final FragmentActivity fa = (FragmentActivity) GeckoAppShell.getGeckoInterface().getActivity();
+ final ContentResolver cr = fa.getContentResolver();
+
+ // Generate an extension if we don't already have one
+ if (name == null || (period = name.lastIndexOf('.')) == -1) {
+ String mimeType = cr.getType(uri);
+ fileExt = "." + GeckoAppShell.getExtensionFromMimeType(mimeType);
+ } else {
+ fileExt = name.substring(period);
+ fileName += name.substring(0, period);
+ }
+
+ // Now write the data to the temp file
+ FileOutputStream fos = null;
+ try {
+ cacheDir.mkdir();
+
+ File file = File.createTempFile(fileName, fileExt, cacheDir);
+ fos = new FileOutputStream(file);
+ InputStream is = cr.openInputStream(uri);
+ byte[] buf = new byte[4096];
+ int len = is.read(buf);
+ while (len != -1) {
+ fos.write(buf, 0, len);
+ len = is.read(buf);
+ }
+ fos.close();
+ is.close();
+ tempFile = file.getAbsolutePath();
+ sendResult((tempFile == null) ? "" : tempFile);
+
+ if (tabId > -1 && !TextUtils.isEmpty(tempFile)) {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+ } catch (IOException ex) {
+ Log.i(LOGTAG, "Error writing file", ex);
+ } finally {
+ if (fos != null) {
+ try {
+ fos.close();
+ } catch (IOException e) { /* not much to do here */ }
+ }
+ }
+ } else {
+ sendResult("");
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) { }
+
+ /*Tabs.OnTabsChangedListener*/
+ // This cleans up our temp file. If it doesn't run, we just hope that Android
+ // will eventually does the cleanup for us.
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ if ((tab == null) || (tab.getId() != tabId)) {
+ return;
+ }
+
+ if (msg == Tabs.TabEvents.LOCATION_CHANGE ||
+ msg == Tabs.TabEvents.CLOSED) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ File f = new File(tempFile);
+ f.delete();
+ }
+ });
+
+ // Tabs' listener array is safe to modify during use: its
+ // iteration pattern is based on snapshots.
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+ }
+ }
+
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java b/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java
new file mode 100644
index 000000000..efa04a04e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/FindInPageBar.java
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+public class FindInPageBar extends LinearLayout implements TextWatcher, View.OnClickListener, GeckoEventListener {
+ private static final String LOGTAG = "GeckoFindInPageBar";
+ private static final String REQUEST_ID = "FindInPageBar";
+
+ private final Context mContext;
+ private CustomEditText mFindText;
+ private TextView mStatusText;
+ private boolean mInflated;
+
+ public FindInPageBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ setFocusable(true);
+ }
+
+ public void inflateContent() {
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ View content = inflater.inflate(R.layout.find_in_page_content, this);
+
+ content.findViewById(R.id.find_prev).setOnClickListener(this);
+ content.findViewById(R.id.find_next).setOnClickListener(this);
+ content.findViewById(R.id.find_close).setOnClickListener(this);
+
+ // Capture clicks on the rest of the view to prevent them from
+ // leaking into other views positioned below.
+ content.setOnClickListener(this);
+
+ mFindText = (CustomEditText) content.findViewById(R.id.find_text);
+ mFindText.addTextChangedListener(this);
+ mFindText.setOnKeyPreImeListener(new CustomEditText.OnKeyPreImeListener() {
+ @Override
+ public boolean onKeyPreIme(View v, int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ hide();
+ return true;
+ }
+ return false;
+ }
+ });
+
+ mStatusText = (TextView) content.findViewById(R.id.find_status);
+
+ mInflated = true;
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "FindInPage:MatchesCountResult",
+ "TextSelection:Data");
+ }
+
+ public void show() {
+ if (!mInflated)
+ inflateContent();
+
+ setVisibility(VISIBLE);
+ mFindText.requestFocus();
+
+ // handleMessage() receives response message and determines initial state of softInput
+ GeckoAppShell.notifyObservers("TextSelection:Get", REQUEST_ID);
+ GeckoAppShell.notifyObservers("FindInPage:Opened", null);
+ }
+
+ public void hide() {
+ if (!mInflated || getVisibility() == View.GONE) {
+ // There's nothing to hide yet.
+ return;
+ }
+
+ // Always clear the Find string, primarily for privacy.
+ mFindText.setText("");
+
+ // Only close the IMM if its EditText is the one with focus.
+ if (mFindText.isFocused()) {
+ getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0);
+ }
+
+ // Close the FIPB / FindHelper state.
+ setVisibility(GONE);
+ GeckoAppShell.notifyObservers("FindInPage:Closed", null);
+ }
+
+ private InputMethodManager getInputMethodManager(View view) {
+ Context context = view.getContext();
+ return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ public void onDestroy() {
+ if (!mInflated) {
+ return;
+ }
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "FindInPage:MatchesCountResult",
+ "TextSelection:Data");
+ }
+
+ private void onMatchesCountResult(final int total, final int current, final int limit, final String searchString) {
+ if (total == -1) {
+ updateResult(Integer.toString(limit) + "+");
+ } else if (total > 0) {
+ updateResult(Integer.toString(current) + "/" + Integer.toString(total));
+ } else if (TextUtils.isEmpty(searchString)) {
+ updateResult("");
+ } else {
+ // We display 0/0, when there were no
+ // matches found, or if matching has been turned off by setting
+ // pref accessibility.typeaheadfind.matchesCountLimit to 0.
+ updateResult("0/0");
+ }
+ }
+
+ private void updateResult(final String statusText) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mStatusText.setVisibility(statusText.isEmpty() ? View.GONE : View.VISIBLE);
+ mStatusText.setText(statusText);
+ }
+ });
+ }
+
+ // TextWatcher implementation
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ sendRequestToFinderHelper("FindInPage:Find", s.toString());
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ // ignore
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ // ignore
+ }
+
+ // View.OnClickListener implementation
+
+ @Override
+ public void onClick(View v) {
+ final int viewId = v.getId();
+
+ String extras = getResources().getResourceEntryName(viewId);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, extras);
+
+ if (viewId == R.id.find_prev) {
+ sendRequestToFinderHelper("FindInPage:Prev", mFindText.getText().toString());
+ getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0);
+ return;
+ }
+
+ if (viewId == R.id.find_next) {
+ sendRequestToFinderHelper("FindInPage:Next", mFindText.getText().toString());
+ getInputMethodManager(mFindText).hideSoftInputFromWindow(mFindText.getWindowToken(), 0);
+ return;
+ }
+
+ if (viewId == R.id.find_close) {
+ hide();
+ }
+ }
+
+ // GeckoEventListener implementation
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ if (event.equals("FindInPage:MatchesCountResult")) {
+ onMatchesCountResult(message.optInt("total", 0),
+ message.optInt("current", 0),
+ message.optInt("limit", 0),
+ message.optString("searchString"));
+ return;
+ }
+
+ if (!event.equals("TextSelection:Data") || !REQUEST_ID.equals(message.optString("requestId"))) {
+ return;
+ }
+
+ final String text = message.optString("text");
+
+ // Populate an initial find string, virtual keyboard not required.
+ if (!TextUtils.isEmpty(text)) {
+ // Populate initial selection
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mFindText.setText(text);
+ }
+ });
+ return;
+ }
+
+ // Show the virtual keyboard.
+ if (mFindText.hasWindowFocus()) {
+ getInputMethodManager(mFindText).showSoftInput(mFindText, 0);
+ } else {
+ // showSoftInput won't work until after the window is focused.
+ mFindText.setOnWindowFocusChangeListener(new CustomEditText.OnWindowFocusChangeListener() {
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (!hasFocus)
+ return;
+
+ mFindText.setOnWindowFocusChangeListener(null);
+ getInputMethodManager(mFindText).showSoftInput(mFindText, 0);
+ }
+ });
+ }
+ }
+
+ /**
+ * Request find operation, and update matchCount results (current count and total).
+ */
+ private void sendRequestToFinderHelper(final String request, final String searchString) {
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest(request, searchString) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ // We don't care about the return value, because `onMatchesCountResult`
+ // does the heavy lifting.
+ }
+
+ @Override
+ public void onError(NativeJSObject error) {
+ // Gecko didn't respond due to state change, javascript error, etc.
+ Log.d(LOGTAG, "No response from Gecko on request to match string: [" +
+ searchString + "]");
+ updateResult("");
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java b/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
new file mode 100644
index 000000000..5c7f932c0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
@@ -0,0 +1,459 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.gfx.FloatSize;
+import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener;
+import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener.OnDismissCallback;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.PointF;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Pair;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.RelativeLayout.LayoutParams;
+import android.widget.TextView;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+public class FormAssistPopup extends RelativeLayout implements GeckoEventListener {
+ private final Context mContext;
+ private final Animation mAnimation;
+
+ private ListView mAutoCompleteList;
+ private RelativeLayout mValidationMessage;
+ private TextView mValidationMessageText;
+ private ImageView mValidationMessageArrow;
+ private ImageView mValidationMessageArrowInverted;
+
+ private double mX;
+ private double mY;
+ private double mW;
+ private double mH;
+
+ private enum PopupType {
+ AUTOCOMPLETE,
+ VALIDATIONMESSAGE;
+ }
+ private PopupType mPopupType;
+
+ private static final int MAX_VISIBLE_ROWS = 5;
+
+ private static int sAutoCompleteMinWidth;
+ private static int sAutoCompleteRowHeight;
+ private static int sValidationMessageHeight;
+ private static int sValidationTextMarginTop;
+ private static LayoutParams sValidationTextLayoutNormal;
+ private static LayoutParams sValidationTextLayoutInverted;
+
+ private static final String LOGTAG = "GeckoFormAssistPopup";
+
+ // The blocklist is so short that ArrayList is probably cheaper than HashSet.
+ private static final Collection<String> sInputMethodBlocklist = Arrays.asList(
+ InputMethods.METHOD_GOOGLE_JAPANESE_INPUT, // bug 775850
+ InputMethods.METHOD_OPENWNN_PLUS, // bug 768108
+ InputMethods.METHOD_SIMEJI, // bug 768108
+ InputMethods.METHOD_SWYPE, // bug 755909
+ InputMethods.METHOD_SWYPE_BETA // bug 755909
+ );
+
+ public FormAssistPopup(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+
+ mAnimation = AnimationUtils.loadAnimation(context, R.anim.grow_fade_in);
+ mAnimation.setDuration(75);
+
+ setFocusable(false);
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "FormAssist:AutoComplete",
+ "FormAssist:ValidationMessage",
+ "FormAssist:Hide");
+ }
+
+ void destroy() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "FormAssist:AutoComplete",
+ "FormAssist:ValidationMessage",
+ "FormAssist:Hide");
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("FormAssist:AutoComplete")) {
+ handleAutoCompleteMessage(message);
+ } else if (event.equals("FormAssist:ValidationMessage")) {
+ handleValidationMessage(message);
+ } else if (event.equals("FormAssist:Hide")) {
+ handleHideMessage(message);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ private void handleAutoCompleteMessage(JSONObject message) throws JSONException {
+ final JSONArray suggestions = message.getJSONArray("suggestions");
+ final JSONObject rect = message.getJSONObject("rect");
+ final boolean isEmpty = message.getBoolean("isEmpty");
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ showAutoCompleteSuggestions(suggestions, rect, isEmpty);
+ }
+ });
+ }
+
+ private void handleValidationMessage(JSONObject message) throws JSONException {
+ final String validationMessage = message.getString("validationMessage");
+ final JSONObject rect = message.getJSONObject("rect");
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ showValidationMessage(validationMessage, rect);
+ }
+ });
+ }
+
+ private void handleHideMessage(JSONObject message) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ hide();
+ }
+ });
+ }
+
+ private void showAutoCompleteSuggestions(JSONArray suggestions, JSONObject rect, boolean isEmpty) {
+ final String inputMethod = InputMethods.getCurrentInputMethod(mContext);
+ if (!isEmpty && sInputMethodBlocklist.contains(inputMethod)) {
+ // Don't display the form auto-complete popup after the user starts typing
+ // to avoid confusing somes IME. See bug 758820 and bug 632744.
+ hide();
+ return;
+ }
+
+ if (mAutoCompleteList == null) {
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ mAutoCompleteList = (ListView) inflater.inflate(R.layout.autocomplete_list, null);
+
+ mAutoCompleteList.setOnItemClickListener(new OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parentView, View view, int position, long id) {
+ // Use the value stored with the autocomplete view, not the label text,
+ // since they can be different.
+ TextView textView = (TextView) view;
+ String value = (String) textView.getTag();
+ broadcastGeckoEvent("FormAssist:AutoComplete", value);
+ hide();
+ }
+ });
+
+ // Create a ListView-specific touch listener. ListViews are given special treatment because
+ // by default they handle touches for their list items... i.e. they're in charge of drawing
+ // the pressed state (the list selector), handling list item clicks, etc.
+ final SwipeDismissListViewTouchListener touchListener = new SwipeDismissListViewTouchListener(mAutoCompleteList, new OnDismissCallback() {
+ @Override
+ public void onDismiss(ListView listView, final int position) {
+ // Use the value stored with the autocomplete view, not the label text,
+ // since they can be different.
+ AutoCompleteListAdapter adapter = (AutoCompleteListAdapter) listView.getAdapter();
+ Pair<String, String> item = adapter.getItem(position);
+
+ // Remove the item from form history.
+ broadcastGeckoEvent("FormAssist:Remove", item.second);
+
+ // Update the list
+ adapter.remove(item);
+ adapter.notifyDataSetChanged();
+ positionAndShowPopup();
+ }
+ });
+ mAutoCompleteList.setOnTouchListener(touchListener);
+
+ // Setting this scroll listener is required to ensure that during ListView scrolling,
+ // we don't look for swipes.
+ mAutoCompleteList.setOnScrollListener(touchListener.makeScrollListener());
+
+ // Setting this recycler listener is required to make sure animated views are reset.
+ mAutoCompleteList.setRecyclerListener(touchListener.makeRecyclerListener());
+
+ addView(mAutoCompleteList);
+ }
+
+ AutoCompleteListAdapter adapter = new AutoCompleteListAdapter(mContext, R.layout.autocomplete_list_item);
+ adapter.populateSuggestionsList(suggestions);
+ mAutoCompleteList.setAdapter(adapter);
+
+ if (setGeckoPositionData(rect, true)) {
+ positionAndShowPopup();
+ }
+ }
+
+ private void showValidationMessage(String validationMessage, JSONObject rect) {
+ if (mValidationMessage == null) {
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ mValidationMessage = (RelativeLayout) inflater.inflate(R.layout.validation_message, null);
+
+ addView(mValidationMessage);
+ mValidationMessageText = (TextView) mValidationMessage.findViewById(R.id.validation_message_text);
+
+ sValidationTextMarginTop = (int) (mContext.getResources().getDimension(R.dimen.validation_message_margin_top));
+
+ sValidationTextLayoutNormal = new LayoutParams(mValidationMessageText.getLayoutParams());
+ sValidationTextLayoutNormal.setMargins(0, sValidationTextMarginTop, 0, 0);
+
+ sValidationTextLayoutInverted = new LayoutParams((ViewGroup.MarginLayoutParams) sValidationTextLayoutNormal);
+ sValidationTextLayoutInverted.setMargins(0, 0, 0, 0);
+
+ mValidationMessageArrow = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow);
+ mValidationMessageArrowInverted = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow_inverted);
+ }
+
+ mValidationMessageText.setText(validationMessage);
+
+ // We need to set the text as selected for the marquee text to work.
+ mValidationMessageText.setSelected(true);
+
+ if (setGeckoPositionData(rect, false)) {
+ positionAndShowPopup();
+ }
+ }
+
+ private boolean setGeckoPositionData(JSONObject rect, boolean isAutoComplete) {
+ try {
+ mX = rect.getDouble("x");
+ mY = rect.getDouble("y");
+ mW = rect.getDouble("w");
+ mH = rect.getDouble("h");
+ } catch (JSONException e) {
+ // Bail if we can't get the correct dimensions for the popup.
+ Log.e(LOGTAG, "Error getting FormAssistPopup dimensions", e);
+ return false;
+ }
+
+ mPopupType = (isAutoComplete ?
+ PopupType.AUTOCOMPLETE : PopupType.VALIDATIONMESSAGE);
+ return true;
+ }
+
+ private void positionAndShowPopup() {
+ positionAndShowPopup(GeckoAppShell.getLayerView().getViewportMetrics());
+ }
+
+ private void positionAndShowPopup(ImmutableViewportMetrics aMetrics) {
+ ThreadUtils.assertOnUiThread();
+
+ // Don't show the form assist popup when using fullscreen VKB
+ InputMethodManager imm =
+ (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (imm.isFullscreenMode()) {
+ return;
+ }
+
+ // Hide/show the appropriate popup contents
+ if (mAutoCompleteList != null) {
+ mAutoCompleteList.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? VISIBLE : GONE);
+ }
+ if (mValidationMessage != null) {
+ mValidationMessage.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? GONE : VISIBLE);
+ }
+
+ if (sAutoCompleteMinWidth == 0) {
+ Resources res = mContext.getResources();
+ sAutoCompleteMinWidth = (int) (res.getDimension(R.dimen.autocomplete_min_width));
+ sAutoCompleteRowHeight = (int) (res.getDimension(R.dimen.autocomplete_row_height));
+ sValidationMessageHeight = (int) (res.getDimension(R.dimen.validation_message_height));
+ }
+
+ float zoom = aMetrics.zoomFactor;
+
+ // These values correspond to the input box for which we want to
+ // display the FormAssistPopup.
+ int left = (int) (mX * zoom - aMetrics.viewportRectLeft);
+ int top = (int) (mY * zoom - aMetrics.viewportRectTop + GeckoAppShell.getLayerView().getSurfaceTranslation());
+ int width = (int) (mW * zoom);
+ int height = (int) (mH * zoom);
+
+ int popupWidth = LayoutParams.MATCH_PARENT;
+ int popupLeft = left < 0 ? 0 : left;
+
+ FloatSize viewport = aMetrics.getSize();
+
+ // For autocomplete suggestions, if the input is smaller than the screen-width,
+ // shrink the popup's width. Otherwise, keep it as MATCH_PARENT.
+ if ((mPopupType == PopupType.AUTOCOMPLETE) && (left + width) < viewport.width) {
+ popupWidth = left < 0 ? left + width : width;
+
+ // Ensure the popup has a minimum width.
+ if (popupWidth < sAutoCompleteMinWidth) {
+ popupWidth = sAutoCompleteMinWidth;
+
+ // Move the popup to the left if there isn't enough room for it.
+ if ((popupLeft + popupWidth) > viewport.width) {
+ popupLeft = (int) (viewport.width - popupWidth);
+ }
+ }
+ }
+
+ int popupHeight;
+ if (mPopupType == PopupType.AUTOCOMPLETE) {
+ // Limit the amount of visible rows.
+ int rows = mAutoCompleteList.getAdapter().getCount();
+ if (rows > MAX_VISIBLE_ROWS) {
+ rows = MAX_VISIBLE_ROWS;
+ }
+
+ popupHeight = sAutoCompleteRowHeight * rows;
+ } else {
+ popupHeight = sValidationMessageHeight;
+ }
+
+ int popupTop = top + height;
+
+ if (mPopupType == PopupType.VALIDATIONMESSAGE) {
+ mValidationMessageText.setLayoutParams(sValidationTextLayoutNormal);
+ mValidationMessageArrow.setVisibility(VISIBLE);
+ mValidationMessageArrowInverted.setVisibility(GONE);
+ }
+
+ // If the popup doesn't fit below the input box, shrink its height, or
+ // see if we can place it above the input instead.
+ if ((popupTop + popupHeight) > viewport.height) {
+ // Find where the maximum space is, and put the popup there.
+ if ((viewport.height - popupTop) > top) {
+ // Shrink the height to fit it below the input box.
+ popupHeight = (int) (viewport.height - popupTop);
+ } else {
+ if (popupHeight < top) {
+ // No shrinking needed to fit on top.
+ popupTop = (top - popupHeight);
+ } else {
+ // Shrink to available space on top.
+ popupTop = 0;
+ popupHeight = top;
+ }
+
+ if (mPopupType == PopupType.VALIDATIONMESSAGE) {
+ mValidationMessageText.setLayoutParams(sValidationTextLayoutInverted);
+ mValidationMessageArrow.setVisibility(GONE);
+ mValidationMessageArrowInverted.setVisibility(VISIBLE);
+ }
+ }
+ }
+
+ LayoutParams layoutParams = new LayoutParams(popupWidth, popupHeight);
+ layoutParams.setMargins(popupLeft, popupTop, 0, 0);
+ setLayoutParams(layoutParams);
+ requestLayout();
+
+ if (!isShown()) {
+ setVisibility(VISIBLE);
+ startAnimation(mAnimation);
+ }
+ }
+
+ public void hide() {
+ if (isShown()) {
+ setVisibility(GONE);
+ broadcastGeckoEvent("FormAssist:Hidden", null);
+ }
+ }
+
+ void onTranslationChanged() {
+ ThreadUtils.assertOnUiThread();
+ if (!isShown()) {
+ return;
+ }
+ positionAndShowPopup();
+ }
+
+ void onMetricsChanged(final ImmutableViewportMetrics aMetrics) {
+ if (!isShown()) {
+ return;
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ positionAndShowPopup(aMetrics);
+ }
+ });
+ }
+
+ private static void broadcastGeckoEvent(String eventName, String eventData) {
+ GeckoAppShell.notifyObservers(eventName, eventData);
+ }
+
+ private class AutoCompleteListAdapter extends ArrayAdapter<Pair<String, String>> {
+ private final LayoutInflater mInflater;
+ private final int mTextViewResourceId;
+
+ public AutoCompleteListAdapter(Context context, int textViewResourceId) {
+ super(context, textViewResourceId);
+
+ mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mTextViewResourceId = textViewResourceId;
+ }
+
+ // This method takes an array of autocomplete suggestions with label/value properties
+ // and adds label/value Pair objects to the array that backs the adapter.
+ public void populateSuggestionsList(JSONArray suggestions) {
+ try {
+ for (int i = 0; i < suggestions.length(); i++) {
+ JSONObject suggestion = suggestions.getJSONObject(i);
+ String label = suggestion.getString("label");
+ String value = suggestion.getString("value");
+ add(new Pair<String, String>(label, value));
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSONException", e);
+ }
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = mInflater.inflate(mTextViewResourceId, null);
+ }
+
+ Pair<String, String> item = getItem(position);
+ TextView itemView = (TextView) convertView;
+
+ // Set the text with the suggestion label
+ itemView.setText(item.first);
+
+ // Set a tag with the suggestion value
+ itemView.setTag(item.second);
+
+ return convertView;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java b/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java
new file mode 100644
index 000000000..774ca6024
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoActivity.java
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.ComponentName;
+import android.content.Intent;
+import android.support.v7.app.AppCompatActivity;
+
+public abstract class GeckoActivity extends AppCompatActivity implements GeckoActivityStatus {
+ // has this activity recently started another Gecko activity?
+ private boolean mGeckoActivityOpened;
+
+ /**
+ * Display any resources that show strings or encompass locale-specific
+ * representations.
+ *
+ * onLocaleReady must always be called on the UI thread.
+ */
+ public void onLocaleReady(final String locale) {
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ if (getApplication() instanceof GeckoApplication) {
+ ((GeckoApplication) getApplication()).onActivityPause(this);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (getApplication() instanceof GeckoApplication) {
+ ((GeckoApplication) getApplication()).onActivityResume(this);
+ mGeckoActivityOpened = false;
+ }
+ }
+
+ @Override
+ public void onCreate(android.os.Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (AppConstants.MOZ_ANDROID_ANR_REPORTER) {
+ ANRReporter.register(getApplicationContext());
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (AppConstants.MOZ_ANDROID_ANR_REPORTER) {
+ ANRReporter.unregister();
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public void startActivity(Intent intent) {
+ mGeckoActivityOpened = checkIfGeckoActivity(intent);
+ super.startActivity(intent);
+ }
+
+ @Override
+ public void startActivityForResult(Intent intent, int request) {
+ mGeckoActivityOpened = checkIfGeckoActivity(intent);
+ super.startActivityForResult(intent, request);
+ }
+
+ private static boolean checkIfGeckoActivity(Intent intent) {
+ // Whenever we call our own activity, the component and its package name is set.
+ // If we call an activity from another package, or an open intent (leaving android to resolve)
+ // component has a different package name or it is null.
+ ComponentName component = intent.getComponent();
+ return (component != null &&
+ AppConstants.ANDROID_PACKAGE_NAME.equals(component.getPackageName()));
+ }
+
+ @Override
+ public boolean isGeckoActivityOpened() {
+ return mGeckoActivityOpened;
+ }
+
+ public boolean isApplicationInBackground() {
+ return ((GeckoApplication) getApplication()).isApplicationInBackground();
+ }
+
+ @Override
+ public void onLowMemory() {
+ MemoryMonitor.getInstance().onLowMemory();
+ super.onLowMemory();
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ MemoryMonitor.getInstance().onTrimMemory(level);
+ super.onTrimMemory(level);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java b/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java
new file mode 100644
index 000000000..ce6b8abd0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoActivityStatus.java
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+public interface GeckoActivityStatus {
+ public boolean isGeckoActivityOpened();
+ public boolean isFinishing(); // typically from android.app.Activity
+};
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
new file mode 100644
index 000000000..05fa2bbf8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApp.java
@@ -0,0 +1,2878 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.gfx.FullScreenState;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.health.HealthRecorder;
+import org.mozilla.gecko.health.SessionInformation;
+import org.mozilla.gecko.health.StubbedHealthRecorder;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.GeckoMenuInflater;
+import org.mozilla.gecko.menu.MenuPanel;
+import org.mozilla.gecko.notifications.NotificationClient;
+import org.mozilla.gecko.notifications.NotificationHelper;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.preferences.ClearOnShutdownPref;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.prompts.PromptService;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.text.FloatingToolbarTextSelection;
+import org.mozilla.gecko.text.TextSelection;
+import org.mozilla.gecko.updater.UpdateServiceHelper;
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.PrefUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.hardware.Sensor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.PowerManager;
+import android.os.Process;
+import android.os.StrictMode;
+import android.provider.ContactsContract;
+import android.provider.MediaStore.Images.Media;
+import android.support.annotation.WorkerThread;
+import android.support.design.widget.Snackbar;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Base64;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.OrientationEventListener;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.widget.AbsoluteLayout;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.ListView;
+import android.widget.RelativeLayout;
+import android.widget.SimpleAdapter;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.WeakReference;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+public abstract class GeckoApp
+ extends GeckoActivity
+ implements
+ ContextGetter,
+ GeckoAppShell.GeckoInterface,
+ GeckoEventListener,
+ GeckoMenu.Callback,
+ GeckoMenu.MenuPresenter,
+ NativeEventListener,
+ Tabs.OnTabsChangedListener,
+ ViewTreeObserver.OnGlobalLayoutListener {
+
+ private static final String LOGTAG = "GeckoApp";
+ private static final long ONE_DAY_MS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS);
+
+ public static final String ACTION_ALERT_CALLBACK = "org.mozilla.gecko.ALERT_CALLBACK";
+ public static final String ACTION_HOMESCREEN_SHORTCUT = "org.mozilla.gecko.BOOKMARK";
+ public static final String ACTION_DEBUG = "org.mozilla.gecko.DEBUG";
+ public static final String ACTION_LAUNCH_SETTINGS = "org.mozilla.gecko.SETTINGS";
+ public static final String ACTION_LOAD = "org.mozilla.gecko.LOAD";
+ public static final String ACTION_INIT_PW = "org.mozilla.gecko.INIT_PW";
+ public static final String ACTION_SWITCH_TAB = "org.mozilla.gecko.SWITCH_TAB";
+
+ public static final String INTENT_REGISTER_STUMBLER_LISTENER = "org.mozilla.gecko.STUMBLER_REGISTER_LOCAL_LISTENER";
+
+ public static final String EXTRA_STATE_BUNDLE = "stateBundle";
+
+ public static final String LAST_SELECTED_TAB = "lastSelectedTab";
+
+ public static final String PREFS_ALLOW_STATE_BUNDLE = "allowStateBundle";
+ public static final String PREFS_VERSION_CODE = "versionCode";
+ public static final String PREFS_WAS_STOPPED = "wasStopped";
+ public static final String PREFS_CRASHED_COUNT = "crashedCount";
+ public static final String PREFS_CLEANUP_TEMP_FILES = "cleanupTempFiles";
+
+ public static final String SAVED_STATE_IN_BACKGROUND = "inBackground";
+ public static final String SAVED_STATE_PRIVATE_SESSION = "privateSession";
+
+ // Delay before running one-time "cleanup" tasks that may be needed
+ // after a version upgrade.
+ private static final int CLEANUP_DEFERRAL_SECONDS = 15;
+
+ private static boolean sAlreadyLoaded;
+
+ private static WeakReference<GeckoApp> lastActiveGeckoApp;
+
+ protected RelativeLayout mRootLayout;
+ protected RelativeLayout mMainLayout;
+
+ protected RelativeLayout mGeckoLayout;
+ private OrientationEventListener mCameraOrientationEventListener;
+ public List<GeckoAppShell.AppStateListener> mAppStateListeners = new LinkedList<GeckoAppShell.AppStateListener>();
+ protected MenuPanel mMenuPanel;
+ protected Menu mMenu;
+ protected boolean mIsRestoringActivity;
+
+ /** Tells if we're aborting app launch, e.g. if this is an unsupported device configuration. */
+ protected boolean mIsAbortingAppLaunch;
+
+ private PromptService mPromptService;
+ protected TextSelection mTextSelection;
+
+ protected DoorHangerPopup mDoorHangerPopup;
+ protected FormAssistPopup mFormAssistPopup;
+
+
+ protected GeckoView mLayerView;
+ private AbsoluteLayout mPluginContainer;
+
+ private FullScreenHolder mFullScreenPluginContainer;
+ private View mFullScreenPluginView;
+
+ private final HashMap<String, PowerManager.WakeLock> mWakeLocks = new HashMap<String, PowerManager.WakeLock>();
+
+ protected boolean mLastSessionCrashed;
+ protected boolean mShouldRestore;
+ private boolean mSessionRestoreParsingFinished = false;
+
+ private EventDispatcher eventDispatcher;
+
+ private int lastSelectedTabId = -1;
+
+ private static final class LastSessionParser extends SessionParser {
+ private JSONArray tabs;
+ private JSONObject windowObject;
+ private boolean isExternalURL;
+
+ private boolean selectNextTab;
+ private boolean tabsWereSkipped;
+ private boolean tabsWereProcessed;
+
+ public LastSessionParser(JSONArray tabs, JSONObject windowObject, boolean isExternalURL) {
+ this.tabs = tabs;
+ this.windowObject = windowObject;
+ this.isExternalURL = isExternalURL;
+ }
+
+ public boolean allTabsSkipped() {
+ return tabsWereSkipped && !tabsWereProcessed;
+ }
+
+ @Override
+ public void onTabRead(final SessionTab sessionTab) {
+ if (sessionTab.isAboutHomeWithoutHistory()) {
+ // This is a tab pointing to about:home with no history. We won't restore
+ // this tab. If we end up restoring no tabs then the browser will decide
+ // whether it needs to open about:home or a different 'homepage'. If we'd
+ // always restore about:home only tabs then we'd never open the homepage.
+ // See bug 1261008.
+
+ if (sessionTab.isSelected()) {
+ // Unfortunately this tab is the selected tab. Let's just try to select
+ // the first tab. If we haven't restored any tabs so far then remember
+ // to select the next tab that gets restored.
+
+ if (!Tabs.getInstance().selectLastTab()) {
+ selectNextTab = true;
+ }
+ }
+
+ // Do not restore this tab.
+ tabsWereSkipped = true;
+ return;
+ }
+
+ tabsWereProcessed = true;
+
+ JSONObject tabObject = sessionTab.getTabObject();
+
+ int flags = Tabs.LOADURL_NEW_TAB;
+ flags |= ((isExternalURL || !sessionTab.isSelected()) ? Tabs.LOADURL_DELAY_LOAD : 0);
+ flags |= (tabObject.optBoolean("desktopMode") ? Tabs.LOADURL_DESKTOP : 0);
+ flags |= (tabObject.optBoolean("isPrivate") ? Tabs.LOADURL_PRIVATE : 0);
+
+ final Tab tab = Tabs.getInstance().loadUrl(sessionTab.getUrl(), flags);
+
+ if (selectNextTab) {
+ // We did not restore the selected tab previously. Now let's select this tab.
+ Tabs.getInstance().selectTab(tab.getId());
+ selectNextTab = false;
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ tab.updateTitle(sessionTab.getTitle());
+ }
+ });
+
+ try {
+ tabObject.put("tabId", tab.getId());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+ tabs.put(tabObject);
+ }
+
+ @Override
+ public void onClosedTabsRead(final JSONArray closedTabData) throws JSONException {
+ windowObject.put("closedTabs", closedTabData);
+ }
+ };
+
+ protected boolean mInitialized;
+ protected boolean mWindowFocusInitialized;
+ private Telemetry.Timer mJavaUiStartupTimer;
+ private Telemetry.Timer mGeckoReadyStartupTimer;
+
+ private String mPrivateBrowsingSession;
+
+ private volatile HealthRecorder mHealthRecorder;
+ private volatile Locale mLastLocale;
+
+ protected Intent mRestartIntent;
+
+ private boolean mWasFirstTabShownAfterActivityUnhidden;
+
+ abstract public int getLayout();
+
+ protected void processTabQueue() {};
+
+ protected void openQueuedTabs() {};
+
+ @SuppressWarnings("serial")
+ class SessionRestoreException extends Exception {
+ public SessionRestoreException(Exception e) {
+ super(e);
+ }
+
+ public SessionRestoreException(String message) {
+ super(message);
+ }
+ }
+
+ void toggleChrome(final boolean aShow) { }
+
+ void focusChrome() { }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ @Override
+ public SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forApp(this);
+ }
+
+ @Override
+ public Activity getActivity() {
+ return this;
+ }
+
+ @Override
+ public void addAppStateListener(GeckoAppShell.AppStateListener listener) {
+ mAppStateListeners.add(listener);
+ }
+
+ @Override
+ public void removeAppStateListener(GeckoAppShell.AppStateListener listener) {
+ mAppStateListeners.remove(listener);
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ // When a tab is closed, it is always unselected first.
+ // When a tab is unselected, another tab is always selected first.
+ switch (msg) {
+ case UNSELECTED:
+ break;
+
+ case LOCATION_CHANGE:
+ // We only care about location change for the selected tab.
+ if (!Tabs.getInstance().isSelectedTab(tab))
+ break;
+ // Fall through...
+ case SELECTED:
+ invalidateOptionsMenu();
+ if (mFormAssistPopup != null)
+ mFormAssistPopup.hide();
+ break;
+
+ case DESKTOP_MODE_CHANGE:
+ if (Tabs.getInstance().isSelectedTab(tab))
+ invalidateOptionsMenu();
+ break;
+ }
+ }
+
+ public void refreshChrome() { }
+
+ @Override
+ public void invalidateOptionsMenu() {
+ if (mMenu == null) {
+ return;
+ }
+
+ onPrepareOptionsMenu(mMenu);
+
+ super.invalidateOptionsMenu();
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ mMenu = menu;
+
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.gecko_app_menu, mMenu);
+ return true;
+ }
+
+ @Override
+ public MenuInflater getMenuInflater() {
+ return new GeckoMenuInflater(this);
+ }
+
+ public MenuPanel getMenuPanel() {
+ if (mMenuPanel == null) {
+ onCreatePanelMenu(Window.FEATURE_OPTIONS_PANEL, null);
+ invalidateOptionsMenu();
+ }
+ return mMenuPanel;
+ }
+
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ return onOptionsItemSelected(item);
+ }
+
+ @Override
+ public boolean onMenuItemLongClick(MenuItem item) {
+ return false;
+ }
+
+ @Override
+ public void openMenu() {
+ openOptionsMenu();
+ }
+
+ @Override
+ public void showMenu(final View menu) {
+ // On devices using the custom menu, focus is cleared from the menu when its tapped.
+ // Close and then reshow it to avoid these issues. See bug 794581 and bug 968182.
+ closeMenu();
+
+ // Post the reshow code back to the UI thread to avoid some optimizations Android
+ // has put in place for menus that hide/show themselves quickly. See bug 985400.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mMenuPanel.removeAllViews();
+ mMenuPanel.addView(menu);
+ openOptionsMenu();
+ }
+ });
+ }
+
+ @Override
+ public void closeMenu() {
+ closeOptionsMenu();
+ }
+
+ @Override
+ public View onCreatePanelView(int featureId) {
+ if (featureId == Window.FEATURE_OPTIONS_PANEL) {
+ if (mMenuPanel == null) {
+ mMenuPanel = new MenuPanel(this, null);
+ } else {
+ // Prepare the panel every time before showing the menu.
+ onPreparePanel(featureId, mMenuPanel, mMenu);
+ }
+
+ return mMenuPanel;
+ }
+
+ return super.onCreatePanelView(featureId);
+ }
+
+ @Override
+ public boolean onCreatePanelMenu(int featureId, Menu menu) {
+ if (featureId == Window.FEATURE_OPTIONS_PANEL) {
+ if (mMenuPanel == null) {
+ mMenuPanel = (MenuPanel) onCreatePanelView(featureId);
+ }
+
+ GeckoMenu gMenu = new GeckoMenu(this, null);
+ gMenu.setCallback(this);
+ gMenu.setMenuPresenter(this);
+ menu = gMenu;
+ mMenuPanel.addView(gMenu);
+
+ return onCreateOptionsMenu(menu);
+ }
+
+ return super.onCreatePanelMenu(featureId, menu);
+ }
+
+ @Override
+ public boolean onPreparePanel(int featureId, View view, Menu menu) {
+ if (featureId == Window.FEATURE_OPTIONS_PANEL) {
+ return onPrepareOptionsMenu(menu);
+ }
+
+ return super.onPreparePanel(featureId, view, menu);
+ }
+
+ @Override
+ public boolean onMenuOpened(int featureId, Menu menu) {
+ // exit full-screen mode whenever the menu is opened
+ if (mLayerView != null && mLayerView.isFullScreen()) {
+ GeckoAppShell.notifyObservers("FullScreen:Exit", null);
+ }
+
+ if (featureId == Window.FEATURE_OPTIONS_PANEL) {
+ if (mMenu == null) {
+ // getMenuPanel() will force the creation of the menu as well
+ MenuPanel panel = getMenuPanel();
+ onPreparePanel(featureId, panel, mMenu);
+ }
+
+ // Scroll custom menu to the top
+ if (mMenuPanel != null)
+ mMenuPanel.scrollTo(0, 0);
+
+ return true;
+ }
+
+ return super.onMenuOpened(featureId, menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.quit) {
+ // Make sure the Guest Browsing notification goes away when we quit.
+ GuestSession.hideNotification(this);
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(this);
+ final Set<String> clearSet =
+ PrefUtils.getStringSet(prefs, ClearOnShutdownPref.PREF, new HashSet<String>());
+
+ final JSONObject clearObj = new JSONObject();
+ for (String clear : clearSet) {
+ try {
+ clearObj.put(clear, true);
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Error adding clear object " + clear, ex);
+ }
+ }
+
+ final JSONObject res = new JSONObject();
+ try {
+ res.put("sanitize", clearObj);
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Error adding sanitize object", ex);
+ }
+
+ // If the user has opted out of session restore, and does want to clear history
+ // we also want to prevent the current session info from being saved.
+ if (clearObj.has("private.data.history")) {
+ final String sessionRestore = getSessionRestorePreference(getSharedPreferences());
+ try {
+ res.put("dontSaveSession", "quit".equals(sessionRestore));
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Error adding session restore data", ex);
+ }
+ }
+
+ GeckoAppShell.notifyObservers("Browser:Quit", res.toString());
+ // We don't call doShutdown() here because this creates a race condition which can
+ // cause the clearing of private data to fail. Instead, we shut down the UI only after
+ // we're done sanitizing.
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onOptionsMenuClosed(Menu menu) {
+ mMenuPanel.removeAllViews();
+ mMenuPanel.addView((GeckoMenu) mMenu);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ // Handle hardware menu key presses separately so that we can show a custom menu in some cases.
+ if (keyCode == KeyEvent.KEYCODE_MENU) {
+ openOptionsMenu();
+ return true;
+ }
+
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putBoolean(SAVED_STATE_IN_BACKGROUND, isApplicationInBackground());
+ outState.putString(SAVED_STATE_PRIVATE_SESSION, mPrivateBrowsingSession);
+ outState.putInt(LAST_SELECTED_TAB, lastSelectedTabId);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(final Bundle inState) {
+ lastSelectedTabId = inState.getInt(LAST_SELECTED_TAB);
+ }
+
+ public void addTab() { }
+
+ public void addPrivateTab() { }
+
+ public void showNormalTabs() { }
+
+ public void showPrivateTabs() { }
+
+ public void hideTabs() { }
+
+ /**
+ * Close the tab UI indirectly (not as the result of a direct user
+ * action). This does not force the UI to close; for example in Firefox
+ * tablet mode it will remain open unless the user explicitly closes it.
+ *
+ * @return True if the tab UI was hidden.
+ */
+ public boolean autoHideTabs() { return false; }
+
+ @Override
+ public boolean areTabsShown() { return false; }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message,
+ final EventCallback callback) {
+ if ("Accessibility:Ready".equals(event)) {
+ GeckoAccessibility.updateAccessibilitySettings(this);
+
+ } else if ("Bookmark:Insert".equals(event)) {
+ final String url = message.getString("url");
+ final String title = message.getString("title");
+ final Context context = this;
+ final BrowserDB db = BrowserDB.from(getProfile());
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final boolean bookmarkAdded = db.addBookmark(getContentResolver(), title, url);
+ final int resId = bookmarkAdded ? R.string.bookmark_added : R.string.bookmark_already_added;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ SnackbarBuilder.builder(GeckoApp.this)
+ .message(resId)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ });
+ }
+ });
+
+ } else if ("Contact:Add".equals(event)) {
+ final String email = message.optString("email", null);
+ final String phone = message.optString("phone", null);
+ if (email != null) {
+ Uri contactUri = Uri.parse(email);
+ Intent i = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, contactUri);
+ startActivity(i);
+ } else if (phone != null) {
+ Uri contactUri = Uri.parse(phone);
+ Intent i = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, contactUri);
+ startActivity(i);
+ } else {
+ // something went wrong.
+ Log.e(LOGTAG, "Received Contact:Add message with no email nor phone number");
+ }
+
+ } else if ("DevToolsAuth:Scan".equals(event)) {
+ DevToolsAuthHelper.scan(this, callback);
+
+ } else if ("DOMFullScreen:Start".equals(event)) {
+ // Local ref to layerView for thread safety
+ LayerView layerView = mLayerView;
+ if (layerView != null) {
+ layerView.setFullScreenState(message.getBoolean("rootElement")
+ ? FullScreenState.ROOT_ELEMENT : FullScreenState.NON_ROOT_ELEMENT);
+ }
+
+ } else if ("DOMFullScreen:Stop".equals(event)) {
+ // Local ref to layerView for thread safety
+ LayerView layerView = mLayerView;
+ if (layerView != null) {
+ layerView.setFullScreenState(FullScreenState.NONE);
+ }
+
+ } else if ("Image:SetAs".equals(event)) {
+ String src = message.getString("url");
+ setImageAs(src);
+
+ } else if ("Locale:Set".equals(event)) {
+ setLocale(message.getString("locale"));
+
+ } else if ("Permissions:Data".equals(event)) {
+ final NativeJSObject[] permissions = message.getObjectArray("permissions");
+ showSiteSettingsDialog(permissions);
+
+ } else if ("PrivateBrowsing:Data".equals(event)) {
+ mPrivateBrowsingSession = message.optString("session", null);
+
+ } else if ("Session:StatePurged".equals(event)) {
+ onStatePurged();
+
+ } else if ("Sanitize:Finished".equals(event)) {
+ if (message.getBoolean("shutdown")) {
+ // Gecko is shutting down and has called our sanitize handlers,
+ // so we can start exiting, too.
+ doShutdown();
+ }
+
+ } else if ("Share:Text".equals(event)) {
+ final String text = message.getString("text");
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ String title = "";
+ if (tab != null) {
+ title = tab.getDisplayTitle();
+ }
+ IntentHelper.openUriExternal(text, "text/plain", "", "", Intent.ACTION_SEND, title, false);
+
+ // Context: Sharing via chrome list (no explicit session is active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "text");
+
+ } else if ("Snackbar:Show".equals(event)) {
+ SnackbarBuilder.builder(this)
+ .fromEvent(message)
+ .callback(callback)
+ .buildAndShow();
+
+ } else if ("SystemUI:Visibility".equals(event)) {
+ setSystemUiVisible(message.getBoolean("visible"));
+
+ } else if ("ToggleChrome:Focus".equals(event)) {
+ focusChrome();
+
+ } else if ("ToggleChrome:Hide".equals(event)) {
+ toggleChrome(false);
+
+ } else if ("ToggleChrome:Show".equals(event)) {
+ toggleChrome(true);
+
+ } else if ("Update:Check".equals(event)) {
+ UpdateServiceHelper.checkForUpdate(this);
+ } else if ("Update:Download".equals(event)) {
+ UpdateServiceHelper.downloadUpdate(this);
+ } else if ("Update:Install".equals(event)) {
+ UpdateServiceHelper.applyUpdate(this);
+ } else if ("RuntimePermissions:Prompt".equals(event)) {
+ String[] permissions = message.getStringArray("permissions");
+ if (callback == null || permissions == null) {
+ return;
+ }
+
+ Permissions.from(this)
+ .withPermissions(permissions)
+ .andFallback(new Runnable() {
+ @Override
+ public void run() {
+ callback.sendSuccess(false);
+ }
+ })
+ .run(new Runnable() {
+ @Override
+ public void run() {
+ callback.sendSuccess(true);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("Gecko:Ready")) {
+ mGeckoReadyStartupTimer.stop();
+ geckoConnected();
+
+ // This method is already running on the background thread, so we
+ // know that mHealthRecorder will exist. That doesn't stop us being
+ // paranoid.
+ // This method is cheap, so don't spawn a new runnable.
+ final HealthRecorder rec = mHealthRecorder;
+ if (rec != null) {
+ rec.recordGeckoStartupTime(mGeckoReadyStartupTimer.getElapsed());
+ }
+
+ GeckoApplication.get().onDelayedStartup();
+
+ } else if (event.equals("Gecko:Exited")) {
+ // Gecko thread exited first; let GeckoApp die too.
+ doShutdown();
+ return;
+
+ } else if (event.equals("Accessibility:Event")) {
+ GeckoAccessibility.sendAccessibilityEvent(message);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ void onStatePurged() { }
+
+ /**
+ * @param permissions
+ * Array of JSON objects to represent site permissions.
+ * Example: { type: "offline-app", setting: "Store Offline Data", value: "Allow" }
+ */
+ private void showSiteSettingsDialog(final NativeJSObject[] permissions) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.site_settings_title);
+
+ final ArrayList<HashMap<String, String>> itemList =
+ new ArrayList<HashMap<String, String>>();
+ for (final NativeJSObject permObj : permissions) {
+ final HashMap<String, String> map = new HashMap<String, String>();
+ map.put("setting", permObj.getString("setting"));
+ map.put("value", permObj.getString("value"));
+ itemList.add(map);
+ }
+
+ // setMultiChoiceItems doesn't support using an adapter, so we're creating a hack with
+ // setSingleChoiceItems and changing the choiceMode below when we create the dialog
+ builder.setSingleChoiceItems(new SimpleAdapter(
+ GeckoApp.this,
+ itemList,
+ R.layout.site_setting_item,
+ new String[] { "setting", "value" },
+ new int[] { R.id.setting, R.id.value }
+ ), -1, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) { }
+ });
+
+ builder.setPositiveButton(R.string.site_settings_clear, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ ListView listView = ((AlertDialog) dialog).getListView();
+ SparseBooleanArray checkedItemPositions = listView.getCheckedItemPositions();
+
+ // An array of the indices of the permissions we want to clear
+ JSONArray permissionsToClear = new JSONArray();
+ for (int i = 0; i < checkedItemPositions.size(); i++)
+ if (checkedItemPositions.get(i))
+ permissionsToClear.put(i);
+
+ GeckoAppShell.notifyObservers("Permissions:Clear", permissionsToClear.toString());
+ }
+ });
+
+ builder.setNegativeButton(R.string.site_settings_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ dialog.cancel();
+ }
+ });
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ AlertDialog dialog = builder.create();
+ dialog.show();
+
+ final ListView listView = dialog.getListView();
+ if (listView != null) {
+ listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ }
+
+ final Button clearButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
+ clearButton.setEnabled(false);
+
+ dialog.getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
+ if (listView.getCheckedItemCount() == 0) {
+ clearButton.setEnabled(false);
+ } else {
+ clearButton.setEnabled(true);
+ }
+ }
+ });
+ }
+ });
+ }
+
+
+
+ /* package */ void addFullScreenPluginView(View view) {
+ if (mFullScreenPluginView != null) {
+ Log.w(LOGTAG, "Already have a fullscreen plugin view");
+ return;
+ }
+
+ setFullScreen(true);
+
+ view.setWillNotDraw(false);
+ if (view instanceof SurfaceView) {
+ ((SurfaceView) view).setZOrderOnTop(true);
+ }
+
+ mFullScreenPluginContainer = new FullScreenHolder(this);
+
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ Gravity.CENTER);
+ mFullScreenPluginContainer.addView(view, layoutParams);
+
+
+ FrameLayout decor = (FrameLayout)getWindow().getDecorView();
+ decor.addView(mFullScreenPluginContainer, layoutParams);
+
+ mFullScreenPluginView = view;
+ }
+
+ @Override
+ public void addPluginView(final View view) {
+
+ if (ThreadUtils.isOnUiThread()) {
+ addFullScreenPluginView(view);
+ } else {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ addFullScreenPluginView(view);
+ }
+ });
+ }
+ }
+
+ /* package */ void removeFullScreenPluginView(View view) {
+ if (mFullScreenPluginView == null) {
+ Log.w(LOGTAG, "Don't have a fullscreen plugin view");
+ return;
+ }
+
+ if (mFullScreenPluginView != view) {
+ Log.w(LOGTAG, "Passed view is not the current full screen view");
+ return;
+ }
+
+ mFullScreenPluginContainer.removeView(mFullScreenPluginView);
+
+ // We need do do this on the next iteration in order to avoid
+ // a deadlock, see comment below in FullScreenHolder
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLayerView.showSurface();
+ }
+ });
+
+ FrameLayout decor = (FrameLayout)getWindow().getDecorView();
+ decor.removeView(mFullScreenPluginContainer);
+
+ mFullScreenPluginView = null;
+
+ GeckoScreenOrientation.getInstance().unlock();
+ setFullScreen(false);
+ }
+
+ @Override
+ public void removePluginView(final View view) {
+ if (ThreadUtils.isOnUiThread()) {
+ removePluginView(view);
+ } else {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ removeFullScreenPluginView(view);
+ }
+ });
+ }
+ }
+
+ // This method starts downloading an image synchronously and displays the Chooser activity to set the image as wallpaper.
+ private void setImageAs(final String aSrc) {
+ boolean isDataURI = aSrc.startsWith("data:");
+ Bitmap image = null;
+ InputStream is = null;
+ ByteArrayOutputStream os = null;
+ try {
+ if (isDataURI) {
+ int dataStart = aSrc.indexOf(",");
+ byte[] buf = Base64.decode(aSrc.substring(dataStart + 1), Base64.DEFAULT);
+ image = BitmapUtils.decodeByteArray(buf);
+ } else {
+ int byteRead;
+ byte[] buf = new byte[4192];
+ os = new ByteArrayOutputStream();
+ URL url = new URL(aSrc);
+ is = url.openStream();
+
+ // Cannot read from same stream twice. Also, InputStream from
+ // URL does not support reset. So converting to byte array.
+
+ while ((byteRead = is.read(buf)) != -1) {
+ os.write(buf, 0, byteRead);
+ }
+ byte[] imgBuffer = os.toByteArray();
+ image = BitmapUtils.decodeByteArray(imgBuffer);
+ }
+ if (image != null) {
+ // Some devices don't have a DCIM folder and the Media.insertImage call will fail.
+ File dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
+
+ if (!dcimDir.mkdirs() && !dcimDir.isDirectory()) {
+ SnackbarBuilder.builder(this)
+ .message(R.string.set_image_path_fail)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ return;
+ }
+ String path = Media.insertImage(getContentResolver(), image, null, null);
+ if (path == null) {
+ SnackbarBuilder.builder(this)
+ .message(R.string.set_image_path_fail)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ return;
+ }
+ final Intent intent = new Intent(Intent.ACTION_ATTACH_DATA);
+ intent.addCategory(Intent.CATEGORY_DEFAULT);
+ intent.setData(Uri.parse(path));
+
+ // Removes the image from storage once the chooser activity ends.
+ Intent chooser = Intent.createChooser(intent, getString(R.string.set_image_chooser_title));
+ ActivityResultHandler handler = new ActivityResultHandler() {
+ @Override
+ public void onActivityResult (int resultCode, Intent data) {
+ getContentResolver().delete(intent.getData(), null, null);
+ }
+ };
+ ActivityHandlerHelper.startIntentForActivity(this, chooser, handler);
+ } else {
+ SnackbarBuilder.builder(this)
+ .message(R.string.set_image_fail)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ } catch (OutOfMemoryError ome) {
+ Log.e(LOGTAG, "Out of Memory when converting to byte array", ome);
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "I/O Exception while setting wallpaper", ioe);
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException ioe) {
+ Log.w(LOGTAG, "I/O Exception while closing stream", ioe);
+ }
+ }
+ if (os != null) {
+ try {
+ os.close();
+ } catch (IOException ioe) {
+ Log.w(LOGTAG, "I/O Exception while closing stream", ioe);
+ }
+ }
+ }
+ }
+
+ private int getBitmapSampleSize(BitmapFactory.Options options, int idealWidth, int idealHeight) {
+ int width = options.outWidth;
+ int height = options.outHeight;
+ int inSampleSize = 1;
+ if (height > idealHeight || width > idealWidth) {
+ if (width > height) {
+ inSampleSize = Math.round((float)height / idealHeight);
+ } else {
+ inSampleSize = Math.round((float)width / idealWidth);
+ }
+ }
+ return inSampleSize;
+ }
+
+ public void requestRender() {
+ mLayerView.requestRender();
+ }
+
+ @Override
+ public void setFullScreen(final boolean fullscreen) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ActivityUtils.setFullScreen(GeckoApp.this, fullscreen);
+ }
+ });
+ }
+
+ /**
+ * Check and start the Java profiler if MOZ_PROFILER_STARTUP env var is specified.
+ **/
+ protected static void earlyStartJavaSampler(SafeIntent intent) {
+ String env = intent.getStringExtra("env0");
+ for (int i = 1; env != null; i++) {
+ if (env.startsWith("MOZ_PROFILER_STARTUP=")) {
+ if (!env.endsWith("=")) {
+ GeckoJavaSampler.start(10, 1000);
+ Log.d(LOGTAG, "Profiling Java on startup");
+ }
+ break;
+ }
+ env = intent.getStringExtra("env" + i);
+ }
+ }
+
+ /**
+ * Called when the activity is first created.
+ *
+ * Here we initialize all of our profile settings, Firefox Health Report,
+ * and other one-shot constructions.
+ **/
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ GeckoAppShell.ensureCrashHandling();
+
+ eventDispatcher = new EventDispatcher();
+
+ // Enable Android Strict Mode for developers' local builds (the "default" channel).
+ if ("default".equals(AppConstants.MOZ_UPDATE_CHANNEL)) {
+ enableStrictMode();
+ }
+
+ if (!HardwareUtils.isSupportedSystem()) {
+ // This build does not support the Android version of the device: Show an error and finish the app.
+ mIsAbortingAppLaunch = true;
+ super.onCreate(savedInstanceState);
+ showSDKVersionError();
+ finish();
+ return;
+ }
+
+ // The clock starts...now. Better hurry!
+ mJavaUiStartupTimer = new Telemetry.UptimeTimer("FENNEC_STARTUP_TIME_JAVAUI");
+ mGeckoReadyStartupTimer = new Telemetry.UptimeTimer("FENNEC_STARTUP_TIME_GECKOREADY");
+
+ final SafeIntent intent = new SafeIntent(getIntent());
+
+ earlyStartJavaSampler(intent);
+
+ // GeckoLoader wants to dig some environment variables out of the
+ // incoming intent, so pass it in here. GeckoLoader will do its
+ // business later and dispose of the reference.
+ GeckoLoader.setLastIntent(intent);
+
+ // Workaround for <http://code.google.com/p/android/issues/detail?id=20915>.
+ try {
+ Class.forName("android.os.AsyncTask");
+ } catch (ClassNotFoundException e) { }
+
+ MemoryMonitor.getInstance().init(getApplicationContext());
+
+ // GeckoAppShell is tightly coupled to us, rather than
+ // the app context, because various parts of Fennec (e.g.,
+ // GeckoScreenOrientation) use GAS to access the Activity in
+ // the guise of fetching a Context.
+ // When that's fixed, `this` can change to
+ // `(GeckoApplication) getApplication()` here.
+ GeckoAppShell.setContextGetter(this);
+ GeckoAppShell.setGeckoInterface(this);
+
+ // Tell Stumbler to register a local broadcast listener to listen for preference intents.
+ // We do this via intents since we can't easily access Stumbler directly,
+ // as it might be compiled outside of Fennec.
+ getApplicationContext().sendBroadcast(
+ new Intent(INTENT_REGISTER_STUMBLER_LISTENER)
+ );
+
+ // Did the OS locale change while we were backgrounded? If so,
+ // we need to die so that Gecko will re-init add-ons that touch
+ // the UI.
+ // This is using a sledgehammer to crack a nut, but it'll do for
+ // now.
+ // Our OS locale pref will be detected as invalid after the
+ // restart, and will be propagated to Gecko accordingly, so there's
+ // no need to touch that here.
+ if (BrowserLocaleManager.getInstance().systemLocaleDidChange()) {
+ Log.i(LOGTAG, "System locale changed. Restarting.");
+ doRestart();
+ return;
+ }
+
+ if (sAlreadyLoaded) {
+ // This happens when the GeckoApp activity is destroyed by Android
+ // without killing the entire application (see Bug 769269).
+ mIsRestoringActivity = true;
+ Telemetry.addToHistogram("FENNEC_RESTORING_ACTIVITY", 1);
+
+ } else {
+ final String action = intent.getAction();
+ final String args = intent.getStringExtra("args");
+
+ sAlreadyLoaded = true;
+ GeckoThread.init(/* profile */ null, args, action,
+ /* debugging */ ACTION_DEBUG.equals(action));
+
+ // Speculatively pre-fetch the profile in the background.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ getProfile();
+ }
+ });
+
+ final String uri = getURIFromIntent(intent);
+ if (!TextUtils.isEmpty(uri)) {
+ // Start a speculative connection as soon as Gecko loads.
+ GeckoThread.speculativeConnect(uri);
+ }
+ }
+
+ // GeckoThread has to register for "Gecko:Ready" first, so GeckoApp registers
+ // for events after initializing GeckoThread but before launching it.
+
+ getAppEventDispatcher().registerGeckoThreadListener((GeckoEventListener)this,
+ "Gecko:Ready",
+ "Gecko:Exited",
+ "Accessibility:Event");
+
+ getAppEventDispatcher().registerGeckoThreadListener((NativeEventListener)this,
+ "Accessibility:Ready",
+ "Bookmark:Insert",
+ "Contact:Add",
+ "DevToolsAuth:Scan",
+ "DOMFullScreen:Start",
+ "DOMFullScreen:Stop",
+ "Image:SetAs",
+ "Locale:Set",
+ "Permissions:Data",
+ "PrivateBrowsing:Data",
+ "RuntimePermissions:Prompt",
+ "Sanitize:Finished",
+ "Session:StatePurged",
+ "Share:Text",
+ "Snackbar:Show",
+ "SystemUI:Visibility",
+ "ToggleChrome:Focus",
+ "ToggleChrome:Hide",
+ "ToggleChrome:Show",
+ "Update:Check",
+ "Update:Download",
+ "Update:Install");
+
+ GeckoThread.launch();
+
+ Bundle stateBundle = IntentUtils.getBundleExtraSafe(getIntent(), EXTRA_STATE_BUNDLE);
+ if (stateBundle != null) {
+ // Use the state bundle if it was given as an intent extra. This is
+ // only intended to be used internally via Robocop, so a boolean
+ // is read from a private shared pref to prevent other apps from
+ // injecting states.
+ final SharedPreferences prefs = getSharedPreferences();
+ if (prefs.getBoolean(PREFS_ALLOW_STATE_BUNDLE, false)) {
+ prefs.edit().remove(PREFS_ALLOW_STATE_BUNDLE).apply();
+ savedInstanceState = stateBundle;
+ }
+ } else if (savedInstanceState != null) {
+ // Bug 896992 - This intent has already been handled; reset the intent.
+ setIntent(new Intent(Intent.ACTION_MAIN));
+ }
+
+ super.onCreate(savedInstanceState);
+
+ GeckoScreenOrientation.getInstance().update(getResources().getConfiguration().orientation);
+
+ setContentView(getLayout());
+
+ // Set up Gecko layout.
+ mRootLayout = (RelativeLayout) findViewById(R.id.root_layout);
+ mGeckoLayout = (RelativeLayout) findViewById(R.id.gecko_layout);
+ mMainLayout = (RelativeLayout) findViewById(R.id.main_layout);
+ mLayerView = (GeckoView) findViewById(R.id.layer_view);
+
+ Tabs.getInstance().attachToContext(this, mLayerView);
+
+ // Use global layout state change to kick off additional initialization
+ mMainLayout.getViewTreeObserver().addOnGlobalLayoutListener(this);
+
+ if (Versions.preMarshmallow) {
+ mTextSelection = new ActionBarTextSelection(this);
+ } else {
+ mTextSelection = new FloatingToolbarTextSelection(this, mLayerView);
+ }
+ mTextSelection.create();
+
+ // Determine whether we should restore tabs.
+ mLastSessionCrashed = updateCrashedState();
+ mShouldRestore = getSessionRestoreState(savedInstanceState);
+ if (mShouldRestore && savedInstanceState != null) {
+ boolean wasInBackground =
+ savedInstanceState.getBoolean(SAVED_STATE_IN_BACKGROUND, false);
+
+ // Don't log OOM-kills if only one activity was destroyed. (For example
+ // from "Don't keep activities" on ICS)
+ if (!wasInBackground && !mIsRestoringActivity) {
+ Telemetry.addToHistogram("FENNEC_WAS_KILLED", 1);
+ }
+
+ mPrivateBrowsingSession = savedInstanceState.getString(SAVED_STATE_PRIVATE_SESSION);
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // If we are doing a restore, read the session data so we can send it to Gecko later.
+ String restoreMessage = null;
+ if (!mIsRestoringActivity && mShouldRestore) {
+ final boolean isExternalURL = invokedWithExternalURL(getIntentURI(new SafeIntent(getIntent())));
+ try {
+ // restoreSessionTabs() will create simple tab stubs with the
+ // URL and title for each page, but we also need to restore
+ // session history. restoreSessionTabs() will inject the IDs
+ // of the tab stubs into the JSON data (which holds the session
+ // history). This JSON data is then sent to Gecko so session
+ // history can be restored for each tab.
+ restoreMessage = restoreSessionTabs(isExternalURL, false);
+ } catch (SessionRestoreException e) {
+ // If mShouldRestore was set to false in restoreSessionTabs(), this means
+ // either that we intentionally skipped all tabs read from the session file,
+ // or else that the file was syntactically valid, but didn't contain any
+ // tabs (e.g. because the user cleared history), therefore we don't need
+ // to switch to the backup copy.
+ if (mShouldRestore) {
+ Log.e(LOGTAG, "An error occurred during restore, switching to backup file", e);
+ // To be on the safe side, we will always attempt to restore from the backup
+ // copy if we end up here.
+ // Since we will also hit this situation regularly during first run though,
+ // we'll only report it in telemetry if we failed to restore despite the
+ // file existing, which means it's very probably damaged.
+ if (getProfile().sessionFileExists()) {
+ Telemetry.addToHistogram("FENNEC_SESSIONSTORE_DAMAGED_SESSION_FILE", 1);
+ }
+ try {
+ restoreMessage = restoreSessionTabs(isExternalURL, true);
+ Telemetry.addToHistogram("FENNEC_SESSIONSTORE_RESTORING_FROM_BACKUP", 1);
+ } catch (SessionRestoreException ex) {
+ if (!mShouldRestore) {
+ // Restoring only "failed" because the backup copy was deliberately empty, too.
+ Telemetry.addToHistogram("FENNEC_SESSIONSTORE_RESTORING_FROM_BACKUP", 1);
+ } else {
+ // Restoring the backup failed, too, so do a normal startup.
+ Log.e(LOGTAG, "An error occurred during restore", ex);
+ mShouldRestore = false;
+ }
+ }
+ }
+ }
+ }
+
+ synchronized (GeckoApp.this) {
+ mSessionRestoreParsingFinished = true;
+ GeckoApp.this.notifyAll();
+ }
+
+ // If we are doing a restore, send the parsed session data to Gecko.
+ if (!mIsRestoringActivity) {
+ GeckoAppShell.notifyObservers("Session:Restore", restoreMessage);
+ }
+
+ // Make sure sessionstore.old is either updated or deleted as necessary.
+ getProfile().updateSessionFile(mShouldRestore);
+ }
+ });
+
+ // Perform background initialization.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final SharedPreferences prefs = GeckoApp.this.getSharedPreferences();
+
+ // Wait until now to set this, because we'd rather throw an exception than
+ // have a caller of BrowserLocaleManager regress startup.
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+ localeManager.initialize(getApplicationContext());
+
+ SessionInformation previousSession = SessionInformation.fromSharedPrefs(prefs);
+ if (previousSession.wasKilled()) {
+ Telemetry.addToHistogram("FENNEC_WAS_KILLED", 1);
+ }
+
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(GeckoAppShell.PREFS_OOM_EXCEPTION, false);
+
+ // Put a flag to check if we got a normal `onSaveInstanceState`
+ // on exit, or if we were suddenly killed (crash or native OOM).
+ editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
+
+ editor.apply();
+
+ // The lifecycle of mHealthRecorder is "shortly after onCreate"
+ // through "onDestroy" -- essentially the same as the lifecycle
+ // of the activity itself.
+ final String profilePath = getProfile().getDir().getAbsolutePath();
+ final EventDispatcher dispatcher = getAppEventDispatcher();
+
+ // This is the locale prior to fixing it up.
+ final Locale osLocale = Locale.getDefault();
+
+ // Both of these are Java-format locale strings: "en_US", not "en-US".
+ final String osLocaleString = osLocale.toString();
+ String appLocaleString = localeManager.getAndApplyPersistedLocale(GeckoApp.this);
+ Log.d(LOGTAG, "OS locale is " + osLocaleString + ", app locale is " + appLocaleString);
+
+ if (appLocaleString == null) {
+ appLocaleString = osLocaleString;
+ }
+
+ mHealthRecorder = GeckoApp.this.createHealthRecorder(GeckoApp.this,
+ profilePath,
+ dispatcher,
+ osLocaleString,
+ appLocaleString,
+ previousSession);
+
+ final String uiLocale = appLocaleString;
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoApp.this.onLocaleReady(uiLocale);
+ }
+ });
+
+ // We use per-profile prefs here, because we're tracking against
+ // a Gecko pref. The same applies to the locale switcher!
+ BrowserLocaleManager.storeAndNotifyOSLocale(GeckoSharedPrefs.forProfile(GeckoApp.this), osLocale);
+ }
+ });
+
+ IntentHelper.init(this);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ mWasFirstTabShownAfterActivityUnhidden = false; // onStart indicates we were hidden.
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ // Overriding here is not necessary, but we do this so we don't
+ // forget to add the abort if we override this method later.
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+ }
+
+ /**
+ * At this point, the resource system and the rest of the browser are
+ * aware of the locale.
+ *
+ * Now we can display strings!
+ *
+ * You can think of this as being something like a second phase of onCreate,
+ * where you can do string-related operations. Use this in place of embedding
+ * strings in view XML.
+ *
+ * By contrast, onConfigurationChanged does some locale operations, but is in
+ * response to device changes.
+ */
+ @Override
+ public void onLocaleReady(final String locale) {
+ if (!ThreadUtils.isOnUiThread()) {
+ throw new RuntimeException("onLocaleReady must always be called from the UI thread.");
+ }
+
+ final Locale loc = Locales.parseLocaleCode(locale);
+ if (loc.equals(mLastLocale)) {
+ Log.d(LOGTAG, "New locale same as old; onLocaleReady has nothing to do.");
+ }
+
+ // The URL bar hint needs to be populated.
+ TextView urlBar = (TextView) findViewById(R.id.url_bar_title);
+ if (urlBar != null) {
+ final String hint = getResources().getString(R.string.url_bar_default_text);
+ urlBar.setHint(hint);
+ } else {
+ Log.d(LOGTAG, "No URL bar in GeckoApp. Not loading localized hint string.");
+ }
+
+ mLastLocale = loc;
+
+ // Allow onConfigurationChanged to take care of the rest.
+ // We don't call this.onConfigurationChanged, because (a) that does
+ // work that's unnecessary after this locale action, and (b) it can
+ // cause a loop! See Bug 1011008, Comment 12.
+ super.onConfigurationChanged(getResources().getConfiguration());
+ }
+
+ protected void initializeChrome() {
+ mDoorHangerPopup = new DoorHangerPopup(this);
+ mPluginContainer = (AbsoluteLayout) findViewById(R.id.plugin_container);
+ mFormAssistPopup = (FormAssistPopup) findViewById(R.id.form_assist_popup);
+ }
+
+ /**
+ * Loads the initial tab at Fennec startup. If we don't restore tabs, this
+ * tab will be about:home, or the homepage if the user has set one.
+ * If we've temporarily disabled restoring to break out of a crash loop, we'll show
+ * the Recent Tabs folder of the Combined History panel, so the user can manually
+ * restore tabs as needed.
+ * If we restore tabs, we don't need to create a new tab.
+ */
+ protected void loadStartupTab(final int flags) {
+ if (!mShouldRestore) {
+ if (mLastSessionCrashed) {
+ // The Recent Tabs panel no longer exists, but BrowserApp will redirect us
+ // to the Recent Tabs folder of the Combined History panel.
+ Tabs.getInstance().loadUrl(AboutPages.getURLForBuiltinPanelType(PanelType.DEPRECATED_RECENT_TABS), flags);
+ } else {
+ final String homepage = getHomepage();
+ Tabs.getInstance().loadUrl(!TextUtils.isEmpty(homepage) ? homepage : AboutPages.HOME, flags);
+ }
+ }
+ }
+
+ /**
+ * Loads the initial tab at Fennec startup. This tab will load with the given
+ * external URL. If that URL is invalid, a startup tab will be loaded.
+ *
+ * @param url External URL to load.
+ * @param intent External intent whose extras modify the request
+ * @param flags Flags used to load the load
+ */
+ protected void loadStartupTab(final String url, final SafeIntent intent, final int flags) {
+ // Invalid url
+ if (url == null) {
+ loadStartupTab(flags);
+ return;
+ }
+
+ Tabs.getInstance().loadUrlWithIntentExtras(url, intent, flags);
+ }
+
+ public String getHomepage() {
+ return null;
+ }
+
+ private String getIntentURI(SafeIntent intent) {
+ final String passedUri;
+ final String uri = getURIFromIntent(intent);
+
+ if (!TextUtils.isEmpty(uri)) {
+ passedUri = uri;
+ } else {
+ passedUri = null;
+ }
+ return passedUri;
+ }
+
+ private boolean invokedWithExternalURL(String uri) {
+ return uri != null && !AboutPages.isAboutHome(uri);
+ }
+
+ private void initialize() {
+ mInitialized = true;
+
+ final boolean isFirstTab = !mWasFirstTabShownAfterActivityUnhidden;
+ mWasFirstTabShownAfterActivityUnhidden = true; // Reset since we'll be loading a tab.
+
+ final SafeIntent intent = new SafeIntent(getIntent());
+ final String action = intent.getAction();
+
+ final String passedUri = getIntentURI(intent);
+
+ final boolean isExternalURL = invokedWithExternalURL(passedUri);
+
+ // Start migrating as early as possible, can do this in
+ // parallel with Gecko load.
+ checkMigrateProfile();
+
+ Tabs.registerOnTabsChangedListener(this);
+
+ initializeChrome();
+
+ // We need to wait here because mShouldRestore can revert back to
+ // false if a parsing error occurs and the startup tab we load
+ // depends on whether we restore tabs or not.
+ synchronized (this) {
+ while (!mSessionRestoreParsingFinished) {
+ try {
+ wait();
+ } catch (final InterruptedException e) {
+ // Ignore and wait again.
+ }
+ }
+ }
+
+ // External URLs should always be loaded regardless of whether Gecko is
+ // already running.
+ if (isExternalURL) {
+ // Restore tabs before opening an external URL so that the new tab
+ // is animated properly.
+ Tabs.getInstance().notifyListeners(null, Tabs.TabEvents.RESTORED);
+ processActionViewIntent(new Runnable() {
+ @Override
+ public void run() {
+ int flags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_USER_ENTERED | Tabs.LOADURL_EXTERNAL;
+ if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) {
+ flags |= Tabs.LOADURL_PINNED;
+ }
+ if (isFirstTab) {
+ flags |= Tabs.LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN;
+ }
+ loadStartupTab(passedUri, intent, flags);
+ }
+ });
+ } else {
+ if (!mIsRestoringActivity) {
+ loadStartupTab(Tabs.LOADURL_NEW_TAB);
+ }
+
+ Tabs.getInstance().notifyListeners(null, Tabs.TabEvents.RESTORED);
+
+ processTabQueue();
+ }
+
+ recordStartupActionTelemetry(passedUri, action);
+
+ // Check if launched from data reporting notification.
+ if (ACTION_LAUNCH_SETTINGS.equals(action)) {
+ Intent settingsIntent = new Intent(GeckoApp.this, GeckoPreferences.class);
+ // Copy extras.
+ settingsIntent.putExtras(intent.getUnsafe());
+ startActivity(settingsIntent);
+ }
+
+ //app state callbacks
+ mAppStateListeners = new LinkedList<GeckoAppShell.AppStateListener>();
+
+ mPromptService = new PromptService(this);
+
+ // Trigger the completion of the telemetry timer that wraps activity startup,
+ // then grab the duration to give to FHR.
+ mJavaUiStartupTimer.stop();
+ final long javaDuration = mJavaUiStartupTimer.getElapsed();
+
+ ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ final HealthRecorder rec = mHealthRecorder;
+ if (rec != null) {
+ rec.recordJavaStartupTime(javaDuration);
+ }
+ }
+ }, 50);
+
+ final int updateServiceDelay = 30 * 1000;
+ ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ UpdateServiceHelper.registerForUpdates(GeckoAppShell.getApplicationContext());
+ }
+ }, updateServiceDelay);
+
+ if (mIsRestoringActivity) {
+ Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ Tabs.getInstance().notifyListeners(selectedTab, Tabs.TabEvents.SELECTED);
+ }
+
+ if (GeckoThread.isRunning()) {
+ geckoConnected();
+ if (mLayerView != null) {
+ mLayerView.setPaintState(LayerView.PAINT_BEFORE_FIRST);
+ }
+ }
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ @Override
+ public void onGlobalLayout() {
+ if (Versions.preJB) {
+ mMainLayout.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ } else {
+ mMainLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ }
+ if (!mInitialized) {
+ initialize();
+ }
+ }
+
+ protected void processActionViewIntent(final Runnable openTabsRunnable) {
+ // We need to ensure that if we receive a VIEW action and there are tabs queued then the
+ // site loaded from the intent is on top (last loaded) and selected with all other tabs
+ // being opened behind it. We process the tab queue first and request a callback from the JS - the
+ // listener will open the url from the intent as normal when the tab queue has been processed.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ if (TabQueueHelper.TAB_QUEUE_ENABLED && TabQueueHelper.shouldOpenTabQueueUrls(GeckoApp.this)) {
+
+ getAppEventDispatcher().registerGeckoThreadListener(new NativeEventListener() {
+ @Override
+ public void handleMessage(String event, NativeJSObject message, EventCallback callback) {
+ if ("Tabs:TabsOpened".equals(event)) {
+ getAppEventDispatcher().unregisterGeckoThreadListener(this, "Tabs:TabsOpened");
+ openTabsRunnable.run();
+ }
+ }
+ }, "Tabs:TabsOpened");
+ TabQueueHelper.openQueuedUrls(GeckoApp.this, getProfile(), TabQueueHelper.FILE_NAME, true);
+ } else {
+ openTabsRunnable.run();
+ }
+ }
+ });
+ }
+
+ @WorkerThread
+ private String restoreSessionTabs(final boolean isExternalURL, boolean useBackup) throws SessionRestoreException {
+ try {
+ String sessionString = getProfile().readSessionFile(useBackup);
+ if (sessionString == null) {
+ throw new SessionRestoreException("Could not read from session file");
+ }
+
+ // If we are doing an OOM restore, parse the session data and
+ // stub the restored tabs immediately. This allows the UI to be
+ // updated before Gecko has restored.
+ final JSONArray tabs = new JSONArray();
+ final JSONObject windowObject = new JSONObject();
+ final boolean sessionDataValid;
+
+ LastSessionParser parser = new LastSessionParser(tabs, windowObject, isExternalURL);
+
+ if (mPrivateBrowsingSession == null) {
+ sessionDataValid = parser.parse(sessionString);
+ } else {
+ sessionDataValid = parser.parse(sessionString, mPrivateBrowsingSession);
+ }
+
+ if (tabs.length() > 0) {
+ windowObject.put("tabs", tabs);
+ sessionString = new JSONObject().put("windows", new JSONArray().put(windowObject)).toString();
+ } else {
+ if (parser.allTabsSkipped() || sessionDataValid) {
+ // If we intentionally skipped all tabs we've read from the session file, we
+ // set mShouldRestore back to false at this point already, so the calling code
+ // can infer that the exception wasn't due to a damaged session store file.
+ // The same applies if the session file was syntactically valid and
+ // simply didn't contain any tabs.
+ mShouldRestore = false;
+ }
+ throw new SessionRestoreException("No tabs could be read from session file");
+ }
+
+ JSONObject restoreData = new JSONObject();
+ restoreData.put("sessionString", sessionString);
+ return restoreData.toString();
+ } catch (JSONException e) {
+ throw new SessionRestoreException(e);
+ }
+ }
+
+ public static EventDispatcher getEventDispatcher() {
+ final GeckoApp geckoApp = (GeckoApp) GeckoAppShell.getGeckoInterface();
+ return geckoApp.getAppEventDispatcher();
+ }
+
+ @Override
+ public EventDispatcher getAppEventDispatcher() {
+ return eventDispatcher;
+ }
+
+ @Override
+ public GeckoProfile getProfile() {
+ return GeckoThread.getActiveProfile();
+ }
+
+ /**
+ * Check whether we've crashed during the last browsing session.
+ *
+ * @return True if the crash reporter ran after the last session.
+ */
+ protected boolean updateCrashedState() {
+ try {
+ File crashFlag = new File(GeckoProfileDirectories.getMozillaDirectory(this), "CRASHED");
+ if (crashFlag.exists() && crashFlag.delete()) {
+ // Set the flag that indicates we were stopped as expected, as
+ // the crash reporter has run, so it is not a silent OOM crash.
+ getSharedPreferences().edit().putBoolean(PREFS_WAS_STOPPED, true).apply();
+ return true;
+ }
+ } catch (NoMozillaDirectoryException e) {
+ // If we can't access the Mozilla directory, we're in trouble anyway.
+ Log.e(LOGTAG, "Cannot read crash flag: ", e);
+ }
+ return false;
+ }
+
+ /**
+ * Determine whether the session should be restored.
+ *
+ * @param savedInstanceState Saved instance state given to the activity
+ * @return Whether to restore
+ */
+ protected boolean getSessionRestoreState(Bundle savedInstanceState) {
+ final SharedPreferences prefs = getSharedPreferences();
+ boolean shouldRestore = false;
+
+ final int versionCode = getVersionCode();
+ if (mLastSessionCrashed) {
+ if (incrementCrashCount(prefs) <= getSessionStoreMaxCrashResumes(prefs) &&
+ getSessionRestoreAfterCrashPreference(prefs)) {
+ shouldRestore = true;
+ } else {
+ shouldRestore = false;
+ }
+ } else if (prefs.getInt(PREFS_VERSION_CODE, 0) != versionCode) {
+ // If the version has changed, the user has done an upgrade, so restore
+ // previous tabs.
+ prefs.edit().putInt(PREFS_VERSION_CODE, versionCode).apply();
+ shouldRestore = true;
+ } else if (savedInstanceState != null ||
+ getSessionRestorePreference(prefs).equals("always") ||
+ getRestartFromIntent()) {
+ // We're coming back from a background kill by the OS, the user
+ // has chosen to always restore, or we restarted.
+ shouldRestore = true;
+ }
+
+ return shouldRestore;
+ }
+
+ private int incrementCrashCount(SharedPreferences prefs) {
+ final int crashCount = getSuccessiveCrashesCount(prefs) + 1;
+ prefs.edit().putInt(PREFS_CRASHED_COUNT, crashCount).apply();
+ return crashCount;
+ }
+
+ private int getSuccessiveCrashesCount(SharedPreferences prefs) {
+ return prefs.getInt(PREFS_CRASHED_COUNT, 0);
+ }
+
+ private int getSessionStoreMaxCrashResumes(SharedPreferences prefs) {
+ return prefs.getInt(GeckoPreferences.PREFS_RESTORE_SESSION_MAX_CRASH_RESUMES, 1);
+ }
+
+ private boolean getSessionRestoreAfterCrashPreference(SharedPreferences prefs) {
+ return prefs.getBoolean(GeckoPreferences.PREFS_RESTORE_SESSION_FROM_CRASH, true);
+ }
+
+ private String getSessionRestorePreference(SharedPreferences prefs) {
+ return prefs.getString(GeckoPreferences.PREFS_RESTORE_SESSION, "always");
+ }
+
+ private boolean getRestartFromIntent() {
+ return IntentUtils.getBooleanExtraSafe(getIntent(), "didRestart", false);
+ }
+
+ /**
+ * Enable Android StrictMode checks (for supported OS versions).
+ * http://developer.android.com/reference/android/os/StrictMode.html
+ */
+ private void enableStrictMode() {
+ Log.d(LOGTAG, "Enabling Android StrictMode");
+
+ StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build());
+
+ StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build());
+ }
+
+ @Override
+ public void enableOrientationListener() {
+ // Start listening for orientation events
+ mCameraOrientationEventListener = new OrientationEventListener(this) {
+ @Override
+ public void onOrientationChanged(int orientation) {
+ if (mAppStateListeners != null) {
+ for (GeckoAppShell.AppStateListener listener: mAppStateListeners) {
+ listener.onOrientationChanged();
+ }
+ }
+ }
+ };
+ mCameraOrientationEventListener.enable();
+ }
+
+ @Override
+ public void disableOrientationListener() {
+ if (mCameraOrientationEventListener != null) {
+ mCameraOrientationEventListener.disable();
+ mCameraOrientationEventListener = null;
+ }
+ }
+
+ @Override
+ public String getDefaultUAString() {
+ return HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET :
+ AppConstants.USER_AGENT_FENNEC_MOBILE;
+ }
+
+ @Override
+ public void createShortcut(final String title, final String url) {
+ Icons.with(this)
+ .pageUrl(url)
+ .skipNetwork()
+ .skipMemory()
+ .forLauncherIcon()
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ doCreateShortcut(title, url, response.getBitmap());
+ }
+ });
+ }
+
+ private void doCreateShortcut(final String aTitle, final String aURI, final Bitmap aIcon) {
+ // The intent to be launched by the shortcut.
+ Intent shortcutIntent = new Intent();
+ shortcutIntent.setAction(GeckoApp.ACTION_HOMESCREEN_SHORTCUT);
+ shortcutIntent.setData(Uri.parse(aURI));
+ shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
+ AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+
+ Intent intent = new Intent();
+ intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
+ intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, getLauncherIcon(aIcon, GeckoAppShell.getPreferredIconSize()));
+
+ if (aTitle != null) {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, aTitle);
+ } else {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, aURI);
+ }
+
+ // Do not allow duplicate items.
+ intent.putExtra("duplicate", false);
+
+ intent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
+ getApplicationContext().sendBroadcast(intent);
+
+ // Remember interaction
+ final UrlAnnotations urlAnnotations = BrowserDB.from(getApplicationContext()).getUrlAnnotations();
+ urlAnnotations.insertHomeScreenShortcut(getContentResolver(), aURI, true);
+
+ // After shortcut is created, show the mobile desktop.
+ ActivityUtils.goToHomeScreen(this);
+ }
+
+ private Bitmap getLauncherIcon(Bitmap aSource, int size) {
+ final float[] DEFAULT_LAUNCHER_ICON_HSV = { 32.0f, 1.0f, 1.0f };
+ final int kOffset = 6;
+ final int kRadius = 5;
+
+ int insetSize = aSource != null ? size * 2 / 3 : size;
+
+ Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+
+ // draw a base color
+ Paint paint = new Paint();
+ if (aSource == null) {
+ // If we aren't drawing a favicon, just use an orange color.
+ paint.setColor(Color.HSVToColor(DEFAULT_LAUNCHER_ICON_HSV));
+ canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint);
+ } else if (aSource.getWidth() >= insetSize || aSource.getHeight() >= insetSize) {
+ // Otherwise, if the icon is large enough, just draw it.
+ Rect iconBounds = new Rect(0, 0, size, size);
+ canvas.drawBitmap(aSource, null, iconBounds, null);
+ return bitmap;
+ } else {
+ // otherwise use the dominant color from the icon + a layer of transparent white to lighten it somewhat
+ int color = BitmapUtils.getDominantColor(aSource);
+ paint.setColor(color);
+ canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint);
+ paint.setColor(Color.argb(100, 255, 255, 255));
+ canvas.drawRoundRect(new RectF(kOffset, kOffset, size - kOffset, size - kOffset), kRadius, kRadius, paint);
+ }
+
+ // draw the overlay
+ Bitmap overlay = BitmapUtils.decodeResource(this, R.drawable.home_bg);
+ canvas.drawBitmap(overlay, null, new Rect(0, 0, size, size), null);
+
+ // draw the favicon
+ if (aSource == null)
+ aSource = BitmapUtils.decodeResource(this, R.drawable.home_star);
+
+ // by default, we scale the icon to this size
+ int sWidth = insetSize / 2;
+ int sHeight = sWidth;
+
+ int halfSize = size / 2;
+ canvas.drawBitmap(aSource,
+ null,
+ new Rect(halfSize - sWidth,
+ halfSize - sHeight,
+ halfSize + sWidth,
+ halfSize + sHeight),
+ null);
+
+ return bitmap;
+ }
+
+ @Override
+ protected void onNewIntent(Intent externalIntent) {
+ final SafeIntent intent = new SafeIntent(externalIntent);
+
+ final boolean isFirstTab = !mWasFirstTabShownAfterActivityUnhidden;
+ mWasFirstTabShownAfterActivityUnhidden = true; // Reset since we'll be loading a tab.
+
+ // if we were previously OOM killed, we can end up here when launching
+ // from external shortcuts, so set this as the intent for initialization
+ if (!mInitialized) {
+ setIntent(externalIntent);
+ return;
+ }
+
+ final String action = intent.getAction();
+
+ final String uri = getURIFromIntent(intent);
+ final String passedUri;
+ if (!TextUtils.isEmpty(uri)) {
+ passedUri = uri;
+ } else {
+ passedUri = null;
+ }
+
+ if (ACTION_LOAD.equals(action)) {
+ Tabs.getInstance().loadUrl(intent.getDataString());
+ lastSelectedTabId = -1;
+ } else if (Intent.ACTION_VIEW.equals(action)) {
+ processActionViewIntent(new Runnable() {
+ @Override
+ public void run() {
+ final String url = intent.getDataString();
+ int flags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_USER_ENTERED | Tabs.LOADURL_EXTERNAL;
+ if (isFirstTab) {
+ flags |= Tabs.LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN;
+ }
+ Tabs.getInstance().loadUrlWithIntentExtras(url, intent, flags);
+ }
+ });
+ lastSelectedTabId = -1;
+ } else if (ACTION_HOMESCREEN_SHORTCUT.equals(action)) {
+ mLayerView.loadUri(uri, GeckoView.LOAD_SWITCH_TAB);
+ } else if (Intent.ACTION_SEARCH.equals(action)) {
+ mLayerView.loadUri(uri, GeckoView.LOAD_NEW_TAB);
+ } else if (NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) {
+ NotificationHelper.getInstance(getApplicationContext()).handleNotificationIntent(intent);
+ } else if (ACTION_LAUNCH_SETTINGS.equals(action)) {
+ // Check if launched from data reporting notification.
+ Intent settingsIntent = new Intent(GeckoApp.this, GeckoPreferences.class);
+ // Copy extras.
+ settingsIntent.putExtras(intent.getUnsafe());
+ startActivity(settingsIntent);
+ } else if (ACTION_SWITCH_TAB.equals(action)) {
+ final int tabId = intent.getIntExtra("TabId", -1);
+ Tabs.getInstance().selectTab(tabId);
+ lastSelectedTabId = -1;
+ }
+
+ recordStartupActionTelemetry(passedUri, action);
+ }
+
+ /**
+ * Handles getting a URI from an intent in a way that is backwards-
+ * compatible with our previous implementations.
+ */
+ protected String getURIFromIntent(SafeIntent intent) {
+ final String action = intent.getAction();
+ if (ACTION_ALERT_CALLBACK.equals(action) ||
+ NotificationHelper.HELPER_BROADCAST_ACTION.equals(action)) {
+ return null;
+ }
+
+ return intent.getDataString();
+ }
+
+ protected int getOrientation() {
+ return GeckoScreenOrientation.getInstance().getAndroidOrientation();
+ }
+
+ @Override
+ public void onResume()
+ {
+ // After an onPause, the activity is back in the foreground.
+ // Undo whatever we did in onPause.
+ super.onResume();
+ if (mIsAbortingAppLaunch) {
+ return;
+ }
+
+ GeckoAppShell.setGeckoInterface(this);
+
+ if (lastSelectedTabId >= 0 && (lastActiveGeckoApp == null || lastActiveGeckoApp.get() != this)) {
+ Tabs.getInstance().selectTab(lastSelectedTabId);
+ }
+
+ int newOrientation = getResources().getConfiguration().orientation;
+ if (GeckoScreenOrientation.getInstance().update(newOrientation)) {
+ refreshChrome();
+ }
+
+ if (mAppStateListeners != null) {
+ for (GeckoAppShell.AppStateListener listener : mAppStateListeners) {
+ listener.onResume();
+ }
+ }
+
+ // We use two times: a pseudo-unique wall-clock time to identify the
+ // current session across power cycles, and the elapsed realtime to
+ // track the duration of the session.
+ final long now = System.currentTimeMillis();
+ final long realTime = android.os.SystemClock.elapsedRealtime();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // Now construct the new session on HealthRecorder's behalf. We do this here
+ // so it can benefit from a single near-startup prefs commit.
+ SessionInformation currentSession = new SessionInformation(now, realTime);
+
+ SharedPreferences prefs = GeckoApp.this.getSharedPreferences();
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
+
+ if (!mLastSessionCrashed) {
+ // The last session terminated normally,
+ // so we can reset the count of successive crashes.
+ editor.putInt(GeckoApp.PREFS_CRASHED_COUNT, 0);
+ }
+
+ currentSession.recordBegin(editor);
+ editor.apply();
+
+ final HealthRecorder rec = mHealthRecorder;
+ if (rec != null) {
+ rec.setCurrentSession(currentSession);
+ rec.processDelayed();
+ } else {
+ Log.w(LOGTAG, "Can't record session: rec is null.");
+ }
+ }
+ });
+
+ Restrictions.update(this);
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+
+ if (!mWindowFocusInitialized && hasFocus) {
+ mWindowFocusInitialized = true;
+ // XXX our editor tests require the GeckoView to have focus to pass, so we have to
+ // manually shift focus to the GeckoView. requestFocus apparently doesn't work at
+ // this stage of starting up, so we have to unset and reset the focusability.
+ mLayerView.setFocusable(false);
+ mLayerView.setFocusable(true);
+ mLayerView.setFocusableInTouchMode(true);
+ getWindow().setBackgroundDrawable(null);
+ }
+ }
+
+ @Override
+ public void onPause()
+ {
+ if (mIsAbortingAppLaunch) {
+ super.onPause();
+ return;
+ }
+
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ lastSelectedTabId = selectedTab.getId();
+ }
+ lastActiveGeckoApp = new WeakReference<GeckoApp>(this);
+
+ final HealthRecorder rec = mHealthRecorder;
+ final Context context = this;
+
+ // In some way it's sad that Android will trigger StrictMode warnings
+ // here as the whole point is to save to disk while the activity is not
+ // interacting with the user.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ SharedPreferences prefs = GeckoApp.this.getSharedPreferences();
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, true);
+ if (rec != null) {
+ rec.recordSessionEnd("P", editor);
+ }
+
+ // onPause might in fact be called even after a crash, but in that case the
+ // crash reporter will record this fact for us and we'll pick it up in onCreate.
+ mLastSessionCrashed = false;
+
+ // If we haven't done it before, cleanup any old files in our old temp dir
+ if (prefs.getBoolean(GeckoApp.PREFS_CLEANUP_TEMP_FILES, true)) {
+ File tempDir = GeckoLoader.getGREDir(GeckoApp.this);
+ FileUtils.delTree(tempDir, new FileUtils.NameAndAgeFilter(null, ONE_DAY_MS), false);
+
+ editor.putBoolean(GeckoApp.PREFS_CLEANUP_TEMP_FILES, false);
+ }
+
+ editor.apply();
+ }
+ });
+
+ if (mAppStateListeners != null) {
+ for (GeckoAppShell.AppStateListener listener : mAppStateListeners) {
+ listener.onPause();
+ }
+ }
+
+ super.onPause();
+ }
+
+ @Override
+ public void onRestart() {
+ if (mIsAbortingAppLaunch) {
+ super.onRestart();
+ return;
+ }
+
+ // Faster on main thread with an async apply().
+ final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+ try {
+ SharedPreferences.Editor editor = GeckoApp.this.getSharedPreferences().edit();
+ editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
+ editor.apply();
+ } finally {
+ StrictMode.setThreadPolicy(savedPolicy);
+ }
+
+ super.onRestart();
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mIsAbortingAppLaunch) {
+ // This build does not support the Android version of the device:
+ // We did not initialize anything, so skip cleaning up.
+ super.onDestroy();
+ return;
+ }
+
+ getAppEventDispatcher().unregisterGeckoThreadListener((GeckoEventListener)this,
+ "Gecko:Ready",
+ "Gecko:Exited",
+ "Accessibility:Event");
+
+ getAppEventDispatcher().unregisterGeckoThreadListener((NativeEventListener)this,
+ "Accessibility:Ready",
+ "Bookmark:Insert",
+ "Contact:Add",
+ "DevToolsAuth:Scan",
+ "DOMFullScreen:Start",
+ "DOMFullScreen:Stop",
+ "Image:SetAs",
+ "Locale:Set",
+ "Permissions:Data",
+ "PrivateBrowsing:Data",
+ "RuntimePermissions:Prompt",
+ "Sanitize:Finished",
+ "Session:StatePurged",
+ "Share:Text",
+ "Snackbar:Show",
+ "SystemUI:Visibility",
+ "ToggleChrome:Focus",
+ "ToggleChrome:Hide",
+ "ToggleChrome:Show",
+ "Update:Check",
+ "Update:Download",
+ "Update:Install");
+
+ if (mPromptService != null)
+ mPromptService.destroy();
+
+ final HealthRecorder rec = mHealthRecorder;
+ mHealthRecorder = null;
+ if (rec != null && rec.isEnabled()) {
+ // Closing a HealthRecorder could incur a write.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ rec.close(GeckoApp.this);
+ }
+ });
+ }
+
+ super.onDestroy();
+
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ public void showSDKVersionError() {
+ final String message = getString(R.string.unsupported_sdk_version, Build.CPU_ABI, Build.VERSION.SDK_INT);
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+ }
+
+ // Get a temporary directory, may return null
+ public static File getTempDirectory() {
+ File dir = GeckoApplication.get().getExternalFilesDir("temp");
+ return dir;
+ }
+
+ // Delete any files in our temporary directory
+ public static void deleteTempFiles() {
+ File dir = getTempDirectory();
+ if (dir == null)
+ return;
+ File[] files = dir.listFiles();
+ if (files == null)
+ return;
+ for (File file : files) {
+ file.delete();
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale);
+
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+ final Locale changed = localeManager.onSystemConfigurationChanged(this, getResources(), newConfig, mLastLocale);
+ if (changed != null) {
+ onLocaleChanged(Locales.getLanguageTag(changed));
+ }
+
+ // onConfigurationChanged is not called for 180 degree orientation changes,
+ // we will miss such rotations and the screen orientation will not be
+ // updated.
+ if (GeckoScreenOrientation.getInstance().update(newConfig.orientation)) {
+ if (mFormAssistPopup != null)
+ mFormAssistPopup.hide();
+ refreshChrome();
+ }
+ super.onConfigurationChanged(newConfig);
+ }
+
+ public String getContentProcessName() {
+ return AppConstants.MOZ_CHILD_PROCESS_NAME;
+ }
+
+ public void addEnvToIntent(Intent intent) {
+ Map<String, String> envMap = System.getenv();
+ Set<Map.Entry<String, String>> envSet = envMap.entrySet();
+ Iterator<Map.Entry<String, String>> envIter = envSet.iterator();
+ int c = 0;
+ while (envIter.hasNext()) {
+ Map.Entry<String, String> entry = envIter.next();
+ intent.putExtra("env" + c, entry.getKey() + "="
+ + entry.getValue());
+ c++;
+ }
+ }
+
+ @Override
+ public void doRestart() {
+ doRestart(null, null);
+ }
+
+ public void doRestart(String args) {
+ doRestart(args, null);
+ }
+
+ public void doRestart(Intent intent) {
+ doRestart(null, intent);
+ }
+
+ public void doRestart(String args, Intent restartIntent) {
+ if (restartIntent == null) {
+ restartIntent = new Intent(Intent.ACTION_MAIN);
+ }
+
+ if (args != null) {
+ restartIntent.putExtra("args", args);
+ }
+
+ mRestartIntent = restartIntent;
+ Log.d(LOGTAG, "doRestart(\"" + restartIntent + "\")");
+
+ doShutdown();
+ }
+
+ private void doShutdown() {
+ // Shut down GeckoApp activity.
+ runOnUiThread(new Runnable() {
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+ @Override public void run() {
+ if (!isFinishing() && (Versions.preJBMR1 || !isDestroyed())) {
+ finish();
+ }
+ }
+ });
+ }
+
+ private void checkMigrateProfile() {
+ final File profileDir = getProfile().getDir();
+
+ if (profileDir != null) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ Handler handler = new Handler();
+ handler.postDelayed(new DeferredCleanupTask(), CLEANUP_DEFERRAL_SECONDS * 1000);
+ }
+ });
+ }
+ }
+
+ private static class DeferredCleanupTask implements Runnable {
+ // The cleanup-version setting is recorded to avoid repeating the same
+ // tasks on subsequent startups; CURRENT_CLEANUP_VERSION may be updated
+ // if we need to do additional cleanup for future Gecko versions.
+
+ private static final String CLEANUP_VERSION = "cleanup-version";
+ private static final int CURRENT_CLEANUP_VERSION = 1;
+
+ @Override
+ public void run() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ long cleanupVersion = GeckoSharedPrefs.forApp(context).getInt(CLEANUP_VERSION, 0);
+
+ if (cleanupVersion < 1) {
+ // Reduce device storage footprint by removing .ttf files from
+ // the res/fonts directory: we no longer need to copy our
+ // bundled fonts out of the APK in order to use them.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=878674.
+ File dir = new File("res/fonts");
+ if (dir.exists() && dir.isDirectory()) {
+ for (File file : dir.listFiles()) {
+ if (file.isFile() && file.getName().endsWith(".ttf")) {
+ file.delete();
+ }
+ }
+ if (!dir.delete()) {
+ Log.w(LOGTAG, "unable to delete res/fonts directory (not empty?)");
+ }
+ }
+ }
+
+ // Additional cleanup needed for future versions would go here
+
+ if (cleanupVersion != CURRENT_CLEANUP_VERSION) {
+ SharedPreferences.Editor editor = GeckoSharedPrefs.forApp(context).edit();
+ editor.putInt(CLEANUP_VERSION, CURRENT_CLEANUP_VERSION);
+ editor.apply();
+ }
+ }
+ }
+
+ protected void onDone() {
+ moveTaskToBack(true);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
+ super.onBackPressed();
+ return;
+ }
+
+ if (autoHideTabs()) {
+ return;
+ }
+
+ if (mDoorHangerPopup != null && mDoorHangerPopup.isShowing()) {
+ mDoorHangerPopup.dismiss();
+ return;
+ }
+
+ if (mFullScreenPluginView != null) {
+ GeckoAppShell.onFullScreenPluginHidden(mFullScreenPluginView);
+ removeFullScreenPluginView(mFullScreenPluginView);
+ return;
+ }
+
+ if (mLayerView != null && mLayerView.isFullScreen()) {
+ GeckoAppShell.notifyObservers("FullScreen:Exit", null);
+ return;
+ }
+
+ final Tabs tabs = Tabs.getInstance();
+ final Tab tab = tabs.getSelectedTab();
+ if (tab == null) {
+ onDone();
+ return;
+ }
+
+ // Give Gecko a chance to handle the back press first, then fallback to the Java UI.
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest("Browser:OnBackPressed", null) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ if (!nativeJSObject.getBoolean("handled")) {
+ // Default behavior is Gecko didn't prevent.
+ onDefault();
+ }
+ }
+
+ @Override
+ public void onError(NativeJSObject error) {
+ // Default behavior is Gecko didn't prevent, via failure.
+ onDefault();
+ }
+
+ // Return from Gecko thread, then back-press through the Java UI.
+ private void onDefault() {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (tab.doBack()) {
+ return;
+ }
+
+ if (tab.isExternal()) {
+ onDone();
+ Tab nextSelectedTab = Tabs.getInstance().getNextTab(tab);
+ if (nextSelectedTab != null) {
+ int nextSelectedTabId = nextSelectedTab.getId();
+ GeckoAppShell.notifyObservers("Tab:KeepZombified", Integer.toString(nextSelectedTabId));
+ }
+ tabs.closeTab(tab);
+ return;
+ }
+
+ final int parentId = tab.getParentId();
+ final Tab parent = tabs.getTab(parentId);
+ if (parent != null) {
+ // The back button should always return to the parent (not a sibling).
+ tabs.closeTab(tab, parent);
+ return;
+ }
+
+ onDone();
+ }
+ });
+ }
+ });
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (!ActivityHandlerHelper.handleActivityResult(requestCode, resultCode, data)) {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, permissions, grantResults);
+ }
+
+ @Override
+ public AbsoluteLayout getPluginContainer() { return mPluginContainer; }
+
+ private static final String CPU = "cpu";
+ private static final String SCREEN = "screen";
+
+ // Called when a Gecko Hal WakeLock is changed
+ @Override
+ // We keep the wake lock independent from the function scope, so we need to
+ // suppress the linter warning.
+ @SuppressLint("Wakelock")
+ public void notifyWakeLockChanged(String topic, String state) {
+ PowerManager.WakeLock wl = mWakeLocks.get(topic);
+ if (state.equals("locked-foreground") && wl == null) {
+ PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
+
+ if (CPU.equals(topic)) {
+ wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, topic);
+ } else if (SCREEN.equals(topic)) {
+ // ON_AFTER_RELEASE is set, the user activity timer will be reset when the
+ // WakeLock is released, causing the illumination to remain on a bit longer.
+ wl = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, topic);
+ }
+
+ if (wl != null) {
+ wl.acquire();
+ mWakeLocks.put(topic, wl);
+ }
+ } else if (!state.equals("locked-foreground") && wl != null) {
+ wl.release();
+ mWakeLocks.remove(topic);
+ }
+ }
+
+ @Override
+ public void notifyCheckUpdateResult(String result) {
+ GeckoAppShell.notifyObservers("Update:CheckResult", result);
+ }
+
+ private void geckoConnected() {
+ mLayerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ }
+
+ @Override
+ public void setAccessibilityEnabled(boolean enabled) {
+ }
+
+ @Override
+ public boolean openUriExternal(String targetURI, String mimeType, String packageName, String className, String action, String title) {
+ // Default to showing prompt in private browsing to be safe.
+ return IntentHelper.openUriExternal(targetURI, mimeType, packageName, className, action, title, true);
+ }
+
+ public static class MainLayout extends RelativeLayout {
+ private TouchEventInterceptor mTouchEventInterceptor;
+ private MotionEventInterceptor mMotionEventInterceptor;
+
+ public MainLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ }
+
+ public void setTouchEventInterceptor(TouchEventInterceptor interceptor) {
+ mTouchEventInterceptor = interceptor;
+ }
+
+ public void setMotionEventInterceptor(MotionEventInterceptor interceptor) {
+ mMotionEventInterceptor = interceptor;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (mTouchEventInterceptor != null && mTouchEventInterceptor.onInterceptTouchEvent(this, event)) {
+ return true;
+ }
+ return super.onInterceptTouchEvent(event);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (mTouchEventInterceptor != null && mTouchEventInterceptor.onTouch(this, event)) {
+ return true;
+ }
+ return super.onTouchEvent(event);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent event) {
+ if (mMotionEventInterceptor != null && mMotionEventInterceptor.onInterceptMotionEvent(this, event)) {
+ return true;
+ }
+ return super.onGenericMotionEvent(event);
+ }
+
+ @Override
+ public void setDrawingCacheEnabled(boolean enabled) {
+ // Instead of setting drawing cache in the view itself, we simply
+ // enable drawing caching on its children. This is mainly used in
+ // animations (see PropertyAnimator)
+ super.setChildrenDrawnWithCacheEnabled(enabled);
+ }
+ }
+
+ private class FullScreenHolder extends FrameLayout {
+
+ public FullScreenHolder(Context ctx) {
+ super(ctx);
+ setBackgroundColor(0xff000000);
+ }
+
+ @Override
+ public void addView(View view, int index) {
+ /**
+ * This normally gets called when Flash adds a separate SurfaceView
+ * for the video. It is unhappy if we have the LayerView underneath
+ * it for some reason so we need to hide that. Hiding the LayerView causes
+ * its surface to be destroyed, which causes a pause composition
+ * event to be sent to Gecko. We synchronously wait for that to be
+ * processed. Simultaneously, however, Flash is waiting on a mutex so
+ * the post() below is an attempt to avoid a deadlock.
+ */
+ super.addView(view, index);
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLayerView.hideSurface();
+ }
+ });
+ }
+
+ /**
+ * The methods below are simply copied from what Android WebKit does.
+ * It wasn't ever called in my testing, but might as well
+ * keep it in case it is for some reason. The methods
+ * all return true because we don't want any events
+ * leaking out from the fullscreen view.
+ */
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (event.isSystem()) {
+ return super.onKeyDown(keyCode, event);
+ }
+ mFullScreenPluginView.onKeyDown(keyCode, event);
+ return true;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (event.isSystem()) {
+ return super.onKeyUp(keyCode, event);
+ }
+ mFullScreenPluginView.onKeyUp(keyCode, event);
+ return true;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ return true;
+ }
+
+ @Override
+ public boolean onTrackballEvent(MotionEvent event) {
+ mFullScreenPluginView.onTrackballEvent(event);
+ return true;
+ }
+ }
+
+ private int getVersionCode() {
+ int versionCode = 0;
+ try {
+ versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
+ } catch (NameNotFoundException e) {
+ Log.wtf(LOGTAG, getPackageName() + " not found", e);
+ }
+ return versionCode;
+ }
+
+ // FHR reason code for a session end prior to a restart for a
+ // locale change.
+ private static final String SESSION_END_LOCALE_CHANGED = "L";
+
+ /**
+ * This exists so that a locale can be applied in two places: when saved
+ * in a nested activity, and then again when we get back up to GeckoApp.
+ *
+ * GeckoApp needs to do a bunch more stuff than, say, GeckoPreferences.
+ */
+ protected void onLocaleChanged(final String locale) {
+ final boolean startNewSession = true;
+ final boolean shouldRestart = false;
+
+ // If the HealthRecorder is not yet initialized (unlikely), the locale change won't
+ // trigger a session transition and subsequent events will be recorded in an environment
+ // with the wrong locale.
+ final HealthRecorder rec = mHealthRecorder;
+ if (rec != null) {
+ rec.onAppLocaleChanged(locale);
+ rec.onEnvironmentChanged(startNewSession, SESSION_END_LOCALE_CHANGED);
+ }
+
+ if (!shouldRestart) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoApp.this.onLocaleReady(locale);
+ }
+ });
+ return;
+ }
+
+ // Do this in the background so that the health recorder has its
+ // time to finish.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoApp.this.doRestart();
+ }
+ });
+ }
+
+ /**
+ * Use BrowserLocaleManager to change our persisted and current locales,
+ * and poke the system to tell it of our changed state.
+ */
+ protected void setLocale(final String locale) {
+ if (locale == null) {
+ return;
+ }
+
+ final String resultant = BrowserLocaleManager.getInstance().setSelectedLocale(this, locale);
+ if (resultant == null) {
+ return;
+ }
+
+ onLocaleChanged(resultant);
+ }
+
+ private void setSystemUiVisible(final boolean visible) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (visible) {
+ mMainLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE);
+ } else {
+ mMainLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE);
+ }
+ }
+ });
+ }
+
+ protected HealthRecorder createHealthRecorder(final Context context,
+ final String profilePath,
+ final EventDispatcher dispatcher,
+ final String osLocale,
+ final String appLocale,
+ final SessionInformation previousSession) {
+ // GeckoApp does not need to record any health information - return a stub.
+ return new StubbedHealthRecorder();
+ }
+
+ protected void recordStartupActionTelemetry(final String passedURL, final String action) {
+ }
+
+ @Override
+ public void checkUriVisited(String uri) {
+ GlobalHistory.getInstance().checkUriVisited(uri);
+ }
+
+ @Override
+ public void markUriVisited(final String uri) {
+ final Context context = getApplicationContext();
+ final BrowserDB db = BrowserDB.from(context);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GlobalHistory.getInstance().add(context, db, uri);
+ }
+ });
+ }
+
+ @Override
+ public void setUriTitle(final String uri, final String title) {
+ final Context context = getApplicationContext();
+ final BrowserDB db = BrowserDB.from(context);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GlobalHistory.getInstance().update(context.getContentResolver(), db, uri, title);
+ }
+ });
+ }
+
+ @Override
+ public String[] getHandlersForMimeType(String mimeType, String action) {
+ Intent intent = IntentHelper.getIntentForActionString(action);
+ if (mimeType != null && mimeType.length() > 0)
+ intent.setType(mimeType);
+ return IntentHelper.getHandlersForIntent(intent);
+ }
+
+ @Override
+ public String[] getHandlersForURL(String url, String action) {
+ // May contain the whole URL or just the protocol.
+ Uri uri = url.indexOf(':') >= 0 ? Uri.parse(url) : new Uri.Builder().scheme(url).build();
+
+ Intent intent = IntentHelper.getOpenURIIntent(getApplicationContext(), uri.toString(), "",
+ TextUtils.isEmpty(action) ? Intent.ACTION_VIEW : action, "");
+
+ return IntentHelper.getHandlersForIntent(intent);
+ }
+
+ @Override
+ public String getDefaultChromeURI() {
+ // Use the chrome URI specified by Gecko's defaultChromeURI pref.
+ return null;
+ }
+
+ public GeckoView getGeckoView() {
+ return mLayerView;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
new file mode 100644
index 000000000..18a6e6535
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -0,0 +1,314 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.app.Application;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.util.Log;
+
+import com.squareup.leakcanary.LeakCanary;
+import com.squareup.leakcanary.RefWatcher;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.dlc.DownloadContentService;
+import org.mozilla.gecko.home.HomePanelsManager;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.mdns.MulticastDNSManager;
+import org.mozilla.gecko.media.AudioFocusAgent;
+import org.mozilla.gecko.notifications.NotificationClient;
+import org.mozilla.gecko.notifications.NotificationHelper;
+import org.mozilla.gecko.preferences.DistroSharedPrefsImport;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.lang.reflect.Method;
+
+public class GeckoApplication extends Application
+ implements ContextGetter {
+ private static final String LOG_TAG = "GeckoApplication";
+
+ private static volatile GeckoApplication instance;
+
+ private boolean mInBackground;
+ private boolean mPausedGecko;
+
+ private LightweightTheme mLightweightTheme;
+
+ private RefWatcher mRefWatcher;
+
+ public GeckoApplication() {
+ super();
+ instance = this;
+ }
+
+ public static GeckoApplication get() {
+ return instance;
+ }
+
+ public static RefWatcher getRefWatcher(Context context) {
+ GeckoApplication app = (GeckoApplication) context.getApplicationContext();
+ return app.mRefWatcher;
+ }
+
+ public static void watchReference(Context context, Object object) {
+ if (context == null) {
+ return;
+ }
+
+ getRefWatcher(context).watch(object);
+ }
+
+ @Override
+ public Context getContext() {
+ return this;
+ }
+
+ @Override
+ public SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forApp(this);
+ }
+
+ /**
+ * We need to do locale work here, because we need to intercept
+ * each hit to onConfigurationChanged.
+ */
+ @Override
+ public void onConfigurationChanged(Configuration config) {
+ Log.d(LOG_TAG, "onConfigurationChanged: " + config.locale +
+ ", background: " + mInBackground);
+
+ // Do nothing if we're in the background. It'll simply cause a loop
+ // (Bug 936756 Comment 11), and it's not necessary.
+ if (mInBackground) {
+ super.onConfigurationChanged(config);
+ return;
+ }
+
+ // Otherwise, correct the locale. This catches some cases that GeckoApp
+ // doesn't get a chance to.
+ try {
+ BrowserLocaleManager.getInstance().correctLocale(this, getResources(), config);
+ } catch (IllegalStateException ex) {
+ // GeckoApp hasn't started, so we have no ContextGetter in BrowserLocaleManager.
+ Log.w(LOG_TAG, "Couldn't correct locale.", ex);
+ }
+
+ super.onConfigurationChanged(config);
+ }
+
+ public void onActivityPause(GeckoActivityStatus activity) {
+ mInBackground = true;
+
+ if ((activity.isFinishing() == false) &&
+ (activity.isGeckoActivityOpened() == false)) {
+ // Notify Gecko that we are pausing; the cache service will be
+ // shutdown, closing the disk cache cleanly. If the android
+ // low memory killer subsequently kills us, the disk cache will
+ // be left in a consistent state, avoiding costly cleanup and
+ // re-creation.
+ GeckoThread.onPause();
+ mPausedGecko = true;
+
+ final BrowserDB db = BrowserDB.from(this);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.expireHistory(getContentResolver(), BrowserContract.ExpirePriority.NORMAL);
+ }
+ });
+ }
+ GeckoNetworkManager.getInstance().stop();
+ }
+
+ public void onActivityResume(GeckoActivityStatus activity) {
+ if (mPausedGecko) {
+ GeckoThread.onResume();
+ mPausedGecko = false;
+ }
+
+ GeckoBatteryManager.getInstance().start(this);
+ GeckoNetworkManager.getInstance().start(this);
+
+ mInBackground = false;
+ }
+
+ @Override
+ protected void attachBaseContext(Context base) {
+ super.attachBaseContext(base);
+ AppConstants.maybeInstallMultiDex(base);
+ }
+
+ @Override
+ public void onCreate() {
+ Log.i(LOG_TAG, "zerdatime " + SystemClock.uptimeMillis() + " - Fennec application start");
+
+ mRefWatcher = LeakCanary.install(this);
+
+ final Context context = getApplicationContext();
+ GeckoAppShell.setApplicationContext(context);
+ HardwareUtils.init(context);
+ Clipboard.init(context);
+ FilePicker.init(context);
+ DownloadsIntegration.init();
+ HomePanelsManager.getInstance().init(context);
+
+ GlobalPageMetadata.getInstance().init();
+
+ // We need to set the notification client before launching Gecko, since Gecko could start
+ // sending notifications immediately after startup, which we don't want to lose/crash on.
+ GeckoAppShell.setNotificationListener(new NotificationClient(context));
+ // This getInstance call will force initialization of the NotificationHelper, but does nothing with the result
+ NotificationHelper.getInstance(context).init();
+
+ MulticastDNSManager.getInstance(context).init();
+
+ GeckoService.register();
+
+ EventDispatcher.getInstance().registerBackgroundThreadListener(new EventListener(),
+ "Profile:Create");
+
+ super.onCreate();
+ }
+
+ public void onDelayedStartup() {
+ if (AppConstants.MOZ_ANDROID_GCM) {
+ // TODO: only run in main process.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // It's fine to throw GCM initialization onto a background thread; the registration process requires
+ // network access, so is naturally asynchronous. This, of course, races against Gecko page load of
+ // content requiring GCM-backed services, like Web Push. There's nothing to be done here.
+ try {
+ final Class<?> clazz = Class.forName("org.mozilla.gecko.push.PushService");
+ final Method onCreate = clazz.getMethod("onCreate", Context.class);
+ onCreate.invoke(null, getApplicationContext()); // Method is static.
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+ });
+ }
+
+ if (AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+ DownloadContentService.startStudy(this);
+ }
+
+ GeckoAccessibility.setAccessibilityManagerListeners(this);
+
+ AudioFocusAgent.getInstance().attachToContext(this);
+ }
+
+ private class EventListener implements BundleEventListener
+ {
+ private void onProfileCreate(final String name, final String path) {
+ // Add everything when we're done loading the distribution.
+ final Context context = GeckoApplication.this;
+ final GeckoProfile profile = GeckoProfile.get(context, name);
+ final Distribution distribution = Distribution.getInstance(context);
+
+ distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+ @Override
+ public void distributionNotFound() {
+ this.distributionFound(null);
+ }
+
+ @Override
+ public void distributionFound(final Distribution distribution) {
+ Log.d(LOG_TAG, "Running post-distribution task: bookmarks.");
+ // Because we are running in the background, we want to synchronize on the
+ // GeckoProfile instance so that we don't race with main thread operations
+ // such as locking/unlocking/removing the profile.
+ synchronized (profile.getLock()) {
+ distributionFoundLocked(distribution);
+ }
+ }
+
+ @Override
+ public void distributionArrivedLate(final Distribution distribution) {
+ Log.d(LOG_TAG, "Running late distribution task: bookmarks.");
+ // Recover as best we can.
+ synchronized (profile.getLock()) {
+ distributionArrivedLateLocked(distribution);
+ }
+ }
+
+ private void distributionFoundLocked(final Distribution distribution) {
+ // Skip initialization if the profile directory has been removed.
+ if (!(new File(path)).exists()) {
+ return;
+ }
+
+ final ContentResolver cr = context.getContentResolver();
+ final LocalBrowserDB db = new LocalBrowserDB(profile.getName());
+
+ // We pass the number of added bookmarks to ensure that the
+ // indices of the distribution and default bookmarks are
+ // contiguous. Because there are always at least as many
+ // bookmarks as there are favicons, we can also guarantee that
+ // the favicon IDs won't overlap.
+ final int offset = distribution == null ? 0 :
+ db.addDistributionBookmarks(cr, distribution, 0);
+ db.addDefaultBookmarks(context, cr, offset);
+
+ Log.d(LOG_TAG, "Running post-distribution task: android preferences.");
+ DistroSharedPrefsImport.importPreferences(context, distribution);
+ }
+
+ private void distributionArrivedLateLocked(final Distribution distribution) {
+ // Skip initialization if the profile directory has been removed.
+ if (!(new File(path)).exists()) {
+ return;
+ }
+
+ final ContentResolver cr = context.getContentResolver();
+ final LocalBrowserDB db = new LocalBrowserDB(profile.getName());
+
+ // We assume we've been called very soon after startup, and so our offset
+ // into "Mobile Bookmarks" is the number of bookmarks in the DB.
+ final int offset = db.getCount(cr, "bookmarks");
+ db.addDistributionBookmarks(cr, distribution, offset);
+
+ Log.d(LOG_TAG, "Running late distribution task: android preferences.");
+ DistroSharedPrefsImport.importPreferences(context, distribution);
+ }
+ });
+ }
+
+ @Override // BundleEventListener
+ public void handleMessage(final String event, final Bundle message,
+ final EventCallback callback) {
+ if ("Profile:Create".equals(event)) {
+ onProfileCreate(message.getCharSequence("name").toString(),
+ message.getCharSequence("path").toString());
+ }
+ }
+ }
+
+ public boolean isApplicationInBackground() {
+ return mInBackground;
+ }
+
+ public LightweightTheme getLightweightTheme() {
+ return mLightweightTheme;
+ }
+
+ public void prepareLightweightTheme() {
+ mLightweightTheme = new LightweightTheme(this);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java
new file mode 100644
index 000000000..319eccec1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoJavaSampler.java
@@ -0,0 +1,211 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.SparseArray;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import java.lang.Thread;
+import java.util.Set;
+
+public class GeckoJavaSampler {
+ private static final String LOGTAG = "JavaSampler";
+ private static Thread sSamplingThread;
+ private static SamplingThread sSamplingRunnable;
+ private static Thread sMainThread;
+
+ // Use the same timer primitive as the profiler
+ // to get a perfect sample syncing.
+ @WrapForJNI
+ private static native double getProfilerTime();
+
+ private static class Sample {
+ public Frame[] mFrames;
+ public double mTime;
+ public long mJavaTime; // non-zero if Android system time is used
+ public Sample(StackTraceElement[] aStack) {
+ mFrames = new Frame[aStack.length];
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.LIBS_READY)) {
+ mTime = getProfilerTime();
+ }
+ if (mTime == 0.0d) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = SystemClock.elapsedRealtime();
+ }
+ for (int i = 0; i < aStack.length; i++) {
+ mFrames[aStack.length - 1 - i] = new Frame();
+ mFrames[aStack.length - 1 - i].fileName = aStack[i].getFileName();
+ mFrames[aStack.length - 1 - i].lineNo = aStack[i].getLineNumber();
+ mFrames[aStack.length - 1 - i].methodName = aStack[i].getMethodName();
+ mFrames[aStack.length - 1 - i].className = aStack[i].getClassName();
+ }
+ }
+ }
+ private static class Frame {
+ public String fileName;
+ public int lineNo;
+ public String methodName;
+ public String className;
+ }
+
+ private static class SamplingThread implements Runnable {
+ private final int mInterval;
+ private final int mSampleCount;
+
+ private boolean mPauseSampler;
+ private boolean mStopSampler;
+
+ private final SparseArray<Sample[]> mSamples = new SparseArray<Sample[]>();
+ private int mSamplePos;
+
+ public SamplingThread(final int aInterval, final int aSampleCount) {
+ // If we sample faster then 10ms we get to many missed samples
+ mInterval = Math.max(10, aInterval);
+ mSampleCount = aSampleCount;
+ }
+
+ @Override
+ public void run() {
+ synchronized (GeckoJavaSampler.class) {
+ mSamples.put(0, new Sample[mSampleCount]);
+ mSamplePos = 0;
+
+ // Find the main thread
+ Set<Thread> threadSet = Thread.getAllStackTraces().keySet();
+ for (Thread t : threadSet) {
+ if (t.getName().compareToIgnoreCase("main") == 0) {
+ sMainThread = t;
+ break;
+ }
+ }
+
+ if (sMainThread == null) {
+ Log.e(LOGTAG, "Main thread not found");
+ return;
+ }
+ }
+
+ while (true) {
+ try {
+ Thread.sleep(mInterval);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ synchronized (GeckoJavaSampler.class) {
+ if (!mPauseSampler) {
+ StackTraceElement[] bt = sMainThread.getStackTrace();
+ mSamples.get(0)[mSamplePos] = new Sample(bt);
+ mSamplePos = (mSamplePos + 1) % mSamples.get(0).length;
+ }
+ if (mStopSampler) {
+ break;
+ }
+ }
+ }
+ }
+
+ private Sample getSample(int aThreadId, int aSampleId) {
+ if (aThreadId < mSamples.size() && aSampleId < mSamples.get(aThreadId).length &&
+ mSamples.get(aThreadId)[aSampleId] != null) {
+ int startPos = 0;
+ if (mSamples.get(aThreadId)[mSamplePos] != null) {
+ startPos = mSamplePos;
+ }
+ int readPos = (startPos + aSampleId) % mSamples.get(aThreadId).length;
+ return mSamples.get(aThreadId)[readPos];
+ }
+ return null;
+ }
+ }
+
+
+ @WrapForJNI
+ public synchronized static String getThreadName(int aThreadId) {
+ if (aThreadId == 0 && sMainThread != null) {
+ return sMainThread.getName();
+ }
+ return null;
+ }
+
+ private synchronized static Sample getSample(int aThreadId, int aSampleId) {
+ return sSamplingRunnable.getSample(aThreadId, aSampleId);
+ }
+
+ @WrapForJNI
+ public synchronized static double getSampleTime(int aThreadId, int aSampleId) {
+ Sample sample = getSample(aThreadId, aSampleId);
+ if (sample != null) {
+ if (sample.mJavaTime != 0) {
+ return (sample.mJavaTime -
+ SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ System.out.println("Sample: " + sample.mTime);
+ return sample.mTime;
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public synchronized static String getFrameName(int aThreadId, int aSampleId, int aFrameId) {
+ Sample sample = getSample(aThreadId, aSampleId);
+ if (sample != null && aFrameId < sample.mFrames.length) {
+ Frame frame = sample.mFrames[aFrameId];
+ if (frame == null) {
+ return null;
+ }
+ return frame.className + "." + frame.methodName + "()";
+ }
+ return null;
+ }
+
+ @WrapForJNI
+ public static void start(int aInterval, int aSamples) {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable != null) {
+ return;
+ }
+ sSamplingRunnable = new SamplingThread(aInterval, aSamples);
+ sSamplingThread = new Thread(sSamplingRunnable, "Java Sampler");
+ sSamplingThread.start();
+ }
+ }
+
+ @WrapForJNI
+ public static void pause() {
+ synchronized (GeckoJavaSampler.class) {
+ sSamplingRunnable.mPauseSampler = true;
+ }
+ }
+
+ @WrapForJNI
+ public static void unpause() {
+ synchronized (GeckoJavaSampler.class) {
+ sSamplingRunnable.mPauseSampler = false;
+ }
+ }
+
+ @WrapForJNI
+ public static void stop() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingThread == null) {
+ return;
+ }
+
+ sSamplingRunnable.mStopSampler = true;
+ try {
+ sSamplingThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ sSamplingThread = null;
+ sSamplingRunnable = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java b/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java
new file mode 100644
index 000000000..c199aad55
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMediaPlayer.java
@@ -0,0 +1,27 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.util.EventCallback;
+
+/**
+ * Wrapper for MediaRouter types supported by Android, such as Chromecast, Miracast, etc.
+ */
+interface GeckoMediaPlayer {
+ /**
+ * Can return null.
+ */
+ JSONObject toJSON();
+ void load(String title, String url, String type, EventCallback callback);
+ void play(EventCallback callback);
+ void pause(EventCallback callback);
+ void stop(EventCallback callback);
+ void start(EventCallback callback);
+ void end(EventCallback callback);
+ void mirror(EventCallback callback);
+ void message(String message, EventCallback callback);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java b/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java
new file mode 100644
index 000000000..b7f4870c2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoMessageReceiver.java
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class GeckoMessageReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+ if (GeckoApp.ACTION_INIT_PW.equals(action)) {
+ GeckoAppShell.notifyObservers("Passwords:Init", null);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java b/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java
new file mode 100644
index 000000000..df9844d7b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoPresentationDisplay.java
@@ -0,0 +1,22 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.util.EventCallback;
+
+/**
+ * Wrapper for MediaRouter types supported by Android to use for
+ * Presentation API, such as Chromecast, Miracast, etc.
+ */
+interface GeckoPresentationDisplay {
+ /**
+ * Can return null.
+ */
+ JSONObject toJSON();
+ void start(EventCallback callback);
+ void stop(EventCallback callback);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java b/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java
new file mode 100644
index 000000000..8a9c461c5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoProfilesProvider.java
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.File;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
+import org.mozilla.gecko.db.BrowserContract;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.util.Log;
+
+/**
+ * This is not a per-profile provider. This provider allows read-only,
+ * restricted access to certain attributes of Fennec profiles.
+ */
+public class GeckoProfilesProvider extends ContentProvider {
+ private static final String LOG_TAG = "GeckoProfilesProvider";
+
+ private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ private static final int PROFILES = 100;
+ private static final int PROFILES_NAME = 101;
+ private static final int PROFILES_DEFAULT = 200;
+
+ private static final String[] DEFAULT_ARGS = {
+ BrowserContract.Profiles.NAME,
+ BrowserContract.Profiles.PATH,
+ };
+
+ static {
+ URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "profiles", PROFILES);
+ URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "profiles/*", PROFILES_NAME);
+ URI_MATCHER.addURI(BrowserContract.PROFILES_AUTHORITY, "default", PROFILES_DEFAULT);
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return null;
+ }
+
+ @Override
+ public boolean onCreate() {
+ // Successfully loaded.
+ return true;
+ }
+
+ private String[] profileValues(final String name, final String path, int len, int nameIndex, int pathIndex) {
+ final String[] values = new String[len];
+ if (nameIndex >= 0) {
+ values[nameIndex] = name;
+ }
+ if (pathIndex >= 0) {
+ values[pathIndex] = path;
+ }
+ return values;
+ }
+
+ protected void addRowForProfile(final MatrixCursor cursor, final int len, final int nameIndex, final int pathIndex, final String name, final String path) {
+ if (path == null || name == null) {
+ return;
+ }
+
+ cursor.addRow(profileValues(name, path, len, nameIndex, pathIndex));
+ }
+
+ protected Cursor getCursorForProfiles(final String[] args, Map<String, String> profiles) {
+ // Compute the projection.
+ int nameIndex = -1;
+ int pathIndex = -1;
+ for (int i = 0; i < args.length; ++i) {
+ if (BrowserContract.Profiles.NAME.equals(args[i])) {
+ nameIndex = i;
+ } else if (BrowserContract.Profiles.PATH.equals(args[i])) {
+ pathIndex = i;
+ }
+ }
+
+ final MatrixCursor cursor = new MatrixCursor(args);
+ for (Entry<String, String> entry : profiles.entrySet()) {
+ addRowForProfile(cursor, args.length, nameIndex, pathIndex, entry.getKey(), entry.getValue());
+ }
+ return cursor;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+
+ final String[] args = (projection == null) ? DEFAULT_ARGS : projection;
+
+ final File mozillaDir;
+ try {
+ mozillaDir = GeckoProfileDirectories.getMozillaDirectory(getContext());
+ } catch (NoMozillaDirectoryException e) {
+ Log.d(LOG_TAG, "No Mozilla directory; cannot query for profiles. Assuming there are none.");
+ return new MatrixCursor(projection);
+ }
+
+ final Map<String, String> matchingProfiles;
+
+ final int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case PROFILES:
+ // Return all profiles.
+ matchingProfiles = GeckoProfileDirectories.getAllProfiles(mozillaDir);
+ break;
+ case PROFILES_NAME:
+ // Return data about the specified profile.
+ final String name = uri.getLastPathSegment();
+ matchingProfiles = GeckoProfileDirectories.getProfilesNamed(mozillaDir,
+ name);
+ break;
+ case PROFILES_DEFAULT:
+ matchingProfiles = GeckoProfileDirectories.getDefaultProfile(mozillaDir);
+ break;
+ default:
+ throw new UnsupportedOperationException("Unknown query URI " + uri);
+ }
+
+ return getCursorForProfiles(args, matchingProfiles);
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ throw new IllegalStateException("Inserts not supported.");
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new IllegalStateException("Deletes not supported.");
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ throw new IllegalStateException("Updates not supported.");
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoService.java b/mobile/android/base/java/org/mozilla/gecko/GeckoService.java
new file mode 100644
index 000000000..3a99fd2a1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoService.java
@@ -0,0 +1,236 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.app.AlarmManager;
+import android.app.Service;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.IBinder;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import java.io.File;
+
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.EventCallback;
+
+public class GeckoService extends Service {
+
+ private static final String LOGTAG = "GeckoService";
+ private static final boolean DEBUG = false;
+
+ private static final String INTENT_PROFILE_NAME = "org.mozilla.gecko.intent.PROFILE_NAME";
+ private static final String INTENT_PROFILE_DIR = "org.mozilla.gecko.intent.PROFILE_DIR";
+
+ private static final String INTENT_ACTION_UPDATE_ADDONS = "update-addons";
+ private static final String INTENT_ACTION_CREATE_SERVICES = "create-services";
+
+ private static final String INTENT_SERVICE_CATEGORY = "category";
+ private static final String INTENT_SERVICE_DATA = "data";
+
+ private static class EventListener implements NativeEventListener {
+ @Override // NativeEventListener
+ public void handleMessage(final String event,
+ final NativeJSObject message,
+ final EventCallback callback) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ switch (event) {
+ case "Gecko:ScheduleRun":
+ if (DEBUG) {
+ Log.d(LOGTAG, "Scheduling " + message.getString("action") +
+ " @ " + message.getInt("interval") + "ms");
+ }
+
+ final Intent intent = getIntentForAction(context, message.getString("action"));
+ final PendingIntent pendingIntent = PendingIntent.getService(
+ context, /* requestCode */ 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ final AlarmManager am = (AlarmManager)
+ context.getSystemService(Context.ALARM_SERVICE);
+ // Cancel any previous alarm and schedule a new one.
+ am.setInexactRepeating(AlarmManager.ELAPSED_REALTIME,
+ message.getInt("trigger"),
+ message.getInt("interval"),
+ pendingIntent);
+ break;
+
+ default:
+ throw new UnsupportedOperationException(event);
+ }
+ }
+ }
+
+ private static final EventListener EVENT_LISTENER = new EventListener();
+
+ public static void register() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Registered listener");
+ }
+ EventDispatcher.getInstance().registerGeckoThreadListener(EVENT_LISTENER,
+ "Gecko:ScheduleRun");
+ }
+
+ public static void unregister() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Unregistered listener");
+ }
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(EVENT_LISTENER,
+ "Gecko:ScheduleRun");
+ }
+
+ @Override // Service
+ public void onCreate() {
+ GeckoAppShell.ensureCrashHandling();
+ GeckoThread.onResume();
+ super.onCreate();
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Created");
+ }
+ }
+
+ @Override // Service
+ public void onDestroy() {
+ GeckoThread.onPause();
+
+ // We want to block here if we can, so we don't get killed when Gecko is in the
+ // middle of handling onPause().
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoThread.waitOnGecko();
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Destroyed");
+ }
+ super.onDestroy();
+ }
+
+ private static Intent getIntentForAction(final Context context, final String action) {
+ final Intent intent = new Intent(action, /* uri */ null, context, GeckoService.class);
+ final GeckoProfile profile = GeckoThread.getActiveProfile();
+ if (profile != null) {
+ setIntentProfile(intent, profile.getName(), profile.getDir().getAbsolutePath());
+ }
+ return intent;
+ }
+
+ public static Intent getIntentToCreateServices(final Context context, final String category, final String data) {
+ final Intent intent = getIntentForAction(context, INTENT_ACTION_CREATE_SERVICES);
+ intent.putExtra(INTENT_SERVICE_CATEGORY, category);
+ intent.putExtra(INTENT_SERVICE_DATA, data);
+ return intent;
+ }
+
+ public static Intent getIntentToCreateServices(final Context context, final String category) {
+ return getIntentToCreateServices(context, category, /* data */ null);
+ }
+
+ public static void setIntentProfile(final Intent intent, final String profileName,
+ final String profileDir) {
+ intent.putExtra(INTENT_PROFILE_NAME, profileName);
+ intent.putExtra(INTENT_PROFILE_DIR, profileDir);
+ }
+
+ private int handleIntent(final Intent intent, final int startId) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Handling " + intent.getAction());
+ }
+
+ final String profileName = intent.getStringExtra(INTENT_PROFILE_NAME);
+ final String profileDir = intent.getStringExtra(INTENT_PROFILE_DIR);
+
+ if (profileName == null) {
+ throw new IllegalArgumentException("Intent must specify profile.");
+ }
+
+ if (!GeckoThread.initWithProfile(profileName != null ? profileName : "",
+ profileDir != null ? new File(profileDir) : null)) {
+ Log.w(LOGTAG, "Ignoring due to profile mismatch: " +
+ profileName + " [" + profileDir + ']');
+
+ final GeckoProfile profile = GeckoThread.getActiveProfile();
+ if (profile != null) {
+ Log.w(LOGTAG, "Current profile is " + profile.getName() +
+ " [" + profile.getDir().getAbsolutePath() + ']');
+ }
+ stopSelf(startId);
+ return Service.START_NOT_STICKY;
+ }
+
+ GeckoThread.launch();
+
+ switch (intent.getAction()) {
+ case INTENT_ACTION_UPDATE_ADDONS:
+ // Run the add-on update service. Because the service is automatically invoked
+ // when loading Gecko, we don't have to do anything else here.
+ break;
+
+ case INTENT_ACTION_CREATE_SERVICES:
+ final String category = intent.getStringExtra(INTENT_SERVICE_CATEGORY);
+ final String data = intent.getStringExtra(INTENT_SERVICE_DATA);
+
+ if (category == null) {
+ break;
+ }
+ GeckoThread.createServices(category, data);
+ break;
+
+ default:
+ Log.w(LOGTAG, "Unknown request: " + intent);
+ }
+
+ stopSelf(startId);
+ return Service.START_NOT_STICKY;
+ }
+
+ @Override // Service
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ if (intent == null) {
+ return Service.START_NOT_STICKY;
+ }
+ try {
+ return handleIntent(intent, startId);
+ } catch (final Throwable e) {
+ Log.e(LOGTAG, "Cannot handle intent: " + intent, e);
+ return Service.START_NOT_STICKY;
+ }
+ }
+
+ @Override // Service
+ public IBinder onBind(final Intent intent) {
+ return null;
+ }
+
+ public static void startGecko(final GeckoProfile profile, final String args, final Context context) {
+ if (GeckoThread.isLaunched()) {
+ if (DEBUG) {
+ Log.v(LOGTAG, "already launched");
+ }
+ return;
+ }
+
+ Handler handler = new Handler(Looper.getMainLooper());
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.ensureCrashHandling();
+ GeckoAppShell.setApplicationContext(context);
+ GeckoThread.onResume();
+
+ GeckoThread.init(profile, args, null, false);
+ GeckoThread.launch();
+
+ if (DEBUG) {
+ Log.v(LOGTAG, "warmed up (launched)");
+ }
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java b/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java
new file mode 100644
index 000000000..f73c42e40
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoUpdateReceiver.java
@@ -0,0 +1,25 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.updater.UpdateServiceHelper;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class GeckoUpdateReceiver extends BroadcastReceiver
+{
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT.equals(intent.getAction())) {
+ String result = intent.getStringExtra("result");
+ if (GeckoAppShell.getGeckoInterface() != null && result != null) {
+ GeckoAppShell.getGeckoInterface().notifyCheckUpdateResult(result);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java b/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java
new file mode 100644
index 000000000..c1d9c4939
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GlobalHistory.java
@@ -0,0 +1,178 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.lang.ref.SoftReference;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
+
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.util.Log;
+
+class GlobalHistory {
+ private static final String LOGTAG = "GeckoGlobalHistory";
+
+ public static final String EVENT_URI_AVAILABLE_IN_HISTORY = "URI_INSERTED_TO_HISTORY";
+ public static final String EVENT_PARAM_URI = "uri";
+
+ private static final String TELEMETRY_HISTOGRAM_ADD = "FENNEC_GLOBALHISTORY_ADD_MS";
+ private static final String TELEMETRY_HISTOGRAM_UPDATE = "FENNEC_GLOBALHISTORY_UPDATE_MS";
+ private static final String TELEMETRY_HISTOGRAM_BUILD_VISITED_LINK = "FENNEC_GLOBALHISTORY_VISITED_BUILD_MS";
+
+ private static final GlobalHistory sInstance = new GlobalHistory();
+
+ static GlobalHistory getInstance() {
+ return sInstance;
+ }
+
+ // this is the delay between receiving a URI check request and processing it.
+ // this allows batching together multiple requests and processing them together,
+ // which is more efficient.
+ private static final long BATCHING_DELAY_MS = 100;
+
+ private final Handler mHandler; // a background thread on which we can process requests
+
+ // Note: These fields are accessed through the NotificationRunnable inner class.
+ final Queue<String> mPendingUris; // URIs that need to be checked
+ SoftReference<Set<String>> mVisitedCache; // cache of the visited URI list
+ boolean mProcessing; // = false // whether or not the runnable is queued/working
+
+ private class NotifierRunnable implements Runnable {
+ private final ContentResolver mContentResolver;
+ private final BrowserDB mDB;
+
+ public NotifierRunnable(final Context context) {
+ mContentResolver = context.getContentResolver();
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public void run() {
+ Set<String> visitedSet = mVisitedCache.get();
+ if (visitedSet == null) {
+ // The cache was wiped. Repopulate it.
+ Log.w(LOGTAG, "Rebuilding visited link set...");
+ final long start = SystemClock.uptimeMillis();
+ final Cursor c = mDB.getAllVisitedHistory(mContentResolver);
+ if (c == null) {
+ return;
+ }
+
+ try {
+ visitedSet = new HashSet<String>();
+ if (c.moveToFirst()) {
+ do {
+ visitedSet.add(c.getString(0));
+ } while (c.moveToNext());
+ }
+ mVisitedCache = new SoftReference<Set<String>>(visitedSet);
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_BUILD_VISITED_LINK, (int) Math.min(took, Integer.MAX_VALUE));
+ } finally {
+ c.close();
+ }
+ }
+
+ // This runs on the same handler thread as the checkUriVisited code,
+ // so no synchronization is needed.
+ while (true) {
+ final String uri = mPendingUris.poll();
+ if (uri == null) {
+ break;
+ }
+
+ if (visitedSet.contains(uri)) {
+ GeckoAppShell.notifyUriVisited(uri);
+ }
+ }
+
+ mProcessing = false;
+ }
+ };
+
+ private GlobalHistory() {
+ mHandler = ThreadUtils.getBackgroundHandler();
+ mPendingUris = new LinkedList<String>();
+ mVisitedCache = new SoftReference<Set<String>>(null);
+ }
+
+ public void addToGeckoOnly(String uri) {
+ Set<String> visitedSet = mVisitedCache.get();
+ if (visitedSet != null) {
+ visitedSet.add(uri);
+ }
+ GeckoAppShell.notifyUriVisited(uri);
+ }
+
+ public void add(final Context context, final BrowserDB db, String uri) {
+ ThreadUtils.assertOnBackgroundThread();
+ final long start = SystemClock.uptimeMillis();
+
+ // stripAboutReaderUrl only removes about:reader if present, in all other cases the original string is returned
+ final String uriToStore = ReaderModeUtils.stripAboutReaderUrl(uri);
+
+ db.updateVisitedHistory(context.getContentResolver(), uriToStore);
+
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_ADD, (int) Math.min(took, Integer.MAX_VALUE));
+ addToGeckoOnly(uriToStore);
+ dispatchUriAvailableMessage(uri);
+ }
+
+ @SuppressWarnings("static-method")
+ public void update(final ContentResolver cr, final BrowserDB db, String uri, String title) {
+ ThreadUtils.assertOnBackgroundThread();
+ final long start = SystemClock.uptimeMillis();
+
+ final String uriToStore = ReaderModeUtils.stripAboutReaderUrl(uri);
+
+ db.updateHistoryTitle(cr, uriToStore, title);
+
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_UPDATE, (int) Math.min(took, Integer.MAX_VALUE));
+ }
+
+ public void checkUriVisited(final String uri) {
+ final String storedURI = ReaderModeUtils.stripAboutReaderUrl(uri);
+
+ final NotifierRunnable runnable = new NotifierRunnable(GeckoAppShell.getContext());
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ // this runs on the same handler thread as the processing loop,
+ // so no synchronization needed
+ mPendingUris.add(storedURI);
+ if (mProcessing) {
+ // there's already a runnable queued up or working away, so
+ // no need to post another
+ return;
+ }
+ mProcessing = true;
+ mHandler.postDelayed(runnable, BATCHING_DELAY_MS);
+ }
+ });
+ }
+
+ private void dispatchUriAvailableMessage(String uri) {
+ final Bundle message = new Bundle();
+ message.putString(EVENT_PARAM_URI, uri);
+ EventDispatcher.getInstance().dispatch(EVENT_URI_AVAILABLE_IN_HISTORY, message);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
new file mode 100644
index 000000000..d9d12962c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GlobalPageMetadata.java
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.ContentProviderClient;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Provides access to metadata information about websites.
+ *
+ * While storing, in case of timing issues preventing us from looking up History GUID by a given uri,
+ * we queue up metadata and wait for GlobalHistory to let us know history record is now available.
+ *
+ * TODO Bug 1313515: selection of metadata for a given uri/history_GUID
+ *
+ * @author grisha
+ */
+/* package-local */ class GlobalPageMetadata implements BundleEventListener {
+ private static final String LOG_TAG = "GeckoGlobalPageMetadata";
+
+ private static final GlobalPageMetadata instance = new GlobalPageMetadata();
+
+ private static final String KEY_HAS_IMAGE = "hasImage";
+ private static final String KEY_METADATA_JSON = "metadataJSON";
+
+ private static final int MAX_METADATA_QUEUE_SIZE = 15;
+
+ private final Map<String, Bundle> queuedMetadata = Collections.synchronizedMap(new LimitedLinkedHashMap<String, Bundle>());
+
+ public static GlobalPageMetadata getInstance() {
+ return instance;
+ }
+
+ private static class LimitedLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
+ private static final long serialVersionUID = 6359725112736360244L;
+
+ @Override
+ protected boolean removeEldestEntry(Entry<K, V> eldest) {
+ if (size() > MAX_METADATA_QUEUE_SIZE) {
+ Log.w(LOG_TAG, "Page metadata queue is full. Dropping oldest metadata.");
+ return true;
+ }
+ return false;
+ }
+ }
+
+ private GlobalPageMetadata() {}
+
+ public void init() {
+ EventDispatcher
+ .getInstance()
+ .registerBackgroundThreadListener(this, GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY);
+ }
+
+ public void add(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) {
+ ThreadUtils.assertOnBackgroundThread();
+
+ // NB: Other than checking that JSON is valid and trimming it,
+ // we do not process metadataJSON in any way, trusting our source.
+ doAddOrQueue(db, contentProviderClient, uri, hasImage, metadataJSON);
+ }
+
+ @VisibleForTesting
+ /*package-local */ void doAddOrQueue(BrowserDB db, ContentProviderClient contentProviderClient, String uri, boolean hasImage, @NonNull String metadataJSON) {
+ final String preparedMetadataJSON;
+ try {
+ preparedMetadataJSON = prepareJSON(metadataJSON);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Couldn't process metadata JSON", e);
+ return;
+ }
+
+ // Don't bother queuing this if deletions fails to find a corresponding history record.
+ // If we can't delete metadata because it didn't exist yet, that's OK.
+ if (preparedMetadataJSON.equals("{}")) {
+ final int deleted = db.deletePageMetadata(contentProviderClient, uri);
+ // We could delete none if history record for uri isn't present.
+ // We must delete one if history record for uri is present.
+ if (deleted != 0 && deleted != 1) {
+ throw new IllegalStateException("Deleted unexpected number of page metadata records: " + deleted);
+ }
+ return;
+ }
+
+ // If we could insert page metadata, we're done.
+ if (db.insertPageMetadata(contentProviderClient, uri, hasImage, preparedMetadataJSON)) {
+ return;
+ }
+
+ // Otherwise, we need to queue it for future insertion when history record is available.
+ Bundle bundledMetadata = new Bundle();
+ bundledMetadata.putBoolean(KEY_HAS_IMAGE, hasImage);
+ bundledMetadata.putString(KEY_METADATA_JSON, preparedMetadataJSON);
+ queuedMetadata.put(uri, bundledMetadata);
+ }
+
+ @VisibleForTesting
+ /* package-local */ int getMetadataQueueSize() {
+ return queuedMetadata.size();
+ }
+
+ @Override
+ public void handleMessage(String event, Bundle message, EventCallback callback) {
+ ThreadUtils.assertOnBackgroundThread();
+
+ if (!GlobalHistory.EVENT_URI_AVAILABLE_IN_HISTORY.equals(event)) {
+ return;
+ }
+
+ final String uri = message.getString(GlobalHistory.EVENT_PARAM_URI);
+ if (TextUtils.isEmpty(uri)) {
+ return;
+ }
+
+ final Bundle bundledMetadata;
+ synchronized (queuedMetadata) {
+ if (!queuedMetadata.containsKey(uri)) {
+ return;
+ }
+
+ bundledMetadata = queuedMetadata.get(uri);
+ queuedMetadata.remove(uri);
+ }
+
+ insertMetadataBundleForUri(uri, bundledMetadata);
+ }
+
+ private void insertMetadataBundleForUri(String uri, Bundle bundledMetadata) {
+ final boolean hasImage = bundledMetadata.getBoolean(KEY_HAS_IMAGE);
+ final String metadataJSON = bundledMetadata.getString(KEY_METADATA_JSON);
+
+ // Acquire CPC, must be released in this function.
+ final ContentProviderClient contentProviderClient = GeckoAppShell.getApplicationContext()
+ .getContentResolver()
+ .acquireContentProviderClient(BrowserContract.PageMetadata.CONTENT_URI);
+
+ // Pre-conditions...
+ if (contentProviderClient == null) {
+ Log.e(LOG_TAG, "Couldn't acquire content provider client");
+ return;
+ }
+
+ if (TextUtils.isEmpty(metadataJSON)) {
+ Log.e(LOG_TAG, "Metadata bundle contained empty metadata json");
+ return;
+ }
+
+ // Insert!
+ try {
+ add(
+ BrowserDB.from(GeckoThread.getActiveProfile()),
+ contentProviderClient,
+ uri, hasImage, metadataJSON
+ );
+ } finally {
+ contentProviderClient.release();
+ }
+ }
+
+ private String prepareJSON(String json) throws JSONException {
+ return (new JSONObject(json)).toString();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/GuestSession.java b/mobile/android/base/java/org/mozilla/gecko/GuestSession.java
new file mode 100644
index 000000000..69502f44a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/GuestSession.java
@@ -0,0 +1,51 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko;
+
+import android.app.KeyguardManager;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.support.v4.app.NotificationCompat;
+import android.view.Window;
+import android.view.WindowManager;
+
+// Utility methods for entering/exiting guest mode.
+public final class GuestSession {
+ private static final String LOGTAG = "GeckoGuestSession";
+
+ public static final String NOTIFICATION_INTENT = "org.mozilla.gecko.GUEST_SESSION_INPROGRESS";
+
+ private static PendingIntent getNotificationIntent(Context context) {
+ Intent intent = new Intent(NOTIFICATION_INTENT);
+ intent.setClassName(context, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ public static void showNotification(Context context) {
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ final Resources res = context.getResources();
+ builder.setContentTitle(res.getString(R.string.guest_browsing_notification_title))
+ .setContentText(res.getString(R.string.guest_browsing_notification_text))
+ .setSmallIcon(R.drawable.alert_guest)
+ .setOngoing(true)
+ .setContentIntent(getNotificationIntent(context));
+
+ final NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ manager.notify(R.id.guestNotification, builder.build());
+ }
+
+ public static void hideNotification(Context context) {
+ final NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ manager.cancel(R.id.guestNotification);
+ }
+
+ public static void onNotificationIntentReceived(BrowserApp context) {
+ context.showGuestModeDialog(BrowserApp.GuestModeDialog.LEAVING);
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
new file mode 100644
index 000000000..efe9576d7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
@@ -0,0 +1,593 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.overlays.ui.ShareDialog;
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.JSONUtils;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.widget.ExternalIntentDuringPrivateBrowsingPromptFragment;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.provider.Browser;
+import android.support.annotation.Nullable;
+import android.support.v4.app.FragmentActivity;
+import android.text.TextUtils;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+public final class IntentHelper implements GeckoEventListener,
+ NativeEventListener {
+
+ private static final String LOGTAG = "GeckoIntentHelper";
+ private static final String[] EVENTS = {
+ "Intent:GetHandlers",
+ "Intent:Open",
+ "Intent:OpenForResult",
+ };
+
+ private static final String[] NATIVE_EVENTS = {
+ "Intent:OpenNoHandler",
+ };
+
+ // via http://developer.android.com/distribute/tools/promote/linking.html
+ private static String MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id=";
+ private static String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url";
+
+ /** A partial URI to an error page - the encoded error URI should be appended before loading. */
+ private static String UNKNOWN_PROTOCOL_URI_PREFIX = "about:neterror?e=unknownProtocolFound&u=";
+
+ private static IntentHelper instance;
+
+ private final FragmentActivity activity;
+
+ private IntentHelper(final FragmentActivity activity) {
+ this.activity = activity;
+ EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this, EVENTS);
+ EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener) this, NATIVE_EVENTS);
+ }
+
+ public static IntentHelper init(final FragmentActivity activity) {
+ if (instance == null) {
+ instance = new IntentHelper(activity);
+ } else {
+ Log.w(LOGTAG, "IntentHelper.init() called twice, ignoring.");
+ }
+
+ return instance;
+ }
+
+ public static void destroy() {
+ if (instance != null) {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) instance, EVENTS);
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) instance, NATIVE_EVENTS);
+ instance = null;
+ }
+ }
+
+ /**
+ * Given the inputs to <code>getOpenURIIntent</code>, plus an optional
+ * package name and class name, create and fire an intent to open the
+ * provided URI. If a class name is specified but a package name is not,
+ * we will default to using the current fennec package.
+ *
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param packageName an optional app package name.
+ * @param className an optional intent class name.
+ * @param action an Android action specifier, such as
+ * <code>Intent.ACTION_SEND</code>.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @param showPromptInPrivateBrowsing whether or not the user should be prompted when opening
+ * this uri from private browsing. This should be true
+ * when the user doesn't explicitly choose to open an an
+ * external app (e.g. just clicked a link).
+ * @return true if the activity started successfully or the user was prompted to open the
+ * application; false otherwise.
+ */
+ public static boolean openUriExternal(String targetURI,
+ String mimeType,
+ String packageName,
+ String className,
+ String action,
+ String title,
+ final boolean showPromptInPrivateBrowsing) {
+ final GeckoAppShell.GeckoInterface gi = GeckoAppShell.getGeckoInterface();
+ final Context activityContext = gi != null ? gi.getActivity() : null;
+ final Context context = activityContext != null ? activityContext : GeckoAppShell.getApplicationContext();
+ final Intent intent = getOpenURIIntent(context, targetURI,
+ mimeType, action, title);
+
+ if (intent == null) {
+ return false;
+ }
+
+ if (!TextUtils.isEmpty(className)) {
+ if (!TextUtils.isEmpty(packageName)) {
+ intent.setClassName(packageName, className);
+ } else {
+ // Default to using the fennec app context.
+ intent.setClassName(context, className);
+ }
+ }
+
+ if (!showPromptInPrivateBrowsing || activityContext == null) {
+ if (activityContext == null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, intent);
+ } else {
+ // Ideally we retrieve the Activity from the calling args, rather than
+ // statically, but since this method is called from Gecko and I'm
+ // unfamiliar with that code, this is a simpler solution.
+ final FragmentActivity fragmentActivity = (FragmentActivity) activityContext;
+ return ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser(
+ context, fragmentActivity.getSupportFragmentManager(), intent);
+ }
+ }
+
+ public static boolean hasHandlersForIntent(Intent intent) {
+ try {
+ return !GeckoAppShell.queryIntentActivities(intent).isEmpty();
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception in hasHandlersForIntent");
+ return false;
+ }
+ }
+
+ public static String[] getHandlersForIntent(Intent intent) {
+ final PackageManager pm = GeckoAppShell.getApplicationContext().getPackageManager();
+ try {
+ final List<ResolveInfo> list = GeckoAppShell.queryIntentActivities(intent);
+
+ int numAttr = 4;
+ final String[] ret = new String[list.size() * numAttr];
+ for (int i = 0; i < list.size(); i++) {
+ ResolveInfo resolveInfo = list.get(i);
+ ret[i * numAttr] = resolveInfo.loadLabel(pm).toString();
+ if (resolveInfo.isDefault)
+ ret[i * numAttr + 1] = "default";
+ else
+ ret[i * numAttr + 1] = "";
+ ret[i * numAttr + 2] = resolveInfo.activityInfo.applicationInfo.packageName;
+ ret[i * numAttr + 3] = resolveInfo.activityInfo.name;
+ }
+ return ret;
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception in getHandlersForIntent");
+ return new String[0];
+ }
+ }
+
+ public static Intent getIntentForActionString(String aAction) {
+ // Default to the view action if no other action as been specified.
+ if (TextUtils.isEmpty(aAction)) {
+ return new Intent(Intent.ACTION_VIEW);
+ }
+ return new Intent(aAction);
+ }
+
+ /**
+ * Given a URI, a MIME type, and a title,
+ * produce a share intent which can be used to query all activities
+ * than can open the specified URI.
+ *
+ * @param context a <code>Context</code> instance.
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @return an <code>Intent</code>, or <code>null</code> if none could be
+ * produced.
+ */
+ public static Intent getShareIntent(final Context context,
+ final String targetURI,
+ final String mimeType,
+ final String title) {
+ Intent shareIntent = getIntentForActionString(Intent.ACTION_SEND);
+ shareIntent.putExtra(Intent.EXTRA_TEXT, targetURI);
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, title);
+ shareIntent.putExtra(ShareDialog.INTENT_EXTRA_DEVICES_ONLY, true);
+
+ // Note that EXTRA_TITLE is intended to be used for share dialog
+ // titles. Common usage (e.g., Pocket) suggests that it's sometimes
+ // interpreted as an alternate to EXTRA_SUBJECT, so we include it.
+ shareIntent.putExtra(Intent.EXTRA_TITLE, title);
+
+ if (mimeType != null && mimeType.length() > 0) {
+ shareIntent.setType(mimeType);
+ }
+
+ return shareIntent;
+ }
+
+ /**
+ * Given a URI, a MIME type, an Android intent "action", and a title,
+ * produce an intent which can be used to start an activity to open
+ * the specified URI.
+ *
+ * @param context a <code>Context</code> instance.
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param action an Android action specifier, such as
+ * <code>Intent.ACTION_SEND</code>.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @return an <code>Intent</code>, or <code>null</code> if none could be
+ * produced.
+ */
+ static Intent getOpenURIIntent(final Context context,
+ final String targetURI,
+ final String mimeType,
+ final String action,
+ final String title) {
+
+ // The resultant chooser can return non-exported activities in 4.1 and earlier.
+ // https://code.google.com/p/android/issues/detail?id=29535
+ final Intent intent = getOpenURIIntentInner(context, targetURI, mimeType, action, title);
+
+ if (intent != null) {
+ // Some applications use this field to return to the same browser after processing the
+ // Intent. While there is some danger (e.g. denial of service), other major browsers already
+ // use it and so it's the norm.
+ intent.putExtra(Browser.EXTRA_APPLICATION_ID, AppConstants.ANDROID_PACKAGE_NAME);
+ }
+
+ return intent;
+ }
+
+ private static Intent getOpenURIIntentInner(final Context context, final String targetURI,
+ final String mimeType, final String action, final String title) {
+
+ if (action.equalsIgnoreCase(Intent.ACTION_SEND)) {
+ Intent shareIntent = getShareIntent(context, targetURI, mimeType, title);
+ return Intent.createChooser(shareIntent,
+ context.getResources().getString(R.string.share_title));
+ }
+
+ Uri uri = normalizeUriScheme(targetURI.indexOf(':') >= 0 ? Uri.parse(targetURI) : new Uri.Builder().scheme(targetURI).build());
+ if (!TextUtils.isEmpty(mimeType)) {
+ Intent intent = getIntentForActionString(action);
+ intent.setDataAndType(uri, mimeType);
+ return intent;
+ }
+
+ if (!GeckoAppShell.isUriSafeForScheme(uri)) {
+ return null;
+ }
+
+ final String scheme = uri.getScheme();
+ if ("intent".equals(scheme) || "android-app".equals(scheme)) {
+ final Intent intent;
+ try {
+ intent = Intent.parseUri(targetURI, 0);
+ } catch (final URISyntaxException e) {
+ Log.e(LOGTAG, "Unable to parse URI - " + e);
+ return null;
+ }
+
+ // Only open applications which can accept arbitrary data from a browser.
+ intent.addCategory(Intent.CATEGORY_BROWSABLE);
+
+ // Prevent site from explicitly opening our internal activities, which can leak data.
+ intent.setComponent(null);
+ nullIntentSelector(intent);
+
+ return intent;
+ }
+
+ // Compute our most likely intent, then check to see if there are any
+ // custom handlers that would apply.
+ // Start with the original URI. If we end up modifying it, we'll
+ // overwrite it.
+ final String extension = MimeTypeMap.getFileExtensionFromUrl(targetURI);
+ final Intent intent = getIntentForActionString(action);
+ intent.setData(uri);
+
+ if ("file".equals(scheme)) {
+ // Only set explicit mimeTypes on file://.
+ final String mimeType2 = GeckoAppShell.getMimeTypeFromExtension(extension);
+ intent.setType(mimeType2);
+ return intent;
+ }
+
+ // Have a special handling for SMS based schemes, as the query parameters
+ // are not extracted from the URI automatically.
+ if (!"sms".equals(scheme) && !"smsto".equals(scheme) && !"mms".equals(scheme) && !"mmsto".equals(scheme)) {
+ return intent;
+ }
+
+ final String query = uri.getEncodedQuery();
+ if (TextUtils.isEmpty(query)) {
+ return intent;
+ }
+
+ // It is common to see sms*/mms* uris on the web without '//', it is W3C standard not to have the slashes,
+ // but android's Uri builder & Uri require the slashes and will interpret those without as malformed.
+ String currentUri = uri.toString();
+ String correctlyFormattedDataURIScheme = scheme + "://";
+ if (!currentUri.contains(correctlyFormattedDataURIScheme)) {
+ uri = Uri.parse(currentUri.replaceFirst(scheme + ":", correctlyFormattedDataURIScheme));
+ }
+
+ final String[] fields = query.split("&");
+ boolean shouldUpdateIntent = false;
+ String resultQuery = "";
+ for (String field : fields) {
+ if (field.startsWith("body=")) {
+ final String body = Uri.decode(field.substring(5));
+ intent.putExtra("sms_body", body);
+ shouldUpdateIntent = true;
+ } else if (field.startsWith("subject=")) {
+ final String subject = Uri.decode(field.substring(8));
+ intent.putExtra("subject", subject);
+ shouldUpdateIntent = true;
+ } else if (field.startsWith("cc=")) {
+ final String ccNumber = Uri.decode(field.substring(3));
+ String phoneNumber = uri.getAuthority();
+ if (phoneNumber != null) {
+ uri = uri.buildUpon().encodedAuthority(phoneNumber + ";" + ccNumber).build();
+ }
+ shouldUpdateIntent = true;
+ } else {
+ resultQuery = resultQuery.concat(resultQuery.length() > 0 ? "&" + field : field);
+ }
+ }
+
+ if (!shouldUpdateIntent) {
+ // No need to rewrite the URI, then.
+ return intent;
+ }
+
+ // Form a new URI without the extracted fields in the query part, and
+ // push that into the new Intent.
+ final String newQuery = resultQuery.length() > 0 ? "?" + resultQuery : "";
+ final Uri pruned = uri.buildUpon().encodedQuery(newQuery).build();
+ intent.setData(pruned);
+
+ return intent;
+ }
+
+ // We create a separate method to better encapsulate the @TargetApi use.
+ @TargetApi(15)
+ private static void nullIntentSelector(final Intent intent) {
+ intent.setSelector(null);
+ }
+
+ /**
+ * Return a <code>Uri</code> instance which is equivalent to <code>u</code>,
+ * but with a guaranteed-lowercase scheme as if the API level 16 method
+ * <code>u.normalizeScheme</code> had been called.
+ *
+ * @param u the <code>Uri</code> to normalize.
+ * @return a <code>Uri</code>, which might be <code>u</code>.
+ */
+ private static Uri normalizeUriScheme(final Uri u) {
+ final String scheme = u.getScheme();
+ final String lower = scheme.toLowerCase(Locale.US);
+ if (lower.equals(scheme)) {
+ return u;
+ }
+
+ // Otherwise, return a new URI with a normalized scheme.
+ return u.buildUpon().scheme(lower).build();
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ if (event.equals("Intent:OpenNoHandler")) {
+ openNoHandler(message, callback);
+ }
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("Intent:GetHandlers")) {
+ getHandlers(message);
+ } else if (event.equals("Intent:Open")) {
+ open(message);
+ } else if (event.equals("Intent:OpenForResult")) {
+ openForResult(message);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ private void getHandlers(JSONObject message) throws JSONException {
+ final Intent intent = getOpenURIIntent(activity,
+ message.optString("url"),
+ message.optString("mime"),
+ message.optString("action"),
+ message.optString("title"));
+ final List<String> appList = Arrays.asList(getHandlersForIntent(intent));
+
+ final JSONObject response = new JSONObject();
+ response.put("apps", new JSONArray(appList));
+ EventDispatcher.sendResponse(message, response);
+ }
+
+ private void open(JSONObject message) throws JSONException {
+ openUriExternal(message.optString("url"),
+ message.optString("mime"),
+ message.optString("packageName"),
+ message.optString("className"),
+ message.optString("action"),
+ message.optString("title"), false);
+ }
+
+ private void openForResult(final JSONObject message) throws JSONException {
+ Intent intent = getOpenURIIntent(activity,
+ message.optString("url"),
+ message.optString("mime"),
+ message.optString("action"),
+ message.optString("title"));
+ intent.setClassName(message.optString("packageName"), message.optString("className"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ final ResultHandler handler = new ResultHandler(message);
+ try {
+ ActivityHandlerHelper.startIntentForActivity(activity, intent, handler);
+ } catch (SecurityException e) {
+ Log.w(LOGTAG, "Forbidden to launch activity.", e);
+ }
+ }
+
+ /**
+ * Opens a URI without any valid handlers on device. In the best case, a package is specified
+ * and we can bring the user directly to the application page in an app market. If a package is
+ * not specified and there is a fallback url in the intent extras, we open that url. If neither
+ * is present, we alert the user that we were unable to open the link.
+ *
+ * @param msg A message with the uri with no handlers as the value for the "uri" key
+ * @param callback A callback that will be called with success & no params if Java loads a page, or with error and
+ * the uri to load if Java does not load a page
+ */
+ private void openNoHandler(final NativeJSObject msg, final EventCallback callback) {
+ final String uri = msg.getString("uri");
+
+ if (TextUtils.isEmpty(uri)) {
+ Log.w(LOGTAG, "Received empty URL - loading about:neterror");
+ callback.sendError(getUnknownProtocolErrorPageUri(""));
+ return;
+ }
+
+ final Intent intent;
+ try {
+ // TODO (bug 1173626): This will not handle android-app uris on non 5.1 devices.
+ intent = Intent.parseUri(uri, 0);
+ } catch (final URISyntaxException e) {
+ String errorUri;
+ try {
+ errorUri = getUnknownProtocolErrorPageUri(URLEncoder.encode(uri, "UTF-8"));
+ } catch (final UnsupportedEncodingException encodingE) {
+ errorUri = getUnknownProtocolErrorPageUri("");
+ }
+
+ // Don't log the exception to prevent leaking URIs.
+ Log.w(LOGTAG, "Unable to parse Intent URI - loading about:neterror");
+ callback.sendError(errorUri);
+ return;
+ }
+
+ // For this flow, we follow Chrome's lead:
+ // https://developer.chrome.com/multidevice/android/intents
+ final String fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL);
+ if (isFallbackUrlValid(fallbackUrl)) {
+ // Opens the page in JS.
+ callback.sendError(fallbackUrl);
+
+ } else if (intent.getPackage() != null) {
+ // Note on alternative flows: we could get the intent package from a component, however, for
+ // security reasons, components are ignored when opening URIs (bug 1168998) so we should
+ // ignore it here too.
+ //
+ // Our old flow used to prompt the user to search for their app in the market by scheme and
+ // while this could help the user find a new app, there is not always a correlation in
+ // scheme to application name and we could end up steering the user wrong (potentially to
+ // malicious software). Better to leave that one alone.
+ final String marketUri = MARKET_INTENT_URI_PACKAGE_PREFIX + intent.getPackage();
+ final Intent marketIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(marketUri));
+ marketIntent.addCategory(Intent.CATEGORY_BROWSABLE);
+ marketIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ // (Bug 1192436) We don't know if marketIntent matches any Activities (e.g. non-Play
+ // Store devices). If it doesn't, clicking the link will cause no action to occur.
+ ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser(
+ activity, activity.getSupportFragmentManager(), marketIntent);
+ callback.sendSuccess(null);
+
+ } else {
+ // We return the error page here, but it will only be shown if we think the load did
+ // not come from clicking a link. Chrome does not show error pages in that case, and
+ // many websites have catered to this behavior. For example, the site might set a timeout and load a play
+ // store url for their app if the intent link fails to load, i.e. the app is not installed.
+ // These work-arounds would often end with our users seeing about:neterror instead of the intended experience.
+ // While I feel showing about:neterror is a better solution for users (when not hacked around),
+ // we should match the status quo for the good of our users.
+ //
+ // Don't log the URI to prevent leaking it.
+ Log.w(LOGTAG, "Unable to open URI, maybe showing neterror");
+ callback.sendError(getUnknownProtocolErrorPageUri(intent.getData().toString()));
+ }
+ }
+
+ private static boolean isFallbackUrlValid(@Nullable final String fallbackUrl) {
+ if (fallbackUrl == null) {
+ return false;
+ }
+
+ try {
+ final String anyCaseScheme = new URI(fallbackUrl).getScheme();
+ final String scheme = (anyCaseScheme == null) ? null : anyCaseScheme.toLowerCase(Locale.US);
+ if ("http".equals(scheme) || "https".equals(scheme)) {
+ return true;
+ } else {
+ Log.w(LOGTAG, "Fallback URI uses unsupported scheme: " + scheme + ". Try http or https.");
+ }
+ } catch (final URISyntaxException e) {
+ // Do not include Exception to avoid leaking uris.
+ Log.w(LOGTAG, "URISyntaxException parsing fallback URI");
+ }
+ return false;
+ }
+
+ /**
+ * Returns an about:neterror uri with the unknownProtocolFound text as a parameter.
+ * @param encodedUri The encoded uri. While the page does not open correctly without specifying
+ * a uri parameter, it happily accepts the empty String so this argument may
+ * be the empty String.
+ */
+ private String getUnknownProtocolErrorPageUri(final String encodedUri) {
+ return UNKNOWN_PROTOCOL_URI_PREFIX + encodedUri;
+ }
+
+ private static class ResultHandler implements ActivityResultHandler {
+ private final JSONObject message;
+
+ public ResultHandler(JSONObject message) {
+ this.message = message;
+ }
+
+ @Override
+ public void onActivityResult(int resultCode, Intent data) {
+ JSONObject response = new JSONObject();
+ try {
+ if (data != null) {
+ if (data.getExtras() != null) {
+ response.put("extras", JSONUtils.bundleToJSON(data.getExtras()));
+ }
+ if (data.getData() != null) {
+ response.put("uri", data.getData().toString());
+ }
+ }
+ response.put("resultCode", resultCode);
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Error building JSON response.", e);
+ }
+ EventDispatcher.sendResponse(message, response);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java b/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java
new file mode 100644
index 000000000..4de8fa423
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/LauncherActivity.java
@@ -0,0 +1,110 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.customtabs.CustomTabsIntent;
+
+import org.mozilla.gecko.customtabs.CustomTabsActivity;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.tabqueue.TabQueueService;
+
+/**
+ * Activity that receives incoming Intents and dispatches them to the appropriate activities (e.g. browser, custom tabs, web app).
+ */
+public class LauncherActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ GeckoAppShell.ensureCrashHandling();
+
+ final SafeIntent safeIntent = new SafeIntent(getIntent());
+
+ // If it's not a view intent, it won't be a custom tabs intent either. Just launch!
+ if (!isViewIntentWithURL(safeIntent)) {
+ dispatchNormalIntent();
+
+ // Is this a custom tabs intent, and are custom tabs enabled?
+ } else if (AppConstants.MOZ_ANDROID_CUSTOM_TABS && isCustomTabsIntent(safeIntent)
+ && isCustomTabsEnabled()) {
+ dispatchCustomTabsIntent();
+
+ // Can we dispatch this VIEW action intent to the tab queue service?
+ } else if (!safeIntent.getBooleanExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, false)
+ && TabQueueHelper.TAB_QUEUE_ENABLED
+ && TabQueueHelper.isTabQueueEnabled(this)) {
+ dispatchTabQueueIntent();
+
+ // Dispatch this VIEW action intent to the browser.
+ } else {
+ dispatchNormalIntent();
+ }
+
+ finish();
+ }
+
+ /**
+ * Launch tab queue service to display overlay.
+ */
+ private void dispatchTabQueueIntent() {
+ Intent intent = new Intent(getIntent());
+ intent.setClass(getApplicationContext(), TabQueueService.class);
+ startService(intent);
+ }
+
+ /**
+ * Launch the browser activity.
+ */
+ private void dispatchNormalIntent() {
+ Intent intent = new Intent(getIntent());
+ intent.setClassName(getApplicationContext(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+
+ filterFlags(intent);
+
+ startActivity(intent);
+ }
+
+ private void dispatchCustomTabsIntent() {
+ Intent intent = new Intent(getIntent());
+ intent.setClassName(getApplicationContext(), CustomTabsActivity.class.getName());
+
+ filterFlags(intent);
+
+ startActivity(intent);
+ }
+
+ private static void filterFlags(Intent intent) {
+ // Explicitly remove the new task and clear task flags (Our browser activity is a single
+ // task activity and we never want to start a second task here). See bug 1280112.
+ intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_CLEAR_TASK);
+
+ // LauncherActivity is started with the "exclude from recents" flag (set in manifest). We do
+ // not want to propagate this flag from the launcher activity to the browser.
+ intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+ }
+
+ private static boolean isViewIntentWithURL(@NonNull final SafeIntent safeIntent) {
+ return Intent.ACTION_VIEW.equals(safeIntent.getAction())
+ && safeIntent.getDataString() != null;
+ }
+
+ private static boolean isCustomTabsIntent(@NonNull final SafeIntent safeIntent) {
+ return isViewIntentWithURL(safeIntent)
+ && safeIntent.hasExtra(CustomTabsIntent.EXTRA_SESSION);
+ }
+
+ private boolean isCustomTabsEnabled() {
+ return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_CUSTOM_TABS, false);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java b/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java
new file mode 100644
index 000000000..795caa925
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/LocaleManager.java
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.Locale;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+
+/**
+ * Implement this interface to provide Fennec's locale switching functionality.
+ *
+ * The LocaleManager is responsible for persisting and applying selected locales,
+ * and correcting configurations after Android has changed them.
+ */
+public interface LocaleManager {
+ void initialize(Context context);
+
+ /**
+ * @return true if locale switching is enabled.
+ */
+ boolean isEnabled();
+ Locale getCurrentLocale(Context context);
+ String getAndApplyPersistedLocale(Context context);
+ void correctLocale(Context context, Resources resources, Configuration newConfig);
+ void updateConfiguration(Context context, Locale locale);
+ String setSelectedLocale(Context context, String localeCode);
+ boolean systemLocaleDidChange();
+ void resetToSystemLocale(Context context);
+
+ /**
+ * Call this in your onConfigurationChanged handler. This method is expected
+ * to do the appropriate thing: if the user has selected a locale, it
+ * corrects the incoming configuration; if not, it signals the new locale to
+ * use.
+ */
+ Locale onSystemConfigurationChanged(Context context, Resources resources, Configuration configuration, Locale currentActivityLocale);
+ String getFallbackLocaleTag();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Locales.java b/mobile/android/base/java/org/mozilla/gecko/Locales.java
new file mode 100644
index 000000000..e030b95e9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Locales.java
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.lang.reflect.Method;
+import java.util.Locale;
+
+import org.mozilla.gecko.LocaleManager;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.os.StrictMode;
+import android.support.v4.app.FragmentActivity;
+import android.support.v7.app.AppCompatActivity;
+
+/**
+ * This is a helper class to do typical locale switching operations without
+ * hitting StrictMode errors or adding boilerplate to common activity
+ * subclasses.
+ *
+ * Either call {@link Locales#initializeLocale(Context)} in your
+ * <code>onCreate</code> method, or inherit from
+ * <code>LocaleAwareFragmentActivity</code> or <code>LocaleAwareActivity</code>.
+ */
+public class Locales {
+ public static LocaleManager getLocaleManager() {
+ try {
+ final Class<?> clazz = Class.forName("org.mozilla.gecko.BrowserLocaleManager");
+ final Method getInstance = clazz.getMethod("getInstance");
+ final LocaleManager localeManager = (LocaleManager) getInstance.invoke(null);
+ return localeManager;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static void initializeLocale(Context context) {
+ final LocaleManager localeManager = getLocaleManager();
+ final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+ StrictMode.allowThreadDiskWrites();
+ try {
+ localeManager.getAndApplyPersistedLocale(context);
+ } finally {
+ StrictMode.setThreadPolicy(savedPolicy);
+ }
+ }
+
+ public static abstract class LocaleAwareAppCompatActivity extends AppCompatActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Locales.initializeLocale(getApplicationContext());
+ super.onCreate(savedInstanceState);
+ }
+
+ }
+ public static abstract class LocaleAwareFragmentActivity extends FragmentActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Locales.initializeLocale(getApplicationContext());
+ super.onCreate(savedInstanceState);
+ }
+ }
+
+ public static abstract class LocaleAwareActivity extends Activity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Locales.initializeLocale(getApplicationContext());
+ super.onCreate(savedInstanceState);
+ }
+ }
+
+ /**
+ * Sometimes we want just the language for a locale, not the entire language
+ * tag. But Java's .getLanguage method is wrong.
+ *
+ * This method is equivalent to the first part of
+ * {@link Locales#getLanguageTag(Locale)}.
+ *
+ * @return a language string, such as "he" for the Hebrew locales.
+ */
+ public static String getLanguage(final Locale locale) {
+ // Can, but should never be, an empty string.
+ final String language = locale.getLanguage();
+
+ // Modernize certain language codes.
+ if (language.equals("iw")) {
+ return "he";
+ }
+
+ if (language.equals("in")) {
+ return "id";
+ }
+
+ if (language.equals("ji")) {
+ return "yi";
+ }
+
+ return language;
+ }
+
+ /**
+ * Gecko uses locale codes like "es-ES", whereas a Java {@link Locale}
+ * stringifies as "es_ES".
+ *
+ * This method approximates the Java 7 method
+ * <code>Locale#toLanguageTag()</code>.
+ *
+ * @return a locale string suitable for passing to Gecko.
+ */
+ public static String getLanguageTag(final Locale locale) {
+ // If this were Java 7:
+ // return locale.toLanguageTag();
+
+ final String language = getLanguage(locale);
+ final String country = locale.getCountry(); // Can be an empty string.
+ if (country.equals("")) {
+ return language;
+ }
+ return language + "-" + country;
+ }
+
+ public static Locale parseLocaleCode(final String localeCode) {
+ int index;
+ if ((index = localeCode.indexOf('-')) != -1 ||
+ (index = localeCode.indexOf('_')) != -1) {
+ final String langCode = localeCode.substring(0, index);
+ final String countryCode = localeCode.substring(index + 1);
+ return new Locale(langCode, countryCode);
+ }
+
+ return new Locale(localeCode);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java b/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java
new file mode 100644
index 000000000..bd109058c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/MediaCastingBar.java
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+public class MediaCastingBar extends RelativeLayout implements View.OnClickListener, GeckoEventListener {
+ private static final String LOGTAG = "GeckoMediaCastingBar";
+
+ private TextView mCastingTo;
+ private ImageButton mMediaPlay;
+ private ImageButton mMediaPause;
+ private ImageButton mMediaStop;
+
+ private boolean mInflated;
+
+ public MediaCastingBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "Casting:Started",
+ "Casting:Paused",
+ "Casting:Playing",
+ "Casting:Stopped");
+ }
+
+ public void inflateContent() {
+ LayoutInflater inflater = LayoutInflater.from(getContext());
+ View content = inflater.inflate(R.layout.media_casting, this);
+
+ mMediaPlay = (ImageButton) content.findViewById(R.id.media_play);
+ mMediaPlay.setOnClickListener(this);
+ mMediaPause = (ImageButton) content.findViewById(R.id.media_pause);
+ mMediaPause.setOnClickListener(this);
+ mMediaStop = (ImageButton) content.findViewById(R.id.media_stop);
+ mMediaStop.setOnClickListener(this);
+
+ mCastingTo = (TextView) content.findViewById(R.id.media_sending_to);
+
+ // Capture clicks on the rest of the view to prevent them from
+ // leaking into other views positioned below.
+ content.setOnClickListener(this);
+
+ mInflated = true;
+ }
+
+ public void show() {
+ if (!mInflated)
+ inflateContent();
+
+ setVisibility(VISIBLE);
+ }
+
+ public void hide() {
+ setVisibility(GONE);
+ }
+
+ public void onDestroy() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "Casting:Started",
+ "Casting:Paused",
+ "Casting:Playing",
+ "Casting:Stopped");
+ }
+
+ // View.OnClickListener implementation
+ @Override
+ public void onClick(View v) {
+ final int viewId = v.getId();
+
+ if (viewId == R.id.media_play) {
+ GeckoAppShell.notifyObservers("Casting:Play", "");
+ mMediaPlay.setVisibility(GONE);
+ mMediaPause.setVisibility(VISIBLE);
+ } else if (viewId == R.id.media_pause) {
+ GeckoAppShell.notifyObservers("Casting:Pause", "");
+ mMediaPause.setVisibility(GONE);
+ mMediaPlay.setVisibility(VISIBLE);
+ } else if (viewId == R.id.media_stop) {
+ GeckoAppShell.notifyObservers("Casting:Stop", "");
+ }
+ }
+
+ // GeckoEventListener implementation
+ @Override
+ public void handleMessage(final String event, final JSONObject message) {
+ final String device = message.optString("device");
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (event.equals("Casting:Started")) {
+ show();
+ if (!TextUtils.isEmpty(device)) {
+ mCastingTo.setText(device);
+ } else {
+ // Should not happen
+ mCastingTo.setText("");
+ Log.d(LOGTAG, "Device name is empty.");
+ }
+ mMediaPlay.setVisibility(GONE);
+ mMediaPause.setVisibility(VISIBLE);
+ } else if (event.equals("Casting:Paused")) {
+ mMediaPause.setVisibility(GONE);
+ mMediaPlay.setVisibility(VISIBLE);
+ } else if (event.equals("Casting:Playing")) {
+ mMediaPlay.setVisibility(GONE);
+ mMediaPause.setVisibility(VISIBLE);
+ } else if (event.equals("Casting:Stopped")) {
+ hide();
+ }
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java b/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java
new file mode 100644
index 000000000..fc0ce82cf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/MediaPlayerManager.java
@@ -0,0 +1,323 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v7.media.MediaControlIntent;
+import android.support.v7.media.MediaRouteSelector;
+import android.support.v7.media.MediaRouter;
+import android.support.v7.media.MediaRouter.RouteInfo;
+import android.util.Log;
+
+import com.google.android.gms.cast.CastMediaControlIntent;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Manages a list of GeckoMediaPlayers methods (i.e. Chromecast/Miracast). Routes messages
+ * from Gecko to the correct caster based on the id of the display
+ */
+public class MediaPlayerManager extends Fragment implements NativeEventListener {
+ /**
+ * Create a new instance of DetailsFragment, initialized to
+ * show the text at 'index'.
+ */
+
+ private static MediaPlayerManager instance = null;
+
+ @ReflectionTarget
+ public static MediaPlayerManager getInstance() {
+ if (instance != null) {
+ return instance;
+ }
+ if (Versions.feature17Plus) {
+ instance = (MediaPlayerManager) new PresentationMediaPlayerManager();
+ } else {
+ instance = new MediaPlayerManager();
+ }
+ return instance;
+ }
+
+ private static final String LOGTAG = "GeckoMediaPlayerManager";
+ protected boolean isPresentationMode = false; // Used to prevent mirroring when Presentation API is used.
+
+ @ReflectionTarget
+ public static final String MEDIA_PLAYER_TAG = "MPManagerFragment";
+
+ private static final boolean SHOW_DEBUG = false;
+ // Simplified debugging interfaces
+ private static void debug(String msg, Exception e) {
+ if (SHOW_DEBUG) {
+ Log.e(LOGTAG, msg, e);
+ }
+ }
+
+ private static void debug(String msg) {
+ if (SHOW_DEBUG) {
+ Log.d(LOGTAG, msg);
+ }
+ }
+
+ protected MediaRouter mediaRouter = null;
+ protected final Map<String, GeckoMediaPlayer> players = new HashMap<String, GeckoMediaPlayer>();
+ protected final Map<String, GeckoPresentationDisplay> displays = new HashMap<String, GeckoPresentationDisplay>(); // used for Presentation API
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "MediaPlayer:Load",
+ "MediaPlayer:Start",
+ "MediaPlayer:Stop",
+ "MediaPlayer:Play",
+ "MediaPlayer:Pause",
+ "MediaPlayer:End",
+ "MediaPlayer:Mirror",
+ "MediaPlayer:Message",
+ "AndroidCastDevice:Start",
+ "AndroidCastDevice:Stop",
+ "AndroidCastDevice:SyncDevice");
+ }
+
+ @Override
+ @JNITarget
+ public void onDestroy() {
+ super.onDestroy();
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "MediaPlayer:Load",
+ "MediaPlayer:Start",
+ "MediaPlayer:Stop",
+ "MediaPlayer:Play",
+ "MediaPlayer:Pause",
+ "MediaPlayer:End",
+ "MediaPlayer:Mirror",
+ "MediaPlayer:Message",
+ "AndroidCastDevice:Start",
+ "AndroidCastDevice:Stop",
+ "AndroidCastDevice:SyncDevice");
+ }
+
+ // GeckoEventListener implementation
+ @Override
+ public void handleMessage(String event, final NativeJSObject message, final EventCallback callback) {
+ debug(event);
+ if (event.startsWith("MediaPlayer:")) {
+ final GeckoMediaPlayer player = players.get(message.getString("id"));
+ if (player == null) {
+ Log.e(LOGTAG, "Couldn't find a player for this id: " + message.getString("id") + " for message: " + event);
+ if (callback != null) {
+ callback.sendError(null);
+ }
+ return;
+ }
+
+ if ("MediaPlayer:Play".equals(event)) {
+ player.play(callback);
+ } else if ("MediaPlayer:Start".equals(event)) {
+ player.start(callback);
+ } else if ("MediaPlayer:Stop".equals(event)) {
+ player.stop(callback);
+ } else if ("MediaPlayer:Pause".equals(event)) {
+ player.pause(callback);
+ } else if ("MediaPlayer:End".equals(event)) {
+ player.end(callback);
+ } else if ("MediaPlayer:Mirror".equals(event)) {
+ player.mirror(callback);
+ } else if ("MediaPlayer:Message".equals(event) && message.has("data")) {
+ player.message(message.getString("data"), callback);
+ } else if ("MediaPlayer:Load".equals(event)) {
+ final String url = message.optString("source", "");
+ final String type = message.optString("type", "video/mp4");
+ final String title = message.optString("title", "");
+ player.load(title, url, type, callback);
+ }
+ }
+
+ if (event.startsWith("AndroidCastDevice:")) {
+ if ("AndroidCastDevice:Start".equals(event)) {
+ final GeckoPresentationDisplay display = displays.get(message.getString("id"));
+ if (display == null) {
+ Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event);
+ return;
+ }
+ display.start(callback);
+ } else if ("AndroidCastDevice:Stop".equals(event)) {
+ final GeckoPresentationDisplay display = displays.get(message.getString("id"));
+ if (display == null) {
+ Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event);
+ return;
+ }
+ display.stop(callback);
+ } else if ("AndroidCastDevice:SyncDevice".equals(event)) {
+ for (Map.Entry<String, GeckoPresentationDisplay> entry : displays.entrySet()) {
+ GeckoPresentationDisplay display = entry.getValue();
+ JSONObject json = display.toJSON();
+ if (json == null) {
+ break;
+ }
+ GeckoAppShell.notifyObservers("AndroidCastDevice:Added", json.toString());
+ }
+ }
+ }
+ }
+
+ private final MediaRouter.Callback callback =
+ new MediaRouter.Callback() {
+ @Override
+ public void onRouteRemoved(MediaRouter router, RouteInfo route) {
+ debug("onRouteRemoved: route=" + route);
+
+ // Remove from media player list.
+ players.remove(route.getId());
+ GeckoAppShell.notifyObservers("MediaPlayer:Removed", route.getId());
+ updatePresentation();
+
+ // Remove from presentation display list.
+ displays.remove(route.getId());
+ GeckoAppShell.notifyObservers("AndroidCastDevice:Removed", route.getId());
+ }
+
+ @SuppressWarnings("unused")
+ public void onRouteSelected(MediaRouter router, int type, MediaRouter.RouteInfo route) {
+ updatePresentation();
+ }
+
+ // These methods aren't used by the support version Media Router
+ @SuppressWarnings("unused")
+ public void onRouteUnselected(MediaRouter router, int type, RouteInfo route) {
+ updatePresentation();
+ }
+
+ @Override
+ public void onRoutePresentationDisplayChanged(MediaRouter router, RouteInfo route) {
+ updatePresentation();
+ }
+
+ @Override
+ public void onRouteVolumeChanged(MediaRouter router, RouteInfo route) {
+ }
+
+ @Override
+ public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo route) {
+ debug("onRouteAdded: route=" + route);
+ final GeckoMediaPlayer player = getMediaPlayerForRoute(route);
+ saveAndNotifyOfPlayer("MediaPlayer:Added", route, player);
+ updatePresentation();
+
+ final GeckoPresentationDisplay display = getPresentationDisplayForRoute(route);
+ saveAndNotifyOfDisplay("AndroidCastDevice:Added", route, display);
+ }
+
+ @Override
+ public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
+ debug("onRouteChanged: route=" + route);
+ final GeckoMediaPlayer player = players.get(route.getId());
+ saveAndNotifyOfPlayer("MediaPlayer:Changed", route, player);
+ updatePresentation();
+
+ final GeckoPresentationDisplay display = displays.get(route.getId());
+ saveAndNotifyOfDisplay("AndroidCastDevice:Changed", route, display);
+ }
+
+ private void saveAndNotifyOfPlayer(final String eventName,
+ MediaRouter.RouteInfo route,
+ final GeckoMediaPlayer player) {
+ if (player == null) {
+ return;
+ }
+
+ final JSONObject json = player.toJSON();
+ if (json == null) {
+ return;
+ }
+
+ players.put(route.getId(), player);
+ GeckoAppShell.notifyObservers(eventName, json.toString());
+ }
+
+ private void saveAndNotifyOfDisplay(final String eventName,
+ MediaRouter.RouteInfo route,
+ final GeckoPresentationDisplay display) {
+ if (display == null) {
+ return;
+ }
+
+ final JSONObject json = display.toJSON();
+ if (json == null) {
+ return;
+ }
+
+ displays.put(route.getId(), display);
+ GeckoAppShell.notifyObservers(eventName, json.toString());
+ }
+ };
+
+ private GeckoMediaPlayer getMediaPlayerForRoute(MediaRouter.RouteInfo route) {
+ try {
+ if (route.supportsControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)) {
+ return new ChromeCastPlayer(getActivity(), route);
+ }
+ } catch (Exception ex) {
+ debug("Error handling presentation", ex);
+ }
+
+ return null;
+ }
+
+ private GeckoPresentationDisplay getPresentationDisplayForRoute(MediaRouter.RouteInfo route) {
+ try {
+ if (route.supportsControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastDisplay.REMOTE_DISPLAY_APP_ID))) {
+ return new ChromeCastDisplay(getActivity(), route);
+ }
+ } catch (Exception ex) {
+ debug("Error handling presentation", ex);
+ }
+ return null;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mediaRouter.removeCallback(callback);
+ mediaRouter = null;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ // The mediaRouter shouldn't exist here, but this is a nice safety check.
+ if (mediaRouter != null) {
+ return;
+ }
+
+ mediaRouter = MediaRouter.getInstance(getActivity());
+ final MediaRouteSelector selectorBuilder = new MediaRouteSelector.Builder()
+ .addControlCategory(MediaControlIntent.CATEGORY_LIVE_VIDEO)
+ .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
+ .addControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastPlayer.MIRROR_RECEIVER_APP_ID))
+ .addControlCategory(CastMediaControlIntent.categoryForCast(ChromeCastDisplay.REMOTE_DISPLAY_APP_ID))
+ .build();
+ mediaRouter.addCallback(selectorBuilder, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
+ }
+
+ public void setPresentationMode(boolean isPresentationMode) {
+ this.isPresentationMode = isPresentationMode;
+ }
+
+ protected void updatePresentation() { /* Overridden in sub-classes. */ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java b/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java
new file mode 100644
index 000000000..94ca761b9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/MemoryMonitor.java
@@ -0,0 +1,279 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserProvider;
+import org.mozilla.gecko.home.ImageLoader;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentCallbacks2;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+/**
+ * This is a utility class to keep track of how much memory and disk-space pressure
+ * the system is under. It receives input from GeckoActivity via the onLowMemory() and
+ * onTrimMemory() functions, and also listens for some system intents related to
+ * disk-space notifications. Internally it will track how much memory and disk pressure
+ * the system is under, and perform various actions to help alleviate the pressure.
+ *
+ * Note that since there is no notification for when the system has lots of free memory
+ * again, this class also assumes that, over time, the system will free up memory. This
+ * assumption is implemented using a timer that slowly lowers the internal memory
+ * pressure state if no new low-memory notifications are received.
+ *
+ * Synchronization note: MemoryMonitor contains an inner class PressureDecrementer. Both
+ * of these classes may be accessed from various threads, and have both been designed to
+ * be thread-safe. In terms of lock ordering, code holding the PressureDecrementer lock
+ * is allowed to pick up the MemoryMonitor lock, but not vice-versa.
+ */
+class MemoryMonitor extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoMemoryMonitor";
+ private static final String ACTION_MEMORY_DUMP = "org.mozilla.gecko.MEMORY_DUMP";
+ private static final String ACTION_FORCE_PRESSURE = "org.mozilla.gecko.FORCE_MEMORY_PRESSURE";
+
+ // Memory pressure levels. Keep these in sync with those in AndroidJavaWrappers.h
+ private static final int MEMORY_PRESSURE_NONE = 0;
+ private static final int MEMORY_PRESSURE_CLEANUP = 1;
+ private static final int MEMORY_PRESSURE_LOW = 2;
+ private static final int MEMORY_PRESSURE_MEDIUM = 3;
+ private static final int MEMORY_PRESSURE_HIGH = 4;
+
+ private static final MemoryMonitor sInstance = new MemoryMonitor();
+
+ static MemoryMonitor getInstance() {
+ return sInstance;
+ }
+
+ private Context mAppContext;
+ private final PressureDecrementer mPressureDecrementer;
+ private int mMemoryPressure; // Synchronized access only.
+ private volatile boolean mStoragePressure; // Accessed via UI thread intent, background runnables.
+ private boolean mInited;
+
+ private MemoryMonitor() {
+ mPressureDecrementer = new PressureDecrementer();
+ mMemoryPressure = MEMORY_PRESSURE_NONE;
+ }
+
+ public void init(final Context context) {
+ if (mInited) {
+ return;
+ }
+
+ mAppContext = context.getApplicationContext();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW);
+ filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK);
+ filter.addAction(ACTION_MEMORY_DUMP);
+ filter.addAction(ACTION_FORCE_PRESSURE);
+ mAppContext.registerReceiver(this, filter);
+ mInited = true;
+ }
+
+ public void onLowMemory() {
+ Log.d(LOGTAG, "onLowMemory() notification received");
+ if (increaseMemoryPressure(MEMORY_PRESSURE_HIGH)) {
+ // We need to wait on Gecko here, because if we haven't reduced
+ // memory usage enough when we return from this, Android will kill us.
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoThread.waitOnGecko();
+ }
+ }
+ }
+
+ public void onTrimMemory(int level) {
+ Log.d(LOGTAG, "onTrimMemory() notification received with level " + level);
+ if (level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE) {
+ // We seem to get this just by entering the task switcher or hitting the home button.
+ // Seems bogus, because we are the foreground app, or at least not at the end of the LRU list.
+ // Just ignore it, and if there is a real memory pressure event (CRITICAL, MODERATE, etc),
+ // we'll respond appropriately.
+ return;
+ }
+
+ switch (level) {
+ case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL:
+ case ComponentCallbacks2.TRIM_MEMORY_MODERATE:
+ // TRIM_MEMORY_MODERATE is the highest level we'll respond to while backgrounded
+ increaseMemoryPressure(MEMORY_PRESSURE_HIGH);
+ break;
+ case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE:
+ increaseMemoryPressure(MEMORY_PRESSURE_MEDIUM);
+ break;
+ case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW:
+ increaseMemoryPressure(MEMORY_PRESSURE_LOW);
+ break;
+ case ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN:
+ case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND:
+ increaseMemoryPressure(MEMORY_PRESSURE_CLEANUP);
+ break;
+ default:
+ Log.d(LOGTAG, "Unhandled onTrimMemory() level " + level);
+ break;
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(intent.getAction())) {
+ Log.d(LOGTAG, "Device storage is low");
+ mStoragePressure = true;
+ ThreadUtils.postToBackgroundThread(new StorageReducer(context));
+ } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(intent.getAction())) {
+ Log.d(LOGTAG, "Device storage is ok");
+ mStoragePressure = false;
+ } else if (ACTION_MEMORY_DUMP.equals(intent.getAction())) {
+ String label = intent.getStringExtra("label");
+ if (label == null) {
+ label = "default";
+ }
+ GeckoAppShell.notifyObservers("Memory:Dump", label);
+ } else if (ACTION_FORCE_PRESSURE.equals(intent.getAction())) {
+ increaseMemoryPressure(MEMORY_PRESSURE_HIGH);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private static native void dispatchMemoryPressure();
+
+ private boolean increaseMemoryPressure(int level) {
+ int oldLevel;
+ synchronized (this) {
+ // bump up our level if we're not already higher
+ if (mMemoryPressure > level) {
+ return false;
+ }
+ oldLevel = mMemoryPressure;
+ mMemoryPressure = level;
+ }
+
+ Log.d(LOGTAG, "increasing memory pressure to " + level);
+
+ // since we don't get notifications for when memory pressure is off,
+ // we schedule our own timer to slowly back off the memory pressure level.
+ // note that this will reset the time to next decrement if the decrementer
+ // is already running, which is the desired behaviour because we just got
+ // a new low-mem notification.
+ mPressureDecrementer.start();
+
+ if (oldLevel == level) {
+ // if we're not going to a higher level we probably don't
+ // need to run another round of the same memory reductions
+ // we did on the last memory pressure increase.
+ return false;
+ }
+
+ // TODO hook in memory-reduction stuff for different levels here
+ if (level >= MEMORY_PRESSURE_MEDIUM) {
+ //Only send medium or higher events because that's all that is used right now
+ if (GeckoThread.isRunning()) {
+ dispatchMemoryPressure();
+ }
+
+ MemoryStorage.get().evictAll();
+ ImageLoader.clearLruCache();
+ LocalBroadcastManager.getInstance(mAppContext)
+ .sendBroadcast(new Intent(BrowserProvider.ACTION_SHRINK_MEMORY));
+ }
+ return true;
+ }
+
+ /**
+ * Thread-safe due to mStoragePressure's volatility.
+ */
+ boolean isUnderStoragePressure() {
+ return mStoragePressure;
+ }
+
+ private boolean decreaseMemoryPressure() {
+ int newLevel;
+ synchronized (this) {
+ if (mMemoryPressure <= 0) {
+ return false;
+ }
+
+ newLevel = --mMemoryPressure;
+ }
+ Log.d(LOGTAG, "Decreased memory pressure to " + newLevel);
+
+ return true;
+ }
+
+ class PressureDecrementer implements Runnable {
+ private static final int DECREMENT_DELAY = 5 * 60 * 1000; // 5 minutes
+
+ private boolean mPosted;
+
+ synchronized void start() {
+ if (mPosted) {
+ // cancel the old one before scheduling a new one
+ ThreadUtils.getBackgroundHandler().removeCallbacks(this);
+ }
+ ThreadUtils.getBackgroundHandler().postDelayed(this, DECREMENT_DELAY);
+ mPosted = true;
+ }
+
+ @Override
+ public synchronized void run() {
+ if (!decreaseMemoryPressure()) {
+ // done decrementing, bail out
+ mPosted = false;
+ return;
+ }
+
+ // need to keep decrementing
+ ThreadUtils.getBackgroundHandler().postDelayed(this, DECREMENT_DELAY);
+ }
+ }
+
+ private static class StorageReducer implements Runnable {
+ private final Context mContext;
+ private final BrowserDB mDB;
+
+ public StorageReducer(final Context context) {
+ this.mContext = context;
+ // Since this may be called while Fennec is in the background, we don't want to risk accidentally
+ // using the wrong context. If the profile we get is a guest profile, use the default profile instead.
+ GeckoProfile profile = GeckoProfile.get(mContext);
+ if (profile.inGuestMode()) {
+ // If it was the guest profile, switch to the default one.
+ profile = GeckoProfile.get(mContext, GeckoProfile.DEFAULT_PROFILE);
+ }
+
+ mDB = BrowserDB.from(profile);
+ }
+
+ @Override
+ public void run() {
+ // this might get run right on startup, if so wait 10 seconds and try again
+ if (!GeckoThread.isRunning()) {
+ ThreadUtils.getBackgroundHandler().postDelayed(this, 10000);
+ return;
+ }
+
+ if (!MemoryMonitor.getInstance().isUnderStoragePressure()) {
+ // Pressure is off, so we can abort.
+ return;
+ }
+
+ final ContentResolver cr = mContext.getContentResolver();
+ mDB.expireHistory(cr, BrowserContract.ExpirePriority.AGGRESSIVE);
+ mDB.removeThumbnails(cr);
+
+ // TODO: drop or shrink disk caches
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java b/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java
new file mode 100644
index 000000000..814c09995
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/MotionEventInterceptor.java
@@ -0,0 +1,13 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.view.MotionEvent;
+import android.view.View;
+
+public interface MotionEventInterceptor {
+ public boolean onInterceptMotionEvent(View view, MotionEvent event);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java b/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java
new file mode 100644
index 000000000..37dd8c304
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/PackageReplacedReceiver.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.mozilla.gecko.mozglue.GeckoLoader;
+
+/**
+ * This broadcast receiver receives ACTION_MY_PACKAGE_REPLACED broadcasts and
+ * starts procedures that should run after the APK has been updated.
+ */
+public class PackageReplacedReceiver extends BroadcastReceiver {
+ public static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) {
+ // This is not the broadcast we are looking for.
+ return;
+ }
+
+ // Extract Gecko libs to allow them to be loaded from cache on startup.
+ extractGeckoLibs(context);
+ }
+
+ private static void extractGeckoLibs(final Context context) {
+ final String resourcePath = context.getPackageResourcePath();
+ GeckoLoader.loadMozGlue(context);
+ GeckoLoader.extractGeckoLibs(context, resourcePath);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java b/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java
new file mode 100644
index 000000000..e44096489
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/PresentationMediaPlayerManager.java
@@ -0,0 +1,149 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.TargetApi;
+import android.app.Presentation;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v7.media.MediaRouter;
+import android.util.Log;
+import android.view.Display;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+
+import org.mozilla.gecko.AppConstants.Versions;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * A MediaPlayerManager with API 17+ Presentation support.
+ */
+@TargetApi(17)
+public class PresentationMediaPlayerManager extends MediaPlayerManager {
+
+ private static final String LOGTAG = "Gecko" + PresentationMediaPlayerManager.class.getSimpleName();
+
+ private GeckoPresentation presentation;
+
+ public PresentationMediaPlayerManager() {
+ if (!Versions.feature17Plus) {
+ throw new IllegalStateException(PresentationMediaPlayerManager.class.getSimpleName() +
+ " does not support < API 17");
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ if (presentation != null) {
+ presentation.dismiss();
+ presentation = null;
+ }
+ }
+
+ @Override
+ protected void updatePresentation() {
+ if (mediaRouter == null) {
+ return;
+ }
+
+ if (isPresentationMode) {
+ return;
+ }
+
+ MediaRouter.RouteInfo route = mediaRouter.getSelectedRoute();
+ Display display = route != null ? route.getPresentationDisplay() : null;
+
+ if (display != null) {
+ if ((presentation != null) && (presentation.getDisplay() != display)) {
+ presentation.dismiss();
+ presentation = null;
+ }
+
+ if (presentation == null) {
+ final GeckoView geckoView = (GeckoView) getActivity().findViewById(R.id.layer_view);
+ presentation = new GeckoPresentation(getActivity(), display, geckoView);
+
+ try {
+ presentation.show();
+ } catch (WindowManager.InvalidDisplayException ex) {
+ Log.w(LOGTAG, "Couldn't show presentation! Display was removed in "
+ + "the meantime.", ex);
+ presentation = null;
+ }
+ }
+ } else if (presentation != null) {
+ presentation.dismiss();
+ presentation = null;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ /* protected */ static native void invalidateAndScheduleComposite(GeckoView geckoView);
+
+ @WrapForJNI(calledFrom = "ui")
+ /* protected */ static native void addPresentationSurface(GeckoView geckoView, Surface surface);
+
+ @WrapForJNI(calledFrom = "ui")
+ /* protected */ static native void removePresentationSurface();
+
+ private static final class GeckoPresentation extends Presentation {
+ private SurfaceView mView;
+ private GeckoView mGeckoView;
+
+ public GeckoPresentation(Context context, Display display, GeckoView geckoView) {
+ super(context, display);
+
+ mGeckoView = geckoView;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mView = new SurfaceView(getContext());
+ setContentView(mView, new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+ mView.getHolder().addCallback(new SurfaceListener(mGeckoView));
+ }
+ }
+
+ private static final class SurfaceListener implements SurfaceHolder.Callback {
+ private GeckoView mGeckoView;
+
+ public SurfaceListener(GeckoView geckoView) {
+ mGeckoView = geckoView;
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width,
+ int height) {
+ // Surface changed so force a composite
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ invalidateAndScheduleComposite(mGeckoView);
+ }
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ addPresentationSurface(mGeckoView, holder.getSurface());
+ }
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ removePresentationSurface();
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/PresentationView.java b/mobile/android/base/java/org/mozilla/gecko/PresentationView.java
new file mode 100644
index 000000000..3e5b5ffb3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/PresentationView.java
@@ -0,0 +1,27 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.GeckoView;
+import org.mozilla.gecko.ScreenManagerHelper;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+
+public class PresentationView extends GeckoView {
+ private static final String LOGTAG = "PresentationView";
+ private static final String presentationViewURI = "chrome://browser/content/PresentationView.xul";
+
+ public PresentationView(Context context, String deviceId, int screenId) {
+ super(context);
+ this.chromeURI = presentationViewURI + "#" + deviceId;
+ this.screenId = screenId;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java b/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java
new file mode 100644
index 000000000..077b2d29b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/PrintHelper.java
@@ -0,0 +1,124 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.print.PrintAttributes;
+import android.print.PrintDocumentAdapter;
+import android.print.PrintDocumentAdapter.LayoutResultCallback;
+import android.print.PrintDocumentAdapter.WriteResultCallback;
+import android.print.PrintDocumentInfo;
+import android.print.PrintManager;
+import android.print.PageRange;
+import android.util.Log;
+
+public class PrintHelper {
+ private static final String LOGTAG = "GeckoPrintUtils";
+
+ public static void printPDF(final Context context) {
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest("Print:PDF", new JSONObject()) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ final String filePath = nativeJSObject.getString("file");
+ final String title = nativeJSObject.getString("title");
+ finish(context, filePath, title);
+ }
+
+ @Override
+ public void onError(NativeJSObject error) {
+ // Gecko didn't respond due to state change, javascript error, etc.
+ Log.d(LOGTAG, "No response from Gecko on request to generate a PDF");
+ }
+
+ private void finish(final Context context, final String filePath, final String title) {
+ PrintManager printManager = (PrintManager) context.getSystemService(Context.PRINT_SERVICE);
+ String jobName = title;
+
+ // The adapter methods are all called on the UI thread by the PrintManager. Put the heavyweight code
+ // in onWrite on the background thread.
+ PrintDocumentAdapter pda = new PrintDocumentAdapter() {
+ @Override
+ public void onWrite(final PageRange[] pages, final ParcelFileDescriptor destination, final CancellationSignal cancellationSignal, final WriteResultCallback callback) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ InputStream input = null;
+ OutputStream output = null;
+
+ try {
+ File pdfFile = new File(filePath);
+ input = new FileInputStream(pdfFile);
+ output = new FileOutputStream(destination.getFileDescriptor());
+
+ byte[] buf = new byte[8192];
+ int bytesRead;
+ while ((bytesRead = input.read(buf)) > 0) {
+ output.write(buf, 0, bytesRead);
+ }
+
+ callback.onWriteFinished(new PageRange[] { PageRange.ALL_PAGES });
+ } catch (FileNotFoundException ee) {
+ Log.d(LOGTAG, "Unable to find the temporary PDF file.");
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "IOException while transferring temporary PDF file: ", ioe);
+ } finally {
+ IOUtils.safeStreamClose(input);
+ IOUtils.safeStreamClose(output);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes, CancellationSignal cancellationSignal, LayoutResultCallback callback, Bundle extras) {
+ if (cancellationSignal.isCanceled()) {
+ callback.onLayoutCancelled();
+ return;
+ }
+
+ PrintDocumentInfo pdi = new PrintDocumentInfo.Builder(filePath).setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT).build();
+ callback.onLayoutFinished(pdi, true);
+ }
+
+ @Override
+ public void onFinish() {
+ // Remove the temporary file when the printing system is finished.
+ try {
+ File pdfFile = new File(filePath);
+ pdfFile.delete();
+ } catch (NullPointerException npe) {
+ // Silence the exception. We only want to delete a real file. We don't
+ // care if the file doesn't exist.
+ }
+ }
+ };
+
+ printManager.print(jobName, pda, null);
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java b/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java
new file mode 100644
index 000000000..39b6899d3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/PrivateTab.java
@@ -0,0 +1,28 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserDB;
+
+public class PrivateTab extends Tab {
+ public PrivateTab(Context context, int id, String url, boolean external, int parentId, String title) {
+ super(context, id, url, external, parentId, title);
+ }
+
+ @Override
+ protected void saveThumbnailToDB(final BrowserDB db) {}
+
+ @Override
+ public void setMetadata(JSONObject metadata) {}
+
+ @Override
+ public boolean isPrivate() {
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java b/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java
new file mode 100644
index 000000000..b4aee9370
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/RemoteClientsDialogFragment.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.db.RemoteClient;
+
+import android.app.AlertDialog;
+import android.app.AlertDialog.Builder;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.support.v4.app.DialogFragment;
+import android.support.v4.app.Fragment;
+import android.util.SparseBooleanArray;
+
+/**
+ * A dialog fragment that displays a list of remote clients.
+ * <p>
+ * The dialog allows both single (one tap) and multiple (checkbox) selection.
+ * The dialog's results are communicated via the {@link RemoteClientsListener}
+ * interface. Either the dialog fragment's <i>target fragment</i> (see
+ * {@link Fragment#setTargetFragment(Fragment, int)}), or the containing
+ * <i>activity</i>, must implement that interface. See
+ * {@link #notifyListener(List)} for details.
+ */
+public class RemoteClientsDialogFragment extends DialogFragment {
+ private static final String KEY_TITLE = "title";
+ private static final String KEY_CHOICE_MODE = "choice_mode";
+ private static final String KEY_POSITIVE_BUTTON_TEXT = "positive_button_text";
+ private static final String KEY_CLIENTS = "clients";
+
+ public interface RemoteClientsListener {
+ // Always called on the main UI thread.
+ public void onClients(List<RemoteClient> clients);
+ }
+
+ public enum ChoiceMode {
+ SINGLE,
+ MULTIPLE,
+ }
+
+ public static RemoteClientsDialogFragment newInstance(String title, String positiveButtonText, ChoiceMode choiceMode, ArrayList<RemoteClient> clients) {
+ final RemoteClientsDialogFragment dialog = new RemoteClientsDialogFragment();
+ final Bundle args = new Bundle();
+ args.putString(KEY_TITLE, title);
+ args.putString(KEY_POSITIVE_BUTTON_TEXT, positiveButtonText);
+ args.putInt(KEY_CHOICE_MODE, choiceMode.ordinal());
+ args.putParcelableArrayList(KEY_CLIENTS, clients);
+ dialog.setArguments(args);
+ return dialog;
+ }
+
+ public RemoteClientsDialogFragment() {
+ // Empty constructor is required for DialogFragment.
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ protected void notifyListener(List<RemoteClient> clients) {
+ RemoteClientsListener listener;
+ try {
+ listener = (RemoteClientsListener) getTargetFragment();
+ } catch (ClassCastException e) {
+ try {
+ listener = (RemoteClientsListener) getActivity();
+ } catch (ClassCastException f) {
+ throw new ClassCastException(getTargetFragment() + " or " + getActivity()
+ + " must implement RemoteClientsListener");
+ }
+ }
+ listener.onClients(clients);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final String title = getArguments().getString(KEY_TITLE);
+ final String positiveButtonText = getArguments().getString(KEY_POSITIVE_BUTTON_TEXT);
+ final ChoiceMode choiceMode = ChoiceMode.values()[getArguments().getInt(KEY_CHOICE_MODE)];
+ final ArrayList<RemoteClient> clients = getArguments().getParcelableArrayList(KEY_CLIENTS);
+
+ final Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setTitle(title);
+
+ final String[] clientNames = new String[clients.size()];
+ for (int i = 0; i < clients.size(); i++) {
+ clientNames[i] = clients.get(i).name;
+ }
+
+ if (choiceMode == ChoiceMode.MULTIPLE) {
+ builder.setMultiChoiceItems(clientNames, null, null);
+ builder.setPositiveButton(positiveButtonText, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int which) {
+ if (which != Dialog.BUTTON_POSITIVE) {
+ return;
+ }
+
+ final AlertDialog dialog = (AlertDialog) dialogInterface;
+ final SparseBooleanArray checkedItemPositions = dialog.getListView().getCheckedItemPositions();
+ final ArrayList<RemoteClient> checked = new ArrayList<RemoteClient>();
+ for (int i = 0; i < clients.size(); i++) {
+ if (checkedItemPositions.get(i)) {
+ checked.add(clients.get(i));
+ }
+ }
+ notifyListener(checked);
+ }
+ });
+ } else {
+ builder.setItems(clientNames, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int index) {
+ final ArrayList<RemoteClient> checked = new ArrayList<RemoteClient>();
+ checked.add(clients.get(index));
+ notifyListener(checked);
+ }
+ });
+ }
+
+ return builder.create();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java b/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java
new file mode 100644
index 000000000..b5a5527c9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/RemotePresentationService.java
@@ -0,0 +1,150 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.PresentationView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ScreenManagerHelper;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+
+import com.google.android.gms.cast.CastMediaControlIntent;
+import com.google.android.gms.cast.CastPresentation;
+import com.google.android.gms.cast.CastRemoteDisplayLocalService;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GooglePlayServicesUtil;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v7.media.MediaControlIntent;
+import android.support.v7.media.MediaRouteSelector;
+import android.support.v7.media.MediaRouter.RouteInfo;
+import android.support.v7.media.MediaRouter;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Display;
+import android.view.ViewGroup.LayoutParams;
+import android.view.WindowManager;
+import android.widget.RelativeLayout;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/*
+ * Service to keep the remote display running even when the app goes into the background
+ */
+public class RemotePresentationService extends CastRemoteDisplayLocalService {
+
+ private static final String LOGTAG = "RemotePresentationService";
+ private CastPresentation presentation;
+ private String deviceId;
+ private int screenId;
+
+ public void setDeviceId(String deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ @Override
+ public void onCreatePresentation(Display display) {
+ createPresentation();
+ }
+
+ @Override
+ public void onDismissPresentation() {
+ dismissPresentation();
+ }
+
+ private void dismissPresentation() {
+ if (presentation != null) {
+ presentation.dismiss();
+ presentation = null;
+ ScreenManagerHelper.removeDisplay(screenId);
+ MediaPlayerManager.getInstance().setPresentationMode(false);
+ }
+ }
+
+ private void createPresentation() {
+ dismissPresentation();
+
+ MediaPlayerManager.getInstance().setPresentationMode(true);
+
+ DisplayMetrics metrics = new DisplayMetrics();
+ getDisplay().getMetrics(metrics);
+ screenId = ScreenManagerHelper.addDisplay(ScreenManagerHelper.DISPLAY_VIRTUAL,
+ metrics.widthPixels,
+ metrics.heightPixels,
+ metrics.density);
+
+ VirtualPresentation virtualPresentation = new VirtualPresentation(this, getDisplay());
+ virtualPresentation.setDeviceId(deviceId);
+ virtualPresentation.setScreenId(screenId);
+ presentation = (CastPresentation) virtualPresentation;
+
+ try {
+ presentation.show();
+ } catch (WindowManager.InvalidDisplayException ex) {
+ Log.e(LOGTAG, "Unable to show presentation, display was removed.", ex);
+ dismissPresentation();
+ }
+ }
+}
+
+class VirtualPresentation extends CastPresentation {
+ private final String LOGTAG = "VirtualPresentation";
+ private RelativeLayout layout;
+ private PresentationView view;
+ private String deviceId;
+ private int screenId;
+
+ public VirtualPresentation(Context context, Display display) {
+ super(context, display);
+ }
+
+ public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
+ public void setScreenId(int screenId) { this.screenId = screenId; }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ /*
+ * NOTICE: The context get from getContext() is different to the context
+ * of the application. Presentaion has its own context to get correct
+ * resources.
+ */
+
+ // Create new PresentationView
+ view = new PresentationView(getContext(), deviceId, screenId);
+ view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT));
+
+ // Create new layout to put the GeckoView
+ layout = new RelativeLayout(getContext());
+ layout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT));
+ layout.addView(view);
+
+ setContentView(layout);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Restarter.java b/mobile/android/base/java/org/mozilla/gecko/Restarter.java
new file mode 100644
index 000000000..b049f7627
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Restarter.java
@@ -0,0 +1,50 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+import android.os.Process;
+import android.util.Log;
+
+public class Restarter extends Service {
+ private static final String LOGTAG = "GeckoRestarter";
+
+ private void doRestart(Intent intent) {
+ final int oldProc = intent.getIntExtra("pid", -1);
+ if (oldProc < 0) {
+ return;
+ }
+
+ Process.killProcess(oldProc);
+ Log.d(LOGTAG, "Killed " + oldProc);
+ try {
+ Thread.sleep(100);
+ } catch (final InterruptedException e) {
+ }
+
+ final Intent restartIntent = (Intent)intent.getParcelableExtra(Intent.EXTRA_INTENT);
+ restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra("didRestart", true)
+ .setClassName(getApplicationContext(),
+ AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ startActivity(restartIntent);
+ Log.d(LOGTAG, "Launched " + restartIntent);
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ doRestart(intent);
+ stopSelf(startId);
+ return Service.START_NOT_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java
new file mode 100644
index 000000000..5cb404ce8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ScreenManagerHelper.java
@@ -0,0 +1,43 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+class ScreenManagerHelper {
+
+ /**
+ * The following display types use the same definition in nsIScreen.idl
+ */
+ final static int DISPLAY_PRIMARY = 0; // primary screen
+ final static int DISPLAY_EXTERNAL = 1; // wired displays, such as HDMI, DisplayPort, etc.
+ final static int DISPLAY_VIRTUAL = 2; // wireless displays, such as Chromecast, WiFi-Display, etc.
+
+ /**
+ * Add a new nsScreen when a new display in Android is available.
+ *
+ * @param displayType the display type of the nsScreen would be added
+ * @param width the width of the new nsScreen
+ * @param height the height of the new nsScreen
+ * @param density the density of the new nsScreen
+ *
+ * @return return the ID of the added nsScreen
+ */
+ @WrapForJNI
+ public native static int addDisplay(int displayType,
+ int width,
+ int height,
+ float density);
+
+ /**
+ * Remove the nsScreen by the specific screen ID.
+ *
+ * @param screenId the ID of the screen would be removed.
+ */
+ @WrapForJNI
+ public native static void removeDisplay(int screenId);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java b/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java
new file mode 100644
index 000000000..64f101e51
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ScreenshotObserver.java
@@ -0,0 +1,146 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.Manifest;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore;
+import android.util.Log;
+
+public class ScreenshotObserver {
+ private static final String LOGTAG = "GeckoScreenshotObserver";
+ public Context context;
+
+ /**
+ * Listener for screenshot changes.
+ */
+ public interface OnScreenshotListener {
+ /**
+ * This callback is executed on the UI thread.
+ */
+ public void onScreenshotTaken(String data, String title);
+ }
+
+ private OnScreenshotListener listener;
+
+ public ScreenshotObserver() {
+ }
+
+ public void setListener(Context context, OnScreenshotListener listener) {
+ this.context = context;
+ this.listener = listener;
+ }
+
+ private MediaObserver mediaObserver;
+ private String[] mediaProjections = new String[] {
+ MediaStore.Images.ImageColumns.DATA,
+ MediaStore.Images.ImageColumns.DISPLAY_NAME,
+ MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME,
+ MediaStore.Images.ImageColumns.DATE_TAKEN,
+ MediaStore.Images.ImageColumns.TITLE
+ };
+
+ /**
+ * Start ScreenshotObserver if this device is supported and all required runtime permissions
+ * have been granted by the user. Calling this method will not prompt for permissions.
+ */
+ public void start() {
+ Permissions.from(context)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPrompt()
+ .run(startObserverRunnable());
+ }
+
+ private Runnable startObserverRunnable() {
+ return new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (mediaObserver == null) {
+ mediaObserver = new MediaObserver();
+ context.getContentResolver().registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, false, mediaObserver);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failure to start watching media: ", e);
+ }
+ }
+ };
+ }
+
+ public void stop() {
+ if (mediaObserver == null) {
+ return;
+ }
+
+ try {
+ context.getContentResolver().unregisterContentObserver(mediaObserver);
+ mediaObserver = null;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failure to stop watching media: ", e);
+ }
+ }
+
+ public void onMediaChange(final Uri uri) {
+ // Make sure we are on not on the main thread.
+ final ContentResolver cr = context.getContentResolver();
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // Find the most recent image added to the MediaStore and see if it's a screenshot.
+ final Cursor cursor = cr.query(uri, mediaProjections, null, null, MediaStore.Images.ImageColumns.DATE_ADDED + " DESC LIMIT 1");
+ try {
+ if (cursor == null) {
+ return;
+ }
+
+ while (cursor.moveToNext()) {
+ String data = cursor.getString(0);
+ Log.i(LOGTAG, "data: " + data);
+ String display = cursor.getString(1);
+ Log.i(LOGTAG, "display: " + display);
+ String album = cursor.getString(2);
+ Log.i(LOGTAG, "album: " + album);
+ long date = cursor.getLong(3);
+ String title = cursor.getString(4);
+ Log.i(LOGTAG, "title: " + title);
+ if (album != null && album.toLowerCase().contains("screenshot")) {
+ if (listener != null) {
+ listener.onScreenshotTaken(data, title);
+ break;
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failure to process media change: ", e);
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+ });
+ }
+
+ private class MediaObserver extends ContentObserver {
+ public MediaObserver() {
+ super(null);
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ super.onChange(selfChange);
+ onMediaChange(MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/SessionParser.java b/mobile/android/base/java/org/mozilla/gecko/SessionParser.java
new file mode 100644
index 000000000..d29aaadc7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/SessionParser.java
@@ -0,0 +1,140 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * ***** BEGIN LICENSE BLOCK *****
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko;
+
+import java.util.LinkedList;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.util.Log;
+
+public abstract class SessionParser {
+ private static final String LOGTAG = "GeckoSessionParser";
+
+ public class SessionTab {
+ final private String mTitle;
+ final private String mUrl;
+ final private JSONObject mTabObject;
+ private boolean mIsSelected;
+
+ private SessionTab(String title, String url, boolean isSelected, JSONObject tabObject) {
+ mTitle = title;
+ mUrl = url;
+ mIsSelected = isSelected;
+ mTabObject = tabObject;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public boolean isSelected() {
+ return mIsSelected;
+ }
+
+ public JSONObject getTabObject() {
+ return mTabObject;
+ }
+
+ /**
+ * Is this tab pointing to about:home and does not contain any other history?
+ */
+ public boolean isAboutHomeWithoutHistory() {
+ JSONArray entries = mTabObject.optJSONArray("entries");
+ return entries != null && entries.length() == 1 && AboutPages.isAboutHome(mUrl);
+ }
+ };
+
+ abstract public void onTabRead(SessionTab tab);
+
+ /**
+ * Placeholder method that must be overloaded to handle closedTabs while parsing session data.
+ *
+ * @param closedTabs, JSONArray of recently closed tab entries.
+ * @throws JSONException
+ */
+ public void onClosedTabsRead(final JSONArray closedTabs) throws JSONException {
+ }
+
+ /**
+ * Parses the provided session store data and calls onTabRead for each tab that has been found.
+ *
+ * @param sessionStrings One or more strings containing session store data.
+ * @return False if any of the session strings provided didn't contain valid session store data.
+ */
+ public boolean parse(String... sessionStrings) {
+ final LinkedList<SessionTab> sessionTabs = new LinkedList<SessionTab>();
+ int totalCount = 0;
+ int selectedIndex = -1;
+ try {
+ for (String sessionString : sessionStrings) {
+ final JSONArray windowsArray = new JSONObject(sessionString).getJSONArray("windows");
+ if (windowsArray.length() == 0) {
+ // Session json can be empty if the user has opted out of session restore.
+ Log.d(LOGTAG, "Session restore file is empty, no session entries found.");
+ continue;
+ }
+
+ final JSONObject window = windowsArray.getJSONObject(0);
+ final JSONArray tabs = window.getJSONArray("tabs");
+ final int optSelected = window.optInt("selected", -1);
+ final JSONArray closedTabs = window.optJSONArray("closedTabs");
+ if (closedTabs != null) {
+ onClosedTabsRead(closedTabs);
+ }
+
+ for (int i = 0; i < tabs.length(); i++) {
+ final JSONObject tab = tabs.getJSONObject(i);
+ final int index = tab.getInt("index");
+ final JSONArray entries = tab.getJSONArray("entries");
+ if (index < 1 || entries.length() < index) {
+ Log.w(LOGTAG, "Session entries and index don't agree.");
+ continue;
+ }
+ final JSONObject entry = entries.getJSONObject(index - 1);
+ final String url = entry.getString("url");
+
+ String title = entry.optString("title");
+ if (title.length() == 0) {
+ title = url;
+ }
+
+ totalCount++;
+ boolean selected = false;
+ if (optSelected == i + 1) {
+ selected = true;
+ selectedIndex = totalCount;
+ }
+ sessionTabs.add(new SessionTab(title, url, selected, tab));
+ }
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ return false;
+ }
+
+ // If no selected index was found, select the first tab.
+ if (selectedIndex == -1 && sessionTabs.size() > 0) {
+ sessionTabs.getFirst().mIsSelected = true;
+ }
+
+ for (SessionTab tab : sessionTabs) {
+ onTabRead(tab);
+ }
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java b/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java
new file mode 100644
index 000000000..1066da079
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/SharedPreferencesHelper.java
@@ -0,0 +1,311 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+import java.util.Map;
+import java.util.HashMap;
+
+/**
+ * Helper class to get, set, and observe Android Shared Preferences.
+ */
+public final class SharedPreferencesHelper
+ implements GeckoEventListener
+{
+ public static final String LOGTAG = "GeckoAndSharedPrefs";
+
+ // Calculate this once, at initialization. isLoggable is too expensive to
+ // have in-line in each log call.
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+
+ private enum Scope {
+ APP("app"),
+ PROFILE("profile"),
+ GLOBAL("global");
+
+ public final String key;
+
+ private Scope(String key) {
+ this.key = key;
+ }
+
+ public static Scope forKey(String key) {
+ for (Scope scope : values()) {
+ if (scope.key.equals(key)) {
+ return scope;
+ }
+ }
+
+ throw new IllegalStateException("SharedPreferences scope must be valid.");
+ }
+ }
+
+ protected final Context mContext;
+
+ // mListeners is not synchronized because it is only updated in
+ // handleObserve, which is called from Gecko serially.
+ protected final Map<String, SharedPreferences.OnSharedPreferenceChangeListener> mListeners;
+
+ public SharedPreferencesHelper(Context context) {
+ mContext = context;
+
+ mListeners = new HashMap<String, SharedPreferences.OnSharedPreferenceChangeListener>();
+
+ EventDispatcher dispatcher = GeckoApp.getEventDispatcher();
+ if (dispatcher == null) {
+ Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException());
+ return;
+ }
+ dispatcher.registerGeckoThreadListener(this,
+ "SharedPreferences:Set",
+ "SharedPreferences:Get",
+ "SharedPreferences:Observe");
+ }
+
+ public synchronized void uninit() {
+ EventDispatcher dispatcher = GeckoApp.getEventDispatcher();
+ if (dispatcher == null) {
+ Log.e(LOGTAG, "Gecko event dispatcher must not be null", new RuntimeException());
+ return;
+ }
+ dispatcher.unregisterGeckoThreadListener(this,
+ "SharedPreferences:Set",
+ "SharedPreferences:Get",
+ "SharedPreferences:Observe");
+ }
+
+ private SharedPreferences getSharedPreferences(JSONObject message) throws JSONException {
+ final Scope scope = Scope.forKey(message.getString("scope"));
+ switch (scope) {
+ case APP:
+ return GeckoSharedPrefs.forApp(mContext);
+ case PROFILE:
+ final String profileName = message.optString("profileName", null);
+ if (profileName == null) {
+ return GeckoSharedPrefs.forProfile(mContext);
+ } else {
+ return GeckoSharedPrefs.forProfileName(mContext, profileName);
+ }
+ case GLOBAL:
+ final String branch = message.optString("branch", null);
+ if (branch == null) {
+ return PreferenceManager.getDefaultSharedPreferences(mContext);
+ } else {
+ return mContext.getSharedPreferences(branch, Context.MODE_PRIVATE);
+ }
+ }
+
+ return null;
+ }
+
+ private String getBranch(Scope scope, String profileName, String branch) {
+ switch (scope) {
+ case APP:
+ return GeckoSharedPrefs.APP_PREFS_NAME;
+ case PROFILE:
+ if (profileName == null) {
+ profileName = GeckoProfile.get(mContext).getName();
+ }
+
+ return GeckoSharedPrefs.PROFILE_PREFS_NAME_PREFIX + profileName;
+ case GLOBAL:
+ return branch;
+ }
+
+ return null;
+ }
+
+ /**
+ * Set many SharedPreferences in Android.
+ *
+ * message.branch must exist, and should be a String SharedPreferences
+ * branch name, or null for the default branch.
+ * message.preferences should be an array of preferences. Each preference
+ * must include a String name, a String type in ["bool", "int", "string"],
+ * and an Object value.
+ */
+ private void handleSet(JSONObject message) throws JSONException {
+ SharedPreferences.Editor editor = getSharedPreferences(message).edit();
+
+ JSONArray jsonPrefs = message.getJSONArray("preferences");
+
+ for (int i = 0; i < jsonPrefs.length(); i++) {
+ JSONObject pref = jsonPrefs.getJSONObject(i);
+ String name = pref.getString("name");
+ String type = pref.getString("type");
+ if ("bool".equals(type)) {
+ editor.putBoolean(name, pref.getBoolean("value"));
+ } else if ("int".equals(type)) {
+ editor.putInt(name, pref.getInt("value"));
+ } else if ("string".equals(type)) {
+ editor.putString(name, pref.getString("value"));
+ } else {
+ Log.w(LOGTAG, "Unknown pref value type [" + type + "] for pref [" + name + "]");
+ }
+ editor.apply();
+ }
+ }
+
+ /**
+ * Get many SharedPreferences from Android.
+ *
+ * message.branch must exist, and should be a String SharedPreferences
+ * branch name, or null for the default branch.
+ * message.preferences should be an array of preferences. Each preference
+ * must include a String name, and a String type in ["bool", "int",
+ * "string"].
+ */
+ private JSONArray handleGet(JSONObject message) throws JSONException {
+ SharedPreferences prefs = getSharedPreferences(message);
+ JSONArray jsonPrefs = message.getJSONArray("preferences");
+ JSONArray jsonValues = new JSONArray();
+
+ for (int i = 0; i < jsonPrefs.length(); i++) {
+ JSONObject pref = jsonPrefs.getJSONObject(i);
+ String name = pref.getString("name");
+ String type = pref.getString("type");
+ JSONObject jsonValue = new JSONObject();
+ jsonValue.put("name", name);
+ jsonValue.put("type", type);
+ try {
+ if ("bool".equals(type)) {
+ boolean value = prefs.getBoolean(name, false);
+ jsonValue.put("value", value);
+ } else if ("int".equals(type)) {
+ int value = prefs.getInt(name, 0);
+ jsonValue.put("value", value);
+ } else if ("string".equals(type)) {
+ String value = prefs.getString(name, "");
+ jsonValue.put("value", value);
+ } else {
+ Log.w(LOGTAG, "Unknown pref value type [" + type + "] for pref [" + name + "]");
+ }
+ } catch (ClassCastException e) {
+ // Thrown if there is a preference with the given name that is
+ // not the right type.
+ Log.w(LOGTAG, "Wrong pref value type [" + type + "] for pref [" + name + "]");
+ }
+ jsonValues.put(jsonValue);
+ }
+
+ return jsonValues;
+ }
+
+ private static class ChangeListener
+ implements SharedPreferences.OnSharedPreferenceChangeListener {
+ public final Scope scope;
+ public final String branch;
+ public final String profileName;
+
+ public ChangeListener(final Scope scope, final String branch, final String profileName) {
+ this.scope = scope;
+ this.branch = branch;
+ this.profileName = profileName;
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (logVerbose) {
+ Log.v(LOGTAG, "Got onSharedPreferenceChanged");
+ }
+ try {
+ final JSONObject msg = new JSONObject();
+ msg.put("scope", this.scope.key);
+ msg.put("branch", this.branch);
+ msg.put("profileName", this.profileName);
+ msg.put("key", key);
+
+ // Truly, this is awful, but the API impedance is strong: there
+ // is no way to get a single untyped value from a
+ // SharedPreferences instance.
+ msg.put("value", sharedPreferences.getAll().get(key));
+
+ GeckoAppShell.notifyObservers("SharedPreferences:Changed", msg.toString());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Got exception creating JSON object", e);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Register or unregister a SharedPreferences.OnSharedPreferenceChangeListener.
+ *
+ * message.branch must exist, and should be a String SharedPreferences
+ * branch name, or null for the default branch.
+ * message.enable should be a boolean: true to enable listening, false to
+ * disable listening.
+ */
+ private void handleObserve(JSONObject message) throws JSONException {
+ final SharedPreferences prefs = getSharedPreferences(message);
+ final boolean enable = message.getBoolean("enable");
+
+ final Scope scope = Scope.forKey(message.getString("scope"));
+ final String profileName = message.optString("profileName", null);
+ final String branch = getBranch(scope, profileName, message.optString("branch", null));
+
+ if (branch == null) {
+ Log.e(LOGTAG, "No branch specified for SharedPreference:Observe; aborting.");
+ return;
+ }
+
+ // mListeners is only modified in this one observer, which is called
+ // from Gecko serially.
+ if (enable && !this.mListeners.containsKey(branch)) {
+ SharedPreferences.OnSharedPreferenceChangeListener listener
+ = new ChangeListener(scope, branch, profileName);
+ this.mListeners.put(branch, listener);
+ prefs.registerOnSharedPreferenceChangeListener(listener);
+ }
+ if (!enable && this.mListeners.containsKey(branch)) {
+ SharedPreferences.OnSharedPreferenceChangeListener listener
+ = this.mListeners.remove(branch);
+ prefs.unregisterOnSharedPreferenceChangeListener(listener);
+ }
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ // Everything here is synchronous and serial, so we need not worry about
+ // overwriting an in-progress response.
+ try {
+ if (event.equals("SharedPreferences:Set")) {
+ if (logVerbose) {
+ Log.v(LOGTAG, "Got SharedPreferences:Set message.");
+ }
+ handleSet(message);
+ } else if (event.equals("SharedPreferences:Get")) {
+ if (logVerbose) {
+ Log.v(LOGTAG, "Got SharedPreferences:Get message.");
+ }
+ JSONObject obj = new JSONObject();
+ obj.put("values", handleGet(message));
+ EventDispatcher.sendResponse(message, obj);
+ } else if (event.equals("SharedPreferences:Observe")) {
+ if (logVerbose) {
+ Log.v(LOGTAG, "Got SharedPreferences:Observe message.");
+ }
+ handleObserve(message);
+ } else {
+ Log.e(LOGTAG, "SharedPreferencesHelper got unexpected message " + event);
+ return;
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Got exception in handleMessage handling event " + event, e);
+ return;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java b/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java
new file mode 100644
index 000000000..e39d25dd8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/SiteIdentity.java
@@ -0,0 +1,249 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.json.JSONObject;
+
+import android.text.TextUtils;
+
+public class SiteIdentity {
+ private final String LOGTAG = "GeckoSiteIdentity";
+ private SecurityMode mSecurityMode;
+ private boolean mSecure;
+ private MixedMode mMixedModeActive;
+ private MixedMode mMixedModeDisplay;
+ private TrackingMode mTrackingMode;
+ private String mHost;
+ private String mOwner;
+ private String mSupplemental;
+ private String mCountry;
+ private String mVerifier;
+ private String mOrigin;
+
+ // The order of the items here relate to image levels in
+ // site_security_level.xml
+ public enum SecurityMode {
+ UNKNOWN("unknown"),
+ IDENTIFIED("identified"),
+ VERIFIED("verified"),
+ CHROMEUI("chromeUI");
+
+ private final String mId;
+
+ private SecurityMode(String id) {
+ mId = id;
+ }
+
+ public static SecurityMode fromString(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Can't convert null String to SiteIdentity");
+ }
+
+ for (SecurityMode mode : SecurityMode.values()) {
+ if (TextUtils.equals(mode.mId, id)) {
+ return mode;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to SiteIdentity");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+ }
+
+ // The order of the items here relate to image levels in
+ // site_security_level.xml
+ public enum MixedMode {
+ UNKNOWN("unknown"),
+ MIXED_CONTENT_BLOCKED("blocked"),
+ MIXED_CONTENT_LOADED("loaded");
+
+ private final String mId;
+
+ private MixedMode(String id) {
+ mId = id;
+ }
+
+ public static MixedMode fromString(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Can't convert null String to MixedMode");
+ }
+
+ for (MixedMode mode : MixedMode.values()) {
+ if (TextUtils.equals(mode.mId, id.toLowerCase())) {
+ return mode;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to MixedMode");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+ }
+
+ // The order of the items here relate to image levels in
+ // site_security_level.xml
+ public enum TrackingMode {
+ UNKNOWN("unknown"),
+ TRACKING_CONTENT_BLOCKED("tracking_content_blocked"),
+ TRACKING_CONTENT_LOADED("tracking_content_loaded");
+
+ private final String mId;
+
+ private TrackingMode(String id) {
+ mId = id;
+ }
+
+ public static TrackingMode fromString(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Can't convert null String to TrackingMode");
+ }
+
+ for (TrackingMode mode : TrackingMode.values()) {
+ if (TextUtils.equals(mode.mId, id.toLowerCase())) {
+ return mode;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to TrackingMode");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+ }
+
+ public SiteIdentity() {
+ reset();
+ }
+
+ public void resetIdentity() {
+ mSecurityMode = SecurityMode.UNKNOWN;
+ mOrigin = null;
+ mHost = null;
+ mOwner = null;
+ mSupplemental = null;
+ mCountry = null;
+ mVerifier = null;
+ mSecure = false;
+ }
+
+ public void reset() {
+ resetIdentity();
+ mMixedModeActive = MixedMode.UNKNOWN;
+ mMixedModeDisplay = MixedMode.UNKNOWN;
+ mTrackingMode = TrackingMode.UNKNOWN;
+ }
+
+ void update(JSONObject identityData) {
+ if (identityData == null) {
+ reset();
+ return;
+ }
+
+ try {
+ JSONObject mode = identityData.getJSONObject("mode");
+
+ try {
+ mMixedModeDisplay = MixedMode.fromString(mode.getString("mixed_display"));
+ } catch (Exception e) {
+ mMixedModeDisplay = MixedMode.UNKNOWN;
+ }
+
+ try {
+ mMixedModeActive = MixedMode.fromString(mode.getString("mixed_active"));
+ } catch (Exception e) {
+ mMixedModeActive = MixedMode.UNKNOWN;
+ }
+
+ try {
+ mTrackingMode = TrackingMode.fromString(mode.getString("tracking"));
+ } catch (Exception e) {
+ mTrackingMode = TrackingMode.UNKNOWN;
+ }
+
+ try {
+ mSecurityMode = SecurityMode.fromString(mode.getString("identity"));
+ } catch (Exception e) {
+ resetIdentity();
+ return;
+ }
+
+ try {
+ mOrigin = identityData.getString("origin");
+ mHost = identityData.optString("host", null);
+ mOwner = identityData.optString("owner", null);
+ mSupplemental = identityData.optString("supplemental", null);
+ mCountry = identityData.optString("country", null);
+ mVerifier = identityData.optString("verifier", null);
+ mSecure = identityData.optBoolean("secure", false);
+ } catch (Exception e) {
+ resetIdentity();
+ }
+ } catch (Exception e) {
+ reset();
+ }
+ }
+
+ public SecurityMode getSecurityMode() {
+ return mSecurityMode;
+ }
+
+ public String getOrigin() {
+ return mOrigin;
+ }
+
+ public String getHost() {
+ return mHost;
+ }
+
+ public String getOwner() {
+ return mOwner;
+ }
+
+ public boolean hasOwner() {
+ return !TextUtils.isEmpty(mOwner);
+ }
+
+ public String getSupplemental() {
+ return mSupplemental;
+ }
+
+ public String getCountry() {
+ return mCountry;
+ }
+
+ public boolean hasCountry() {
+ return !TextUtils.isEmpty(mCountry);
+ }
+
+ public String getVerifier() {
+ return mVerifier;
+ }
+
+ public boolean isSecure() {
+ return mSecure;
+ }
+
+ public MixedMode getMixedModeActive() {
+ return mMixedModeActive;
+ }
+
+ public MixedMode getMixedModeDisplay() {
+ return mMixedModeDisplay;
+ }
+
+ public TrackingMode getTrackingMode() {
+ return mTrackingMode;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java b/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java
new file mode 100644
index 000000000..3283e7c37
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/SnackbarBuilder.java
@@ -0,0 +1,257 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeJSObject;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.InsetDrawable;
+import android.support.annotation.StringRes;
+import android.support.design.widget.Snackbar;
+import android.support.v4.content.ContextCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Helper class for creating and dismissing snackbars. Use this class to guarantee a consistent style and behavior
+ * across the app.
+ */
+public class SnackbarBuilder {
+ /**
+ * Combined interface for handling all callbacks from a snackbar because anonymous classes can only extend one
+ * interface or class.
+ */
+ public static abstract class SnackbarCallback extends Snackbar.Callback implements View.OnClickListener {}
+ public static final String LOGTAG = "GeckoSnackbarBuilder";
+
+ /**
+ * SnackbarCallback implementation for delegating snackbar events to an EventCallback.
+ */
+ private static class SnackbarEventCallback extends SnackbarCallback {
+ private EventCallback callback;
+
+ public SnackbarEventCallback(EventCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public synchronized void onClick(View view) {
+ if (callback == null) {
+ return;
+ }
+
+ callback.sendSuccess(null);
+ callback = null; // Releasing reference. We only want to execute the callback once.
+ }
+
+ @Override
+ public synchronized void onDismissed(Snackbar snackbar, int event) {
+ if (callback == null || event == Snackbar.Callback.DISMISS_EVENT_ACTION) {
+ return;
+ }
+
+ callback.sendError(null);
+ callback = null; // Releasing reference. We only want to execute the callback once.
+ }
+ }
+
+ private static final Object currentSnackbarLock = new Object();
+ private static WeakReference<Snackbar> currentSnackbar = new WeakReference<>(null); // Guarded by 'currentSnackbarLock'
+
+ private final Activity activity;
+ private String message;
+ private int duration;
+ private String action;
+ private SnackbarCallback callback;
+ private Drawable icon;
+ private Integer backgroundColor;
+ private Integer actionColor;
+
+ /**
+ * @param activity Activity to show the snackbar in.
+ */
+ private SnackbarBuilder(final Activity activity) {
+ this.activity = activity;
+ }
+
+ public static SnackbarBuilder builder(final Activity activity) {
+ return new SnackbarBuilder(activity);
+ }
+
+ /**
+ * @param message The text to show. Can be formatted text.
+ */
+ public SnackbarBuilder message(final String message) {
+ this.message = message;
+ return this;
+ }
+
+ /**
+ * @param id The id of the string resource to show. Can be formatted text.
+ */
+ public SnackbarBuilder message(@StringRes final int id) {
+ message = activity.getResources().getString(id);
+ return this;
+ }
+
+ /**
+ * @param duration How long to display the message.
+ */
+ public SnackbarBuilder duration(final int duration) {
+ this.duration = duration;
+ return this;
+ }
+
+ /**
+ * @param action Action text to display.
+ */
+ public SnackbarBuilder action(final String action) {
+ this.action = action;
+ return this;
+ }
+
+ /**
+ * @param id The id of the string resource for the action text to display.
+ */
+ public SnackbarBuilder action(@StringRes final int id) {
+ action = activity.getResources().getString(id);
+ return this;
+ }
+
+ /**
+ * @param callback Callback to be invoked when the action is clicked or the snackbar is dismissed.
+ */
+ public SnackbarBuilder callback(final SnackbarCallback callback) {
+ this.callback = callback;
+ return this;
+ }
+
+ /**
+ * @param callback Callback to be invoked when the action is clicked or the snackbar is dismissed.
+ */
+ public SnackbarBuilder callback(final EventCallback callback) {
+ this.callback = new SnackbarEventCallback(callback);
+ return this;
+ }
+
+ /**
+ * @param icon Icon to be displayed with the snackbar text.
+ */
+ public SnackbarBuilder icon(final Drawable icon) {
+ this.icon = icon;
+ return this;
+ }
+
+ /**
+ * @param backgroundColor Snackbar background color.
+ */
+ public SnackbarBuilder backgroundColor(final Integer backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ return this;
+ }
+
+ /**
+ * @param actionColor Action text color.
+ */
+ public SnackbarBuilder actionColor(final Integer actionColor) {
+ this.actionColor = actionColor;
+ return this;
+ }
+
+ /**
+ * @param object Populate the builder with data from a Gecko Snackbar:Show event.
+ */
+ public SnackbarBuilder fromEvent(final NativeJSObject object) {
+ message = object.getString("message");
+ duration = object.getInt("duration");
+
+ if (object.has("backgroundColor")) {
+ final String providedColor = object.getString("backgroundColor");
+ try {
+ backgroundColor = Color.parseColor(providedColor);
+ } catch (IllegalArgumentException e) {
+ Log.w(LOGTAG, "Failed to parse color string: " + providedColor);
+ }
+ }
+
+ NativeJSObject actionObject = object.optObject("action", null);
+ if (actionObject != null) {
+ action = actionObject.optString("label", null);
+ }
+ return this;
+ }
+
+ public void buildAndShow() {
+ final View parentView = findBestParentView(activity);
+ final Snackbar snackbar = Snackbar.make(parentView, message, duration);
+
+ if (callback != null && !TextUtils.isEmpty(action)) {
+ snackbar.setAction(action, callback);
+ if (actionColor == null) {
+ snackbar.setActionTextColor(ContextCompat.getColor(activity, R.color.fennec_ui_orange));
+ } else {
+ snackbar.setActionTextColor(actionColor);
+ }
+ snackbar.setCallback(callback);
+ }
+
+ if (icon != null) {
+ int leftPadding = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, activity.getResources().getDisplayMetrics());
+
+ final InsetDrawable paddedIcon = new InsetDrawable(icon, 0, 0, leftPadding, 0);
+
+ paddedIcon.setBounds(0, 0, leftPadding + icon.getIntrinsicWidth(), icon.getIntrinsicHeight());
+
+ TextView textView = (TextView) snackbar.getView().findViewById(android.support.design.R.id.snackbar_text);
+ textView.setCompoundDrawables(paddedIcon, null, null, null);
+ }
+
+ if (backgroundColor != null) {
+ snackbar.getView().setBackgroundColor(backgroundColor);
+ }
+
+ snackbar.show();
+
+ synchronized (currentSnackbarLock) {
+ currentSnackbar = new WeakReference<>(snackbar);
+ }
+ }
+
+ /**
+ * Dismiss the currently visible snackbar.
+ */
+ public static void dismissCurrentSnackbar() {
+ synchronized (currentSnackbarLock) {
+ final Snackbar snackbar = currentSnackbar.get();
+ if (snackbar != null && snackbar.isShown()) {
+ snackbar.dismiss();
+ }
+ }
+ }
+
+ /**
+ * Find the best parent view to hold the Snackbar's view. The Snackbar implementation of the support
+ * library will use this view to walk up the view tree to find an actual suitable parent (if needed).
+ */
+ private static View findBestParentView(Activity activity) {
+ if (activity instanceof GeckoApp) {
+ final View view = activity.findViewById(R.id.root_layout);
+ if (view != null) {
+ return view;
+ }
+ }
+
+ return activity.findViewById(android.R.id.content);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java b/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java
new file mode 100644
index 000000000..e43bbef1f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/SuggestClient.java
@@ -0,0 +1,142 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+
+import org.json.JSONArray;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+import org.mozilla.gecko.util.NetworkUtils;
+
+/**
+ * Use network-based search suggestions.
+ */
+public class SuggestClient {
+ private static final String LOGTAG = "GeckoSuggestClient";
+
+ // This should go through GeckoInterface to get the UA, but the search activity
+ // doesn't use a GeckoView yet. Until it does, get the UA directly.
+ private static final String USER_AGENT = HardwareUtils.isTablet() ?
+ AppConstants.USER_AGENT_FENNEC_TABLET : AppConstants.USER_AGENT_FENNEC_MOBILE;
+
+ private final Context mContext;
+ private final int mTimeout;
+
+ // should contain the string "__searchTerms__", which is replaced with the query
+ private final String mSuggestTemplate;
+
+ // the maximum number of suggestions to return
+ private final int mMaxResults;
+
+ // used by robocop for testing
+ private final boolean mCheckNetwork;
+
+ // used to make suggestions appear instantly after opt-in
+ private String mPrevQuery;
+ private ArrayList<String> mPrevResults;
+
+ @RobocopTarget
+ public SuggestClient(Context context, String suggestTemplate, int timeout, int maxResults, boolean checkNetwork) {
+ mContext = context;
+ mMaxResults = maxResults;
+ mSuggestTemplate = suggestTemplate;
+ mTimeout = timeout;
+ mCheckNetwork = checkNetwork;
+ }
+
+ public String getSuggestTemplate() {
+ return mSuggestTemplate;
+ }
+
+ /**
+ * Queries for a given search term and returns an ArrayList of suggestions.
+ */
+ public ArrayList<String> query(String query) {
+ if (query.equals(mPrevQuery))
+ return mPrevResults;
+
+ ArrayList<String> suggestions = new ArrayList<String>();
+ if (TextUtils.isEmpty(mSuggestTemplate) || TextUtils.isEmpty(query)) {
+ return suggestions;
+ }
+
+ if (!NetworkUtils.isConnected(mContext) && mCheckNetwork) {
+ Log.i(LOGTAG, "Not connected to network");
+ return suggestions;
+ }
+
+ try {
+ String encoded = URLEncoder.encode(query, "UTF-8");
+ String suggestUri = mSuggestTemplate.replace("__searchTerms__", encoded);
+
+ URL url = new URL(suggestUri);
+ String json = null;
+ HttpURLConnection urlConnection = null;
+ InputStream in = null;
+ try {
+ urlConnection = (HttpURLConnection) url.openConnection();
+ urlConnection.setConnectTimeout(mTimeout);
+ urlConnection.setRequestProperty("User-Agent", USER_AGENT);
+ in = new BufferedInputStream(urlConnection.getInputStream());
+ json = convertStreamToString(in);
+ } finally {
+ if (urlConnection != null)
+ urlConnection.disconnect();
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ Log.e(LOGTAG, "error", e);
+ }
+ }
+ }
+
+ if (json != null) {
+ /*
+ * Sample result:
+ * ["foo",["food network","foothill college","foot locker",...]]
+ */
+ JSONArray results = new JSONArray(json);
+ JSONArray jsonSuggestions = results.getJSONArray(1);
+
+ int added = 0;
+ for (int i = 0; (i < jsonSuggestions.length()) && (added < mMaxResults); i++) {
+ String suggestion = jsonSuggestions.getString(i);
+ if (!suggestion.equalsIgnoreCase(query)) {
+ suggestions.add(suggestion);
+ added++;
+ }
+ }
+ } else {
+ Log.e(LOGTAG, "Suggestion query failed");
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error", e);
+ }
+
+ mPrevQuery = query;
+ mPrevResults = suggestions;
+ return suggestions;
+ }
+
+ private String convertStreamToString(java.io.InputStream is) {
+ try {
+ return new java.util.Scanner(is).useDelimiter("\\A").next();
+ } catch (java.util.NoSuchElementException e) {
+ return "";
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Tab.java b/mobile/android/base/java/org/mozilla/gecko/Tab.java
new file mode 100644
index 000000000..6010a3dd9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Tab.java
@@ -0,0 +1,843 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Future;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.URLMetadata;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequestBuilder;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.reader.ReadingListHelper;
+import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.SiteLogins;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+
+public class Tab {
+ private static final String LOGTAG = "GeckoTab";
+
+ private static Pattern sColorPattern;
+ private final int mId;
+ private final BrowserDB mDB;
+ private long mLastUsed;
+ private String mUrl;
+ private String mBaseDomain;
+ private String mUserRequested; // The original url requested. May be typed by the user or sent by an extneral app for example.
+ private String mTitle;
+ private Bitmap mFavicon;
+ private String mFaviconUrl;
+ private String mApplicationId; // Intended to be null after explicit user action.
+
+ private IconRequestBuilder mIconRequestBuilder;
+ private Future<IconResponse> mRunningIconRequest;
+
+ private boolean mHasFeeds;
+ private boolean mHasOpenSearch;
+ private final SiteIdentity mSiteIdentity;
+ private SiteLogins mSiteLogins;
+ private BitmapDrawable mThumbnail;
+ private final int mParentId;
+ // Indicates the url was loaded from a source external to the app. This will be cleared
+ // when the user explicitly loads a new url (e.g. clicking a link is not explicit).
+ private final boolean mExternal;
+ private boolean mBookmark;
+ private int mFaviconLoadId;
+ private String mContentType;
+ private boolean mHasTouchListeners;
+ private final ArrayList<View> mPluginViews;
+ private int mState;
+ private Bitmap mThumbnailBitmap;
+ private boolean mDesktopMode;
+ private boolean mEnteringReaderMode;
+ private final Context mAppContext;
+ private ErrorType mErrorType = ErrorType.NONE;
+ private volatile int mLoadProgress;
+ private volatile int mRecordingCount;
+ private volatile boolean mIsAudioPlaying;
+ private volatile boolean mIsMediaPlaying;
+ private String mMostRecentHomePanel;
+ private boolean mShouldShowToolbarWithoutAnimationOnFirstSelection;
+
+ /*
+ * Bundle containing restore data for the panel referenced in mMostRecentHomePanel. This can be
+ * e.g. the most recent folder for the bookmarks panel, or any other state that should be
+ * persisted. This is then used e.g. when returning to homepanels via history.
+ */
+ private Bundle mMostRecentHomePanelData;
+
+ private int mHistoryIndex;
+ private int mHistorySize;
+ private boolean mCanDoBack;
+ private boolean mCanDoForward;
+
+ private boolean mIsEditing;
+ private final TabEditingState mEditingState = new TabEditingState();
+
+ // Will be true when tab is loaded from cache while device was offline.
+ private boolean mLoadedFromCache;
+
+ public static final int STATE_DELAYED = 0;
+ public static final int STATE_LOADING = 1;
+ public static final int STATE_SUCCESS = 2;
+ public static final int STATE_ERROR = 3;
+
+ public static final int LOAD_PROGRESS_INIT = 10;
+ public static final int LOAD_PROGRESS_START = 20;
+ public static final int LOAD_PROGRESS_LOCATION_CHANGE = 60;
+ public static final int LOAD_PROGRESS_LOADED = 80;
+ public static final int LOAD_PROGRESS_STOP = 100;
+
+ public enum ErrorType {
+ CERT_ERROR, // Pages with certificate problems
+ BLOCKED, // Pages blocked for phishing or malware warnings
+ NET_ERROR, // All other types of error
+ NONE // Non error pages
+ }
+
+ public Tab(Context context, int id, String url, boolean external, int parentId, String title) {
+ mAppContext = context.getApplicationContext();
+ mDB = BrowserDB.from(context);
+ mId = id;
+ mUrl = url;
+ mBaseDomain = "";
+ mUserRequested = "";
+ mExternal = external;
+ mParentId = parentId;
+ mTitle = title == null ? "" : title;
+ mSiteIdentity = new SiteIdentity();
+ mHistoryIndex = -1;
+ mContentType = "";
+ mPluginViews = new ArrayList<View>();
+ mState = shouldShowProgress(url) ? STATE_LOADING : STATE_SUCCESS;
+ mLoadProgress = LOAD_PROGRESS_INIT;
+ mIconRequestBuilder = Icons.with(mAppContext).pageUrl(mUrl);
+
+ updateBookmark();
+ }
+
+ private ContentResolver getContentResolver() {
+ return mAppContext.getContentResolver();
+ }
+
+ public void onDestroy() {
+ Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.CLOSED);
+ }
+
+ @RobocopTarget
+ public int getId() {
+ return mId;
+ }
+
+ public synchronized void onChange() {
+ mLastUsed = System.currentTimeMillis();
+ }
+
+ public synchronized long getLastUsed() {
+ return mLastUsed;
+ }
+
+ public int getParentId() {
+ return mParentId;
+ }
+
+ // may be null if user-entered query hasn't yet been resolved to a URI
+ public synchronized String getURL() {
+ return mUrl;
+ }
+
+ // mUserRequested should never be null, but it may be an empty string
+ public synchronized String getUserRequested() {
+ return mUserRequested;
+ }
+
+ // mTitle should never be null, but it may be an empty string
+ public synchronized String getTitle() {
+ return mTitle;
+ }
+
+ public String getDisplayTitle() {
+ if (mTitle != null && mTitle.length() > 0) {
+ return mTitle;
+ }
+
+ return mUrl;
+ }
+
+ /**
+ * Returns the base domain of the loaded uri. Note that if the page is
+ * a Reader mode uri, the base domain returned is that of the original uri.
+ */
+ public String getBaseDomain() {
+ return mBaseDomain;
+ }
+
+ public Bitmap getFavicon() {
+ return mFavicon;
+ }
+
+ protected String getApplicationId() {
+ return mApplicationId;
+ }
+
+ protected void setApplicationId(final String applicationId) {
+ mApplicationId = applicationId;
+ }
+
+ public BitmapDrawable getThumbnail() {
+ return mThumbnail;
+ }
+
+ public String getMostRecentHomePanel() {
+ return mMostRecentHomePanel;
+ }
+
+ public Bundle getMostRecentHomePanelData() {
+ return mMostRecentHomePanelData;
+ }
+
+ public void setMostRecentHomePanel(String panelId) {
+ mMostRecentHomePanel = panelId;
+ mMostRecentHomePanelData = null;
+ }
+
+ public void setMostRecentHomePanelData(Bundle data) {
+ mMostRecentHomePanelData = data;
+ }
+
+ public Bitmap getThumbnailBitmap(int width, int height) {
+ if (mThumbnailBitmap != null) {
+ // Bug 787318 - Honeycomb has a bug with bitmap caching, we can't
+ // reuse the bitmap there.
+ boolean honeycomb = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB
+ && Build.VERSION.SDK_INT <= Build.VERSION_CODES.HONEYCOMB_MR2);
+ boolean sizeChange = mThumbnailBitmap.getWidth() != width
+ || mThumbnailBitmap.getHeight() != height;
+ if (honeycomb || sizeChange) {
+ mThumbnailBitmap = null;
+ }
+ }
+
+ if (mThumbnailBitmap == null) {
+ Bitmap.Config config = (GeckoAppShell.getScreenDepth() == 24) ?
+ Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565;
+ mThumbnailBitmap = Bitmap.createBitmap(width, height, config);
+ }
+
+ return mThumbnailBitmap;
+ }
+
+ public void updateThumbnail(final Bitmap b, final ThumbnailHelper.CachePolicy cachePolicy) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ if (b != null) {
+ try {
+ mThumbnail = new BitmapDrawable(mAppContext.getResources(), b);
+ if (mState == Tab.STATE_SUCCESS && cachePolicy == ThumbnailHelper.CachePolicy.STORE) {
+ saveThumbnailToDB(mDB);
+ } else {
+ // If the page failed to load, or requested that we not cache info about it, clear any previous
+ // thumbnails we've stored.
+ clearThumbnailFromDB(mDB);
+ }
+ } catch (OutOfMemoryError oom) {
+ Log.w(LOGTAG, "Unable to create/scale bitmap.", oom);
+ mThumbnail = null;
+ }
+ } else {
+ mThumbnail = null;
+ }
+
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.THUMBNAIL);
+ }
+ });
+ }
+
+ public synchronized String getFaviconURL() {
+ return mFaviconUrl;
+ }
+
+ public boolean hasFeeds() {
+ return mHasFeeds;
+ }
+
+ public boolean hasOpenSearch() {
+ return mHasOpenSearch;
+ }
+
+ public boolean hasLoadedFromCache() {
+ return mLoadedFromCache;
+ }
+
+ public SiteIdentity getSiteIdentity() {
+ return mSiteIdentity;
+ }
+
+ public void resetSiteIdentity() {
+ if (mSiteIdentity != null) {
+ mSiteIdentity.reset();
+ Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.SECURITY_CHANGE);
+ }
+ }
+
+ public SiteLogins getSiteLogins() {
+ return mSiteLogins;
+ }
+
+ public boolean isBookmark() {
+ return mBookmark;
+ }
+
+ public boolean isExternal() {
+ return mExternal;
+ }
+
+ public synchronized void updateURL(String url) {
+ if (url != null && url.length() > 0) {
+ mUrl = url;
+ }
+ }
+
+ public synchronized void updateUserRequested(String userRequested) {
+ mUserRequested = userRequested;
+ }
+
+ public void setErrorType(String type) {
+ if ("blocked".equals(type))
+ setErrorType(ErrorType.BLOCKED);
+ else if ("certerror".equals(type))
+ setErrorType(ErrorType.CERT_ERROR);
+ else if ("neterror".equals(type))
+ setErrorType(ErrorType.NET_ERROR);
+ else
+ setErrorType(ErrorType.NONE);
+ }
+
+ public void setErrorType(ErrorType type) {
+ mErrorType = type;
+ }
+
+ public void setMetadata(JSONObject metadata) {
+ if (metadata == null) {
+ return;
+ }
+
+ final ContentResolver cr = mAppContext.getContentResolver();
+ final URLMetadata urlMetadata = mDB.getURLMetadata();
+
+ final Map<String, Object> data = urlMetadata.fromJSON(metadata);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ urlMetadata.save(cr, data);
+ }
+ });
+ }
+
+ public ErrorType getErrorType() {
+ return mErrorType;
+ }
+
+ public void setContentType(String contentType) {
+ mContentType = (contentType == null) ? "" : contentType;
+ }
+
+ public String getContentType() {
+ return mContentType;
+ }
+
+ public int getHistoryIndex() {
+ return mHistoryIndex;
+ }
+
+ public int getHistorySize() {
+ return mHistorySize;
+ }
+
+ public synchronized void updateTitle(String title) {
+ // Keep the title unchanged while entering reader mode.
+ if (mEnteringReaderMode) {
+ return;
+ }
+
+ // If there was a title, but it hasn't changed, do nothing.
+ if (mTitle != null &&
+ TextUtils.equals(mTitle, title)) {
+ return;
+ }
+
+ mTitle = (title == null ? "" : title);
+ Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.TITLE);
+ }
+
+ public void setState(int state) {
+ mState = state;
+
+ if (mState != Tab.STATE_LOADING)
+ mEnteringReaderMode = false;
+ }
+
+ public int getState() {
+ return mState;
+ }
+
+ public void setHasTouchListeners(boolean aValue) {
+ mHasTouchListeners = aValue;
+ }
+
+ public boolean getHasTouchListeners() {
+ return mHasTouchListeners;
+ }
+
+ public synchronized void addFavicon(String faviconURL, int faviconSize, String mimeType) {
+ mIconRequestBuilder
+ .icon(IconDescriptor.createFavicon(faviconURL, faviconSize, mimeType))
+ .deferBuild();
+ }
+
+ public synchronized void addTouchicon(String iconUrl, int faviconSize, String mimeType) {
+ mIconRequestBuilder
+ .icon(IconDescriptor.createTouchicon(iconUrl, faviconSize, mimeType))
+ .deferBuild();
+ }
+
+ public void loadFavicon() {
+ // Static Favicons never change
+ if (AboutPages.isBuiltinIconPage(mUrl) && mFavicon != null) {
+ return;
+ }
+
+ mRunningIconRequest = mIconRequestBuilder
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ mFavicon = response.getBitmap();
+
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.FAVICON);
+ }
+ });
+ }
+
+ public synchronized void clearFavicon() {
+ // Cancel any ongoing favicon load (if we never finished downloading the old favicon before
+ // we changed page).
+ if (mRunningIconRequest != null) {
+ mRunningIconRequest.cancel(true);
+ }
+
+ // Keep the favicon unchanged while entering reader mode
+ if (mEnteringReaderMode)
+ return;
+
+ mFavicon = null;
+ mFaviconUrl = null;
+ }
+
+ public void setHasFeeds(boolean hasFeeds) {
+ mHasFeeds = hasFeeds;
+ }
+
+ public void setHasOpenSearch(boolean hasOpenSearch) {
+ mHasOpenSearch = hasOpenSearch;
+ }
+
+ public void setLoadedFromCache(boolean loadedFromCache) {
+ mLoadedFromCache = loadedFromCache;
+ }
+
+ public void updateIdentityData(JSONObject identityData) {
+ mSiteIdentity.update(identityData);
+ }
+
+ public void setSiteLogins(SiteLogins siteLogins) {
+ mSiteLogins = siteLogins;
+ }
+
+ void updateBookmark() {
+ if (getURL() == null) {
+ return;
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+ final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(url);
+
+ mBookmark = mDB.isBookmark(getContentResolver(), pageUrl);
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.MENU_UPDATED);
+ }
+ });
+ }
+
+ public void addBookmark() {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+
+ final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(getURL());
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ mDB.addBookmark(getContentResolver(), mTitle, pageUrl);
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.BOOKMARK_ADDED);
+ }
+ });
+
+ if (AboutPages.isAboutReader(url)) {
+ ReadingListHelper.cacheReaderItem(pageUrl, mId, mAppContext);
+ }
+ }
+
+ public void removeBookmark() {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+
+ final String pageUrl = ReaderModeUtils.stripAboutReaderUrl(getURL());
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ mDB.removeBookmarksWithURL(getContentResolver(), pageUrl);
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.BOOKMARK_REMOVED);
+ }
+ });
+
+ // We need to ensure we remove readercached items here - we could have switched out of readermode
+ // before unbookmarking, so we don't necessarily have an about:reader URL here.
+ ReadingListHelper.removeCachedReaderItem(pageUrl, mAppContext);
+ }
+
+ public boolean isEnteringReaderMode() {
+ return mEnteringReaderMode;
+ }
+
+ public void doReload(boolean bypassCache) {
+ GeckoAppShell.notifyObservers("Session:Reload", "{\"bypassCache\":" + String.valueOf(bypassCache) + "}");
+ }
+
+ // Our version of nsSHistory::GetCanGoBack
+ public boolean canDoBack() {
+ return mCanDoBack;
+ }
+
+ public boolean doBack() {
+ if (!canDoBack())
+ return false;
+
+ GeckoAppShell.notifyObservers("Session:Back", "");
+ return true;
+ }
+
+ public void doStop() {
+ GeckoAppShell.notifyObservers("Session:Stop", "");
+ }
+
+ // Our version of nsSHistory::GetCanGoForward
+ public boolean canDoForward() {
+ return mCanDoForward;
+ }
+
+ public boolean doForward() {
+ if (!canDoForward())
+ return false;
+
+ GeckoAppShell.notifyObservers("Session:Forward", "");
+ return true;
+ }
+
+ void handleLocationChange(JSONObject message) throws JSONException {
+ final String uri = message.getString("uri");
+ final String oldUrl = getURL();
+ final boolean sameDocument = message.getBoolean("sameDocument");
+ mEnteringReaderMode = ReaderModeUtils.isEnteringReaderMode(oldUrl, uri);
+ mHistoryIndex = message.getInt("historyIndex");
+ mHistorySize = message.getInt("historySize");
+ mCanDoBack = message.getBoolean("canGoBack");
+ mCanDoForward = message.getBoolean("canGoForward");
+
+ if (!TextUtils.equals(oldUrl, uri)) {
+ updateURL(uri);
+ updateBookmark();
+ if (!sameDocument) {
+ // We can unconditionally clear the favicon and title here: we
+ // already filtered both cases in which this was a (pseudo-)
+ // spurious location change, so we're definitely loading a new
+ // page.
+ clearFavicon();
+
+ // Start to build a new request to load a favicon.
+ mIconRequestBuilder = Icons.with(mAppContext)
+ .pageUrl(uri);
+
+ // Load local static Favicons immediately
+ if (AboutPages.isBuiltinIconPage(uri)) {
+ loadFavicon();
+ }
+
+ updateTitle(null);
+ }
+ }
+
+ if (sameDocument) {
+ // We can get a location change event for the same document with an anchor tag
+ // Notify listeners so that buttons like back or forward will update themselves
+ Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.LOCATION_CHANGE, oldUrl);
+ return;
+ }
+
+ setContentType(message.getString("contentType"));
+ updateUserRequested(message.getString("userRequested"));
+ mBaseDomain = message.optString("baseDomain");
+
+ setHasFeeds(false);
+ setHasOpenSearch(false);
+ mSiteIdentity.reset();
+ setSiteLogins(null);
+ setHasTouchListeners(false);
+ setErrorType(ErrorType.NONE);
+ setLoadProgressIfLoading(LOAD_PROGRESS_LOCATION_CHANGE);
+
+ Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.LOCATION_CHANGE, oldUrl);
+ }
+
+ private static boolean shouldShowProgress(final String url) {
+ return !AboutPages.isAboutPage(url);
+ }
+
+ void handleDocumentStart(boolean restoring, String url) {
+ setLoadProgress(LOAD_PROGRESS_START);
+ setState((!restoring && shouldShowProgress(url)) ? STATE_LOADING : STATE_SUCCESS);
+ mSiteIdentity.reset();
+ }
+
+ void handleDocumentStop(boolean success) {
+ setState(success ? STATE_SUCCESS : STATE_ERROR);
+
+ final String oldURL = getURL();
+ final Tab tab = this;
+ tab.setLoadProgress(LOAD_PROGRESS_STOP);
+
+ ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ // tab.getURL() may return null
+ if (!TextUtils.equals(oldURL, getURL()))
+ return;
+
+ ThumbnailHelper.getInstance().getAndProcessThumbnailFor(tab);
+ }
+ }, 500);
+ }
+
+ void handleContentLoaded() {
+ setLoadProgressIfLoading(LOAD_PROGRESS_LOADED);
+ }
+
+ protected void saveThumbnailToDB(final BrowserDB db) {
+ final BitmapDrawable thumbnail = mThumbnail;
+ if (thumbnail == null) {
+ return;
+ }
+
+ try {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+
+ db.updateThumbnailForUrl(getContentResolver(), url, thumbnail);
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ public void loadThumbnailFromDB(final BrowserDB db) {
+ try {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+
+ byte[] thumbnail = db.getThumbnailForUrl(getContentResolver(), url);
+ if (thumbnail == null) {
+ return;
+ }
+
+ Bitmap bitmap = BitmapUtils.decodeByteArray(thumbnail);
+ mThumbnail = new BitmapDrawable(mAppContext.getResources(), bitmap);
+
+ Tabs.getInstance().notifyListeners(Tab.this, Tabs.TabEvents.THUMBNAIL);
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ private void clearThumbnailFromDB(final BrowserDB db) {
+ try {
+ final String url = getURL();
+ if (url == null) {
+ return;
+ }
+
+ // Passing in a null thumbnail will delete the stored thumbnail for this url
+ db.updateThumbnailForUrl(getContentResolver(), url, null);
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ public void addPluginView(View view) {
+ mPluginViews.add(view);
+ }
+
+ public void removePluginView(View view) {
+ mPluginViews.remove(view);
+ }
+
+ public View[] getPluginViews() {
+ return mPluginViews.toArray(new View[mPluginViews.size()]);
+ }
+
+ public void setDesktopMode(boolean enabled) {
+ mDesktopMode = enabled;
+ }
+
+ public boolean getDesktopMode() {
+ return mDesktopMode;
+ }
+
+ public boolean isPrivate() {
+ return false;
+ }
+
+ /**
+ * Sets the tab load progress to the given percentage.
+ *
+ * @param progressPercentage Percentage to set progress to (0-100)
+ */
+ void setLoadProgress(int progressPercentage) {
+ mLoadProgress = progressPercentage;
+ }
+
+ /**
+ * Sets the tab load progress to the given percentage only if the tab is
+ * currently loading.
+ *
+ * about:neterror can trigger a STOP before other page load events (bug
+ * 976426), so any post-START events should make sure the page is loading
+ * before updating progress.
+ *
+ * @param progressPercentage Percentage to set progress to (0-100)
+ */
+ void setLoadProgressIfLoading(int progressPercentage) {
+ if (getState() == STATE_LOADING) {
+ setLoadProgress(progressPercentage);
+ }
+ }
+
+ /**
+ * Gets the tab load progress percentage.
+ *
+ * @return Current progress percentage
+ */
+ public int getLoadProgress() {
+ return mLoadProgress;
+ }
+
+ public void setRecording(boolean isRecording) {
+ if (isRecording) {
+ mRecordingCount++;
+ } else {
+ mRecordingCount--;
+ }
+ }
+
+ public boolean isRecording() {
+ return mRecordingCount > 0;
+ }
+
+ /**
+ * The "MediaPlaying" is used for controling media control interface and
+ * means the tab has playing media.
+ *
+ * @param isMediaPlaying the tab has any playing media or not
+ */
+ public void setIsMediaPlaying(boolean isMediaPlaying) {
+ mIsMediaPlaying = isMediaPlaying;
+ }
+
+ public boolean isMediaPlaying() {
+ return mIsMediaPlaying;
+ }
+
+ /**
+ * The "AudioPlaying" is used for showing the tab sound indicator and means
+ * the tab has playing media and the media is audible.
+ *
+ * @param isAudioPlaying the tab has any audible playing media or not
+ */
+ public void setIsAudioPlaying(boolean isAudioPlaying) {
+ mIsAudioPlaying = isAudioPlaying;
+ }
+
+ public boolean isAudioPlaying() {
+ return mIsAudioPlaying;
+ }
+
+ public boolean isEditing() {
+ return mIsEditing;
+ }
+
+ public void setIsEditing(final boolean isEditing) {
+ this.mIsEditing = isEditing;
+ }
+
+ public TabEditingState getEditingState() {
+ return mEditingState;
+ }
+
+ public void setShouldShowToolbarWithoutAnimationOnFirstSelection(final boolean shouldShowWithoutAnimation) {
+ mShouldShowToolbarWithoutAnimationOnFirstSelection = shouldShowWithoutAnimation;
+ }
+
+ public boolean getShouldShowToolbarWithoutAnimationOnFirstSelection() {
+ return mShouldShowToolbarWithoutAnimationOnFirstSelection;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Tabs.java b/mobile/android/base/java/org/mozilla/gecko/Tabs.java
new file mode 100644
index 000000000..c7e024fe0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Tabs.java
@@ -0,0 +1,1021 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import android.support.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.notifications.WhatsNewReceiver;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.OnAccountsUpdateListener;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.sqlite.SQLiteException;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.Browser;
+import android.support.v4.content.ContextCompat;
+import android.util.Log;
+
+public class Tabs implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoTabs";
+
+ // mOrder and mTabs are always of the same cardinality, and contain the same values.
+ private final CopyOnWriteArrayList<Tab> mOrder = new CopyOnWriteArrayList<Tab>();
+
+ // All writes to mSelectedTab must be synchronized on the Tabs instance.
+ // In general, it's preferred to always use selectTab()).
+ private volatile Tab mSelectedTab;
+
+ // All accesses to mTabs must be synchronized on the Tabs instance.
+ private final HashMap<Integer, Tab> mTabs = new HashMap<Integer, Tab>();
+
+ private AccountManager mAccountManager;
+ private OnAccountsUpdateListener mAccountListener;
+
+ public static final int LOADURL_NONE = 0;
+ public static final int LOADURL_NEW_TAB = 1 << 0;
+ public static final int LOADURL_USER_ENTERED = 1 << 1;
+ public static final int LOADURL_PRIVATE = 1 << 2;
+ public static final int LOADURL_PINNED = 1 << 3;
+ public static final int LOADURL_DELAY_LOAD = 1 << 4;
+ public static final int LOADURL_DESKTOP = 1 << 5;
+ public static final int LOADURL_BACKGROUND = 1 << 6;
+ /** Indicates the url has been specified by a source external to the app. */
+ public static final int LOADURL_EXTERNAL = 1 << 7;
+ /** Indicates the tab is the first shown after Firefox is hidden and restored. */
+ public static final int LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN = 1 << 8;
+
+ private static final long PERSIST_TABS_AFTER_MILLISECONDS = 1000 * 2;
+
+ public static final int INVALID_TAB_ID = -1;
+
+ private static final AtomicInteger sTabId = new AtomicInteger(0);
+ private volatile boolean mInitialTabsAdded;
+
+ private Context mAppContext;
+ private LayerView mLayerView;
+ private ContentObserver mBookmarksContentObserver;
+ private PersistTabsRunnable mPersistTabsRunnable;
+ private int mPrivateClearColor;
+
+ private static class PersistTabsRunnable implements Runnable {
+ private final BrowserDB db;
+ private final Context context;
+ private final Iterable<Tab> tabs;
+
+ public PersistTabsRunnable(final Context context, Iterable<Tab> tabsInOrder) {
+ this.context = context;
+ this.db = BrowserDB.from(context);
+ this.tabs = tabsInOrder;
+ }
+
+ @Override
+ public void run() {
+ try {
+ db.getTabsAccessor().persistLocalTabs(context.getContentResolver(), tabs);
+ } catch (SQLiteException e) {
+ Log.w(LOGTAG, "Error persisting local tabs", e);
+ }
+ }
+ };
+
+ private Tabs() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ "Tab:Added",
+ "Tab:Close",
+ "Tab:Select",
+ "Content:LocationChange",
+ "Content:SecurityChange",
+ "Content:StateChange",
+ "Content:LoadError",
+ "Content:PageShow",
+ "DOMTitleChanged",
+ "Link:Favicon",
+ "Link:Touchicon",
+ "Link:Feed",
+ "Link:OpenSearch",
+ "DesktopMode:Changed",
+ "Tab:StreamStart",
+ "Tab:StreamStop",
+ "Tab:AudioPlayingChange",
+ "Tab:MediaPlaybackChange");
+
+ mPrivateClearColor = Color.RED;
+
+ }
+
+ public synchronized void attachToContext(Context context, LayerView layerView) {
+ final Context appContext = context.getApplicationContext();
+ if (mAppContext == appContext) {
+ return;
+ }
+
+ if (mAppContext != null) {
+ // This should never happen.
+ Log.w(LOGTAG, "The application context has changed!");
+ }
+
+ mAppContext = appContext;
+ mLayerView = layerView;
+ mPrivateClearColor = ContextCompat.getColor(context, R.color.tabs_tray_grey_pressed);
+ mAccountManager = AccountManager.get(appContext);
+
+ mAccountListener = new OnAccountsUpdateListener() {
+ @Override
+ public void onAccountsUpdated(Account[] accounts) {
+ queuePersistAllTabs();
+ }
+ };
+
+ // The listener will run on the background thread (see 2nd argument).
+ mAccountManager.addOnAccountsUpdatedListener(mAccountListener, ThreadUtils.getBackgroundHandler(), false);
+
+ if (mBookmarksContentObserver != null) {
+ // It's safe to use the db here since we aren't doing any I/O.
+ final GeckoProfile profile = GeckoProfile.get(context);
+ BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver);
+ }
+ }
+
+ /**
+ * Gets the tab count corresponding to the private state of the selected
+ * tab.
+ *
+ * If the selected tab is a non-private tab, this will return the number of
+ * non-private tabs; likewise, if this is a private tab, this will return
+ * the number of private tabs.
+ *
+ * @return the number of tabs in the current private state
+ */
+ public synchronized int getDisplayCount() {
+ // Once mSelectedTab is non-null, it cannot be null for the remainder
+ // of the object's lifetime.
+ boolean getPrivate = mSelectedTab != null && mSelectedTab.isPrivate();
+ int count = 0;
+ for (Tab tab : mOrder) {
+ if (tab.isPrivate() == getPrivate) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public int isOpen(String url) {
+ for (Tab tab : mOrder) {
+ if (tab.getURL().equals(url)) {
+ return tab.getId();
+ }
+ }
+ return -1;
+ }
+
+ // Must be synchronized to avoid racing on mBookmarksContentObserver.
+ private void lazyRegisterBookmarkObserver() {
+ if (mBookmarksContentObserver == null) {
+ mBookmarksContentObserver = new ContentObserver(null) {
+ @Override
+ public void onChange(boolean selfChange) {
+ for (Tab tab : mOrder) {
+ tab.updateBookmark();
+ }
+ }
+ };
+
+ // It's safe to use the db here since we aren't doing any I/O.
+ final GeckoProfile profile = GeckoProfile.get(mAppContext);
+ BrowserDB.from(profile).registerBookmarkObserver(getContentResolver(), mBookmarksContentObserver);
+ }
+ }
+
+ private Tab addTab(int id, String url, boolean external, int parentId, String title, boolean isPrivate, int tabIndex) {
+ final Tab tab = isPrivate ? new PrivateTab(mAppContext, id, url, external, parentId, title) :
+ new Tab(mAppContext, id, url, external, parentId, title);
+ synchronized (this) {
+ lazyRegisterBookmarkObserver();
+ mTabs.put(id, tab);
+
+ if (tabIndex > -1) {
+ mOrder.add(tabIndex, tab);
+ } else {
+ mOrder.add(tab);
+ }
+ }
+
+ // Suppress the ADDED event to prevent animation of tabs created via session restore.
+ if (mInitialTabsAdded) {
+ notifyListeners(tab, TabEvents.ADDED,
+ Integer.toString(getPrivacySpecificTabIndex(tabIndex, isPrivate)));
+ }
+
+ return tab;
+ }
+
+ // Return the index, among those tabs whose privacy setting matches isPrivate, of the tab at
+ // position index in mOrder. Returns -1, for "new last tab", when index is -1.
+ private int getPrivacySpecificTabIndex(int index, boolean isPrivate) {
+ int privacySpecificIndex = -1;
+ for (int i = 0; i <= index; i++) {
+ final Tab tab = mOrder.get(i);
+ if (tab.isPrivate() == isPrivate) {
+ privacySpecificIndex++;
+ }
+ }
+ return privacySpecificIndex;
+ }
+
+ public synchronized void removeTab(int id) {
+ if (mTabs.containsKey(id)) {
+ Tab tab = getTab(id);
+ mOrder.remove(tab);
+ mTabs.remove(id);
+ }
+ }
+
+ public synchronized Tab selectTab(int id) {
+ if (!mTabs.containsKey(id))
+ return null;
+
+ final Tab oldTab = getSelectedTab();
+ final Tab tab = mTabs.get(id);
+
+ // This avoids a NPE below, but callers need to be careful to
+ // handle this case.
+ if (tab == null || oldTab == tab) {
+ return tab;
+ }
+
+ mSelectedTab = tab;
+ notifyListeners(tab, TabEvents.SELECTED);
+
+ if (mLayerView != null) {
+ mLayerView.setClearColor(getTabColor(tab));
+ }
+
+ if (oldTab != null) {
+ notifyListeners(oldTab, TabEvents.UNSELECTED);
+ }
+
+ // Pass a message to Gecko to update tab state in BrowserApp.
+ GeckoAppShell.notifyObservers("Tab:Selected", String.valueOf(tab.getId()));
+ return tab;
+ }
+
+ public synchronized boolean selectLastTab() {
+ if (mOrder.isEmpty()) {
+ return false;
+ }
+
+ selectTab(mOrder.get(mOrder.size() - 1).getId());
+ return true;
+ }
+
+ private int getIndexOf(Tab tab) {
+ return mOrder.lastIndexOf(tab);
+ }
+
+ private Tab getNextTabFrom(Tab tab, boolean getPrivate) {
+ int numTabs = mOrder.size();
+ int index = getIndexOf(tab);
+ for (int i = index + 1; i < numTabs; i++) {
+ Tab next = mOrder.get(i);
+ if (next.isPrivate() == getPrivate) {
+ return next;
+ }
+ }
+ return null;
+ }
+
+ private Tab getPreviousTabFrom(Tab tab, boolean getPrivate) {
+ int index = getIndexOf(tab);
+ for (int i = index - 1; i >= 0; i--) {
+ Tab prev = mOrder.get(i);
+ if (prev.isPrivate() == getPrivate) {
+ return prev;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Gets the selected tab.
+ *
+ * The selected tab can be null if we're doing a session restore after a
+ * crash and Gecko isn't ready yet.
+ *
+ * @return the selected tab, or null if no tabs exist
+ */
+ @Nullable
+ public Tab getSelectedTab() {
+ return mSelectedTab;
+ }
+
+ public boolean isSelectedTab(Tab tab) {
+ return tab != null && tab == mSelectedTab;
+ }
+
+ public boolean isSelectedTabId(int tabId) {
+ final Tab selected = mSelectedTab;
+ return selected != null && selected.getId() == tabId;
+ }
+
+ @RobocopTarget
+ public synchronized Tab getTab(int id) {
+ if (id == -1)
+ return null;
+
+ if (mTabs.size() == 0)
+ return null;
+
+ if (!mTabs.containsKey(id))
+ return null;
+
+ return mTabs.get(id);
+ }
+
+ public synchronized Tab getTabForApplicationId(final String applicationId) {
+ if (applicationId == null) {
+ return null;
+ }
+
+ for (final Tab tab : mOrder) {
+ if (applicationId.equals(tab.getApplicationId())) {
+ return tab;
+ }
+ }
+
+ return null;
+ }
+
+ /** Close tab and then select the default next tab */
+ @RobocopTarget
+ public synchronized void closeTab(Tab tab) {
+ closeTab(tab, getNextTab(tab));
+ }
+
+ public synchronized void closeTab(Tab tab, Tab nextTab) {
+ closeTab(tab, nextTab, false);
+ }
+
+ public synchronized void closeTab(Tab tab, boolean showUndoToast) {
+ closeTab(tab, getNextTab(tab), showUndoToast);
+ }
+
+ /** Close tab and then select nextTab */
+ public synchronized void closeTab(final Tab tab, Tab nextTab, boolean showUndoToast) {
+ if (tab == null)
+ return;
+
+ int tabId = tab.getId();
+ removeTab(tabId);
+
+ if (nextTab == null) {
+ nextTab = loadUrl(AboutPages.HOME, LOADURL_NEW_TAB);
+ }
+
+ selectTab(nextTab.getId());
+
+ tab.onDestroy();
+
+ final JSONObject args = new JSONObject();
+ try {
+ args.put("tabId", String.valueOf(tabId));
+ args.put("showUndoToast", showUndoToast);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error building Tab:Closed arguments: " + e);
+ }
+
+ // Pass a message to Gecko to update tab state in BrowserApp
+ GeckoAppShell.notifyObservers("Tab:Closed", args.toString());
+ }
+
+ /** Return the tab that will be selected by default after this one is closed */
+ public Tab getNextTab(Tab tab) {
+ Tab selectedTab = getSelectedTab();
+ if (selectedTab != tab)
+ return selectedTab;
+
+ boolean getPrivate = tab.isPrivate();
+ Tab nextTab = getNextTabFrom(tab, getPrivate);
+ if (nextTab == null)
+ nextTab = getPreviousTabFrom(tab, getPrivate);
+ if (nextTab == null && getPrivate) {
+ // If there are no private tabs remaining, get the last normal tab
+ Tab lastTab = mOrder.get(mOrder.size() - 1);
+ if (!lastTab.isPrivate()) {
+ nextTab = lastTab;
+ } else {
+ nextTab = getPreviousTabFrom(lastTab, false);
+ }
+ }
+
+ Tab parent = getTab(tab.getParentId());
+ if (parent != null) {
+ // If the next tab is a sibling, switch to it. Otherwise go back to the parent.
+ if (nextTab != null && nextTab.getParentId() == tab.getParentId())
+ return nextTab;
+ else
+ return parent;
+ }
+ return nextTab;
+ }
+
+ public Iterable<Tab> getTabsInOrder() {
+ return mOrder;
+ }
+
+ /**
+ * @return the current GeckoApp instance, or throws if
+ * we aren't correctly initialized.
+ */
+ private synchronized Context getAppContext() {
+ if (mAppContext == null) {
+ throw new IllegalStateException("Tabs not initialized with a GeckoApp instance.");
+ }
+ return mAppContext;
+ }
+
+ public ContentResolver getContentResolver() {
+ return getAppContext().getContentResolver();
+ }
+
+ // Make Tabs a singleton class.
+ private static class TabsInstanceHolder {
+ private static final Tabs INSTANCE = new Tabs();
+ }
+
+ @RobocopTarget
+ public static Tabs getInstance() {
+ return Tabs.TabsInstanceHolder.INSTANCE;
+ }
+
+ // GeckoEventListener implementation
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ try {
+ // All other events handled below should contain a tabID property
+ int id = message.getInt("tabID");
+ Tab tab = getTab(id);
+
+ // "Tab:Added" is a special case because tab will be null if the tab was just added
+ if (event.equals("Tab:Added")) {
+ String url = message.isNull("uri") ? null : message.getString("uri");
+
+ if (message.getBoolean("cancelEditMode")) {
+ final Tab oldTab = getSelectedTab();
+ if (oldTab != null) {
+ oldTab.setIsEditing(false);
+ }
+ }
+
+ if (message.getBoolean("stub")) {
+ if (tab == null) {
+ // Tab was already closed; abort
+ return;
+ }
+ } else {
+ tab = addTab(id, url, message.getBoolean("external"),
+ message.getInt("parentId"),
+ message.getString("title"),
+ message.getBoolean("isPrivate"),
+ message.getInt("tabIndex"));
+ // If we added the tab as a stub, we should have already
+ // selected it, so ignore this flag for stubbed tabs.
+ if (message.getBoolean("selected"))
+ selectTab(id);
+ }
+
+ if (message.getBoolean("delayLoad"))
+ tab.setState(Tab.STATE_DELAYED);
+ if (message.getBoolean("desktopMode"))
+ tab.setDesktopMode(true);
+ return;
+ }
+
+ // Tab was already closed; abort
+ if (tab == null)
+ return;
+
+ if (event.equals("Tab:Close")) {
+ closeTab(tab);
+ } else if (event.equals("Tab:Select")) {
+ selectTab(tab.getId());
+ } else if (event.equals("Content:LocationChange")) {
+ tab.handleLocationChange(message);
+ } else if (event.equals("Content:SecurityChange")) {
+ tab.updateIdentityData(message.getJSONObject("identity"));
+ notifyListeners(tab, TabEvents.SECURITY_CHANGE);
+ } else if (event.equals("Content:StateChange")) {
+ int state = message.getInt("state");
+ if ((state & GeckoAppShell.WPL_STATE_IS_NETWORK) != 0) {
+ if ((state & GeckoAppShell.WPL_STATE_START) != 0) {
+ boolean restoring = message.getBoolean("restoring");
+ tab.handleDocumentStart(restoring, message.getString("uri"));
+ notifyListeners(tab, Tabs.TabEvents.START);
+ } else if ((state & GeckoAppShell.WPL_STATE_STOP) != 0) {
+ tab.handleDocumentStop(message.getBoolean("success"));
+ notifyListeners(tab, Tabs.TabEvents.STOP);
+ }
+ }
+ } else if (event.equals("Content:LoadError")) {
+ tab.handleContentLoaded();
+ notifyListeners(tab, Tabs.TabEvents.LOAD_ERROR);
+ } else if (event.equals("Content:PageShow")) {
+ tab.setLoadedFromCache(message.getBoolean("fromCache"));
+ tab.updateUserRequested(message.getString("userRequested"));
+ notifyListeners(tab, TabEvents.PAGE_SHOW);
+ } else if (event.equals("DOMTitleChanged")) {
+ tab.updateTitle(message.getString("title"));
+ } else if (event.equals("Link:Favicon")) {
+ // Add the favicon to the set of available icons for this tab.
+
+ tab.addFavicon(message.getString("href"), message.getInt("size"), message.getString("mime"));
+
+ // Load the favicon. If the tab is still loading, we actually do the load once the
+ // page has loaded, in an attempt to prevent the favicon load from having a
+ // detrimental effect on page load time.
+ if (tab.getState() != Tab.STATE_LOADING) {
+ tab.loadFavicon();
+ }
+ } else if (event.equals("Link:Touchicon")) {
+ tab.addTouchicon(message.getString("href"), message.getInt("size"), message.getString("mime"));
+ } else if (event.equals("Link:Feed")) {
+ tab.setHasFeeds(true);
+ notifyListeners(tab, TabEvents.LINK_FEED);
+ } else if (event.equals("Link:OpenSearch")) {
+ boolean visible = message.getBoolean("visible");
+ tab.setHasOpenSearch(visible);
+ } else if (event.equals("DesktopMode:Changed")) {
+ tab.setDesktopMode(message.getBoolean("desktopMode"));
+ notifyListeners(tab, TabEvents.DESKTOP_MODE_CHANGE);
+ } else if (event.equals("Tab:StreamStart")) {
+ tab.setRecording(true);
+ notifyListeners(tab, TabEvents.RECORDING_CHANGE);
+ } else if (event.equals("Tab:StreamStop")) {
+ tab.setRecording(false);
+ notifyListeners(tab, TabEvents.RECORDING_CHANGE);
+ } else if (event.equals("Tab:AudioPlayingChange")) {
+ tab.setIsAudioPlaying(message.getBoolean("isAudioPlaying"));
+ notifyListeners(tab, TabEvents.AUDIO_PLAYING_CHANGE);
+ } else if (event.equals("Tab:MediaPlaybackChange")) {
+ final String status = message.getString("status");
+ if (status.equals("resume")) {
+ notifyListeners(tab, TabEvents.MEDIA_PLAYING_RESUME);
+ } else {
+ tab.setIsMediaPlaying(status.equals("start"));
+ notifyListeners(tab, TabEvents.MEDIA_PLAYING_CHANGE);
+ }
+ }
+
+ } catch (Exception e) {
+ Log.w(LOGTAG, "handleMessage threw for " + event, e);
+ }
+ }
+
+ public void refreshThumbnails() {
+ final BrowserDB db = BrowserDB.from(mAppContext);
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ for (final Tab tab : mOrder) {
+ if (tab.getThumbnail() == null) {
+ tab.loadThumbnailFromDB(db);
+ }
+ }
+ }
+ });
+ }
+
+ public interface OnTabsChangedListener {
+ void onTabChanged(Tab tab, TabEvents msg, String data);
+ }
+
+ private static final List<OnTabsChangedListener> TABS_CHANGED_LISTENERS = new CopyOnWriteArrayList<OnTabsChangedListener>();
+
+ public static void registerOnTabsChangedListener(OnTabsChangedListener listener) {
+ TABS_CHANGED_LISTENERS.add(listener);
+ }
+
+ public static void unregisterOnTabsChangedListener(OnTabsChangedListener listener) {
+ TABS_CHANGED_LISTENERS.remove(listener);
+ }
+
+ public enum TabEvents {
+ CLOSED,
+ START,
+ LOADED,
+ LOAD_ERROR,
+ STOP,
+ FAVICON,
+ THUMBNAIL,
+ TITLE,
+ SELECTED,
+ UNSELECTED,
+ ADDED,
+ RESTORED,
+ LOCATION_CHANGE,
+ MENU_UPDATED,
+ PAGE_SHOW,
+ LINK_FEED,
+ SECURITY_CHANGE,
+ DESKTOP_MODE_CHANGE,
+ RECORDING_CHANGE,
+ BOOKMARK_ADDED,
+ BOOKMARK_REMOVED,
+ AUDIO_PLAYING_CHANGE,
+ OPENED_FROM_TABS_TRAY,
+ MEDIA_PLAYING_CHANGE,
+ MEDIA_PLAYING_RESUME
+ }
+
+ public void notifyListeners(Tab tab, TabEvents msg) {
+ notifyListeners(tab, msg, "");
+ }
+
+ public void notifyListeners(final Tab tab, final TabEvents msg, final String data) {
+ if (tab == null &&
+ msg != TabEvents.RESTORED) {
+ throw new IllegalArgumentException("onTabChanged:" + msg + " must specify a tab.");
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ onTabChanged(tab, msg, data);
+
+ if (TABS_CHANGED_LISTENERS.isEmpty()) {
+ return;
+ }
+
+ Iterator<OnTabsChangedListener> items = TABS_CHANGED_LISTENERS.iterator();
+ while (items.hasNext()) {
+ items.next().onTabChanged(tab, msg, data);
+ }
+ }
+ });
+ }
+
+ private void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
+ switch (msg) {
+ // We want the tab record to have an accurate favicon, so queue
+ // the persisting of tabs when it changes.
+ case FAVICON:
+ case LOCATION_CHANGE:
+ queuePersistAllTabs();
+ break;
+ case RESTORED:
+ mInitialTabsAdded = true;
+ break;
+
+ // When one tab is deselected, another one is always selected, so only
+ // queue a single persist operation. When tabs are added/closed, they
+ // are also selected/unselected, so it would be redundant to also listen
+ // for ADDED/CLOSED events.
+ case SELECTED:
+ if (mLayerView != null) {
+ mLayerView.setSurfaceBackgroundColor(getTabColor(tab));
+ mLayerView.setPaintState(LayerView.PAINT_START);
+ }
+ queuePersistAllTabs();
+ case UNSELECTED:
+ tab.onChange();
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Queues a request to persist tabs after PERSIST_TABS_AFTER_MILLISECONDS
+ * milliseconds have elapsed. If any existing requests are already queued then
+ * those requests are removed.
+ */
+ private void queuePersistAllTabs() {
+ final Handler backgroundHandler = ThreadUtils.getBackgroundHandler();
+
+ // Note: Its safe to modify the runnable here because all of the callers are on the same thread.
+ if (mPersistTabsRunnable != null) {
+ backgroundHandler.removeCallbacks(mPersistTabsRunnable);
+ mPersistTabsRunnable = null;
+ }
+
+ mPersistTabsRunnable = new PersistTabsRunnable(mAppContext, getTabsInOrder());
+ backgroundHandler.postDelayed(mPersistTabsRunnable, PERSIST_TABS_AFTER_MILLISECONDS);
+ }
+
+ /**
+ * Looks for an open tab with the given URL.
+ * @param url the URL of the tab we're looking for
+ *
+ * @return first Tab with the given URL, or null if there is no such tab.
+ */
+ public Tab getFirstTabForUrl(String url) {
+ return getFirstTabForUrlHelper(url, null);
+ }
+
+ /**
+ * Looks for an open tab with the given URL and private state.
+ * @param url the URL of the tab we're looking for
+ * @param isPrivate if true, only look for tabs that are private. if false,
+ * only look for tabs that are non-private.
+ *
+ * @return first Tab with the given URL, or null if there is no such tab.
+ */
+ public Tab getFirstTabForUrl(String url, boolean isPrivate) {
+ return getFirstTabForUrlHelper(url, isPrivate);
+ }
+
+ private Tab getFirstTabForUrlHelper(String url, Boolean isPrivate) {
+ if (url == null) {
+ return null;
+ }
+
+ for (Tab tab : mOrder) {
+ if (isPrivate != null && isPrivate != tab.isPrivate()) {
+ continue;
+ }
+ if (url.equals(tab.getURL())) {
+ return tab;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Looks for a reader mode enabled open tab with the given URL and private
+ * state.
+ *
+ * @param url
+ * The URL of the tab we're looking for. The url parameter can be
+ * the actual article URL or the reader mode article URL.
+ * @param isPrivate
+ * If true, only look for tabs that are private. If false, only
+ * look for tabs that are not private.
+ *
+ * @return The first Tab with the given URL, or null if there is no such
+ * tab.
+ */
+ public Tab getFirstReaderTabForUrl(String url, boolean isPrivate) {
+ if (url == null) {
+ return null;
+ }
+
+ url = ReaderModeUtils.stripAboutReaderUrl(url);
+
+ for (Tab tab : mOrder) {
+ if (isPrivate != tab.isPrivate()) {
+ continue;
+ }
+ String tabUrl = tab.getURL();
+ if (AboutPages.isAboutReader(tabUrl)) {
+ tabUrl = ReaderModeUtils.stripAboutReaderUrl(tabUrl);
+ if (url.equals(tabUrl)) {
+ return tab;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Loads a tab with the given URL in the currently selected tab.
+ *
+ * @param url URL of page to load, or search term used if searchEngine is given
+ */
+ @RobocopTarget
+ public Tab loadUrl(String url) {
+ return loadUrl(url, LOADURL_NONE);
+ }
+
+ /**
+ * Loads a tab with the given URL.
+ *
+ * @param url URL of page to load, or search term used if searchEngine is given
+ * @param flags flags used to load tab
+ *
+ * @return the Tab if a new one was created; null otherwise
+ */
+ @RobocopTarget
+ public Tab loadUrl(String url, int flags) {
+ return loadUrl(url, null, -1, null, flags);
+ }
+
+ public Tab loadUrlWithIntentExtras(final String url, final SafeIntent intent, final int flags) {
+ // We can't directly create a listener to tell when the user taps on the "What's new"
+ // notification, so we use this intent handling as a signal that they tapped the notification.
+ if (intent.getBooleanExtra(WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION, false)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION,
+ WhatsNewReceiver.EXTRA_WHATSNEW_NOTIFICATION);
+ }
+
+ // Note: we don't get the URL from the intent so the calling
+ // method has the opportunity to change the URL if applicable.
+ return loadUrl(url, null, -1, intent, flags);
+ }
+
+ public Tab loadUrl(final String url, final String searchEngine, final int parentId, final int flags) {
+ return loadUrl(url, searchEngine, parentId, null, flags);
+ }
+
+ /**
+ * Loads a tab with the given URL.
+ *
+ * @param url URL of page to load, or search term used if searchEngine is given
+ * @param searchEngine if given, the search engine with this name is used
+ * to search for the url string; if null, the URL is loaded directly
+ * @param parentId ID of this tab's parent, or -1 if it has no parent
+ * @param intent an intent whose extras are used to modify the request
+ * @param flags flags used to load tab
+ *
+ * @return the Tab if a new one was created; null otherwise
+ */
+ public Tab loadUrl(final String url, final String searchEngine, final int parentId,
+ final SafeIntent intent, final int flags) {
+ JSONObject args = new JSONObject();
+ Tab tabToSelect = null;
+ boolean delayLoad = (flags & LOADURL_DELAY_LOAD) != 0;
+
+ // delayLoad implies background tab
+ boolean background = delayLoad || (flags & LOADURL_BACKGROUND) != 0;
+
+ try {
+ boolean isPrivate = (flags & LOADURL_PRIVATE) != 0;
+ boolean userEntered = (flags & LOADURL_USER_ENTERED) != 0;
+ boolean desktopMode = (flags & LOADURL_DESKTOP) != 0;
+ boolean external = (flags & LOADURL_EXTERNAL) != 0;
+ final boolean isFirstShownAfterActivityUnhidden = (flags & LOADURL_FIRST_AFTER_ACTIVITY_UNHIDDEN) != 0;
+
+ args.put("url", url);
+ args.put("engine", searchEngine);
+ args.put("parentId", parentId);
+ args.put("userEntered", userEntered);
+ args.put("isPrivate", isPrivate);
+ args.put("pinned", (flags & LOADURL_PINNED) != 0);
+ args.put("desktopMode", desktopMode);
+
+ final boolean needsNewTab;
+ final String applicationId = (intent == null) ? null :
+ intent.getStringExtra(Browser.EXTRA_APPLICATION_ID);
+ if (applicationId == null) {
+ needsNewTab = (flags & LOADURL_NEW_TAB) != 0;
+ } else {
+ // If you modify this code, be careful that intent != null.
+ final boolean extraCreateNewTab = intent.getBooleanExtra(Browser.EXTRA_CREATE_NEW_TAB, false);
+ final Tab applicationTab = getTabForApplicationId(applicationId);
+ if (applicationTab == null || extraCreateNewTab) {
+ needsNewTab = true;
+ } else {
+ needsNewTab = false;
+ delayLoad = false;
+ background = false;
+
+ tabToSelect = applicationTab;
+ final int tabToSelectId = tabToSelect.getId();
+ args.put("tabID", tabToSelectId);
+
+ // This must be called before the "Tab:Load" event is sent. I think addTab gets
+ // away with it because having "newTab" == true causes the selected tab to be
+ // updated in JS for the "Tab:Load" event but "newTab" is false in our case.
+ // This makes me think the other selectTab is not necessary (bug 1160673).
+ //
+ // Note: that makes the later call redundant but selectTab exits early so I'm
+ // fine not adding the complex logic to avoid calling it again.
+ selectTab(tabToSelect.getId());
+ }
+ }
+
+ args.put("newTab", needsNewTab);
+ args.put("delayLoad", delayLoad);
+ args.put("selected", !background);
+
+ if (needsNewTab) {
+ int tabId = getNextTabId();
+ args.put("tabID", tabId);
+
+ // The URL is updated for the tab once Gecko responds with the
+ // Tab:Added message. We can preliminarily set the tab's URL as
+ // long as it's a valid URI.
+ String tabUrl = (url != null && Uri.parse(url).getScheme() != null) ? url : null;
+
+ // Add the new tab to the end of the tab order.
+ final int tabIndex = -1;
+
+ tabToSelect = addTab(tabId, tabUrl, external, parentId, url, isPrivate, tabIndex);
+ tabToSelect.setDesktopMode(desktopMode);
+ tabToSelect.setApplicationId(applicationId);
+ if (isFirstShownAfterActivityUnhidden) {
+ // We just opened Firefox so we want to show
+ // the toolbar but not animate it to avoid jank.
+ tabToSelect.setShouldShowToolbarWithoutAnimationOnFirstSelection(true);
+ }
+ }
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Error building JSON arguments for loadUrl.", e);
+ }
+
+ GeckoAppShell.notifyObservers("Tab:Load", args.toString());
+
+ if (tabToSelect == null) {
+ return null;
+ }
+
+ if (!delayLoad && !background) {
+ selectTab(tabToSelect.getId());
+ }
+
+ // Load favicon instantly for about:home page because it's already cached
+ if (AboutPages.isBuiltinIconPage(url)) {
+ tabToSelect.loadFavicon();
+ }
+
+ return tabToSelect;
+ }
+
+ public Tab addTab() {
+ return loadUrl(AboutPages.HOME, Tabs.LOADURL_NEW_TAB);
+ }
+
+ public Tab addPrivateTab() {
+ return loadUrl(AboutPages.PRIVATEBROWSING, Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_PRIVATE);
+ }
+
+ /**
+ * Open the url as a new tab, and mark the selected tab as its "parent".
+ *
+ * If the url is already open in a tab, the existing tab is selected.
+ * Use this for tabs opened by the browser chrome, so users can press the
+ * "Back" button to return to the previous tab.
+ *
+ * This method will open a new private tab if the currently selected tab
+ * is also private.
+ *
+ * @param url URL of page to load
+ */
+ public void loadUrlInTab(String url) {
+ Iterable<Tab> tabs = getTabsInOrder();
+ for (Tab tab : tabs) {
+ if (url.equals(tab.getURL())) {
+ selectTab(tab.getId());
+ return;
+ }
+ }
+
+ // getSelectedTab() can return null if no tab has been created yet
+ // (i.e., we're restoring a session after a crash). In these cases,
+ // don't mark any tabs as a parent.
+ int parentId = -1;
+ int flags = LOADURL_NEW_TAB;
+
+ final Tab selectedTab = getSelectedTab();
+ if (selectedTab != null) {
+ parentId = selectedTab.getId();
+ if (selectedTab.isPrivate()) {
+ flags = flags | LOADURL_PRIVATE;
+ }
+ }
+
+ loadUrl(url, null, parentId, flags);
+ }
+
+ /**
+ * Gets the next tab ID.
+ */
+ @JNITarget
+ public static int getNextTabId() {
+ return sTabId.getAndIncrement();
+ }
+
+ private int getTabColor(Tab tab) {
+ if (tab != null) {
+ return tab.isPrivate() ? mPrivateClearColor : Color.WHITE;
+ }
+
+ return Color.WHITE;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/Telemetry.java b/mobile/android/base/java/org/mozilla/gecko/Telemetry.java
new file mode 100644
index 000000000..342445bf2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/Telemetry.java
@@ -0,0 +1,246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.TelemetryContract.Event;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.TelemetryContract.Reason;
+import org.mozilla.gecko.TelemetryContract.Session;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+/**
+ * All telemetry times are relative to one of two clocks:
+ *
+ * * Real time since the device was booted, including deep sleep. Use this
+ * as a substitute for wall clock.
+ * * Uptime since the device was booted, excluding deep sleep. Use this to
+ * avoid timing a user activity when their phone is in their pocket!
+ *
+ * The majority of methods in this class are defined in terms of real time.
+ */
+@RobocopTarget
+public class Telemetry {
+ private static final String LOGTAG = "Telemetry";
+
+ @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko")
+ private static native void nativeAddHistogram(String name, int value);
+ @WrapForJNI(stubName = "AddKeyedHistogram", dispatchTo = "gecko")
+ private static native void nativeAddKeyedHistogram(String name, String key, int value);
+ @WrapForJNI(stubName = "StartUISession", dispatchTo = "gecko")
+ private static native void nativeStartUiSession(String name, long timestamp);
+ @WrapForJNI(stubName = "StopUISession", dispatchTo = "gecko")
+ private static native void nativeStopUiSession(String name, String reason, long timestamp);
+ @WrapForJNI(stubName = "AddUIEvent", dispatchTo = "gecko")
+ private static native void nativeAddUiEvent(String action, String method,
+ long timestamp, String extras);
+
+ public static long uptime() {
+ return SystemClock.uptimeMillis();
+ }
+
+ public static long realtime() {
+ return SystemClock.elapsedRealtime();
+ }
+
+ // Define new histograms in:
+ // toolkit/components/telemetry/Histograms.json
+ public static void addToHistogram(String name, int value) {
+ if (GeckoThread.isRunning()) {
+ nativeAddHistogram(name, value);
+ } else {
+ GeckoThread.queueNativeCall(Telemetry.class, "nativeAddHistogram",
+ String.class, name, value);
+ }
+ }
+
+ public static void addToKeyedHistogram(String name, String key, int value) {
+ if (GeckoThread.isRunning()) {
+ nativeAddKeyedHistogram(name, key, value);
+ } else {
+ GeckoThread.queueNativeCall(Telemetry.class, "nativeAddKeyedHistogram",
+ String.class, name, String.class, key, value);
+ }
+ }
+
+ public abstract static class Timer {
+ private final long mStartTime;
+ private final String mName;
+
+ private volatile boolean mHasFinished;
+ private volatile long mElapsed = -1;
+
+ protected abstract long now();
+
+ public Timer(String name) {
+ mName = name;
+ mStartTime = now();
+ }
+
+ public void cancel() {
+ mHasFinished = true;
+ }
+
+ public long getElapsed() {
+ return mElapsed;
+ }
+
+ public void stop() {
+ // Only the first stop counts.
+ if (mHasFinished) {
+ return;
+ }
+
+ mHasFinished = true;
+
+ final long elapsed = now() - mStartTime;
+ if (elapsed < 0) {
+ Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?");
+ return;
+ }
+
+ mElapsed = elapsed;
+ if (elapsed > Integer.MAX_VALUE) {
+ Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram.");
+ return;
+ }
+
+ addToHistogram(mName, (int) (elapsed));
+ }
+ }
+
+ public static class RealtimeTimer extends Timer {
+ public RealtimeTimer(String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return Telemetry.realtime();
+ }
+ }
+
+ public static class UptimeTimer extends Timer {
+ public UptimeTimer(String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return Telemetry.uptime();
+ }
+ }
+
+ public static void startUISession(final Session session, final String sessionNameSuffix) {
+ final String sessionName = getSessionName(session, sessionNameSuffix);
+
+ Log.d(LOGTAG, "StartUISession: " + sessionName);
+ if (GeckoThread.isRunning()) {
+ nativeStartUiSession(sessionName, realtime());
+ } else {
+ GeckoThread.queueNativeCall(Telemetry.class, "nativeStartUiSession",
+ String.class, sessionName, realtime());
+ }
+ }
+
+ public static void startUISession(final Session session) {
+ startUISession(session, null);
+ }
+
+ public static void stopUISession(final Session session, final String sessionNameSuffix,
+ final Reason reason) {
+ final String sessionName = getSessionName(session, sessionNameSuffix);
+
+ Log.d(LOGTAG, "StopUISession: " + sessionName + ", reason=" + reason);
+ if (GeckoThread.isRunning()) {
+ nativeStopUiSession(sessionName, reason.toString(), realtime());
+ } else {
+ GeckoThread.queueNativeCall(Telemetry.class, "nativeStopUiSession",
+ String.class, sessionName,
+ String.class, reason.toString(), realtime());
+ }
+ }
+
+ public static void stopUISession(final Session session, final Reason reason) {
+ stopUISession(session, null, reason);
+ }
+
+ public static void stopUISession(final Session session, final String sessionNameSuffix) {
+ stopUISession(session, sessionNameSuffix, Reason.NONE);
+ }
+
+ public static void stopUISession(final Session session) {
+ stopUISession(session, null, Reason.NONE);
+ }
+
+ private static String getSessionName(final Session session, final String sessionNameSuffix) {
+ if (sessionNameSuffix != null) {
+ return session.toString() + ":" + sessionNameSuffix;
+ } else {
+ return session.toString();
+ }
+ }
+
+ /**
+ * @param method A non-null method (if null is desired, consider using Method.NONE)
+ */
+ private static void sendUIEvent(final String eventName, final Method method,
+ final long timestamp, final String extras) {
+ if (method == null) {
+ throw new IllegalArgumentException("Expected non-null method - use Method.NONE?");
+ }
+
+ if (!AppConstants.RELEASE_OR_BETA) {
+ final String logString = "SendUIEvent: event = " + eventName + " method = " + method + " timestamp = " +
+ timestamp + " extras = " + extras;
+ Log.d(LOGTAG, logString);
+ }
+ if (GeckoThread.isRunning()) {
+ nativeAddUiEvent(eventName, method.toString(), timestamp, extras);
+ } else {
+ GeckoThread.queueNativeCall(Telemetry.class, "nativeAddUiEvent",
+ String.class, eventName, String.class, method.toString(),
+ timestamp, String.class, extras);
+ }
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final long timestamp,
+ final String extras) {
+ sendUIEvent(event.toString(), method, timestamp, extras);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final long timestamp) {
+ sendUIEvent(event, method, timestamp, null);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final String extras) {
+ sendUIEvent(event, method, realtime(), extras);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method) {
+ sendUIEvent(event, method, realtime(), null);
+ }
+
+ public static void sendUIEvent(final Event event) {
+ sendUIEvent(event, Method.NONE, realtime(), null);
+ }
+
+ /**
+ * Sends a UIEvent with the given status appended to the event name.
+ *
+ * This method is a slight bend of the Telemetry framework so chances
+ * are that you don't want to use this: please think really hard before you do.
+ *
+ * Intended for use with data policy notifications.
+ */
+ public static void sendUIEvent(final Event event, final boolean eventStatus) {
+ final String eventName = event + ":" + eventStatus;
+ sendUIEvent(eventName, Method.NONE, realtime(), null);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java b/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java
new file mode 100644
index 000000000..0c2051a9d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/TelemetryContract.java
@@ -0,0 +1,307 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+/**
+ * Holds data definitions for our UI Telemetry implementation.
+ *
+ * Note that enum values of "_TEST*" are reserved for testing and
+ * should not be changed without changing the associated tests.
+ *
+ * See mobile/android/base/docs/index.rst for a full dictionary.
+ */
+@RobocopTarget
+public interface TelemetryContract {
+
+ /**
+ * Holds event names. Intended for use with
+ * Telemetry.sendUIEvent() as the "action" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Event {
+ // Generic action, usually for tracking menu and toolbar actions.
+ ACTION("action.1"),
+
+ // Cancel a state, action, etc.
+ CANCEL("cancel.1"),
+
+ // Start casting a video.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ CAST("cast.1"),
+
+ // Editing an item.
+ EDIT("edit.1"),
+
+ // Launching (opening) an external application.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ LAUNCH("launch.1"),
+
+ // Loading a URL.
+ LOAD_URL("loadurl.1"),
+
+ LOCALE_BROWSER_RESET("locale.browser.reset.1"),
+ LOCALE_BROWSER_SELECTED("locale.browser.selected.1"),
+ LOCALE_BROWSER_UNSELECTED("locale.browser.unselected.1"),
+
+ // Hide a built-in home panel.
+ PANEL_HIDE("panel.hide.1"),
+
+ // Move a home panel up or down.
+ PANEL_MOVE("panel.move.1"),
+
+ // Remove a custom home panel.
+ PANEL_REMOVE("panel.remove.1"),
+
+ // Set default home panel.
+ PANEL_SET_DEFAULT("panel.setdefault.1"),
+
+ // Show a hidden built-in home panel.
+ PANEL_SHOW("panel.show.1"),
+
+ // Pinning an item.
+ PIN("pin.1"),
+
+ // Outcome of data policy notification: can be true or false.
+ POLICY_NOTIFICATION_SUCCESS("policynotification.success.1"),
+
+ // Sanitizing private data.
+ SANITIZE("sanitize.1"),
+
+ // Saving a resource (reader, bookmark, etc) for viewing later.
+ SAVE("save.1"),
+
+ // Perform a search -- currently used when starting a search in the search activity.
+ SEARCH("search.1"),
+
+ // Remove a search engine.
+ SEARCH_REMOVE("search.remove.1"),
+
+ // Restore default search engines.
+ SEARCH_RESTORE_DEFAULTS("search.restoredefaults.1"),
+
+ // Set default search engine.
+ SEARCH_SET_DEFAULT("search.setdefault.1"),
+
+ // Sharing content.
+ SHARE("share.1"),
+
+ // Show a UI element.
+ SHOW("show.1"),
+
+ // Undoing a user action.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ UNDO("undo.1"),
+
+ // Unpinning an item.
+ UNPIN("unpin.1"),
+
+ // Stop holding a resource (reader, bookmark, etc) for viewing later.
+ UNSAVE("unsave.1"),
+
+ // When the user performs actions on the in-content network error page.
+ NETERROR("neterror.1"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_event_1.1"),
+ _TEST2("_test_event_2.1"),
+ _TEST3("_test_event_3.1"),
+ _TEST4("_test_event_4.1"),
+ ;
+
+ private final String string;
+
+ Event(final String string) {
+ this.string = string;
+ }
+
+ @Override
+ public String toString() {
+ return string;
+ }
+ }
+
+ /**
+ * Holds event methods. Intended for use in
+ * Telemetry.sendUIEvent() as the "method" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Method {
+ // Action triggered from the action bar (including the toolbar).
+ ACTIONBAR("actionbar"),
+
+ // Action triggered by hitting the Android back button.
+ BACK("back"),
+
+ // Action triggered from a button.
+ BUTTON("button"),
+
+ // Action taken from a content page -- for example, a search results web page.
+ CONTENT("content"),
+
+ // Action occurred via a context menu.
+ CONTEXT_MENU("contextmenu"),
+
+ // Action triggered from a dialog.
+ DIALOG("dialog"),
+
+ // Action triggered from a doorhanger popup prompt.
+ DOORHANGER("doorhanger"),
+
+ // Action triggered from a view grid item, like a thumbnail.
+ GRID_ITEM("griditem"),
+
+ // Action occurred via an intent.
+ INTENT("intent"),
+
+ // Action occurred via a homescreen launcher.
+ HOMESCREEN("homescreen"),
+
+ // Action triggered from a list.
+ LIST("list"),
+
+ // Action triggered from a view list item, like a row of a list.
+ LIST_ITEM("listitem"),
+
+ // Action occurred via the main menu.
+ MENU("menu"),
+
+ // No method is specified.
+ NONE(null),
+
+ // Action triggered from a notification in the Android notification bar.
+ NOTIFICATION("notification"),
+
+ // Action triggered from a pageaction in the URLBar.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ PAGEACTION("pageaction"),
+
+ // Action triggered from one of a series of views, such as ViewPager.
+ PANEL("panel"),
+
+ // Action triggered by a background service / automatic system making a decision.
+ SERVICE("service"),
+
+ // Action triggered from a settings screen.
+ SETTINGS("settings"),
+
+ // Actions triggered from the share overlay.
+ SHARE_OVERLAY("shareoverlay"),
+
+ // Action triggered from a suggestion provided to the user.
+ SUGGESTION("suggestion"),
+
+ // Action triggered from an OS system action.
+ SYSTEM("system"),
+
+ // Action triggered from a SuperToast.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ TOAST("toast"),
+
+ // Action triggerred by pressing a SearchWidget button
+ WIDGET("widget"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_method_1"),
+ _TEST2("_test_method_2"),
+ ;
+
+ private final String string;
+
+ Method(final String string) {
+ this.string = string;
+ }
+
+ @Override
+ public String toString() {
+ return string;
+ }
+ }
+
+ /**
+ * Holds session names. Intended for use with
+ * Telemetry.startUISession() as the "sessionName" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Session {
+ // Awesomescreen (including frecency search) is active.
+ AWESOMESCREEN("awesomescreen.1"),
+
+ // Used to tag experiments being run.
+ EXPERIMENT("experiment.1"),
+
+ // Started the very first time we believe the application has been launched.
+ FIRSTRUN("firstrun.1"),
+
+ // Awesomescreen frecency search is active.
+ FRECENCY("frecency.1"),
+
+ // Started when a user enters a given home panel.
+ // Session name is dynamic, encoded as "homepanel.1:<panel_id>"
+ HOME_PANEL("homepanel.1"),
+
+ // Started when a Reader viewer becomes active in the foreground.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ READER("reader.1"),
+
+ // Started when the search activity launches.
+ SEARCH_ACTIVITY("searchactivity.1"),
+
+ // Settings activity is active.
+ SETTINGS("settings.1"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST_STARTED_TWICE("_test_session_started_twice.1"),
+ _TEST_STOPPED_TWICE("_test_session_stopped_twice.1"),
+ ;
+
+ private final String string;
+
+ Session(final String string) {
+ this.string = string;
+ }
+
+ @Override
+ public String toString() {
+ return string;
+ }
+ }
+
+ /**
+ * Holds reasons for stopping a session. Intended for use in
+ * Telemetry.stopUISession() as the "reason" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Reason {
+ // Changes were committed.
+ COMMIT("commit"),
+
+ // No reason is specified.
+ NONE(null),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_reason_1"),
+ _TEST2("_test_reason_2"),
+ _TEST_IGNORED("_test_reason_ignored"),
+ ;
+
+ private final String string;
+
+ Reason(final String string) {
+ this.string = string;
+ }
+
+ @Override
+ public String toString() {
+ return string;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java b/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java
new file mode 100644
index 000000000..3a7012431
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ThumbnailHelper.java
@@ -0,0 +1,246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.mozglue.DirectBufferAllocator;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/**
+ * Helper class to generate thumbnails for tabs.
+ * Internally, a queue of pending thumbnails is maintained in mPendingThumbnails.
+ * The head of the queue is the thumbnail that is currently being processed; upon
+ * completion of the current thumbnail the next one is automatically processed.
+ * Changes to the thumbnail width are stashed in mPendingWidth and the change is
+ * applied between thumbnail processing. This allows a single thumbnail buffer to
+ * be used for all thumbnails.
+ */
+public final class ThumbnailHelper {
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoThumbnailHelper";
+
+ public static final float TABS_PANEL_THUMBNAIL_ASPECT_RATIO = 0.8333333f;
+ public static final float TOP_SITES_THUMBNAIL_ASPECT_RATIO = 0.571428571f; // this is a 4:7 ratio (as per UX decision)
+ public static final float THUMBNAIL_ASPECT_RATIO;
+
+ static {
+ // As we only want to generate one thumbnail for each tab, we calculate the
+ // largest aspect ratio required and create the thumbnail based off that.
+ // Any views with a smaller aspect ratio will use a cropped version of the
+ // same image.
+ THUMBNAIL_ASPECT_RATIO = Math.max(TABS_PANEL_THUMBNAIL_ASPECT_RATIO, TOP_SITES_THUMBNAIL_ASPECT_RATIO);
+ }
+
+ public enum CachePolicy {
+ STORE,
+ NO_STORE
+ }
+
+ // static singleton stuff
+
+ private static ThumbnailHelper sInstance;
+
+ public static synchronized ThumbnailHelper getInstance() {
+ if (sInstance == null) {
+ sInstance = new ThumbnailHelper();
+ }
+ return sInstance;
+ }
+
+ // instance stuff
+
+ private final ArrayList<Tab> mPendingThumbnails; // synchronized access only
+ private volatile int mPendingWidth;
+ private int mWidth;
+ private int mHeight;
+ private ByteBuffer mBuffer;
+
+ private ThumbnailHelper() {
+ final Resources res = GeckoAppShell.getContext().getResources();
+
+ mPendingThumbnails = new ArrayList<>();
+ try {
+ mPendingWidth = (int) res.getDimension(R.dimen.tab_thumbnail_width);
+ } catch (Resources.NotFoundException nfe) {
+ }
+ mWidth = -1;
+ mHeight = -1;
+ }
+
+ public void getAndProcessThumbnailFor(final int tabId, final ResourceDrawableUtils.BitmapLoader loader) {
+ final Tab tab = Tabs.getInstance().getTab(tabId);
+ if (tab != null) {
+ getAndProcessThumbnailFor(tab, loader);
+ }
+ }
+
+ public void getAndProcessThumbnailFor(final Tab tab, final ResourceDrawableUtils.BitmapLoader loader) {
+ ResourceDrawableUtils.runOnBitmapFoundOnUiThread(loader, tab.getThumbnail());
+
+ Tabs.registerOnTabsChangedListener(new Tabs.OnTabsChangedListener() {
+ @Override
+ public void onTabChanged(final Tab t, final Tabs.TabEvents msg, final String data) {
+ if (tab != t || msg != Tabs.TabEvents.THUMBNAIL) {
+ return;
+ }
+ Tabs.unregisterOnTabsChangedListener(this);
+ ResourceDrawableUtils.runOnBitmapFoundOnUiThread(loader, t.getThumbnail());
+ }
+ });
+ getAndProcessThumbnailFor(tab);
+ }
+
+ public void getAndProcessThumbnailFor(Tab tab) {
+ if (AboutPages.isAboutHome(tab.getURL()) || AboutPages.isAboutPrivateBrowsing(tab.getURL())) {
+ tab.updateThumbnail(null, CachePolicy.NO_STORE);
+ return;
+ }
+
+ synchronized (mPendingThumbnails) {
+ if (mPendingThumbnails.lastIndexOf(tab) > 0) {
+ // This tab is already in the queue, so don't add it again.
+ // Note that if this tab is only at the *head* of the queue,
+ // (i.e. mPendingThumbnails.lastIndexOf(tab) == 0) then we do
+ // add it again because it may have already been thumbnailed
+ // and now we need to do it again.
+ return;
+ }
+
+ mPendingThumbnails.add(tab);
+ if (mPendingThumbnails.size() > 1) {
+ // Some thumbnail was already being processed, so wait
+ // for that to be done.
+ return;
+ }
+
+ requestThumbnailLocked(tab);
+ }
+ }
+
+ public void setThumbnailWidth(int width) {
+ // Check inverted for safety: Bug 803299 Comment 34.
+ if (GeckoAppShell.getScreenDepth() == 24) {
+ mPendingWidth = width;
+ } else {
+ // Bug 776906: on 16-bit screens we need to ensure an even width.
+ mPendingWidth = (width + 1) & (~1);
+ }
+ }
+
+ private void updateThumbnailSizeLocked() {
+ // Apply any pending width updates.
+ mWidth = mPendingWidth;
+ mHeight = Math.round(mWidth * THUMBNAIL_ASPECT_RATIO);
+
+ int pixelSize = (GeckoAppShell.getScreenDepth() == 24) ? 4 : 2;
+ int capacity = mWidth * mHeight * pixelSize;
+ if (DEBUG) {
+ Log.d(LOGTAG, "Using new thumbnail size: " + capacity +
+ " (width " + mWidth + " - height " + mHeight + ")");
+ }
+ if (mBuffer == null || mBuffer.capacity() != capacity) {
+ if (mBuffer != null) {
+ mBuffer = DirectBufferAllocator.free(mBuffer);
+ }
+ try {
+ mBuffer = DirectBufferAllocator.allocate(capacity);
+ } catch (IllegalArgumentException iae) {
+ Log.w(LOGTAG, iae.toString());
+ } catch (OutOfMemoryError oom) {
+ Log.w(LOGTAG, "Unable to allocate thumbnail buffer of capacity " + capacity);
+ }
+ // If we hit an error above, mBuffer will be pointing to null, so we are in a sane state.
+ }
+ }
+
+ private void requestThumbnailLocked(Tab tab) {
+ updateThumbnailSizeLocked();
+
+ if (mBuffer == null) {
+ // Buffer allocation may have failed. In this case we can't send the
+ // event requesting the screenshot which means we won't get back a response
+ // and so our queue will grow unboundedly. Handle this scenario by clearing
+ // the queue (no point trying more thumbnailing right now since we're likely
+ // low on memory). We will try again normally on the next call to
+ // getAndProcessThumbnailFor which will hopefully be when we have more free memory.
+ mPendingThumbnails.clear();
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Sending thumbnail event: " + mWidth + ", " + mHeight);
+ }
+ requestThumbnailLocked(mBuffer, tab, tab.getId(), mWidth, mHeight);
+ }
+
+ @WrapForJNI(stubName = "RequestThumbnail", dispatchTo = "proxy")
+ private static native void requestThumbnailLocked(ByteBuffer data, Tab tab, int tabId,
+ int width, int height);
+
+ /* This method is invoked by JNI once the thumbnail data is ready. */
+ @WrapForJNI(calledFrom = "gecko")
+ private static void notifyThumbnail(final ByteBuffer data, final Tab tab,
+ final boolean success, final boolean shouldStore) {
+ final ThumbnailHelper helper = ThumbnailHelper.getInstance();
+ if (success) {
+ helper.handleThumbnailData(
+ tab, data, shouldStore ? CachePolicy.STORE : CachePolicy.NO_STORE);
+ }
+ helper.processNextThumbnail();
+ }
+
+ private void processNextThumbnail() {
+ synchronized (mPendingThumbnails) {
+ if (mPendingThumbnails.isEmpty()) {
+ return;
+ }
+
+ mPendingThumbnails.remove(0);
+
+ if (!mPendingThumbnails.isEmpty()) {
+ requestThumbnailLocked(mPendingThumbnails.get(0));
+ }
+ }
+ }
+
+ private void handleThumbnailData(Tab tab, ByteBuffer data, CachePolicy cachePolicy) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleThumbnailData: " + data.capacity());
+ }
+ if (data != mBuffer) {
+ // This should never happen, but log it and recover gracefully
+ Log.e(LOGTAG, "handleThumbnailData called with an unexpected ByteBuffer!");
+ }
+
+ processThumbnailData(tab, data, cachePolicy);
+ }
+
+ private void processThumbnailData(Tab tab, ByteBuffer data, CachePolicy cachePolicy) {
+ Bitmap b = tab.getThumbnailBitmap(mWidth, mHeight);
+ data.position(0);
+ b.copyPixelsFromBuffer(data);
+ setTabThumbnail(tab, b, null, cachePolicy);
+ }
+
+ private void setTabThumbnail(Tab tab, Bitmap bitmap, byte[] compressed, CachePolicy cachePolicy) {
+ if (bitmap == null) {
+ if (compressed == null) {
+ Log.w(LOGTAG, "setTabThumbnail: one of bitmap or compressed must be non-null!");
+ return;
+ }
+ bitmap = BitmapUtils.decodeByteArray(compressed);
+ }
+ tab.updateThumbnail(bitmap, cachePolicy);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java b/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java
new file mode 100644
index 000000000..c0c9307dc
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/ZoomedView.java
@@ -0,0 +1,838 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.gfx.PanZoomController;
+import org.mozilla.gecko.gfx.PointUtils;
+import org.mozilla.gecko.mozglue.DirectBufferAllocator;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.OvershootInterpolator;
+import android.view.animation.ScaleAnimation;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import java.nio.ByteBuffer;
+import java.text.DecimalFormat;
+
+public class ZoomedView extends FrameLayout implements LayerView.DynamicToolbarListener,
+ LayerView.ZoomedViewListener, GeckoEventListener {
+ private static final String LOGTAG = "Gecko" + ZoomedView.class.getSimpleName();
+
+ private static final float[] ZOOM_FACTORS_LIST = {2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f, 1.5f};
+ private static final int W_CAPTURED_VIEW_IN_PERCENT = 50;
+ private static final int H_CAPTURED_VIEW_IN_PERCENT = 50;
+ private static final int MINIMUM_DELAY_BETWEEN_TWO_RENDER_CALLS_NS = 1000000;
+ private static final int DELAY_BEFORE_NEXT_RENDER_REQUEST_MS = 2000;
+ private static final int OPENING_ANIMATION_DURATION_MS = 250;
+ private static final int CLOSING_ANIMATION_DURATION_MS = 150;
+ private static final float OVERSHOOT_INTERPOLATOR_TENSION = 1.5f;
+
+ private float zoomFactor;
+ private int currentZoomFactorIndex;
+ private boolean isSimplifiedUI;
+ private int defaultZoomFactor;
+ private PrefsHelper.PrefHandler prefObserver;
+
+ private ImageView zoomedImageView;
+ private LayerView layerView;
+ private int viewWidth;
+ private int viewHeight; // Only the zoomed view height, no toolbar, no shadow ...
+ private int viewContainerWidth;
+ private int viewContainerHeight; // Zoomed view height with toolbar and other elements like shadow, ...
+ private int containterSize; // shadow, margin, ...
+ private Point lastPosition;
+ private boolean shouldSetVisibleOnUpdate;
+ private boolean isBlockedFromAppearing; // Prevent the display of the zoomedview while FormAssistantPopup is visible
+ private PointF returnValue;
+ private final PointF animationStart;
+ private ImageView closeButton;
+ private TextView changeZoomFactorButton;
+ private boolean toolbarOnTop;
+ private float offsetDueToToolBarPosition;
+ private int toolbarHeight;
+ private int cornerRadius;
+ private float dynamicToolbarOverlap;
+
+ private boolean stopUpdateView;
+
+ private int lastOrientation;
+
+ private ByteBuffer buffer;
+ private Runnable requestRenderRunnable;
+ private long startTimeReRender;
+ private long lastStartTimeReRender;
+
+ private ZoomedViewTouchListener touchListener;
+
+ private enum StartPointUpdate {
+ GECKO_POSITION, CENTER, NO_CHANGE
+ }
+
+ private class RoundedBitmapDrawable extends BitmapDrawable {
+ private Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG);
+ final float cornerRadius;
+ final boolean squareOnTopOfDrawable;
+
+ RoundedBitmapDrawable(Resources res, Bitmap bitmap, boolean squareOnTop, int radius) {
+ super(res, bitmap);
+ squareOnTopOfDrawable = squareOnTop;
+ final BitmapShader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP,
+ Shader.TileMode.CLAMP);
+ paint.setAntiAlias(true);
+ paint.setShader(shader);
+ cornerRadius = radius;
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ int height = getBounds().height();
+ int width = getBounds().width();
+ RectF rect = new RectF(0.0f, 0.0f, width, height);
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint);
+
+ //draw rectangles over the corners we want to be square
+ if (squareOnTopOfDrawable) {
+ canvas.drawRect(0, 0, cornerRadius, cornerRadius, paint);
+ canvas.drawRect(width - cornerRadius, 0, width, cornerRadius, paint);
+ } else {
+ canvas.drawRect(0, height - cornerRadius, cornerRadius, height, paint);
+ canvas.drawRect(width - cornerRadius, height - cornerRadius, width, height, paint);
+ }
+ }
+ }
+
+ private class ZoomedViewTouchListener implements View.OnTouchListener {
+ private float originRawX;
+ private float originRawY;
+ private boolean dragged;
+ private MotionEvent actionDownEvent;
+
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ if (layerView == null) {
+ return false;
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_MOVE:
+ if (moveZoomedView(event)) {
+ dragged = true;
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ if (dragged) {
+ dragged = false;
+ } else {
+ if (isClickInZoomedView(event.getY())) {
+ GeckoAppShell.notifyObservers("Gesture:ClickInZoomedView", "");
+ layerView.dispatchTouchEvent(actionDownEvent);
+ actionDownEvent.recycle();
+ PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(event.getX(), event.getY());
+ // the LayerView expects the coordinates relative to the window, not the surface, so we need
+ // to adjust that here.
+ convertedPosition.y += layerView.getSurfaceTranslation();
+ MotionEvent e = MotionEvent.obtain(event.getDownTime(), event.getEventTime(),
+ MotionEvent.ACTION_UP, convertedPosition.x, convertedPosition.y,
+ event.getMetaState());
+ layerView.dispatchTouchEvent(e);
+ e.recycle();
+ }
+ }
+ break;
+
+ case MotionEvent.ACTION_DOWN:
+ dragged = false;
+ originRawX = event.getRawX();
+ originRawY = event.getRawY();
+ PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(event.getX(), event.getY());
+ // the LayerView expects the coordinates relative to the window, not the surface, so we need
+ // to adjust that here.
+ convertedPosition.y += layerView.getSurfaceTranslation();
+ actionDownEvent = MotionEvent.obtain(event.getDownTime(), event.getEventTime(),
+ MotionEvent.ACTION_DOWN, convertedPosition.x, convertedPosition.y,
+ event.getMetaState());
+ break;
+ }
+ return true;
+ }
+
+ private boolean isClickInZoomedView(float y) {
+ return ((toolbarOnTop && y > toolbarHeight) ||
+ (!toolbarOnTop && y < ZoomedView.this.viewHeight));
+ }
+
+ private boolean moveZoomedView(MotionEvent event) {
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) ZoomedView.this.getLayoutParams();
+ if ((!dragged) && (Math.abs((int) (event.getRawX() - originRawX)) < PanZoomController.CLICK_THRESHOLD)
+ && (Math.abs((int) (event.getRawY() - originRawY)) < PanZoomController.CLICK_THRESHOLD)) {
+ // When the user just touches the screen ACTION_MOVE can be detected for a very small delta on position.
+ // In this case, the move is ignored if the delta is lower than 1 unit.
+ return false;
+ }
+
+ float newLeftMargin = params.leftMargin + event.getRawX() - originRawX;
+ float newTopMargin = params.topMargin + event.getRawY() - originRawY;
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ ZoomedView.this.moveZoomedView(metrics, newLeftMargin, newTopMargin, StartPointUpdate.CENTER);
+ originRawX = event.getRawX();
+ originRawY = event.getRawY();
+ return true;
+ }
+ }
+
+ public ZoomedView(Context context) {
+ this(context, null, 0);
+ }
+
+ public ZoomedView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ZoomedView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ isSimplifiedUI = true;
+ isBlockedFromAppearing = false;
+ getPrefs();
+ currentZoomFactorIndex = 0;
+ returnValue = new PointF();
+ animationStart = new PointF();
+ requestRenderRunnable = new Runnable() {
+ @Override
+ public void run() {
+ requestZoomedViewRender();
+ }
+ };
+ touchListener = new ZoomedViewTouchListener();
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "Gesture:clusteredLinksClicked", "Window:Resize", "Content:LocationChange",
+ "Gesture:CloseZoomedView", "Browser:ZoomToPageWidth", "Browser:ZoomToRect",
+ "FormAssist:AutoComplete", "FormAssist:Hide");
+ }
+
+ void destroy() {
+ if (prefObserver != null) {
+ PrefsHelper.removeObserver(prefObserver);
+ prefObserver = null;
+ }
+ ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable);
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "Gesture:clusteredLinksClicked", "Window:Resize", "Content:LocationChange",
+ "Gesture:CloseZoomedView", "Browser:ZoomToPageWidth", "Browser:ZoomToRect",
+ "FormAssist:AutoComplete", "FormAssist:Hide");
+ }
+
+ // This method (onFinishInflate) is called only when the zoomed view class is used inside
+ // an xml structure <org.mozilla.gecko.ZoomedView ...
+ // It won't be called if the class is used from java code like "new ZoomedView(context);"
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ closeButton = (ImageView) findViewById(R.id.dialog_close);
+ changeZoomFactorButton = (TextView) findViewById(R.id.change_zoom_factor);
+ zoomedImageView = (ImageView) findViewById(R.id.zoomed_image_view);
+
+ updateUI();
+
+ toolbarHeight = getResources().getDimensionPixelSize(R.dimen.zoomed_view_toolbar_height);
+ containterSize = getResources().getDimensionPixelSize(R.dimen.drawable_dropshadow_size);
+ cornerRadius = getResources().getDimensionPixelSize(R.dimen.standard_corner_radius);
+
+ moveToolbar(true);
+ }
+
+ private void setListeners() {
+ closeButton.setOnClickListener(new View.OnClickListener() {
+ public void onClick(View view) {
+ stopZoomDisplay(true);
+ }
+ });
+
+ changeZoomFactorButton.setOnTouchListener(new OnTouchListener() {
+ public boolean onTouch(View v, MotionEvent event) {
+
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ if (event.getX() >= (changeZoomFactorButton.getLeft() + changeZoomFactorButton.getWidth() / 2)) {
+ changeZoomFactor(true);
+ } else {
+ changeZoomFactor(false);
+ }
+ }
+ return true;
+ }
+ });
+
+ setOnTouchListener(touchListener);
+ }
+
+ private void removeListeners() {
+ closeButton.setOnClickListener(null);
+
+ changeZoomFactorButton.setOnTouchListener(null);
+
+ setOnTouchListener(null);
+ }
+ /*
+ * Convert a click from ZoomedView. Return the position of the click in the
+ * LayerView
+ */
+ private PointF getUnzoomedPositionFromPointInZoomedView(float x, float y) {
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ final float parentWidth = metrics.getWidth();
+ final float parentHeight = metrics.getHeight();
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) getLayoutParams();
+
+ // The number of unzoomed content pixels that can be displayed in the
+ // zoomed area.
+ float visibleContentPixels = viewWidth / zoomFactor;
+ // The offset in content pixels of the leftmost zoomed pixel from the
+ // layerview's left edge when the zoomed view is moved to the right as
+ // far as it can go.
+ float maxContentOffset = parentWidth - visibleContentPixels;
+ // The maximum offset in screen pixels that the zoomed view can have
+ float maxZoomedViewOffset = parentWidth - viewContainerWidth;
+
+ // The above values allow us to compute the term
+ // maxContentOffset / maxZoomedViewOffset
+ // which is the number of content pixels that we should move over by
+ // for every screen pixel that the zoomed view is moved over by.
+ // This allows a smooth transition from when the zoomed view is at the
+ // leftmost extent to when it is at the rightmost extent.
+
+ // This is the offset in content pixels of the leftmost zoomed pixel
+ // visible in the zoomed view. This value is relative to the layerview
+ // edge.
+ float zoomedContentOffset = ((float)params.leftMargin) * maxContentOffset / maxZoomedViewOffset;
+ returnValue.x = (int)(zoomedContentOffset + (x / zoomFactor));
+
+ // Same comments here vertically
+ visibleContentPixels = viewHeight / zoomFactor;
+ maxContentOffset = parentHeight - visibleContentPixels;
+ maxZoomedViewOffset = parentHeight - (viewContainerHeight - toolbarHeight);
+ float zoomedAreaOffset = (float)params.topMargin + offsetDueToToolBarPosition - layerView.getSurfaceTranslation();
+ zoomedContentOffset = zoomedAreaOffset * maxContentOffset / maxZoomedViewOffset;
+ returnValue.y = (int)(zoomedContentOffset + ((y - offsetDueToToolBarPosition) / zoomFactor));
+
+ return returnValue;
+ }
+
+ /*
+ * A touch point (x,y) occurs in LayerView, this point should be displayed
+ * in the center of the zoomed view. The returned point is the position of
+ * the Top-Left zoomed view point on the screen device
+ */
+ private PointF getZoomedViewTopLeftPositionFromTouchPosition(float x, float y) {
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ final float parentWidth = metrics.getWidth();
+ final float parentHeight = metrics.getHeight();
+
+ // See comments in getUnzoomedPositionFromPointInZoomedView, but the
+ // transformations here are largely the reverse of that function.
+
+ float visibleContentPixels = viewWidth / zoomFactor;
+ float maxContentOffset = parentWidth - visibleContentPixels;
+ float maxZoomedViewOffset = parentWidth - viewContainerWidth;
+ float contentPixelOffset = x - (visibleContentPixels / 2.0f);
+ returnValue.x = (int)(contentPixelOffset * (maxZoomedViewOffset / maxContentOffset));
+
+ visibleContentPixels = viewHeight / zoomFactor;
+ maxContentOffset = parentHeight - visibleContentPixels;
+ maxZoomedViewOffset = parentHeight - (viewContainerHeight - toolbarHeight);
+ contentPixelOffset = y - (visibleContentPixels / 2.0f);
+ float unscaledViewOffset = layerView.getSurfaceTranslation() - offsetDueToToolBarPosition;
+ returnValue.y = (int)((contentPixelOffset * (maxZoomedViewOffset / maxContentOffset)) + unscaledViewOffset);
+
+ return returnValue;
+ }
+
+ private void moveZoomedView(ImmutableViewportMetrics metrics, float newLeftMargin, float newTopMargin,
+ StartPointUpdate animateStartPoint) {
+ RelativeLayout.LayoutParams newLayoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
+ newLayoutParams.leftMargin = (int) newLeftMargin;
+ newLayoutParams.topMargin = (int) newTopMargin;
+ int topMarginMin = (int)(layerView.getSurfaceTranslation() + dynamicToolbarOverlap);
+ int topMarginMax = layerView.getHeight() - viewContainerHeight;
+ int leftMarginMin = 0;
+ int leftMarginMax = layerView.getWidth() - viewContainerWidth;
+
+ if (newTopMargin < topMarginMin) {
+ newLayoutParams.topMargin = topMarginMin;
+ } else if (newTopMargin > topMarginMax) {
+ newLayoutParams.topMargin = topMarginMax;
+ }
+
+ if (newLeftMargin < leftMarginMin) {
+ newLayoutParams.leftMargin = leftMarginMin;
+ } else if (newLeftMargin > leftMarginMax) {
+ newLayoutParams.leftMargin = leftMarginMax;
+ }
+
+ if (newLayoutParams.topMargin < topMarginMin + 1) {
+ moveToolbar(false);
+ } else if (newLayoutParams.topMargin > topMarginMax - 1) {
+ moveToolbar(true);
+ }
+
+ if (animateStartPoint == StartPointUpdate.GECKO_POSITION) {
+ // Before this point, the animationStart point is relative to the layerView.
+ // The value is initialized in startZoomDisplay using the click point position coming from Gecko.
+ // The position of the zoomed view is now calculated, so the position of the animation
+ // can now be correctly set relative to the zoomed view
+ animationStart.x = animationStart.x - newLayoutParams.leftMargin;
+ animationStart.y = animationStart.y - newLayoutParams.topMargin;
+ } else if (animateStartPoint == StartPointUpdate.CENTER) {
+ // At this point, the animationStart point is no more valid probably because
+ // the zoomed view has been moved by the user.
+ // In this case, the animationStart point is set to the center point of the zoomed view.
+ PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(viewContainerWidth / 2, viewContainerHeight / 2);
+ animationStart.x = convertedPosition.x - newLayoutParams.leftMargin;
+ animationStart.y = convertedPosition.y - newLayoutParams.topMargin;
+ }
+
+ setLayoutParams(newLayoutParams);
+ PointF convertedPosition = getUnzoomedPositionFromPointInZoomedView(0, offsetDueToToolBarPosition);
+ lastPosition = PointUtils.round(convertedPosition);
+ requestZoomedViewRender();
+ }
+
+ private void moveToolbar(boolean moveTop) {
+ if (toolbarOnTop == moveTop) {
+ return;
+ }
+ toolbarOnTop = moveTop;
+ if (toolbarOnTop) {
+ offsetDueToToolBarPosition = toolbarHeight;
+ } else {
+ offsetDueToToolBarPosition = 0;
+ }
+
+ RelativeLayout.LayoutParams p = (RelativeLayout.LayoutParams) zoomedImageView.getLayoutParams();
+ RelativeLayout.LayoutParams pChangeZoomFactorButton = (RelativeLayout.LayoutParams) changeZoomFactorButton.getLayoutParams();
+ RelativeLayout.LayoutParams pCloseButton = (RelativeLayout.LayoutParams) closeButton.getLayoutParams();
+
+ if (moveTop) {
+ p.addRule(RelativeLayout.BELOW, R.id.change_zoom_factor);
+ pChangeZoomFactorButton.addRule(RelativeLayout.BELOW, 0);
+ pCloseButton.addRule(RelativeLayout.BELOW, 0);
+ } else {
+ p.addRule(RelativeLayout.BELOW, 0);
+ pChangeZoomFactorButton.addRule(RelativeLayout.BELOW, R.id.zoomed_image_view);
+ pCloseButton.addRule(RelativeLayout.BELOW, R.id.zoomed_image_view);
+ }
+ pChangeZoomFactorButton.addRule(RelativeLayout.ALIGN_LEFT, R.id.zoomed_image_view);
+ pCloseButton.addRule(RelativeLayout.ALIGN_RIGHT, R.id.zoomed_image_view);
+ zoomedImageView.setLayoutParams(p);
+ changeZoomFactorButton.setLayoutParams(pChangeZoomFactorButton);
+ closeButton.setLayoutParams(pCloseButton);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ // In case of orientation change, the zoomed view update is stopped until the orientation change
+ // is completed. At this time, the function onMetricsChanged is called and the
+ // zoomed view update is restarted again.
+ if (lastOrientation != newConfig.orientation) {
+ shouldBlockUpdate(true);
+ lastOrientation = newConfig.orientation;
+ }
+ }
+
+ private void refreshZoomedViewSize(ImmutableViewportMetrics viewport) {
+ if (layerView == null) {
+ return;
+ }
+
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) getLayoutParams();
+ setCapturedSize(viewport);
+ moveZoomedView(viewport, params.leftMargin, params.topMargin, StartPointUpdate.NO_CHANGE);
+ }
+
+ private void setCapturedSize(ImmutableViewportMetrics metrics) {
+ float parentMinSize = Math.min(metrics.getWidth(), metrics.getHeight());
+ viewWidth = (int) ((parentMinSize * W_CAPTURED_VIEW_IN_PERCENT / (zoomFactor * 100.0)) * zoomFactor);
+ viewHeight = (int) ((parentMinSize * H_CAPTURED_VIEW_IN_PERCENT / (zoomFactor * 100.0)) * zoomFactor);
+ viewContainerHeight = viewHeight + toolbarHeight +
+ 2 * containterSize; // Top and bottom shadows
+ viewContainerWidth = viewWidth +
+ 2 * containterSize; // Right and left shadows
+ // Display in zoomedview is corrupted when width is an odd number
+ // More details about this issue here: bug 776906 comment 11
+ viewWidth &= ~0x1;
+ }
+
+ private void shouldBlockUpdate(boolean shouldBlockUpdate) {
+ stopUpdateView = shouldBlockUpdate;
+ }
+
+ private Bitmap.Config getBitmapConfig() {
+ return (GeckoAppShell.getScreenDepth() == 24) ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565;
+ }
+
+ private void updateUI() {
+ // onFinishInflate is not yet completed, the update of the UI will be done later
+ if (changeZoomFactorButton == null) {
+ return;
+ }
+ if (isSimplifiedUI) {
+ changeZoomFactorButton.setVisibility(View.INVISIBLE);
+ } else {
+ setTextInZoomFactorButton(zoomFactor);
+ changeZoomFactorButton.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void getPrefs() {
+ prefObserver = new PrefsHelper.PrefHandlerBase() {
+ @Override
+ public void prefValue(String pref, boolean simplified) {
+ isSimplifiedUI = simplified;
+ if (simplified) {
+ zoomFactor = (float) defaultZoomFactor;
+ } else {
+ zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex];
+ }
+ updateUI();
+ }
+
+ @Override
+ public void prefValue(String pref, int defaultZoomFactorFromSettings) {
+ defaultZoomFactor = defaultZoomFactorFromSettings;
+ if (isSimplifiedUI) {
+ zoomFactor = (float) defaultZoomFactor;
+ } else {
+ zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex];
+ }
+ updateUI();
+ }
+ };
+ PrefsHelper.addObserver(new String[] { "ui.zoomedview.simplified",
+ "ui.zoomedview.defaultZoomFactor" },
+ prefObserver);
+ }
+
+ private void startZoomDisplay(LayerView aLayerView, final int leftFromGecko, final int topFromGecko) {
+ if (isBlockedFromAppearing) {
+ return;
+ }
+ if (layerView == null) {
+ layerView = aLayerView;
+ layerView.addZoomedViewListener(this);
+ layerView.getDynamicToolbarAnimator().addTranslationListener(this);
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ setCapturedSize(metrics);
+ }
+ startTimeReRender = 0;
+ shouldSetVisibleOnUpdate = true;
+
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ // At this point, the start point is relative to the layerView.
+ // Later, it will be converted relative to the zoomed view as soon as
+ // the position of the zoomed view will be calculated.
+ animationStart.x = (float) leftFromGecko * metrics.zoomFactor;
+ animationStart.y = (float) topFromGecko * metrics.zoomFactor + layerView.getSurfaceTranslation();
+
+ moveUsingGeckoPosition(leftFromGecko, topFromGecko);
+ }
+
+ public void stopZoomDisplay(boolean withAnimation) {
+ // If "startZoomDisplay" is running and not totally completed (Gecko thread is still
+ // running and "showZoomedView" has not yet been called), the zoomed view will be
+ // displayed after this call and it should not.
+ // Force the stop of the zoomed view, changing the shouldSetVisibleOnUpdate flag
+ // before the test of the visibility.
+ shouldSetVisibleOnUpdate = false;
+ if (getVisibility() == View.VISIBLE) {
+ hideZoomedView(withAnimation);
+ ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable);
+ if (layerView != null) {
+ layerView.getDynamicToolbarAnimator().removeTranslationListener(this);
+ layerView.removeZoomedViewListener(this);
+ layerView = null;
+ }
+ }
+ }
+
+ private void changeZoomFactor(boolean zoomIn) {
+ if (zoomIn && currentZoomFactorIndex < ZOOM_FACTORS_LIST.length - 1) {
+ currentZoomFactorIndex++;
+ } else if (zoomIn && currentZoomFactorIndex >= ZOOM_FACTORS_LIST.length - 1) {
+ currentZoomFactorIndex = 0;
+ } else if (!zoomIn && currentZoomFactorIndex > 0) {
+ currentZoomFactorIndex--;
+ } else {
+ currentZoomFactorIndex = ZOOM_FACTORS_LIST.length - 1;
+ }
+ zoomFactor = ZOOM_FACTORS_LIST[currentZoomFactorIndex];
+
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ refreshZoomedViewSize(metrics);
+ setTextInZoomFactorButton(zoomFactor);
+ }
+
+ private void setTextInZoomFactorButton(float zoom) {
+ final String percentageValue = Integer.toString((int) (100 * zoom));
+ changeZoomFactorButton.setText("- " + getResources().getString(R.string.percent, percentageValue) + " +");
+ }
+
+ @Override
+ public void handleMessage(final String event, final JSONObject message) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (event.equals("Gesture:clusteredLinksClicked")) {
+ final JSONObject clickPosition = message.getJSONObject("clickPosition");
+ int left = clickPosition.getInt("x");
+ int top = clickPosition.getInt("y");
+ // Start to display inside the zoomedView
+ LayerView geckoAppLayerView = GeckoAppShell.getLayerView();
+ if (geckoAppLayerView != null) {
+ startZoomDisplay(geckoAppLayerView, left, top);
+ }
+ } else if (event.equals("Window:Resize")) {
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ refreshZoomedViewSize(metrics);
+ } else if (event.equals("Content:LocationChange")) {
+ stopZoomDisplay(false);
+ } else if (event.equals("Gesture:CloseZoomedView") ||
+ event.equals("Browser:ZoomToPageWidth") ||
+ event.equals("Browser:ZoomToRect")) {
+ stopZoomDisplay(true);
+ } else if (event.equals("FormAssist:AutoComplete")) {
+ isBlockedFromAppearing = true;
+ stopZoomDisplay(true);
+ } else if (event.equals("FormAssist:Hide")) {
+ isBlockedFromAppearing = false;
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON exception", e);
+ }
+ }
+ });
+ }
+
+ private void moveUsingGeckoPosition(int leftFromGecko, int topFromGecko) {
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ final float parentHeight = metrics.getHeight();
+ // moveToolbar is called before getZoomedViewTopLeftPositionFromTouchPosition in order to
+ // correctly center vertically the zoomed area
+ moveToolbar((topFromGecko * metrics.zoomFactor > parentHeight / 2));
+ PointF convertedPosition = getZoomedViewTopLeftPositionFromTouchPosition((leftFromGecko * metrics.zoomFactor),
+ (topFromGecko * metrics.zoomFactor));
+ moveZoomedView(metrics, convertedPosition.x, convertedPosition.y, StartPointUpdate.GECKO_POSITION);
+ }
+
+ @Override
+ public void onTranslationChanged(float aToolbarTranslation, float aLayerViewTranslation) {
+ ThreadUtils.assertOnUiThread();
+ if (layerView != null) {
+ dynamicToolbarOverlap = aLayerViewTranslation - aToolbarTranslation;
+ refreshZoomedViewSize(layerView.getViewportMetrics());
+ }
+ }
+
+ @Override
+ public void onMetricsChanged(final ImmutableViewportMetrics viewport) {
+ // It can be called from a Gecko thread (forceViewportMetrics in GeckoLayerClient).
+ // Post to UI Thread to avoid Exception:
+ // "Only the original thread that created a view hierarchy can touch its views."
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ shouldBlockUpdate(false);
+ refreshZoomedViewSize(viewport);
+ }
+ });
+ }
+
+ @Override
+ public void onPanZoomStopped() {
+ }
+
+ @Override
+ public void updateView(ByteBuffer data) {
+ final Bitmap sb3 = Bitmap.createBitmap(viewWidth, viewHeight, getBitmapConfig());
+ if (sb3 != null) {
+ data.rewind();
+ try {
+ sb3.copyPixelsFromBuffer(data);
+ } catch (Exception iae) {
+ Log.w(LOGTAG, iae.toString());
+ }
+ if (zoomedImageView != null) {
+ RoundedBitmapDrawable ob3 = new RoundedBitmapDrawable(getResources(), sb3, toolbarOnTop, cornerRadius);
+ zoomedImageView.setImageDrawable(ob3);
+ }
+ }
+ if (shouldSetVisibleOnUpdate) {
+ this.showZoomedView();
+ }
+ lastStartTimeReRender = startTimeReRender;
+ startTimeReRender = 0;
+ }
+
+ private void showZoomedView() {
+ // no animation if the zoomed view is already visible
+ if (getVisibility() != View.VISIBLE) {
+ final Animation anim = new ScaleAnimation(
+ 0f, 1f, // Start and end values for the X axis scaling
+ 0f, 1f, // Start and end values for the Y axis scaling
+ Animation.ABSOLUTE, animationStart.x, // Pivot point of X scaling
+ Animation.ABSOLUTE, animationStart.y); // Pivot point of Y scaling
+ anim.setFillAfter(true); // Needed to keep the result of the animation
+ anim.setDuration(OPENING_ANIMATION_DURATION_MS);
+ anim.setInterpolator(new OvershootInterpolator(OVERSHOOT_INTERPOLATOR_TENSION));
+ anim.setAnimationListener(new AnimationListener() {
+ public void onAnimationEnd(Animation animation) {
+ setListeners();
+ }
+ public void onAnimationRepeat(Animation animation) {
+ }
+ public void onAnimationStart(Animation animation) {
+ removeListeners();
+ }
+ });
+ setAnimation(anim);
+ }
+ setVisibility(View.VISIBLE);
+ shouldSetVisibleOnUpdate = false;
+ }
+
+ private void hideZoomedView(boolean withAnimation) {
+ if (withAnimation) {
+ final Animation anim = new ScaleAnimation(
+ 1f, 0f, // Start and end values for the X axis scaling
+ 1f, 0f, // Start and end values for the Y axis scaling
+ Animation.ABSOLUTE, animationStart.x, // Pivot point of X scaling
+ Animation.ABSOLUTE, animationStart.y); // Pivot point of Y scaling
+ anim.setFillAfter(true); // Needed to keep the result of the animation
+ anim.setDuration(CLOSING_ANIMATION_DURATION_MS);
+ anim.setAnimationListener(new AnimationListener() {
+ public void onAnimationEnd(Animation animation) {
+ }
+ public void onAnimationRepeat(Animation animation) {
+ }
+ public void onAnimationStart(Animation animation) {
+ removeListeners();
+ }
+ });
+ setAnimation(anim);
+ } else {
+ removeListeners();
+ setAnimation(null);
+ }
+ setVisibility(View.GONE);
+ shouldSetVisibleOnUpdate = false;
+ }
+
+ private void updateBufferSize() {
+ int pixelSize = (GeckoAppShell.getScreenDepth() == 24) ? 4 : 2;
+ int capacity = viewWidth * viewHeight * pixelSize;
+ if (buffer == null || buffer.capacity() != capacity) {
+ buffer = DirectBufferAllocator.free(buffer);
+ buffer = DirectBufferAllocator.allocate(capacity);
+ }
+ }
+
+ private boolean isRendering() {
+ return (startTimeReRender != 0);
+ }
+
+ private boolean renderFrequencyTooHigh() {
+ return ((System.nanoTime() - lastStartTimeReRender) < MINIMUM_DELAY_BETWEEN_TWO_RENDER_CALLS_NS);
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void requestZoomedViewData(ByteBuffer buffer, int tabId,
+ int xPos, int yPos, int width,
+ int height, float scale);
+
+ @Override
+ public void requestZoomedViewRender() {
+ if (stopUpdateView) {
+ return;
+ }
+ // remove pending runnable
+ ThreadUtils.removeCallbacksFromUiThread(requestRenderRunnable);
+
+ // "requestZoomedViewRender" can be called very often by Gecko (endDrawing in LayerRender) without
+ // any thing changed in the zoomed area (useless calls from the "zoomed area" point of view).
+ // "requestZoomedViewRender" can take time to re-render the zoomed view, it depends of the complexity
+ // of the html on this area.
+ // To avoid to slow down the application, the 2 following cases are tested:
+
+ // 1- Last render is still running, plan another render later.
+ if (isRendering()) {
+ // post a new runnable DELAY_BEFORE_NEXT_RENDER_REQUEST_MS later
+ // We need to post with a delay to be sure that the last call to requestZoomedViewRender will be done.
+ // For a static html page WITHOUT any animation/video, there is a last call to endDrawing and we need to make
+ // the zoomed render on this last call.
+ ThreadUtils.postDelayedToUiThread(requestRenderRunnable, DELAY_BEFORE_NEXT_RENDER_REQUEST_MS);
+ return;
+ }
+
+ // 2- Current render occurs too early, plan another render later.
+ if (renderFrequencyTooHigh()) {
+ // post a new runnable DELAY_BEFORE_NEXT_RENDER_REQUEST_MS later
+ // We need to post with a delay to be sure that the last call to requestZoomedViewRender will be done.
+ // For a page WITH animation/video, the animation/video can be stopped, and we need to make
+ // the zoomed render on this last call.
+ ThreadUtils.postDelayedToUiThread(requestRenderRunnable, DELAY_BEFORE_NEXT_RENDER_REQUEST_MS);
+ return;
+ }
+
+ startTimeReRender = System.nanoTime();
+ // Allocate the buffer if it's the first call.
+ // Change the buffer size if it's not the right size.
+ updateBufferSize();
+
+ int tabId = Tabs.getInstance().getSelectedTab().getId();
+
+ ImmutableViewportMetrics metrics = layerView.getViewportMetrics();
+ PointF origin = metrics.getOrigin();
+
+ final int xPos = (int)origin.x + lastPosition.x;
+ final int yPos = (int)origin.y + lastPosition.y;
+
+ requestZoomedViewData(buffer, tabId, xPos, yPos, viewWidth, viewHeight,
+ zoomFactor * metrics.zoomFactor);
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java
new file mode 100644
index 000000000..d1c3f5916
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java
@@ -0,0 +1,149 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.activitystream;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.publicsuffix.PublicSuffix;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class ActivityStream {
+ /**
+ * List of undesired prefixes for labels based on a URL.
+ *
+ * This list is by no means complete and is based on those sources:
+ * - https://gist.github.com/nchapman/36502ad115e8825d522a66549971a3f0
+ * - https://github.com/mozilla/activity-stream/issues/1311
+ */
+ private static final List<String> UNDESIRED_LABEL_PREFIXES = Arrays.asList(
+ "index.",
+ "home."
+ );
+
+ /**
+ * Undesired labels for labels based on a URL.
+ *
+ * This list is by no means complete and is based on those sources:
+ * - https://gist.github.com/nchapman/36502ad115e8825d522a66549971a3f0
+ * - https://github.com/mozilla/activity-stream/issues/1311
+ */
+ private static final List<String> UNDESIRED_LABELS = Arrays.asList(
+ "render",
+ "login",
+ "edit"
+ );
+
+ public static boolean isEnabled(Context context) {
+ if (!isUserEligible(context)) {
+ // If the user is not eligible then disable activity stream. Even if it has been
+ // enabled before.
+ return false;
+ }
+
+ return GeckoSharedPrefs.forApp(context)
+ .getBoolean(GeckoPreferences.PREFS_ACTIVITY_STREAM, false);
+ }
+
+ /**
+ * Is the user eligible to use activity stream or should we hide it from settings etc.?
+ */
+ public static boolean isUserEligible(Context context) {
+ if (AppConstants.MOZ_ANDROID_ACTIVITY_STREAM) {
+ // If the build flag is enabled then just show the option to the user.
+ return true;
+ }
+
+ if (AppConstants.NIGHTLY_BUILD && SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM)) {
+ // If this is a nightly build and the user is part of the activity stream experiment then
+ // the option should be visible too. The experiment is limited to Nightly too but I want
+ // to make really sure that this isn't riding the trains accidentally.
+ return true;
+ }
+
+ // For everyone else activity stream is not available yet.
+ return false;
+ }
+
+ /**
+ * Query whether we want to display Activity Stream as a Home Panel (within the HomePager),
+ * or as a HomePager replacement.
+ */
+ public static boolean isHomePanel() {
+ return true;
+ }
+
+ /**
+ * Extract a label from a URL to use in Activity Stream.
+ *
+ * This method implements the proposal from this desktop AS issue:
+ * https://github.com/mozilla/activity-stream/issues/1311
+ *
+ * @param usePath Use the path of the URL to extract a label (if suitable)
+ */
+ public static void extractLabel(final Context context, final String url, final boolean usePath, final LabelCallback callback) {
+ new AsyncTask<Void, Void, String>() {
+ @Override
+ protected String doInBackground(Void... params) {
+ if (TextUtils.isEmpty(url)) {
+ return "";
+ }
+
+ final Uri uri = Uri.parse(url);
+
+ // Use last path segment if suitable
+ if (usePath) {
+ final String segment = uri.getLastPathSegment();
+ if (!TextUtils.isEmpty(segment)
+ && !UNDESIRED_LABELS.contains(segment)
+ && !segment.matches("^[0-9]+$")) {
+
+ boolean hasUndesiredPrefix = false;
+ for (int i = 0; i < UNDESIRED_LABEL_PREFIXES.size(); i++) {
+ if (segment.startsWith(UNDESIRED_LABEL_PREFIXES.get(i))) {
+ hasUndesiredPrefix = true;
+ break;
+ }
+ }
+
+ if (!hasUndesiredPrefix) {
+ return segment;
+ }
+ }
+ }
+
+ // If no usable path segment was found then use the host without public suffix and common subdomains
+ final String host = uri.getHost();
+ if (TextUtils.isEmpty(host)) {
+ return url;
+ }
+
+ return StringUtils.stripCommonSubdomains(
+ PublicSuffix.stripPublicSuffix(context, host));
+ }
+
+ @Override
+ protected void onPostExecute(String label) {
+ callback.onLabelExtracted(label);
+ }
+ }.execute();
+ }
+
+ public abstract static class LabelCallback {
+ public abstract void onLabelExtracted(String label);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java
new file mode 100644
index 000000000..aee0bba63
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustBrowserAppDelegate.java
@@ -0,0 +1,52 @@
+package org.mozilla.gecko.adjust;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+
+import org.mozilla.gecko.AdjustConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.delegates.BrowserAppDelegate;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.IntentUtils;
+
+public class AdjustBrowserAppDelegate extends BrowserAppDelegate {
+ private final AdjustHelperInterface adjustHelper;
+ private final AttributionHelperListener attributionHelperListener;
+
+ public AdjustBrowserAppDelegate(AttributionHelperListener attributionHelperListener) {
+ this.adjustHelper = AdjustConstants.getAdjustHelper();
+ this.attributionHelperListener = attributionHelperListener;
+ }
+
+ @Override
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ adjustHelper.onCreate(browserApp,
+ AdjustConstants.MOZ_INSTALL_TRACKING_ADJUST_SDK_APP_TOKEN,
+ attributionHelperListener);
+
+ final boolean isInAutomation = IntentUtils.getIsInAutomationFromEnvironment(
+ new SafeIntent(browserApp.getIntent()));
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp);
+
+ // Adjust stores enabled state so this is only necessary because users may have set
+ // their data preferences before this feature was implemented and we need to respect
+ // those before upload can occur in Adjust.onResume.
+ adjustHelper.setEnabled(!isInAutomation
+ && prefs.getBoolean(GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true));
+ }
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ // Needed for Adjust to get accurate session measurements
+ adjustHelper.onResume();
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ // Needed for Adjust to get accurate session measurements
+ adjustHelper.onPause();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java
new file mode 100644
index 000000000..19399e735
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelper.java
@@ -0,0 +1,75 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.adjust;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.adjust.sdk.Adjust;
+import com.adjust.sdk.AdjustAttribution;
+import com.adjust.sdk.AdjustConfig;
+import com.adjust.sdk.AdjustReferrerReceiver;
+import com.adjust.sdk.LogLevel;
+import com.adjust.sdk.OnAttributionChangedListener;
+
+import org.mozilla.gecko.AppConstants;
+
+public class AdjustHelper implements AdjustHelperInterface, OnAttributionChangedListener {
+
+ private static final String LOGTAG = AdjustHelper.class.getSimpleName();
+ private AttributionHelperListener attributionListener;
+
+ public void onCreate(final Context context, final String maybeAppToken, final AttributionHelperListener listener) {
+ final String environment;
+ final LogLevel logLevel;
+ if (AppConstants.MOZILLA_OFFICIAL) {
+ environment = AdjustConfig.ENVIRONMENT_PRODUCTION;
+ logLevel = LogLevel.WARN;
+ } else {
+ environment = AdjustConfig.ENVIRONMENT_SANDBOX;
+ logLevel = LogLevel.VERBOSE;
+ }
+ if (maybeAppToken == null) {
+ // We've got install tracking turned on -- we better have a token!
+ throw new IllegalArgumentException("maybeAppToken must not be null");
+ }
+ attributionListener = listener;
+ AdjustConfig config = new AdjustConfig(context, maybeAppToken, environment);
+ config.setLogLevel(logLevel);
+ config.setOnAttributionChangedListener(this);
+ Adjust.onCreate(config);
+ }
+
+ public void onPause() {
+ Adjust.onPause();
+ }
+
+ public void onResume() {
+ Adjust.onResume();
+ }
+
+ public void setEnabled(final boolean isEnabled) {
+ Adjust.setEnabled(isEnabled);
+ }
+
+ public void onReceive(final Context context, final Intent intent) {
+ new AdjustReferrerReceiver().onReceive(context, intent);
+ }
+
+ @Override
+ public void onAttributionChanged(AdjustAttribution attribution) {
+ if (attributionListener == null) {
+ throw new IllegalStateException("Expected non-null attribution listener.");
+ }
+
+ if (attribution == null) {
+ Log.e(LOGTAG, "Adjust attribution is null; skipping campaign id retrieval.");
+ return;
+ }
+ attributionListener.onCampaignIdChanged(attribution.campaign);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java
new file mode 100644
index 000000000..aeb7b4334
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AdjustHelperInterface.java
@@ -0,0 +1,22 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.adjust;
+
+import android.content.Context;
+import android.content.Intent;
+
+public interface AdjustHelperInterface {
+ /**
+ * Register the Application with the Adjust SDK.
+ * @param appToken the (secret!) Adjust SDK per-application token to register with; may be null.
+ */
+ void onCreate(final Context context, final String appToken, final AttributionHelperListener listener);
+ void onPause();
+ void onResume();
+
+ void setEnabled(final boolean isEnabled);
+ void onReceive(final Context context, final Intent intent);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java b/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java
new file mode 100644
index 000000000..6dadd2261
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/adjust/AttributionHelperListener.java
@@ -0,0 +1,17 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.adjust;
+
+/**
+ * Because of how our build module dependencies are structured, we aren't able to use
+ * the {@link com.adjust.sdk.OnAttributionChangedListener} directly outside of {@link AdjustHelper}.
+ * If the Adjust SDK is enabled, this listener should be notified when {@link com.adjust.sdk.OnAttributionChangedListener}
+ * is fired (i.e. this listener would be daisy-chained to the Adjust one). The listener also
+ * inherits thread-safety from GeckoSharedPrefs which is used to store the campaign ID.
+ */
+public interface AttributionHelperListener {
+ void onCampaignIdChanged(String campaignId);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java b/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java
new file mode 100644
index 000000000..ddfed84bd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/adjust/StubAdjustHelper.java
@@ -0,0 +1,31 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.adjust;
+
+import android.content.Context;
+import android.content.Intent;
+
+public class StubAdjustHelper implements AdjustHelperInterface {
+ public void onCreate(final Context context, final String appToken, final AttributionHelperListener listener) {
+ // Do nothing.
+ }
+
+ public void onPause() {
+ // Do nothing.
+ }
+
+ public void onResume() {
+ // Do nothing.
+ }
+
+ public void setEnabled(final boolean isEnabled) {
+ // Do nothing.
+ }
+
+ public void onReceive(final Context context, final Intent intent) {
+ // Do nothing.
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java b/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java
new file mode 100644
index 000000000..63e8e168e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/animation/AnimationUtils.java
@@ -0,0 +1,21 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+package org.mozilla.gecko.animation;
+
+import android.content.Context;
+
+public class AnimationUtils {
+ private static long mShortDuration = -1;
+
+ public static long getShortDuration(Context context) {
+ if (mShortDuration < 0) {
+ mShortDuration = context.getResources().getInteger(android.R.integer.config_shortAnimTime);
+ }
+ return mShortDuration;
+ }
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java b/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java
new file mode 100644
index 000000000..bf8007bbf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/animation/HeightChangeAnimation.java
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.animation;
+
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+
+public class HeightChangeAnimation extends Animation {
+ int mFromHeight;
+ int mToHeight;
+ View mView;
+
+ public HeightChangeAnimation(View view, int fromHeight, int toHeight) {
+ mView = view;
+ mFromHeight = fromHeight;
+ mToHeight = toHeight;
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+ mView.getLayoutParams().height = Math.round((mFromHeight * (1 - interpolatedTime)) + (mToHeight * interpolatedTime));
+ mView.requestLayout();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java b/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java
new file mode 100644
index 000000000..dc2403bbd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/animation/PropertyAnimator.java
@@ -0,0 +1,342 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.animation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.AppConstants.Versions;
+
+import android.os.Handler;
+import android.support.v4.view.ViewCompat;
+import android.view.Choreographer;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.animation.AnimationUtils;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
+
+public class PropertyAnimator implements Runnable {
+ private static final String LOGTAG = "GeckoPropertyAnimator";
+
+ public static enum Property {
+ ALPHA,
+ TRANSLATION_X,
+ TRANSLATION_Y,
+ SCROLL_X,
+ SCROLL_Y,
+ WIDTH,
+ HEIGHT
+ }
+
+ private class ElementHolder {
+ View view;
+ Property property;
+ float from;
+ float to;
+ }
+
+ public static interface PropertyAnimationListener {
+ public void onPropertyAnimationStart();
+ public void onPropertyAnimationEnd();
+ }
+
+ private final Interpolator mInterpolator;
+ private long mStartTime;
+ private final long mDuration;
+ private final float mDurationReciprocal;
+ private final List<ElementHolder> mElementsList;
+ private List<PropertyAnimationListener> mListeners;
+ FramePoster mFramePoster;
+ private boolean mUseHardwareLayer;
+
+ public PropertyAnimator(long duration) {
+ this(duration, new DecelerateInterpolator());
+ }
+
+ public PropertyAnimator(long duration, Interpolator interpolator) {
+ mDuration = duration;
+ mDurationReciprocal = 1.0f / mDuration;
+ mInterpolator = interpolator;
+ mElementsList = new ArrayList<ElementHolder>();
+ mFramePoster = FramePoster.create(this);
+ mUseHardwareLayer = true;
+ }
+
+ public void setUseHardwareLayer(boolean useHardwareLayer) {
+ mUseHardwareLayer = useHardwareLayer;
+ }
+
+ public void attach(View view, Property property, float to) {
+ ElementHolder element = new ElementHolder();
+
+ element.view = view;
+ element.property = property;
+ element.to = to;
+
+ mElementsList.add(element);
+ }
+
+ public void addPropertyAnimationListener(PropertyAnimationListener listener) {
+ if (mListeners == null) {
+ mListeners = new ArrayList<PropertyAnimationListener>();
+ }
+
+ mListeners.add(listener);
+ }
+
+ public long getDuration() {
+ return mDuration;
+ }
+
+ public long getRemainingTime() {
+ int timePassed = (int) (AnimationUtils.currentAnimationTimeMillis() - mStartTime);
+ return mDuration - timePassed;
+ }
+
+ @Override
+ public void run() {
+ int timePassed = (int) (AnimationUtils.currentAnimationTimeMillis() - mStartTime);
+ if (timePassed >= mDuration) {
+ stop();
+ return;
+ }
+
+ float interpolation = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
+
+ for (ElementHolder element : mElementsList) {
+ float delta = element.from + ((element.to - element.from) * interpolation);
+ invalidate(element, delta);
+ }
+
+ mFramePoster.postNextAnimationFrame();
+ }
+
+ public void start() {
+ if (mDuration == 0) {
+ return;
+ }
+
+ mStartTime = AnimationUtils.currentAnimationTimeMillis();
+
+ // Fix the from value based on current position and property
+ for (ElementHolder element : mElementsList) {
+ if (element.property == Property.ALPHA)
+ element.from = ViewHelper.getAlpha(element.view);
+ else if (element.property == Property.TRANSLATION_Y)
+ element.from = ViewHelper.getTranslationY(element.view);
+ else if (element.property == Property.TRANSLATION_X)
+ element.from = ViewHelper.getTranslationX(element.view);
+ else if (element.property == Property.SCROLL_Y)
+ element.from = ViewHelper.getScrollY(element.view);
+ else if (element.property == Property.SCROLL_X)
+ element.from = ViewHelper.getScrollX(element.view);
+ else if (element.property == Property.WIDTH)
+ element.from = ViewHelper.getWidth(element.view);
+ else if (element.property == Property.HEIGHT)
+ element.from = ViewHelper.getHeight(element.view);
+
+ ViewCompat.setHasTransientState(element.view, true);
+
+ if (shouldEnableHardwareLayer(element))
+ element.view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ else
+ element.view.setDrawingCacheEnabled(true);
+ }
+
+ // Get ViewTreeObserver from any of the participant views
+ // in the animation.
+ final ViewTreeObserver treeObserver;
+ if (mElementsList.size() > 0) {
+ treeObserver = mElementsList.get(0).view.getViewTreeObserver();
+ } else {
+ treeObserver = null;
+ }
+
+ final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ if (treeObserver.isAlive()) {
+ treeObserver.removeOnPreDrawListener(this);
+ }
+
+ mFramePoster.postFirstAnimationFrame();
+ return true;
+ }
+ };
+
+ // Try to start animation after any on-going layout round
+ // in the current view tree. OnPreDrawListener seems broken
+ // on pre-Honeycomb devices, start animation immediatelly
+ // in this case.
+ if (treeObserver != null && treeObserver.isAlive()) {
+ treeObserver.addOnPreDrawListener(preDrawListener);
+ } else {
+ mFramePoster.postFirstAnimationFrame();
+ }
+
+ if (mListeners != null) {
+ for (PropertyAnimationListener listener : mListeners) {
+ listener.onPropertyAnimationStart();
+ }
+ }
+ }
+
+ /**
+ * Stop the animation, optionally snapping to the end position.
+ * onPropertyAnimationEnd is only called when snapping to the end position.
+ */
+ public void stop(boolean snapToEndPosition) {
+ mFramePoster.cancelAnimationFrame();
+
+ // Make sure to snap to the end position.
+ for (ElementHolder element : mElementsList) {
+ if (snapToEndPosition)
+ invalidate(element, element.to);
+
+ ViewCompat.setHasTransientState(element.view, false);
+
+ if (shouldEnableHardwareLayer(element)) {
+ element.view.setLayerType(View.LAYER_TYPE_NONE, null);
+ } else {
+ element.view.setDrawingCacheEnabled(false);
+ }
+ }
+
+ mElementsList.clear();
+
+ if (mListeners != null) {
+ if (snapToEndPosition) {
+ for (PropertyAnimationListener listener : mListeners) {
+ listener.onPropertyAnimationEnd();
+ }
+ }
+
+ mListeners.clear();
+ mListeners = null;
+ }
+ }
+
+ public void stop() {
+ stop(true);
+ }
+
+ private boolean shouldEnableHardwareLayer(ElementHolder element) {
+ if (!mUseHardwareLayer) {
+ return false;
+ }
+
+ if (!(element.view instanceof ViewGroup)) {
+ return false;
+ }
+
+ if (element.property == Property.ALPHA ||
+ element.property == Property.TRANSLATION_Y ||
+ element.property == Property.TRANSLATION_X) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private void invalidate(final ElementHolder element, final float delta) {
+ final View view = element.view;
+
+ // check to see if the view was detached between the check above and this code
+ // getting run on the UI thread.
+ if (view.getHandler() == null)
+ return;
+
+ if (element.property == Property.ALPHA)
+ ViewHelper.setAlpha(element.view, delta);
+ else if (element.property == Property.TRANSLATION_Y)
+ ViewHelper.setTranslationY(element.view, delta);
+ else if (element.property == Property.TRANSLATION_X)
+ ViewHelper.setTranslationX(element.view, delta);
+ else if (element.property == Property.SCROLL_Y)
+ ViewHelper.scrollTo(element.view, ViewHelper.getScrollX(element.view), (int) delta);
+ else if (element.property == Property.SCROLL_X)
+ ViewHelper.scrollTo(element.view, (int) delta, ViewHelper.getScrollY(element.view));
+ else if (element.property == Property.WIDTH)
+ ViewHelper.setWidth(element.view, (int) delta);
+ else if (element.property == Property.HEIGHT)
+ ViewHelper.setHeight(element.view, (int) delta);
+ }
+
+ private static abstract class FramePoster {
+ public static FramePoster create(Runnable r) {
+ if (Versions.feature16Plus) {
+ return new FramePosterPostJB(r);
+ }
+
+ return new FramePosterPreJB(r);
+ }
+
+ public abstract void postFirstAnimationFrame();
+ public abstract void postNextAnimationFrame();
+ public abstract void cancelAnimationFrame();
+ }
+
+ private static class FramePosterPreJB extends FramePoster {
+ // Default refresh rate in ms.
+ private static final int INTERVAL = 10;
+
+ private final Handler mHandler;
+ private final Runnable mRunnable;
+
+ public FramePosterPreJB(Runnable r) {
+ mHandler = new Handler();
+ mRunnable = r;
+ }
+
+ @Override
+ public void postFirstAnimationFrame() {
+ mHandler.post(mRunnable);
+ }
+
+ @Override
+ public void postNextAnimationFrame() {
+ mHandler.postDelayed(mRunnable, INTERVAL);
+ }
+
+ @Override
+ public void cancelAnimationFrame() {
+ mHandler.removeCallbacks(mRunnable);
+ }
+ }
+
+ private static class FramePosterPostJB extends FramePoster {
+ private final Choreographer mChoreographer;
+ private final Choreographer.FrameCallback mCallback;
+
+ public FramePosterPostJB(final Runnable r) {
+ mChoreographer = Choreographer.getInstance();
+
+ mCallback = new Choreographer.FrameCallback() {
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ r.run();
+ }
+ };
+ }
+
+ @Override
+ public void postFirstAnimationFrame() {
+ postNextAnimationFrame();
+ }
+
+ @Override
+ public void postNextAnimationFrame() {
+ mChoreographer.postFrameCallback(mCallback);
+ }
+
+ @Override
+ public void cancelAnimationFrame() {
+ mChoreographer.removeFrameCallback(mCallback);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java b/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java
new file mode 100644
index 000000000..7e8377f55
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/animation/Rotate3DAnimation.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2007 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.animation;
+
+import android.view.animation.Animation;
+import android.view.animation.Transformation;
+
+import android.graphics.Camera;
+import android.graphics.Matrix;
+
+/**
+ * An animation that rotates the view on the Y axis between two specified angles.
+ * This animation also adds a translation on the Z axis (depth) to improve the effect.
+ */
+public class Rotate3DAnimation extends Animation {
+ private final float mFromDegrees;
+ private final float mToDegrees;
+
+ private final float mCenterX;
+ private final float mCenterY;
+
+ private final float mDepthZ;
+ private final boolean mReverse;
+ private Camera mCamera;
+
+ private int mWidth = 1;
+ private int mHeight = 1;
+
+ /**
+ * Creates a new 3D rotation on the Y axis. The rotation is defined by its
+ * start angle and its end angle. Both angles are in degrees. The rotation
+ * is performed around a center point on the 2D space, defined by a pair
+ * of X and Y coordinates, called centerX and centerY. When the animation
+ * starts, a translation on the Z axis (depth) is performed. The length
+ * of the translation can be specified, as well as whether the translation
+ * should be reversed in time.
+ *
+ * @param fromDegrees the start angle of the 3D rotation
+ * @param toDegrees the end angle of the 3D rotation
+ * @param centerX the X center of the 3D rotation
+ * @param centerY the Y center of the 3D rotation
+ * @param reverse true if the translation should be reversed, false otherwise
+ */
+ public Rotate3DAnimation(float fromDegrees, float toDegrees,
+ float centerX, float centerY, float depthZ, boolean reverse) {
+ mFromDegrees = fromDegrees;
+ mToDegrees = toDegrees;
+ mCenterX = centerX;
+ mCenterY = centerY;
+ mDepthZ = depthZ;
+ mReverse = reverse;
+ }
+
+ @Override
+ public void initialize(int width, int height, int parentWidth, int parentHeight) {
+ super.initialize(width, height, parentWidth, parentHeight);
+ mCamera = new Camera();
+ mWidth = width;
+ mHeight = height;
+ }
+
+ @Override
+ protected void applyTransformation(float interpolatedTime, Transformation t) {
+ final float fromDegrees = mFromDegrees;
+ float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);
+
+ final Camera camera = mCamera;
+ final Matrix matrix = t.getMatrix();
+
+ camera.save();
+ if (mReverse) {
+ camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
+ } else {
+ camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
+ }
+ camera.rotateX(degrees);
+ camera.getMatrix(matrix);
+ camera.restore();
+
+ matrix.preTranslate(-mCenterX * mWidth, -mCenterY * mHeight);
+ matrix.postTranslate(mCenterX * mWidth, mCenterY * mHeight);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java b/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java
new file mode 100644
index 000000000..3ea2e8437
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/animation/ViewHelper.java
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.animation;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+public final class ViewHelper {
+ private ViewHelper() {
+ }
+
+ public static float getTranslationX(View view) {
+ if (view != null) {
+ return view.getTranslationX();
+ }
+
+ return 0;
+ }
+
+ public static void setTranslationX(View view, float translationX) {
+ if (view != null) {
+ view.setTranslationX(translationX);
+ }
+ }
+
+ public static float getTranslationY(View view) {
+ if (view != null) {
+ return view.getTranslationY();
+ }
+
+ return 0;
+ }
+
+ public static void setTranslationY(View view, float translationY) {
+ if (view != null) {
+ view.setTranslationY(translationY);
+ }
+ }
+
+ public static float getAlpha(View view) {
+ if (view != null) {
+ return view.getAlpha();
+ }
+
+ return 1;
+ }
+
+ public static void setAlpha(View view, float alpha) {
+ if (view != null) {
+ view.setAlpha(alpha);
+ }
+ }
+
+ public static int getWidth(View view) {
+ if (view != null) {
+ return view.getWidth();
+ }
+
+ return 0;
+ }
+
+ public static void setWidth(View view, int width) {
+ if (view != null) {
+ ViewGroup.LayoutParams lp = view.getLayoutParams();
+ lp.width = width;
+ view.setLayoutParams(lp);
+ }
+ }
+
+ public static int getHeight(View view) {
+ if (view != null) {
+ return view.getHeight();
+ }
+
+ return 0;
+ }
+
+ public static void setHeight(View view, int height) {
+ if (view != null) {
+ ViewGroup.LayoutParams lp = view.getLayoutParams();
+ lp.height = height;
+ view.setLayoutParams(lp);
+ }
+ }
+
+ public static int getScrollX(View view) {
+ if (view != null) {
+ return view.getScrollX();
+ }
+
+ return 0;
+ }
+
+ public static int getScrollY(View view) {
+ if (view != null) {
+ return view.getScrollY();
+ }
+
+ return 0;
+ }
+
+ public static void scrollTo(View view, int scrollX, int scrollY) {
+ if (view != null) {
+ view.scrollTo(scrollX, scrollY);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java
new file mode 100644
index 000000000..447b837e8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupController.java
@@ -0,0 +1,81 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.cleanup;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.support.annotation.VisibleForTesting;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Encapsulates the code to run the {@link FileCleanupService}. Call
+ * {@link #startIfReady(Context, SharedPreferences, String)} to start the clean-up.
+ *
+ * Note: for simplicity, the current implementation does not cache which
+ * files have been cleaned up and will attempt to delete the same files
+ * each time it is run. If the file deletion list grows large, consider
+ * keeping a cache.
+ */
+public class FileCleanupController {
+
+ private static final long MILLIS_BETWEEN_CLEANUPS = TimeUnit.DAYS.toMillis(7);
+ @VisibleForTesting static final String PREF_LAST_CLEANUP_MILLIS = "cleanup.lastFileCleanupMillis";
+
+ // These will be prepended with the path of the profile we're cleaning up.
+ private static final String[] PROFILE_FILES_TO_CLEANUP = new String[] {
+ "health.db",
+ "health.db-journal",
+ "health.db-shm",
+ "health.db-wal",
+ };
+
+ /**
+ * Starts the clean-up if it's time to clean-up, otherwise returns. For simplicity,
+ * it does not schedule the cleanup for some point in the future - this method will
+ * have to be called again (i.e. polled) in order to run the clean-up service.
+ *
+ * @param context Context of the calling {@link android.app.Activity}
+ * @param sharedPrefs The {@link SharedPreferences} instance to store the controller state to
+ * @param profilePath The path to the profile the service should clean-up files from
+ */
+ public static void startIfReady(final Context context, final SharedPreferences sharedPrefs, final String profilePath) {
+ if (!isCleanupReady(sharedPrefs)) {
+ return;
+ }
+
+ recordCleanupScheduled(sharedPrefs);
+
+ final Intent fileCleanupIntent = new Intent(context, FileCleanupService.class);
+ fileCleanupIntent.setAction(FileCleanupService.ACTION_DELETE_FILES);
+ fileCleanupIntent.putExtra(FileCleanupService.EXTRA_FILE_PATHS_TO_DELETE, getFilesToCleanup(profilePath + "/"));
+ context.startService(fileCleanupIntent);
+ }
+
+ private static boolean isCleanupReady(final SharedPreferences sharedPrefs) {
+ final long lastCleanupMillis = sharedPrefs.getLong(PREF_LAST_CLEANUP_MILLIS, -1);
+ return lastCleanupMillis + MILLIS_BETWEEN_CLEANUPS < System.currentTimeMillis();
+ }
+
+ private static void recordCleanupScheduled(final SharedPreferences sharedPrefs) {
+ final SharedPreferences.Editor editor = sharedPrefs.edit();
+ editor.putLong(PREF_LAST_CLEANUP_MILLIS, System.currentTimeMillis()).apply();
+ }
+
+ @VisibleForTesting
+ static ArrayList<String> getFilesToCleanup(final String profilePath) {
+ final ArrayList<String> out = new ArrayList<>(PROFILE_FILES_TO_CLEANUP.length);
+ for (final String path : PROFILE_FILES_TO_CLEANUP) {
+ // Append a file separator, just in-case the caller didn't include one.
+ out.add(profilePath + File.separator + path);
+ }
+ return out;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java
new file mode 100644
index 000000000..76aff733a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/cleanup/FileCleanupService.java
@@ -0,0 +1,80 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.cleanup;
+
+import android.app.IntentService;
+import android.content.Intent;
+import android.util.Log;
+
+import java.io.File;
+import java.util.ArrayList;
+
+/**
+ * An IntentService to delete files.
+ *
+ * It takes an {@link ArrayList} of String file paths to delete via the extra
+ * {@link #EXTRA_FILE_PATHS_TO_DELETE}. If these file paths are directories, they will
+ * not be traversed recursively and will only be deleted if empty. This is to avoid accidentally
+ * trashing a users' profile if a folder is accidentally listed.
+ *
+ * An IntentService was chosen because:
+ * * It generally won't be killed when the Activity is
+ * * (unlike HandlerThread) The system handles scheduling, prioritizing,
+ * and shutting down the underlying background thread
+ * * (unlike an existing background thread) We don't block our background operations
+ * for this, which doesn't directly affect the user.
+ *
+ * The major trade-off is that this Service is very dangerous if it's exported... so don't do that!
+ */
+public class FileCleanupService extends IntentService {
+ private static final String LOGTAG = "Gecko" + FileCleanupService.class.getSimpleName();
+ private static final String WORKER_THREAD_NAME = LOGTAG + "Worker";
+
+ public static final String ACTION_DELETE_FILES = "org.mozilla.gecko.intent.action.DELETE_FILES";
+ public static final String EXTRA_FILE_PATHS_TO_DELETE = "org.mozilla.gecko.file_paths_to_delete";
+
+ public FileCleanupService() {
+ super(WORKER_THREAD_NAME);
+
+ // We're likely to get scheduled again - let's wait until then in order to avoid:
+ // * The coding complexity of re-running this
+ // * Consuming system resources: we were probably killed for resource conservation purposes
+ setIntentRedelivery(false);
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ if (!isIntentValid(intent)) {
+ return;
+ }
+
+ final ArrayList<String> filesToDelete = intent.getStringArrayListExtra(EXTRA_FILE_PATHS_TO_DELETE);
+ for (final String path : filesToDelete) {
+ final File file = new File(path);
+ file.delete();
+ }
+ }
+
+ private static boolean isIntentValid(final Intent intent) {
+ if (intent == null) {
+ Log.w(LOGTAG, "Received null intent");
+ return false;
+ }
+
+ if (!intent.getAction().equals(ACTION_DELETE_FILES)) {
+ Log.w(LOGTAG, "Received unknown intent action: " + intent.getAction());
+ return false;
+ }
+
+ if (!intent.hasExtra(EXTRA_FILE_PATHS_TO_DELETE)) {
+ Log.w(LOGTAG, "Received intent with no files extra");
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java b/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java
new file mode 100644
index 000000000..b1bf567b0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/customtabs/CustomTabsActivity.java
@@ -0,0 +1,177 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.customtabs;
+
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.Toolbar;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.TextView;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.util.ColorUtil;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.reflect.Field;
+
+import static android.support.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR;
+
+public class CustomTabsActivity extends GeckoApp implements Tabs.OnTabsChangedListener {
+ private static final String LOGTAG = "CustomTabsActivity";
+ private static final String SAVED_TOOLBAR_COLOR = "SavedToolbarColor";
+ private static final String SAVED_TOOLBAR_TITLE = "SavedToolbarTitle";
+ private static final int NO_COLOR = -1;
+ private Toolbar toolbar;
+
+ private ActionBar actionBar;
+ private int tabId = -1;
+ private boolean useDomainTitle = true;
+
+ private int toolbarColor;
+ private String toolbarTitle;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState != null) {
+ toolbarColor = savedInstanceState.getInt(SAVED_TOOLBAR_COLOR, NO_COLOR);
+ toolbarTitle = savedInstanceState.getString(SAVED_TOOLBAR_TITLE, AppConstants.MOZ_APP_BASENAME);
+ } else {
+ toolbarColor = NO_COLOR;
+ toolbarTitle = AppConstants.MOZ_APP_BASENAME;
+ }
+
+ Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
+ updateActionBarWithToolbar(toolbar);
+ try {
+ // Since we don't create the Toolbar's TextView ourselves, this seems
+ // to be the only way of changing the ellipsize setting.
+ Field f = toolbar.getClass().getDeclaredField("mTitleTextView");
+ f.setAccessible(true);
+ TextView textView = (TextView) f.get(toolbar);
+ textView.setEllipsize(TextUtils.TruncateAt.START);
+ } catch (Exception e) {
+ // If we can't ellipsize at the start of the title, we shouldn't display the host
+ // so as to avoid displaying a misleadingly truncated host.
+ Log.w(LOGTAG, "Failed to get Toolbar TextView, using default title.");
+ useDomainTitle = false;
+ }
+ actionBar = getSupportActionBar();
+ actionBar.setTitle(toolbarTitle);
+ updateToolbarColor(toolbar);
+
+ toolbar.setNavigationOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onBackPressed();
+ }
+ });
+
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ @Override
+ public int getLayout() {
+ return R.layout.customtabs_activity;
+ }
+
+ @Override
+ protected void onDone() {
+ finish();
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ if (tab == null) {
+ return;
+ }
+
+ if (tabId >= 0 && tab.getId() != tabId) {
+ return;
+ }
+
+ if (msg == Tabs.TabEvents.LOCATION_CHANGE) {
+ tabId = tab.getId();
+ final Uri uri = Uri.parse(tab.getURL());
+ String title = null;
+ if (uri != null) {
+ title = uri.getHost();
+ }
+ if (!useDomainTitle || title == null || title.isEmpty()) {
+ toolbarTitle = AppConstants.MOZ_APP_BASENAME;
+ } else {
+ toolbarTitle = title;
+ }
+ actionBar.setTitle(toolbarTitle);
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ outState.putInt(SAVED_TOOLBAR_COLOR, toolbarColor);
+ outState.putString(SAVED_TOOLBAR_TITLE, toolbarTitle);
+ }
+
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void updateActionBarWithToolbar(final Toolbar toolbar) {
+ setSupportActionBar(toolbar);
+ final ActionBar ab = getSupportActionBar();
+ if (ab != null) {
+ ab.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ private void updateToolbarColor(final Toolbar toolbar) {
+ if (toolbarColor == NO_COLOR) {
+ final int color = getIntent().getIntExtra(EXTRA_TOOLBAR_COLOR, NO_COLOR);
+ if (color == NO_COLOR) {
+ return;
+ }
+ toolbarColor = color;
+ }
+
+ final int titleTextColor = ColorUtil.getReadableTextColor(toolbarColor);
+
+ toolbar.setBackgroundColor(toolbarColor);
+ toolbar.setTitleTextColor(titleTextColor);
+ final Window window = getWindow();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+ window.setStatusBarColor(ColorUtil.darken(toolbarColor, 0.25));
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java b/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java
new file mode 100644
index 000000000..7960f7832
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/customtabs/GeckoCustomTabsService.java
@@ -0,0 +1,65 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.customtabs;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.customtabs.CustomTabsService;
+import android.support.customtabs.CustomTabsSessionToken;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoService;
+
+import java.util.List;
+
+/**
+ * Custom tabs service external, third-party apps connect to.
+ */
+public class GeckoCustomTabsService extends CustomTabsService {
+ private static final String LOGTAG = "GeckoCustomTabsService";
+ private static final boolean DEBUG = false;
+
+ @Override
+ protected boolean updateVisuals(CustomTabsSessionToken sessionToken, Bundle bundle) {
+ Log.v(LOGTAG, "updateVisuals()");
+
+ return false;
+ }
+
+ @Override
+ protected boolean warmup(long flags) {
+ if (DEBUG) {
+ Log.v(LOGTAG, "warming up...");
+ }
+
+ GeckoService.startGecko(GeckoProfile.initFromArgs(this, null), null, getApplicationContext());
+
+ return true;
+ }
+
+ @Override
+ protected boolean newSession(CustomTabsSessionToken sessionToken) {
+ Log.v(LOGTAG, "newSession()");
+
+ // Pretend session has been started
+ return true;
+ }
+
+ @Override
+ protected boolean mayLaunchUrl(CustomTabsSessionToken sessionToken, Uri uri, Bundle bundle, List<Bundle> list) {
+ Log.v(LOGTAG, "mayLaunchUrl()");
+
+ return false;
+ }
+
+ @Override
+ protected Bundle extraCommand(String commandName, Bundle bundle) {
+ Log.v(LOGTAG, "extraCommand()");
+
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java
new file mode 100644
index 000000000..2e056cc1e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/AbstractPerProfileDatabaseProvider.java
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+
+/**
+ * The base class for ContentProviders that wish to use a different DB
+ * for each profile.
+ *
+ * This class has logic shared between ordinary per-profile CPs and
+ * those that wish to share DB connections between CPs.
+ */
+public abstract class AbstractPerProfileDatabaseProvider extends AbstractTransactionalProvider {
+
+ /**
+ * Extend this to provide access to your own map of shared databases. This
+ * is a method so that your subclass doesn't collide with others!
+ */
+ protected abstract PerProfileDatabases<? extends SQLiteOpenHelper> getDatabases();
+
+ /*
+ * Fetches a readable database based on the profile indicated in the
+ * passed URI. If the URI does not contain a profile param, the default profile
+ * is used.
+ *
+ * @param uri content URI optionally indicating the profile of the user
+ * @return instance of a readable SQLiteDatabase
+ */
+ @Override
+ protected SQLiteDatabase getReadableDatabase(Uri uri) {
+ String profile = null;
+ if (uri != null) {
+ profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE);
+ }
+
+ return getDatabases().getDatabaseHelperForProfile(profile, isTest(uri)).getReadableDatabase();
+ }
+
+ /*
+ * Fetches a writable database based on the profile indicated in the
+ * passed URI. If the URI does not contain a profile param, the default profile
+ * is used
+ *
+ * @param uri content URI optionally indicating the profile of the user
+ * @return instance of a writable SQLiteDatabase
+ */
+ @Override
+ protected SQLiteDatabase getWritableDatabase(Uri uri) {
+ String profile = null;
+ if (uri != null) {
+ profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE);
+ }
+
+ return getDatabases().getDatabaseHelperForProfile(profile, isTest(uri)).getWritableDatabase();
+ }
+
+ protected SQLiteDatabase getWritableDatabaseForProfile(String profile, boolean isTest) {
+ return getDatabases().getDatabaseHelperForProfile(profile, isTest).getWritableDatabase();
+ }
+
+ /**
+ * This method should ONLY be used for testing purposes.
+ *
+ * @param uri content URI optionally indicating the profile of the user
+ * @return instance of a writable SQLiteDatabase
+ */
+ @Override
+ @RobocopTarget
+ public SQLiteDatabase getWritableDatabaseForTesting(Uri uri) {
+ return getWritableDatabase(uri);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java
new file mode 100644
index 000000000..7e289b76f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/AbstractTransactionalProvider.java
@@ -0,0 +1,328 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.AppConstants.Versions;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * This abstract class exists to capture some of the transaction-handling
+ * commonalities in Fennec's DB layer.
+ *
+ * In particular, this abstracts DB access, batching, and a particular
+ * transaction approach.
+ *
+ * That approach is: subclasses implement the abstract methods
+ * {@link #insertInTransaction(android.net.Uri, android.content.ContentValues)},
+ * {@link #deleteInTransaction(android.net.Uri, String, String[])}, and
+ * {@link #updateInTransaction(android.net.Uri, android.content.ContentValues, String, String[])}.
+ *
+ * These are all called expecting a transaction to be established, so failed
+ * modifications can be rolled-back, and work batched.
+ *
+ * If no transaction is established, that's not a problem. Transaction nesting
+ * can be avoided by using {@link #beginWrite(SQLiteDatabase)}.
+ *
+ * The decision of when to begin a transaction is left to the subclasses,
+ * primarily to avoid the pattern of a transaction being begun, a read occurring,
+ * and then a write being necessary. This lock upgrade can result in SQLITE_BUSY,
+ * which we don't handle well. Better to avoid starting a transaction too soon!
+ *
+ * You are probably interested in some subclasses:
+ *
+ * * {@link AbstractPerProfileDatabaseProvider} provides a simple abstraction for
+ * querying databases that are stored in the user's profile directory.
+ * * {@link PerProfileDatabaseProvider} is a simple version that only allows a
+ * single ContentProvider to access each per-profile database.
+ * * {@link SharedBrowserDatabaseProvider} is an example of a per-profile provider
+ * that allows for multiple providers to safely work with the same databases.
+ */
+@SuppressWarnings("javadoc")
+public abstract class AbstractTransactionalProvider extends ContentProvider {
+ private static final String LOGTAG = "GeckoTransProvider";
+
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+
+ protected abstract SQLiteDatabase getReadableDatabase(Uri uri);
+ protected abstract SQLiteDatabase getWritableDatabase(Uri uri);
+
+ public abstract SQLiteDatabase getWritableDatabaseForTesting(Uri uri);
+
+ protected abstract Uri insertInTransaction(Uri uri, ContentValues values);
+ protected abstract int deleteInTransaction(Uri uri, String selection, String[] selectionArgs);
+ protected abstract int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs);
+
+ /**
+ * Track whether we're in a batch operation.
+ *
+ * When we're in a batch operation, individual write steps won't even try
+ * to start a transaction... and neither will they attempt to finish one.
+ *
+ * Set this to <code>Boolean.TRUE</code> when you're entering a batch --
+ * a section of code in which {@link ContentProvider} methods will be
+ * called, but nested transactions should not be started. Callers are
+ * responsible for beginning and ending the enclosing transaction, and
+ * for setting this to <code>Boolean.FALSE</code> when done.
+ *
+ * This is a ThreadLocal separate from `db.inTransaction` because batched
+ * operations start transactions independent of individual ContentProvider
+ * operations. This doesn't work well with the entire concept of this
+ * abstract class -- that is, automatically beginning and ending transactions
+ * for each insert/delete/update operation -- and doing so without
+ * causing arbitrary nesting requires external tracking.
+ *
+ * Note that beginWrite takes a DB argument, but we don't differentiate
+ * between databases in this tracking flag. If your ContentProvider manages
+ * multiple database transactions within the same thread, you'll need to
+ * amend this scheme -- but then, you're already doing some serious wizardry,
+ * so rock on.
+ */
+ final ThreadLocal<Boolean> isInBatchOperation = new ThreadLocal<Boolean>();
+
+ private boolean isInBatch() {
+ final Boolean isInBatch = isInBatchOperation.get();
+ if (isInBatch == null) {
+ return false;
+ }
+
+ return isInBatch;
+ }
+
+ /**
+ * If we're not currently in a transaction, and we should be, start one.
+ */
+ protected void beginWrite(final SQLiteDatabase db) {
+ if (isInBatch()) {
+ trace("Not bothering with an intermediate write transaction: inside batch operation.");
+ return;
+ }
+
+ if (!db.inTransaction()) {
+ trace("beginWrite: beginning transaction.");
+ db.beginTransaction();
+ }
+ }
+
+ /**
+ * If we're not in a batch, but we are in a write transaction, mark it as
+ * successful.
+ */
+ protected void markWriteSuccessful(final SQLiteDatabase db) {
+ if (isInBatch()) {
+ trace("Not marking write successful: inside batch operation.");
+ return;
+ }
+
+ if (db.inTransaction()) {
+ trace("Marking write transaction successful.");
+ db.setTransactionSuccessful();
+ }
+ }
+
+ /**
+ * If we're not in a batch, but we are in a write transaction,
+ * end it.
+ *
+ * @see PerProfileDatabaseProvider#markWriteSuccessful(SQLiteDatabase)
+ */
+ protected void endWrite(final SQLiteDatabase db) {
+ if (isInBatch()) {
+ trace("Not ending write: inside batch operation.");
+ return;
+ }
+
+ if (db.inTransaction()) {
+ trace("endWrite: ending transaction.");
+ db.endTransaction();
+ }
+ }
+
+ protected void beginBatch(final SQLiteDatabase db) {
+ trace("Beginning batch.");
+ isInBatchOperation.set(Boolean.TRUE);
+ db.beginTransaction();
+ }
+
+ protected void markBatchSuccessful(final SQLiteDatabase db) {
+ if (isInBatch()) {
+ trace("Marking batch successful.");
+ db.setTransactionSuccessful();
+ return;
+ }
+ Log.w(LOGTAG, "Unexpectedly asked to mark batch successful, but not in batch!");
+ throw new IllegalStateException("Not in batch.");
+ }
+
+ protected void endBatch(final SQLiteDatabase db) {
+ trace("Ending batch.");
+ db.endTransaction();
+ isInBatchOperation.set(Boolean.FALSE);
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ trace("Calling delete on URI: " + uri + ", " + selection + ", " + selectionArgs);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ int deleted = 0;
+
+ try {
+ deleted = deleteInTransaction(uri, selection, selectionArgs);
+ markWriteSuccessful(db);
+ } finally {
+ endWrite(db);
+ }
+
+ if (deleted > 0) {
+ final boolean shouldSyncToNetwork = !isCallerSync(uri);
+ getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork);
+ }
+
+ return deleted;
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ trace("Calling insert on URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ Uri result = null;
+ try {
+ result = insertInTransaction(uri, values);
+ markWriteSuccessful(db);
+ } catch (SQLException sqle) {
+ Log.e(LOGTAG, "exception in DB operation", sqle);
+ } catch (UnsupportedOperationException uoe) {
+ Log.e(LOGTAG, "don't know how to perform that insert", uoe);
+ } finally {
+ endWrite(db);
+ }
+
+ if (result != null) {
+ final boolean shouldSyncToNetwork = !isCallerSync(uri);
+ getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork);
+ }
+
+ return result;
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ trace("Calling update on URI: " + uri + ", " + selection + ", " + selectionArgs);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ int updated = 0;
+
+ try {
+ updated = updateInTransaction(uri, values, selection,
+ selectionArgs);
+ markWriteSuccessful(db);
+ } finally {
+ endWrite(db);
+ }
+
+ if (updated > 0) {
+ final boolean shouldSyncToNetwork = !isCallerSync(uri);
+ getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork);
+ }
+
+ return updated;
+ }
+
+ @Override
+ public int bulkInsert(Uri uri, ContentValues[] values) {
+ if (values == null) {
+ return 0;
+ }
+
+ int numValues = values.length;
+ int successes = 0;
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ debug("bulkInsert: explicitly starting transaction.");
+ beginBatch(db);
+
+ try {
+ for (int i = 0; i < numValues; i++) {
+ insertInTransaction(uri, values[i]);
+ successes++;
+ }
+ trace("Flushing DB bulkinsert...");
+ markBatchSuccessful(db);
+ } finally {
+ debug("bulkInsert: explicitly ending transaction.");
+ endBatch(db);
+ }
+
+ if (successes > 0) {
+ final boolean shouldSyncToNetwork = !isCallerSync(uri);
+ getContext().getContentResolver().notifyChange(uri, null, shouldSyncToNetwork);
+ }
+
+ return successes;
+ }
+
+ /**
+ * Indicates whether a query should include deleted fields
+ * based on the URI.
+ * @param uri query URI
+ */
+ protected static boolean shouldShowDeleted(Uri uri) {
+ String showDeleted = uri.getQueryParameter(BrowserContract.PARAM_SHOW_DELETED);
+ return !TextUtils.isEmpty(showDeleted);
+ }
+
+ /**
+ * Indicates whether an insertion should be made if a record doesn't
+ * exist, based on the URI.
+ * @param uri query URI
+ */
+ protected static boolean shouldUpdateOrInsert(Uri uri) {
+ String insertIfNeeded = uri.getQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED);
+ return Boolean.parseBoolean(insertIfNeeded);
+ }
+
+ /**
+ * Indicates whether query is a test based on the URI.
+ * @param uri query URI
+ */
+ protected static boolean isTest(Uri uri) {
+ if (uri == null) {
+ return false;
+ }
+ String isTest = uri.getQueryParameter(BrowserContract.PARAM_IS_TEST);
+ return !TextUtils.isEmpty(isTest);
+ }
+
+ /**
+ * Return true of the query is from Firefox Sync.
+ * @param uri query URI
+ */
+ protected static boolean isCallerSync(Uri uri) {
+ String isSync = uri.getQueryParameter(BrowserContract.PARAM_IS_SYNC);
+ return !TextUtils.isEmpty(isSync);
+ }
+
+ protected static void trace(String message) {
+ if (logVerbose) {
+ Log.v(LOGTAG, message);
+ }
+ }
+
+ protected static void debug(String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java b/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java
new file mode 100644
index 000000000..418d547ed
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BaseTable.java
@@ -0,0 +1,64 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.Log;
+
+// BaseTable provides a basic implementation of a Table for tables that don't require advanced operations during
+// insert, delete, update, or query operations. Implementors must still provide onCreate and onUpgrade operations.
+public abstract class BaseTable implements Table {
+ private static final String LOGTAG = "GeckoBaseTable";
+
+ private static final boolean DEBUG = false;
+
+ protected static void log(String msg) {
+ if (DEBUG) {
+ Log.i(LOGTAG, msg);
+ }
+ }
+
+ // Table implementation
+ @Override
+ public Table.ContentProviderInfo[] getContentProviderInfo() {
+ return new Table.ContentProviderInfo[0];
+ }
+
+ // Returns the name of the table to modify/query
+ protected abstract String getTable();
+
+ // Table implementation
+ @Override
+ public Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] columns, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit) {
+ Cursor c = db.query(getTable(), columns, selection, selectionArgs, groupBy, null, sortOrder, limit);
+ log("query " + columns + " in " + selection + " = " + c);
+ return c;
+ }
+
+ @Override
+ public int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs) {
+ int updated = db.updateWithOnConflict(getTable(), values, selection, selectionArgs, SQLiteDatabase.CONFLICT_REPLACE);
+ log("update " + values + " in " + selection + " = " + updated);
+ return updated;
+ }
+
+ @Override
+ public long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values) {
+ long inserted = db.insertOrThrow(getTable(), null, values);
+ log("insert " + values + " = " + inserted);
+ return inserted;
+ }
+
+ @Override
+ public int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs) {
+ int deleted = db.delete(getTable(), selection, selectionArgs);
+ log("delete " + selection + " = " + deleted);
+ return deleted;
+ }
+};
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
new file mode 100644
index 000000000..51c8d964f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -0,0 +1,785 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.AppConstants;
+
+import android.net.Uri;
+import android.support.annotation.NonNull;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+@RobocopTarget
+public class BrowserContract {
+ public static final String AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.browser";
+ public static final Uri AUTHORITY_URI = Uri.parse("content://" + AUTHORITY);
+
+ public static final String PASSWORDS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.passwords";
+ public static final Uri PASSWORDS_AUTHORITY_URI = Uri.parse("content://" + PASSWORDS_AUTHORITY);
+
+ public static final String FORM_HISTORY_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.formhistory";
+ public static final Uri FORM_HISTORY_AUTHORITY_URI = Uri.parse("content://" + FORM_HISTORY_AUTHORITY);
+
+ public static final String TABS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.tabs";
+ public static final Uri TABS_AUTHORITY_URI = Uri.parse("content://" + TABS_AUTHORITY);
+
+ public static final String HOME_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.home";
+ public static final Uri HOME_AUTHORITY_URI = Uri.parse("content://" + HOME_AUTHORITY);
+
+ public static final String PROFILES_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".profiles";
+ public static final Uri PROFILES_AUTHORITY_URI = Uri.parse("content://" + PROFILES_AUTHORITY);
+
+ public static final String READING_LIST_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.readinglist";
+ public static final Uri READING_LIST_AUTHORITY_URI = Uri.parse("content://" + READING_LIST_AUTHORITY);
+
+ public static final String SEARCH_HISTORY_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.searchhistory";
+ public static final Uri SEARCH_HISTORY_AUTHORITY_URI = Uri.parse("content://" + SEARCH_HISTORY_AUTHORITY);
+
+ public static final String LOGINS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.logins";
+ public static final Uri LOGINS_AUTHORITY_URI = Uri.parse("content://" + LOGINS_AUTHORITY);
+
+ public static final String PARAM_PROFILE = "profile";
+ public static final String PARAM_PROFILE_PATH = "profilePath";
+ public static final String PARAM_LIMIT = "limit";
+ public static final String PARAM_SUGGESTEDSITES_LIMIT = "suggestedsites_limit";
+ public static final String PARAM_TOPSITES_DISABLE_PINNED = "topsites_disable_pinned";
+ public static final String PARAM_IS_SYNC = "sync";
+ public static final String PARAM_SHOW_DELETED = "show_deleted";
+ public static final String PARAM_IS_TEST = "test";
+ public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed";
+ public static final String PARAM_INCREMENT_VISITS = "increment_visits";
+ public static final String PARAM_INCREMENT_REMOTE_AGGREGATES = "increment_remote_aggregates";
+ public static final String PARAM_EXPIRE_PRIORITY = "priority";
+ public static final String PARAM_DATASET_ID = "dataset_id";
+ public static final String PARAM_GROUP_BY = "group_by";
+
+ static public enum ExpirePriority {
+ NORMAL,
+ AGGRESSIVE
+ }
+
+ /**
+ * Produces a SQL expression used for sorting results of the "combined" view by frecency.
+ * Combines remote and local frecency calculations, weighting local visits much heavier.
+ *
+ * @param includesBookmarks When URL is bookmarked, should we give it bonus frecency points?
+ * @param ascending Indicates if sorting order ascending
+ * @return Combined frecency sorting expression
+ */
+ static public String getCombinedFrecencySortOrder(boolean includesBookmarks, boolean ascending) {
+ final long now = System.currentTimeMillis();
+ StringBuilder order = new StringBuilder(getRemoteFrecencySQL(now) + " + " + getLocalFrecencySQL(now));
+
+ if (includesBookmarks) {
+ order.insert(0, "(CASE WHEN " + Combined.BOOKMARK_ID + " > -1 THEN 100 ELSE 0 END) + ");
+ }
+
+ order.append(ascending ? " ASC" : " DESC");
+ return order.toString();
+ }
+
+ /**
+ * See Bug 1265525 for details (explanation + graphs) on how Remote frecency compares to Local frecency for different
+ * combinations of visits count and age.
+ *
+ * @param now Base time in milliseconds for age calculation
+ * @return remote frecency SQL calculation
+ */
+ static public String getRemoteFrecencySQL(final long now) {
+ return getFrecencyCalculation(now, 1, 110, Combined.REMOTE_VISITS_COUNT, Combined.REMOTE_DATE_LAST_VISITED);
+ }
+
+ /**
+ * Local frecency SQL calculation. Note higher scale factor and squared visit count which achieve
+ * visits generated locally being much preferred over remote visits.
+ * See Bug 1265525 for details (explanation + comparison graphs).
+ *
+ * @param now Base time in milliseconds for age calculation
+ * @return local frecency SQL calculation
+ */
+ static public String getLocalFrecencySQL(final long now) {
+ String visitCountExpr = "(" + Combined.LOCAL_VISITS_COUNT + " + 2)";
+ visitCountExpr = visitCountExpr + " * " + visitCountExpr;
+
+ return getFrecencyCalculation(now, 2, 225, visitCountExpr, Combined.LOCAL_DATE_LAST_VISITED);
+ }
+
+ /**
+ * Our version of frecency is computed by scaling the number of visits by a multiplier
+ * that approximates Gaussian decay, based on how long ago the entry was last visited.
+ * Since we're limited by the math we can do with sqlite, we're calculating this
+ * approximation using the Cauchy distribution: multiplier = scale_const / (age^2 + scale_const).
+ * For example, with 15 as our scale parameter, we get a scale constant 15^2 = 225. Then:
+ * frecencyScore = numVisits * max(1, 100 * 225 / (age*age + 225)). (See bug 704977)
+ *
+ * @param now Base time in milliseconds for age calculation
+ * @param minFrecency Minimum allowed frecency value
+ * @param multiplier Scale constant
+ * @param visitCountExpr Expression which will produce a visit count
+ * @param lastVisitExpr Expression which will produce "last-visited" timestamp
+ * @return Frecency SQL calculation
+ */
+ static public String getFrecencyCalculation(final long now, final int minFrecency, final int multiplier, @NonNull final String visitCountExpr, @NonNull final String lastVisitExpr) {
+ final long nowInMicroseconds = now * 1000;
+ final long microsecondsPerDay = 86400000000L;
+ final String ageExpr = "(" + nowInMicroseconds + " - " + lastVisitExpr + ") / " + microsecondsPerDay;
+
+ return visitCountExpr + " * MAX(" + minFrecency + ", 100 * " + multiplier + " / (" + ageExpr + " * " + ageExpr + " + " + multiplier + "))";
+ }
+
+ @RobocopTarget
+ public interface CommonColumns {
+ public static final String _ID = "_id";
+ }
+
+ @RobocopTarget
+ public interface DateSyncColumns {
+ public static final String DATE_CREATED = "created";
+ public static final String DATE_MODIFIED = "modified";
+ }
+
+ @RobocopTarget
+ public interface SyncColumns extends DateSyncColumns {
+ public static final String GUID = "guid";
+ public static final String IS_DELETED = "deleted";
+ }
+
+ @RobocopTarget
+ public interface URLColumns {
+ public static final String URL = "url";
+ public static final String TITLE = "title";
+ }
+
+ @RobocopTarget
+ public interface FaviconColumns {
+ public static final String FAVICON = "favicon";
+ public static final String FAVICON_ID = "favicon_id";
+ public static final String FAVICON_URL = "favicon_url";
+ }
+
+ @RobocopTarget
+ public interface HistoryColumns {
+ public static final String DATE_LAST_VISITED = "date";
+ public static final String VISITS = "visits";
+ // Aggregates used to speed up top sites and search frecency-powered queries
+ public static final String LOCAL_VISITS = "visits_local";
+ public static final String REMOTE_VISITS = "visits_remote";
+ public static final String LOCAL_DATE_LAST_VISITED = "date_local";
+ public static final String REMOTE_DATE_LAST_VISITED = "date_remote";
+ }
+
+ @RobocopTarget
+ public interface VisitsColumns {
+ public static final String HISTORY_GUID = "history_guid";
+ public static final String VISIT_TYPE = "visit_type";
+ public static final String DATE_VISITED = "date";
+ // Used to distinguish between visits that were generated locally vs those that came in from Sync.
+ // Since we don't track "origin clientID" for visits, this is the best we can do for now.
+ public static final String IS_LOCAL = "is_local";
+ }
+
+ public interface PageMetadataColumns {
+ public static final String HISTORY_GUID = "history_guid";
+ public static final String DATE_CREATED = "created";
+ public static final String HAS_IMAGE = "has_image";
+ public static final String JSON = "json";
+ }
+
+ public interface DeletedColumns {
+ public static final String ID = "id";
+ public static final String GUID = "guid";
+ public static final String TIME_DELETED = "timeDeleted";
+ }
+
+ @RobocopTarget
+ public static final class Favicons implements CommonColumns, DateSyncColumns {
+ private Favicons() {}
+
+ public static final String TABLE_NAME = "favicons";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "favicons");
+
+ public static final String URL = "url";
+ public static final String DATA = "data";
+ public static final String PAGE_URL = "page_url";
+ }
+
+ @RobocopTarget
+ public static final class Thumbnails implements CommonColumns {
+ private Thumbnails() {}
+
+ public static final String TABLE_NAME = "thumbnails";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "thumbnails");
+
+ public static final String URL = "url";
+ public static final String DATA = "data";
+ }
+
+ public static final class Profiles {
+ private Profiles() {}
+ public static final String NAME = "name";
+ public static final String PATH = "path";
+ }
+
+ @RobocopTarget
+ public static final class Bookmarks implements CommonColumns, URLColumns, FaviconColumns, SyncColumns {
+ private Bookmarks() {}
+
+ public static final String TABLE_NAME = "bookmarks";
+
+ public static final String VIEW_WITH_FAVICONS = "bookmarks_with_favicons";
+
+ public static final String VIEW_WITH_ANNOTATIONS = "bookmarks_with_annotations";
+
+ public static final int FIXED_ROOT_ID = 0;
+ public static final int FAKE_DESKTOP_FOLDER_ID = -1;
+ public static final int FIXED_READING_LIST_ID = -2;
+ public static final int FIXED_PINNED_LIST_ID = -3;
+ public static final int FIXED_SCREENSHOT_FOLDER_ID = -4;
+ public static final int FAKE_READINGLIST_SMARTFOLDER_ID = -5;
+
+ /**
+ * This ID and the following negative IDs are reserved for bookmarks from Android's partner
+ * bookmark provider.
+ */
+ public static final long FAKE_PARTNER_BOOKMARKS_START = -1000;
+
+ public static final String MOBILE_FOLDER_GUID = "mobile";
+ public static final String PLACES_FOLDER_GUID = "places";
+ public static final String MENU_FOLDER_GUID = "menu";
+ public static final String TAGS_FOLDER_GUID = "tags";
+ public static final String TOOLBAR_FOLDER_GUID = "toolbar";
+ public static final String UNFILED_FOLDER_GUID = "unfiled";
+ public static final String FAKE_DESKTOP_FOLDER_GUID = "desktop";
+ public static final String PINNED_FOLDER_GUID = "pinned";
+ public static final String SCREENSHOT_FOLDER_GUID = "screenshots";
+ public static final String FAKE_READINGLIST_SMARTFOLDER_GUID = "readinglist";
+
+ public static final int TYPE_FOLDER = 0;
+ public static final int TYPE_BOOKMARK = 1;
+ public static final int TYPE_SEPARATOR = 2;
+ public static final int TYPE_LIVEMARK = 3;
+ public static final int TYPE_QUERY = 4;
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "bookmarks");
+ public static final Uri PARENTS_CONTENT_URI = Uri.withAppendedPath(CONTENT_URI, "parents");
+ // Hacky API for bulk-updating positions. Bug 728783.
+ public static final Uri POSITIONS_CONTENT_URI = Uri.withAppendedPath(CONTENT_URI, "positions");
+ public static final long DEFAULT_POSITION = Long.MIN_VALUE;
+
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/bookmark";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/bookmark";
+ public static final String TYPE = "type";
+ public static final String PARENT = "parent";
+ public static final String POSITION = "position";
+ public static final String TAGS = "tags";
+ public static final String DESCRIPTION = "description";
+ public static final String KEYWORD = "keyword";
+
+ public static final String ANNOTATION_KEY = "annotation_key";
+ public static final String ANNOTATION_VALUE = "annotation_value";
+ }
+
+ @RobocopTarget
+ public static final class History implements CommonColumns, URLColumns, HistoryColumns, FaviconColumns, SyncColumns {
+ private History() {}
+
+ public static final String TABLE_NAME = "history";
+
+ public static final String VIEW_WITH_FAVICONS = "history_with_favicons";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "history");
+ public static final Uri CONTENT_OLD_URI = Uri.withAppendedPath(AUTHORITY_URI, "history/old");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/browser-history";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/browser-history";
+ }
+
+ @RobocopTarget
+ public static final class Visits implements CommonColumns, VisitsColumns {
+ private Visits() {}
+
+ public static final String TABLE_NAME = "visits";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "visits");
+
+ public static final int VISIT_IS_LOCAL = 1;
+ public static final int VISIT_IS_REMOTE = 0;
+ }
+
+ // Combined bookmarks and history
+ @RobocopTarget
+ public static final class Combined implements CommonColumns, URLColumns, HistoryColumns, FaviconColumns {
+ private Combined() {}
+
+ public static final String VIEW_NAME = "combined";
+
+ public static final String VIEW_WITH_FAVICONS = "combined_with_favicons";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "combined");
+
+ public static final String BOOKMARK_ID = "bookmark_id";
+ public static final String HISTORY_ID = "history_id";
+
+ public static final String REMOTE_VISITS_COUNT = "remoteVisitCount";
+ public static final String REMOTE_DATE_LAST_VISITED = "remoteDateLastVisited";
+
+ public static final String LOCAL_VISITS_COUNT = "localVisitCount";
+ public static final String LOCAL_DATE_LAST_VISITED = "localDateLastVisited";
+ }
+
+ public static final class Schema {
+ private Schema() {}
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "schema");
+
+ public static final String VERSION = "version";
+ }
+
+ public static final class Passwords {
+ private Passwords() {}
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "passwords");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/passwords";
+
+ public static final String ID = "id";
+ public static final String HOSTNAME = "hostname";
+ public static final String HTTP_REALM = "httpRealm";
+ public static final String FORM_SUBMIT_URL = "formSubmitURL";
+ public static final String USERNAME_FIELD = "usernameField";
+ public static final String PASSWORD_FIELD = "passwordField";
+ public static final String ENCRYPTED_USERNAME = "encryptedUsername";
+ public static final String ENCRYPTED_PASSWORD = "encryptedPassword";
+ public static final String ENC_TYPE = "encType";
+ public static final String TIME_CREATED = "timeCreated";
+ public static final String TIME_LAST_USED = "timeLastUsed";
+ public static final String TIME_PASSWORD_CHANGED = "timePasswordChanged";
+ public static final String TIMES_USED = "timesUsed";
+ public static final String GUID = "guid";
+
+ // This needs to be kept in sync with the types defined in toolkit/components/passwordmgr/nsILoginManagerCrypto.idl#45
+ public static final int ENCTYPE_SDR = 1;
+ }
+
+ public static final class DeletedPasswords implements DeletedColumns {
+ private DeletedPasswords() {}
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-passwords";
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "deleted-passwords");
+ }
+
+ @RobocopTarget
+ public static final class GeckoDisabledHosts {
+ private GeckoDisabledHosts() {}
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/disabled-hosts";
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(PASSWORDS_AUTHORITY_URI, "disabled-hosts");
+
+ public static final String HOSTNAME = "hostname";
+ }
+
+ public static final class FormHistory {
+ private FormHistory() {}
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(FORM_HISTORY_AUTHORITY_URI, "formhistory");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/formhistory";
+
+ public static final String ID = "id";
+ public static final String FIELD_NAME = "fieldname";
+ public static final String VALUE = "value";
+ public static final String TIMES_USED = "timesUsed";
+ public static final String FIRST_USED = "firstUsed";
+ public static final String LAST_USED = "lastUsed";
+ public static final String GUID = "guid";
+ }
+
+ public static final class DeletedFormHistory implements DeletedColumns {
+ private DeletedFormHistory() {}
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(FORM_HISTORY_AUTHORITY_URI, "deleted-formhistory");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-formhistory";
+ }
+
+ @RobocopTarget
+ public static final class Tabs implements CommonColumns {
+ private Tabs() {}
+ public static final String TABLE_NAME = "tabs";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "tabs");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/tab";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/tab";
+
+ // Title of the tab.
+ public static final String TITLE = "title";
+
+ // Topmost URL from the history array. Allows processing of this tab without
+ // parsing that array.
+ public static final String URL = "url";
+
+ // Sync-assigned GUID for client device. NULL for local tabs.
+ public static final String CLIENT_GUID = "client_guid";
+
+ // JSON-encoded array of history URL strings, from most recent to least recent.
+ public static final String HISTORY = "history";
+
+ // Favicon URL for the tab's topmost history entry.
+ public static final String FAVICON = "favicon";
+
+ // Last used time of the tab.
+ public static final String LAST_USED = "last_used";
+
+ // Position of the tab. 0 represents foreground.
+ public static final String POSITION = "position";
+ }
+
+ public static final class Clients implements CommonColumns {
+ private Clients() {}
+ public static final Uri CONTENT_RECENCY_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "clients_recency");
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(TABS_AUTHORITY_URI, "clients");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/client";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/client";
+
+ // Client-provided name string. Could conceivably be null.
+ public static final String NAME = "name";
+
+ // Sync-assigned GUID for client device. NULL for local tabs.
+ public static final String GUID = "guid";
+
+ // Last modified time for the client's tab record. For remote records, a server
+ // timestamp provided by Sync during insertion.
+ public static final String LAST_MODIFIED = "last_modified";
+
+ public static final String DEVICE_TYPE = "device_type";
+ }
+
+ // Data storage for dynamic panels on about:home
+ @RobocopTarget
+ public static final class HomeItems implements CommonColumns {
+ private HomeItems() {}
+ public static final Uri CONTENT_FAKE_URI = Uri.withAppendedPath(HOME_AUTHORITY_URI, "items/fake");
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(HOME_AUTHORITY_URI, "items");
+
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/homeitem";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/homeitem";
+
+ public static final String DATASET_ID = "dataset_id";
+ public static final String URL = "url";
+ public static final String TITLE = "title";
+ public static final String DESCRIPTION = "description";
+ public static final String IMAGE_URL = "image_url";
+ public static final String BACKGROUND_COLOR = "background_color";
+ public static final String BACKGROUND_URL = "background_url";
+ public static final String CREATED = "created";
+ public static final String FILTER = "filter";
+
+ public static final String[] DEFAULT_PROJECTION =
+ new String[] { _ID, DATASET_ID, URL, TITLE, DESCRIPTION, IMAGE_URL, BACKGROUND_COLOR, BACKGROUND_URL, FILTER };
+ }
+
+ @RobocopTarget
+ public static final class ReadingListItems implements CommonColumns, URLColumns {
+ public static final String EXCERPT = "excerpt";
+ public static final String CLIENT_LAST_MODIFIED = "client_last_modified";
+ public static final String GUID = "guid";
+ public static final String SERVER_LAST_MODIFIED = "last_modified";
+ public static final String SERVER_STORED_ON = "stored_on";
+ public static final String ADDED_ON = "added_on";
+ public static final String MARKED_READ_ON = "marked_read_on";
+ public static final String IS_DELETED = "is_deleted";
+ public static final String IS_ARCHIVED = "is_archived";
+ public static final String IS_UNREAD = "is_unread";
+ public static final String IS_ARTICLE = "is_article";
+ public static final String IS_FAVORITE = "is_favorite";
+ public static final String RESOLVED_URL = "resolved_url";
+ public static final String RESOLVED_TITLE = "resolved_title";
+ public static final String ADDED_BY = "added_by";
+ public static final String MARKED_READ_BY = "marked_read_by";
+ public static final String WORD_COUNT = "word_count";
+ public static final String READ_POSITION = "read_position";
+ public static final String CONTENT_STATUS = "content_status";
+
+ public static final String SYNC_STATUS = "sync_status";
+ public static final String SYNC_CHANGE_FLAGS = "sync_change_flags";
+
+ private ReadingListItems() {}
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(READING_LIST_AUTHORITY_URI, "items");
+
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/readinglistitem";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/readinglistitem";
+
+ // CONTENT_STATUS represents the result of an attempt to fetch content for the reading list item.
+ public static final int STATUS_UNFETCHED = 0;
+ public static final int STATUS_FETCH_FAILED_TEMPORARY = 1;
+ public static final int STATUS_FETCH_FAILED_PERMANENT = 2;
+ public static final int STATUS_FETCH_FAILED_UNSUPPORTED_FORMAT = 3;
+ public static final int STATUS_FETCHED_ARTICLE = 4;
+
+ // See https://github.com/mozilla-services/readinglist/wiki/Client-phases for how this is expected to work.
+ //
+ // If an item is SYNCED, it doesn't need to be uploaded.
+ //
+ // If its status is NEW, the entire record should be uploaded.
+ //
+ // If DELETED, the record should be deleted. A record can only move into this state from SYNCED; NEW records
+ // are deleted immediately.
+ //
+
+ public static final int SYNC_STATUS_SYNCED = 0;
+ public static final int SYNC_STATUS_NEW = 1; // Upload everything.
+ public static final int SYNC_STATUS_DELETED = 2; // Delete the record from the server.
+ public static final int SYNC_STATUS_MODIFIED = 3; // Consult SYNC_CHANGE_FLAGS.
+
+ // SYNC_CHANGE_FLAG represents the sets of fields that need to be uploaded.
+ // If its status is only UNREAD_CHANGED (and maybe FAVORITE_CHANGED?), then it can easily be uploaded
+ // in a fire-and-forget manner. This change can never conflict.
+ //
+ // If its status is RESOLVED, then one or more of the content-oriented fields has changed, and a full
+ // upload of those fields should occur. These can result in conflicts.
+ //
+ // Note that these are flags; they should be considered together when deciding on a course of action.
+ //
+ // These flags are meaningless for records in any state other than SYNCED. They can be safely altered in
+ // other states (to avoid having to query to pre-fill a ContentValues), but should be ignored.
+ public static final int SYNC_CHANGE_NONE = 0;
+ public static final int SYNC_CHANGE_UNREAD_CHANGED = 1 << 0; // => marked_read_{on,by}, is_unread
+ public static final int SYNC_CHANGE_FAVORITE_CHANGED = 1 << 1; // => is_favorite
+ public static final int SYNC_CHANGE_RESOLVED = 1 << 2; // => is_article, resolved_{url,title}, excerpt, word_count
+
+
+ public static final String DEFAULT_SORT_ORDER = CLIENT_LAST_MODIFIED + " DESC";
+ public static final String[] DEFAULT_PROJECTION = new String[] { _ID, URL, TITLE, EXCERPT, WORD_COUNT, IS_UNREAD };
+
+ // Minimum fields required to create a reading list item.
+ public static final String[] REQUIRED_FIELDS = { ReadingListItems.URL, ReadingListItems.TITLE };
+
+ // All fields that might be mapped from the DB into a record object.
+ public static final String[] ALL_FIELDS = {
+ CommonColumns._ID,
+ URLColumns.URL,
+ URLColumns.TITLE,
+ EXCERPT,
+ CLIENT_LAST_MODIFIED,
+ GUID,
+ SERVER_LAST_MODIFIED,
+ SERVER_STORED_ON,
+ ADDED_ON,
+ MARKED_READ_ON,
+ IS_DELETED,
+ IS_ARCHIVED,
+ IS_UNREAD,
+ IS_ARTICLE,
+ IS_FAVORITE,
+ RESOLVED_URL,
+ RESOLVED_TITLE,
+ ADDED_BY,
+ MARKED_READ_BY,
+ WORD_COUNT,
+ READ_POSITION,
+ CONTENT_STATUS,
+
+ SYNC_STATUS,
+ SYNC_CHANGE_FLAGS,
+ };
+
+ public static final String TABLE_NAME = "reading_list";
+ }
+
+ @RobocopTarget
+ public static final class TopSites implements CommonColumns, URLColumns {
+ private TopSites() {}
+
+ public static final int TYPE_BLANK = 0;
+ public static final int TYPE_TOP = 1;
+ public static final int TYPE_PINNED = 2;
+ public static final int TYPE_SUGGESTED = 3;
+
+ public static final String BOOKMARK_ID = "bookmark_id";
+ public static final String HISTORY_ID = "history_id";
+ public static final String TYPE = "type";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "topsites");
+ }
+
+ public static final class Highlights {
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "highlights");
+
+ public static final String DATE = "date";
+ }
+
+ @RobocopTarget
+ public static final class SearchHistory implements CommonColumns, HistoryColumns {
+ private SearchHistory() {}
+
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/searchhistory";
+ public static final String QUERY = "query";
+ public static final String DATE = "date";
+ public static final String TABLE_NAME = "searchhistory";
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(SEARCH_HISTORY_AUTHORITY_URI, "searchhistory");
+ }
+
+ @RobocopTarget
+ public static final class SuggestedSites implements CommonColumns, URLColumns {
+ private SuggestedSites() {}
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "suggestedsites");
+ }
+
+ public static final class ActivityStreamBlocklist implements CommonColumns {
+ private ActivityStreamBlocklist() {}
+
+ public static final String TABLE_NAME = "activity_stream_blocklist";
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, TABLE_NAME);
+
+ public static final String URL = "url";
+ public static final String CREATED = "created";
+ }
+
+ @RobocopTarget
+ public static final class UrlAnnotations implements CommonColumns, DateSyncColumns {
+ private UrlAnnotations() {}
+
+ public static final String TABLE_NAME = "urlannotations";
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, TABLE_NAME);
+
+ public static final String URL = "url";
+ public static final String KEY = "key";
+ public static final String VALUE = "value";
+ public static final String SYNC_STATUS = "sync_status";
+
+ public enum Key {
+ // We use a parameter, rather than name(), as defensive coding: we can't let the
+ // enum name change because we've already stored values into the DB.
+ SCREENSHOT ("screenshot"),
+
+ /**
+ * This key maps URLs to its feeds.
+ *
+ * Key: feed
+ * Value: URL of feed
+ */
+ FEED("feed"),
+
+ /**
+ * This key maps URLs of feeds to an object describing the feed.
+ *
+ * Key: feed_subscription
+ * Value: JSON object describing feed
+ */
+ FEED_SUBSCRIPTION("feed_subscription"),
+
+ /**
+ * Indicates that this URL (if stored as a bookmark) should be opened into reader view.
+ *
+ * Key: reader_view
+ * Value: String "true" to indicate that we would like to open into reader view.
+ */
+ READER_VIEW("reader_view"),
+
+ /**
+ * Indicator that the user interacted with the URL in regards to home screen shortcuts.
+ *
+ * Key: home_screen_shortcut
+ * Value: True: User created an home screen shortcut for this URL
+ * False: User declined to create a shortcut for this URL
+ */
+ HOME_SCREEN_SHORTCUT("home_screen_shortcut");
+
+ private final String dbValue;
+
+ Key(final String dbValue) { this.dbValue = dbValue; }
+ public String getDbValue() { return dbValue; }
+ }
+
+ public enum SyncStatus {
+ // We use a parameter, rather than ordinal(), as defensive coding: we can't let the
+ // ordinal values change because we've already stored values into the DB.
+ NEW (0);
+
+ // Value stored into the database for this column.
+ private final int dbValue;
+
+ SyncStatus(final int dbValue) {
+ this.dbValue = dbValue;
+ }
+
+ public int getDBValue() { return dbValue; }
+ }
+
+ /**
+ * Value used to indicate that a reader view item is saved. We use the
+ */
+ public static final String READER_VIEW_SAVED_VALUE = "true";
+ }
+
+ public static final class Numbers {
+ private Numbers() {}
+
+ public static final String TABLE_NAME = "numbers";
+
+ public static final String POSITION = "position";
+
+ public static final int MAX_VALUE = 50;
+ }
+
+ @RobocopTarget
+ public static final class Logins implements CommonColumns {
+ private Logins() {}
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins";
+ public static final String TABLE_LOGINS = "logins";
+
+ public static final String HOSTNAME = "hostname";
+ public static final String HTTP_REALM = "httpRealm";
+ public static final String FORM_SUBMIT_URL = "formSubmitURL";
+ public static final String USERNAME_FIELD = "usernameField";
+ public static final String PASSWORD_FIELD = "passwordField";
+ public static final String ENCRYPTED_USERNAME = "encryptedUsername";
+ public static final String ENCRYPTED_PASSWORD = "encryptedPassword";
+ public static final String ENC_TYPE = "encType";
+ public static final String TIME_CREATED = "timeCreated";
+ public static final String TIME_LAST_USED = "timeLastUsed";
+ public static final String TIME_PASSWORD_CHANGED = "timePasswordChanged";
+ public static final String TIMES_USED = "timesUsed";
+ public static final String GUID = "guid";
+ }
+
+ @RobocopTarget
+ public static final class DeletedLogins implements CommonColumns {
+ private DeletedLogins() {}
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "deleted-logins");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-logins";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/deleted-logins";
+ public static final String TABLE_DELETED_LOGINS = "deleted_logins";
+
+ public static final String GUID = "guid";
+ public static final String TIME_DELETED = "timeDeleted";
+ }
+
+ @RobocopTarget
+ public static final class LoginsDisabledHosts implements CommonColumns {
+ private LoginsDisabledHosts() {}
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins-disabled-hosts");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins-disabled-hosts";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins-disabled-hosts";
+ public static final String TABLE_DISABLED_HOSTS = "logins_disabled_hosts";
+
+ public static final String HOSTNAME = "hostname";
+ }
+
+ @RobocopTarget
+ public static final class PageMetadata implements CommonColumns, PageMetadataColumns {
+ private PageMetadata() {}
+
+ public static final String TABLE_NAME = "page_metadata";
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "page_metadata");
+ }
+
+ // We refer to the service by name to decouple services from the rest of the code base.
+ public static final String TAB_RECEIVED_SERVICE_CLASS_NAME = "org.mozilla.gecko.tabqueue.TabReceivedService";
+
+ public static final String SKIP_TAB_QUEUE_FLAG = "skip_tab_queue";
+
+ public static final String EXTRA_CLIENT_GUID = "org.mozilla.gecko.extra.CLIENT_ID";
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
new file mode 100644
index 000000000..4219e45b1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+
+import android.content.ContentProviderClient;
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.drawable.BitmapDrawable;
+import android.support.v4.content.CursorLoader;
+
+/**
+ * Interface for interactions with all databases. If you want an instance
+ * that implements this, you should go through GeckoProfile. E.g.,
+ * <code>BrowserDB.from(context)</code>.
+ */
+public abstract class BrowserDB {
+ public static enum FilterFlags {
+ EXCLUDE_PINNED_SITES
+ }
+
+ public abstract Searches getSearches();
+ public abstract TabsAccessor getTabsAccessor();
+ public abstract URLMetadata getURLMetadata();
+ @RobocopTarget public abstract UrlAnnotations getUrlAnnotations();
+
+ /**
+ * Add default bookmarks to the database.
+ * Takes an offset; returns a new offset.
+ */
+ public abstract int addDefaultBookmarks(Context context, ContentResolver cr, int offset);
+
+ /**
+ * Add bookmarks from the provided distribution.
+ * Takes an offset; returns a new offset.
+ */
+ public abstract int addDistributionBookmarks(ContentResolver cr, Distribution distribution, int offset);
+
+ /**
+ * Invalidate cached data.
+ */
+ public abstract void invalidate();
+
+ public abstract int getCount(ContentResolver cr, String database);
+
+ /**
+ * @return a cursor representing the contents of the DB filtered according to the arguments.
+ * Can return <code>null</code>. <code>CursorLoader</code> will handle this correctly.
+ */
+ public abstract Cursor filter(ContentResolver cr, CharSequence constraint,
+ int limit, EnumSet<BrowserDB.FilterFlags> flags);
+
+ /**
+ * @return a cursor over top sites (high-ranking bookmarks and history).
+ * Can return <code>null</code>.
+ * Returns no more than <code>limit</code> results.
+ * Suggested sites will be limited to being within the first <code>suggestedRangeLimit</code> results.
+ */
+ public abstract Cursor getTopSites(ContentResolver cr, int suggestedRangeLimit, int limit);
+
+ public abstract CursorLoader getActivityStreamTopSites(Context context, int limit);
+
+ public abstract void updateVisitedHistory(ContentResolver cr, String uri);
+
+ public abstract void updateHistoryTitle(ContentResolver cr, String uri, String title);
+
+ /**
+ * Can return <code>null</code>.
+ */
+ public abstract Cursor getAllVisitedHistory(ContentResolver cr);
+
+ /**
+ * Can return <code>null</code>.
+ */
+ public abstract Cursor getRecentHistory(ContentResolver cr, int limit);
+
+ public abstract Cursor getHistoryForURL(ContentResolver cr, String uri);
+
+ public abstract Cursor getRecentHistoryBetweenTime(ContentResolver cr, int historyLimit, long start, long end);
+
+ public abstract long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath);
+
+ public abstract void expireHistory(ContentResolver cr, ExpirePriority priority);
+
+ public abstract void removeHistoryEntry(ContentResolver cr, String url);
+
+ public abstract void clearHistory(ContentResolver cr, boolean clearSearchHistory);
+
+
+ public abstract String getUrlForKeyword(ContentResolver cr, String keyword);
+
+ public abstract boolean isBookmark(ContentResolver cr, String uri);
+ public abstract boolean addBookmark(ContentResolver cr, String title, String uri);
+ public abstract Cursor getBookmarkForUrl(ContentResolver cr, String url);
+ public abstract Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl);
+ public abstract void removeBookmarksWithURL(ContentResolver cr, String uri);
+ public abstract void registerBookmarkObserver(ContentResolver cr, ContentObserver observer);
+ public abstract void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword);
+ public abstract boolean hasBookmarkWithGuid(ContentResolver cr, String guid);
+
+ public abstract boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON);
+ public abstract int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl);
+ /**
+ * Can return <code>null</code>.
+ */
+ public abstract Cursor getBookmarksInFolder(ContentResolver cr, long folderId);
+
+ public abstract int getBookmarkCountForFolder(ContentResolver cr, long folderId);
+
+ /**
+ * Get the favicon from the database, if any, associated with the given favicon URL. (That is,
+ * the URL of the actual favicon image, not the URL of the page with which the favicon is associated.)
+ * @param cr The ContentResolver to use.
+ * @param faviconURL The URL of the favicon to fetch from the database.
+ * @return The decoded Bitmap from the database, if any. null if none is stored.
+ */
+ public abstract LoadFaviconResult getFaviconForUrl(Context context, ContentResolver cr, String faviconURL);
+
+ /**
+ * Try to find a usable favicon URL in the history or bookmarks table.
+ */
+ public abstract String getFaviconURLFromPageURL(ContentResolver cr, String uri);
+
+ public abstract byte[] getThumbnailForUrl(ContentResolver cr, String uri);
+ public abstract void updateThumbnailForUrl(ContentResolver cr, String uri, BitmapDrawable thumbnail);
+
+ /**
+ * Query for non-null thumbnails matching the provided <code>urls</code>.
+ * The returned cursor will have no more than, but possibly fewer than,
+ * the requested number of thumbnails.
+ *
+ * Returns null if the provided list of URLs is empty or null.
+ */
+ public abstract Cursor getThumbnailsForUrls(ContentResolver cr,
+ List<String> urls);
+
+ public abstract void removeThumbnails(ContentResolver cr);
+
+ // Utility function for updating existing history using batch operations
+ public abstract void updateHistoryInBatch(ContentResolver cr,
+ Collection<ContentProviderOperation> operations, String url,
+ String title, long date, int visits);
+
+ public abstract void updateBookmarkInBatch(ContentResolver cr,
+ Collection<ContentProviderOperation> operations, String url,
+ String title, String guid, long parent, long added, long modified,
+ long position, String keyword, int type);
+
+ public abstract void pinSite(ContentResolver cr, String url, String title, int position);
+ public abstract void unpinSite(ContentResolver cr, int position);
+
+ public abstract boolean hideSuggestedSite(String url);
+ public abstract void setSuggestedSites(SuggestedSites suggestedSites);
+ public abstract SuggestedSites getSuggestedSites();
+ public abstract boolean hasSuggestedImageUrl(String url);
+ public abstract String getSuggestedImageUrlForUrl(String url);
+ public abstract int getSuggestedBackgroundColorForUrl(String url);
+
+ /**
+ * Obtain a set of links for highlights from bookmarks and history.
+ *
+ * @param context The context to load the cursor.
+ * @param limit Maximum number of results to return.
+ */
+ public abstract CursorLoader getHighlights(Context context, int limit);
+
+ /**
+ * Block a page from the highlights list.
+ *
+ * @param url The page URL. Only pages exactly matching this URL will be blocked.
+ */
+ public abstract void blockActivityStreamSite(ContentResolver cr, String url);
+
+ public static BrowserDB from(final Context context) {
+ return from(GeckoProfile.get(context));
+ }
+
+ public static BrowserDB from(final GeckoProfile profile) {
+ synchronized (profile.getLock()) {
+ BrowserDB db = (BrowserDB) profile.getData();
+ if (db != null) {
+ return db;
+ }
+
+ db = new LocalBrowserDB(profile.getName());
+ profile.setData(db);
+ return db;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java
new file mode 100644
index 000000000..f823d9060
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java
@@ -0,0 +1,2237 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.File;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.mozilla.apache.commons.codec.binary.Base32;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.BrowserContract.Favicons;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.Visits;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
+import org.mozilla.gecko.db.BrowserContract.Numbers;
+import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
+import org.mozilla.gecko.db.BrowserContract.SearchHistory;
+import org.mozilla.gecko.db.BrowserContract.Thumbnails;
+import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.RepoUtils;
+import org.mozilla.gecko.util.FileUtils;
+
+import static org.mozilla.gecko.db.DBUtils.qualifyColumn;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Log;
+
+
+// public for robocop testing
+public final class BrowserDatabaseHelper extends SQLiteOpenHelper {
+ private static final String LOGTAG = "GeckoBrowserDBHelper";
+
+ // Replace the Bug number below with your Bug that is conducting a DB upgrade, as to force a merge conflict with any
+ // other patches that require a DB upgrade.
+ public static final int DATABASE_VERSION = 36; // Bug 1301717
+ public static final String DATABASE_NAME = "browser.db";
+
+ final protected Context mContext;
+
+ static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
+ static final String TABLE_HISTORY = History.TABLE_NAME;
+ static final String TABLE_VISITS = Visits.TABLE_NAME;
+ static final String TABLE_PAGE_METADATA = PageMetadata.TABLE_NAME;
+ static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
+ static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
+ static final String TABLE_READING_LIST = ReadingListItems.TABLE_NAME;
+ static final String TABLE_TABS = TabsProvider.TABLE_TABS;
+ static final String TABLE_CLIENTS = TabsProvider.TABLE_CLIENTS;
+ static final String TABLE_LOGINS = BrowserContract.Logins.TABLE_LOGINS;
+ static final String TABLE_DELETED_LOGINS = BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+ static final String TABLE_DISABLED_HOSTS = BrowserContract.LoginsDisabledHosts.TABLE_DISABLED_HOSTS;
+ static final String TABLE_ANNOTATIONS = UrlAnnotations.TABLE_NAME;
+
+ static final String VIEW_COMBINED = Combined.VIEW_NAME;
+ static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS;
+ static final String VIEW_BOOKMARKS_WITH_ANNOTATIONS = Bookmarks.VIEW_WITH_ANNOTATIONS;
+ static final String VIEW_HISTORY_WITH_FAVICONS = History.VIEW_WITH_FAVICONS;
+ static final String VIEW_COMBINED_WITH_FAVICONS = Combined.VIEW_WITH_FAVICONS;
+
+ static final String TABLE_BOOKMARKS_JOIN_FAVICONS = TABLE_BOOKMARKS + " LEFT OUTER JOIN " +
+ TABLE_FAVICONS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " = " +
+ qualifyColumn(TABLE_FAVICONS, Favicons._ID);
+
+ static final String TABLE_BOOKMARKS_JOIN_ANNOTATIONS = TABLE_BOOKMARKS + " JOIN " +
+ TABLE_ANNOTATIONS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " +
+ qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.URL);
+
+ static final String TABLE_HISTORY_JOIN_FAVICONS = TABLE_HISTORY + " LEFT OUTER JOIN " +
+ TABLE_FAVICONS + " ON " + qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " = " +
+ qualifyColumn(TABLE_FAVICONS, Favicons._ID);
+
+ static final String TABLE_BOOKMARKS_TMP = TABLE_BOOKMARKS + "_tmp";
+ static final String TABLE_HISTORY_TMP = TABLE_HISTORY + "_tmp";
+
+ private static final String[] mobileIdColumns = new String[] { Bookmarks._ID };
+ private static final String[] mobileIdSelectionArgs = new String[] { Bookmarks.MOBILE_FOLDER_GUID };
+
+ private boolean didCreateTabsTable = false;
+ private boolean didCreateCurrentReadingListTable = false;
+
+ public BrowserDatabaseHelper(Context context, String databasePath) {
+ super(context, databasePath, null, DATABASE_VERSION);
+ mContext = context;
+ }
+
+ private void createBookmarksTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_BOOKMARKS + " table");
+
+ db.execSQL("CREATE TABLE " + TABLE_BOOKMARKS + "(" +
+ Bookmarks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ Bookmarks.TITLE + " TEXT," +
+ Bookmarks.URL + " TEXT," +
+ Bookmarks.TYPE + " INTEGER NOT NULL DEFAULT " + Bookmarks.TYPE_BOOKMARK + "," +
+ Bookmarks.PARENT + " INTEGER," +
+ Bookmarks.POSITION + " INTEGER NOT NULL," +
+ Bookmarks.KEYWORD + " TEXT," +
+ Bookmarks.DESCRIPTION + " TEXT," +
+ Bookmarks.TAGS + " TEXT," +
+ Bookmarks.FAVICON_ID + " INTEGER," +
+ Bookmarks.DATE_CREATED + " INTEGER," +
+ Bookmarks.DATE_MODIFIED + " INTEGER," +
+ Bookmarks.GUID + " TEXT NOT NULL," +
+ Bookmarks.IS_DELETED + " INTEGER NOT NULL DEFAULT 0, " +
+ "FOREIGN KEY (" + Bookmarks.PARENT + ") REFERENCES " +
+ TABLE_BOOKMARKS + "(" + Bookmarks._ID + ")" +
+ ");");
+
+ db.execSQL("CREATE INDEX bookmarks_url_index ON " + TABLE_BOOKMARKS + "("
+ + Bookmarks.URL + ")");
+ db.execSQL("CREATE INDEX bookmarks_type_deleted_index ON " + TABLE_BOOKMARKS + "("
+ + Bookmarks.TYPE + ", " + Bookmarks.IS_DELETED + ")");
+ db.execSQL("CREATE UNIQUE INDEX bookmarks_guid_index ON " + TABLE_BOOKMARKS + "("
+ + Bookmarks.GUID + ")");
+ db.execSQL("CREATE INDEX bookmarks_modified_index ON " + TABLE_BOOKMARKS + "("
+ + Bookmarks.DATE_MODIFIED + ")");
+ }
+
+ private void createHistoryTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_HISTORY + " table");
+ db.execSQL("CREATE TABLE " + TABLE_HISTORY + "(" +
+ History._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ History.TITLE + " TEXT," +
+ History.URL + " TEXT NOT NULL," +
+ // Can we drop VISITS count? Can we calculate it in the Combined view as a sum?
+ // See Bug 1277329.
+ History.VISITS + " INTEGER NOT NULL DEFAULT 0," +
+ History.LOCAL_VISITS + " INTEGER NOT NULL DEFAULT 0," +
+ History.REMOTE_VISITS + " INTEGER NOT NULL DEFAULT 0," +
+ History.FAVICON_ID + " INTEGER," +
+ History.DATE_LAST_VISITED + " INTEGER," +
+ History.LOCAL_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0," +
+ History.REMOTE_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0," +
+ History.DATE_CREATED + " INTEGER," +
+ History.DATE_MODIFIED + " INTEGER," +
+ History.GUID + " TEXT NOT NULL," +
+ History.IS_DELETED + " INTEGER NOT NULL DEFAULT 0" +
+ ");");
+
+ db.execSQL("CREATE INDEX history_url_index ON " + TABLE_HISTORY + '('
+ + History.URL + ')');
+ db.execSQL("CREATE UNIQUE INDEX history_guid_index ON " + TABLE_HISTORY + '('
+ + History.GUID + ')');
+ db.execSQL("CREATE INDEX history_modified_index ON " + TABLE_HISTORY + '('
+ + History.DATE_MODIFIED + ')');
+ db.execSQL("CREATE INDEX history_visited_index ON " + TABLE_HISTORY + '('
+ + History.DATE_LAST_VISITED + ')');
+ }
+
+ private void createVisitsTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_VISITS + " table");
+ db.execSQL("CREATE TABLE " + TABLE_VISITS + "(" +
+ Visits._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ Visits.HISTORY_GUID + " TEXT NOT NULL," +
+ Visits.VISIT_TYPE + " TINYINT NOT NULL DEFAULT 1," +
+ Visits.DATE_VISITED + " INTEGER NOT NULL, " +
+ Visits.IS_LOCAL + " TINYINT NOT NULL DEFAULT 1, " +
+
+ "FOREIGN KEY (" + Visits.HISTORY_GUID + ") REFERENCES " +
+ TABLE_HISTORY + "(" + History.GUID + ") ON DELETE CASCADE ON UPDATE CASCADE" +
+ ");");
+
+ db.execSQL("CREATE UNIQUE INDEX visits_history_guid_and_date_visited_index ON " + TABLE_VISITS + "("
+ + Visits.HISTORY_GUID + "," + Visits.DATE_VISITED + ")");
+ db.execSQL("CREATE INDEX visits_history_guid_index ON " + TABLE_VISITS + "(" + Visits.HISTORY_GUID + ")");
+ }
+
+ private void createFaviconsTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_FAVICONS + " table");
+ db.execSQL("CREATE TABLE " + TABLE_FAVICONS + " (" +
+ Favicons._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ Favicons.URL + " TEXT UNIQUE," +
+ Favicons.DATA + " BLOB," +
+ Favicons.DATE_CREATED + " INTEGER," +
+ Favicons.DATE_MODIFIED + " INTEGER" +
+ ");");
+
+ db.execSQL("CREATE INDEX favicons_modified_index ON " + TABLE_FAVICONS + "("
+ + Favicons.DATE_MODIFIED + ")");
+ }
+
+ private void createThumbnailsTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_THUMBNAILS + " table");
+ db.execSQL("CREATE TABLE " + TABLE_THUMBNAILS + " (" +
+ Thumbnails._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ Thumbnails.URL + " TEXT UNIQUE," +
+ Thumbnails.DATA + " BLOB" +
+ ");");
+ }
+
+ private void createPageMetadataTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_PAGE_METADATA + " table");
+ db.execSQL("CREATE TABLE " + TABLE_PAGE_METADATA + "(" +
+ PageMetadata._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ PageMetadata.HISTORY_GUID + " TEXT NOT NULL," +
+ PageMetadata.DATE_CREATED + " INTEGER NOT NULL, " +
+ PageMetadata.HAS_IMAGE + " TINYINT NOT NULL DEFAULT 0, " +
+ PageMetadata.JSON + " TEXT NOT NULL, " +
+
+ "FOREIGN KEY (" + Visits.HISTORY_GUID + ") REFERENCES " +
+ TABLE_HISTORY + "(" + History.GUID + ") ON DELETE CASCADE ON UPDATE CASCADE" +
+ ");");
+
+ // Establish a 1-to-1 relationship with History table.
+ db.execSQL("CREATE UNIQUE INDEX page_metadata_history_guid ON " + TABLE_PAGE_METADATA + "("
+ + PageMetadata.HISTORY_GUID + ")");
+ // Improve performance of commonly occurring selections.
+ db.execSQL("CREATE INDEX page_metadata_history_guid_and_has_image ON " + TABLE_PAGE_METADATA + "("
+ + PageMetadata.HISTORY_GUID + ", " + PageMetadata.HAS_IMAGE + ")");
+ }
+
+ private void createBookmarksWithFaviconsView(SQLiteDatabase db) {
+ debug("Creating " + VIEW_BOOKMARKS_WITH_FAVICONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_BOOKMARKS_WITH_FAVICONS + " AS " +
+ "SELECT " + qualifyColumn(TABLE_BOOKMARKS, "*") +
+ ", " + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Bookmarks.FAVICON +
+ ", " + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Bookmarks.FAVICON_URL +
+ " FROM " + TABLE_BOOKMARKS_JOIN_FAVICONS);
+ }
+
+ private void createBookmarksWithAnnotationsView(SQLiteDatabase db) {
+ debug("Creating " + VIEW_BOOKMARKS_WITH_ANNOTATIONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_BOOKMARKS_WITH_ANNOTATIONS + " AS " +
+ "SELECT " + qualifyColumn(TABLE_BOOKMARKS, "*") +
+ ", " + qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.KEY) + " AS " + Bookmarks.ANNOTATION_KEY +
+ ", " + qualifyColumn(TABLE_ANNOTATIONS, UrlAnnotations.VALUE) + " AS " + Bookmarks.ANNOTATION_VALUE +
+ " FROM " + TABLE_BOOKMARKS_JOIN_ANNOTATIONS);
+ }
+
+ private void createHistoryWithFaviconsView(SQLiteDatabase db) {
+ debug("Creating " + VIEW_HISTORY_WITH_FAVICONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_HISTORY_WITH_FAVICONS + " AS " +
+ "SELECT " + qualifyColumn(TABLE_HISTORY, "*") +
+ ", " + qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + History.FAVICON +
+ ", " + qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + History.FAVICON_URL +
+ " FROM " + TABLE_HISTORY_JOIN_FAVICONS);
+ }
+
+ private void createClientsTable(SQLiteDatabase db) {
+ debug("Creating " + TABLE_CLIENTS + " table");
+
+ // Table for client's name-guid mapping.
+ db.execSQL("CREATE TABLE " + TABLE_CLIENTS + "(" +
+ BrowserContract.Clients.GUID + " TEXT PRIMARY KEY," +
+ BrowserContract.Clients.NAME + " TEXT," +
+ BrowserContract.Clients.LAST_MODIFIED + " INTEGER," +
+ BrowserContract.Clients.DEVICE_TYPE + " TEXT" +
+ ");");
+ }
+
+ private void createTabsTable(SQLiteDatabase db, final String tableName) {
+ debug("Creating tabs.db: " + db.getPath());
+ debug("Creating " + tableName + " table");
+
+ // Table for each tab on any client.
+ db.execSQL("CREATE TABLE " + tableName + "(" +
+ BrowserContract.Tabs._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BrowserContract.Tabs.CLIENT_GUID + " TEXT," +
+ BrowserContract.Tabs.TITLE + " TEXT," +
+ BrowserContract.Tabs.URL + " TEXT," +
+ BrowserContract.Tabs.HISTORY + " TEXT," +
+ BrowserContract.Tabs.FAVICON + " TEXT," +
+ BrowserContract.Tabs.LAST_USED + " INTEGER," +
+ BrowserContract.Tabs.POSITION + " INTEGER, " +
+ "FOREIGN KEY (" + BrowserContract.Tabs.CLIENT_GUID + ") REFERENCES " +
+ TABLE_CLIENTS + "(" + BrowserContract.Clients.GUID + ") ON DELETE CASCADE" +
+ ");");
+
+ didCreateTabsTable = true;
+ }
+
+ private void createTabsTableIndices(SQLiteDatabase db, final String tableName) {
+ // Indices on CLIENT_GUID and POSITION.
+ db.execSQL("CREATE INDEX " + TabsProvider.INDEX_TABS_GUID +
+ " ON " + tableName + "(" + BrowserContract.Tabs.CLIENT_GUID + ")");
+ db.execSQL("CREATE INDEX " + TabsProvider.INDEX_TABS_POSITION +
+ " ON " + tableName + "(" + BrowserContract.Tabs.POSITION + ")");
+ }
+
+ // Insert a client row for our local Fennec client.
+ private void createLocalClient(SQLiteDatabase db) {
+ debug("Inserting local Fennec client into " + TABLE_CLIENTS + " table");
+
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.Clients.LAST_MODIFIED, System.currentTimeMillis());
+ db.insertOrThrow(TABLE_CLIENTS, null, values);
+ }
+
+ private void createCombinedViewOn19(SQLiteDatabase db) {
+ /*
+ The v19 combined view removes the redundant subquery from the v16
+ combined view and reorders the columns as necessary to prevent this
+ from breaking any code that might be referencing columns by index.
+
+ The rows in the ensuing view are, in order:
+
+ Combined.BOOKMARK_ID
+ Combined.HISTORY_ID
+ Combined._ID (always 0)
+ Combined.URL
+ Combined.TITLE
+ Combined.VISITS
+ Combined.DATE_LAST_VISITED
+ Combined.FAVICON_ID
+
+ We need to return an _id column because CursorAdapter requires it for its
+ default implementation for the getItemId() method. However, since
+ we're not using this feature in the parts of the UI using this view,
+ we can just use 0 for all rows.
+ */
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" +
+
+ // Bookmarks without history.
+ " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," +
+ "-1 AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " +
+ "-1 AS " + Combined.VISITS + ", " +
+ "-1 AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID +
+ " FROM " + TABLE_BOOKMARKS +
+ " WHERE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " +
+ // Ignore pinned bookmarks.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) +
+ " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" +
+ " UNION ALL" +
+
+ // History with and without bookmark.
+ " SELECT " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) +
+
+ // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't
+ // completely ignore them here because they're joined with history entries we care about.
+ " WHEN 0 THEN " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) +
+ " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " +
+ "NULL " +
+ "ELSE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) +
+ " END " +
+ "ELSE " +
+ "NULL " +
+ "END AS " + Combined.BOOKMARK_ID + "," +
+ qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," +
+
+ // Prioritize bookmark titles over history titles, since the user may have
+ // customized the title for a bookmark.
+ "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " +
+ qualifyColumn(TABLE_HISTORY, History.TITLE) +
+ ") AS " + Combined.TITLE + "," +
+ qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," +
+ qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID +
+
+ // We really shouldn't be selecting deleted bookmarks, but oh well.
+ " FROM " + TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_BOOKMARKS +
+ " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) +
+ " WHERE " +
+ qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " +
+ "(" +
+ // The left outer join didn't match...
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " +
+
+ // ... or it's a bookmark. This is less efficient than filtering prior
+ // to the join if you have lots of folders.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK +
+ ")"
+ );
+
+ debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" +
+ " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON +
+ " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS +
+ " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID));
+
+ }
+
+ private void createCombinedViewOn33(final SQLiteDatabase db) {
+ /*
+ Builds on top of v19 combined view, and adds the following aggregates:
+ - Combined.LOCAL_DATE_LAST_VISITED - last date visited for all local visits
+ - Combined.REMOTE_DATE_LAST_VISITED - last date visited for all remote visits
+ - Combined.LOCAL_VISITS_COUNT - total number of local visits
+ - Combined.REMOTE_VISITS_COUNT - total number of remote visits
+
+ Any code written prior to v33 referencing columns by index directly remains intact
+ (yet must die a fiery death), as new columns were added to the end of the list.
+
+ The rows in the ensuing view are, in order:
+ Combined.BOOKMARK_ID
+ Combined.HISTORY_ID
+ Combined._ID (always 0)
+ Combined.URL
+ Combined.TITLE
+ Combined.VISITS
+ Combined.DATE_LAST_VISITED
+ Combined.FAVICON_ID
+ Combined.LOCAL_DATE_LAST_VISITED
+ Combined.REMOTE_DATE_LAST_VISITED
+ Combined.LOCAL_VISITS_COUNT
+ Combined.REMOTE_VISITS_COUNT
+ */
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" +
+
+ // Bookmarks without history.
+ " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," +
+ "-1 AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " +
+ "-1 AS " + Combined.VISITS + ", " +
+ "-1 AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," +
+ "0 AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " +
+ "0 AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " +
+ "0 AS " + Combined.LOCAL_VISITS_COUNT + ", " +
+ "0 AS " + Combined.REMOTE_VISITS_COUNT +
+ " FROM " + TABLE_BOOKMARKS +
+ " WHERE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " +
+ // Ignore pinned bookmarks.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) +
+ " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" +
+ " UNION ALL" +
+
+ // History with and without bookmark.
+ " SELECT " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) +
+
+ // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't
+ // completely ignore them here because they're joined with history entries we care about.
+ " WHEN 0 THEN " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) +
+ " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " +
+ "NULL " +
+ "ELSE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) +
+ " END " +
+ "ELSE " +
+ "NULL " +
+ "END AS " + Combined.BOOKMARK_ID + "," +
+ qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," +
+
+ // Prioritize bookmark titles over history titles, since the user may have
+ // customized the title for a bookmark.
+ "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " +
+ qualifyColumn(TABLE_HISTORY, History.TITLE) +
+ ") AS " + Combined.TITLE + "," +
+ qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," +
+ qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," +
+
+ // Figure out "last visited" days using MAX values for visit timestamps.
+ // We use CASE statements here to separate local from remote visits.
+ "COALESCE(MAX(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " " +
+ "WHEN 1 THEN " + qualifyColumn(TABLE_VISITS, Visits.DATE_VISITED) + " " +
+ "ELSE 0 END" +
+ "), 0) AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " +
+
+ "COALESCE(MAX(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " " +
+ "WHEN 0 THEN " + qualifyColumn(TABLE_VISITS, Visits.DATE_VISITED) + " " +
+ "ELSE 0 END" +
+ "), 0) AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " +
+
+ // Sum up visit counts for local and remote visit types. Again, use CASE to separate the two.
+ "COALESCE(SUM(" + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + "), 0) AS " + Combined.LOCAL_VISITS_COUNT + ", " +
+ "COALESCE(SUM(CASE " + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + " WHEN 0 THEN 1 ELSE 0 END), 0) AS " + Combined.REMOTE_VISITS_COUNT +
+
+ // We need to JOIN on Visits in order to compute visit counts
+ " FROM " + TABLE_HISTORY + " " +
+ "LEFT OUTER JOIN " + TABLE_VISITS +
+ " ON " + qualifyColumn(TABLE_HISTORY, History.GUID) + " = " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " " +
+
+ // We really shouldn't be selecting deleted bookmarks, but oh well.
+ "LEFT OUTER JOIN " + TABLE_BOOKMARKS +
+ " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) +
+ " WHERE " +
+ qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " +
+ "(" +
+ // The left outer join didn't match...
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " +
+
+ // ... or it's a bookmark. This is less efficient than filtering prior
+ // to the join if you have lots of folders.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK +
+
+ ") GROUP BY " + qualifyColumn(TABLE_HISTORY, History.GUID)
+ );
+
+ debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" +
+ " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON +
+ " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS +
+ " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID));
+ }
+
+ private void createCombinedViewOn34(final SQLiteDatabase db) {
+ /*
+ Builds on top of v33 combined view, and instead of calculating the following aggregates, gets them
+ from the history table:
+ - Combined.LOCAL_DATE_LAST_VISITED - last date visited for all local visits
+ - Combined.REMOTE_DATE_LAST_VISITED - last date visited for all remote visits
+ - Combined.LOCAL_VISITS_COUNT - total number of local visits
+ - Combined.REMOTE_VISITS_COUNT - total number of remote visits
+
+ Any code written prior to v33 referencing columns by index directly remains intact
+ (yet must die a fiery death), as new columns were added to the end of the list.
+
+ The rows in the ensuing view are, in order:
+ Combined.BOOKMARK_ID
+ Combined.HISTORY_ID
+ Combined._ID (always 0)
+ Combined.URL
+ Combined.TITLE
+ Combined.VISITS
+ Combined.DATE_LAST_VISITED
+ Combined.FAVICON_ID
+ Combined.LOCAL_DATE_LAST_VISITED
+ Combined.REMOTE_DATE_LAST_VISITED
+ Combined.LOCAL_VISITS_COUNT
+ Combined.REMOTE_VISITS_COUNT
+ */
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED + " AS" +
+
+ // Bookmarks without history.
+ " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + "," +
+ "-1 AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " +
+ "-1 AS " + Combined.VISITS + ", " +
+ "-1 AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," +
+ "0 AS " + Combined.LOCAL_DATE_LAST_VISITED + ", " +
+ "0 AS " + Combined.REMOTE_DATE_LAST_VISITED + ", " +
+ "0 AS " + Combined.LOCAL_VISITS_COUNT + ", " +
+ "0 AS " + Combined.REMOTE_VISITS_COUNT +
+ " FROM " + TABLE_BOOKMARKS +
+ " WHERE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " +
+ // Ignore pinned bookmarks.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " <> " + Bookmarks.FIXED_PINNED_LIST_ID + " AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) +
+ " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" +
+ " UNION ALL" +
+
+ // History with and without bookmark.
+ " SELECT " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) +
+
+ // Give pinned bookmarks a NULL ID so that they're not treated as bookmarks. We can't
+ // completely ignore them here because they're joined with history entries we care about.
+ " WHEN 0 THEN " +
+ "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) +
+ " WHEN " + Bookmarks.FIXED_PINNED_LIST_ID + " THEN " +
+ "NULL " +
+ "ELSE " +
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) +
+ " END " +
+ "ELSE " +
+ "NULL " +
+ "END AS " + Combined.BOOKMARK_ID + "," +
+ qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + "," +
+ "0 AS " + Combined._ID + "," +
+ qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + "," +
+
+ // Prioritize bookmark titles over history titles, since the user may have
+ // customized the title for a bookmark.
+ "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " +
+ qualifyColumn(TABLE_HISTORY, History.TITLE) +
+ ") AS " + Combined.TITLE + "," +
+ qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + "," +
+ qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_HISTORY, History.FAVICON_ID) + " AS " + Combined.FAVICON_ID + "," +
+
+ qualifyColumn(TABLE_HISTORY, History.LOCAL_DATE_LAST_VISITED) + " AS " + Combined.LOCAL_DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_HISTORY, History.REMOTE_DATE_LAST_VISITED) + " AS " + Combined.REMOTE_DATE_LAST_VISITED + "," +
+ qualifyColumn(TABLE_HISTORY, History.LOCAL_VISITS) + " AS " + Combined.LOCAL_VISITS_COUNT + "," +
+ qualifyColumn(TABLE_HISTORY, History.REMOTE_VISITS) + " AS " + Combined.REMOTE_VISITS_COUNT +
+
+ // We need to JOIN on Visits in order to compute visit counts
+ " FROM " + TABLE_HISTORY + " " +
+
+ // We really shouldn't be selecting deleted bookmarks, but oh well.
+ "LEFT OUTER JOIN " + TABLE_BOOKMARKS +
+ " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) +
+ " WHERE " +
+ qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND " +
+ "(" +
+ // The left outer join didn't match...
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " +
+
+ // ... or it's a bookmark. This is less efficient than filtering prior
+ // to the join if you have lots of folders.
+ qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + ")"
+ );
+
+ debug("Creating " + VIEW_COMBINED_WITH_FAVICONS + " view");
+
+ db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_FAVICONS + " AS" +
+ " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " +
+ qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON +
+ " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS +
+ " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID));
+ }
+
+ private void createLoginsTable(SQLiteDatabase db, final String tableName) {
+ debug("Creating logins.db: " + db.getPath());
+ debug("Creating " + tableName + " table");
+
+ // Table for each login.
+ db.execSQL("CREATE TABLE " + tableName + "(" +
+ BrowserContract.Logins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BrowserContract.Logins.HOSTNAME + " TEXT NOT NULL," +
+ BrowserContract.Logins.HTTP_REALM + " TEXT," +
+ BrowserContract.Logins.FORM_SUBMIT_URL + " TEXT," +
+ BrowserContract.Logins.USERNAME_FIELD + " TEXT NOT NULL," +
+ BrowserContract.Logins.PASSWORD_FIELD + " TEXT NOT NULL," +
+ BrowserContract.Logins.ENCRYPTED_USERNAME + " TEXT NOT NULL," +
+ BrowserContract.Logins.ENCRYPTED_PASSWORD + " TEXT NOT NULL," +
+ BrowserContract.Logins.GUID + " TEXT UNIQUE NOT NULL," +
+ BrowserContract.Logins.ENC_TYPE + " INTEGER NOT NULL, " +
+ BrowserContract.Logins.TIME_CREATED + " INTEGER," +
+ BrowserContract.Logins.TIME_LAST_USED + " INTEGER," +
+ BrowserContract.Logins.TIME_PASSWORD_CHANGED + " INTEGER," +
+ BrowserContract.Logins.TIMES_USED + " INTEGER" +
+ ");");
+ }
+
+ private void createLoginsTableIndices(SQLiteDatabase db, final String tableName) {
+ // No need to create an index on GUID, it is an unique column.
+ db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME +
+ " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + ")");
+ db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL +
+ " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.FORM_SUBMIT_URL + ")");
+ db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_HTTP_REALM +
+ " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.HTTP_REALM + ")");
+ }
+
+ private void createDeletedLoginsTable(SQLiteDatabase db, final String tableName) {
+ debug("Creating deleted_logins.db: " + db.getPath());
+ debug("Creating " + tableName + " table");
+
+ // Table for each deleted login.
+ db.execSQL("CREATE TABLE " + tableName + "(" +
+ BrowserContract.DeletedLogins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BrowserContract.DeletedLogins.GUID + " TEXT UNIQUE NOT NULL," +
+ BrowserContract.DeletedLogins.TIME_DELETED + " INTEGER NOT NULL" +
+ ");");
+ }
+
+ private void createDisabledHostsTable(SQLiteDatabase db, final String tableName) {
+ debug("Creating disabled_hosts.db: " + db.getPath());
+ debug("Creating " + tableName + " table");
+
+ // Table for each disabled host.
+ db.execSQL("CREATE TABLE " + tableName + "(" +
+ BrowserContract.LoginsDisabledHosts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BrowserContract.LoginsDisabledHosts.HOSTNAME + " TEXT UNIQUE NOT NULL ON CONFLICT REPLACE" +
+ ");");
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ debug("Creating browser.db: " + db.getPath());
+
+ for (Table table : BrowserProvider.sTables) {
+ table.onCreate(db);
+ }
+
+ createBookmarksTable(db);
+ createHistoryTable(db);
+ createFaviconsTable(db);
+ createThumbnailsTable(db);
+ createClientsTable(db);
+ createLocalClient(db);
+ createTabsTable(db, TABLE_TABS);
+ createTabsTableIndices(db, TABLE_TABS);
+
+
+ createBookmarksWithFaviconsView(db);
+ createHistoryWithFaviconsView(db);
+
+ createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID,
+ R.string.bookmarks_folder_places, 0);
+
+ createOrUpdateAllSpecialFolders(db);
+ createSearchHistoryTable(db);
+ createUrlAnnotationsTable(db);
+ createNumbersTable(db);
+
+ createDeletedLoginsTable(db, TABLE_DELETED_LOGINS);
+ createDisabledHostsTable(db, TABLE_DISABLED_HOSTS);
+ createLoginsTable(db, TABLE_LOGINS);
+ createLoginsTableIndices(db, TABLE_LOGINS);
+
+ createBookmarksWithAnnotationsView(db);
+
+ createVisitsTable(db);
+ createCombinedViewOn34(db);
+
+ createActivityStreamBlocklistTable(db);
+
+ createPageMetadataTable(db);
+ }
+
+ /**
+ * Copies the tabs and clients tables out of the given tabs.db file and into the destinationDB.
+ *
+ * @param tabsDBFile Path to existing tabs.db.
+ * @param destinationDB The destination database.
+ */
+ public void copyTabsDB(File tabsDBFile, SQLiteDatabase destinationDB) {
+ createClientsTable(destinationDB);
+ createTabsTable(destinationDB, TABLE_TABS);
+ createTabsTableIndices(destinationDB, TABLE_TABS);
+
+ SQLiteDatabase oldTabsDB = null;
+ try {
+ oldTabsDB = SQLiteDatabase.openDatabase(tabsDBFile.getPath(), null, SQLiteDatabase.OPEN_READONLY);
+
+ if (!DBUtils.copyTable(oldTabsDB, TABLE_CLIENTS, destinationDB, TABLE_CLIENTS)) {
+ Log.e(LOGTAG, "Failed to migrate table clients; ignoring.");
+ }
+ if (!DBUtils.copyTable(oldTabsDB, TABLE_TABS, destinationDB, TABLE_TABS)) {
+ Log.e(LOGTAG, "Failed to migrate table tabs; ignoring.");
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception occurred while trying to copy from " + tabsDBFile.getPath() +
+ " to " + destinationDB.getPath() + "; ignoring.", e);
+ } finally {
+ if (oldTabsDB != null) {
+ oldTabsDB.close();
+ }
+ }
+ }
+
+ /**
+ * We used to have a separate history extensions database which was used by Sync to store arrays
+ * of visits for individual History GUIDs. It was only used by Sync.
+ * This function migrates contents of that database over to the Visits table.
+ *
+ * Warning to callers: this method might throw IllegalStateException if we fail to allocate a
+ * cursor to read HistoryExtensionsDB data for whatever reason. See Bug 1280409.
+ *
+ * @param historyExtensionDb Source History Extensions database
+ * @param db Destination database
+ */
+ private void copyHistoryExtensionDataToVisitsTable(final SQLiteDatabase historyExtensionDb, final SQLiteDatabase db) {
+ final String historyExtensionTable = "HistoryExtension";
+ final String columnGuid = "guid";
+ final String columnVisits = "visits";
+
+ final Cursor historyExtensionCursor = historyExtensionDb.query(historyExtensionTable,
+ new String[] {columnGuid, columnVisits},
+ null, null, null, null, null);
+ // Ignore null or empty cursor, we can't (or have nothing to) copy at this point.
+ if (historyExtensionCursor == null) {
+ return;
+ }
+ try {
+ if (!historyExtensionCursor.moveToFirst()) {
+ return;
+ }
+
+ final int guidCol = historyExtensionCursor.getColumnIndexOrThrow(columnGuid);
+
+ // Use prepared (aka "compiled") SQL statements because they are much faster when we're inserting
+ // lots of data. We avoid GC churn and recompilation of SQL statements on every insert.
+ // NB #1: OR IGNORE clause applies to UNIQUE, NOT NULL, CHECK, and PRIMARY KEY constraints.
+ // It does not apply to Foreign Key constraints, but in our case, at this point in time, foreign key
+ // constraints are disabled anyway.
+ // We care about OR IGNORE because we want to ensure that in case of (GUID,DATE)
+ // clash (the UNIQUE constraint), we will not fail the transaction, and just skip conflicting row.
+ // Clash might occur if visits array we got from Sync has duplicate (guid,date) records.
+ // NB #2: IS_LOCAL is always 0, since we consider all visits coming from Sync to be remote.
+ final String insertSqlStatement = "INSERT OR IGNORE INTO " + Visits.TABLE_NAME + " (" +
+ Visits.DATE_VISITED + "," +
+ Visits.VISIT_TYPE + "," +
+ Visits.HISTORY_GUID + "," +
+ Visits.IS_LOCAL + ") VALUES (?, ?, ?, " + Visits.VISIT_IS_REMOTE + ")";
+ final SQLiteStatement compiledInsertStatement = db.compileStatement(insertSqlStatement);
+
+ do {
+ final String guid = historyExtensionCursor.getString(guidCol);
+
+ // Sanity check, let's not risk a bad incoming GUID.
+ if (guid == null || guid.isEmpty()) {
+ continue;
+ }
+
+ // First, check if history with given GUID exists in the History table.
+ // We might have a lot of entries in the HistoryExtensionDatabase whose GUID doesn't
+ // match one in the History table. Let's avoid doing unnecessary work by first checking if
+ // GUID exists locally.
+ // Note that we don't have foreign key constraints enabled at this point.
+ // See Bug 1266232 for details.
+ if (!isGUIDPresentInHistoryTable(db, guid)) {
+ continue;
+ }
+
+ final JSONArray visitsInHistoryExtensionDB = RepoUtils.getJSONArrayFromCursor(historyExtensionCursor, columnVisits);
+
+ if (visitsInHistoryExtensionDB == null) {
+ continue;
+ }
+
+ final int histExtVisitCount = visitsInHistoryExtensionDB.size();
+
+ debug("Inserting " + histExtVisitCount + " visits from history extension db for GUID: " + guid);
+ for (int i = 0; i < histExtVisitCount; i++) {
+ final JSONObject visit = (JSONObject) visitsInHistoryExtensionDB.get(i);
+
+ // Sanity check.
+ if (visit == null) {
+ continue;
+ }
+
+ // Let's not rely on underlying data being correct, and guard against casting failures.
+ // Since we can't recover from this (other than ignoring this visit), let's not fail user's migration.
+ final Long date;
+ final Long visitType;
+ try {
+ date = (Long) visit.get("date");
+ visitType = (Long) visit.get("type");
+ } catch (ClassCastException e) {
+ continue;
+ }
+ // Sanity check our incoming data.
+ if (date == null || visitType == null) {
+ continue;
+ }
+
+ // Bind parameters use a 1-based index.
+ compiledInsertStatement.clearBindings();
+ compiledInsertStatement.bindLong(1, date);
+ compiledInsertStatement.bindLong(2, visitType);
+ compiledInsertStatement.bindString(3, guid);
+ compiledInsertStatement.executeInsert();
+ }
+ } while (historyExtensionCursor.moveToNext());
+ } finally {
+ // We return on a null cursor, so don't have to check it here.
+ historyExtensionCursor.close();
+ }
+ }
+
+ private boolean isGUIDPresentInHistoryTable(final SQLiteDatabase db, String guid) {
+ final Cursor historyCursor = db.query(
+ History.TABLE_NAME,
+ new String[] {History.GUID}, History.GUID + " = ?", new String[] {guid},
+ null, null, null);
+ if (historyCursor == null) {
+ return false;
+ }
+ try {
+ // No history record found for given GUID
+ if (!historyCursor.moveToFirst()) {
+ return false;
+ }
+ } finally {
+ historyCursor.close();
+ }
+
+ return true;
+ }
+
+ private void createSearchHistoryTable(SQLiteDatabase db) {
+ debug("Creating " + SearchHistory.TABLE_NAME + " table");
+
+ db.execSQL("CREATE TABLE " + SearchHistory.TABLE_NAME + "(" +
+ SearchHistory._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ SearchHistory.QUERY + " TEXT UNIQUE NOT NULL, " +
+ SearchHistory.DATE_LAST_VISITED + " INTEGER, " +
+ SearchHistory.VISITS + " INTEGER ) ");
+
+ db.execSQL("CREATE INDEX idx_search_history_last_visited ON " +
+ SearchHistory.TABLE_NAME + "(" + SearchHistory.DATE_LAST_VISITED + ")");
+ }
+
+ private void createActivityStreamBlocklistTable(final SQLiteDatabase db) {
+ debug("Creating " + ActivityStreamBlocklist.TABLE_NAME + " table");
+
+ db.execSQL("CREATE TABLE " + ActivityStreamBlocklist.TABLE_NAME + "(" +
+ ActivityStreamBlocklist._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ ActivityStreamBlocklist.URL + " TEXT UNIQUE NOT NULL, " +
+ ActivityStreamBlocklist.CREATED + " INTEGER NOT NULL)");
+ }
+
+ private void createReadingListTable(final SQLiteDatabase db, final String tableName) {
+ debug("Creating " + TABLE_READING_LIST + " table");
+
+ db.execSQL("CREATE TABLE " + tableName + "(" +
+ ReadingListItems._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ ReadingListItems.GUID + " TEXT UNIQUE, " + // Server-assigned.
+
+ ReadingListItems.CONTENT_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.STATUS_UNFETCHED + ", " +
+ ReadingListItems.SYNC_STATUS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_STATUS_NEW + ", " +
+ ReadingListItems.SYNC_CHANGE_FLAGS + " TINYINT NOT NULL DEFAULT " + ReadingListItems.SYNC_CHANGE_NONE + ", " +
+
+ ReadingListItems.CLIENT_LAST_MODIFIED + " INTEGER NOT NULL, " + // Client time.
+ ReadingListItems.SERVER_LAST_MODIFIED + " INTEGER, " + // Server-assigned.
+
+ // Server-assigned.
+ ReadingListItems.SERVER_STORED_ON + " INTEGER, " +
+ ReadingListItems.ADDED_ON + " INTEGER, " + // Client time. Shouldn't be null, but not enforced. Formerly DATE_CREATED.
+ ReadingListItems.MARKED_READ_ON + " INTEGER, " +
+
+ // These boolean flags represent the server 'status', 'unread', 'is_article', and 'favorite' fields.
+ ReadingListItems.IS_DELETED + " TINYINT NOT NULL DEFAULT 0, " +
+ ReadingListItems.IS_ARCHIVED + " TINYINT NOT NULL DEFAULT 0, " +
+ ReadingListItems.IS_UNREAD + " TINYINT NOT NULL DEFAULT 1, " +
+ ReadingListItems.IS_ARTICLE + " TINYINT NOT NULL DEFAULT 0, " +
+ ReadingListItems.IS_FAVORITE + " TINYINT NOT NULL DEFAULT 0, " +
+
+ ReadingListItems.URL + " TEXT NOT NULL, " +
+ ReadingListItems.TITLE + " TEXT, " +
+ ReadingListItems.RESOLVED_URL + " TEXT, " +
+ ReadingListItems.RESOLVED_TITLE + " TEXT, " +
+
+ ReadingListItems.EXCERPT + " TEXT, " +
+
+ ReadingListItems.ADDED_BY + " TEXT, " +
+ ReadingListItems.MARKED_READ_BY + " TEXT, " +
+
+ ReadingListItems.WORD_COUNT + " INTEGER DEFAULT 0, " +
+ ReadingListItems.READ_POSITION + " INTEGER DEFAULT 0 " +
+ "); ");
+
+ didCreateCurrentReadingListTable = true; // Mostly correct, in the absence of transactions.
+ }
+
+ private void createReadingListIndices(final SQLiteDatabase db, final String tableName) {
+ // No need to create an index on GUID; it's a UNIQUE column.
+ db.execSQL("CREATE INDEX reading_list_url ON " + tableName + "("
+ + ReadingListItems.URL + ")");
+ db.execSQL("CREATE INDEX reading_list_content_status ON " + tableName + "("
+ + ReadingListItems.CONTENT_STATUS + ")");
+ }
+
+ private void createUrlAnnotationsTable(final SQLiteDatabase db) {
+ debug("Creating " + UrlAnnotations.TABLE_NAME + " table");
+
+ db.execSQL("CREATE TABLE " + UrlAnnotations.TABLE_NAME + "(" +
+ UrlAnnotations._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ UrlAnnotations.URL + " TEXT NOT NULL, " +
+ UrlAnnotations.KEY + " TEXT NOT NULL, " +
+ UrlAnnotations.VALUE + " TEXT, " +
+ UrlAnnotations.DATE_CREATED + " INTEGER NOT NULL, " +
+ UrlAnnotations.DATE_MODIFIED + " INTEGER NOT NULL, " +
+ UrlAnnotations.SYNC_STATUS + " TINYINT NOT NULL DEFAULT " + UrlAnnotations.SyncStatus.NEW.getDBValue() +
+ " );");
+
+ db.execSQL("CREATE INDEX idx_url_annotations_url_key ON " +
+ UrlAnnotations.TABLE_NAME + "(" + UrlAnnotations.URL + ", " + UrlAnnotations.KEY + ")");
+ }
+
+ private void createOrUpdateAllSpecialFolders(SQLiteDatabase db) {
+ createOrUpdateSpecialFolder(db, Bookmarks.MOBILE_FOLDER_GUID,
+ R.string.bookmarks_folder_mobile, 0);
+ createOrUpdateSpecialFolder(db, Bookmarks.TOOLBAR_FOLDER_GUID,
+ R.string.bookmarks_folder_toolbar, 1);
+ createOrUpdateSpecialFolder(db, Bookmarks.MENU_FOLDER_GUID,
+ R.string.bookmarks_folder_menu, 2);
+ createOrUpdateSpecialFolder(db, Bookmarks.TAGS_FOLDER_GUID,
+ R.string.bookmarks_folder_tags, 3);
+ createOrUpdateSpecialFolder(db, Bookmarks.UNFILED_FOLDER_GUID,
+ R.string.bookmarks_folder_unfiled, 4);
+ createOrUpdateSpecialFolder(db, Bookmarks.PINNED_FOLDER_GUID,
+ R.string.bookmarks_folder_pinned, 5);
+ }
+
+ private void createOrUpdateSpecialFolder(SQLiteDatabase db,
+ String guid, int titleId, int position) {
+ ContentValues values = new ContentValues();
+ values.put(Bookmarks.GUID, guid);
+ values.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER);
+ values.put(Bookmarks.POSITION, position);
+
+ if (guid.equals(Bookmarks.PLACES_FOLDER_GUID)) {
+ values.put(Bookmarks._ID, Bookmarks.FIXED_ROOT_ID);
+ } else if (guid.equals(Bookmarks.PINNED_FOLDER_GUID)) {
+ values.put(Bookmarks._ID, Bookmarks.FIXED_PINNED_LIST_ID);
+ }
+
+ // Set the parent to 0, which sync assumes is the root
+ values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID);
+
+ String title = mContext.getResources().getString(titleId);
+ values.put(Bookmarks.TITLE, title);
+
+ long now = System.currentTimeMillis();
+ values.put(Bookmarks.DATE_CREATED, now);
+ values.put(Bookmarks.DATE_MODIFIED, now);
+
+ int updated = db.update(TABLE_BOOKMARKS, values,
+ Bookmarks.GUID + " = ?",
+ new String[] { guid });
+
+ if (updated == 0) {
+ db.insert(TABLE_BOOKMARKS, Bookmarks.GUID, values);
+ debug("Inserted special folder: " + guid);
+ } else {
+ debug("Updated special folder: " + guid);
+ }
+ }
+
+ private void createNumbersTable(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + " INTEGER PRIMARY KEY AUTOINCREMENT)");
+
+ if (db.getVersion() >= 3007011) { // SQLite 3.7.11
+ // This is only available in SQLite >= 3.7.11, see release notes:
+ // "Enhance the INSERT syntax to allow multiple rows to be inserted via the VALUES clause"
+ final String numbers = "(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)," +
+ "(10),(11),(12),(13),(14),(15),(16),(17),(18),(19)," +
+ "(20),(21),(22),(23),(24),(25),(26),(27),(28),(29)," +
+ "(30),(31),(32),(33),(34),(35),(36),(37),(38),(39)," +
+ "(40),(41),(42),(43),(44),(45),(46),(47),(48),(49)," +
+ "(50)";
+
+ db.execSQL("INSERT INTO " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + ") VALUES " + numbers);
+ } else {
+ final SQLiteStatement statement = db.compileStatement("INSERT INTO " + Numbers.TABLE_NAME + " (" + Numbers.POSITION + ") VALUES (?)");
+
+ for (int i = 0; i <= Numbers.MAX_VALUE; i++) {
+ statement.bindLong(1, i);
+ statement.executeInsert();
+ }
+ }
+ }
+
+ private boolean isSpecialFolder(ContentValues values) {
+ String guid = values.getAsString(Bookmarks.GUID);
+ if (guid == null) {
+ return false;
+ }
+
+ return guid.equals(Bookmarks.MOBILE_FOLDER_GUID) ||
+ guid.equals(Bookmarks.MENU_FOLDER_GUID) ||
+ guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID) ||
+ guid.equals(Bookmarks.UNFILED_FOLDER_GUID) ||
+ guid.equals(Bookmarks.TAGS_FOLDER_GUID);
+ }
+
+ private void migrateBookmarkFolder(SQLiteDatabase db, int folderId,
+ BookmarkMigrator migrator) {
+ Cursor c = null;
+
+ debug("Migrating bookmark folder with id = " + folderId);
+
+ String selection = Bookmarks.PARENT + " = " + folderId;
+ String[] selectionArgs = null;
+
+ boolean isRootFolder = (folderId == Bookmarks.FIXED_ROOT_ID);
+
+ // If we're loading the root folder, we have to account for
+ // any previously created special folder that was created without
+ // setting a parent id (e.g. mobile folder) and making sure we're
+ // not adding any infinite recursion as root's parent is root itself.
+ if (isRootFolder) {
+ selection = Bookmarks.GUID + " != ?" + " AND (" +
+ selection + " OR " + Bookmarks.PARENT + " = NULL)";
+ selectionArgs = new String[] { Bookmarks.PLACES_FOLDER_GUID };
+ }
+
+ List<Integer> subFolders = new ArrayList<Integer>();
+ List<ContentValues> invalidSpecialEntries = new ArrayList<ContentValues>();
+
+ try {
+ c = db.query(TABLE_BOOKMARKS_TMP,
+ null,
+ selection,
+ selectionArgs,
+ null, null, null);
+
+ // The key point here is that bookmarks should be added in
+ // parent order to avoid any problems with the foreign key
+ // in Bookmarks.PARENT.
+ while (c.moveToNext()) {
+ ContentValues values = new ContentValues();
+
+ // We're using a null projection in the query which
+ // means we're getting all columns from the table.
+ // It's safe to simply transform the row into the
+ // values to be inserted on the new table.
+ DatabaseUtils.cursorRowToContentValues(c, values);
+
+ boolean isSpecialFolder = isSpecialFolder(values);
+
+ // The mobile folder used to be created with PARENT = NULL.
+ // We want fix that here.
+ if (values.getAsLong(Bookmarks.PARENT) == null && isSpecialFolder)
+ values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID);
+
+ if (isRootFolder && !isSpecialFolder) {
+ invalidSpecialEntries.add(values);
+ continue;
+ }
+
+ if (migrator != null)
+ migrator.updateForNewTable(values);
+
+ debug("Migrating bookmark: " + values.getAsString(Bookmarks.TITLE));
+ db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values);
+
+ Integer type = values.getAsInteger(Bookmarks.TYPE);
+ if (type != null && type == Bookmarks.TYPE_FOLDER)
+ subFolders.add(values.getAsInteger(Bookmarks._ID));
+ }
+ } finally {
+ if (c != null)
+ c.close();
+ }
+
+ // At this point is safe to assume that the mobile folder is
+ // in the new table given that we've always created it on
+ // database creation time.
+ final int nInvalidSpecialEntries = invalidSpecialEntries.size();
+ if (nInvalidSpecialEntries > 0) {
+ Integer mobileFolderId = getMobileFolderId(db);
+ if (mobileFolderId == null) {
+ Log.e(LOGTAG, "Error migrating invalid special folder entries: mobile folder id is null");
+ return;
+ }
+
+ debug("Found " + nInvalidSpecialEntries + " invalid special folder entries");
+ for (int i = 0; i < nInvalidSpecialEntries; i++) {
+ ContentValues values = invalidSpecialEntries.get(i);
+ values.put(Bookmarks.PARENT, mobileFolderId);
+
+ db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values);
+ }
+ }
+
+ final int nSubFolders = subFolders.size();
+ for (int i = 0; i < nSubFolders; i++) {
+ int subFolderId = subFolders.get(i);
+ migrateBookmarkFolder(db, subFolderId, migrator);
+ }
+ }
+
+ private void migrateBookmarksTable(SQLiteDatabase db) {
+ migrateBookmarksTable(db, null);
+ }
+
+ private void migrateBookmarksTable(SQLiteDatabase db, BookmarkMigrator migrator) {
+ debug("Renaming bookmarks table to " + TABLE_BOOKMARKS_TMP);
+ db.execSQL("ALTER TABLE " + TABLE_BOOKMARKS +
+ " RENAME TO " + TABLE_BOOKMARKS_TMP);
+
+ debug("Dropping views and indexes related to " + TABLE_BOOKMARKS);
+
+ db.execSQL("DROP INDEX IF EXISTS bookmarks_url_index");
+ db.execSQL("DROP INDEX IF EXISTS bookmarks_type_deleted_index");
+ db.execSQL("DROP INDEX IF EXISTS bookmarks_guid_index");
+ db.execSQL("DROP INDEX IF EXISTS bookmarks_modified_index");
+
+ createBookmarksTable(db);
+
+ createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID,
+ R.string.bookmarks_folder_places, 0);
+
+ migrateBookmarkFolder(db, Bookmarks.FIXED_ROOT_ID, migrator);
+
+ // Ensure all special folders exist and have the
+ // right folder hierarchy.
+ createOrUpdateAllSpecialFolders(db);
+
+ debug("Dropping bookmarks temporary table");
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_BOOKMARKS_TMP);
+ }
+
+ /**
+ * Migrate a history table from some old version to the newest one by creating the new table and
+ * copying all the data over.
+ */
+ private void migrateHistoryTable(SQLiteDatabase db) {
+ debug("Renaming history table to " + TABLE_HISTORY_TMP);
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " RENAME TO " + TABLE_HISTORY_TMP);
+
+ debug("Dropping views and indexes related to " + TABLE_HISTORY);
+
+ db.execSQL("DROP INDEX IF EXISTS history_url_index");
+ db.execSQL("DROP INDEX IF EXISTS history_guid_index");
+ db.execSQL("DROP INDEX IF EXISTS history_modified_index");
+ db.execSQL("DROP INDEX IF EXISTS history_visited_index");
+
+ createHistoryTable(db);
+
+ db.execSQL("INSERT INTO " + TABLE_HISTORY + " SELECT * FROM " + TABLE_HISTORY_TMP);
+
+ debug("Dropping history temporary table");
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_HISTORY_TMP);
+ }
+
+ private void upgradeDatabaseFrom3to4(SQLiteDatabase db) {
+ migrateBookmarksTable(db, new BookmarkMigrator3to4());
+ }
+
+ private void upgradeDatabaseFrom6to7(SQLiteDatabase db) {
+ debug("Removing history visits with NULL GUIDs");
+ db.execSQL("DELETE FROM " + TABLE_HISTORY + " WHERE " + History.GUID + " IS NULL");
+
+ migrateBookmarksTable(db);
+ migrateHistoryTable(db);
+ }
+
+ private void upgradeDatabaseFrom7to8(SQLiteDatabase db) {
+ debug("Combining history entries with the same URL");
+
+ final String TABLE_DUPES = "duped_urls";
+ final String TOTAL = "total";
+ final String LATEST = "latest";
+ final String WINNER = "winner";
+
+ db.execSQL("CREATE TEMP TABLE " + TABLE_DUPES + " AS" +
+ " SELECT " + History.URL + ", " +
+ "SUM(" + History.VISITS + ") AS " + TOTAL + ", " +
+ "MAX(" + History.DATE_MODIFIED + ") AS " + LATEST + ", " +
+ "MAX(" + History._ID + ") AS " + WINNER +
+ " FROM " + TABLE_HISTORY +
+ " GROUP BY " + History.URL +
+ " HAVING count(" + History.URL + ") > 1");
+
+ db.execSQL("CREATE UNIQUE INDEX " + TABLE_DUPES + "_url_index ON " +
+ TABLE_DUPES + " (" + History.URL + ")");
+
+ final String fromClause = " FROM " + TABLE_DUPES + " WHERE " +
+ qualifyColumn(TABLE_DUPES, History.URL) + " = " +
+ qualifyColumn(TABLE_HISTORY, History.URL);
+
+ db.execSQL("UPDATE " + TABLE_HISTORY +
+ " SET " + History.VISITS + " = (SELECT " + TOTAL + fromClause + "), " +
+ History.DATE_MODIFIED + " = (SELECT " + LATEST + fromClause + "), " +
+ History.IS_DELETED + " = " +
+ "(" + History._ID + " <> (SELECT " + WINNER + fromClause + "))" +
+ " WHERE " + History.URL + " IN (SELECT " + History.URL + " FROM " + TABLE_DUPES + ")");
+
+ db.execSQL("DROP TABLE " + TABLE_DUPES);
+ }
+
+ private void upgradeDatabaseFrom10to11(SQLiteDatabase db) {
+ db.execSQL("CREATE INDEX bookmarks_type_deleted_index ON " + TABLE_BOOKMARKS + "("
+ + Bookmarks.TYPE + ", " + Bookmarks.IS_DELETED + ")");
+ }
+
+ private void upgradeDatabaseFrom12to13(SQLiteDatabase db) {
+ createFaviconsTable(db);
+
+ // Add favicon_id column to the history/bookmarks tables. We wrap this in a try-catch
+ // because the column *may* already exist at this point (depending on how many upgrade
+ // steps have been performed in this operation). In which case these queries will throw,
+ // but we don't care.
+ try {
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " ADD COLUMN " + History.FAVICON_ID + " INTEGER");
+ db.execSQL("ALTER TABLE " + TABLE_BOOKMARKS +
+ " ADD COLUMN " + Bookmarks.FAVICON_ID + " INTEGER");
+ } catch (SQLException e) {
+ // Don't care.
+ debug("Exception adding favicon_id column. We're probably fine." + e);
+ }
+
+ createThumbnailsTable(db);
+
+ db.execSQL("DROP VIEW IF EXISTS bookmarks_with_images");
+ db.execSQL("DROP VIEW IF EXISTS history_with_images");
+ db.execSQL("DROP VIEW IF EXISTS combined_with_images");
+
+ createBookmarksWithFaviconsView(db);
+ createHistoryWithFaviconsView(db);
+
+ db.execSQL("DROP TABLE IF EXISTS images");
+ }
+
+ private void upgradeDatabaseFrom13to14(SQLiteDatabase db) {
+ createOrUpdateSpecialFolder(db, Bookmarks.PINNED_FOLDER_GUID,
+ R.string.bookmarks_folder_pinned, 6);
+ }
+
+ private void upgradeDatabaseFrom14to15(SQLiteDatabase db) {
+ Cursor c = null;
+ try {
+ // Get all the pinned bookmarks
+ c = db.query(TABLE_BOOKMARKS,
+ new String[] { Bookmarks._ID, Bookmarks.URL },
+ Bookmarks.PARENT + " = ?",
+ new String[] { Integer.toString(Bookmarks.FIXED_PINNED_LIST_ID) },
+ null, null, null);
+
+ while (c.moveToNext()) {
+ // Check if this URL can be parsed as a URI with a valid scheme.
+ String url = c.getString(c.getColumnIndexOrThrow(Bookmarks.URL));
+ if (Uri.parse(url).getScheme() != null) {
+ continue;
+ }
+
+ // If it can't, update the URL to be an encoded "user-entered" value.
+ ContentValues values = new ContentValues(1);
+ String newUrl = Uri.fromParts("user-entered", url, null).toString();
+ values.put(Bookmarks.URL, newUrl);
+ db.update(TABLE_BOOKMARKS, values, Bookmarks._ID + " = ?",
+ new String[] { Integer.toString(c.getInt(c.getColumnIndexOrThrow(Bookmarks._ID))) });
+ }
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+
+ private void upgradeDatabaseFrom15to16(SQLiteDatabase db) {
+ // No harm in creating the v19 combined view here: means we don't need two almost-identical
+ // functions to define both the v16 and v19 ones. The upgrade path will redundantly drop
+ // and recreate the view again. *shrug*
+ createV19CombinedView(db);
+ }
+
+ private void upgradeDatabaseFrom16to17(SQLiteDatabase db) {
+ // Purge any 0-byte favicons/thumbnails
+ try {
+ db.execSQL("DELETE FROM " + TABLE_FAVICONS +
+ " WHERE length(" + Favicons.DATA + ") = 0");
+ db.execSQL("DELETE FROM " + TABLE_THUMBNAILS +
+ " WHERE length(" + Thumbnails.DATA + ") = 0");
+ } catch (SQLException e) {
+ Log.e(LOGTAG, "Error purging invalid favicons or thumbnails", e);
+ }
+ }
+
+ /*
+ * Moves reading list items from 'bookmarks' table to 'reading_list' table.
+ */
+ private void upgradeDatabaseFrom17to18(SQLiteDatabase db) {
+ debug("Moving reading list items from 'bookmarks' table to 'reading_list' table");
+
+ final String selection = Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " = ? ";
+ final String[] selectionArgs = { String.valueOf(Bookmarks.FIXED_READING_LIST_ID), "0" };
+ final String[] projection = { Bookmarks._ID,
+ Bookmarks.GUID,
+ Bookmarks.URL,
+ Bookmarks.DATE_MODIFIED,
+ Bookmarks.DATE_CREATED,
+ Bookmarks.TITLE };
+
+ try {
+ db.beginTransaction();
+
+ // Create 'reading_list' table.
+ createReadingListTable(db, TABLE_READING_LIST);
+
+ // Get all the reading list items from bookmarks table.
+ final Cursor cursor = db.query(TABLE_BOOKMARKS, projection, selection, selectionArgs, null, null, null);
+
+ if (cursor == null) {
+ // This should never happen.
+ db.setTransactionSuccessful();
+ return;
+ }
+
+ try {
+ // Insert reading list items into reading_list table.
+ while (cursor.moveToNext()) {
+ debug(DatabaseUtils.dumpCurrentRowToString(cursor));
+ final ContentValues values = new ContentValues();
+
+ // We don't preserve bookmark GUIDs.
+ DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.URL, values, ReadingListItems.URL);
+ DatabaseUtils.cursorStringToContentValues(cursor, Bookmarks.TITLE, values, ReadingListItems.TITLE);
+ DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_CREATED, values, ReadingListItems.ADDED_ON);
+ DatabaseUtils.cursorLongToContentValues(cursor, Bookmarks.DATE_MODIFIED, values, ReadingListItems.CLIENT_LAST_MODIFIED);
+
+ db.insertOrThrow(TABLE_READING_LIST, null, values);
+ }
+ } finally {
+ cursor.close();
+ }
+
+ // Delete reading list items from bookmarks table.
+ db.delete(TABLE_BOOKMARKS,
+ Bookmarks.PARENT + " = ? ",
+ new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) });
+
+ // Delete reading list special folder.
+ db.delete(TABLE_BOOKMARKS,
+ Bookmarks._ID + " = ? ",
+ new String[] { String.valueOf(Bookmarks.FIXED_READING_LIST_ID) });
+
+ // Create indices.
+ createReadingListIndices(db, TABLE_READING_LIST);
+
+ // Done.
+ db.setTransactionSuccessful();
+ } catch (SQLException e) {
+ Log.e(LOGTAG, "Error migrating reading list items", e);
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ private void upgradeDatabaseFrom18to19(SQLiteDatabase db) {
+ // Redefine the "combined" view...
+ createV19CombinedView(db);
+
+ // Kill any history entries with NULL URL. This ostensibly can't happen...
+ db.execSQL("DELETE FROM " + TABLE_HISTORY + " WHERE " + History.URL + " IS NULL");
+
+ // Similar for bookmark types. Replaces logic from the combined view, also shouldn't happen.
+ db.execSQL("UPDATE " + TABLE_BOOKMARKS + " SET " +
+ Bookmarks.TYPE + " = " + Bookmarks.TYPE_BOOKMARK +
+ " WHERE " + Bookmarks.TYPE + " IS NULL");
+ }
+
+ private void upgradeDatabaseFrom19to20(SQLiteDatabase db) {
+ createSearchHistoryTable(db);
+ }
+
+ private void upgradeDatabaseFrom21to22(SQLiteDatabase db) {
+ if (didCreateCurrentReadingListTable) {
+ debug("No need to add CONTENT_STATUS to reading list; we just created with the current schema.");
+ return;
+ }
+
+ debug("Adding CONTENT_STATUS column to reading list table.");
+
+ try {
+ db.execSQL("ALTER TABLE " + TABLE_READING_LIST +
+ " ADD COLUMN " + ReadingListItems.CONTENT_STATUS +
+ " TINYINT DEFAULT " + ReadingListItems.STATUS_UNFETCHED);
+
+ db.execSQL("CREATE INDEX reading_list_content_status ON " + TABLE_READING_LIST + "("
+ + ReadingListItems.CONTENT_STATUS + ")");
+ } catch (SQLiteException e) {
+ // We're betting that an error here means that the table already has the column,
+ // so we're failing due to the duplicate column name.
+ Log.e(LOGTAG, "Error upgrading database from 21 to 22", e);
+ }
+ }
+
+ private void upgradeDatabaseFrom22to23(SQLiteDatabase db) {
+ if (didCreateCurrentReadingListTable) {
+ // If we just created this table it is already in the expected >= 23 schema. Trying
+ // to run this migration will crash because columns that were in the <= 22 schema
+ // no longer exist.
+ debug("No need to rev reading list schema; we just created with the current schema.");
+ return;
+ }
+
+ debug("Rewriting reading list table.");
+ createReadingListTable(db, "tmp_rl");
+
+ // Remove indexes. We don't need them now, and we'll be throwing away the table.
+ db.execSQL("DROP INDEX IF EXISTS reading_list_url");
+ db.execSQL("DROP INDEX IF EXISTS reading_list_guid");
+ db.execSQL("DROP INDEX IF EXISTS reading_list_content_status");
+
+ // This used to be a part of the no longer existing ReadingListProvider, since we're deleting
+ // this table later in the second migration, and since sync for this table never existed,
+ // we don't care about the device name here.
+ final String thisDevice = "_fake_device_name_that_will_be_discarded_in_the_next_migration_";
+ db.execSQL("INSERT INTO tmp_rl (" +
+ // Here are the columns we can preserve.
+ ReadingListItems._ID + ", " +
+ ReadingListItems.URL + ", " +
+ ReadingListItems.TITLE + ", " +
+ ReadingListItems.RESOLVED_TITLE + ", " + // = TITLE (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE)
+ ReadingListItems.RESOLVED_URL + ", " + // = URL (if CONTENT_STATUS = STATUS_FETCHED_ARTICLE)
+ ReadingListItems.EXCERPT + ", " +
+ ReadingListItems.IS_UNREAD + ", " + // = !READ
+ ReadingListItems.IS_DELETED + ", " + // = 0
+ ReadingListItems.GUID + ", " + // = NULL
+ ReadingListItems.CLIENT_LAST_MODIFIED + ", " + // = DATE_MODIFIED
+ ReadingListItems.ADDED_ON + ", " + // = DATE_CREATED
+ ReadingListItems.CONTENT_STATUS + ", " +
+ ReadingListItems.MARKED_READ_BY + ", " + // if READ + ", = this device
+ ReadingListItems.ADDED_BY + // = this device
+ ") " +
+ "SELECT " +
+ "_id, url, title, " +
+ "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN title ELSE NULL END, " + // RESOLVED_TITLE.
+ "CASE content_status WHEN " + ReadingListItems.STATUS_FETCHED_ARTICLE + " THEN url ELSE NULL END, " + // RESOLVED_URL.
+ "excerpt, " +
+ "CASE read WHEN 1 THEN 0 ELSE 1 END, " + // IS_UNREAD.
+ "0, " + // IS_DELETED.
+ "NULL, modified, created, content_status, " +
+ "CASE read WHEN 1 THEN ? ELSE NULL END, " + // MARKED_READ_BY.
+ "?" + // ADDED_BY.
+ " FROM " + TABLE_READING_LIST +
+ " WHERE deleted = 0",
+ new String[] {thisDevice, thisDevice});
+
+ // Now switch these tables over and recreate the indices.
+ db.execSQL("DROP TABLE " + TABLE_READING_LIST);
+ db.execSQL("ALTER TABLE tmp_rl RENAME TO " + TABLE_READING_LIST);
+
+ createReadingListIndices(db, TABLE_READING_LIST);
+ }
+
+ private void upgradeDatabaseFrom23to24(SQLiteDatabase db) {
+ // Version 24 consolidates the tabs and clients table into browser.db. Before, they lived in tabs.db.
+ // It's easier to copy the existing data than to arrange for Sync to re-populate it.
+ try {
+ final File oldTabsDBFile = new File(GeckoProfile.get(mContext).getDir(), "tabs.db");
+ copyTabsDB(oldTabsDBFile, db);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception copying tabs and clients data from tabs.db to browser.db; ignoring.", e);
+ }
+
+ // Delete the database, the shared memory, and the log.
+ for (String filename : new String[] { "tabs.db", "tabs.db-shm", "tabs.db-wal" }) {
+ final File file = new File(GeckoProfile.get(mContext).getDir(), filename);
+ try {
+ FileUtils.delete(file);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception occurred while trying to delete " + file.getPath() + "; ignoring.", e);
+ }
+ }
+ }
+
+ private void upgradeDatabaseFrom24to25(SQLiteDatabase db) {
+ if (didCreateTabsTable) {
+ // This migration adds a foreign key constraint (the table scheme stays identical, except
+ // for the new constraint) - hence it is safe to run this migration on a newly created tabs
+ // table - but it's unnecessary hence we should avoid doing so.
+ debug("No need to rev tabs schema; foreign key constraint exists.");
+ return;
+ }
+
+ debug("Rewriting tabs table.");
+ createTabsTable(db, "tmp_tabs");
+
+ // Remove indexes. We don't need them now, and we'll be throwing away the table.
+ db.execSQL("DROP INDEX IF EXISTS " + TabsProvider.INDEX_TABS_GUID);
+ db.execSQL("DROP INDEX IF EXISTS " + TabsProvider.INDEX_TABS_POSITION);
+
+ db.execSQL("INSERT INTO tmp_tabs (" +
+ // Here are the columns we can preserve.
+ BrowserContract.Tabs._ID + ", " +
+ BrowserContract.Tabs.CLIENT_GUID + ", " +
+ BrowserContract.Tabs.TITLE + ", " +
+ BrowserContract.Tabs.URL + ", " +
+ BrowserContract.Tabs.HISTORY + ", " +
+ BrowserContract.Tabs.FAVICON + ", " +
+ BrowserContract.Tabs.LAST_USED + ", " +
+ BrowserContract.Tabs.POSITION +
+ ") " +
+ "SELECT " +
+ "_id, client_guid, title, url, history, favicon, last_used, position" +
+ " FROM " + TABLE_TABS);
+
+ // Now switch these tables over and recreate the indices.
+ db.execSQL("DROP TABLE " + TABLE_TABS);
+ db.execSQL("ALTER TABLE tmp_tabs RENAME TO " + TABLE_TABS);
+ createTabsTableIndices(db, TABLE_TABS);
+ didCreateTabsTable = true;
+ }
+
+ private void upgradeDatabaseFrom25to26(SQLiteDatabase db) {
+ debug("Dropping unnecessary indices");
+ db.execSQL("DROP INDEX IF EXISTS clients_guid_index");
+ db.execSQL("DROP INDEX IF EXISTS thumbnails_url_index");
+ db.execSQL("DROP INDEX IF EXISTS favicons_url_index");
+ }
+
+ private void upgradeDatabaseFrom27to28(final SQLiteDatabase db) {
+ debug("Adding url annotations table");
+ createUrlAnnotationsTable(db);
+ }
+
+ private void upgradeDatabaseFrom28to29(SQLiteDatabase db) {
+ debug("Adding numbers table");
+ createNumbersTable(db);
+ }
+
+ private void upgradeDatabaseFrom29to30(final SQLiteDatabase db) {
+ debug("creating logins table");
+ createDeletedLoginsTable(db, TABLE_DELETED_LOGINS);
+ createDisabledHostsTable(db, TABLE_DISABLED_HOSTS);
+ createLoginsTable(db, TABLE_LOGINS);
+ createLoginsTableIndices(db, TABLE_LOGINS);
+ }
+
+ // Get the cache path for a URL, based on the storage format in place during the 27to28 transition.
+ // This is a reimplementation of _toHashedPath from ReaderMode.jsm - given that we're likely
+ // to migrate the SavedReaderViewHelper implementation at some point, it seems safest to have a local
+ // implementation here - moreover this is probably faster than calling into JS.
+ // This is public only to allow for testing.
+ @RobocopTarget
+ public static String getReaderCacheFileNameForURL(String url) {
+ try {
+ // On KitKat and above we can use java.nio.charset.StandardCharsets.UTF_8 in place of "UTF8"
+ // which avoids having to handle UnsupportedCodingException
+ byte[] utf8 = url.getBytes("UTF8");
+
+ final MessageDigest digester = MessageDigest.getInstance("MD5");
+ byte[] hash = digester.digest(utf8);
+
+ final String hashString = new Base32().encodeAsString(hash);
+ return hashString.substring(0, hashString.indexOf('=')) + ".json";
+ } catch (UnsupportedEncodingException e) {
+ // This should never happen
+ throw new IllegalStateException("UTF8 encoding not available - can't process readercache filename");
+ } catch (NoSuchAlgorithmException e) {
+ // This should also never happen
+ throw new IllegalStateException("MD5 digester unavailable - can't process readercache filename");
+ }
+ }
+
+ /*
+ * Moves reading list items from the 'reading_list' table back into the 'bookmarks' table. This time the
+ * reading list items are placed into a "Reading List" folder, which is a subfolder of the mobile-bookmarks table.
+ */
+ private void upgradeDatabaseFrom30to31(SQLiteDatabase db) {
+ // We only need to do the migration if reading-list items already exist. We could do a query of count(*) on
+ // TABLE_READING_LIST, however if we are doing the migration, we'll need to query all items in the reading-list,
+ // hence we might as well just query all items, and proceed with the migration if cursor.count > 0.
+
+ // We try to retain the original ordering below. Our LocalReadingListAccessor actually coalesced
+ // SERVER_STORED_ON with ADDED_ON to determine positioning, however reading list syncing was never
+ // implemented hence SERVER_STORED will have always been null.
+ final Cursor readingListCursor = db.query(TABLE_READING_LIST,
+ new String[] {
+ ReadingListItems.URL,
+ ReadingListItems.TITLE,
+ ReadingListItems.ADDED_ON,
+ ReadingListItems.CLIENT_LAST_MODIFIED
+ },
+ ReadingListItems.IS_DELETED + " = 0",
+ null,
+ null,
+ null,
+ ReadingListItems.ADDED_ON + " DESC");
+
+ // We'll want to walk the cache directory, so that we can (A) bookkeep readercache items
+ // that we want and (B) delete unneeded readercache items. (B) shouldn't actually happen, but
+ // is possible if there were bugs in our reader-caching code.
+ // We need to construct this here since we populate this map while walking the DB cursor,
+ // and use the map later when walking the cache.
+ final Map<String, String> fileToURLMap = new HashMap<>();
+
+
+ try {
+ if (!readingListCursor.moveToFirst()) {
+ return;
+ }
+
+ final Integer mobileBookmarksID = getMobileFolderId(db);
+
+ if (mobileBookmarksID == null) {
+ // This folder is created either on DB creation or during the 3-4 or 6-7 migrations.
+ throw new IllegalStateException("mobile bookmarks folder must already exist");
+ }
+
+ final long now = System.currentTimeMillis();
+
+ // We try to retain the same order as the reading-list would show. We should hopefully be reading the
+ // items in the order they are displayed on screen (final param of db.query above), by providing
+ // a position we should obtain the same ordering in the bookmark folder.
+ long position = 0;
+
+ final int titleColumnID = readingListCursor.getColumnIndexOrThrow(ReadingListItems.TITLE);
+ final int createdColumnID = readingListCursor.getColumnIndexOrThrow(ReadingListItems.ADDED_ON);
+
+ // This isn't the most efficient implementation, but the migration is one-off, and this
+ // also more maintainable than the SQL equivalent (generating the guids correctly is
+ // difficult in SQLite).
+ do {
+ final ContentValues readingListItemValues = new ContentValues();
+
+ final String url = readingListCursor.getString(readingListCursor.getColumnIndexOrThrow(ReadingListItems.URL));
+
+ readingListItemValues.put(Bookmarks.PARENT, mobileBookmarksID);
+ readingListItemValues.put(Bookmarks.GUID, Utils.generateGuid());
+ readingListItemValues.put(Bookmarks.URL, url);
+ // Title may be null, however we're expecting a String - we can generate an empty string if needed:
+ if (!readingListCursor.isNull(titleColumnID)) {
+ readingListItemValues.put(Bookmarks.TITLE, readingListCursor.getString(titleColumnID));
+ } else {
+ readingListItemValues.put(Bookmarks.TITLE, "");
+ }
+ readingListItemValues.put(Bookmarks.DATE_CREATED, readingListCursor.getLong(createdColumnID));
+ readingListItemValues.put(Bookmarks.DATE_MODIFIED, now);
+ readingListItemValues.put(Bookmarks.POSITION, position);
+
+ db.insert(TABLE_BOOKMARKS,
+ null,
+ readingListItemValues);
+
+ final String cacheFileName = getReaderCacheFileNameForURL(url);
+ fileToURLMap.put(cacheFileName, url);
+
+ position++;
+ } while (readingListCursor.moveToNext());
+
+ } finally {
+ readingListCursor.close();
+ // We need to do this work here since we might be returning (we return early if the
+ // reading-list table is empty).
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_READING_LIST);
+ createBookmarksWithAnnotationsView(db);
+ }
+
+ final File profileDir = GeckoProfile.get(mContext).getDir();
+ final File cacheDir = new File(profileDir, "readercache");
+
+ // At the time of this migration the SavedReaderViewHelper becomes a 1:1 mirror of reader view
+ // url-annotations. This may change in future implementations, however currently we only need to care
+ // about standard bookmarks (untouched during this migration) and bookmarks with a reader
+ // view annotation (which we're creating here, and which are guaranteed to be saved offline).
+ //
+ // This is why we have to migrate the cache items (instead of cleaning the cache
+ // and rebuilding it). We simply don't support uncached reader view bookmarks, and we would
+ // break existing reading list items (they would convert into plain bookmarks without
+ // reader view). This helps ensure that offline content isn't lost during the migration.
+ if (cacheDir.exists() && cacheDir.isDirectory()) {
+ SavedReaderViewHelper savedReaderViewHelper = SavedReaderViewHelper.getSavedReaderViewHelper(mContext);
+
+ // Usually we initialise the helper during onOpen(). However onUpgrade() is run before
+ // onOpen() hence we need to manually initialise it at this stage.
+ savedReaderViewHelper.loadItems();
+
+ for (File cacheFile : cacheDir.listFiles()) {
+ if (fileToURLMap.containsKey(cacheFile.getName())) {
+ final String url = fileToURLMap.get(cacheFile.getName());
+ final String path = cacheFile.getAbsolutePath();
+ long size = cacheFile.length();
+
+ savedReaderViewHelper.put(url, path, size);
+ } else {
+ // This should never happen, but we don't actually know whether or not orphaned
+ // items happened in the wild.
+ boolean deleted = cacheFile.delete();
+
+ if (!deleted) {
+ Log.w(LOGTAG, "Failed to delete orphaned saved reader view file.");
+ }
+ }
+ }
+ }
+ }
+
+ private void upgradeDatabaseFrom31to32(final SQLiteDatabase db) {
+ debug("Adding visits table");
+ createVisitsTable(db);
+
+ debug("Migrating visits from history extension db into visits table");
+ String historyExtensionDbName = "history_extension_database";
+
+ SQLiteDatabase historyExtensionDb = null;
+ final File historyExtensionsDatabase = mContext.getDatabasePath(historyExtensionDbName);
+
+ // Primary goal of this migration is to improve Top Sites experience by distinguishing between
+ // local and remote visits. If Sync is enabled, we rely on visit data from Sync and treat it as remote.
+ // However, if Sync is disabled but we detect evidence that it was enabled at some point (HistoryExtensionsDB is present)
+ // then we synthesize visits from the History table, but we mark them all as "remote". This will ensure
+ // that once user starts browsing around, their Top Sites will reflect their local browsing history.
+ // Otherwise, we risk overwhelming their Top Sites with remote history, just as we did before this migration.
+ try {
+ // If FxAccount exists (Sync is enabled) then port data over to the Visits table.
+ if (FirefoxAccounts.firefoxAccountsExist(mContext)) {
+ try {
+ historyExtensionDb = SQLiteDatabase.openDatabase(historyExtensionsDatabase.getPath(), null,
+ SQLiteDatabase.OPEN_READONLY);
+
+ if (historyExtensionDb != null) {
+ copyHistoryExtensionDataToVisitsTable(historyExtensionDb, db);
+ }
+
+ // If we fail to open HistoryExtensionDatabase, then synthesize visits marking them as remote
+ } catch (SQLiteException e) {
+ Log.w(LOGTAG, "Couldn't open history extension database; synthesizing visits instead", e);
+ synthesizeAndInsertVisits(db, false);
+
+ // It's possible that we might fail to copy over visit data from the HistoryExtensionsDB,
+ // so let's synthesize visits marking them as remote. See Bug 1280409.
+ } catch (IllegalStateException e) {
+ Log.w(LOGTAG, "Couldn't copy over history extension data; synthesizing visits instead", e);
+ synthesizeAndInsertVisits(db, false);
+ }
+
+ // FxAccount doesn't exist, but there's evidence Sync was enabled at some point.
+ // Synthesize visits from History table marking them all as remote.
+ } else if (historyExtensionsDatabase.exists()) {
+ synthesizeAndInsertVisits(db, false);
+
+ // FxAccount doesn't exist and there's no evidence sync was ever enabled.
+ // Synthesize visits from History table marking them all as local.
+ } else {
+ synthesizeAndInsertVisits(db, true);
+ }
+ } finally {
+ if (historyExtensionDb != null) {
+ historyExtensionDb.close();
+ }
+ }
+
+ // Delete history extensions database if it's present.
+ if (historyExtensionsDatabase.exists()) {
+ if (!mContext.deleteDatabase(historyExtensionDbName)) {
+ Log.e(LOGTAG, "Couldn't remove history extension database");
+ }
+ }
+ }
+
+ private void synthesizeAndInsertVisits(final SQLiteDatabase db, boolean markAsLocal) {
+ final Cursor cursor = db.query(
+ History.TABLE_NAME,
+ new String[] {History.GUID, History.VISITS, History.DATE_LAST_VISITED},
+ null, null, null, null, null);
+ if (cursor == null) {
+ Log.e(LOGTAG, "Null cursor while selecting all history records");
+ return;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ Log.e(LOGTAG, "No history records to synthesize visits for.");
+ return;
+ }
+
+ int guidCol = cursor.getColumnIndexOrThrow(History.GUID);
+ int visitsCol = cursor.getColumnIndexOrThrow(History.VISITS);
+ int dateCol = cursor.getColumnIndexOrThrow(History.DATE_LAST_VISITED);
+
+ // Re-use compiled SQL statements for faster inserts.
+ // Visit Type is going to be 1, which is the column's default value.
+ final String insertSqlStatement = "INSERT OR IGNORE INTO " + Visits.TABLE_NAME + "(" +
+ Visits.DATE_VISITED + "," +
+ Visits.HISTORY_GUID + "," +
+ Visits.IS_LOCAL +
+ ") VALUES (?, ?, ?)";
+ final SQLiteStatement compiledInsertStatement = db.compileStatement(insertSqlStatement);
+
+ // For each history record, insert as many visits as there are recorded in the VISITS column.
+ do {
+ final int numberOfVisits = cursor.getInt(visitsCol);
+ final String guid = cursor.getString(guidCol);
+ final long lastVisitedDate = cursor.getLong(dateCol);
+
+ // Sanity check.
+ if (guid == null) {
+ continue;
+ }
+
+ // In a strange case that lastVisitedDate is a very low number, let's not introduce
+ // negative timestamps into our data.
+ if (lastVisitedDate - numberOfVisits < 0) {
+ continue;
+ }
+
+ for (int i = 0; i < numberOfVisits; i++) {
+ final long offsetVisitedDate = lastVisitedDate - i;
+ compiledInsertStatement.clearBindings();
+ compiledInsertStatement.bindLong(1, offsetVisitedDate);
+ compiledInsertStatement.bindString(2, guid);
+ // Very old school, 1 is true and 0 is false :)
+ if (markAsLocal) {
+ compiledInsertStatement.bindLong(3, Visits.VISIT_IS_LOCAL);
+ } else {
+ compiledInsertStatement.bindLong(3, Visits.VISIT_IS_REMOTE);
+ }
+ compiledInsertStatement.executeInsert();
+ }
+ } while (cursor.moveToNext());
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error while synthesizing visits for history record", e);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private void updateHistoryTableAddVisitAggregates(final SQLiteDatabase db) {
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " ADD COLUMN " + History.LOCAL_VISITS + " INTEGER NOT NULL DEFAULT 0");
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " ADD COLUMN " + History.REMOTE_VISITS + " INTEGER NOT NULL DEFAULT 0");
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " ADD COLUMN " + History.LOCAL_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0");
+ db.execSQL("ALTER TABLE " + TABLE_HISTORY +
+ " ADD COLUMN " + History.REMOTE_DATE_LAST_VISITED + " INTEGER NOT NULL DEFAULT 0");
+ }
+
+ private void calculateHistoryTableVisitAggregates(final SQLiteDatabase db) {
+ // Note that we convert from microseconds (timestamps in the visits table) to milliseconds
+ // (timestamps in the history table). Sync works in microseconds, so for visits Fennec stores
+ // timestamps in microseconds as well - but the rest of the timestamps are stored in milliseconds.
+ db.execSQL("UPDATE " + TABLE_HISTORY + " SET " +
+ History.LOCAL_VISITS + " = (" +
+ "SELECT COALESCE(SUM(" + qualifyColumn(TABLE_VISITS, Visits.IS_LOCAL) + "), 0)" +
+ " FROM " + TABLE_VISITS +
+ " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) +
+ "), " +
+ History.REMOTE_VISITS + " = (" +
+ "SELECT COALESCE(SUM(CASE " + Visits.IS_LOCAL + " WHEN 0 THEN 1 ELSE 0 END), 0)" +
+ " FROM " + TABLE_VISITS +
+ " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) +
+ "), " +
+ History.LOCAL_DATE_LAST_VISITED + " = (" +
+ "SELECT COALESCE(MAX(CASE " + Visits.IS_LOCAL + " WHEN 1 THEN " + Visits.DATE_VISITED + " ELSE 0 END), 0) / 1000" +
+ " FROM " + TABLE_VISITS +
+ " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) +
+ "), " +
+ History.REMOTE_DATE_LAST_VISITED + " = (" +
+ "SELECT COALESCE(MAX(CASE " + Visits.IS_LOCAL + " WHEN 0 THEN " + Visits.DATE_VISITED + " ELSE 0 END), 0) / 1000" +
+ " FROM " + TABLE_VISITS +
+ " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) +
+ ") " +
+ "WHERE EXISTS " +
+ "(SELECT " + Visits._ID +
+ " FROM " + TABLE_VISITS +
+ " WHERE " + qualifyColumn(TABLE_VISITS, Visits.HISTORY_GUID) + " = " + qualifyColumn(TABLE_HISTORY, History.GUID) + ")"
+ );
+ }
+
+ private void upgradeDatabaseFrom32to33(final SQLiteDatabase db) {
+ createV33CombinedView(db);
+ }
+
+ private void upgradeDatabaseFrom33to34(final SQLiteDatabase db) {
+ updateHistoryTableAddVisitAggregates(db);
+ calculateHistoryTableVisitAggregates(db);
+ createV34CombinedView(db);
+ }
+
+ private void upgradeDatabaseFrom34to35(final SQLiteDatabase db) {
+ createActivityStreamBlocklistTable(db);
+ }
+
+ private void upgradeDatabaseFrom35to36(final SQLiteDatabase db) {
+ createPageMetadataTable(db);
+ }
+
+ private void createV33CombinedView(final SQLiteDatabase db) {
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED);
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS);
+
+ createCombinedViewOn33(db);
+ }
+
+ private void createV34CombinedView(final SQLiteDatabase db) {
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED);
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS);
+
+ createCombinedViewOn34(db);
+ }
+
+ private void createV19CombinedView(SQLiteDatabase db) {
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED);
+ db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS);
+
+ createCombinedViewOn19(db);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ debug("Upgrading browser.db: " + db.getPath() + " from " +
+ oldVersion + " to " + newVersion);
+
+ // We have to do incremental upgrades until we reach the current
+ // database schema version.
+ for (int v = oldVersion + 1; v <= newVersion; v++) {
+ switch (v) {
+ case 4:
+ upgradeDatabaseFrom3to4(db);
+ break;
+
+ case 7:
+ upgradeDatabaseFrom6to7(db);
+ break;
+
+ case 8:
+ upgradeDatabaseFrom7to8(db);
+ break;
+
+ case 11:
+ upgradeDatabaseFrom10to11(db);
+ break;
+
+ case 13:
+ upgradeDatabaseFrom12to13(db);
+ break;
+
+ case 14:
+ upgradeDatabaseFrom13to14(db);
+ break;
+
+ case 15:
+ upgradeDatabaseFrom14to15(db);
+ break;
+
+ case 16:
+ upgradeDatabaseFrom15to16(db);
+ break;
+
+ case 17:
+ upgradeDatabaseFrom16to17(db);
+ break;
+
+ case 18:
+ upgradeDatabaseFrom17to18(db);
+ break;
+
+ case 19:
+ upgradeDatabaseFrom18to19(db);
+ break;
+
+ case 20:
+ upgradeDatabaseFrom19to20(db);
+ break;
+
+ case 22:
+ upgradeDatabaseFrom21to22(db);
+ break;
+
+ case 23:
+ upgradeDatabaseFrom22to23(db);
+ break;
+
+ case 24:
+ upgradeDatabaseFrom23to24(db);
+ break;
+
+ case 25:
+ upgradeDatabaseFrom24to25(db);
+ break;
+
+ case 26:
+ upgradeDatabaseFrom25to26(db);
+ break;
+
+ // case 27 occurs in UrlMetadataTable.onUpgrade
+
+ case 28:
+ upgradeDatabaseFrom27to28(db);
+ break;
+
+ case 29:
+ upgradeDatabaseFrom28to29(db);
+ break;
+
+ case 30:
+ upgradeDatabaseFrom29to30(db);
+ break;
+
+ case 31:
+ upgradeDatabaseFrom30to31(db);
+ break;
+
+ case 32:
+ upgradeDatabaseFrom31to32(db);
+ break;
+
+ case 33:
+ upgradeDatabaseFrom32to33(db);
+ break;
+
+ case 34:
+ upgradeDatabaseFrom33to34(db);
+ break;
+
+ case 35:
+ upgradeDatabaseFrom34to35(db);
+ break;
+
+ case 36:
+ upgradeDatabaseFrom35to36(db);
+ break;
+ }
+ }
+
+ for (Table table : BrowserProvider.sTables) {
+ table.onUpgrade(db, oldVersion, newVersion);
+ }
+
+ // Delete the obsolete favicon database after all other upgrades complete.
+ // This can probably equivalently be moved into upgradeDatabaseFrom12to13.
+ if (oldVersion < 13 && newVersion >= 13) {
+ if (mContext.getDatabasePath("favicon_urls.db").exists()) {
+ mContext.deleteDatabase("favicon_urls.db");
+ }
+ }
+ }
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ debug("Opening browser.db: " + db.getPath());
+
+ // Force explicit readercache loading - we won't access readercache state for bookmarks
+ // until we actually know what our bookmarks are. Bookmarks are stored in the DB, hence
+ // it is sufficient to ensure that the readercache is loaded before the DB can be accessed.
+ // Note, this takes ~4-6ms to load on an N4 (compared to 20-50ms for most DB queries), and
+ // is only done once, hence this shouldn't have noticeable impact on performance. Moreover
+ // this is run on a background thread and therefore won't block UI code during startup.
+ SavedReaderViewHelper.getSavedReaderViewHelper(mContext).loadItems();
+
+ Cursor cursor = null;
+ try {
+ cursor = db.rawQuery("PRAGMA foreign_keys=ON", null);
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+ cursor = null;
+ try {
+ cursor = db.rawQuery("PRAGMA synchronous=NORMAL", null);
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ // From Honeycomb on, it's possible to run several db
+ // commands in parallel using multiple connections.
+ if (Build.VERSION.SDK_INT >= 11) {
+ // Modern Android allows WAL to be enabled through a mode flag.
+ if (Build.VERSION.SDK_INT < 16) {
+ db.enableWriteAheadLogging();
+
+ // This does nothing on 16+.
+ db.setLockingEnabled(false);
+ }
+ } else {
+ // Pre-Honeycomb, we can do some lesser optimizations.
+ cursor = null;
+ try {
+ cursor = db.rawQuery("PRAGMA journal_mode=PERSIST", null);
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+ }
+ }
+
+ // Calculate these once, at initialization. isLoggable is too expensive to
+ // have in-line in each log call.
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+ protected static void trace(String message) {
+ if (logVerbose) {
+ Log.v(LOGTAG, message);
+ }
+ }
+
+ protected static void debug(String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ private Integer getMobileFolderId(SQLiteDatabase db) {
+ Cursor c = null;
+
+ try {
+ c = db.query(TABLE_BOOKMARKS,
+ mobileIdColumns,
+ Bookmarks.GUID + " = ?",
+ mobileIdSelectionArgs,
+ null, null, null);
+
+ if (c == null || !c.moveToFirst())
+ return null;
+
+ return c.getInt(c.getColumnIndex(Bookmarks._ID));
+ } finally {
+ if (c != null)
+ c.close();
+ }
+ }
+
+ private interface BookmarkMigrator {
+ public void updateForNewTable(ContentValues bookmark);
+ }
+
+ private class BookmarkMigrator3to4 implements BookmarkMigrator {
+ @Override
+ public void updateForNewTable(ContentValues bookmark) {
+ Integer isFolder = bookmark.getAsInteger("folder");
+ if (isFolder == null || isFolder != 1) {
+ bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_BOOKMARK);
+ } else {
+ bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER);
+ }
+
+ bookmark.remove("folder");
+ }
+ }
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
new file mode 100644
index 000000000..eb75d0be9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -0,0 +1,2340 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.BrowserContract.FaviconColumns;
+import org.mozilla.gecko.db.BrowserContract.Favicons;
+import org.mozilla.gecko.db.BrowserContract.Highlights;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.Visits;
+import org.mozilla.gecko.db.BrowserContract.Schema;
+import org.mozilla.gecko.db.BrowserContract.Tabs;
+import org.mozilla.gecko.db.BrowserContract.Thumbnails;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
+import org.mozilla.gecko.db.DBUtils.UpdateOperation;
+import org.mozilla.gecko.icons.IconsHelper;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.BroadcastReceiver;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.OperationApplicationException;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteCursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class BrowserProvider extends SharedBrowserDatabaseProvider {
+ public static final String ACTION_SHRINK_MEMORY = "org.mozilla.gecko.db.intent.action.SHRINK_MEMORY";
+
+ private static final String LOGTAG = "GeckoBrowserProvider";
+
+ // How many records to reposition in a single query.
+ // This should be less than the SQLite maximum number of query variables
+ // (currently 999) divided by the number of variables used per positioning
+ // query (currently 3).
+ static final int MAX_POSITION_UPDATES_PER_QUERY = 100;
+
+ // Minimum number of records to keep when expiring history.
+ static final int DEFAULT_EXPIRY_RETAIN_COUNT = 2000;
+ static final int AGGRESSIVE_EXPIRY_RETAIN_COUNT = 500;
+
+ // Factor used to determine the minimum number of records to keep when expiring the activity stream blocklist
+ static final int ACTIVITYSTREAM_BLOCKLIST_EXPIRY_FACTOR = 5;
+
+ // Minimum duration to keep when expiring.
+ static final long DEFAULT_EXPIRY_PRESERVE_WINDOW = 1000L * 60L * 60L * 24L * 28L; // Four weeks.
+ // Minimum number of thumbnails to keep around.
+ static final int DEFAULT_EXPIRY_THUMBNAIL_COUNT = 15;
+
+ static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
+ static final String TABLE_HISTORY = History.TABLE_NAME;
+ static final String TABLE_VISITS = Visits.TABLE_NAME;
+ static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
+ static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
+ static final String TABLE_TABS = Tabs.TABLE_NAME;
+ static final String TABLE_URL_ANNOTATIONS = UrlAnnotations.TABLE_NAME;
+ static final String TABLE_ACTIVITY_STREAM_BLOCKLIST = ActivityStreamBlocklist.TABLE_NAME;
+ static final String TABLE_PAGE_METADATA = PageMetadata.TABLE_NAME;
+
+ static final String VIEW_COMBINED = Combined.VIEW_NAME;
+ static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS;
+ static final String VIEW_BOOKMARKS_WITH_ANNOTATIONS = Bookmarks.VIEW_WITH_ANNOTATIONS;
+ static final String VIEW_HISTORY_WITH_FAVICONS = History.VIEW_WITH_FAVICONS;
+ static final String VIEW_COMBINED_WITH_FAVICONS = Combined.VIEW_WITH_FAVICONS;
+
+ // Bookmark matches
+ static final int BOOKMARKS = 100;
+ static final int BOOKMARKS_ID = 101;
+ static final int BOOKMARKS_FOLDER_ID = 102;
+ static final int BOOKMARKS_PARENT = 103;
+ static final int BOOKMARKS_POSITIONS = 104;
+
+ // History matches
+ static final int HISTORY = 200;
+ static final int HISTORY_ID = 201;
+ static final int HISTORY_OLD = 202;
+
+ // Favicon matches
+ static final int FAVICONS = 300;
+ static final int FAVICON_ID = 301;
+
+ // Schema matches
+ static final int SCHEMA = 400;
+
+ // Combined bookmarks and history matches
+ static final int COMBINED = 500;
+
+ // Control matches
+ static final int CONTROL = 600;
+
+ // Search Suggest matches. Obsolete.
+ static final int SEARCH_SUGGEST = 700;
+
+ // Thumbnail matches
+ static final int THUMBNAILS = 800;
+ static final int THUMBNAIL_ID = 801;
+
+ static final int URL_ANNOTATIONS = 900;
+
+ static final int TOPSITES = 1000;
+
+ static final int VISITS = 1100;
+
+ static final int METADATA = 1200;
+
+ static final int HIGHLIGHTS = 1300;
+
+ static final int ACTIVITY_STREAM_BLOCKLIST = 1400;
+
+ static final int PAGE_METADATA = 1500;
+
+ static final String DEFAULT_BOOKMARKS_SORT_ORDER = Bookmarks.TYPE
+ + " ASC, " + Bookmarks.POSITION + " ASC, " + Bookmarks._ID
+ + " ASC";
+
+ static final String DEFAULT_HISTORY_SORT_ORDER = History.DATE_LAST_VISITED + " DESC";
+ static final String DEFAULT_VISITS_SORT_ORDER = Visits.DATE_VISITED + " DESC";
+
+ static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ static final Map<String, String> BOOKMARKS_PROJECTION_MAP;
+ static final Map<String, String> HISTORY_PROJECTION_MAP;
+ static final Map<String, String> COMBINED_PROJECTION_MAP;
+ static final Map<String, String> SCHEMA_PROJECTION_MAP;
+ static final Map<String, String> FAVICONS_PROJECTION_MAP;
+ static final Map<String, String> THUMBNAILS_PROJECTION_MAP;
+ static final Map<String, String> URL_ANNOTATIONS_PROJECTION_MAP;
+ static final Map<String, String> VISIT_PROJECTION_MAP;
+ static final Map<String, String> PAGE_METADATA_PROJECTION_MAP;
+ static final Table[] sTables;
+
+ static {
+ sTables = new Table[] {
+ // See awful shortcut assumption hack in getURLMetadataTable.
+ new URLMetadataTable()
+ };
+ // We will reuse this.
+ HashMap<String, String> map;
+
+ // Bookmarks
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks", BOOKMARKS);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/#", BOOKMARKS_ID);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/parents", BOOKMARKS_PARENT);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/positions", BOOKMARKS_POSITIONS);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/folder/#", BOOKMARKS_FOLDER_ID);
+
+ map = new HashMap<String, String>();
+ map.put(Bookmarks._ID, Bookmarks._ID);
+ map.put(Bookmarks.TITLE, Bookmarks.TITLE);
+ map.put(Bookmarks.URL, Bookmarks.URL);
+ map.put(Bookmarks.FAVICON, Bookmarks.FAVICON);
+ map.put(Bookmarks.FAVICON_ID, Bookmarks.FAVICON_ID);
+ map.put(Bookmarks.FAVICON_URL, Bookmarks.FAVICON_URL);
+ map.put(Bookmarks.TYPE, Bookmarks.TYPE);
+ map.put(Bookmarks.PARENT, Bookmarks.PARENT);
+ map.put(Bookmarks.POSITION, Bookmarks.POSITION);
+ map.put(Bookmarks.TAGS, Bookmarks.TAGS);
+ map.put(Bookmarks.DESCRIPTION, Bookmarks.DESCRIPTION);
+ map.put(Bookmarks.KEYWORD, Bookmarks.KEYWORD);
+ map.put(Bookmarks.DATE_CREATED, Bookmarks.DATE_CREATED);
+ map.put(Bookmarks.DATE_MODIFIED, Bookmarks.DATE_MODIFIED);
+ map.put(Bookmarks.GUID, Bookmarks.GUID);
+ map.put(Bookmarks.IS_DELETED, Bookmarks.IS_DELETED);
+ BOOKMARKS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // History
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history", HISTORY);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/#", HISTORY_ID);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/old", HISTORY_OLD);
+
+ map = new HashMap<String, String>();
+ map.put(History._ID, History._ID);
+ map.put(History.TITLE, History.TITLE);
+ map.put(History.URL, History.URL);
+ map.put(History.FAVICON, History.FAVICON);
+ map.put(History.FAVICON_ID, History.FAVICON_ID);
+ map.put(History.FAVICON_URL, History.FAVICON_URL);
+ map.put(History.VISITS, History.VISITS);
+ map.put(History.LOCAL_VISITS, History.LOCAL_VISITS);
+ map.put(History.REMOTE_VISITS, History.REMOTE_VISITS);
+ map.put(History.DATE_LAST_VISITED, History.DATE_LAST_VISITED);
+ map.put(History.LOCAL_DATE_LAST_VISITED, History.LOCAL_DATE_LAST_VISITED);
+ map.put(History.REMOTE_DATE_LAST_VISITED, History.REMOTE_DATE_LAST_VISITED);
+ map.put(History.DATE_CREATED, History.DATE_CREATED);
+ map.put(History.DATE_MODIFIED, History.DATE_MODIFIED);
+ map.put(History.GUID, History.GUID);
+ map.put(History.IS_DELETED, History.IS_DELETED);
+ HISTORY_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // Visits
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "visits", VISITS);
+
+ map = new HashMap<String, String>();
+ map.put(Visits._ID, Visits._ID);
+ map.put(Visits.HISTORY_GUID, Visits.HISTORY_GUID);
+ map.put(Visits.VISIT_TYPE, Visits.VISIT_TYPE);
+ map.put(Visits.DATE_VISITED, Visits.DATE_VISITED);
+ map.put(Visits.IS_LOCAL, Visits.IS_LOCAL);
+ VISIT_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // Favicons
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons", FAVICONS);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "favicons/#", FAVICON_ID);
+
+ map = new HashMap<String, String>();
+ map.put(Favicons._ID, Favicons._ID);
+ map.put(Favicons.URL, Favicons.URL);
+ map.put(Favicons.DATA, Favicons.DATA);
+ map.put(Favicons.DATE_CREATED, Favicons.DATE_CREATED);
+ map.put(Favicons.DATE_MODIFIED, Favicons.DATE_MODIFIED);
+ FAVICONS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // Thumbnails
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "thumbnails", THUMBNAILS);
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "thumbnails/#", THUMBNAIL_ID);
+
+ map = new HashMap<String, String>();
+ map.put(Thumbnails._ID, Thumbnails._ID);
+ map.put(Thumbnails.URL, Thumbnails.URL);
+ map.put(Thumbnails.DATA, Thumbnails.DATA);
+ THUMBNAILS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // Url annotations
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, TABLE_URL_ANNOTATIONS, URL_ANNOTATIONS);
+
+ map = new HashMap<>();
+ map.put(UrlAnnotations._ID, UrlAnnotations._ID);
+ map.put(UrlAnnotations.URL, UrlAnnotations.URL);
+ map.put(UrlAnnotations.KEY, UrlAnnotations.KEY);
+ map.put(UrlAnnotations.VALUE, UrlAnnotations.VALUE);
+ map.put(UrlAnnotations.DATE_CREATED, UrlAnnotations.DATE_CREATED);
+ map.put(UrlAnnotations.DATE_MODIFIED, UrlAnnotations.DATE_MODIFIED);
+ map.put(UrlAnnotations.SYNC_STATUS, UrlAnnotations.SYNC_STATUS);
+ URL_ANNOTATIONS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ // Combined bookmarks and history
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "combined", COMBINED);
+
+ map = new HashMap<String, String>();
+ map.put(Combined._ID, Combined._ID);
+ map.put(Combined.BOOKMARK_ID, Combined.BOOKMARK_ID);
+ map.put(Combined.HISTORY_ID, Combined.HISTORY_ID);
+ map.put(Combined.URL, Combined.URL);
+ map.put(Combined.TITLE, Combined.TITLE);
+ map.put(Combined.VISITS, Combined.VISITS);
+ map.put(Combined.DATE_LAST_VISITED, Combined.DATE_LAST_VISITED);
+ map.put(Combined.FAVICON, Combined.FAVICON);
+ map.put(Combined.FAVICON_ID, Combined.FAVICON_ID);
+ map.put(Combined.FAVICON_URL, Combined.FAVICON_URL);
+ map.put(Combined.LOCAL_DATE_LAST_VISITED, Combined.LOCAL_DATE_LAST_VISITED);
+ map.put(Combined.REMOTE_DATE_LAST_VISITED, Combined.REMOTE_DATE_LAST_VISITED);
+ map.put(Combined.LOCAL_VISITS_COUNT, Combined.LOCAL_VISITS_COUNT);
+ map.put(Combined.REMOTE_VISITS_COUNT, Combined.REMOTE_VISITS_COUNT);
+ COMBINED_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ map = new HashMap<>();
+ map.put(PageMetadata._ID, PageMetadata._ID);
+ map.put(PageMetadata.HISTORY_GUID, PageMetadata.HISTORY_GUID);
+ map.put(PageMetadata.DATE_CREATED, PageMetadata.DATE_CREATED);
+ map.put(PageMetadata.HAS_IMAGE, PageMetadata.HAS_IMAGE);
+ map.put(PageMetadata.JSON, PageMetadata.JSON);
+ PAGE_METADATA_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "page_metadata", PAGE_METADATA);
+
+ // Schema
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "schema", SCHEMA);
+
+ map = new HashMap<String, String>();
+ map.put(Schema.VERSION, Schema.VERSION);
+ SCHEMA_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+
+ // Control
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "control", CONTROL);
+
+ for (Table table : sTables) {
+ for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, type.name, type.id);
+ }
+ }
+
+ // Combined pinned sites, top visited sites, and suggested sites
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "topsites", TOPSITES);
+
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, "highlights", HIGHLIGHTS);
+
+ URI_MATCHER.addURI(BrowserContract.AUTHORITY, ActivityStreamBlocklist.TABLE_NAME, ACTIVITY_STREAM_BLOCKLIST);
+ }
+
+ private static class ShrinkMemoryReceiver extends BroadcastReceiver {
+ private final WeakReference<BrowserProvider> mBrowserProviderWeakReference;
+
+ public ShrinkMemoryReceiver(final BrowserProvider browserProvider) {
+ mBrowserProviderWeakReference = new WeakReference<>(browserProvider);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final BrowserProvider browserProvider = mBrowserProviderWeakReference.get();
+ if (browserProvider == null) {
+ return;
+ }
+ final PerProfileDatabases<BrowserDatabaseHelper> databases = browserProvider.getDatabases();
+ if (databases == null) {
+ return;
+ }
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ databases.shrinkMemory();
+ }
+ });
+ }
+ }
+
+ private final ShrinkMemoryReceiver mShrinkMemoryReceiver = new ShrinkMemoryReceiver(this);
+
+ @Override
+ public boolean onCreate() {
+ if (!super.onCreate()) {
+ return false;
+ }
+
+ LocalBroadcastManager.getInstance(getContext()).registerReceiver(mShrinkMemoryReceiver,
+ new IntentFilter(ACTION_SHRINK_MEMORY));
+
+ return true;
+ }
+
+ @Override
+ public void shutdown() {
+ LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(mShrinkMemoryReceiver);
+
+ super.shutdown();
+ }
+
+ // Convenience accessor.
+ // Assumes structure of sTables!
+ private URLMetadataTable getURLMetadataTable() {
+ return (URLMetadataTable) sTables[0];
+ }
+
+ private static boolean hasFaviconsInProjection(String[] projection) {
+ if (projection == null) return true;
+ for (int i = 0; i < projection.length; ++i) {
+ if (projection[i].equals(FaviconColumns.FAVICON) ||
+ projection[i].equals(FaviconColumns.FAVICON_URL))
+ return true;
+ }
+
+ return false;
+ }
+
+ // Calculate these once, at initialization. isLoggable is too expensive to
+ // have in-line in each log call.
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+ protected static void trace(String message) {
+ if (logVerbose) {
+ Log.v(LOGTAG, message);
+ }
+ }
+
+ protected static void debug(String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ /**
+ * Remove enough activity stream blocklist items to bring the database count below <code>retain</code>.
+ *
+ * Items will be removed according to their creation date, oldest being removed first.
+ */
+ private void expireActivityStreamBlocklist(final SQLiteDatabase db, final int retain) {
+ Log.d(LOGTAG, "Expiring highlights blocklist.");
+ final long rows = DatabaseUtils.queryNumEntries(db, TABLE_ACTIVITY_STREAM_BLOCKLIST);
+
+ if (retain >= rows) {
+ debug("Not expiring highlights blocklist: only have " + rows + " rows.");
+ return;
+ }
+
+ final long toRemove = rows - retain;
+
+ final String statement = "DELETE FROM " + TABLE_ACTIVITY_STREAM_BLOCKLIST + " WHERE " + ActivityStreamBlocklist._ID + " IN " +
+ " ( SELECT " + ActivityStreamBlocklist._ID + " FROM " + TABLE_ACTIVITY_STREAM_BLOCKLIST + " " +
+ "ORDER BY " + ActivityStreamBlocklist.CREATED + " ASC LIMIT " + toRemove + ")";
+
+ beginWrite(db);
+ db.execSQL(statement);
+ }
+
+ /**
+ * Remove enough history items to bring the database count below <code>retain</code>,
+ * removing no items with a modified time after <code>keepAfter</code>.
+ *
+ * Provide <code>keepAfter</code> less than or equal to zero to skip that check.
+ *
+ * Items will be removed according to last visited date.
+ */
+ private void expireHistory(final SQLiteDatabase db, final int retain, final long keepAfter) {
+ Log.d(LOGTAG, "Expiring history.");
+ final long rows = DatabaseUtils.queryNumEntries(db, TABLE_HISTORY);
+
+ if (retain >= rows) {
+ debug("Not expiring history: only have " + rows + " rows.");
+ return;
+ }
+
+ final long toRemove = rows - retain;
+ debug("Expiring at most " + toRemove + " rows earlier than " + keepAfter + ".");
+
+ final String sql;
+ if (keepAfter > 0) {
+ sql = "DELETE FROM " + TABLE_HISTORY + " " +
+ "WHERE MAX(" + History.DATE_LAST_VISITED + ", " + History.DATE_MODIFIED + ") < " + keepAfter + " " +
+ " AND " + History._ID + " IN ( SELECT " +
+ History._ID + " FROM " + TABLE_HISTORY + " " +
+ "ORDER BY " + History.DATE_LAST_VISITED + " ASC LIMIT " + toRemove +
+ ")";
+ } else {
+ sql = "DELETE FROM " + TABLE_HISTORY + " WHERE " + History._ID + " " +
+ "IN ( SELECT " + History._ID + " FROM " + TABLE_HISTORY + " " +
+ "ORDER BY " + History.DATE_LAST_VISITED + " ASC LIMIT " + toRemove + ")";
+ }
+ trace("Deleting using query: " + sql);
+
+ beginWrite(db);
+ db.execSQL(sql);
+ }
+
+ /**
+ * Remove any thumbnails that for sites that aren't likely to be ever shown.
+ * Items will be removed according to a frecency calculation and only if they are not pinned
+ *
+ * Call this method within a transaction.
+ */
+ private void expireThumbnails(final SQLiteDatabase db) {
+ Log.d(LOGTAG, "Expiring thumbnails.");
+ final String sortOrder = BrowserContract.getCombinedFrecencySortOrder(true, false);
+ final String sql = "DELETE FROM " + TABLE_THUMBNAILS +
+ " WHERE " + Thumbnails.URL + " NOT IN ( " +
+ " SELECT " + Combined.URL +
+ " FROM " + Combined.VIEW_NAME +
+ " ORDER BY " + sortOrder +
+ " LIMIT " + DEFAULT_EXPIRY_THUMBNAIL_COUNT +
+ ") AND " + Thumbnails.URL + " NOT IN ( " +
+ " SELECT " + Bookmarks.URL +
+ " FROM " + TABLE_BOOKMARKS +
+ " WHERE " + Bookmarks.PARENT + " = " + Bookmarks.FIXED_PINNED_LIST_ID +
+ ") AND " + Thumbnails.URL + " NOT IN ( " +
+ " SELECT " + Tabs.URL +
+ " FROM " + TABLE_TABS +
+ ")";
+ trace("Clear thumbs using query: " + sql);
+ db.execSQL(sql);
+ }
+
+ private boolean shouldIncrementVisits(Uri uri) {
+ String incrementVisits = uri.getQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS);
+ return Boolean.parseBoolean(incrementVisits);
+ }
+
+ private boolean shouldIncrementRemoteAggregates(Uri uri) {
+ final String incrementRemoteAggregates = uri.getQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES);
+ return Boolean.parseBoolean(incrementRemoteAggregates);
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ trace("Getting URI type: " + uri);
+
+ switch (match) {
+ case BOOKMARKS:
+ trace("URI is BOOKMARKS: " + uri);
+ return Bookmarks.CONTENT_TYPE;
+ case BOOKMARKS_ID:
+ trace("URI is BOOKMARKS_ID: " + uri);
+ return Bookmarks.CONTENT_ITEM_TYPE;
+ case HISTORY:
+ trace("URI is HISTORY: " + uri);
+ return History.CONTENT_TYPE;
+ case HISTORY_ID:
+ trace("URI is HISTORY_ID: " + uri);
+ return History.CONTENT_ITEM_TYPE;
+ default:
+ String type = getContentItemType(match);
+ if (type != null) {
+ trace("URI is " + type);
+ return type;
+ }
+
+ debug("URI has unrecognized type: " + uri);
+ return null;
+ }
+ }
+
+ @SuppressWarnings("fallthrough")
+ @Override
+ public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+ trace("Calling delete in transaction on URI: " + uri);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ final int match = URI_MATCHER.match(uri);
+ int deleted = 0;
+
+ switch (match) {
+ case BOOKMARKS_ID:
+ trace("Delete on BOOKMARKS_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case BOOKMARKS: {
+ trace("Deleting bookmarks: " + uri);
+ deleted = deleteBookmarks(uri, selection, selectionArgs);
+ deleteUnusedImages(uri);
+ break;
+ }
+
+ case HISTORY_ID:
+ trace("Delete on HISTORY_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case HISTORY: {
+ trace("Deleting history: " + uri);
+ beginWrite(db);
+ /**
+ * Deletes from Sync are actual DELETE statements, which will cascade delete relevant visits.
+ * Fennec's deletes mark records as deleted and wipe out all information (except for GUID).
+ * Eventually, Fennec will purge history records that were marked as deleted for longer than some
+ * period of time (e.g. 20 days).
+ * See {@link SharedBrowserDatabaseProvider#cleanUpSomeDeletedRecords(Uri, String)}.
+ */
+ final ArrayList<String> historyGUIDs = getHistoryGUIDsFromSelection(db, uri, selection, selectionArgs);
+
+ if (!isCallerSync(uri)) {
+ deleteVisitsForHistory(db, historyGUIDs);
+ }
+ deletePageMetadataForHistory(db, historyGUIDs);
+ deleted = deleteHistory(db, uri, selection, selectionArgs);
+ deleteUnusedImages(uri);
+ break;
+ }
+
+ case VISITS:
+ trace("Deleting visits: " + uri);
+ beginWrite(db);
+ deleted = deleteVisits(uri, selection, selectionArgs);
+ break;
+
+ case HISTORY_OLD: {
+ String priority = uri.getQueryParameter(BrowserContract.PARAM_EXPIRE_PRIORITY);
+ long keepAfter = System.currentTimeMillis() - DEFAULT_EXPIRY_PRESERVE_WINDOW;
+ int retainCount = DEFAULT_EXPIRY_RETAIN_COUNT;
+
+ if (BrowserContract.ExpirePriority.AGGRESSIVE.toString().equals(priority)) {
+ keepAfter = 0;
+ retainCount = AGGRESSIVE_EXPIRY_RETAIN_COUNT;
+ }
+ expireHistory(db, retainCount, keepAfter);
+ expireActivityStreamBlocklist(db, retainCount / ACTIVITYSTREAM_BLOCKLIST_EXPIRY_FACTOR);
+ expireThumbnails(db);
+ deleteUnusedImages(uri);
+ break;
+ }
+
+ case FAVICON_ID:
+ debug("Delete on FAVICON_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_FAVICONS + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case FAVICONS: {
+ trace("Deleting favicons: " + uri);
+ beginWrite(db);
+ deleted = deleteFavicons(uri, selection, selectionArgs);
+ break;
+ }
+
+ case THUMBNAIL_ID:
+ debug("Delete on THUMBNAIL_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_THUMBNAILS + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case THUMBNAILS: {
+ trace("Deleting thumbnails: " + uri);
+ beginWrite(db);
+ deleted = deleteThumbnails(uri, selection, selectionArgs);
+ break;
+ }
+
+ case URL_ANNOTATIONS:
+ trace("Delete on URL_ANNOTATIONS: " + uri);
+ deleteUrlAnnotation(uri, selection, selectionArgs);
+ break;
+
+ case PAGE_METADATA:
+ trace("Delete on PAGE_METADATA: " + uri);
+ deleted = deletePageMetadata(uri, selection, selectionArgs);
+ break;
+
+ default: {
+ Table table = findTableFor(match);
+ if (table == null) {
+ throw new UnsupportedOperationException("Unknown delete URI " + uri);
+ }
+ trace("Deleting TABLE: " + uri);
+ beginWrite(db);
+ deleted = table.delete(db, uri, match, selection, selectionArgs);
+ }
+ }
+
+ debug("Deleted " + deleted + " rows for URI: " + uri);
+
+ return deleted;
+ }
+
+ @Override
+ public Uri insertInTransaction(Uri uri, ContentValues values) {
+ trace("Calling insert in transaction on URI: " + uri);
+
+ int match = URI_MATCHER.match(uri);
+ long id = -1;
+
+ switch (match) {
+ case BOOKMARKS: {
+ trace("Insert on BOOKMARKS: " + uri);
+ id = insertBookmark(uri, values);
+ break;
+ }
+
+ case HISTORY: {
+ trace("Insert on HISTORY: " + uri);
+ id = insertHistory(uri, values);
+ break;
+ }
+
+ case VISITS: {
+ trace("Insert on VISITS: " + uri);
+ id = insertVisit(uri, values);
+ break;
+ }
+
+ case FAVICONS: {
+ trace("Insert on FAVICONS: " + uri);
+ id = insertFavicon(uri, values);
+ break;
+ }
+
+ case THUMBNAILS: {
+ trace("Insert on THUMBNAILS: " + uri);
+ id = insertThumbnail(uri, values);
+ break;
+ }
+
+ case URL_ANNOTATIONS: {
+ trace("Insert on URL_ANNOTATIONS: " + uri);
+ id = insertUrlAnnotation(uri, values);
+ break;
+ }
+
+ case ACTIVITY_STREAM_BLOCKLIST: {
+ trace("Insert on ACTIVITY_STREAM_BLOCKLIST: " + uri);
+ id = insertActivityStreamBlocklistSite(uri, values);
+ break;
+ }
+
+ case PAGE_METADATA: {
+ trace("Insert on PAGE_METADATA: " + uri);
+ id = insertPageMetadata(uri, values);
+ break;
+ }
+
+ default: {
+ Table table = findTableFor(match);
+ if (table == null) {
+ throw new UnsupportedOperationException("Unknown insert URI " + uri);
+ }
+
+ trace("Insert on TABLE: " + uri);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ id = table.insert(db, uri, match, values);
+ }
+ }
+
+ debug("Inserted ID in database: " + id);
+
+ if (id >= 0)
+ return ContentUris.withAppendedId(uri, id);
+
+ return null;
+ }
+
+ @SuppressWarnings("fallthrough")
+ @Override
+ public int updateInTransaction(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ trace("Calling update in transaction on URI: " + uri);
+
+ int match = URI_MATCHER.match(uri);
+ int updated = 0;
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ switch (match) {
+ // We provide a dedicated (hacky) API for callers to bulk-update the positions of
+ // folder children by passing an array of GUID strings as `selectionArgs`.
+ // Each child will have its position column set to its index in the provided array.
+ //
+ // This avoids callers having to issue a large number of UPDATE queries through
+ // the usual channels. See Bug 728783.
+ //
+ // Note that this is decidedly not a general-purpose API; use at your own risk.
+ // `values` and `selection` are ignored.
+ case BOOKMARKS_POSITIONS: {
+ debug("Update on BOOKMARKS_POSITIONS: " + uri);
+
+ // This already starts and finishes its own transaction.
+ updated = updateBookmarkPositions(uri, selectionArgs);
+ break;
+ }
+
+ case BOOKMARKS_PARENT: {
+ debug("Update on BOOKMARKS_PARENT: " + uri);
+ beginWrite(db);
+ updated = updateBookmarkParents(db, values, selection, selectionArgs);
+ break;
+ }
+
+ case BOOKMARKS_ID:
+ debug("Update on BOOKMARKS_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case BOOKMARKS: {
+ debug("Updating bookmark: " + uri);
+ if (shouldUpdateOrInsert(uri)) {
+ updated = updateOrInsertBookmark(uri, values, selection, selectionArgs);
+ } else {
+ updated = updateBookmarks(uri, values, selection, selectionArgs);
+ }
+ break;
+ }
+
+ case HISTORY_ID:
+ debug("Update on HISTORY_ID: " + uri);
+
+ selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case HISTORY: {
+ debug("Updating history: " + uri);
+ if (shouldUpdateOrInsert(uri)) {
+ updated = updateOrInsertHistory(uri, values, selection, selectionArgs);
+ } else {
+ updated = updateHistory(uri, values, selection, selectionArgs);
+ }
+ if (shouldIncrementVisits(uri)) {
+ insertVisitForHistory(uri, values, selection, selectionArgs);
+ }
+ break;
+ }
+
+ case FAVICONS: {
+ debug("Update on FAVICONS: " + uri);
+
+ String url = values.getAsString(Favicons.URL);
+ String faviconSelection = null;
+ String[] faviconSelectionArgs = null;
+
+ if (!TextUtils.isEmpty(url)) {
+ faviconSelection = Favicons.URL + " = ?";
+ faviconSelectionArgs = new String[] { url };
+ }
+
+ if (shouldUpdateOrInsert(uri)) {
+ updated = updateOrInsertFavicon(uri, values, faviconSelection, faviconSelectionArgs);
+ } else {
+ updated = updateExistingFavicon(uri, values, faviconSelection, faviconSelectionArgs);
+ }
+ break;
+ }
+
+ case THUMBNAILS: {
+ debug("Update on THUMBNAILS: " + uri);
+
+ String url = values.getAsString(Thumbnails.URL);
+
+ // if no URL is provided, update all of the entries
+ if (TextUtils.isEmpty(values.getAsString(Thumbnails.URL))) {
+ updated = updateExistingThumbnail(uri, values, null, null);
+ } else if (shouldUpdateOrInsert(uri)) {
+ updated = updateOrInsertThumbnail(uri, values, Thumbnails.URL + " = ?",
+ new String[] { url });
+ } else {
+ updated = updateExistingThumbnail(uri, values, Thumbnails.URL + " = ?",
+ new String[] { url });
+ }
+ break;
+ }
+
+ case URL_ANNOTATIONS:
+ updateUrlAnnotation(uri, values, selection, selectionArgs);
+ break;
+
+ default: {
+ Table table = findTableFor(match);
+ if (table == null) {
+ throw new UnsupportedOperationException("Unknown update URI " + uri);
+ }
+ trace("Update TABLE: " + uri);
+
+ beginWrite(db);
+ updated = table.update(db, uri, match, values, selection, selectionArgs);
+ if (shouldUpdateOrInsert(uri) && updated == 0) {
+ trace("No update, inserting for URL: " + uri);
+ table.insert(db, uri, match, values);
+ updated = 1;
+ }
+ }
+ }
+
+ debug("Updated " + updated + " rows for URI: " + uri);
+ return updated;
+ }
+
+ /**
+ * Get topsites by themselves, without the inclusion of pinned sites. Suggested sites
+ * will be appended (if necessary) to the end of the list in order to provide up to PARAM_LIMIT items.
+ */
+ private Cursor getPlainTopSites(final Uri uri) {
+ final SQLiteDatabase db = getReadableDatabase(uri);
+
+ final String limitParam = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+ final int limit;
+ if (limitParam != null) {
+ limit = Integer.parseInt(limitParam);
+ } else {
+ limit = 12;
+ }
+
+ // Filter out: unvisited pages (history_id == -1) pinned (and other special) sites, deleted sites,
+ // and about: pages.
+ final String ignoreForTopSitesWhereClause =
+ "(" + Combined.HISTORY_ID + " IS NOT -1)" +
+ " AND " +
+ Combined.URL + " NOT IN (SELECT " +
+ Bookmarks.URL + " FROM " + TABLE_BOOKMARKS + " WHERE " +
+ DBUtils.qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " < " + Bookmarks.FIXED_ROOT_ID + " AND " +
+ DBUtils.qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " == 0)" +
+ " AND " +
+ "(" + Combined.URL + " NOT LIKE ?)";
+
+ final String[] ignoreForTopSitesArgs = new String[] {
+ AboutPages.URL_FILTER
+ };
+
+ final Cursor c = db.rawQuery("SELECT " +
+ Bookmarks._ID + ", " +
+ Combined.BOOKMARK_ID + ", " +
+ Combined.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ Combined.HISTORY_ID + ", " +
+ TopSites.TYPE_TOP + " AS " + TopSites.TYPE +
+ " FROM " + Combined.VIEW_NAME +
+ " WHERE " + ignoreForTopSitesWhereClause +
+ " ORDER BY " + BrowserContract.getCombinedFrecencySortOrder(true, false) +
+ " LIMIT " + limit,
+ ignoreForTopSitesArgs);
+
+ c.setNotificationUri(getContext().getContentResolver(),
+ BrowserContract.AUTHORITY_URI);
+
+ if (c.getCount() == limit) {
+ return c;
+ }
+
+ // If we don't have enough data: get suggested sites too
+ final SuggestedSites suggestedSites = BrowserDB.from(GeckoProfile.get(
+ getContext(), uri.getQueryParameter(BrowserContract.PARAM_PROFILE))).getSuggestedSites();
+
+ final Cursor suggestedSitesCursor = suggestedSites.get(limit - c.getCount());
+
+ return new MergeCursor(new Cursor[]{
+ c,
+ suggestedSitesCursor
+ });
+ }
+
+ private Cursor getTopSites(final Uri uri) {
+ // In order to correctly merge the top and pinned sites we:
+ //
+ // 1. Generate a list of free ids for topsites - this is the positions that are NOT used by pinned sites.
+ // We do this using a subquery with a self-join in order to generate rowids, that allow us to join with
+ // the list of topsites.
+ // 2. Generate the list of topsites in order of frecency.
+ // 3. Join these, so that each topsite is given its resulting position
+ // 4. UNION all with the pinned sites, and order by position
+ //
+ // Suggested sites are placed after the topsites, but might still be interspersed with the suggested sites,
+ // hence we append these to the topsite list, and treat these identically to topsites from this point on.
+ //
+ // We require rowids to join the two lists, however subqueries aren't given rowids - hence we use two different
+ // tricks to generate these:
+ // 1. The list of free ids is small, hence we can do a self-join to generate rowids.
+ // 2. The topsites list is larger, hence we use a temporary table, which automatically provides rowids.
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ final String TABLE_TOPSITES = "topsites";
+
+ final String limitParam = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+ final String gridLimitParam = uri.getQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT);
+
+ final int totalLimit;
+ final int suggestedGridLimit;
+
+ if (limitParam == null) {
+ totalLimit = 50;
+ } else {
+ totalLimit = Integer.parseInt(limitParam, 10);
+ }
+
+ if (gridLimitParam == null) {
+ suggestedGridLimit = getContext().getResources().getInteger(R.integer.number_of_top_sites);
+ } else {
+ suggestedGridLimit = Integer.parseInt(gridLimitParam, 10);
+ }
+
+ final String pinnedSitesFromClause = "FROM " + TABLE_BOOKMARKS + " WHERE " +
+ Bookmarks.PARENT + " == " + Bookmarks.FIXED_PINNED_LIST_ID +
+ " AND " + Bookmarks.IS_DELETED + " IS NOT 1";
+
+ // Ideally we'd use a recursive CTE to generate our sequence, e.g. something like this worked at one point:
+ // " WITH RECURSIVE" +
+ // " cnt(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM cnt WHERE x < 6)" +
+ // However that requires SQLite >= 3.8.3 (available on Android >= 5.0), so in the meantime
+ // we use a temporary numbers table.
+ // Note: SQLite rowids are 1-indexed, whereas we're expecting 0-indexed values for the position. Our numbers
+ // table starts at position = 0, which ensures the correct results here.
+ final String freeIDSubquery =
+ " SELECT count(free_ids.position) + 1 AS rowid, numbers.position AS " + Bookmarks.POSITION +
+ " FROM (SELECT position FROM numbers WHERE position NOT IN (SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + ")) AS numbers" +
+ " LEFT OUTER JOIN " +
+ " (SELECT position FROM numbers WHERE position NOT IN (SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + ")) AS free_ids" +
+ " ON numbers.position > free_ids.position" +
+ " GROUP BY numbers.position" +
+ " ORDER BY numbers.position ASC" +
+ " LIMIT " + suggestedGridLimit;
+
+ // Filter out: unvisited pages (history_id == -1) pinned (and other special) sites, deleted sites,
+ // and about: pages.
+ final String ignoreForTopSitesWhereClause =
+ "(" + Combined.HISTORY_ID + " IS NOT -1)" +
+ " AND " +
+ Combined.URL + " NOT IN (SELECT " +
+ Bookmarks.URL + " FROM bookmarks WHERE " +
+ DBUtils.qualifyColumn("bookmarks", Bookmarks.PARENT) + " < " + Bookmarks.FIXED_ROOT_ID + " AND " +
+ DBUtils.qualifyColumn("bookmarks", Bookmarks.IS_DELETED) + " == 0)" +
+ " AND " +
+ "(" + Combined.URL + " NOT LIKE ?)";
+
+ final String[] ignoreForTopSitesArgs = new String[] {
+ AboutPages.URL_FILTER
+ };
+
+ // Stuff the suggested sites into SQL: this allows us to filter pinned and topsites out of the suggested
+ // sites list as part of the final query (as opposed to walking cursors in java)
+ final SuggestedSites suggestedSites = BrowserDB.from(GeckoProfile.get(
+ getContext(), uri.getQueryParameter(BrowserContract.PARAM_PROFILE))).getSuggestedSites();
+
+ StringBuilder suggestedSitesBuilder = new StringBuilder();
+ // We could access the underlying data here, however SuggestedSites also performs filtering on the suggested
+ // sites list, which means we'd need to process the lists within SuggestedSites in any case. If we're doing
+ // that processing, there is little real between us using a MatrixCursor, or a Map (or List) instead of the
+ // MatrixCursor.
+ final Cursor suggestedSitesCursor = suggestedSites.get(suggestedGridLimit);
+
+ String[] suggestedSiteArgs = new String[0];
+
+ boolean hasProcessedAnySuggestedSites = false;
+
+ final int idColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks._ID);
+ final int urlColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks.URL);
+ final int titleColumnIndex = suggestedSitesCursor.getColumnIndexOrThrow(Bookmarks.TITLE);
+
+ while (suggestedSitesCursor.moveToNext()) {
+ // We'll be using this as a subquery, hence we need to avoid the preceding UNION ALL
+ if (hasProcessedAnySuggestedSites) {
+ suggestedSitesBuilder.append(" UNION ALL");
+ } else {
+ hasProcessedAnySuggestedSites = true;
+ }
+ suggestedSitesBuilder.append(" SELECT" +
+ " ? AS " + Bookmarks._ID + "," +
+ " ? AS " + Bookmarks.URL + "," +
+ " ? AS " + Bookmarks.TITLE);
+
+ suggestedSiteArgs = DBUtils.appendSelectionArgs(suggestedSiteArgs,
+ new String[] {
+ suggestedSitesCursor.getString(idColumnIndex),
+ suggestedSitesCursor.getString(urlColumnIndex),
+ suggestedSitesCursor.getString(titleColumnIndex)
+ });
+ }
+ suggestedSitesCursor.close();
+
+ boolean hasPreparedBlankTiles = false;
+
+ // We can somewhat reduce the number of blanks we produce by eliminating suggested sites.
+ // We do the actual limit calculation in SQL (since we need to take into account the number
+ // of pinned sites too), but this might avoid producing 5 or so additional blank tiles
+ // that would then need to be filtered out.
+ final int maxBlanksNeeded = suggestedGridLimit - suggestedSitesCursor.getCount();
+
+ final StringBuilder blanksBuilder = new StringBuilder();
+ for (int i = 0; i < maxBlanksNeeded; i++) {
+ if (hasPreparedBlankTiles) {
+ blanksBuilder.append(" UNION ALL");
+ } else {
+ hasPreparedBlankTiles = true;
+ }
+
+ blanksBuilder.append(" SELECT" +
+ " -1 AS " + Bookmarks._ID + "," +
+ " '' AS " + Bookmarks.URL + "," +
+ " '' AS " + Bookmarks.TITLE);
+ }
+
+
+
+ // To restrict suggested sites to the grid, we simply subtract the number of topsites (which have already had
+ // the pinned sites filtered out), and the number of pinned sites.
+ // SQLite completely ignores negative limits, hence we need to manually limit to 0 in this case.
+ final String suggestedLimitClause = " LIMIT MAX(0, (" + suggestedGridLimit + " - (SELECT COUNT(*) FROM " + TABLE_TOPSITES + ") - (SELECT COUNT(*) " + pinnedSitesFromClause + "))) ";
+
+ // Pinned site positions are zero indexed, but we need to get the maximum 1-indexed position.
+ // Hence to correctly calculate the largest pinned position (which should be 0 if there are
+ // no sites, or 1-6 if we have at least one pinned site), we coalesce the DB position (0-5)
+ // with -1 to represent no-sites, which allows us to directly add 1 to obtain the expected value
+ // regardless of whether a position was actually retrieved.
+ final String blanksLimitClause = " LIMIT MAX(0, " +
+ "COALESCE((SELECT " + Bookmarks.POSITION + " " + pinnedSitesFromClause + "), -1) + 1" +
+ " - (SELECT COUNT(*) " + pinnedSitesFromClause + ")" +
+ " - (SELECT COUNT(*) FROM " + TABLE_TOPSITES + ")" +
+ ")";
+
+ db.beginTransaction();
+ try {
+ db.execSQL("DROP TABLE IF EXISTS " + TABLE_TOPSITES);
+
+ db.execSQL("CREATE TEMP TABLE " + TABLE_TOPSITES + " AS" +
+ " SELECT " +
+ Bookmarks._ID + ", " +
+ Combined.BOOKMARK_ID + ", " +
+ Combined.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ Combined.HISTORY_ID + ", " +
+ TopSites.TYPE_TOP + " AS " + TopSites.TYPE +
+ " FROM " + Combined.VIEW_NAME +
+ " WHERE " + ignoreForTopSitesWhereClause +
+ " ORDER BY " + BrowserContract.getCombinedFrecencySortOrder(true, false) +
+ " LIMIT " + totalLimit,
+
+ ignoreForTopSitesArgs);
+
+ if (hasProcessedAnySuggestedSites) {
+ db.execSQL("INSERT INTO " + TABLE_TOPSITES +
+ // We need to LIMIT _after_ selecting the relevant suggested sites, which requires us to
+ // use an additional internal subquery, since we cannot LIMIT a subquery that is part of UNION ALL.
+ // Hence the weird SELECT * FROM (SELECT ...relevant suggested sites... LIMIT ?)
+ " SELECT * FROM (SELECT " +
+ Bookmarks._ID + ", " +
+ Bookmarks._ID + " AS " + Combined.BOOKMARK_ID + ", " +
+ " -1 AS " + Combined.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ "NULL AS " + Combined.HISTORY_ID + ", " +
+ TopSites.TYPE_SUGGESTED + " as " + TopSites.TYPE +
+ " FROM ( " + suggestedSitesBuilder.toString() + " )" +
+ " WHERE " +
+ Bookmarks.URL + " NOT IN (SELECT url FROM " + TABLE_TOPSITES + ")" +
+ " AND " +
+ Bookmarks.URL + " NOT IN (SELECT url " + pinnedSitesFromClause + ")" +
+ suggestedLimitClause + " )",
+
+ suggestedSiteArgs);
+ }
+
+ if (hasPreparedBlankTiles) {
+ db.execSQL("INSERT INTO " + TABLE_TOPSITES +
+ // We need to LIMIT _after_ selecting the relevant suggested sites, which requires us to
+ // use an additional internal subquery, since we cannot LIMIT a subquery that is part of UNION ALL.
+ // Hence the weird SELECT * FROM (SELECT ...relevant suggested sites... LIMIT ?)
+ " SELECT * FROM (SELECT " +
+ Bookmarks._ID + ", " +
+ Bookmarks._ID + " AS " + Combined.BOOKMARK_ID + ", " +
+ " -1 AS " + Combined.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ "NULL AS " + Combined.HISTORY_ID + ", " +
+ TopSites.TYPE_BLANK + " as " + TopSites.TYPE +
+ " FROM ( " + blanksBuilder.toString() + " )" +
+ blanksLimitClause + " )");
+ }
+
+ // If we retrieve more topsites than we have free positions for in the freeIdSubquery,
+ // we will have topsites that don't receive a position when joining TABLE_TOPSITES
+ // with freeIdSubquery. Hence we need to coalesce the position with a generated position.
+ // We know that the difference in positions will be at most suggestedGridLimit, hence we
+ // can add that to the rowid to generate a safe position.
+ // I.e. if we have 6 pinned sites then positions 0..5 are filled, the JOIN results in
+ // the first N rows having positions 6..(N+6), so row N+1 should receive a position that is at
+ // least N+1+6, which is equal to rowid + 6.
+ final SQLiteCursor c = (SQLiteCursor) db.rawQuery(
+ "SELECT " +
+ Bookmarks._ID + ", " +
+ TopSites.BOOKMARK_ID + ", " +
+ TopSites.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ "COALESCE(" + Bookmarks.POSITION + ", " +
+ DBUtils.qualifyColumn(TABLE_TOPSITES, "rowid") + " + " + suggestedGridLimit +
+ ")" + " AS " + Bookmarks.POSITION + ", " +
+ Combined.HISTORY_ID + ", " +
+ TopSites.TYPE +
+ " FROM " + TABLE_TOPSITES +
+ " LEFT OUTER JOIN " + // TABLE_IDS +
+ "(" + freeIDSubquery + ") AS id_results" +
+ " ON " + DBUtils.qualifyColumn(TABLE_TOPSITES, "rowid") +
+ " = " + DBUtils.qualifyColumn("id_results", "rowid") +
+
+ " UNION ALL " +
+
+ "SELECT " +
+ Bookmarks._ID + ", " +
+ Bookmarks._ID + " AS " + TopSites.BOOKMARK_ID + ", " +
+ " -1 AS " + TopSites.HISTORY_ID + ", " +
+ Bookmarks.URL + ", " +
+ Bookmarks.TITLE + ", " +
+ Bookmarks.POSITION + ", " +
+ "NULL AS " + Combined.HISTORY_ID + ", " +
+ TopSites.TYPE_PINNED + " as " + TopSites.TYPE +
+ " " + pinnedSitesFromClause +
+
+ " ORDER BY " + Bookmarks.POSITION,
+
+ null);
+
+ c.setNotificationUri(getContext().getContentResolver(),
+ BrowserContract.AUTHORITY_URI);
+
+ // Force the cursor to be compiled and the cursor-window filled now:
+ // (A) without compiling the cursor now we won't have access to the TEMP table which
+ // is removed as soon as we close our connection.
+ // (B) this might also mitigate the situation causing this crash where we're accessing
+ // a cursor and crashing in fillWindow.
+ c.moveToFirst();
+
+ db.setTransactionSuccessful();
+ return c;
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Obtain a set of links for highlights (from bookmarks and history).
+ *
+ * Based on the query for Activity^ Stream (desktop):
+ * https://github.com/mozilla/activity-stream/blob/9eb9f451b553bb62ae9b8d6b41a8ef94a2e020ea/addon/PlacesProvider.js#L578
+ */
+ public Cursor getHighlights(final SQLiteDatabase db, String limit) {
+ final int totalLimit = limit == null ? 20 : Integer.parseInt(limit);
+
+ final long threeDaysAgo = System.currentTimeMillis() - (1000 * 60 * 60 * 24 * 3);
+ final long bookmarkLimit = 1;
+
+ // Select recent bookmarks that have not been visited much
+ final String bookmarksQuery = "SELECT * FROM (SELECT " +
+ "-1 AS " + Combined.HISTORY_ID + ", " +
+ DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " +
+ DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + ", " +
+ DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TITLE) + ", " +
+ DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " AS " + Highlights.DATE + " " +
+ "FROM " + Bookmarks.TABLE_NAME + " " +
+ "LEFT JOIN " + History.TABLE_NAME + " ON " +
+ DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " = " +
+ DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + " " +
+ "WHERE " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " > " + threeDaysAgo + " " +
+ "AND (" + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " <= 3 " +
+ "OR " + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " IS NULL) " +
+ "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.IS_DELETED) + " = 0 " +
+ "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " " +
+ "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" +
+ "ORDER BY " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " DESC " +
+ "LIMIT " + bookmarkLimit + ")";
+
+ final long last30Minutes = System.currentTimeMillis() - (1000 * 60 * 30);
+ final long historyLimit = totalLimit - bookmarkLimit;
+
+ // Select recent history that has not been visited much.
+ final String historyQuery = "SELECT * FROM (SELECT " +
+ History._ID + " AS " + Combined.HISTORY_ID + ", " +
+ "-1 AS " + Combined.BOOKMARK_ID + ", " +
+ History.URL + ", " +
+ History.TITLE + ", " +
+ History.DATE_LAST_VISITED + " AS " + Highlights.DATE + " " +
+ "FROM " + History.TABLE_NAME + " " +
+ "WHERE " + History.DATE_LAST_VISITED + " < " + last30Minutes + " " +
+ "AND " + History.VISITS + " <= 3 " +
+ "AND " + History.TITLE + " NOT NULL AND " + History.TITLE + " != '' " +
+ "AND " + History.IS_DELETED + " = 0 " +
+ "AND " + History.URL + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" +
+ // TODO: Implement domain black list (bug 1298786)
+ // TODO: Group by host (bug 1298785)
+ "ORDER BY " + History.DATE_LAST_VISITED + " DESC " +
+ "LIMIT " + historyLimit + ")";
+
+ final String query = "SELECT DISTINCT * " +
+ "FROM (" + bookmarksQuery + " " +
+ "UNION ALL " + historyQuery + ") " +
+ "GROUP BY " + Combined.URL + ";";
+
+ final Cursor cursor = db.rawQuery(query, null);
+
+ cursor.setNotificationUri(getContext().getContentResolver(),
+ BrowserContract.AUTHORITY_URI);
+
+ return cursor;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ final int match = URI_MATCHER.match(uri);
+
+ // Handle only queries requiring a writable DB connection here: most queries need only a readable
+ // connection, hence we can get a readable DB once, and then handle most queries within a switch.
+ // TopSites requires a writable connection (because of the temporary tables it uses), hence
+ // we handle that separately, i.e. before retrieving a readable connection.
+ if (match == TOPSITES) {
+ if (uri.getBooleanQueryParameter(BrowserContract.PARAM_TOPSITES_DISABLE_PINNED, false)) {
+ return getPlainTopSites(uri);
+ } else {
+ return getTopSites(uri);
+ }
+ }
+
+ SQLiteDatabase db = getReadableDatabase(uri);
+
+ SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+ String groupBy = null;
+
+ switch (match) {
+ case BOOKMARKS_FOLDER_ID:
+ case BOOKMARKS_ID:
+ case BOOKMARKS: {
+ debug("Query is on bookmarks: " + uri);
+
+ if (match == BOOKMARKS_ID) {
+ selection = DBUtils.concatenateWhere(selection, Bookmarks._ID + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ } else if (match == BOOKMARKS_FOLDER_ID) {
+ selection = DBUtils.concatenateWhere(selection, Bookmarks.PARENT + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ }
+
+ if (!shouldShowDeleted(uri))
+ selection = DBUtils.concatenateWhere(Bookmarks.IS_DELETED + " = 0", selection);
+
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_BOOKMARKS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(BOOKMARKS_PROJECTION_MAP);
+
+ if (hasFaviconsInProjection(projection)) {
+ qb.setTables(VIEW_BOOKMARKS_WITH_FAVICONS);
+ } else if (selection != null && selection.contains(Bookmarks.ANNOTATION_KEY)) {
+ qb.setTables(VIEW_BOOKMARKS_WITH_ANNOTATIONS);
+
+ groupBy = uri.getQueryParameter(BrowserContract.PARAM_GROUP_BY);
+ } else {
+ qb.setTables(TABLE_BOOKMARKS);
+ }
+
+ break;
+ }
+
+ case HISTORY_ID:
+ selection = DBUtils.concatenateWhere(selection, History._ID + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case HISTORY: {
+ debug("Query is on history: " + uri);
+
+ if (!shouldShowDeleted(uri))
+ selection = DBUtils.concatenateWhere(History.IS_DELETED + " = 0", selection);
+
+ if (TextUtils.isEmpty(sortOrder))
+ sortOrder = DEFAULT_HISTORY_SORT_ORDER;
+
+ qb.setProjectionMap(HISTORY_PROJECTION_MAP);
+
+ if (hasFaviconsInProjection(projection))
+ qb.setTables(VIEW_HISTORY_WITH_FAVICONS);
+ else
+ qb.setTables(TABLE_HISTORY);
+
+ break;
+ }
+
+ case VISITS:
+ debug("Query is on visits: " + uri);
+ qb.setProjectionMap(VISIT_PROJECTION_MAP);
+ qb.setTables(TABLE_VISITS);
+
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_VISITS_SORT_ORDER;
+ }
+ break;
+
+ case FAVICON_ID:
+ selection = DBUtils.concatenateWhere(selection, Favicons._ID + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case FAVICONS: {
+ debug("Query is on favicons: " + uri);
+
+ qb.setProjectionMap(FAVICONS_PROJECTION_MAP);
+ qb.setTables(TABLE_FAVICONS);
+
+ break;
+ }
+
+ case THUMBNAIL_ID:
+ selection = DBUtils.concatenateWhere(selection, Thumbnails._ID + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case THUMBNAILS: {
+ debug("Query is on thumbnails: " + uri);
+
+ qb.setProjectionMap(THUMBNAILS_PROJECTION_MAP);
+ qb.setTables(TABLE_THUMBNAILS);
+
+ break;
+ }
+
+ case URL_ANNOTATIONS:
+ debug("Query is on url annotations: " + uri);
+
+ qb.setProjectionMap(URL_ANNOTATIONS_PROJECTION_MAP);
+ qb.setTables(TABLE_URL_ANNOTATIONS);
+ break;
+
+ case SCHEMA: {
+ debug("Query is on schema.");
+ MatrixCursor schemaCursor = new MatrixCursor(new String[] { Schema.VERSION });
+ schemaCursor.newRow().add(BrowserDatabaseHelper.DATABASE_VERSION);
+
+ return schemaCursor;
+ }
+
+ case COMBINED: {
+ debug("Query is on combined: " + uri);
+
+ if (TextUtils.isEmpty(sortOrder))
+ sortOrder = DEFAULT_HISTORY_SORT_ORDER;
+
+ // This will avoid duplicate entries in the awesomebar
+ // results when a history entry has multiple bookmarks.
+ groupBy = Combined.URL;
+
+ qb.setProjectionMap(COMBINED_PROJECTION_MAP);
+
+ if (hasFaviconsInProjection(projection))
+ qb.setTables(VIEW_COMBINED_WITH_FAVICONS);
+ else
+ qb.setTables(Combined.VIEW_NAME);
+
+ break;
+ }
+
+ case HIGHLIGHTS: {
+ debug("Highlights query: " + uri);
+
+ return getHighlights(db, limit);
+ }
+
+ case PAGE_METADATA: {
+ debug("PageMetadata query: " + uri);
+
+ qb.setProjectionMap(PAGE_METADATA_PROJECTION_MAP);
+ qb.setTables(TABLE_PAGE_METADATA);
+ break;
+ }
+
+ default: {
+ Table table = findTableFor(match);
+ if (table == null) {
+ throw new UnsupportedOperationException("Unknown query URI " + uri);
+ }
+ trace("Update TABLE: " + uri);
+ return table.query(db, uri, match, projection, selection, selectionArgs, sortOrder, groupBy, limit);
+ }
+ }
+
+ trace("Running built query.");
+ Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy,
+ null, sortOrder, limit);
+ cursor.setNotificationUri(getContext().getContentResolver(),
+ BrowserContract.AUTHORITY_URI);
+
+ return cursor;
+ }
+
+ /**
+ * Update the positions of bookmarks in batches.
+ *
+ * Begins and ends its own transactions.
+ *
+ * @see #updateBookmarkPositionsInTransaction(SQLiteDatabase, String[], int, int)
+ */
+ private int updateBookmarkPositions(Uri uri, String[] guids) {
+ if (guids == null) {
+ return 0;
+ }
+
+ int guidsCount = guids.length;
+ if (guidsCount == 0) {
+ return 0;
+ }
+
+ int offset = 0;
+ int updated = 0;
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ db.beginTransaction();
+
+ while (offset < guidsCount) {
+ try {
+ updated += updateBookmarkPositionsInTransaction(db, guids, offset,
+ MAX_POSITION_UPDATES_PER_QUERY);
+ } catch (SQLException e) {
+ Log.e(LOGTAG, "Got SQLite exception updating bookmark positions at offset " + offset, e);
+
+ // Need to restart the transaction.
+ // The only way a caller knows that anything failed is that the
+ // returned update count will be smaller than the requested
+ // number of records.
+ db.setTransactionSuccessful();
+ db.endTransaction();
+
+ db.beginTransaction();
+ }
+
+ offset += MAX_POSITION_UPDATES_PER_QUERY;
+ }
+
+ db.setTransactionSuccessful();
+ db.endTransaction();
+
+ return updated;
+ }
+
+ /**
+ * Construct and execute an update expression that will modify the positions
+ * of records in-place.
+ */
+ private static int updateBookmarkPositionsInTransaction(final SQLiteDatabase db, final String[] guids,
+ final int offset, final int max) {
+ int guidsCount = guids.length;
+ int processCount = Math.min(max, guidsCount - offset);
+
+ // Each must appear twice: once in a CASE, and once in the IN clause.
+ String[] args = new String[processCount * 2];
+ System.arraycopy(guids, offset, args, 0, processCount);
+ System.arraycopy(guids, offset, args, processCount, processCount);
+
+ StringBuilder b = new StringBuilder("UPDATE " + TABLE_BOOKMARKS +
+ " SET " + Bookmarks.POSITION +
+ " = CASE guid");
+
+ // Build the CASE statement body for GUID/index pairs from offset up to
+ // the computed limit.
+ final int end = offset + processCount;
+ int i = offset;
+ for (; i < end; ++i) {
+ if (guids[i] == null) {
+ // We don't want to issue the query if not every GUID is specified.
+ debug("updateBookmarkPositions called with null GUID at index " + i);
+ return 0;
+ }
+ b.append(" WHEN ? THEN " + i);
+ }
+
+ b.append(" END WHERE " + DBUtils.computeSQLInClause(processCount, Bookmarks.GUID));
+ db.execSQL(b.toString(), args);
+
+ // We can't easily get a modified count without calling something like changes().
+ return processCount;
+ }
+
+ /**
+ * Construct an update expression that will modify the parents of any records
+ * that match.
+ */
+ private int updateBookmarkParents(SQLiteDatabase db, ContentValues values, String selection, String[] selectionArgs) {
+ trace("Updating bookmark parents of " + selection + " (" + selectionArgs[0] + ")");
+ String where = Bookmarks._ID + " IN (" +
+ " SELECT DISTINCT " + Bookmarks.PARENT +
+ " FROM " + TABLE_BOOKMARKS +
+ " WHERE " + selection + " )";
+ return db.update(TABLE_BOOKMARKS, values, where, selectionArgs);
+ }
+
+ private long insertBookmark(Uri uri, ContentValues values) {
+ // Generate values if not specified. Don't overwrite
+ // if specified by caller.
+ long now = System.currentTimeMillis();
+ if (!values.containsKey(Bookmarks.DATE_CREATED)) {
+ values.put(Bookmarks.DATE_CREATED, now);
+ }
+
+ if (!values.containsKey(Bookmarks.DATE_MODIFIED)) {
+ values.put(Bookmarks.DATE_MODIFIED, now);
+ }
+
+ if (!values.containsKey(Bookmarks.GUID)) {
+ values.put(Bookmarks.GUID, Utils.generateGuid());
+ }
+
+ if (!values.containsKey(Bookmarks.POSITION)) {
+ debug("Inserting bookmark with no position for URI");
+ values.put(Bookmarks.POSITION,
+ Long.toString(BrowserContract.Bookmarks.DEFAULT_POSITION));
+ }
+
+ if (!values.containsKey(Bookmarks.TITLE)) {
+ // Desktop Places barfs on insertion of a bookmark with no title,
+ // so we don't store them that way.
+ values.put(Bookmarks.TITLE, "");
+ }
+
+ String url = values.getAsString(Bookmarks.URL);
+
+ debug("Inserting bookmark in database with URL: " + url);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values);
+ }
+
+
+ private int updateOrInsertBookmark(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ int updated = updateBookmarks(uri, values, selection, selectionArgs);
+ if (updated > 0) {
+ return updated;
+ }
+
+ // Transaction already begun by updateBookmarks.
+ if (0 <= insertBookmark(uri, values)) {
+ // We 'updated' one row.
+ return 1;
+ }
+
+ // If something went wrong, then we updated zero rows.
+ return 0;
+ }
+
+ private int updateBookmarks(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ trace("Updating bookmarks on URI: " + uri);
+
+ final String[] bookmarksProjection = new String[] {
+ Bookmarks._ID, // 0
+ };
+
+ if (!values.containsKey(Bookmarks.DATE_MODIFIED)) {
+ values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
+ }
+
+ trace("Querying bookmarks to update on URI: " + uri);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ // Compute matching IDs.
+ final Cursor cursor = db.query(TABLE_BOOKMARKS, bookmarksProjection,
+ selection, selectionArgs, null, null, null);
+
+ // Now that we're done reading, open a transaction.
+ final String inClause;
+ try {
+ inClause = DBUtils.computeSQLInClauseFromLongs(cursor, Bookmarks._ID);
+ } finally {
+ cursor.close();
+ }
+
+ beginWrite(db);
+ return db.update(TABLE_BOOKMARKS, values, inClause, null);
+ }
+
+ private long insertHistory(Uri uri, ContentValues values) {
+ final long now = System.currentTimeMillis();
+ values.put(History.DATE_CREATED, now);
+ values.put(History.DATE_MODIFIED, now);
+
+ // Generate GUID for new history entry. Don't override specified GUIDs.
+ if (!values.containsKey(History.GUID)) {
+ values.put(History.GUID, Utils.generateGuid());
+ }
+
+ String url = values.getAsString(History.URL);
+
+ debug("Inserting history in database with URL: " + url);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.insertOrThrow(TABLE_HISTORY, History.VISITS, values);
+ }
+
+ private int updateOrInsertHistory(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ final int updated = updateHistory(uri, values, selection, selectionArgs);
+ if (updated > 0) {
+ return updated;
+ }
+
+ // Insert a new entry if necessary, setting visit and date aggregate values.
+ if (!values.containsKey(History.VISITS)) {
+ values.put(History.VISITS, 1);
+ values.put(History.LOCAL_VISITS, 1);
+ } else {
+ values.put(History.LOCAL_VISITS, values.getAsInteger(History.VISITS));
+ }
+ if (values.containsKey(History.DATE_LAST_VISITED)) {
+ values.put(History.LOCAL_DATE_LAST_VISITED, values.getAsLong(History.DATE_LAST_VISITED));
+ }
+ if (!values.containsKey(History.TITLE)) {
+ values.put(History.TITLE, values.getAsString(History.URL));
+ }
+
+ if (0 <= insertHistory(uri, values)) {
+ return 1;
+ }
+
+ return 0;
+ }
+
+ private int updateHistory(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ trace("Updating history on URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ if (!values.containsKey(History.DATE_MODIFIED)) {
+ values.put(History.DATE_MODIFIED, System.currentTimeMillis());
+ }
+
+ // Use the simple code path for easy updates.
+ if (!shouldIncrementVisits(uri) && !shouldIncrementRemoteAggregates(uri)) {
+ trace("Updating history meta data only");
+ return db.update(TABLE_HISTORY, values, selection, selectionArgs);
+ }
+
+ trace("Updating history meta data and incrementing visits");
+
+ if (values.containsKey(History.DATE_LAST_VISITED)) {
+ values.put(History.LOCAL_DATE_LAST_VISITED, values.getAsLong(History.DATE_LAST_VISITED));
+ }
+
+ // Create a separate set of values that will be updated as an expression.
+ final ContentValues visits = new ContentValues();
+ if (shouldIncrementVisits(uri)) {
+ // Update data and increment visits by 1.
+ final long incVisits = 1;
+
+ visits.put(History.VISITS, History.VISITS + " + " + incVisits);
+ visits.put(History.LOCAL_VISITS, History.LOCAL_VISITS + " + " + incVisits);
+ }
+
+ if (shouldIncrementRemoteAggregates(uri)) {
+ // Let's fail loudly instead of trying to assume what users of this API meant to do.
+ if (!values.containsKey(History.REMOTE_VISITS)) {
+ throw new IllegalArgumentException(
+ "Tried incrementing History.REMOTE_VISITS by unknown value");
+ }
+ visits.put(
+ History.REMOTE_VISITS,
+ History.REMOTE_VISITS + " + " + values.getAsInteger(History.REMOTE_VISITS)
+ );
+ // Need to remove passed in value, so that we increment REMOTE_VISITS, and not just set it.
+ values.remove(History.REMOTE_VISITS);
+ }
+
+ final ContentValues[] valuesAndVisits = { values, visits };
+ final UpdateOperation[] ops = { UpdateOperation.ASSIGN, UpdateOperation.EXPRESSION };
+
+ return DBUtils.updateArrays(db, TABLE_HISTORY, valuesAndVisits, ops, selection, selectionArgs);
+ }
+
+ private long insertVisitForHistory(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ trace("Inserting visit for history on URI: " + uri);
+
+ final SQLiteDatabase db = getReadableDatabase(uri);
+
+ final Cursor cursor = db.query(
+ History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs,
+ null, null, null);
+ if (cursor == null) {
+ Log.e(LOGTAG, "Null cursor while trying to insert visit for history URI: " + uri);
+ return 0;
+ }
+ final ContentValues[] visitValues;
+ try {
+ visitValues = new ContentValues[cursor.getCount()];
+
+ if (!cursor.moveToFirst()) {
+ Log.e(LOGTAG, "No history records found while inserting visit(s) for history URI: " + uri);
+ return 0;
+ }
+
+ // Sync works in microseconds, so we store visit timestamps in microseconds as well.
+ // History timestamps are in milliseconds.
+ // This is the conversion point for locally generated visits.
+ final long visitDate;
+ if (values.containsKey(History.DATE_LAST_VISITED)) {
+ visitDate = values.getAsLong(History.DATE_LAST_VISITED) * 1000;
+ } else {
+ visitDate = System.currentTimeMillis() * 1000;
+ }
+
+ final int guidColumn = cursor.getColumnIndexOrThrow(History.GUID);
+ while (!cursor.isAfterLast()) {
+ final ContentValues visit = new ContentValues();
+ visit.put(Visits.HISTORY_GUID, cursor.getString(guidColumn));
+ visit.put(Visits.DATE_VISITED, visitDate);
+ visitValues[cursor.getPosition()] = visit;
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.close();
+ }
+
+ if (visitValues.length == 1) {
+ return insertVisit(Visits.CONTENT_URI, visitValues[0]);
+ } else {
+ return bulkInsert(Visits.CONTENT_URI, visitValues);
+ }
+ }
+
+ private long insertVisit(Uri uri, ContentValues values) {
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ debug("Inserting history in database with URL: " + uri);
+ beginWrite(db);
+
+ // We ignore insert conflicts here to simplify inserting visits records coming in from Sync.
+ // Visits table has a unique index on (history_guid,date), so a conflict might arise when we're
+ // trying to insert history record visits coming in from sync which are already present locally
+ // as a result of previous sync operations.
+ // An alternative to doing this is to filter out already present records when we're doing history inserts
+ // from Sync, which is a costly operation to do en masse.
+ return db.insertWithOnConflict(
+ TABLE_VISITS, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+ }
+
+ private void updateFaviconIdsForUrl(SQLiteDatabase db, String pageUrl, Long faviconId) {
+ ContentValues updateValues = new ContentValues(1);
+ updateValues.put(FaviconColumns.FAVICON_ID, faviconId);
+ db.update(TABLE_HISTORY,
+ updateValues,
+ History.URL + " = ?",
+ new String[] { pageUrl });
+ db.update(TABLE_BOOKMARKS,
+ updateValues,
+ Bookmarks.URL + " = ?",
+ new String[] { pageUrl });
+ }
+
+ private long insertFavicon(Uri uri, ContentValues values) {
+ return insertFavicon(getWritableDatabase(uri), values);
+ }
+
+ private long insertFavicon(SQLiteDatabase db, ContentValues values) {
+ String faviconUrl = values.getAsString(Favicons.URL);
+ String pageUrl = null;
+
+ trace("Inserting favicon for URL: " + faviconUrl);
+
+ DBUtils.stripEmptyByteArray(values, Favicons.DATA);
+
+ // Extract the page URL from the ContentValues
+ if (values.containsKey(Favicons.PAGE_URL)) {
+ pageUrl = values.getAsString(Favicons.PAGE_URL);
+ values.remove(Favicons.PAGE_URL);
+ }
+
+ // If no URL is provided, insert using the default one.
+ if (TextUtils.isEmpty(faviconUrl) && !TextUtils.isEmpty(pageUrl)) {
+ values.put(Favicons.URL, IconsHelper.guessDefaultFaviconURL(pageUrl));
+ }
+
+ final long now = System.currentTimeMillis();
+ values.put(Favicons.DATE_CREATED, now);
+ values.put(Favicons.DATE_MODIFIED, now);
+
+ beginWrite(db);
+ final long faviconId = db.insertOrThrow(TABLE_FAVICONS, null, values);
+
+ if (pageUrl != null) {
+ updateFaviconIdsForUrl(db, pageUrl, faviconId);
+ }
+ return faviconId;
+ }
+
+ private int updateOrInsertFavicon(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ return updateFavicon(uri, values, selection, selectionArgs,
+ true /* insert if needed */);
+ }
+
+ private int updateExistingFavicon(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ return updateFavicon(uri, values, selection, selectionArgs,
+ false /* only update, no insert */);
+ }
+
+ private int updateFavicon(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs, boolean insertIfNeeded) {
+ String faviconUrl = values.getAsString(Favicons.URL);
+ String pageUrl = null;
+ int updated = 0;
+ Long faviconId = null;
+ long now = System.currentTimeMillis();
+
+ trace("Updating favicon for URL: " + faviconUrl);
+
+ DBUtils.stripEmptyByteArray(values, Favicons.DATA);
+
+ // Extract the page URL from the ContentValues
+ if (values.containsKey(Favicons.PAGE_URL)) {
+ pageUrl = values.getAsString(Favicons.PAGE_URL);
+ values.remove(Favicons.PAGE_URL);
+ }
+
+ values.put(Favicons.DATE_MODIFIED, now);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ // If there's no favicon URL given and we're inserting if needed, skip
+ // the update and only do an insert (otherwise all rows would be
+ // updated).
+ if (!(insertIfNeeded && (faviconUrl == null))) {
+ updated = db.update(TABLE_FAVICONS, values, selection, selectionArgs);
+ }
+
+ if (updated > 0) {
+ if ((faviconUrl != null) && (pageUrl != null)) {
+ final Cursor cursor = db.query(TABLE_FAVICONS,
+ new String[] { Favicons._ID },
+ Favicons.URL + " = ?",
+ new String[] { faviconUrl },
+ null, null, null);
+ try {
+ if (cursor.moveToFirst()) {
+ faviconId = cursor.getLong(cursor.getColumnIndexOrThrow(Favicons._ID));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ if (pageUrl != null) {
+ beginWrite(db);
+ }
+ } else if (insertIfNeeded) {
+ values.put(Favicons.DATE_CREATED, now);
+
+ trace("No update, inserting favicon for URL: " + faviconUrl);
+ beginWrite(db);
+ faviconId = db.insert(TABLE_FAVICONS, null, values);
+ updated = 1;
+ }
+
+ if (pageUrl != null) {
+ updateFaviconIdsForUrl(db, pageUrl, faviconId);
+ }
+
+ return updated;
+ }
+
+ private long insertThumbnail(Uri uri, ContentValues values) {
+ final String url = values.getAsString(Thumbnails.URL);
+
+ trace("Inserting thumbnail for URL: " + url);
+
+ DBUtils.stripEmptyByteArray(values, Thumbnails.DATA);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.insertOrThrow(TABLE_THUMBNAILS, null, values);
+ }
+
+ private long insertActivityStreamBlocklistSite(final Uri uri, final ContentValues values) {
+ final String url = values.getAsString(ActivityStreamBlocklist.URL);
+ trace("Inserting url into highlights blocklist, URL: " + url);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ values.put(ActivityStreamBlocklist.CREATED, System.currentTimeMillis());
+
+ beginWrite(db);
+ return db.insertOrThrow(TABLE_ACTIVITY_STREAM_BLOCKLIST, null, values);
+ }
+
+ private long insertPageMetadata(final Uri uri, final ContentValues values) {
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ if (!values.containsKey(PageMetadata.DATE_CREATED)) {
+ values.put(PageMetadata.DATE_CREATED, System.currentTimeMillis());
+ }
+
+ beginWrite(db);
+
+ // Perform INSERT OR REPLACE, there might be page metadata present and we want to replace it.
+ // Depends on a conflict arising from unique foreign key (history_guid) constraint violation.
+ return db.insertWithOnConflict(
+ TABLE_PAGE_METADATA, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ }
+
+ private long insertUrlAnnotation(final Uri uri, final ContentValues values) {
+ final String url = values.getAsString(UrlAnnotations.URL);
+ trace("Inserting url annotations for URL: " + url);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.insertOrThrow(TABLE_URL_ANNOTATIONS, null, values);
+ }
+
+ private void deleteUrlAnnotation(final Uri uri, final String selection, final String[] selectionArgs) {
+ trace("Deleting url annotation for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ db.delete(TABLE_URL_ANNOTATIONS, selection, selectionArgs);
+ }
+
+ private int deletePageMetadata(final Uri uri, final String selection, final String[] selectionArgs) {
+ trace("Deleting page metadata for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ return db.delete(TABLE_PAGE_METADATA, selection, selectionArgs);
+ }
+
+ private void updateUrlAnnotation(final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) {
+ trace("Updating url annotation for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ db.update(TABLE_URL_ANNOTATIONS, values, selection, selectionArgs);
+ }
+
+ private int updateOrInsertThumbnail(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ return updateThumbnail(uri, values, selection, selectionArgs,
+ true /* insert if needed */);
+ }
+
+ private int updateExistingThumbnail(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ return updateThumbnail(uri, values, selection, selectionArgs,
+ false /* only update, no insert */);
+ }
+
+ private int updateThumbnail(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs, boolean insertIfNeeded) {
+ final String url = values.getAsString(Thumbnails.URL);
+ DBUtils.stripEmptyByteArray(values, Thumbnails.DATA);
+
+ trace("Updating thumbnail for URL: " + url);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ int updated = db.update(TABLE_THUMBNAILS, values, selection, selectionArgs);
+
+ if (updated == 0 && insertIfNeeded) {
+ trace("No update, inserting thumbnail for URL: " + url);
+ db.insert(TABLE_THUMBNAILS, null, values);
+ updated = 1;
+ }
+
+ return updated;
+ }
+
+ /**
+ * This method does not create a new transaction. Its first operation is
+ * guaranteed to be a write, which in the case of a new enclosing
+ * transaction will guarantee that a read does not need to be upgraded to
+ * a write.
+ */
+ private int deleteHistory(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs) {
+ debug("Deleting history entry for URI: " + uri);
+
+ if (isCallerSync(uri)) {
+ return db.delete(TABLE_HISTORY, selection, selectionArgs);
+ }
+
+ debug("Marking history entry as deleted for URI: " + uri);
+
+ ContentValues values = new ContentValues();
+ values.put(History.IS_DELETED, 1);
+
+ // Wipe sensitive data.
+ values.putNull(History.TITLE);
+ values.put(History.URL, ""); // Column is NOT NULL.
+ values.put(History.DATE_CREATED, 0);
+ values.put(History.DATE_LAST_VISITED, 0);
+ values.put(History.VISITS, 0);
+ values.put(History.DATE_MODIFIED, System.currentTimeMillis());
+
+ // Doing this UPDATE (or the DELETE above) first ensures that the
+ // first operation within a new enclosing transaction is a write.
+ // The cleanup call below will do a SELECT first, and thus would
+ // require the transaction to be upgraded from a reader to a writer.
+ // In some cases that upgrade can fail (SQLITE_BUSY), so we avoid
+ // it if we can.
+ final int updated = db.update(TABLE_HISTORY, values, selection, selectionArgs);
+ try {
+ cleanUpSomeDeletedRecords(uri, TABLE_HISTORY);
+ } catch (Exception e) {
+ // We don't care.
+ Log.e(LOGTAG, "Unable to clean up deleted history records: ", e);
+ }
+ return updated;
+ }
+
+ private ArrayList<String> getHistoryGUIDsFromSelection(SQLiteDatabase db, Uri uri, String selection, String[] selectionArgs) {
+ final ArrayList<String> historyGUIDs = new ArrayList<>();
+
+ final Cursor cursor = db.query(
+ History.TABLE_NAME, new String[] {History.GUID}, selection, selectionArgs,
+ null, null, null);
+ if (cursor == null) {
+ Log.e(LOGTAG, "Null cursor while trying to delete visits for history URI: " + uri);
+ return historyGUIDs;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ trace("No history items for which to remove visits matched for URI: " + uri);
+ return historyGUIDs;
+ }
+ final int historyColumn = cursor.getColumnIndexOrThrow(History.GUID);
+ while (!cursor.isAfterLast()) {
+ historyGUIDs.add(cursor.getString(historyColumn));
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return historyGUIDs;
+ }
+
+ private int deletePageMetadataForHistory(SQLiteDatabase db, ArrayList<String> historyGUIDs) {
+ return bulkDeleteByHistoryGUID(db, historyGUIDs, PageMetadata.TABLE_NAME, PageMetadata.HISTORY_GUID);
+ }
+
+ private int deleteVisitsForHistory(SQLiteDatabase db, ArrayList<String> historyGUIDs) {
+ return bulkDeleteByHistoryGUID(db, historyGUIDs, Visits.TABLE_NAME, Visits.HISTORY_GUID);
+ }
+
+ private int bulkDeleteByHistoryGUID(SQLiteDatabase db, ArrayList<String> historyGUIDs, String table, String historyGUIDColumn) {
+ // Due to SQLite's maximum variable limitation, we need to chunk our delete statements.
+ // For example, if there were 1200 GUIDs, this will perform 2 delete statements.
+ int deleted = 0;
+ for (int chunk = 0; chunk <= historyGUIDs.size() / DBUtils.SQLITE_MAX_VARIABLE_NUMBER; chunk++) {
+ final int chunkStart = chunk * DBUtils.SQLITE_MAX_VARIABLE_NUMBER;
+ int chunkEnd = (chunk + 1) * DBUtils.SQLITE_MAX_VARIABLE_NUMBER;
+ if (chunkEnd > historyGUIDs.size()) {
+ chunkEnd = historyGUIDs.size();
+ }
+ final List<String> chunkGUIDs = historyGUIDs.subList(chunkStart, chunkEnd);
+ deleted += db.delete(
+ table,
+ DBUtils.computeSQLInClause(chunkGUIDs.size(), historyGUIDColumn),
+ chunkGUIDs.toArray(new String[chunkGUIDs.size()])
+ );
+ }
+
+ return deleted;
+ }
+
+ private int deleteVisits(Uri uri, String selection, String[] selectionArgs) {
+ debug("Deleting visits for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ beginWrite(db);
+ return db.delete(TABLE_VISITS, selection, selectionArgs);
+ }
+
+ private int deleteBookmarks(Uri uri, String selection, String[] selectionArgs) {
+ debug("Deleting bookmarks for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ if (isCallerSync(uri)) {
+ beginWrite(db);
+ return db.delete(TABLE_BOOKMARKS, selection, selectionArgs);
+ }
+
+ debug("Marking bookmarks as deleted for URI: " + uri);
+
+ ContentValues values = new ContentValues();
+ values.put(Bookmarks.IS_DELETED, 1);
+ values.put(Bookmarks.POSITION, 0);
+ values.putNull(Bookmarks.PARENT);
+ values.putNull(Bookmarks.URL);
+ values.putNull(Bookmarks.TITLE);
+ values.putNull(Bookmarks.DESCRIPTION);
+ values.putNull(Bookmarks.KEYWORD);
+ values.putNull(Bookmarks.TAGS);
+ values.putNull(Bookmarks.FAVICON_ID);
+
+ // Doing this UPDATE (or the DELETE above) first ensures that the
+ // first operation within this transaction is a write.
+ // The cleanup call below will do a SELECT first, and thus would
+ // require the transaction to be upgraded from a reader to a writer.
+ final int updated = updateBookmarks(uri, values, selection, selectionArgs);
+ try {
+ cleanUpSomeDeletedRecords(uri, TABLE_BOOKMARKS);
+ } catch (Exception e) {
+ // We don't care.
+ Log.e(LOGTAG, "Unable to clean up deleted bookmark records: ", e);
+ }
+ return updated;
+ }
+
+ private int deleteFavicons(Uri uri, String selection, String[] selectionArgs) {
+ debug("Deleting favicons for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ return db.delete(TABLE_FAVICONS, selection, selectionArgs);
+ }
+
+ private int deleteThumbnails(Uri uri, String selection, String[] selectionArgs) {
+ debug("Deleting thumbnails for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ return db.delete(TABLE_THUMBNAILS, selection, selectionArgs);
+ }
+
+ private int deleteUnusedImages(Uri uri) {
+ debug("Deleting all unused favicons and thumbnails for URI: " + uri);
+
+ String faviconSelection = Favicons._ID + " NOT IN "
+ + "(SELECT " + History.FAVICON_ID
+ + " FROM " + TABLE_HISTORY
+ + " WHERE " + History.IS_DELETED + " = 0"
+ + " AND " + History.FAVICON_ID + " IS NOT NULL"
+ + " UNION ALL SELECT " + Bookmarks.FAVICON_ID
+ + " FROM " + TABLE_BOOKMARKS
+ + " WHERE " + Bookmarks.IS_DELETED + " = 0"
+ + " AND " + Bookmarks.FAVICON_ID + " IS NOT NULL)";
+
+ String thumbnailSelection = Thumbnails.URL + " NOT IN "
+ + "(SELECT " + History.URL
+ + " FROM " + TABLE_HISTORY
+ + " WHERE " + History.IS_DELETED + " = 0"
+ + " AND " + History.URL + " IS NOT NULL"
+ + " UNION ALL SELECT " + Bookmarks.URL
+ + " FROM " + TABLE_BOOKMARKS
+ + " WHERE " + Bookmarks.IS_DELETED + " = 0"
+ + " AND " + Bookmarks.URL + " IS NOT NULL)";
+
+ return deleteFavicons(uri, faviconSelection, null) +
+ deleteThumbnails(uri, thumbnailSelection, null) +
+ getURLMetadataTable().deleteUnused(getWritableDatabase(uri));
+ }
+
+ @Override
+ public ContentProviderResult[] applyBatch (ArrayList<ContentProviderOperation> operations)
+ throws OperationApplicationException {
+ final int numOperations = operations.size();
+ final ContentProviderResult[] results = new ContentProviderResult[numOperations];
+
+ if (numOperations < 1) {
+ debug("applyBatch: no operations; returning immediately.");
+ // The original Android implementation returns a zero-length
+ // array in this case. We do the same.
+ return results;
+ }
+
+ boolean failures = false;
+
+ // We only have 1 database for all Uris that we can get.
+ SQLiteDatabase db = getWritableDatabase(operations.get(0).getUri());
+
+ // Note that the apply() call may cause us to generate
+ // additional transactions for the individual operations.
+ // But Android's wrapper for SQLite supports nested transactions,
+ // so this will do the right thing.
+ //
+ // Note further that in some circumstances this can result in
+ // exceptions: if this transaction is first involved in reading,
+ // and then (naturally) tries to perform writes, SQLITE_BUSY can
+ // be raised. See Bug 947939 and friends.
+ beginBatch(db);
+
+ for (int i = 0; i < numOperations; i++) {
+ try {
+ final ContentProviderOperation operation = operations.get(i);
+ results[i] = operation.apply(this, results, i);
+ } catch (SQLException e) {
+ Log.w(LOGTAG, "SQLite Exception during applyBatch.", e);
+ // The Android API makes it implementation-defined whether
+ // the failure of a single operation makes all others abort
+ // or not. For our use cases, best-effort operation makes
+ // more sense. Rolling back and forcing the caller to retry
+ // after it figures out what went wrong isn't very convenient
+ // anyway.
+ // Signal failed operation back, so the caller knows what
+ // went through and what didn't.
+ results[i] = new ContentProviderResult(0);
+ failures = true;
+ // http://www.sqlite.org/lang_conflict.html
+ // Note that we need a new transaction, subsequent operations
+ // on this one will fail (we're in ABORT by default, which
+ // isn't IGNORE). We still need to set it as successful to let
+ // everything before the failed op go through.
+ // We can't set conflict resolution on API level < 8, and even
+ // above 8 it requires splitting the call per operation
+ // (insert/update/delete).
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ db.beginTransaction();
+ } catch (OperationApplicationException e) {
+ // Repeat of above.
+ results[i] = new ContentProviderResult(0);
+ failures = true;
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ db.beginTransaction();
+ }
+ }
+
+ trace("Flushing DB applyBatch...");
+ markBatchSuccessful(db);
+ endBatch(db);
+
+ if (failures) {
+ throw new OperationApplicationException();
+ }
+
+ return results;
+ }
+
+ private static Table findTableFor(int id) {
+ for (Table table : sTables) {
+ for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+ if (type.id == id) {
+ return table;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static void addTablesToMatcher(Table[] tables, final UriMatcher matcher) {
+ }
+
+ private static String getContentItemType(final int match) {
+ for (Table table : sTables) {
+ for (Table.ContentProviderInfo type : table.getContentProviderInfo()) {
+ if (type.id == match) {
+ return "vnd.android.cursor.item/" + type.name;
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java
new file mode 100644
index 000000000..cfa2f870f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java
@@ -0,0 +1,450 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.annotation.TargetApi;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteStatement;
+import android.os.Build;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.Telemetry;
+
+import java.util.Map;
+
+public class DBUtils {
+ private static final String LOGTAG = "GeckoDBUtils";
+
+ public static final int SQLITE_MAX_VARIABLE_NUMBER = 999;
+
+ public static final String qualifyColumn(String table, String column) {
+ return table + "." + column;
+ }
+
+ // This is available in Android >= 11. Implemented locally to be
+ // compatible with older versions.
+ public static String concatenateWhere(String a, String b) {
+ if (TextUtils.isEmpty(a)) {
+ return b;
+ }
+
+ if (TextUtils.isEmpty(b)) {
+ return a;
+ }
+
+ return "(" + a + ") AND (" + b + ")";
+ }
+
+ // This is available in Android >= 11. Implemented locally to be
+ // compatible with older versions.
+ public static String[] appendSelectionArgs(String[] originalValues, String[] newValues) {
+ if (originalValues == null || originalValues.length == 0) {
+ return newValues;
+ }
+
+ if (newValues == null || newValues.length == 0) {
+ return originalValues;
+ }
+
+ String[] result = new String[originalValues.length + newValues.length];
+ System.arraycopy(originalValues, 0, result, 0, originalValues.length);
+ System.arraycopy(newValues, 0, result, originalValues.length, newValues.length);
+
+ return result;
+ }
+
+ /**
+ * Concatenate multiple lists of selection arguments. <code>values</code> may be <code>null</code>.
+ */
+ public static String[] concatenateSelectionArgs(String[]... values) {
+ // Since we're most likely to be concatenating a few arrays of many values, it is most
+ // efficient to iterate over the arrays once to obtain their lengths, allowing us to create one target array
+ // (as opposed to copying arrays on every iteration, which would result in many more copies).
+ int totalLength = 0;
+ for (String[] v : values) {
+ if (v != null) {
+ totalLength += v.length;
+ }
+ }
+
+ String[] result = new String[totalLength];
+
+ int position = 0;
+ for (String[] v: values) {
+ if (v != null) {
+ int currentLength = v.length;
+ System.arraycopy(v, 0, result, position, currentLength);
+ position += currentLength;
+ }
+ }
+
+ return result;
+ }
+
+ public static void replaceKey(ContentValues aValues, String aOriginalKey,
+ String aNewKey, String aDefault) {
+ String value = aDefault;
+ if (aOriginalKey != null && aValues.containsKey(aOriginalKey)) {
+ value = aValues.get(aOriginalKey).toString();
+ aValues.remove(aOriginalKey);
+ }
+
+ if (!aValues.containsKey(aNewKey)) {
+ aValues.put(aNewKey, value);
+ }
+ }
+
+ private static String HISTOGRAM_DATABASE_LOCKED = "DATABASE_LOCKED_EXCEPTION";
+ private static String HISTOGRAM_DATABASE_UNLOCKED = "DATABASE_SUCCESSFUL_UNLOCK";
+ public static void ensureDatabaseIsNotLocked(SQLiteOpenHelper dbHelper, String databasePath) {
+ final int maxAttempts = 5;
+ int attempt = 0;
+ SQLiteDatabase db = null;
+ for (; attempt < maxAttempts; attempt++) {
+ try {
+ // Try a simple test and exit the loop.
+ db = dbHelper.getWritableDatabase();
+ break;
+ } catch (Exception e) {
+ // We assume that this is a android.database.sqlite.SQLiteDatabaseLockedException.
+ // That class is only available on API 11+.
+ Telemetry.addToHistogram(HISTOGRAM_DATABASE_LOCKED, attempt);
+
+ // Things could get very bad if we don't find a way to unlock the DB.
+ Log.d(LOGTAG, "Database is locked, trying to kill any zombie processes: " + databasePath);
+ GeckoAppShell.killAnyZombies();
+ try {
+ Thread.sleep(attempt * 100);
+ } catch (InterruptedException ie) {
+ }
+ }
+ }
+
+ if (db == null) {
+ Log.w(LOGTAG, "Failed to unlock database.");
+ GeckoAppShell.listOfOpenFiles();
+ return;
+ }
+
+ // If we needed to retry, but we succeeded, report that in telemetry.
+ // Failures are indicated by a lower frequency of UNLOCKED than LOCKED.
+ if (attempt > 1) {
+ Telemetry.addToHistogram(HISTOGRAM_DATABASE_UNLOCKED, attempt - 1);
+ }
+ }
+
+ /**
+ * Copies a table <b>between</b> database files.
+ *
+ * This method assumes that the source table and destination table already exist in the
+ * source and destination databases, respectively.
+ *
+ * The table is copied row-by-row in a single transaction.
+ *
+ * @param source The source database that the table will be copied from.
+ * @param sourceTableName The name of the source table.
+ * @param destination The destination database that the table will be copied to.
+ * @param destinationTableName The name of the destination table.
+ * @return true if all rows were copied; false otherwise.
+ */
+ public static boolean copyTable(SQLiteDatabase source, String sourceTableName,
+ SQLiteDatabase destination, String destinationTableName) {
+ Cursor cursor = null;
+ try {
+ destination.beginTransaction();
+
+ cursor = source.query(sourceTableName, null, null, null, null, null, null);
+ Log.d(LOGTAG, "Trying to copy " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName);
+
+ final ContentValues contentValues = new ContentValues();
+ while (cursor.moveToNext()) {
+ contentValues.clear();
+ DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
+ destination.insert(destinationTableName, null, contentValues);
+ }
+
+ destination.setTransactionSuccessful();
+ Log.d(LOGTAG, "Successfully copied " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName);
+ return true;
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Got exception copying rows from " + sourceTableName + " to " + destinationTableName + "; ignoring.", e);
+ return false;
+ } finally {
+ destination.endTransaction();
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Verifies that 0-byte arrays aren't added as favicon or thumbnail data.
+ * @param values ContentValues of query
+ * @param columnName Name of data column to verify
+ */
+ public static void stripEmptyByteArray(ContentValues values, String columnName) {
+ if (values.containsKey(columnName)) {
+ byte[] data = values.getAsByteArray(columnName);
+ if (data == null || data.length == 0) {
+ Log.w(LOGTAG, "Tried to insert an empty or non-byte-array image. Ignoring.");
+ values.putNull(columnName);
+ }
+ }
+ }
+
+ /**
+ * Builds a selection string that searches for a list of arguments in a particular column.
+ * For example URL in (?,?,?). Callers should pass the actual arguments into their query
+ * as selection args.
+ * @para columnName The column to search in
+ * @para size The number of arguments to search for
+ */
+ public static String computeSQLInClause(int items, String field) {
+ final StringBuilder builder = new StringBuilder(field);
+ builder.append(" IN (");
+ int i = 0;
+ for (; i < items - 1; ++i) {
+ builder.append("?, ");
+ }
+ if (i < items) {
+ builder.append("?");
+ }
+ builder.append(")");
+ return builder.toString();
+ }
+
+ /**
+ * Turn a single-column cursor of longs into a single SQL "IN" clause.
+ * We can do this without using selection arguments because Long isn't
+ * vulnerable to injection.
+ */
+ public static String computeSQLInClauseFromLongs(final Cursor cursor, String field) {
+ final StringBuilder builder = new StringBuilder(field);
+ builder.append(" IN (");
+ final int commaLimit = cursor.getCount() - 1;
+ int i = 0;
+ while (cursor.moveToNext()) {
+ builder.append(cursor.getLong(0));
+ if (i++ < commaLimit) {
+ builder.append(", ");
+ }
+ }
+ builder.append(")");
+ return builder.toString();
+ }
+
+ public static Uri appendProfile(final String profile, final Uri uri) {
+ return uri.buildUpon().appendQueryParameter(BrowserContract.PARAM_PROFILE, profile).build();
+ }
+
+ public static Uri appendProfileWithDefault(final String profile, final Uri uri) {
+ if (profile == null) {
+ return appendProfile(GeckoProfile.DEFAULT_PROFILE, uri);
+ }
+ return appendProfile(profile, uri);
+ }
+
+ /**
+ * Use the following when no conflict action is specified.
+ */
+ private static final int CONFLICT_NONE = 0;
+ private static final String[] CONFLICT_VALUES = new String[] {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", " OR REPLACE "};
+
+ /**
+ * Convenience method for updating rows in the database.
+ *
+ * @param table the table to update in
+ * @param values a map from column names to new column values. null is a
+ * valid value that will be translated to NULL.
+ * @param whereClause the optional WHERE clause to apply when updating.
+ * Passing null will update all rows.
+ * @param whereArgs You may include ?s in the where clause, which
+ * will be replaced by the values from whereArgs. The values
+ * will be bound as Strings.
+ * @return the number of rows affected
+ */
+ @RobocopTarget
+ public static int updateArrays(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) {
+ return updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, true);
+ }
+
+ public static void updateArraysBlindly(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) {
+ updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, false);
+ }
+
+ @RobocopTarget
+ public enum UpdateOperation {
+ /**
+ * ASSIGN is the usual update: replaces the value in the named column with the provided value.
+ *
+ * foo = ?
+ */
+ ASSIGN,
+
+ /**
+ * BITWISE_OR applies the provided value to the existing value with a bitwise OR. This is useful for adding to flags.
+ *
+ * foo |= ?
+ */
+ BITWISE_OR,
+
+ /**
+ * EXPRESSION is an end-run around the API: it allows callers to specify a fragment of SQL to splice into the
+ * SET part of the query.
+ *
+ * foo = $value
+ *
+ * Be very careful not to use user input in this.
+ */
+ EXPRESSION,
+ }
+
+ /**
+ * This is an evil reimplementation of SQLiteDatabase's methods to allow for
+ * smarter updating.
+ *
+ * Each ContentValues has an associated enum that describes how to unify input values with the existing column values.
+ */
+ private static int updateArraysWithOnConflict(SQLiteDatabase db, String table,
+ ContentValues[] values,
+ UpdateOperation[] ops,
+ String whereClause,
+ String[] whereArgs,
+ int conflictAlgorithm,
+ boolean returnChangedRows) {
+ if (values == null || values.length == 0) {
+ throw new IllegalArgumentException("Empty values");
+ }
+
+ if (ops == null || ops.length != values.length) {
+ throw new IllegalArgumentException("ops and values don't match");
+ }
+
+ StringBuilder sql = new StringBuilder(120);
+ sql.append("UPDATE ");
+ sql.append(CONFLICT_VALUES[conflictAlgorithm]);
+ sql.append(table);
+ sql.append(" SET ");
+
+ // move all bind args to one array
+ int setValuesSize = 0;
+ for (int i = 0; i < values.length; i++) {
+ // EXPRESSION types don't contribute any placeholders.
+ if (ops[i] != UpdateOperation.EXPRESSION) {
+ setValuesSize += values[i].size();
+ }
+ }
+
+ int bindArgsSize = (whereArgs == null) ? setValuesSize : (setValuesSize + whereArgs.length);
+ Object[] bindArgs = new Object[bindArgsSize];
+
+ int arg = 0;
+ for (int i = 0; i < values.length; i++) {
+ final ContentValues v = values[i];
+ final UpdateOperation op = ops[i];
+
+ // Alas, code duplication.
+ switch (op) {
+ case ASSIGN:
+ for (Map.Entry<String, Object> entry : v.valueSet()) {
+ final String colName = entry.getKey();
+ sql.append((arg > 0) ? "," : "");
+ sql.append(colName);
+ bindArgs[arg++] = entry.getValue();
+ sql.append("= ?");
+ }
+ break;
+ case BITWISE_OR:
+ for (Map.Entry<String, Object> entry : v.valueSet()) {
+ final String colName = entry.getKey();
+ sql.append((arg > 0) ? "," : "");
+ sql.append(colName);
+ bindArgs[arg++] = entry.getValue();
+ sql.append("= ? | ");
+ sql.append(colName);
+ }
+ break;
+ case EXPRESSION:
+ // Treat each value as a literal SQL string.
+ for (Map.Entry<String, Object> entry : v.valueSet()) {
+ final String colName = entry.getKey();
+ sql.append((arg > 0) ? "," : "");
+ sql.append(colName);
+ sql.append(" = ");
+ sql.append(entry.getValue());
+ }
+ break;
+ }
+ }
+
+ if (whereArgs != null) {
+ for (arg = setValuesSize; arg < bindArgsSize; arg++) {
+ bindArgs[arg] = whereArgs[arg - setValuesSize];
+ }
+ }
+ if (!TextUtils.isEmpty(whereClause)) {
+ sql.append(" WHERE ");
+ sql.append(whereClause);
+ }
+
+ // What a huge pain in the ass, all because SQLiteDatabase doesn't expose .executeSql,
+ // and we can't get a DB handle. Nor can we easily construct a statement with arguments
+ // already bound.
+ final SQLiteStatement statement = db.compileStatement(sql.toString());
+ try {
+ bindAllArgs(statement, bindArgs);
+ if (!returnChangedRows) {
+ statement.execute();
+ return 0;
+ }
+ // This is a separate method so we can annotate it with @TargetApi.
+ return executeStatementReturningChangedRows(statement);
+ } finally {
+ statement.close();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ private static int executeStatementReturningChangedRows(SQLiteStatement statement) {
+ return statement.executeUpdateDelete();
+ }
+
+ // All because {@link SQLiteProgram#bind(integer, Object)} is private.
+ private static void bindAllArgs(SQLiteStatement statement, Object[] bindArgs) {
+ if (bindArgs == null) {
+ return;
+ }
+ for (int i = bindArgs.length; i != 0; i--) {
+ Object v = bindArgs[i - 1];
+ if (v == null) {
+ statement.bindNull(i);
+ } else if (v instanceof String) {
+ statement.bindString(i, (String) v);
+ } else if (v instanceof Double) {
+ statement.bindDouble(i, (Double) v);
+ } else if (v instanceof Float) {
+ statement.bindDouble(i, (Float) v);
+ } else if (v instanceof Long) {
+ statement.bindLong(i, (Long) v);
+ } else if (v instanceof Integer) {
+ statement.bindLong(i, (Integer) v);
+ } else if (v instanceof Byte) {
+ statement.bindLong(i, (Byte) v);
+ } else if (v instanceof byte[]) {
+ statement.bindBlob(i, (byte[]) v);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java
new file mode 100644
index 000000000..ff2f5238e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/FormHistoryProvider.java
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.lang.IllegalArgumentException;
+import java.util.HashMap;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.db.BrowserContract.FormHistory;
+import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.sqlite.SQLiteBridge;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+import android.text.TextUtils;
+
+public class FormHistoryProvider extends SQLiteBridgeContentProvider {
+ static final String TABLE_FORM_HISTORY = "moz_formhistory";
+ static final String TABLE_DELETED_FORM_HISTORY = "moz_deleted_formhistory";
+
+ private static final int FORM_HISTORY = 100;
+ private static final int DELETED_FORM_HISTORY = 101;
+
+ private static final UriMatcher URI_MATCHER;
+
+
+ // This should be kept in sync with the db version in toolkit/components/satchel/nsFormHistory.js
+ private static final int DB_VERSION = 4;
+ private static final String DB_FILENAME = "formhistory.sqlite";
+ private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_FORMS";
+
+ private static final String WHERE_GUID_IS_NULL = BrowserContract.DeletedFormHistory.GUID + " IS NULL";
+ private static final String WHERE_GUID_IS_VALUE = BrowserContract.DeletedFormHistory.GUID + " = ?";
+
+ private static final String LOG_TAG = "FormHistoryProvider";
+
+ static {
+ URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+ URI_MATCHER.addURI(BrowserContract.FORM_HISTORY_AUTHORITY, "formhistory", FORM_HISTORY);
+ URI_MATCHER.addURI(BrowserContract.FORM_HISTORY_AUTHORITY, "deleted-formhistory", DELETED_FORM_HISTORY);
+ }
+
+ public FormHistoryProvider() {
+ super(LOG_TAG);
+ }
+
+
+ @Override
+ public String getType(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ switch (match) {
+ case FORM_HISTORY:
+ return FormHistory.CONTENT_TYPE;
+
+ case DELETED_FORM_HISTORY:
+ return DeletedFormHistory.CONTENT_TYPE;
+
+ default:
+ throw new UnsupportedOperationException("Unknown type " + uri);
+ }
+ }
+
+ @Override
+ public String getTable(Uri uri) {
+ String table = null;
+ final int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case DELETED_FORM_HISTORY:
+ table = TABLE_DELETED_FORM_HISTORY;
+ break;
+
+ case FORM_HISTORY:
+ table = TABLE_FORM_HISTORY;
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown table " + uri);
+ }
+ return table;
+ }
+
+ @Override
+ public String getSortOrder(Uri uri, String aRequested) {
+ if (!TextUtils.isEmpty(aRequested)) {
+ return aRequested;
+ }
+
+ return null;
+ }
+
+ @Override
+ public void setupDefaults(Uri uri, ContentValues values) {
+ int match = URI_MATCHER.match(uri);
+ long now = System.currentTimeMillis();
+
+ switch (match) {
+ case DELETED_FORM_HISTORY:
+ values.put(DeletedFormHistory.TIME_DELETED, now);
+
+ // Deleted entries must contain a guid
+ if (!values.containsKey(FormHistory.GUID)) {
+ throw new IllegalArgumentException("Must provide a GUID for a deleted form history");
+ }
+ break;
+
+ case FORM_HISTORY:
+ // Generate GUID for new entry. Don't override specified GUIDs.
+ if (!values.containsKey(FormHistory.GUID)) {
+ String guid = Utils.generateGuid();
+ values.put(FormHistory.GUID, guid);
+ }
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown insert URI " + uri);
+ }
+ }
+
+ @Override
+ public void initGecko() {
+ GeckoAppShell.notifyObservers("FormHistory:Init", null);
+ }
+
+ @Override
+ public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) {
+ if (!values.containsKey(FormHistory.GUID)) {
+ return;
+ }
+
+ String guid = values.getAsString(FormHistory.GUID);
+ if (guid == null) {
+ db.delete(TABLE_DELETED_FORM_HISTORY, WHERE_GUID_IS_NULL, null);
+ return;
+ }
+ String[] args = new String[] { guid };
+ db.delete(TABLE_DELETED_FORM_HISTORY, WHERE_GUID_IS_VALUE, args);
+ }
+
+ @Override
+ public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) { }
+
+ @Override
+ public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) { }
+
+ @Override
+ protected String getDBName() {
+ return DB_FILENAME;
+ }
+
+ @Override
+ protected String getTelemetryPrefix() {
+ return TELEMETRY_TAG;
+ }
+
+ @Override
+ protected int getDBVersion() {
+ return DB_VERSION;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java
new file mode 100644
index 000000000..1a241f9da
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/HomeProvider.java
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.IOException;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.db.DBUtils;
+import org.mozilla.gecko.sqlite.SQLiteBridge;
+import org.mozilla.gecko.util.RawResource;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.util.Log;
+
+public class HomeProvider extends SQLiteBridgeContentProvider {
+ private static final String LOGTAG = "GeckoHomeProvider";
+
+ // This should be kept in sync with the db version in mobile/android/modules/HomeProvider.jsm
+ private static final int DB_VERSION = 3;
+ private static final String DB_FILENAME = "home.sqlite";
+ private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_HOME";
+
+ private static final String TABLE_ITEMS = "items";
+
+ // Endpoint to return static fake data.
+ static final int ITEMS_FAKE = 100;
+ static final int ITEMS = 101;
+ static final int ITEMS_ID = 102;
+
+ static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ static {
+ URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items/fake", ITEMS_FAKE);
+ URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items", ITEMS);
+ URI_MATCHER.addURI(BrowserContract.HOME_AUTHORITY, "items/#", ITEMS_ID);
+ }
+
+ public HomeProvider() {
+ super(LOGTAG);
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ switch (match) {
+ case ITEMS_FAKE: {
+ return HomeItems.CONTENT_TYPE;
+ }
+ case ITEMS: {
+ return HomeItems.CONTENT_TYPE;
+ }
+ default: {
+ throw new UnsupportedOperationException("Unknown type " + uri);
+ }
+ }
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ final int match = URI_MATCHER.match(uri);
+
+ // If we're querying the fake items, don't try to get the database.
+ if (match == ITEMS_FAKE) {
+ return queryFakeItems(uri, projection, selection, selectionArgs, sortOrder);
+ }
+
+ final String datasetId = uri.getQueryParameter(BrowserContract.PARAM_DATASET_ID);
+ if (datasetId == null) {
+ throw new IllegalArgumentException("All queries should contain a dataset ID parameter");
+ }
+
+ selection = DBUtils.concatenateWhere(selection, HomeItems.DATASET_ID + " = ?");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { datasetId });
+
+ // Otherwise, let the SQLiteContentProvider implementation take care of this query for us!
+ Cursor c = super.query(uri, projection, selection, selectionArgs, sortOrder);
+
+ // SQLiteBridgeContentProvider may return a null Cursor if the database hasn't been created yet.
+ // However, we need a non-null cursor in order to listen for notifications.
+ if (c == null) {
+ c = new MatrixCursor(projection != null ? projection : HomeItems.DEFAULT_PROJECTION);
+ }
+
+ final ContentResolver cr = getContext().getContentResolver();
+ c.setNotificationUri(cr, getDatasetNotificationUri(datasetId));
+
+ return c;
+ }
+
+ /**
+ * Returns a cursor populated with static fake data.
+ */
+ private Cursor queryFakeItems(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ JSONArray items = null;
+ try {
+ final String jsonString = RawResource.getAsString(getContext(), R.raw.fake_home_items);
+ items = new JSONArray(jsonString);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error getting fake home items", e);
+ return null;
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error parsing fake_home_items.json", e);
+ return null;
+ }
+
+ final MatrixCursor c = new MatrixCursor(HomeItems.DEFAULT_PROJECTION);
+ for (int i = 0; i < items.length(); i++) {
+ try {
+ final JSONObject item = items.getJSONObject(i);
+ c.addRow(new Object[] {
+ item.getInt("id"),
+ item.getString("dataset_id"),
+ item.getString("url"),
+ item.getString("title"),
+ item.getString("description"),
+ item.getString("image_url"),
+ item.getString("filter")
+ });
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating cursor row for fake home item", e);
+ }
+ }
+ return c;
+ }
+
+ /**
+ * SQLiteBridgeContentProvider implementation
+ */
+
+ @Override
+ protected String getDBName() {
+ return DB_FILENAME;
+ }
+
+ @Override
+ protected String getTelemetryPrefix() {
+ return TELEMETRY_TAG;
+ }
+
+ @Override
+ protected int getDBVersion() {
+ return DB_VERSION;
+ }
+
+ @Override
+ public String getTable(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case ITEMS: {
+ return TABLE_ITEMS;
+ }
+ default: {
+ throw new UnsupportedOperationException("Unknown table " + uri);
+ }
+ }
+ }
+
+ @Override
+ public String getSortOrder(Uri uri, String aRequested) {
+ return null;
+ }
+
+ @Override
+ public void setupDefaults(Uri uri, ContentValues values) { }
+
+ @Override
+ public void initGecko() { }
+
+ @Override
+ public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) { }
+
+ @Override
+ public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) { }
+
+ @Override
+ public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) { }
+
+ public static Uri getDatasetNotificationUri(String datasetId) {
+ return Uri.withAppendedPath(HomeItems.CONTENT_URI, datasetId);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
new file mode 100644
index 000000000..8c219282f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -0,0 +1,1938 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.lang.IllegalAccessException;
+import java.lang.NoSuchFieldException;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.ActivityStreamBlocklist;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.BrowserContract.ExpirePriority;
+import org.mozilla.gecko.db.BrowserContract.Favicons;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.SyncColumns;
+import org.mozilla.gecko.db.BrowserContract.Thumbnails;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.db.BrowserContract.Highlights;
+import org.mozilla.gecko.db.BrowserContract.PageMetadata;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.icons.decoders.FaviconDecoder;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.content.ContentProviderClient;
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MergeCursor;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.support.annotation.CheckResult;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.CursorLoader;
+import android.text.TextUtils;
+import android.util.Log;
+import org.mozilla.gecko.util.IOUtils;
+
+import static org.mozilla.gecko.util.IOUtils.ConsumedInputStream;
+
+public class LocalBrowserDB extends BrowserDB {
+ // The default size of the buffer to use for downloading Favicons in the event no size is given
+ // by the server.
+ public static final int DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000;
+
+ private static final String LOGTAG = "GeckoLocalBrowserDB";
+
+ // Calculate this once, at initialization. isLoggable is too expensive to
+ // have in-line in each log call.
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ protected static void debug(String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ // Sentinel value used to indicate a failure to locate an ID for a default favicon.
+ private static final int FAVICON_ID_NOT_FOUND = Integer.MIN_VALUE;
+
+ // Constant used to indicate that no folder was found for particular GUID.
+ private static final long FOLDER_NOT_FOUND = -1L;
+
+ private final String mProfile;
+
+ // Map of folder GUIDs to IDs. Used for caching.
+ private final HashMap<String, Long> mFolderIdMap;
+
+ // Use wrapped Boolean so that we can have a null state
+ private volatile Boolean mDesktopBookmarksExist;
+
+ private volatile SuggestedSites mSuggestedSites;
+
+ // Constants used when importing history data from legacy browser.
+ public static String HISTORY_VISITS_DATE = "date";
+ public static String HISTORY_VISITS_COUNT = "visits";
+ public static String HISTORY_VISITS_URL = "url";
+
+ private static final String TELEMETRY_HISTOGRAM_ACITIVITY_STREAM_TOPSITES = "FENNEC_ACTIVITY_STREAM_TOPSITES_LOADER_TIME_MS";
+
+ private final Uri mBookmarksUriWithProfile;
+ private final Uri mParentsUriWithProfile;
+ private final Uri mHistoryUriWithProfile;
+ private final Uri mHistoryExpireUriWithProfile;
+ private final Uri mCombinedUriWithProfile;
+ private final Uri mUpdateHistoryUriWithProfile;
+ private final Uri mFaviconsUriWithProfile;
+ private final Uri mThumbnailsUriWithProfile;
+ private final Uri mTopSitesUriWithProfile;
+ private final Uri mHighlightsUriWithProfile;
+ private final Uri mSearchHistoryUri;
+ private final Uri mActivityStreamBlockedUriWithProfile;
+ private final Uri mPageMetadataWithProfile;
+
+ private LocalSearches searches;
+ private LocalTabsAccessor tabsAccessor;
+ private LocalURLMetadata urlMetadata;
+ private LocalUrlAnnotations urlAnnotations;
+
+ private static final String[] DEFAULT_BOOKMARK_COLUMNS =
+ new String[] { Bookmarks._ID,
+ Bookmarks.GUID,
+ Bookmarks.URL,
+ Bookmarks.TITLE,
+ Bookmarks.TYPE,
+ Bookmarks.PARENT };
+
+ public LocalBrowserDB(String profile) {
+ mProfile = profile;
+ mFolderIdMap = new HashMap<String, Long>();
+
+ mBookmarksUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.CONTENT_URI);
+ mParentsUriWithProfile = DBUtils.appendProfile(profile, Bookmarks.PARENTS_CONTENT_URI);
+ mHistoryUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_URI);
+ mHistoryExpireUriWithProfile = DBUtils.appendProfile(profile, History.CONTENT_OLD_URI);
+ mCombinedUriWithProfile = DBUtils.appendProfile(profile, Combined.CONTENT_URI);
+ mFaviconsUriWithProfile = DBUtils.appendProfile(profile, Favicons.CONTENT_URI);
+ mTopSitesUriWithProfile = DBUtils.appendProfile(profile, TopSites.CONTENT_URI);
+ mHighlightsUriWithProfile = DBUtils.appendProfile(profile, Highlights.CONTENT_URI);
+ mThumbnailsUriWithProfile = DBUtils.appendProfile(profile, Thumbnails.CONTENT_URI);
+ mActivityStreamBlockedUriWithProfile = DBUtils.appendProfile(profile, ActivityStreamBlocklist.CONTENT_URI);
+
+ mPageMetadataWithProfile = DBUtils.appendProfile(profile, PageMetadata.CONTENT_URI);
+
+ mSearchHistoryUri = BrowserContract.SearchHistory.CONTENT_URI;
+
+ mUpdateHistoryUriWithProfile =
+ mHistoryUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS, "true")
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
+ .build();
+
+ searches = new LocalSearches(mProfile);
+ tabsAccessor = new LocalTabsAccessor(mProfile);
+ urlMetadata = new LocalURLMetadata(mProfile);
+ urlAnnotations = new LocalUrlAnnotations(mProfile);
+ }
+
+ @Override
+ public Searches getSearches() {
+ return searches;
+ }
+
+ @Override
+ public TabsAccessor getTabsAccessor() {
+ return tabsAccessor;
+ }
+
+ @Override
+ public URLMetadata getURLMetadata() {
+ return urlMetadata;
+ }
+
+ @RobocopTarget
+ @Override
+ public UrlAnnotations getUrlAnnotations() {
+ return urlAnnotations;
+ }
+
+ /**
+ * Not thread safe. A helper to allocate new IDs for arbitrary strings.
+ */
+ private static class NameCounter {
+ private final HashMap<String, Integer> names = new HashMap<String, Integer>();
+ private int counter;
+ private final int increment;
+
+ public NameCounter(int start, int increment) {
+ this.counter = start;
+ this.increment = increment;
+ }
+
+ public int get(final String name) {
+ Integer mapping = names.get(name);
+ if (mapping == null) {
+ int ours = counter;
+ counter += increment;
+ names.put(name, ours);
+ return ours;
+ }
+
+ return mapping;
+ }
+
+ public boolean has(final String name) {
+ return names.containsKey(name);
+ }
+ }
+
+ /**
+ * Add default bookmarks to the database.
+ * Takes an offset; returns a new offset.
+ */
+ @Override
+ public int addDefaultBookmarks(Context context, ContentResolver cr, final int offset) {
+ final long folderID = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
+ if (folderID == FOLDER_NOT_FOUND) {
+ Log.e(LOGTAG, "No mobile folder: cannot add default bookmarks.");
+ return offset;
+ }
+
+ // Use reflection to walk the set of bookmark defaults.
+ // This is horrible.
+ final Class<?> stringsClass = R.string.class;
+ final Field[] fields = stringsClass.getFields();
+ final Pattern p = Pattern.compile("^bookmarkdefaults_title_");
+
+ int pos = offset;
+ final long now = System.currentTimeMillis();
+
+ final ArrayList<ContentValues> bookmarkValues = new ArrayList<ContentValues>();
+ final ArrayList<ContentValues> faviconValues = new ArrayList<ContentValues>();
+
+ // Count down from -offset into negative values to get new favicon IDs.
+ final NameCounter faviconIDs = new NameCounter((-1 - offset), -1);
+
+ for (int i = 0; i < fields.length; i++) {
+ final String name = fields[i].getName();
+ final Matcher m = p.matcher(name);
+ if (!m.find()) {
+ continue;
+ }
+
+ try {
+ if (Restrictions.isRestrictedProfile(context)) {
+ // matching on variable name from strings.xml.in
+ final String addons = "bookmarkdefaults_title_addons";
+ final String regularSumo = "bookmarkdefaults_title_support";
+ if (name.equals(addons) || name.equals(regularSumo)) {
+ continue;
+ }
+ }
+ if (!Restrictions.isRestrictedProfile(context)) {
+ // if we're not in kidfox, skip the kidfox specific bookmark(s)
+ if (name.startsWith("bookmarkdefaults_title_restricted")) {
+ continue;
+ }
+ }
+ final int titleID = fields[i].getInt(null);
+ final String title = context.getString(titleID);
+
+ final Field urlField = stringsClass.getField(name.replace("_title_", "_url_"));
+ final int urlID = urlField.getInt(null);
+ final String url = context.getString(urlID);
+
+ final ContentValues bookmarkValue = createBookmark(now, title, url, pos++, folderID);
+ bookmarkValues.add(bookmarkValue);
+
+ ConsumedInputStream faviconStream = getDefaultFaviconFromDrawable(context, name);
+ if (faviconStream == null) {
+ faviconStream = getDefaultFaviconFromPath(context, name);
+ }
+
+ if (faviconStream == null) {
+ continue;
+ }
+
+ // In the event that truncating the buffer fails, give up and move on.
+ byte[] icon;
+ try {
+ icon = faviconStream.getTruncatedData();
+ } catch (OutOfMemoryError e) {
+ continue;
+ }
+
+ final ContentValues iconValue = createFavicon(url, icon);
+
+ // Assign a reserved negative _id to each new favicon.
+ // For now, each name is expected to be unique, and duplicate
+ // icons will be duplicated in the DB. See Bug 1040806 Comment 8.
+ if (iconValue != null) {
+ final int faviconID = faviconIDs.get(name);
+ iconValue.put("_id", faviconID);
+ bookmarkValue.put(Bookmarks.FAVICON_ID, faviconID);
+ faviconValues.add(iconValue);
+ }
+ } catch (IllegalAccessException | IllegalArgumentException | NoSuchFieldException e) {
+ Log.wtf(LOGTAG, "Reflection failure.", e);
+ }
+ }
+
+ if (!faviconValues.isEmpty()) {
+ try {
+ cr.bulkInsert(mFaviconsUriWithProfile, faviconValues.toArray(new ContentValues[faviconValues.size()]));
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error bulk-inserting default favicons.", e);
+ }
+ }
+
+ if (!bookmarkValues.isEmpty()) {
+ try {
+ final int inserted = cr.bulkInsert(mBookmarksUriWithProfile, bookmarkValues.toArray(new ContentValues[bookmarkValues.size()]));
+ return offset + inserted;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error bulk-inserting default bookmarks.", e);
+ }
+ }
+
+ return offset;
+ }
+
+ /**
+ * Add bookmarks from the provided distribution.
+ * Takes an offset; returns a new offset.
+ */
+ @Override
+ public int addDistributionBookmarks(ContentResolver cr, Distribution distribution, int offset) {
+ if (!distribution.exists()) {
+ Log.d(LOGTAG, "No distribution from which to add bookmarks.");
+ return offset;
+ }
+
+ final JSONArray bookmarks = distribution.getBookmarks();
+ if (bookmarks == null) {
+ Log.d(LOGTAG, "No distribution bookmarks.");
+ return offset;
+ }
+
+ final long folderID = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
+ if (folderID == FOLDER_NOT_FOUND) {
+ Log.e(LOGTAG, "No mobile folder: cannot add distribution bookmarks.");
+ return offset;
+ }
+
+ final Locale locale = Locale.getDefault();
+ final long now = System.currentTimeMillis();
+ int mobilePos = offset;
+ int pinnedPos = 0; // Assume nobody has pinned anything yet.
+
+ final ArrayList<ContentValues> bookmarkValues = new ArrayList<ContentValues>();
+ final ArrayList<ContentValues> faviconValues = new ArrayList<ContentValues>();
+
+ // Count down from -offset into negative values to get new favicon IDs.
+ final NameCounter faviconIDs = new NameCounter((-1 - offset), -1);
+
+ for (int i = 0; i < bookmarks.length(); i++) {
+ try {
+ final JSONObject bookmark = bookmarks.getJSONObject(i);
+
+ final String title = getLocalizedProperty(bookmark, "title", locale);
+ final String url = getLocalizedProperty(bookmark, "url", locale);
+ final long parent;
+ final int pos;
+ if (bookmark.has("pinned")) {
+ parent = Bookmarks.FIXED_PINNED_LIST_ID;
+ pos = pinnedPos++;
+ } else {
+ parent = folderID;
+ pos = mobilePos++;
+ }
+
+ final ContentValues bookmarkValue = createBookmark(now, title, url, pos, parent);
+ bookmarkValues.add(bookmarkValue);
+
+ // Return early if there is no icon for this bookmark.
+ if (!bookmark.has("icon")) {
+ continue;
+ }
+
+ try {
+ final String iconData = bookmark.getString("icon");
+
+ byte[] icon = BitmapUtils.getBytesFromDataURI(iconData);
+ if (icon == null) {
+ continue;
+ }
+
+ final ContentValues iconValue = createFavicon(url, icon);
+ if (iconValue == null) {
+ continue;
+ }
+
+ /*
+ * Find out if this icon is a duplicate. If it is, don't try
+ * to insert it again, but reuse the shared ID.
+ * Otherwise, assign a new reserved negative _id.
+ * Duplicates won't be detected in default bookmarks, or
+ * those already in the database.
+ */
+ final boolean seen = faviconIDs.has(iconData);
+ final int faviconID = faviconIDs.get(iconData);
+
+ iconValue.put("_id", faviconID);
+ bookmarkValue.put(Bookmarks.FAVICON_ID, faviconID);
+
+ if (!seen) {
+ faviconValues.add(iconValue);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating distribution bookmark icon.", e);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating distribution bookmark.", e);
+ }
+ }
+
+ if (!faviconValues.isEmpty()) {
+ try {
+ cr.bulkInsert(mFaviconsUriWithProfile, faviconValues.toArray(new ContentValues[faviconValues.size()]));
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error bulk-inserting distribution favicons.", e);
+ }
+ }
+
+ if (!bookmarkValues.isEmpty()) {
+ try {
+ final int inserted = cr.bulkInsert(mBookmarksUriWithProfile, bookmarkValues.toArray(new ContentValues[bookmarkValues.size()]));
+ return offset + inserted;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error bulk-inserting distribution bookmarks.", e);
+ }
+ }
+
+ return offset;
+ }
+
+ private static ContentValues createBookmark(final long timestamp, final String title, final String url, final int pos, final long parent) {
+ final ContentValues v = new ContentValues();
+
+ v.put(Bookmarks.DATE_CREATED, timestamp);
+ v.put(Bookmarks.DATE_MODIFIED, timestamp);
+ v.put(Bookmarks.GUID, Utils.generateGuid());
+
+ v.put(Bookmarks.PARENT, parent);
+ v.put(Bookmarks.POSITION, pos);
+ v.put(Bookmarks.TITLE, title);
+ v.put(Bookmarks.URL, url);
+ return v;
+ }
+
+ private static ContentValues createFavicon(final String url, final byte[] icon) {
+ ContentValues iconValues = new ContentValues();
+ iconValues.put(Favicons.PAGE_URL, url);
+ iconValues.put(Favicons.DATA, icon);
+
+ return iconValues;
+ }
+
+ private static String getLocalizedProperty(final JSONObject bookmark, final String property, final Locale locale) throws JSONException {
+ // Try the full locale.
+ final String fullLocale = property + "." + locale.toString();
+ if (bookmark.has(fullLocale)) {
+ return bookmark.getString(fullLocale);
+ }
+
+ // Try without a variant.
+ if (!TextUtils.isEmpty(locale.getVariant())) {
+ String noVariant = fullLocale.substring(0, fullLocale.lastIndexOf("_"));
+ if (bookmark.has(noVariant)) {
+ return bookmark.getString(noVariant);
+ }
+ }
+
+ // Try just the language.
+ String lang = property + "." + locale.getLanguage();
+ if (bookmark.has(lang)) {
+ return bookmark.getString(lang);
+ }
+
+ // Default to the non-localized property name.
+ return bookmark.getString(property);
+ }
+
+ private static int getFaviconId(String name) {
+ try {
+ Class<?> drawablesClass = R.raw.class;
+
+ // Look for a favicon with the id R.raw.bookmarkdefaults_favicon_*.
+ Field faviconField = drawablesClass.getField(name.replace("_title_", "_favicon_"));
+ faviconField.setAccessible(true);
+
+ return faviconField.getInt(null);
+ } catch (IllegalAccessException | NoSuchFieldException e) {
+ // We'll end up here for any default bookmark that doesn't have a favicon in
+ // resources/raw/ (i.e., about:firefox). When this happens, the Favicons service will
+ // fall back to the default branding icon for about pages. Non-about pages should always
+ // specify an icon; otherwise, the placeholder globe favicon will be used.
+ Log.d(LOGTAG, "No raw favicon resource found for " + name);
+ }
+
+ Log.e(LOGTAG, "Failed to find favicon resource ID for " + name);
+ return FAVICON_ID_NOT_FOUND;
+ }
+
+ @Override
+ public boolean insertPageMetadata(ContentProviderClient contentProviderClient, String pageUrl, boolean hasImage, String metadataJSON) {
+ final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl);
+
+ if (historyGUID == null) {
+ return false;
+ }
+
+ // We have the GUID, insert the metadata.
+ final ContentValues cv = new ContentValues();
+ cv.put(PageMetadata.HISTORY_GUID, historyGUID);
+ cv.put(PageMetadata.HAS_IMAGE, hasImage);
+ cv.put(PageMetadata.JSON, metadataJSON);
+
+ try {
+ contentProviderClient.insert(mPageMetadataWithProfile, cv);
+ } catch (RemoteException e) {
+ throw new IllegalStateException("Unexpected RemoteException", e);
+ }
+
+ return true;
+ }
+
+ @Override
+ public int deletePageMetadata(ContentProviderClient contentProviderClient, String pageUrl) {
+ final String historyGUID = lookupHistoryGUIDByPageUri(contentProviderClient, pageUrl);
+
+ if (historyGUID == null) {
+ return 0;
+ }
+
+ try {
+ return contentProviderClient.delete(mPageMetadataWithProfile, PageMetadata.HISTORY_GUID + " = ?", new String[]{historyGUID});
+ } catch (RemoteException e) {
+ throw new IllegalStateException("Unexpected RemoteException", e);
+ }
+ }
+
+ @Nullable
+ private String lookupHistoryGUIDByPageUri(ContentProviderClient contentProviderClient, String uri) {
+ // Unfortunately we might have duplicate history records for the same URL.
+ final Cursor cursor;
+ try {
+ cursor = contentProviderClient.query(
+ mHistoryUriWithProfile
+ .buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT, "1")
+ .build(),
+ new String[]{
+ History.GUID,
+ },
+ History.URL + "= ?",
+ new String[]{uri}, History.DATE_LAST_VISITED + " DESC"
+ );
+ } catch (RemoteException e) {
+ // Won't happen, we control the implementation.
+ throw new IllegalStateException("Unexpected RemoteException", e);
+ }
+
+ if (cursor == null) {
+ return null;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+
+ final int historyGUIDCol = cursor.getColumnIndexOrThrow(History.GUID);
+ return cursor.getString(historyGUIDCol);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Load a favicon from the omnijar.
+ * @return A ConsumedInputStream containing the bytes loaded from omnijar. This must be a format
+ * compatible with the favicon decoder (most probably a PNG or ICO file).
+ */
+ private static ConsumedInputStream getDefaultFaviconFromPath(Context context, String name) {
+ final int faviconId = getFaviconId(name);
+ if (faviconId == FAVICON_ID_NOT_FOUND) {
+ return null;
+ }
+
+ final String bitmapPath = GeckoJarReader.getJarURL(context, context.getString(faviconId));
+ final InputStream iStream = GeckoJarReader.getStream(context, bitmapPath);
+
+ return IOUtils.readFully(iStream, DEFAULT_FAVICON_BUFFER_SIZE_BYTES);
+ }
+
+ private static ConsumedInputStream getDefaultFaviconFromDrawable(Context context, String name) {
+ int faviconId = getFaviconId(name);
+ if (faviconId == FAVICON_ID_NOT_FOUND) {
+ return null;
+ }
+
+ InputStream iStream = context.getResources().openRawResource(faviconId);
+ return IOUtils.readFully(iStream, DEFAULT_FAVICON_BUFFER_SIZE_BYTES);
+ }
+
+ // Invalidate cached data
+ @Override
+ public void invalidate() {
+ mDesktopBookmarksExist = null;
+ }
+
+ private Uri bookmarksUriWithLimit(int limit) {
+ return mBookmarksUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(limit))
+ .build();
+ }
+
+ private Uri combinedUriWithLimit(int limit) {
+ return mCombinedUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(limit))
+ .build();
+ }
+
+ private static Uri withDeleted(final Uri uri) {
+ return uri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1")
+ .build();
+ }
+
+ private Cursor filterAllSites(ContentResolver cr, String[] projection, CharSequence constraint,
+ int limit, CharSequence urlFilter, String selection, String[] selectionArgs) {
+ // The combined history/bookmarks selection queries for sites with a URL or title containing
+ // the constraint string(s), treating space-separated words as separate constraints
+ if (!TextUtils.isEmpty(constraint)) {
+ final String[] constraintWords = constraint.toString().split(" ");
+
+ // Only create a filter query with a maximum of 10 constraint words.
+ final int constraintCount = Math.min(constraintWords.length, 10);
+ for (int i = 0; i < constraintCount; i++) {
+ selection = DBUtils.concatenateWhere(selection, "(" + Combined.URL + " LIKE ? OR " +
+ Combined.TITLE + " LIKE ?)");
+ String constraintWord = "%" + constraintWords[i] + "%";
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { constraintWord, constraintWord });
+ }
+ }
+
+ if (urlFilter != null) {
+ selection = DBUtils.concatenateWhere(selection, "(" + Combined.URL + " NOT LIKE ?)");
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { urlFilter.toString() });
+ }
+
+ // Order by combined remote+local frecency score.
+ // Local visits are preferred, so they will by far outweigh remote visits.
+ // Bookmarked history items get extra frecency points.
+ final String sortOrder = BrowserContract.getCombinedFrecencySortOrder(true, false);
+
+ return cr.query(combinedUriWithLimit(limit),
+ projection,
+ selection,
+ selectionArgs,
+ sortOrder);
+ }
+
+ @Override
+ public int getCount(ContentResolver cr, String database) {
+ int count = 0;
+ String[] columns = null;
+ String constraint = null;
+ Uri uri = null;
+
+ if ("history".equals(database)) {
+ uri = mHistoryUriWithProfile;
+ columns = new String[] { History._ID };
+ constraint = Combined.VISITS + " > 0";
+ } else if ("bookmarks".equals(database)) {
+ uri = mBookmarksUriWithProfile;
+ columns = new String[] { Bookmarks._ID };
+ // ignore folders, tags, keywords, separators, etc.
+ constraint = Bookmarks.TYPE + " = " + Bookmarks.TYPE_BOOKMARK;
+ } else if ("thumbnails".equals(database)) {
+ uri = mThumbnailsUriWithProfile;
+ columns = new String[] { Thumbnails._ID };
+ } else if ("favicons".equals(database)) {
+ uri = mFaviconsUriWithProfile;
+ columns = new String[] { Favicons._ID };
+ }
+
+ if (uri != null) {
+ final Cursor cursor = cr.query(uri, columns, constraint, null, null);
+
+ try {
+ count = cursor.getCount();
+ } finally {
+ cursor.close();
+ }
+ }
+
+ debug("Got count " + count + " for " + database);
+ return count;
+ }
+
+ @Override
+ @RobocopTarget
+ public Cursor filter(ContentResolver cr, CharSequence constraint, int limit,
+ EnumSet<FilterFlags> flags) {
+ String selection = "";
+ String[] selectionArgs = null;
+
+ if (flags.contains(FilterFlags.EXCLUDE_PINNED_SITES)) {
+ selection = Combined.URL + " NOT IN (SELECT " +
+ Bookmarks.URL + " FROM bookmarks WHERE " +
+ DBUtils.qualifyColumn("bookmarks", Bookmarks.PARENT) + " = ? AND " +
+ DBUtils.qualifyColumn("bookmarks", Bookmarks.IS_DELETED) + " == 0)";
+ selectionArgs = new String[] { String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) };
+ }
+
+ return filterAllSites(cr,
+ new String[] { Combined._ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID },
+ constraint,
+ limit,
+ null,
+ selection, selectionArgs);
+ }
+
+ @Override
+ public void updateVisitedHistory(ContentResolver cr, String uri) {
+ ContentValues values = new ContentValues();
+
+ values.put(History.URL, uri);
+ values.put(History.DATE_LAST_VISITED, System.currentTimeMillis());
+ values.put(History.IS_DELETED, 0);
+
+ // This will insert a new history entry if one for this URL
+ // doesn't already exist
+ cr.update(mUpdateHistoryUriWithProfile,
+ values,
+ History.URL + " = ?",
+ new String[] { uri });
+ }
+
+ @Override
+ public void updateHistoryTitle(ContentResolver cr, String uri, String title) {
+ ContentValues values = new ContentValues();
+ values.put(History.TITLE, title);
+
+ cr.update(mHistoryUriWithProfile,
+ values,
+ History.URL + " = ?",
+ new String[] { uri });
+ }
+
+ @Override
+ @RobocopTarget
+ public Cursor getAllVisitedHistory(ContentResolver cr) {
+ return cr.query(mHistoryUriWithProfile,
+ new String[] { History.URL },
+ History.VISITS + " > 0",
+ null,
+ null);
+ }
+
+ @Override
+ public Cursor getRecentHistory(ContentResolver cr, int limit) {
+ return cr.query(combinedUriWithLimit(limit),
+ new String[] { Combined._ID,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.DATE_LAST_VISITED,
+ Combined.VISITS },
+ History.DATE_LAST_VISITED + " > 0",
+ null,
+ History.DATE_LAST_VISITED + " DESC");
+ }
+
+ @Override
+ public Cursor getRecentHistoryBetweenTime(ContentResolver cr, int limit, long start, long end) {
+ return cr.query(combinedUriWithLimit(limit),
+ new String[] { Combined._ID,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.DATE_LAST_VISITED,
+ Combined.VISITS },
+ History.DATE_LAST_VISITED + " >= " + start + " AND " + History.DATE_LAST_VISITED + " < " + end,
+ null,
+ History.DATE_LAST_VISITED + " DESC");
+ }
+
+ public Cursor getHistoryForURL(ContentResolver cr, String uri) {
+ return cr.query(mHistoryUriWithProfile,
+ new String[] {
+ History.VISITS,
+ History.DATE_LAST_VISITED
+ },
+ History.URL + "= ?",
+ new String[] { uri },
+ History.DATE_LAST_VISITED + " DESC"
+ );
+ }
+
+ @Override
+ public long getPrePathLastVisitedTimeMilliseconds(ContentResolver cr, String prePath) {
+ if (prePath == null) {
+ return 0;
+ }
+ // If we don't end with a trailing slash, then both https://foo.com and https://foo.company.biz will match.
+ if (!prePath.endsWith("/")) {
+ prePath = prePath + "/";
+ }
+ final Cursor cursor = cr.query(BrowserContract.History.CONTENT_URI,
+ new String[] { "MAX(" + BrowserContract.HistoryColumns.DATE_LAST_VISITED + ") AS date" },
+ BrowserContract.URLColumns.URL + " BETWEEN ? AND ?", new String[] { prePath, prePath + "\u007f" }, null);
+ try {
+ cursor.moveToFirst();
+ if (cursor.isAfterLast()) {
+ return 0;
+ }
+ return cursor.getLong(0);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Override
+ public void expireHistory(ContentResolver cr, ExpirePriority priority) {
+ Uri url = mHistoryExpireUriWithProfile;
+ url = url.buildUpon().appendQueryParameter(BrowserContract.PARAM_EXPIRE_PRIORITY, priority.toString()).build();
+ cr.delete(url, null, null);
+ }
+
+ @Override
+ @RobocopTarget
+ public void removeHistoryEntry(ContentResolver cr, String url) {
+ cr.delete(mHistoryUriWithProfile,
+ History.URL + " = ?",
+ new String[] { url });
+ }
+
+ @Override
+ public void clearHistory(ContentResolver cr, boolean clearSearchHistory) {
+ if (clearSearchHistory) {
+ cr.delete(mSearchHistoryUri, null, null);
+ } else {
+ cr.delete(mHistoryUriWithProfile, null, null);
+ }
+ }
+
+ private void assertDefaultBookmarkColumnOrdering() {
+ // We need to insert MatrixCursor values in a specific order - in order to protect against changes
+ // in DEFAULT_BOOKMARK_COLUMNS we can just assert that we're using the correct ordering.
+ // Alternatively we could use RowBuilder.add(columnName, value) but that needs api >= 19,
+ // or we could iterate over DEFAULT_BOOKMARK_COLUMNS, but that gets messy once we need
+ // to add more than one artificial folder.
+ if (!((DEFAULT_BOOKMARK_COLUMNS[0].equals(Bookmarks._ID)) &&
+ (DEFAULT_BOOKMARK_COLUMNS[1].equals(Bookmarks.GUID)) &&
+ (DEFAULT_BOOKMARK_COLUMNS[2].equals(Bookmarks.URL)) &&
+ (DEFAULT_BOOKMARK_COLUMNS[3].equals(Bookmarks.TITLE)) &&
+ (DEFAULT_BOOKMARK_COLUMNS[4].equals(Bookmarks.TYPE)) &&
+ (DEFAULT_BOOKMARK_COLUMNS[5].equals(Bookmarks.PARENT)) &&
+ (DEFAULT_BOOKMARK_COLUMNS.length == 6))) {
+ // If DEFAULT_BOOKMARK_COLUMNS changes we need to update all the MatrixCursor rows
+ // to contain appropriate data.
+ throw new IllegalStateException("Fake folder MatrixCursor creation code must be updated to match DEFAULT_BOOKMARK_COLUMNS");
+ }
+ }
+
+ /**
+ * Retrieve the list of reader-view bookmarks, i.e. the equivalent of the former reading-list.
+ * This is the result of a join of bookmarks with reader-view annotations (as stored in
+ * UrlAnnotations).
+ */
+ private Cursor getReadingListBookmarks(ContentResolver cr) {
+ // group by URL to avoid having duplicate bookmarks listed. It's possible to have multiple
+ // bookmarks pointing to the same URL (this would most commonly happen by manually
+ // copying bookmarks on desktop, followed by syncing with mobile), and we don't want
+ // to show the same URL multiple times in the reading list folder.
+ final Uri bookmarksGroupedByUri = mBookmarksUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_GROUP_BY, Bookmarks.URL)
+ .build();
+
+ return cr.query(bookmarksGroupedByUri,
+ DEFAULT_BOOKMARK_COLUMNS,
+ Bookmarks.ANNOTATION_KEY + " == ? AND " +
+ Bookmarks.ANNOTATION_VALUE + " == ? AND " +
+ "(" + Bookmarks.TYPE + " = ? AND " + Bookmarks.URL + " IS NOT NULL)",
+ new String[] {
+ BrowserContract.UrlAnnotations.Key.READER_VIEW.getDbValue(),
+ BrowserContract.UrlAnnotations.READER_VIEW_SAVED_VALUE,
+ String.valueOf(Bookmarks.TYPE_BOOKMARK) },
+ null);
+ }
+
+ @Override
+ @RobocopTarget
+ public Cursor getBookmarksInFolder(ContentResolver cr, long folderId) {
+ final boolean addDesktopFolder;
+ final boolean addScreenshotsFolder;
+ final boolean addReadingListFolder;
+
+ // We always want to show mobile bookmarks in the root view.
+ if (folderId == Bookmarks.FIXED_ROOT_ID) {
+ folderId = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
+
+ // We'll add a fake "Desktop Bookmarks" folder to the root view if desktop
+ // bookmarks exist, so that the user can still access non-mobile bookmarks.
+ addDesktopFolder = desktopBookmarksExist(cr);
+ addScreenshotsFolder = AppConstants.SCREENSHOTS_IN_BOOKMARKS_ENABLED;
+
+ final int readingListItemCount = getBookmarkCountForFolder(cr, Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID);
+ addReadingListFolder = (readingListItemCount > 0);
+ } else {
+ addDesktopFolder = false;
+ addScreenshotsFolder = false;
+ addReadingListFolder = false;
+ }
+
+ final Cursor c;
+
+ // (You can't switch on a long in Java, hence the if statements)
+ if (folderId == Bookmarks.FAKE_DESKTOP_FOLDER_ID) {
+ // Since the "Desktop Bookmarks" folder doesn't actually exist, we
+ // just fake it by querying specifically certain known desktop folders.
+ c = cr.query(mBookmarksUriWithProfile,
+ DEFAULT_BOOKMARK_COLUMNS,
+ Bookmarks.GUID + " = ? OR " +
+ Bookmarks.GUID + " = ? OR " +
+ Bookmarks.GUID + " = ?",
+ new String[] { Bookmarks.TOOLBAR_FOLDER_GUID,
+ Bookmarks.MENU_FOLDER_GUID,
+ Bookmarks.UNFILED_FOLDER_GUID },
+ null);
+ } else if (folderId == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) {
+ c = getUrlAnnotations().getScreenshots(cr);
+ } else if (folderId == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ c = getReadingListBookmarks(cr);
+ } else {
+ // Right now, we only support showing folder and bookmark type of
+ // entries. We should add support for other types though (bug 737024)
+ c = cr.query(mBookmarksUriWithProfile,
+ DEFAULT_BOOKMARK_COLUMNS,
+ Bookmarks.PARENT + " = ? AND " +
+ "(" + Bookmarks.TYPE + " = ? OR " +
+ "(" + Bookmarks.TYPE + " = ? AND " + Bookmarks.URL + " IS NOT NULL))",
+ new String[] { String.valueOf(folderId),
+ String.valueOf(Bookmarks.TYPE_FOLDER),
+ String.valueOf(Bookmarks.TYPE_BOOKMARK) },
+ null);
+ }
+
+ final List<Cursor> cursorsToMerge = getSpecialFoldersCursorList(addDesktopFolder, addScreenshotsFolder, addReadingListFolder);
+ if (cursorsToMerge.size() >= 1) {
+ cursorsToMerge.add(c);
+ final Cursor[] arr = (Cursor[]) Array.newInstance(Cursor.class, cursorsToMerge.size());
+ return new MergeCursor(cursorsToMerge.toArray(arr));
+ } else {
+ return c;
+ }
+ }
+
+ @Override
+ public int getBookmarkCountForFolder(ContentResolver cr, long folderID) {
+ if (folderID == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ return getUrlAnnotations().getAnnotationCount(cr, BrowserContract.UrlAnnotations.Key.READER_VIEW);
+ } else {
+ throw new IllegalArgumentException("Retrieving bookmark count for folder with ID=" + folderID + " not supported yet");
+ }
+ }
+
+ @CheckResult
+ private ArrayList<Cursor> getSpecialFoldersCursorList(final boolean addDesktopFolder,
+ final boolean addScreenshotsFolder, final boolean addReadingListFolder) {
+ if (addDesktopFolder || addScreenshotsFolder || addReadingListFolder) {
+ // Avoid calling this twice.
+ assertDefaultBookmarkColumnOrdering();
+ }
+
+ // Capacity is number of cursors added below plus one for non-special data.
+ final ArrayList<Cursor> out = new ArrayList<>(4);
+ if (addDesktopFolder) {
+ out.add(getSpecialFolderCursor(Bookmarks.FAKE_DESKTOP_FOLDER_ID, Bookmarks.FAKE_DESKTOP_FOLDER_GUID));
+ }
+
+ if (addScreenshotsFolder) {
+ out.add(getSpecialFolderCursor(Bookmarks.FIXED_SCREENSHOT_FOLDER_ID, Bookmarks.SCREENSHOT_FOLDER_GUID));
+ }
+
+ if (addReadingListFolder) {
+ out.add(getSpecialFolderCursor(Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID, Bookmarks.FAKE_READINGLIST_SMARTFOLDER_GUID));
+ }
+
+ return out;
+ }
+
+ @CheckResult
+ private MatrixCursor getSpecialFolderCursor(final int folderId, final String folderGuid) {
+ final MatrixCursor out = new MatrixCursor(DEFAULT_BOOKMARK_COLUMNS);
+ out.addRow(new Object[] {
+ folderId,
+ folderGuid,
+ "",
+ "", // Title localisation is done later, in the UI layer (BookmarksListAdapter)
+ Bookmarks.TYPE_FOLDER,
+ Bookmarks.FIXED_ROOT_ID
+ });
+ return out;
+ }
+
+ // Returns true if any desktop bookmarks exist, which will be true if the user
+ // has set up sync at one point, or done a profile migration from XUL fennec.
+ private boolean desktopBookmarksExist(ContentResolver cr) {
+ if (mDesktopBookmarksExist != null) {
+ return mDesktopBookmarksExist;
+ }
+
+ // Check to see if there are any bookmarks in one of our three
+ // fixed "Desktop Bookmarks" folders.
+ final Cursor c = cr.query(bookmarksUriWithLimit(1),
+ new String[] { Bookmarks._ID },
+ Bookmarks.PARENT + " = ? OR " +
+ Bookmarks.PARENT + " = ? OR " +
+ Bookmarks.PARENT + " = ?",
+ new String[] { String.valueOf(getFolderIdFromGuid(cr, Bookmarks.TOOLBAR_FOLDER_GUID)),
+ String.valueOf(getFolderIdFromGuid(cr, Bookmarks.MENU_FOLDER_GUID)),
+ String.valueOf(getFolderIdFromGuid(cr, Bookmarks.UNFILED_FOLDER_GUID)) },
+ null);
+
+ try {
+ // Don't read back out of the cache to avoid races with invalidation.
+ final boolean e = c.getCount() > 0;
+ mDesktopBookmarksExist = e;
+ return e;
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ @RobocopTarget
+ public boolean isBookmark(ContentResolver cr, String uri) {
+ final Cursor c = cr.query(bookmarksUriWithLimit(1),
+ new String[] { Bookmarks._ID },
+ Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ?",
+ new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) },
+ Bookmarks.URL);
+
+ if (c == null) {
+ Log.e(LOGTAG, "Null cursor in isBookmark");
+ return false;
+ }
+
+ try {
+ return c.getCount() > 0;
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ public String getUrlForKeyword(ContentResolver cr, String keyword) {
+ final Cursor c = cr.query(mBookmarksUriWithProfile,
+ new String[] { Bookmarks.URL },
+ Bookmarks.KEYWORD + " = ?",
+ new String[] { keyword },
+ null);
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.URL));
+ } finally {
+ c.close();
+ }
+ }
+
+ private synchronized long getFolderIdFromGuid(final ContentResolver cr, final String guid) {
+ if (mFolderIdMap.containsKey(guid)) {
+ return mFolderIdMap.get(guid);
+ }
+
+ final Cursor c = cr.query(mBookmarksUriWithProfile,
+ new String[] { Bookmarks._ID },
+ Bookmarks.GUID + " = ?",
+ new String[] { guid },
+ null);
+ try {
+ final int col = c.getColumnIndexOrThrow(Bookmarks._ID);
+ if (!c.moveToFirst() || c.isNull(col)) {
+ return FOLDER_NOT_FOUND;
+ }
+
+ final long id = c.getLong(col);
+ mFolderIdMap.put(guid, id);
+ return id;
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Find parents of records that match the provided criteria, and bump their
+ * modified timestamp.
+ */
+ protected void bumpParents(ContentResolver cr, String param, String value) {
+ ContentValues values = new ContentValues();
+ values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
+
+ String where = param + " = ?";
+ String[] args = new String[] { value };
+ int updated = cr.update(mParentsUriWithProfile, values, where, args);
+ debug("Updated " + updated + " rows to new modified time.");
+ }
+
+ private void addBookmarkItem(ContentResolver cr, String title, String uri, long folderId) {
+ final long now = System.currentTimeMillis();
+ ContentValues values = new ContentValues();
+ if (title != null) {
+ values.put(Bookmarks.TITLE, title);
+ }
+
+ values.put(Bookmarks.URL, uri);
+ values.put(Bookmarks.PARENT, folderId);
+ values.put(Bookmarks.DATE_MODIFIED, now);
+
+ // Get the page's favicon ID from the history table
+ final Cursor c = cr.query(mHistoryUriWithProfile,
+ new String[] { History.FAVICON_ID },
+ History.URL + " = ?",
+ new String[] { uri },
+ null);
+ try {
+ if (c.moveToFirst()) {
+ int columnIndex = c.getColumnIndexOrThrow(History.FAVICON_ID);
+ if (!c.isNull(columnIndex)) {
+ values.put(Bookmarks.FAVICON_ID, c.getLong(columnIndex));
+ }
+ }
+ } finally {
+ c.close();
+ }
+
+ // Restore deleted record if possible
+ values.put(Bookmarks.IS_DELETED, 0);
+
+ final Uri bookmarksWithInsert = mBookmarksUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
+ .build();
+ cr.update(bookmarksWithInsert,
+ values,
+ Bookmarks.URL + " = ? AND " +
+ Bookmarks.PARENT + " = " + folderId,
+ new String[] { uri });
+
+ // Bump parent modified time using its ID.
+ debug("Bumping parent modified time for addition to: " + folderId);
+ final String where = Bookmarks._ID + " = ?";
+ final String[] args = new String[] { String.valueOf(folderId) };
+
+ ContentValues bumped = new ContentValues();
+ bumped.put(Bookmarks.DATE_MODIFIED, now);
+
+ final int updated = cr.update(mBookmarksUriWithProfile, bumped, where, args);
+ debug("Updated " + updated + " rows to new modified time.");
+ }
+
+ @Override
+ @RobocopTarget
+ public boolean addBookmark(ContentResolver cr, String title, String uri) {
+ long folderId = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
+ if (isBookmarkForUrlInFolder(cr, uri, folderId)) {
+ // Bookmark added already.
+ return false;
+ }
+
+ // Add a new bookmark.
+ addBookmarkItem(cr, title, uri, folderId);
+ return true;
+ }
+
+ private boolean isBookmarkForUrlInFolder(ContentResolver cr, String uri, long folderId) {
+ final Cursor c = cr.query(bookmarksUriWithLimit(1),
+ new String[] { Bookmarks._ID },
+ Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " = ? AND " + Bookmarks.IS_DELETED + " == 0",
+ new String[] { uri, String.valueOf(folderId) },
+ Bookmarks.URL);
+
+ if (c == null) {
+ return false;
+ }
+
+ try {
+ return c.getCount() > 0;
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ @RobocopTarget
+ public void removeBookmarksWithURL(ContentResolver cr, String uri) {
+ Uri contentUri = mBookmarksUriWithProfile;
+
+ // Do this now so that the items still exist!
+ bumpParents(cr, Bookmarks.URL, uri);
+
+ final String[] urlArgs = new String[] { uri, String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) };
+ final String urlEquals = Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " != ? ";
+
+ cr.delete(contentUri, urlEquals, urlArgs);
+ }
+
+ @Override
+ public void registerBookmarkObserver(ContentResolver cr, ContentObserver observer) {
+ cr.registerContentObserver(mBookmarksUriWithProfile, false, observer);
+ }
+
+ @Override
+ @RobocopTarget
+ public void updateBookmark(ContentResolver cr, int id, String uri, String title, String keyword) {
+ ContentValues values = new ContentValues();
+ values.put(Bookmarks.TITLE, title);
+ values.put(Bookmarks.URL, uri);
+ values.put(Bookmarks.KEYWORD, keyword);
+ values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis());
+
+ cr.update(mBookmarksUriWithProfile,
+ values,
+ Bookmarks._ID + " = ?",
+ new String[] { String.valueOf(id) });
+ }
+
+ @Override
+ public boolean hasBookmarkWithGuid(ContentResolver cr, String guid) {
+ Cursor c = cr.query(bookmarksUriWithLimit(1),
+ new String[] { Bookmarks.GUID },
+ Bookmarks.GUID + " = ?",
+ new String[] { guid },
+ null);
+
+ try {
+ return c != null && c.getCount() > 0;
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+
+ /**
+ * Get the favicon from the database, if any, associated with the given favicon URL. (That is,
+ * the URL of the actual favicon image, not the URL of the page with which the favicon is associated.)
+ * @param cr The ContentResolver to use.
+ * @param faviconURL The URL of the favicon to fetch from the database.
+ * @return The decoded Bitmap from the database, if any. null if none is stored.
+ */
+ @Override
+ public LoadFaviconResult getFaviconForUrl(Context context, ContentResolver cr, String faviconURL) {
+ final Cursor c = cr.query(mFaviconsUriWithProfile,
+ new String[] { Favicons.DATA },
+ Favicons.URL + " = ? AND " + Favicons.DATA + " IS NOT NULL",
+ new String[] { faviconURL },
+ null);
+
+ boolean shouldDelete = false;
+ byte[] b = null;
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+
+ final int faviconIndex = c.getColumnIndexOrThrow(Favicons.DATA);
+ try {
+ b = c.getBlob(faviconIndex);
+ } catch (IllegalStateException e) {
+ // This happens when the blob is more than 1MB: Bug 1106347.
+ // Delete that row.
+ shouldDelete = true;
+ }
+ } finally {
+ c.close();
+ }
+
+ if (shouldDelete) {
+ try {
+ Log.d(LOGTAG, "Deleting invalid favicon.");
+ cr.delete(mFaviconsUriWithProfile,
+ Favicons.URL + " = ?",
+ new String[] { faviconURL });
+ } catch (Exception e) {
+ // Do nothing.
+ }
+ }
+
+ if (b == null) {
+ return null;
+ }
+
+ return FaviconDecoder.decodeFavicon(context, b);
+ }
+
+ /**
+ * Try to find a usable favicon URL in the history or bookmarks table.
+ */
+ @Override
+ public String getFaviconURLFromPageURL(ContentResolver cr, String uri) {
+ // Check first in the history table.
+ Cursor c = cr.query(mHistoryUriWithProfile,
+ new String[] { History.FAVICON_URL },
+ Combined.URL + " = ?",
+ new String[] { uri },
+ null);
+
+ try {
+ if (c.moveToFirst()) {
+ // Interrupted page loads can leave History items without a valid favicon_id.
+ final int columnIndex = c.getColumnIndexOrThrow(History.FAVICON_URL);
+ if (!c.isNull(columnIndex)) {
+ final String faviconURL = c.getString(columnIndex);
+ if (faviconURL != null) {
+ return faviconURL;
+ }
+ }
+ }
+ } finally {
+ c.close();
+ }
+
+ // If that fails, check in the bookmarks table.
+ c = cr.query(mBookmarksUriWithProfile,
+ new String[] { Bookmarks.FAVICON_URL },
+ Bookmarks.URL + " = ?",
+ new String[] { uri },
+ null);
+
+ try {
+ if (c.moveToFirst()) {
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.FAVICON_URL));
+ }
+
+ return null;
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ public boolean hideSuggestedSite(String url) {
+ if (mSuggestedSites == null) {
+ return false;
+ }
+
+ return mSuggestedSites.hideSite(url);
+ }
+
+ @Override
+ public void updateThumbnailForUrl(ContentResolver cr, String uri,
+ BitmapDrawable thumbnail) {
+ // If a null thumbnail was passed in, delete the stored thumbnail for this url.
+ if (thumbnail == null) {
+ cr.delete(mThumbnailsUriWithProfile, Thumbnails.URL + " == ?", new String[] { uri });
+ return;
+ }
+
+ Bitmap bitmap = thumbnail.getBitmap();
+
+ byte[] data = null;
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ if (bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)) {
+ data = stream.toByteArray();
+ } else {
+ Log.w(LOGTAG, "Favicon compression failed.");
+ }
+
+ ContentValues values = new ContentValues();
+ values.put(Thumbnails.URL, uri);
+ values.put(Thumbnails.DATA, data);
+
+ Uri thumbnailsUri = mThumbnailsUriWithProfile.buildUpon().
+ appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build();
+ cr.update(thumbnailsUri,
+ values,
+ Thumbnails.URL + " = ?",
+ new String[] { uri });
+ }
+
+ @Override
+ @RobocopTarget
+ public byte[] getThumbnailForUrl(ContentResolver cr, String uri) {
+ final Cursor c = cr.query(mThumbnailsUriWithProfile,
+ new String[]{ Thumbnails.DATA },
+ Thumbnails.URL + " = ? AND " + Thumbnails.DATA + " IS NOT NULL",
+ new String[]{ uri },
+ null);
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+
+ int thumbnailIndex = c.getColumnIndexOrThrow(Thumbnails.DATA);
+
+ return c.getBlob(thumbnailIndex);
+ } finally {
+ c.close();
+ }
+
+ }
+
+ /**
+ * Query for non-null thumbnails matching the provided <code>urls</code>.
+ * The returned cursor will have no more than, but possibly fewer than,
+ * the requested number of thumbnails.
+ *
+ * Returns null if the provided list of URLs is empty or null.
+ */
+ @Override
+ public Cursor getThumbnailsForUrls(ContentResolver cr, List<String> urls) {
+ final int urlCount = urls.size();
+ if (urlCount == 0) {
+ return null;
+ }
+
+ // Don't match against null thumbnails.
+ final String selection = Thumbnails.DATA + " IS NOT NULL AND " +
+ DBUtils.computeSQLInClause(urlCount, Thumbnails.URL);
+ final String[] selectionArgs = urls.toArray(new String[urlCount]);
+
+ return cr.query(mThumbnailsUriWithProfile,
+ new String[] { Thumbnails.URL, Thumbnails.DATA },
+ selection,
+ selectionArgs,
+ null);
+ }
+
+ @Override
+ @RobocopTarget
+ public void removeThumbnails(ContentResolver cr) {
+ cr.delete(mThumbnailsUriWithProfile, null, null);
+ }
+
+ /**
+ * Utility method used by AndroidImport for updating existing history record using batch operations.
+ *
+ * @param cr <code>ContentResolver</code> used for querying information about existing history records.
+ * @param operations Collection of operations for queueing record updates.
+ * @param url URL used for querying history records to update.
+ * @param title Optional new title.
+ * @param date New last visited date. Will be used if newer than current last visited date.
+ * @param visits Will increment existing visit counts by this number.
+ */
+ @Override
+ public void updateHistoryInBatch(@NonNull ContentResolver cr,
+ @NonNull Collection<ContentProviderOperation> operations,
+ @NonNull String url, @Nullable String title,
+ long date, int visits) {
+ final String[] projection = {
+ History._ID,
+ History.VISITS,
+ History.LOCAL_VISITS,
+ History.DATE_LAST_VISITED,
+ History.LOCAL_DATE_LAST_VISITED
+ };
+
+ // We need to get the old visit and date aggregates.
+ final Cursor cursor = cr.query(withDeleted(mHistoryUriWithProfile),
+ projection,
+ History.URL + " = ?",
+ new String[] { url },
+ null);
+ if (cursor == null) {
+ Log.w(LOGTAG, "Null cursor while querying for old visit and date aggregates");
+ return;
+ }
+
+ try {
+ final ContentValues values = new ContentValues();
+
+ // Restore deleted record if possible
+ values.put(History.IS_DELETED, 0);
+
+ if (cursor.moveToFirst()) {
+ final int visitsCol = cursor.getColumnIndexOrThrow(History.VISITS);
+ final int localVisitsCol = cursor.getColumnIndexOrThrow(History.LOCAL_VISITS);
+ final int dateCol = cursor.getColumnIndexOrThrow(History.DATE_LAST_VISITED);
+ final int localDateCol = cursor.getColumnIndexOrThrow(History.LOCAL_DATE_LAST_VISITED);
+
+ final int oldVisits = cursor.getInt(visitsCol);
+ final int oldLocalVisits = cursor.getInt(localVisitsCol);
+ final long oldDate = cursor.getLong(dateCol);
+ final long oldLocalDate = cursor.getLong(localDateCol);
+
+ // NB: This will increment visit counts even if subsequent "insert visits" operations
+ // insert no new visits (see insertVisitsFromImportHistoryInBatch).
+ // So, we're doing a wrong thing here if user imports history more than once.
+ // See Bug 1277330.
+ values.put(History.VISITS, oldVisits + visits);
+ values.put(History.LOCAL_VISITS, oldLocalVisits + visits);
+ // Only update last visited if newer.
+ if (date > oldDate) {
+ values.put(History.DATE_LAST_VISITED, date);
+ }
+ if (date > oldLocalDate) {
+ values.put(History.LOCAL_DATE_LAST_VISITED, date);
+ }
+ } else {
+ values.put(History.VISITS, visits);
+ values.put(History.LOCAL_VISITS, visits);
+ values.put(History.DATE_LAST_VISITED, date);
+ values.put(History.LOCAL_DATE_LAST_VISITED, date);
+ }
+ if (title != null) {
+ values.put(History.TITLE, title);
+ }
+ values.put(History.URL, url);
+
+ final Uri historyUri = withDeleted(mHistoryUriWithProfile).buildUpon().
+ appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build();
+
+ // Update or insert
+ final ContentProviderOperation.Builder builder =
+ ContentProviderOperation.newUpdate(historyUri);
+ builder.withSelection(History.URL + " = ?", new String[] { url });
+ builder.withValues(values);
+
+ // Queue the operation
+ operations.add(builder.build());
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Utility method used by AndroidImport to insert visit data for history records that were just imported.
+ * Uses batch operations.
+ *
+ * @param cr <code>ContentResolver</code> used to query history table and bulkInsert visit records
+ * @param operations Collection of operations for queueing inserts
+ * @param visitsToSynthesize List of ContentValues describing visit information for each history record:
+ * (History URL, LAST DATE VISITED, VISIT COUNT)
+ */
+ public void insertVisitsFromImportHistoryInBatch(ContentResolver cr,
+ Collection<ContentProviderOperation> operations,
+ ArrayList<ContentValues> visitsToSynthesize) {
+ // If for any reason we fail to obtain history GUID for a tuple we're processing,
+ // let's just ignore it. It's possible that the "best-effort" history import
+ // did not fully succeed, so we could be missing some of the records.
+ int historyGUIDCol = -1;
+ for (ContentValues visitsInformation : visitsToSynthesize) {
+ final Cursor cursor = cr.query(mHistoryUriWithProfile,
+ new String[] {History.GUID},
+ History.URL + " = ?",
+ new String[] {visitsInformation.getAsString(HISTORY_VISITS_URL)},
+ null);
+ if (cursor == null) {
+ continue;
+ }
+
+ final String historyGUID;
+
+ try {
+ if (!cursor.moveToFirst()) {
+ continue;
+ }
+ if (historyGUIDCol == -1) {
+ historyGUIDCol = cursor.getColumnIndexOrThrow(History.GUID);
+ }
+
+ historyGUID = cursor.getString(historyGUIDCol);
+ } finally {
+ // We "continue" on a null cursor above, so it's safe to act upon it without checking.
+ cursor.close();
+ }
+ if (historyGUID == null) {
+ continue;
+ }
+
+ // This fakes the individual visit records, using last visited date as the starting point.
+ for (int i = 0; i < visitsInformation.getAsInteger(HISTORY_VISITS_COUNT); i++) {
+ // We rely on database defaults for IS_LOCAL and VISIT_TYPE.
+ final ContentValues visitToInsert = new ContentValues();
+ visitToInsert.put(BrowserContract.Visits.HISTORY_GUID, historyGUID);
+
+ // Visit timestamps are stored in microseconds, while Android Browser visit timestmaps
+ // are in milliseconds. This is the conversion point for imports.
+ visitToInsert.put(BrowserContract.Visits.DATE_VISITED,
+ (visitsInformation.getAsLong(HISTORY_VISITS_DATE) - i) * 1000);
+
+ final ContentProviderOperation.Builder builder =
+ ContentProviderOperation.newInsert(BrowserContract.Visits.CONTENT_URI);
+ builder.withValues(visitToInsert);
+
+ // Queue the insert operation
+ operations.add(builder.build());
+ }
+ }
+ }
+
+ @Override
+ public void updateBookmarkInBatch(ContentResolver cr,
+ Collection<ContentProviderOperation> operations,
+ String url, String title, String guid,
+ long parent, long added,
+ long modified, long position,
+ String keyword, int type) {
+ ContentValues values = new ContentValues();
+ if (title == null && url != null) {
+ title = url;
+ }
+ if (title != null) {
+ values.put(Bookmarks.TITLE, title);
+ }
+ if (url != null) {
+ values.put(Bookmarks.URL, url);
+ }
+ if (guid != null) {
+ values.put(SyncColumns.GUID, guid);
+ }
+ if (keyword != null) {
+ values.put(Bookmarks.KEYWORD, keyword);
+ }
+ if (added > 0) {
+ values.put(SyncColumns.DATE_CREATED, added);
+ }
+ if (modified > 0) {
+ values.put(SyncColumns.DATE_MODIFIED, modified);
+ }
+ values.put(Bookmarks.POSITION, position);
+ // Restore deleted record if possible
+ values.put(Bookmarks.IS_DELETED, 0);
+
+ // This assumes no "real" folder has a negative ID. Only
+ // things like the reading list folder do.
+ if (parent < 0) {
+ parent = getFolderIdFromGuid(cr, Bookmarks.MOBILE_FOLDER_GUID);
+ }
+ values.put(Bookmarks.PARENT, parent);
+ values.put(Bookmarks.TYPE, type);
+
+ Uri bookmarkUri = withDeleted(mBookmarksUriWithProfile).buildUpon().
+ appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build();
+ // Update or insert
+ ContentProviderOperation.Builder builder =
+ ContentProviderOperation.newUpdate(bookmarkUri);
+ if (url != null) {
+ // Bookmarks are defined by their URL and Folder.
+ builder.withSelection(Bookmarks.URL + " = ? AND "
+ + Bookmarks.PARENT + " = ?",
+ new String[] { url,
+ Long.toString(parent)
+ });
+ } else if (title != null) {
+ // Or their title and parent folder. (Folders!)
+ builder.withSelection(Bookmarks.TITLE + " = ? AND "
+ + Bookmarks.PARENT + " = ?",
+ new String[]{ title,
+ Long.toString(parent)
+ });
+ } else if (type == Bookmarks.TYPE_SEPARATOR) {
+ // Or their their position (separators)
+ builder.withSelection(Bookmarks.POSITION + " = ? AND "
+ + Bookmarks.PARENT + " = ?",
+ new String[] { Long.toString(position),
+ Long.toString(parent)
+ });
+ } else {
+ Log.e(LOGTAG, "Bookmark entry without url or title and not a separator, not added.");
+ }
+ builder.withValues(values);
+
+ // Queue the operation
+ operations.add(builder.build());
+ }
+
+ @Override
+ public void pinSite(ContentResolver cr, String url, String title, int position) {
+ ContentValues values = new ContentValues();
+ final long now = System.currentTimeMillis();
+ values.put(Bookmarks.TITLE, title);
+ values.put(Bookmarks.URL, url);
+ values.put(Bookmarks.PARENT, Bookmarks.FIXED_PINNED_LIST_ID);
+ values.put(Bookmarks.DATE_MODIFIED, now);
+ values.put(Bookmarks.POSITION, position);
+ values.put(Bookmarks.IS_DELETED, 0);
+
+ // We do an update-and-replace here without deleting any existing pins for the given URL.
+ // That means if the user pins a URL, then edits another thumbnail to use the same URL,
+ // we'll end up with two pins for that site. This is the intended behavior, which
+ // incidentally saves us a delete query.
+ Uri uri = mBookmarksUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true").build();
+ cr.update(uri,
+ values,
+ Bookmarks.POSITION + " = ? AND " +
+ Bookmarks.PARENT + " = ?",
+ new String[] { Integer.toString(position),
+ String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID) });
+ }
+
+ @Override
+ public void unpinSite(ContentResolver cr, int position) {
+ cr.delete(mBookmarksUriWithProfile,
+ Bookmarks.PARENT + " == ? AND " + Bookmarks.POSITION + " = ?",
+ new String[] {
+ String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID),
+ Integer.toString(position)
+ });
+ }
+
+ @Override
+ @RobocopTarget
+ public Cursor getBookmarkForUrl(ContentResolver cr, String url) {
+ Cursor c = cr.query(bookmarksUriWithLimit(1),
+ new String[] { Bookmarks._ID,
+ Bookmarks.URL,
+ Bookmarks.TITLE,
+ Bookmarks.KEYWORD },
+ Bookmarks.URL + " = ?",
+ new String[] { url },
+ null);
+
+ if (c != null && c.getCount() == 0) {
+ c.close();
+ c = null;
+ }
+
+ return c;
+ }
+
+ @Override
+ public Cursor getBookmarksForPartialUrl(ContentResolver cr, String partialUrl) {
+ Cursor c = cr.query(mBookmarksUriWithProfile,
+ new String[] { Bookmarks.GUID, Bookmarks._ID, Bookmarks.URL },
+ Bookmarks.URL + " LIKE '%" + partialUrl + "%'", // TODO: Escaping!
+ null,
+ null);
+
+ if (c != null && c.getCount() == 0) {
+ c.close();
+ c = null;
+ }
+
+ return c;
+ }
+
+ @Override
+ public void setSuggestedSites(SuggestedSites suggestedSites) {
+ mSuggestedSites = suggestedSites;
+ }
+
+ @Override
+ public SuggestedSites getSuggestedSites() {
+ return mSuggestedSites;
+ }
+
+ @Override
+ public boolean hasSuggestedImageUrl(String url) {
+ if (mSuggestedSites == null) {
+ return false;
+ }
+ return mSuggestedSites.contains(url);
+ }
+
+ @Override
+ public String getSuggestedImageUrlForUrl(String url) {
+ if (mSuggestedSites == null) {
+ return null;
+ }
+ return mSuggestedSites.getImageUrlForUrl(url);
+ }
+
+ @Override
+ public int getSuggestedBackgroundColorForUrl(String url) {
+ if (mSuggestedSites == null) {
+ return 0;
+ }
+ final String bgColor = mSuggestedSites.getBackgroundColorForUrl(url);
+ if (bgColor != null) {
+ return Color.parseColor(bgColor);
+ }
+
+ return 0;
+ }
+
+ private static void appendUrlsFromCursor(List<String> urls, Cursor c) {
+ if (!c.moveToFirst()) {
+ return;
+ }
+
+ do {
+ String url = c.getString(c.getColumnIndex(History.URL));
+
+ // Do a simpler check before decoding to avoid parsing
+ // all URLs unnecessarily.
+ if (StringUtils.isUserEnteredUrl(url)) {
+ url = StringUtils.decodeUserEnteredUrl(url);
+ }
+
+ urls.add(url);
+ } while (c.moveToNext());
+ }
+
+
+ /**
+ * Internal CursorLoader that extends the framework CursorLoader in order to measure
+ * performance for telemetry purposes.
+ */
+ private static final class TelemetrisedCursorLoader extends CursorLoader {
+ final String mHistogramName;
+
+ public TelemetrisedCursorLoader(Context context, Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder,
+ final String histogramName) {
+ super(context, uri, projection, selection, selectionArgs, sortOrder);
+ mHistogramName = histogramName;
+ }
+
+ @Override
+ public Cursor loadInBackground() {
+ final long start = SystemClock.uptimeMillis();
+
+ final Cursor cursor = super.loadInBackground();
+
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+
+ Telemetry.addToHistogram(mHistogramName, (int) Math.min(took, Integer.MAX_VALUE));
+ return cursor;
+ }
+ }
+
+ public CursorLoader getActivityStreamTopSites(Context context, int limit) {
+ final Uri uri = mTopSitesUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(limit))
+ .appendQueryParameter(BrowserContract.PARAM_TOPSITES_DISABLE_PINNED, Boolean.TRUE.toString())
+ .build();
+
+ return new TelemetrisedCursorLoader(context,
+ uri,
+ new String[]{ Combined._ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID },
+ null,
+ null,
+ null,
+ TELEMETRY_HISTOGRAM_ACITIVITY_STREAM_TOPSITES);
+ }
+
+ @Override
+ public Cursor getTopSites(ContentResolver cr, int suggestedRangeLimit, int limit) {
+ final Uri uri = mTopSitesUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(limit))
+ .appendQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT,
+ String.valueOf(suggestedRangeLimit))
+ .build();
+
+ Cursor topSitesCursor = cr.query(uri,
+ new String[] { Combined._ID,
+ Combined.URL,
+ Combined.TITLE,
+ Combined.BOOKMARK_ID,
+ Combined.HISTORY_ID },
+ null,
+ null,
+ null);
+
+ // It's possible that we will retrieve fewer sites than are required to fill the top-sites panel - in this case
+ // we need to add "blank" tiles. It's much easier to add these here (as opposed to SQL), since we don't care
+ // about their ordering (they go after all the other sites), but we do care about their number (and calculating
+ // that inside out topsites SQL query would be difficult given the other processing we're already doing there).
+ final int blanksRequired = suggestedRangeLimit - topSitesCursor.getCount();
+
+ if (blanksRequired <= 0) {
+ return topSitesCursor;
+ }
+
+ MatrixCursor blanksCursor = new MatrixCursor(new String[] {
+ TopSites._ID,
+ TopSites.BOOKMARK_ID,
+ TopSites.HISTORY_ID,
+ TopSites.URL,
+ TopSites.TITLE,
+ TopSites.TYPE});
+
+ final MatrixCursor.RowBuilder rb = blanksCursor.newRow();
+ rb.add(-1);
+ rb.add(-1);
+ rb.add(-1);
+ rb.add("");
+ rb.add("");
+ rb.add(TopSites.TYPE_BLANK);
+
+ return new MergeCursor(new Cursor[] {topSitesCursor, blanksCursor});
+ }
+
+ @Override
+ public CursorLoader getHighlights(Context context, int limit) {
+ final Uri uri = mHighlightsUriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(limit))
+ .build();
+
+ return new CursorLoader(context, uri, null, null, null, null);
+ }
+
+ @Override
+ public void blockActivityStreamSite(ContentResolver cr, String url) {
+ final ContentValues values = new ContentValues();
+ values.put(ActivityStreamBlocklist.URL, url);
+ cr.insert(mActivityStreamBlockedUriWithProfile, values);
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java
new file mode 100644
index 000000000..a9a55e51d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalSearches.java
@@ -0,0 +1,28 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.net.Uri;
+
+/**
+ * Helper class for dealing with the search provider inside Fennec.
+ */
+public class LocalSearches implements Searches {
+ private final Uri uriWithProfile;
+
+ public LocalSearches(String mProfile) {
+ uriWithProfile = DBUtils.appendProfileWithDefault(mProfile, BrowserContract.SearchHistory.CONTENT_URI);
+ }
+
+ @Override
+ public void insert(ContentResolver cr, String query) {
+ final ContentValues values = new ContentValues();
+ values.put(BrowserContract.SearchHistory.QUERY, query);
+ cr.insert(uriWithProfile, values);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java
new file mode 100644
index 000000000..c7bd9475c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalTabsAccessor.java
@@ -0,0 +1,320 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class LocalTabsAccessor implements TabsAccessor {
+ private static final String LOGTAG = "GeckoTabsAccessor";
+ private static final long THREE_WEEKS_IN_MILLISECONDS = TimeUnit.MILLISECONDS.convert(21L, TimeUnit.DAYS);
+
+ public static final String[] TABS_PROJECTION_COLUMNS = new String[] {
+ BrowserContract.Tabs.TITLE,
+ BrowserContract.Tabs.URL,
+ BrowserContract.Clients.GUID,
+ BrowserContract.Clients.NAME,
+ BrowserContract.Tabs.LAST_USED,
+ BrowserContract.Clients.LAST_MODIFIED,
+ BrowserContract.Clients.DEVICE_TYPE,
+ };
+
+ public static final String[] CLIENTS_PROJECTION_COLUMNS = new String[] {
+ BrowserContract.Clients.GUID,
+ BrowserContract.Clients.NAME,
+ BrowserContract.Clients.LAST_MODIFIED,
+ BrowserContract.Clients.DEVICE_TYPE
+ };
+
+ private static final String REMOTE_CLIENTS_SELECTION = BrowserContract.Clients.GUID + " IS NOT NULL";
+ private static final String LOCAL_TABS_SELECTION = BrowserContract.Tabs.CLIENT_GUID + " IS NULL";
+ private static final String REMOTE_TABS_SELECTION = BrowserContract.Tabs.CLIENT_GUID + " IS NOT NULL";
+ private static final String REMOTE_TABS_SELECTION_CLIENT_RECENCY = REMOTE_TABS_SELECTION +
+ " AND " + BrowserContract.Clients.LAST_MODIFIED + " > ?";
+
+ private static final String REMOTE_TABS_SORT_ORDER =
+ // Most recently synced clients first.
+ BrowserContract.Clients.LAST_MODIFIED + " DESC, " +
+ // If two clients somehow had the same last modified time, this will
+ // group them (arbitrarily).
+ BrowserContract.Clients.GUID + " DESC, " +
+ // Within a single client, most recently used tabs first.
+ BrowserContract.Tabs.LAST_USED + " DESC";
+
+ private static final String LOCAL_CLIENT_SELECTION = BrowserContract.Clients.GUID + " IS NULL";
+
+ private static final Pattern FILTERED_URL_PATTERN = Pattern.compile("^(about|chrome|wyciwyg|file):");
+
+ private final Uri clientsRecencyUriWithProfile;
+ private final Uri tabsUriWithProfile;
+ private final Uri clientsUriWithProfile;
+
+ public LocalTabsAccessor(String profileName) {
+ tabsUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Tabs.CONTENT_URI);
+ clientsUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Clients.CONTENT_URI);
+ clientsRecencyUriWithProfile = DBUtils.appendProfileWithDefault(profileName, BrowserContract.Clients.CONTENT_RECENCY_URI);
+ }
+
+ /**
+ * Extracts a List of just RemoteClients from a cursor.
+ * The supplied cursor should be grouped by guid and sorted by most recently used.
+ */
+ @Override
+ public List<RemoteClient> getClientsWithoutTabsByRecencyFromCursor(Cursor cursor) {
+ final ArrayList<RemoteClient> clients = new ArrayList<>(cursor.getCount());
+
+ final int originalPosition = cursor.getPosition();
+ try {
+ if (!cursor.moveToFirst()) {
+ return clients;
+ }
+
+ final int clientGuidIndex = cursor.getColumnIndex(BrowserContract.Clients.GUID);
+ final int clientNameIndex = cursor.getColumnIndex(BrowserContract.Clients.NAME);
+ final int clientLastModifiedIndex = cursor.getColumnIndex(BrowserContract.Clients.LAST_MODIFIED);
+ final int clientDeviceTypeIndex = cursor.getColumnIndex(BrowserContract.Clients.DEVICE_TYPE);
+
+ while (!cursor.isAfterLast()) {
+ final String clientGuid = cursor.getString(clientGuidIndex);
+ final String clientName = cursor.getString(clientNameIndex);
+ final String deviceType = cursor.getString(clientDeviceTypeIndex);
+ final long lastModified = cursor.getLong(clientLastModifiedIndex);
+
+ clients.add(new RemoteClient(clientGuid, clientName, lastModified, deviceType));
+
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.moveToPosition(originalPosition);
+ }
+ return clients;
+ }
+
+ /**
+ * Extract client and tab records from a cursor.
+ * <p>
+ * The position of the cursor is moved to before the first record before
+ * reading. The cursor is advanced until there are no more records to be
+ * read. The position of the cursor is restored before returning.
+ *
+ * @param cursor
+ * to extract records from. The records should already be grouped
+ * by client GUID.
+ * @return list of clients, each containing list of tabs.
+ */
+ @Override
+ public List<RemoteClient> getClientsFromCursor(final Cursor cursor) {
+ final ArrayList<RemoteClient> clients = new ArrayList<RemoteClient>();
+
+ final int originalPosition = cursor.getPosition();
+ try {
+ if (!cursor.moveToFirst()) {
+ return clients;
+ }
+
+ final int tabTitleIndex = cursor.getColumnIndex(BrowserContract.Tabs.TITLE);
+ final int tabUrlIndex = cursor.getColumnIndex(BrowserContract.Tabs.URL);
+ final int tabLastUsedIndex = cursor.getColumnIndex(BrowserContract.Tabs.LAST_USED);
+ final int clientGuidIndex = cursor.getColumnIndex(BrowserContract.Clients.GUID);
+ final int clientNameIndex = cursor.getColumnIndex(BrowserContract.Clients.NAME);
+ final int clientLastModifiedIndex = cursor.getColumnIndex(BrowserContract.Clients.LAST_MODIFIED);
+ final int clientDeviceTypeIndex = cursor.getColumnIndex(BrowserContract.Clients.DEVICE_TYPE);
+
+ // A walking partition, chunking by client GUID. We assume the
+ // cursor records are already grouped by client GUID; see the query
+ // sort order.
+ RemoteClient lastClient = null;
+ while (!cursor.isAfterLast()) {
+ final String clientGuid = cursor.getString(clientGuidIndex);
+ if (lastClient == null || !TextUtils.equals(lastClient.guid, clientGuid)) {
+ final String clientName = cursor.getString(clientNameIndex);
+ final long lastModified = cursor.getLong(clientLastModifiedIndex);
+ final String deviceType = cursor.getString(clientDeviceTypeIndex);
+ lastClient = new RemoteClient(clientGuid, clientName, lastModified, deviceType);
+ clients.add(lastClient);
+ }
+
+ final String tabTitle = cursor.getString(tabTitleIndex);
+ final String tabUrl = cursor.getString(tabUrlIndex);
+ final long tabLastUsed = cursor.getLong(tabLastUsedIndex);
+ lastClient.tabs.add(new RemoteTab(tabTitle, tabUrl, tabLastUsed));
+
+ cursor.moveToNext();
+ }
+ } finally {
+ cursor.moveToPosition(originalPosition);
+ }
+
+ return clients;
+ }
+
+ @Override
+ public Cursor getRemoteClientsByRecencyCursor(Context context) {
+ final Uri uri = clientsRecencyUriWithProfile;
+ return context.getContentResolver().query(uri, CLIENTS_PROJECTION_COLUMNS,
+ REMOTE_CLIENTS_SELECTION, null, null);
+ }
+
+ @Override
+ public Cursor getRemoteTabsCursor(Context context) {
+ return getRemoteTabsCursor(context, -1);
+ }
+
+ @Override
+ public Cursor getRemoteTabsCursor(Context context, int limit) {
+ Uri uri = tabsUriWithProfile;
+
+ if (limit > 0) {
+ uri = uri.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(limit))
+ .build();
+ }
+
+ final String threeWeeksAgoTimestampMillis = Long.valueOf(
+ System.currentTimeMillis() - THREE_WEEKS_IN_MILLISECONDS).toString();
+ return context.getContentResolver().query(uri,
+ TABS_PROJECTION_COLUMNS,
+ REMOTE_TABS_SELECTION_CLIENT_RECENCY,
+ new String[] {threeWeeksAgoTimestampMillis},
+ REMOTE_TABS_SORT_ORDER);
+ }
+
+ // This method returns all tabs from all remote clients,
+ // ordered by most recent client first, most recent tab first
+ @Override
+ public void getTabs(final Context context, final OnQueryTabsCompleteListener listener) {
+ getTabs(context, 0, listener);
+ }
+
+ // This method returns limited number of tabs from all remote clients,
+ // ordered by most recent client first, most recent tab first
+ @Override
+ public void getTabs(final Context context, final int limit, final OnQueryTabsCompleteListener listener) {
+ // If there is no listener, no point in doing work.
+ if (listener == null)
+ return;
+
+ (new UIAsyncTask.WithoutParams<List<RemoteClient>>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ protected List<RemoteClient> doInBackground() {
+ final Cursor cursor = getRemoteTabsCursor(context, limit);
+ if (cursor == null)
+ return null;
+
+ try {
+ return Collections.unmodifiableList(getClientsFromCursor(cursor));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Override
+ protected void onPostExecute(List<RemoteClient> clients) {
+ listener.onQueryTabsComplete(clients);
+ }
+ }).execute();
+ }
+
+ // Updates the modified time of the local client with the current time.
+ private void updateLocalClient(final ContentResolver cr) {
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.Clients.LAST_MODIFIED, System.currentTimeMillis());
+
+ cr.update(clientsUriWithProfile, values, LOCAL_CLIENT_SELECTION, null);
+ }
+
+ // Deletes all local tabs.
+ private void deleteLocalTabs(final ContentResolver cr) {
+ cr.delete(tabsUriWithProfile, LOCAL_TABS_SELECTION, null);
+ }
+
+ /**
+ * Tabs are positioned in the DB in the same order that they appear in the tabs param.
+ * - URL should never empty or null. Skip this tab if there's no URL.
+ * - TITLE should always a string, either a page title or empty.
+ * - LAST_USED should always be numeric.
+ * - FAVICON should be a URL or null.
+ * - HISTORY should be serialized JSON array of URLs.
+ * - POSITION should always be numeric.
+ * - CLIENT_GUID should always be null to represent the local client.
+ */
+ private void insertLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs) {
+ // Reuse this for serializing individual history URLs as JSON.
+ JSONArray history = new JSONArray();
+ ArrayList<ContentValues> valuesToInsert = new ArrayList<ContentValues>();
+
+ int position = 0;
+ for (Tab tab : tabs) {
+ // Skip this tab if it has a null URL or is in private browsing mode, or is a filtered URL.
+ String url = tab.getURL();
+ if (url == null || tab.isPrivate() || isFilteredURL(url))
+ continue;
+
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.Tabs.URL, url);
+ values.put(BrowserContract.Tabs.TITLE, tab.getTitle());
+ values.put(BrowserContract.Tabs.LAST_USED, tab.getLastUsed());
+
+ String favicon = tab.getFaviconURL();
+ if (favicon != null)
+ values.put(BrowserContract.Tabs.FAVICON, favicon);
+ else
+ values.putNull(BrowserContract.Tabs.FAVICON);
+
+ // We don't have access to session history in Java, so for now, we'll
+ // just use a JSONArray that holds most recent history item.
+ try {
+ history.put(0, tab.getURL());
+ values.put(BrowserContract.Tabs.HISTORY, history.toString());
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "JSONException adding URL to tab history array.", e);
+ }
+
+ values.put(BrowserContract.Tabs.POSITION, position++);
+
+ // A null client guid corresponds to the local client.
+ values.putNull(BrowserContract.Tabs.CLIENT_GUID);
+
+ valuesToInsert.add(values);
+ }
+
+ ContentValues[] valuesToInsertArray = valuesToInsert.toArray(new ContentValues[valuesToInsert.size()]);
+ cr.bulkInsert(tabsUriWithProfile, valuesToInsertArray);
+ }
+
+ // Deletes all local tabs and replaces them with a new list of tabs.
+ @Override
+ public synchronized void persistLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs) {
+ deleteLocalTabs(cr);
+ insertLocalTabs(cr, tabs);
+ updateLocalClient(cr);
+ }
+
+ /**
+ * Matches the supplied URL string against the set of URLs to filter.
+ *
+ * @return true if the supplied URL should be skipped; false otherwise.
+ */
+ private boolean isFilteredURL(String url) {
+ return FILTERED_URL_PATTERN.matcher(url).lookingAt();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java
new file mode 100644
index 000000000..7f2c4a736
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalURLMetadata.java
@@ -0,0 +1,240 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.mozilla.gecko.db;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.Log;
+import android.util.LruCache;
+
+// Holds metadata info about URLs. Supports some helper functions for getting back a HashMap of key value data.
+public class LocalURLMetadata implements URLMetadata {
+ private static final String LOGTAG = "GeckoURLMetadata";
+ private final Uri uriWithProfile;
+
+ public LocalURLMetadata(String mProfile) {
+ uriWithProfile = DBUtils.appendProfileWithDefault(mProfile, URLMetadataTable.CONTENT_URI);
+ }
+
+ // A list of columns in the table. It's used to simplify some loops for reading/writing data.
+ private static final Set<String> COLUMNS;
+ static {
+ final HashSet<String> tempModel = new HashSet<>(4);
+ tempModel.add(URLMetadataTable.URL_COLUMN);
+ tempModel.add(URLMetadataTable.TILE_IMAGE_URL_COLUMN);
+ tempModel.add(URLMetadataTable.TILE_COLOR_COLUMN);
+ tempModel.add(URLMetadataTable.TOUCH_ICON_COLUMN);
+ COLUMNS = Collections.unmodifiableSet(tempModel);
+ }
+
+ // Store a cache of recent results. This number is chosen to match the max number of tiles on about:home
+ private static final int CACHE_SIZE = 9;
+ // Note: Members of this cache are unmodifiable.
+ private final LruCache<String, Map<String, Object>> cache = new LruCache<String, Map<String, Object>>(CACHE_SIZE);
+
+ /**
+ * Converts a JSON object into a unmodifiable Map of known metadata properties.
+ * Will throw away any properties that aren't stored in the database.
+ *
+ * Incoming data can include a list like: {touchIconList:{56:"http://x.com/56.png", 76:"http://x.com/76.png"}}.
+ * This will then be filtered to find the most appropriate touchIcon, i.e. the closest icon size that is larger
+ * than (or equal to) the preferred homescreen launcher icon size, which is then stored in the "touchIcon" property.
+ */
+ @Override
+ public Map<String, Object> fromJSON(JSONObject obj) {
+ Map<String, Object> data = new HashMap<String, Object>();
+
+ for (String key : COLUMNS) {
+ if (obj.has(key)) {
+ data.put(key, obj.optString(key));
+ }
+ }
+
+
+ try {
+ JSONObject icons;
+ if (obj.has("touchIconList") &&
+ (icons = obj.getJSONObject("touchIconList")).length() > 0) {
+ int preferredSize = GeckoAppShell.getPreferredIconSize();
+
+ Iterator<String> keys = icons.keys();
+
+ ArrayList<Integer> sizes = new ArrayList<Integer>(icons.length());
+ while (keys.hasNext()) {
+ sizes.add(new Integer(keys.next()));
+ }
+
+ final int bestSize = LoadFaviconResult.selectBestSizeFromList(sizes, preferredSize);
+ final String iconURL = icons.getString(Integer.toString(bestSize));
+
+ data.put(URLMetadataTable.TOUCH_ICON_COLUMN, iconURL);
+ }
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Exception processing touchIconList for LocalURLMetadata; ignoring.", e);
+ }
+
+ return Collections.unmodifiableMap(data);
+ }
+
+ /**
+ * Converts a Cursor into a unmodifiable Map of known metadata properties.
+ * Will throw away any properties that aren't stored in the database.
+ * Will also not iterate through multiple rows in the cursor.
+ */
+ private Map<String, Object> fromCursor(Cursor c) {
+ Map<String, Object> data = new HashMap<String, Object>();
+
+ String[] columns = c.getColumnNames();
+ for (String column : columns) {
+ if (COLUMNS.contains(column)) {
+ try {
+ data.put(column, c.getString(c.getColumnIndexOrThrow(column)));
+ } catch (Exception ex) {
+ Log.i(LOGTAG, "Error getting data for " + column, ex);
+ }
+ }
+ }
+
+ return Collections.unmodifiableMap(data);
+ }
+
+ /**
+ * Returns an unmodifiable Map of url->Metadata (i.e. A second HashMap) for a list of urls.
+ * Must not be called from UI or Gecko threads.
+ */
+ @Override
+ public Map<String, Map<String, Object>> getForURLs(final ContentResolver cr,
+ final Collection<String> urls,
+ final List<String> requestedColumns) {
+ ThreadUtils.assertNotOnUiThread();
+ ThreadUtils.assertNotOnGeckoThread();
+
+ final Map<String, Map<String, Object>> data = new HashMap<String, Map<String, Object>>();
+
+ // Nothing to query for
+ if (urls.isEmpty() || requestedColumns.isEmpty()) {
+ Log.e(LOGTAG, "Queried metadata for nothing");
+ return data;
+ }
+
+ // Search the cache for any of these urls
+ List<String> urlsToQuery = new ArrayList<String>();
+ for (String url : urls) {
+ final Map<String, Object> hit = cache.get(url);
+ if (hit != null) {
+ // Cache hit: we've found the URL in the cache, however we may not have cached the desired columns
+ // for that URL. Hence we need to check whether our cache hit contains those columns, and directly
+ // retrieve the desired data if not. (E.g. the top sites panel retrieves the tile, and tilecolor. If
+ // we later try to retrieve the touchIcon for a top-site the cache hit will only point to
+ // tile+tilecolor, and not the required touchIcon. In this case we don't want to use the cache.)
+ boolean useCache = true;
+ for (String c: requestedColumns) {
+ if (!hit.containsKey(c)) {
+ useCache = false;
+ }
+ }
+ if (useCache) {
+ data.put(url, hit);
+ } else {
+ urlsToQuery.add(url);
+ }
+ } else {
+ urlsToQuery.add(url);
+ }
+ }
+
+ // If everything was in the cache, we're done!
+ if (urlsToQuery.size() == 0) {
+ return Collections.unmodifiableMap(data);
+ }
+
+ final String selection = DBUtils.computeSQLInClause(urlsToQuery.size(), URLMetadataTable.URL_COLUMN);
+ List<String> columns = requestedColumns;
+ // We need the url to build our final HashMap, so we force it to be included in the query.
+ if (!columns.contains(URLMetadataTable.URL_COLUMN)) {
+ // The requestedColumns may be immutable (e.g. if the caller used Collections.singletonList), hence
+ // we have to create a copy.
+ columns = new ArrayList<String>(columns);
+ columns.add(URLMetadataTable.URL_COLUMN);
+ }
+
+ final Cursor cursor = cr.query(uriWithProfile,
+ columns.toArray(new String[columns.size()]), // columns,
+ selection, // selection
+ urlsToQuery.toArray(new String[urlsToQuery.size()]), // selectionargs
+ null);
+ try {
+ if (!cursor.moveToFirst()) {
+ return Collections.unmodifiableMap(data);
+ }
+
+ do {
+ final Map<String, Object> metadata = fromCursor(cursor);
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(URLMetadataTable.URL_COLUMN));
+
+ data.put(url, metadata);
+ cache.put(url, metadata);
+ } while (cursor.moveToNext());
+
+ } finally {
+ cursor.close();
+ }
+
+ return Collections.unmodifiableMap(data);
+ }
+
+ /**
+ * Saves a HashMap of metadata into the database. Will iterate through columns
+ * in the Database and only save rows with matching keys in the HashMap.
+ * Must not be called from UI or Gecko threads.
+ */
+ @Override
+ public void save(final ContentResolver cr, final Map<String, Object> data) {
+ ThreadUtils.assertNotOnUiThread();
+ ThreadUtils.assertNotOnGeckoThread();
+
+ try {
+ ContentValues values = new ContentValues();
+
+ for (String key : COLUMNS) {
+ if (data.containsKey(key)) {
+ values.put(key, (String) data.get(key));
+ }
+ }
+
+ if (values.size() == 0) {
+ return;
+ }
+
+ Uri uri = uriWithProfile.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED, "true")
+ .build();
+ cr.update(uri, values, URLMetadataTable.URL_COLUMN + "=?", new String[] {
+ (String) data.get(URLMetadataTable.URL_COLUMN)
+ });
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "error saving", ex);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
new file mode 100644
index 000000000..9df41a169
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalUrlAnnotations.java
@@ -0,0 +1,253 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract.UrlAnnotations.Key;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+
+public class LocalUrlAnnotations implements UrlAnnotations {
+ private static final String LOGTAG = "LocalUrlAnnotations";
+
+ private Uri urlAnnotationsTableWithProfile;
+
+ public LocalUrlAnnotations(final String profile) {
+ urlAnnotationsTableWithProfile = DBUtils.appendProfile(profile, BrowserContract.UrlAnnotations.CONTENT_URI);
+ }
+
+ /**
+ * Get all feed subscriptions.
+ */
+ @Override
+ public Cursor getFeedSubscriptions(ContentResolver cr) {
+ return queryByKey(cr,
+ Key.FEED_SUBSCRIPTION,
+ new String[] { BrowserContract.UrlAnnotations.URL, BrowserContract.UrlAnnotations.VALUE },
+ null);
+ }
+
+ /**
+ * Insert mapping from website URL to URL of the feed.
+ */
+ @Override
+ public void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl) {
+ insertAnnotation(cr, originUrl, Key.FEED, feedUrl);
+ }
+
+ @Override
+ public boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url) {
+ return hasResultsForSelection(cr,
+ BrowserContract.UrlAnnotations.URL + " = ?",
+ new String[]{url});
+ }
+
+ @Override
+ public void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut) {
+ insertAnnotation(cr, url, Key.HOME_SCREEN_SHORTCUT, String.valueOf(hasCreatedShortCut));
+ }
+
+ /**
+ * Returns true if there's a mapping from the given website URL to a feed URL. False otherwise.
+ */
+ @Override
+ public boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl) {
+ return hasResultsForSelection(cr,
+ BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+ new String[]{websiteUrl, Key.FEED.getDbValue()});
+ }
+
+ /**
+ * Returns true if there's a website URL with this feed URL. False otherwise.
+ */
+ @Override
+ public boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl) {
+ return hasResultsForSelection(cr,
+ BrowserContract.UrlAnnotations.VALUE + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+ new String[]{feedUrl, Key.FEED.getDbValue()});
+ }
+
+ /**
+ * Delete the feed URL mapping for this website URL.
+ */
+ @Override
+ public void deleteFeedUrl(ContentResolver cr, String websiteUrl) {
+ deleteAnnotation(cr, websiteUrl, Key.FEED);
+ }
+
+ /**
+ * Get website URLs that are mapped to the given feed URL.
+ */
+ @Override
+ public Cursor getWebsitesWithFeedUrl(ContentResolver cr) {
+ return cr.query(urlAnnotationsTableWithProfile,
+ new String[] { BrowserContract.UrlAnnotations.URL },
+ BrowserContract.UrlAnnotations.KEY + " = ?",
+ new String[] { Key.FEED.getDbValue() },
+ null);
+ }
+
+ /**
+ * Returns true if there's a subscription for this feed URL. False otherwise.
+ */
+ @Override
+ public boolean hasFeedSubscription(ContentResolver cr, String feedUrl) {
+ return hasResultsForSelection(cr,
+ BrowserContract.UrlAnnotations.URL + " = ? AND " + BrowserContract.UrlAnnotations.KEY + " = ?",
+ new String[]{feedUrl, Key.FEED_SUBSCRIPTION.getDbValue()});
+ }
+
+ /**
+ * Insert the given feed subscription (Mapping from feed URL to the subscription object).
+ */
+ @Override
+ public void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+ try {
+ insertAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString());
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not serialize subscription");
+ }
+ }
+
+ /**
+ * Update the feed subscription with new values.
+ */
+ @Override
+ public void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+ try {
+ updateAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION, subscription.toJSON().toString());
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not serialize subscription");
+ }
+ }
+
+ /**
+ * Delete the subscription for the feed URL.
+ */
+ @Override
+ public void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription) {
+ deleteAnnotation(cr, subscription.getFeedUrl(), Key.FEED_SUBSCRIPTION);
+ }
+
+ private int deleteAnnotation(final ContentResolver cr, final String url, final Key key) {
+ return cr.delete(urlAnnotationsTableWithProfile,
+ BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?",
+ new String[] { key.getDbValue(), url });
+ }
+
+ private int updateAnnotation(final ContentResolver cr, final String url, final Key key, final String value) {
+ ContentValues values = new ContentValues();
+ values.put(BrowserContract.UrlAnnotations.VALUE, value);
+ values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, System.currentTimeMillis());
+
+ return cr.update(urlAnnotationsTableWithProfile,
+ values,
+ BrowserContract.UrlAnnotations.KEY + " = ? AND " + BrowserContract.UrlAnnotations.URL + " = ?",
+ new String[]{key.getDbValue(), url});
+ }
+
+ private void insertAnnotation(final ContentResolver cr, final String url, final Key key, final String value) {
+ insertAnnotation(cr, url, key.getDbValue(), value);
+ }
+
+ @RobocopTarget
+ @Override
+ public void insertAnnotation(final ContentResolver cr, final String url, final String key, final String value) {
+ final long creationTime = System.currentTimeMillis();
+ final ContentValues values = new ContentValues(5);
+ values.put(BrowserContract.UrlAnnotations.URL, url);
+ values.put(BrowserContract.UrlAnnotations.KEY, key);
+ values.put(BrowserContract.UrlAnnotations.VALUE, value);
+ values.put(BrowserContract.UrlAnnotations.DATE_CREATED, creationTime);
+ values.put(BrowserContract.UrlAnnotations.DATE_MODIFIED, creationTime);
+ cr.insert(urlAnnotationsTableWithProfile, values);
+ }
+
+ /**
+ * @return true if the table contains rows for the given selection.
+ */
+ private boolean hasResultsForSelection(ContentResolver cr, String selection, String[] selectionArgs) {
+ Cursor cursor = cr.query(urlAnnotationsTableWithProfile,
+ new String[] { BrowserContract.UrlAnnotations._ID },
+ selection,
+ selectionArgs,
+ null);
+ if (cursor == null) {
+ return false;
+ }
+
+ try {
+ return cursor.getCount() > 0;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private Cursor queryByKey(final ContentResolver cr, @NonNull final Key key, @Nullable final String[] projections,
+ @Nullable final String sortOrder) {
+ return cr.query(urlAnnotationsTableWithProfile,
+ projections,
+ BrowserContract.UrlAnnotations.KEY + " = ?", new String[] { key.getDbValue() },
+ sortOrder);
+ }
+
+ @Override
+ public Cursor getScreenshots(ContentResolver cr) {
+ return queryByKey(cr,
+ Key.SCREENSHOT,
+ new String[] {
+ BrowserContract.UrlAnnotations._ID,
+ BrowserContract.UrlAnnotations.URL,
+ BrowserContract.UrlAnnotations.KEY,
+ BrowserContract.UrlAnnotations.VALUE,
+ BrowserContract.UrlAnnotations.DATE_CREATED,
+ },
+ BrowserContract.UrlAnnotations.DATE_CREATED + " DESC");
+ }
+
+ public void insertScreenshot(final ContentResolver cr, final String pageUrl, final String screenshotPath) {
+ insertAnnotation(cr, pageUrl, Key.SCREENSHOT.getDbValue(), screenshotPath);
+ }
+
+ @Override
+ public void insertReaderViewUrl(final ContentResolver cr, final String pageUrl) {
+ insertAnnotation(cr, pageUrl, Key.READER_VIEW.getDbValue(), BrowserContract.UrlAnnotations.READER_VIEW_SAVED_VALUE);
+ }
+
+ @Override
+ public void deleteReaderViewUrl(ContentResolver cr, String pageURL) {
+ deleteAnnotation(cr, pageURL, Key.READER_VIEW);
+ }
+
+ public int getAnnotationCount(ContentResolver cr, Key key) {
+ final String countColumnname = "count";
+ final Cursor c = queryByKey(cr,
+ key,
+ new String[] {
+ "COUNT(*) AS " + countColumnname
+ },
+ null);
+
+ try {
+ if (c != null && c.moveToFirst()) {
+ return c.getInt(c.getColumnIndexOrThrow(countColumnname));
+ } else {
+ return 0;
+ }
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java
new file mode 100644
index 000000000..d2d504851
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java
@@ -0,0 +1,520 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.MatrixCursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.Base64;
+
+import org.mozilla.gecko.db.BrowserContract.DeletedLogins;
+import org.mozilla.gecko.db.BrowserContract.Logins;
+import org.mozilla.gecko.db.BrowserContract.LoginsDisabledHosts;
+import org.mozilla.gecko.sync.Utils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+
+import javax.crypto.Cipher;
+import javax.crypto.NullCipher;
+
+import static org.mozilla.gecko.db.BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+import static org.mozilla.gecko.db.BrowserContract.Logins.TABLE_LOGINS;
+import static org.mozilla.gecko.db.BrowserContract.LoginsDisabledHosts.TABLE_DISABLED_HOSTS;
+
+public class LoginsProvider extends SharedBrowserDatabaseProvider {
+
+ private static final int LOGINS = 100;
+ private static final int LOGINS_ID = 101;
+ private static final int DELETED_LOGINS = 102;
+ private static final int DELETED_LOGINS_ID = 103;
+ private static final int DISABLED_HOSTS = 104;
+ private static final int DISABLED_HOSTS_HOSTNAME = 105;
+ private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ private static final HashMap<String, String> LOGIN_PROJECTION_MAP;
+ private static final HashMap<String, String> DELETED_LOGIN_PROJECTION_MAP;
+ private static final HashMap<String, String> DISABLED_HOSTS_PROJECTION_MAP;
+
+ private static final String DEFAULT_LOGINS_SORT_ORDER = Logins.HOSTNAME + " ASC";
+ private static final String DEFAULT_DELETED_LOGINS_SORT_ORDER = DeletedLogins.TIME_DELETED + " ASC";
+ private static final String DEFAULT_DISABLED_HOSTS_SORT_ORDER = LoginsDisabledHosts.HOSTNAME + " ASC";
+ private static final String WHERE_GUID_IS_NULL = DeletedLogins.GUID + " IS NULL";
+ private static final String WHERE_GUID_IS_VALUE = DeletedLogins.GUID + " = ?";
+
+ protected static final String INDEX_LOGINS_HOSTNAME = "login_hostname_index";
+ protected static final String INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL = "login_hostname_formSubmitURL_index";
+ protected static final String INDEX_LOGINS_HOSTNAME_HTTP_REALM = "login_hostname_httpRealm_index";
+
+ static {
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins", LOGINS);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins/#", LOGINS_ID);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins", DELETED_LOGINS);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins/#", DELETED_LOGINS_ID);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins-disabled-hosts", DISABLED_HOSTS);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins-disabled-hosts/hostname/*", DISABLED_HOSTS_HOSTNAME);
+
+ LOGIN_PROJECTION_MAP = new HashMap<>();
+ LOGIN_PROJECTION_MAP.put(Logins._ID, Logins._ID);
+ LOGIN_PROJECTION_MAP.put(Logins.HOSTNAME, Logins.HOSTNAME);
+ LOGIN_PROJECTION_MAP.put(Logins.HTTP_REALM, Logins.HTTP_REALM);
+ LOGIN_PROJECTION_MAP.put(Logins.FORM_SUBMIT_URL, Logins.FORM_SUBMIT_URL);
+ LOGIN_PROJECTION_MAP.put(Logins.USERNAME_FIELD, Logins.USERNAME_FIELD);
+ LOGIN_PROJECTION_MAP.put(Logins.PASSWORD_FIELD, Logins.PASSWORD_FIELD);
+ LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_USERNAME, Logins.ENCRYPTED_USERNAME);
+ LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_PASSWORD, Logins.ENCRYPTED_PASSWORD);
+ LOGIN_PROJECTION_MAP.put(Logins.GUID, Logins.GUID);
+ LOGIN_PROJECTION_MAP.put(Logins.ENC_TYPE, Logins.ENC_TYPE);
+ LOGIN_PROJECTION_MAP.put(Logins.TIME_CREATED, Logins.TIME_CREATED);
+ LOGIN_PROJECTION_MAP.put(Logins.TIME_LAST_USED, Logins.TIME_LAST_USED);
+ LOGIN_PROJECTION_MAP.put(Logins.TIME_PASSWORD_CHANGED, Logins.TIME_PASSWORD_CHANGED);
+ LOGIN_PROJECTION_MAP.put(Logins.TIMES_USED, Logins.TIMES_USED);
+
+ DELETED_LOGIN_PROJECTION_MAP = new HashMap<>();
+ DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins._ID, DeletedLogins._ID);
+ DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.GUID, DeletedLogins.GUID);
+ DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.TIME_DELETED, DeletedLogins.TIME_DELETED);
+
+ DISABLED_HOSTS_PROJECTION_MAP = new HashMap<>();
+ DISABLED_HOSTS_PROJECTION_MAP.put(LoginsDisabledHosts._ID, LoginsDisabledHosts._ID);
+ DISABLED_HOSTS_PROJECTION_MAP.put(LoginsDisabledHosts.HOSTNAME, LoginsDisabledHosts.HOSTNAME);
+ }
+
+ private static String projectColumn(String table, String column) {
+ return table + "." + column;
+ }
+
+ private static String selectColumn(String table, String column) {
+ return projectColumn(table, column) + " = ?";
+ }
+
+ @Override
+ protected Uri insertInTransaction(Uri uri, ContentValues values) {
+ trace("Calling insert in transaction on URI: " + uri);
+
+ final int match = URI_MATCHER.match(uri);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ final long id;
+ String guid;
+
+ setupDefaultValues(values, uri);
+ switch (match) {
+ case LOGINS:
+ removeDeletedLoginsByGUIDInTransaction(values, db);
+ // Encrypt sensitive data.
+ encryptContentValueFields(values);
+ guid = values.getAsString(Logins.GUID);
+ debug("Inserting login in database with GUID: " + guid);
+ id = db.insertOrThrow(TABLE_LOGINS, Logins.GUID, values);
+ break;
+
+ case DELETED_LOGINS:
+ guid = values.getAsString(DeletedLogins.GUID);
+ debug("Inserting deleted-login in database with GUID: " + guid);
+ id = db.insertOrThrow(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values);
+ break;
+
+ case DISABLED_HOSTS:
+ String hostname = values.getAsString(LoginsDisabledHosts.HOSTNAME);
+ debug("Inserting disabled-host in database with hostname: " + hostname);
+ id = db.insertOrThrow(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME, values);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown insert URI " + uri);
+ }
+
+ debug("Inserted ID in database: " + id);
+
+ if (id >= 0) {
+ return ContentUris.withAppendedId(uri, id);
+ }
+
+ return null;
+ }
+
+ @Override
+ @SuppressWarnings("fallthrough")
+ protected int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+ trace("Calling delete in transaction on URI: " + uri);
+
+ final int match = URI_MATCHER.match(uri);
+ final String table;
+ final SQLiteDatabase db = getWritableDatabase(uri);
+
+ beginWrite(db);
+ switch (match) {
+ case LOGINS_ID:
+ trace("Delete on LOGINS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[]{Long.toString(ContentUris.parseId(uri))});
+ // Store the deleted client in deleted-logins table.
+ final String guid = getLoginGUIDByID(selection, selectionArgs, db);
+ if (guid == null) {
+ // No matching logins found for the id.
+ return 0;
+ }
+ boolean isInsertSuccessful = storeDeletedLoginForGUIDInTransaction(guid, db);
+ if (!isInsertSuccessful) {
+ // Failed to insert into deleted-logins, return early.
+ return 0;
+ }
+ // fall through
+ case LOGINS:
+ trace("Delete on LOGINS: " + uri);
+ table = TABLE_LOGINS;
+ break;
+
+ case DELETED_LOGINS_ID:
+ trace("Delete on DELETED_LOGINS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[]{Long.toString(ContentUris.parseId(uri))});
+ // fall through
+ case DELETED_LOGINS:
+ trace("Delete on DELETED_LOGINS_ID: " + uri);
+ table = TABLE_DELETED_LOGINS;
+ break;
+
+ case DISABLED_HOSTS_HOSTNAME:
+ trace("Delete on DISABLED_HOSTS_HOSTNAME: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[]{uri.getLastPathSegment()});
+ // fall through
+ case DISABLED_HOSTS:
+ trace("Delete on DISABLED_HOSTS: " + uri);
+ table = TABLE_DISABLED_HOSTS;
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown delete URI " + uri);
+ }
+
+ debug("Deleting " + table + " for URI: " + uri);
+ return db.delete(table, selection, selectionArgs);
+ }
+
+ @Override
+ @SuppressWarnings("fallthrough")
+ protected int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ trace("Calling update in transaction on URI: " + uri);
+
+ final int match = URI_MATCHER.match(uri);
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ final String table;
+
+ beginWrite(db);
+ switch (match) {
+ case LOGINS_ID:
+ trace("Update on LOGINS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[]{Long.toString(ContentUris.parseId(uri))});
+
+ case LOGINS:
+ trace("Update on LOGINS: " + uri);
+ table = TABLE_LOGINS;
+ // Encrypt sensitive data.
+ encryptContentValueFields(values);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown update URI " + uri);
+ }
+
+ trace("Updating " + table + " on URI: " + uri);
+ return db.update(table, values, selection, selectionArgs);
+
+ }
+
+ @Override
+ @SuppressWarnings("fallthrough")
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ trace("Calling query on URI: " + uri);
+
+ final SQLiteDatabase db = getReadableDatabase(uri);
+ final int match = URI_MATCHER.match(uri);
+ final String groupBy = null;
+ final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ final String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+
+ switch (match) {
+ case LOGINS_ID:
+ trace("Query is on LOGINS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+
+ // fall through
+ case LOGINS:
+ trace("Query is on LOGINS: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_LOGINS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(LOGIN_PROJECTION_MAP);
+ qb.setTables(TABLE_LOGINS);
+ break;
+
+ case DELETED_LOGINS_ID:
+ trace("Query is on DELETED_LOGINS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+
+ // fall through
+ case DELETED_LOGINS:
+ trace("Query is on DELETED_LOGINS: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_DELETED_LOGINS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(DELETED_LOGIN_PROJECTION_MAP);
+ qb.setTables(TABLE_DELETED_LOGINS);
+ break;
+
+ case DISABLED_HOSTS_HOSTNAME:
+ trace("Query is on DISABLED_HOSTS_HOSTNAME: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.HOSTNAME));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { uri.getLastPathSegment() });
+
+ // fall through
+ case DISABLED_HOSTS:
+ trace("Query is on DISABLED_HOSTS: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_DISABLED_HOSTS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(DISABLED_HOSTS_PROJECTION_MAP);
+ qb.setTables(TABLE_DISABLED_HOSTS);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown query URI " + uri);
+ }
+
+ trace("Running built query.");
+ Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit);
+ // If decryptManyCursorRows does not return the original cursor, it closes it, so there's
+ // no need to close here.
+ cursor = decryptManyCursorRows(cursor);
+ cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.LOGINS_AUTHORITY_URI);
+ return cursor;
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ switch (match) {
+ case LOGINS:
+ return Logins.CONTENT_TYPE;
+
+ case LOGINS_ID:
+ return Logins.CONTENT_ITEM_TYPE;
+
+ case DELETED_LOGINS:
+ return DeletedLogins.CONTENT_TYPE;
+
+ case DELETED_LOGINS_ID:
+ return DeletedLogins.CONTENT_ITEM_TYPE;
+
+ case DISABLED_HOSTS:
+ return LoginsDisabledHosts.CONTENT_TYPE;
+
+ case DISABLED_HOSTS_HOSTNAME:
+ return LoginsDisabledHosts.CONTENT_ITEM_TYPE;
+
+ default:
+ throw new UnsupportedOperationException("Unknown type " + uri);
+ }
+ }
+
+ /**
+ * Caller is responsible for invoking this method inside a transaction.
+ */
+ private String getLoginGUIDByID(final String selection, final String[] selectionArgs, final SQLiteDatabase db) {
+ final Cursor cursor = db.query(Logins.TABLE_LOGINS, new String[]{Logins.GUID}, selection, selectionArgs, null, null, DEFAULT_LOGINS_SORT_ORDER);
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+ return cursor.getString(cursor.getColumnIndexOrThrow(Logins.GUID));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Caller is responsible for invoking this method inside a transaction.
+ */
+ private boolean storeDeletedLoginForGUIDInTransaction(final String guid, final SQLiteDatabase db) {
+ if (guid == null) {
+ return false;
+ }
+ final ContentValues values = new ContentValues();
+ values.put(DeletedLogins.GUID, guid);
+ values.put(DeletedLogins.TIME_DELETED, System.currentTimeMillis());
+ return db.insert(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values) > 0;
+ }
+
+ /**
+ * Caller is responsible for invoking this method inside a transaction.
+ */
+ private void removeDeletedLoginsByGUIDInTransaction(ContentValues values, SQLiteDatabase db) {
+ if (values.containsKey(Logins.GUID)) {
+ final String guid = values.getAsString(Logins.GUID);
+ if (guid == null) {
+ db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_NULL, null);
+ } else {
+ String[] args = new String[]{guid};
+ db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_VALUE, args);
+ }
+ }
+ }
+
+ private void setupDefaultValues(ContentValues values, Uri uri) throws IllegalArgumentException {
+ final int match = URI_MATCHER.match(uri);
+ final long now = System.currentTimeMillis();
+ switch (match) {
+ case DELETED_LOGINS:
+ values.put(DeletedLogins.TIME_DELETED, now);
+ // deleted-logins must contain a guid
+ if (!values.containsKey(DeletedLogins.GUID)) {
+ throw new IllegalArgumentException("Must provide GUID for deleted-login");
+ }
+ break;
+
+ case LOGINS:
+ values.put(Logins.TIME_CREATED, now);
+ // Generate GUID for new login. Don't override specified GUIDs.
+ if (!values.containsKey(Logins.GUID)) {
+ final String guid = Utils.generateGuid();
+ values.put(Logins.GUID, guid);
+ }
+ // The database happily accepts strings for long values; this just lets us re-use
+ // the existing helper method.
+ String nowString = Long.toString(now);
+ DBUtils.replaceKey(values, null, Logins.HTTP_REALM, null);
+ DBUtils.replaceKey(values, null, Logins.FORM_SUBMIT_URL, null);
+ DBUtils.replaceKey(values, null, Logins.ENC_TYPE, "0");
+ DBUtils.replaceKey(values, null, Logins.TIME_LAST_USED, nowString);
+ DBUtils.replaceKey(values, null, Logins.TIME_PASSWORD_CHANGED, nowString);
+ DBUtils.replaceKey(values, null, Logins.TIMES_USED, "0");
+ break;
+
+ case DISABLED_HOSTS:
+ if (!values.containsKey(LoginsDisabledHosts.HOSTNAME)) {
+ throw new IllegalArgumentException("Must provide hostname for disabled-host");
+ }
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown URI in setupDefaultValues " + uri);
+ }
+ }
+
+ private void encryptContentValueFields(final ContentValues values) {
+ if (values.containsKey(Logins.ENCRYPTED_PASSWORD)) {
+ final String res = encrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD));
+ values.put(Logins.ENCRYPTED_PASSWORD, res);
+ }
+
+ if (values.containsKey(Logins.ENCRYPTED_USERNAME)) {
+ final String res = encrypt(values.getAsString(Logins.ENCRYPTED_USERNAME));
+ values.put(Logins.ENCRYPTED_USERNAME, res);
+ }
+ }
+
+ /**
+ * Replace each password and username encrypted ciphertext with its equivalent decrypted
+ * plaintext in the given cursor.
+ * <p/>
+ * The encryption algorithm used to protect logins is unspecified; and further, a consumer of
+ * consumers should never have access to encrypted ciphertext.
+ *
+ * @param cursor containing at least one of password and username encrypted ciphertexts.
+ * @return a new {@link Cursor} with password and username decrypted plaintexts.
+ */
+ private Cursor decryptManyCursorRows(final Cursor cursor) {
+ final int passwordIndex = cursor.getColumnIndex(Logins.ENCRYPTED_PASSWORD);
+ final int usernameIndex = cursor.getColumnIndex(Logins.ENCRYPTED_USERNAME);
+
+ if (passwordIndex == -1 && usernameIndex == -1) {
+ return cursor;
+ }
+
+ // Special case, decrypt the encrypted username or password before returning the cursor.
+ final MatrixCursor newCursor = new MatrixCursor(cursor.getColumnNames(), cursor.getColumnCount());
+ try {
+ for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
+ final ContentValues values = new ContentValues();
+ DatabaseUtils.cursorRowToContentValues(cursor, values);
+
+ if (passwordIndex > -1) {
+ String decrypted = decrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD));
+ values.put(Logins.ENCRYPTED_PASSWORD, decrypted);
+ }
+
+ if (usernameIndex > -1) {
+ String decrypted = decrypt(values.getAsString(Logins.ENCRYPTED_USERNAME));
+ values.put(Logins.ENCRYPTED_USERNAME, decrypted);
+ }
+
+ final MatrixCursor.RowBuilder rowBuilder = newCursor.newRow();
+ for (String key : cursor.getColumnNames()) {
+ rowBuilder.add(values.get(key));
+ }
+ }
+ } finally {
+ // Close the old cursor before returning the new one.
+ cursor.close();
+ }
+
+ return newCursor;
+ }
+
+ private String encrypt(@NonNull String initialValue) {
+ try {
+ final Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
+ return Base64.encodeToString(cipher.doFinal(initialValue.getBytes("UTF-8")), Base64.URL_SAFE);
+ } catch (Exception e) {
+ debug("encryption failed : " + e);
+ throw new IllegalStateException("Logins encryption failed", e);
+ }
+ }
+
+ private String decrypt(@NonNull String initialValue) {
+ try {
+ final Cipher cipher = getCipher(Cipher.DECRYPT_MODE);
+ return new String(cipher.doFinal(Base64.decode(initialValue.getBytes("UTF-8"), Base64.URL_SAFE)));
+ } catch (Exception e) {
+ debug("Decryption failed : " + e);
+ throw new IllegalStateException("Logins decryption failed", e);
+ }
+ }
+
+ private Cipher getCipher(int mode) throws UnsupportedEncodingException, GeneralSecurityException {
+ return new NullCipher();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java
new file mode 100644
index 000000000..2f5e11ed4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/PasswordsProvider.java
@@ -0,0 +1,348 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.util.HashMap;
+
+import org.mozilla.gecko.CrashHandler;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoMessageReceiver;
+import org.mozilla.gecko.NSSBridge;
+import org.mozilla.gecko.db.BrowserContract.DeletedPasswords;
+import org.mozilla.gecko.db.BrowserContract.GeckoDisabledHosts;
+import org.mozilla.gecko.db.BrowserContract.Passwords;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.gecko.sqlite.MatrixBlobCursor;
+import org.mozilla.gecko.sqlite.SQLiteBridge;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class PasswordsProvider extends SQLiteBridgeContentProvider {
+ static final String TABLE_PASSWORDS = "moz_logins";
+ static final String TABLE_DELETED_PASSWORDS = "moz_deleted_logins";
+ static final String TABLE_DISABLED_HOSTS = "moz_disabledHosts";
+
+ private static final String TELEMETRY_TAG = "SQLITEBRIDGE_PROVIDER_PASSWORDS";
+
+ private static final int PASSWORDS = 100;
+ private static final int DELETED_PASSWORDS = 101;
+ private static final int DISABLED_HOSTS = 102;
+
+ static final String DEFAULT_PASSWORDS_SORT_ORDER = Passwords.HOSTNAME + " ASC";
+ static final String DEFAULT_DELETED_PASSWORDS_SORT_ORDER = DeletedPasswords.TIME_DELETED + " ASC";
+
+ private static final UriMatcher URI_MATCHER;
+
+ private static final HashMap<String, String> PASSWORDS_PROJECTION_MAP;
+ private static final HashMap<String, String> DELETED_PASSWORDS_PROJECTION_MAP;
+ private static final HashMap<String, String> DISABLED_HOSTS_PROJECTION_MAP;
+
+ // this should be kept in sync with the version in toolkit/components/passwordmgr/storage-mozStorage.js
+ private static final int DB_VERSION = 6;
+ private static final String DB_FILENAME = "signons.sqlite";
+ private static final String WHERE_GUID_IS_NULL = BrowserContract.DeletedPasswords.GUID + " IS NULL";
+ private static final String WHERE_GUID_IS_VALUE = BrowserContract.DeletedPasswords.GUID + " = ?";
+
+ private static final String LOG_TAG = "GeckoPasswordsProvider";
+
+ private CrashHandler mCrashHandler;
+
+ static {
+ URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ // content://org.mozilla.gecko.providers.browser/passwords/#
+ URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "passwords", PASSWORDS);
+
+ PASSWORDS_PROJECTION_MAP = new HashMap<String, String>();
+ PASSWORDS_PROJECTION_MAP.put(Passwords.ID, Passwords.ID);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.HOSTNAME, Passwords.HOSTNAME);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.HTTP_REALM, Passwords.HTTP_REALM);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.FORM_SUBMIT_URL, Passwords.FORM_SUBMIT_URL);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.USERNAME_FIELD, Passwords.USERNAME_FIELD);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.PASSWORD_FIELD, Passwords.PASSWORD_FIELD);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.ENCRYPTED_USERNAME, Passwords.ENCRYPTED_USERNAME);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.ENCRYPTED_PASSWORD, Passwords.ENCRYPTED_PASSWORD);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.GUID, Passwords.GUID);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.ENC_TYPE, Passwords.ENC_TYPE);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_CREATED, Passwords.TIME_CREATED);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_LAST_USED, Passwords.TIME_LAST_USED);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.TIME_PASSWORD_CHANGED, Passwords.TIME_PASSWORD_CHANGED);
+ PASSWORDS_PROJECTION_MAP.put(Passwords.TIMES_USED, Passwords.TIMES_USED);
+
+ URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "deleted-passwords", DELETED_PASSWORDS);
+
+ DELETED_PASSWORDS_PROJECTION_MAP = new HashMap<String, String>();
+ DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.ID, DeletedPasswords.ID);
+ DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.GUID, DeletedPasswords.GUID);
+ DELETED_PASSWORDS_PROJECTION_MAP.put(DeletedPasswords.TIME_DELETED, DeletedPasswords.TIME_DELETED);
+
+ URI_MATCHER.addURI(BrowserContract.PASSWORDS_AUTHORITY, "disabled-hosts", DISABLED_HOSTS);
+
+ DISABLED_HOSTS_PROJECTION_MAP = new HashMap<String, String>();
+ DISABLED_HOSTS_PROJECTION_MAP.put(GeckoDisabledHosts.HOSTNAME, GeckoDisabledHosts.HOSTNAME);
+ }
+
+ public PasswordsProvider() {
+ super(LOG_TAG);
+ }
+
+ @Override
+ public boolean onCreate() {
+ mCrashHandler = CrashHandler.createDefaultCrashHandler(getContext());
+
+ // We don't use .loadMozGlue because we're in a different process,
+ // and we just want to reuse code rather than use the loader lock etc.
+ GeckoLoader.doLoadLibrary(getContext(), "mozglue");
+ return super.onCreate();
+ }
+
+ @Override
+ public void shutdown() {
+ super.shutdown();
+
+ if (mCrashHandler != null) {
+ mCrashHandler.unregister();
+ mCrashHandler = null;
+ }
+ }
+
+ @Override
+ protected String getDBName() {
+ return DB_FILENAME;
+ }
+
+ @Override
+ protected String getTelemetryPrefix() {
+ return TELEMETRY_TAG;
+ }
+
+ @Override
+ protected int getDBVersion() {
+ return DB_VERSION;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ switch (match) {
+ case PASSWORDS:
+ return Passwords.CONTENT_TYPE;
+
+ case DELETED_PASSWORDS:
+ return DeletedPasswords.CONTENT_TYPE;
+
+ case DISABLED_HOSTS:
+ return GeckoDisabledHosts.CONTENT_TYPE;
+
+ default:
+ throw new UnsupportedOperationException("Unknown type " + uri);
+ }
+ }
+
+ @Override
+ public String getTable(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case DELETED_PASSWORDS:
+ return TABLE_DELETED_PASSWORDS;
+
+ case PASSWORDS:
+ return TABLE_PASSWORDS;
+
+ case DISABLED_HOSTS:
+ return TABLE_DISABLED_HOSTS;
+
+ default:
+ throw new UnsupportedOperationException("Unknown table " + uri);
+ }
+ }
+
+ @Override
+ public String getSortOrder(Uri uri, String aRequested) {
+ if (!TextUtils.isEmpty(aRequested)) {
+ return aRequested;
+ }
+
+ final int match = URI_MATCHER.match(uri);
+ switch (match) {
+ case DELETED_PASSWORDS:
+ return DEFAULT_DELETED_PASSWORDS_SORT_ORDER;
+
+ case PASSWORDS:
+ return DEFAULT_PASSWORDS_SORT_ORDER;
+
+ case DISABLED_HOSTS:
+ return null;
+
+ default:
+ throw new UnsupportedOperationException("Unknown URI " + uri);
+ }
+ }
+
+ @Override
+ public void setupDefaults(Uri uri, ContentValues values)
+ throws IllegalArgumentException {
+ int match = URI_MATCHER.match(uri);
+ long now = System.currentTimeMillis();
+ switch (match) {
+ case DELETED_PASSWORDS:
+ values.put(DeletedPasswords.TIME_DELETED, now);
+
+ // Deleted passwords must contain a guid
+ if (!values.containsKey(Passwords.GUID)) {
+ throw new IllegalArgumentException("Must provide a GUID for a deleted password");
+ }
+ break;
+
+ case PASSWORDS:
+ values.put(Passwords.TIME_CREATED, now);
+
+ // Generate GUID for new password. Don't override specified GUIDs.
+ if (!values.containsKey(Passwords.GUID)) {
+ String guid = Utils.generateGuid();
+ values.put(Passwords.GUID, guid);
+ }
+ String nowString = Long.toString(now);
+ DBUtils.replaceKey(values, null, Passwords.HOSTNAME, "");
+ DBUtils.replaceKey(values, null, Passwords.HTTP_REALM, "");
+ DBUtils.replaceKey(values, null, Passwords.FORM_SUBMIT_URL, "");
+ DBUtils.replaceKey(values, null, Passwords.USERNAME_FIELD, "");
+ DBUtils.replaceKey(values, null, Passwords.PASSWORD_FIELD, "");
+ DBUtils.replaceKey(values, null, Passwords.ENCRYPTED_USERNAME, "");
+ DBUtils.replaceKey(values, null, Passwords.ENCRYPTED_PASSWORD, "");
+ DBUtils.replaceKey(values, null, Passwords.ENC_TYPE, "0");
+ DBUtils.replaceKey(values, null, Passwords.TIME_LAST_USED, nowString);
+ DBUtils.replaceKey(values, null, Passwords.TIME_PASSWORD_CHANGED, nowString);
+ DBUtils.replaceKey(values, null, Passwords.TIMES_USED, "0");
+ break;
+
+ case DISABLED_HOSTS:
+ if (!values.containsKey(GeckoDisabledHosts.HOSTNAME)) {
+ throw new IllegalArgumentException("Must provide a hostname for a disabled host");
+ }
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown URI " + uri);
+ }
+ }
+
+ @Override
+ public void initGecko() {
+ // We're not in the main process. The receiver of this Intent can
+ // communicate with Gecko in the main process.
+ Intent initIntent = new Intent(getContext(), GeckoMessageReceiver.class);
+ initIntent.setAction(GeckoApp.ACTION_INIT_PW);
+ mContext.sendBroadcast(initIntent);
+ }
+
+ private String doCrypto(String initialValue, Uri uri, Boolean encrypt) {
+ String profilePath = null;
+ if (uri != null) {
+ profilePath = uri.getQueryParameter(BrowserContract.PARAM_PROFILE_PATH);
+ }
+
+ String result = "";
+ try {
+ if (encrypt) {
+ if (profilePath != null) {
+ result = NSSBridge.encrypt(mContext, profilePath, initialValue);
+ } else {
+ result = NSSBridge.encrypt(mContext, initialValue);
+ }
+ } else {
+ if (profilePath != null) {
+ result = NSSBridge.decrypt(mContext, profilePath, initialValue);
+ } else {
+ result = NSSBridge.decrypt(mContext, initialValue);
+ }
+ }
+ } catch (Exception ex) {
+ Log.e(LOG_TAG, "Error in NSSBridge");
+ throw new RuntimeException(ex);
+ }
+ return result;
+ }
+
+ @Override
+ public void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db) {
+ if (values.containsKey(Passwords.GUID)) {
+ String guid = values.getAsString(Passwords.GUID);
+ if (guid == null) {
+ db.delete(TABLE_DELETED_PASSWORDS, WHERE_GUID_IS_NULL, null);
+ return;
+ }
+ String[] args = new String[] { guid };
+ db.delete(TABLE_DELETED_PASSWORDS, WHERE_GUID_IS_VALUE, args);
+ }
+
+ if (values.containsKey(Passwords.ENCRYPTED_PASSWORD)) {
+ String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_PASSWORD), uri, true);
+ values.put(Passwords.ENCRYPTED_PASSWORD, res);
+ values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR);
+ }
+
+ if (values.containsKey(Passwords.ENCRYPTED_USERNAME)) {
+ String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_USERNAME), uri, true);
+ values.put(Passwords.ENCRYPTED_USERNAME, res);
+ values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR);
+ }
+ }
+
+ @Override
+ public void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db) {
+ if (values.containsKey(Passwords.ENCRYPTED_PASSWORD)) {
+ String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_PASSWORD), uri, true);
+ values.put(Passwords.ENCRYPTED_PASSWORD, res);
+ values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR);
+ }
+
+ if (values.containsKey(Passwords.ENCRYPTED_USERNAME)) {
+ String res = doCrypto(values.getAsString(Passwords.ENCRYPTED_USERNAME), uri, true);
+ values.put(Passwords.ENCRYPTED_USERNAME, res);
+ values.put(Passwords.ENC_TYPE, Passwords.ENCTYPE_SDR);
+ }
+ }
+
+ @Override
+ public void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db) {
+ int passwordIndex = -1;
+ int usernameIndex = -1;
+ String profilePath = null;
+
+ try {
+ passwordIndex = cursor.getColumnIndexOrThrow(Passwords.ENCRYPTED_PASSWORD);
+ } catch (Exception ex) { }
+ try {
+ usernameIndex = cursor.getColumnIndexOrThrow(Passwords.ENCRYPTED_USERNAME);
+ } catch (Exception ex) { }
+
+ if (passwordIndex > -1 || usernameIndex > -1) {
+ MatrixBlobCursor m = (MatrixBlobCursor)cursor;
+ if (cursor.moveToFirst()) {
+ do {
+ if (passwordIndex > -1) {
+ String decrypted = doCrypto(cursor.getString(passwordIndex), uri, false);;
+ m.set(passwordIndex, decrypted);
+ }
+
+ if (usernameIndex > -1) {
+ String decrypted = doCrypto(cursor.getString(usernameIndex), uri, false);
+ m.set(usernameIndex, decrypted);
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java
new file mode 100644
index 000000000..7075c6e8a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabaseProvider.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.db.PerProfileDatabases.DatabaseHelperFactory;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteOpenHelper;
+
+/**
+ * Abstract class containing methods needed to make a SQLite-based content
+ * provider with a database helper of type T, where one database helper is
+ * held per profile.
+ */
+public abstract class PerProfileDatabaseProvider<T extends SQLiteOpenHelper> extends AbstractPerProfileDatabaseProvider {
+ private PerProfileDatabases<T> databases;
+
+ @Override
+ protected PerProfileDatabases<T> getDatabases() {
+ return databases;
+ }
+
+ protected abstract String getDatabaseName();
+
+ /**
+ * Creates and returns an instance of the appropriate DB helper.
+ *
+ * @param context to use to create the database helper
+ * @param databasePath path to the DB file
+ * @return instance of the database helper
+ */
+ protected abstract T createDatabaseHelper(Context context, String databasePath);
+
+ @Override
+ public boolean onCreate() {
+ synchronized (this) {
+ databases = new PerProfileDatabases<T>(
+ getContext(), getDatabaseName(), new DatabaseHelperFactory<T>() {
+ @Override
+ public T makeDatabaseHelper(Context context, String databasePath) {
+ final T helper = createDatabaseHelper(context, databasePath);
+ if (Versions.feature16Plus) {
+ helper.setWriteAheadLoggingEnabled(true);
+ }
+ return helper;
+ }
+ });
+ }
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java
new file mode 100644
index 000000000..288d9cae7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/PerProfileDatabases.java
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.File;
+import java.util.HashMap;
+
+import org.mozilla.gecko.GeckoProfile;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.text.TextUtils;
+
+/**
+ * Manages a set of per-profile database storage helpers.
+ */
+public class PerProfileDatabases<T extends SQLiteOpenHelper> {
+
+ private final HashMap<String, T> mStorages = new HashMap<String, T>();
+
+ private final Context mContext;
+ private final String mDatabaseName;
+ private final DatabaseHelperFactory<T> mHelperFactory;
+
+ // Only used during tests.
+ public void shutdown() {
+ synchronized (this) {
+ for (T t : mStorages.values()) {
+ try {
+ t.close();
+ } catch (Throwable e) {
+ // Never mind.
+ }
+ }
+ }
+ }
+
+ public interface DatabaseHelperFactory<T> {
+ public T makeDatabaseHelper(Context context, String databasePath);
+ }
+
+ public PerProfileDatabases(final Context context, final String databaseName, final DatabaseHelperFactory<T> helperFactory) {
+ mContext = context;
+ mDatabaseName = databaseName;
+ mHelperFactory = helperFactory;
+ }
+
+ public String getDatabasePathForProfile(String profile) {
+ final File profileDir = GeckoProfile.get(mContext, profile).getDir();
+ if (profileDir == null) {
+ return null;
+ }
+
+ return new File(profileDir, mDatabaseName).getAbsolutePath();
+ }
+
+ public T getDatabaseHelperForProfile(String profile) {
+ return getDatabaseHelperForProfile(profile, false);
+ }
+
+ public T getDatabaseHelperForProfile(String profile, boolean isTest) {
+ // Always fall back to default profile if none has been provided.
+ if (profile == null) {
+ profile = GeckoProfile.get(mContext).getName();
+ }
+
+ synchronized (this) {
+ if (mStorages.containsKey(profile)) {
+ return mStorages.get(profile);
+ }
+
+ final String databasePath = isTest ? mDatabaseName : getDatabasePathForProfile(profile);
+ if (databasePath == null) {
+ throw new IllegalStateException("Database path is null for profile: " + profile);
+ }
+
+ final T helper = mHelperFactory.makeDatabaseHelper(mContext, databasePath);
+ DBUtils.ensureDatabaseIsNotLocked(helper, databasePath);
+
+ mStorages.put(profile, helper);
+ return helper;
+ }
+ }
+
+ public synchronized void shrinkMemory() {
+ for (T t : mStorages.values()) {
+ final SQLiteDatabase db = t.getWritableDatabase();
+ db.execSQL("PRAGMA shrink_memory");
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java b/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java
new file mode 100644
index 000000000..07f057c11
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/RemoteClient.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.util.ArrayList;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A thin representation of a remote client.
+ * <p>
+ * We use the hash of the client's GUID as the ID elsewhere.
+ */
+public class RemoteClient implements Parcelable {
+ public final String guid;
+ public final String name;
+ public final long lastModified;
+ public final String deviceType;
+ public final ArrayList<RemoteTab> tabs;
+
+ public RemoteClient(String guid, String name, long lastModified, String deviceType) {
+ this.guid = guid;
+ this.name = name;
+ this.lastModified = lastModified;
+ this.deviceType = deviceType;
+ this.tabs = new ArrayList<>();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int flags) {
+ parcel.writeString(guid);
+ parcel.writeString(name);
+ parcel.writeLong(lastModified);
+ parcel.writeString(deviceType);
+ parcel.writeTypedList(tabs);
+ }
+
+ public static final Creator<RemoteClient> CREATOR = new Creator<RemoteClient>() {
+ @Override
+ public RemoteClient createFromParcel(final Parcel source) {
+ final String guid = source.readString();
+ final String name = source.readString();
+ final long lastModified = source.readLong();
+ final String deviceType = source.readString();
+
+ final RemoteClient client = new RemoteClient(guid, name, lastModified, deviceType);
+ source.readTypedList(client.tabs, RemoteTab.CREATOR);
+
+ return client;
+ }
+
+ @Override
+ public RemoteClient[] newArray(final int size) {
+ return new RemoteClient[size];
+ }
+ };
+
+ public boolean isDesktop() {
+ return "desktop".equals(deviceType);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java b/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java
new file mode 100644
index 000000000..f7660c1f7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/RemoteTab.java
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A thin representation of a remote tab.
+ * <p>
+ * These are generated functions.
+ */
+public class RemoteTab implements Parcelable {
+ public final String title;
+ public final String url;
+ public final long lastUsed;
+
+ public RemoteTab(String title, String url, long lastUsed) {
+ this.title = title;
+ this.url = url;
+ this.lastUsed = lastUsed;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int flags) {
+ parcel.writeString(title);
+ parcel.writeString(url);
+ parcel.writeLong(lastUsed);
+ }
+
+ public static final Creator<RemoteTab> CREATOR = new Creator<RemoteTab>() {
+ @Override
+ public RemoteTab createFromParcel(final Parcel source) {
+ final String title = source.readString();
+ final String url = source.readString();
+ final long lastUsed = source.readLong();
+ return new RemoteTab(title, url, lastUsed);
+ }
+
+ @Override
+ public RemoteTab[] newArray(final int size) {
+ return new RemoteTab[size];
+ }
+ };
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((title == null) ? 0 : title.hashCode());
+ result = prime * result + ((url == null) ? 0 : url.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ RemoteTab other = (RemoteTab) obj;
+ if (title == null) {
+ if (other.title != null) {
+ return false;
+ }
+ } else if (!title.equals(other.title)) {
+ return false;
+ }
+ if (url == null) {
+ if (other.url != null) {
+ return false;
+ }
+ } else if (!url.equals(other.url)) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java
new file mode 100644
index 000000000..d48604f03
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/SQLiteBridgeContentProvider.java
@@ -0,0 +1,471 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.io.File;
+import java.util.HashMap;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.gecko.sqlite.SQLiteBridge;
+import org.mozilla.gecko.sqlite.SQLiteBridgeException;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+/*
+ * Provides a basic ContentProvider that sets up and sends queries through
+ * SQLiteBridge. Content providers should extend this by setting the appropriate
+ * table and version numbers in onCreate, and implementing the abstract methods:
+ *
+ * public abstract String getTable(Uri uri);
+ * public abstract String getSortOrder(Uri uri, String aRequested);
+ * public abstract void setupDefaults(Uri uri, ContentValues values);
+ * public abstract void initGecko();
+ */
+
+public abstract class SQLiteBridgeContentProvider extends ContentProvider {
+ private static final String ERROR_MESSAGE_DATABASE_IS_LOCKED = "Can't step statement: (5) database is locked";
+
+ private HashMap<String, SQLiteBridge> mDatabasePerProfile;
+ protected Context mContext;
+ private final String mLogTag;
+
+ protected SQLiteBridgeContentProvider(String logTag) {
+ mLogTag = logTag;
+ }
+
+ /**
+ * Subclasses must override this to allow error reporting code to compose
+ * the correct histogram name.
+ *
+ * Ensure that you define the new histograms if you define a new class!
+ */
+ protected abstract String getTelemetryPrefix();
+
+ /**
+ * Errors are recorded in telemetry using an enumerated histogram.
+ *
+ * <https://developer.mozilla.org/en-US/docs/Mozilla/Performance/
+ * Adding_a_new_Telemetry_probe#Choosing_a_Histogram_Type>
+ *
+ * These are the allowable enumeration values. Keep these in sync with the
+ * histogram definition!
+ *
+ */
+ private static enum TelemetryErrorOp {
+ BULKINSERT (0),
+ DELETE (1),
+ INSERT (2),
+ QUERY (3),
+ UPDATE (4);
+
+ private final int bucket;
+
+ TelemetryErrorOp(final int bucket) {
+ this.bucket = bucket;
+ }
+
+ public int getBucket() {
+ return bucket;
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ if (mDatabasePerProfile == null) {
+ return;
+ }
+
+ synchronized (this) {
+ for (SQLiteBridge bridge : mDatabasePerProfile.values()) {
+ if (bridge != null) {
+ try {
+ bridge.close();
+ } catch (Exception ex) { }
+ }
+ }
+ mDatabasePerProfile = null;
+ }
+ super.shutdown();
+ }
+
+ @Override
+ public void finalize() {
+ shutdown();
+ }
+
+ /**
+ * Return true of the query is from Firefox Sync.
+ * @param uri query URI
+ */
+ public static boolean isCallerSync(Uri uri) {
+ String isSync = uri.getQueryParameter(BrowserContract.PARAM_IS_SYNC);
+ return !TextUtils.isEmpty(isSync);
+ }
+
+ private SQLiteBridge getDB(Context context, final String databasePath) {
+ SQLiteBridge bridge = null;
+
+ boolean dbNeedsSetup = true;
+ try {
+ String resourcePath = context.getPackageResourcePath();
+ GeckoLoader.loadSQLiteLibs(context, resourcePath);
+ GeckoLoader.loadNSSLibs(context, resourcePath);
+ bridge = SQLiteBridge.openDatabase(databasePath, null, 0);
+ int version = bridge.getVersion();
+ dbNeedsSetup = version != getDBVersion();
+ } catch (SQLiteBridgeException ex) {
+ // close the database
+ if (bridge != null) {
+ bridge.close();
+ }
+
+ // this will throw if the database can't be found
+ // we should attempt to set it up if Gecko is running
+ dbNeedsSetup = true;
+ Log.e(mLogTag, "Error getting version ", ex);
+
+ // if Gecko is not running, we should bail out. Otherwise we try to
+ // let Gecko build the database for us
+ if (!GeckoThread.isRunning()) {
+ Log.e(mLogTag, "Can not set up database. Gecko is not running");
+ return null;
+ }
+ }
+
+ // If the database is not set up yet, or is the wrong schema version, we send an initialize
+ // call to Gecko. Gecko will handle building the database file correctly, as well as any
+ // migrations that are necessary
+ if (dbNeedsSetup) {
+ bridge = null;
+ initGecko();
+ }
+ return bridge;
+ }
+
+ /**
+ * Returns the absolute path of a database file depending on the specified profile and dbName.
+ * @param profile
+ * the profile whose dbPath must be returned
+ * @param dbName
+ * the name of the db file whose absolute path must be returned
+ * @return the absolute path of the db file or <code>null</code> if it was not possible to retrieve a valid path
+ *
+ */
+ private String getDatabasePathForProfile(String profile, String dbName) {
+ // Depends on the vagaries of GeckoProfile.get, so null check for safety.
+ File profileDir = GeckoProfile.get(mContext, profile).getDir();
+ if (profileDir == null) {
+ return null;
+ }
+
+ String databasePath = new File(profileDir, dbName).getAbsolutePath();
+ return databasePath;
+ }
+
+ /**
+ * Returns a SQLiteBridge object according to the specified profile id and to the name of db related to the
+ * current provider instance.
+ * @param profile
+ * the id of the profile to be used to retrieve the related SQLiteBridge
+ * @return the <code>SQLiteBridge</code> related to the specified profile id or <code>null</code> if it was
+ * not possible to retrieve a valid SQLiteBridge
+ */
+ private SQLiteBridge getDatabaseForProfile(String profile) {
+ if (profile == null) {
+ profile = GeckoProfile.get(mContext).getName();
+ Log.d(mLogTag, "No profile provided, using '" + profile + "'");
+ }
+
+ final String dbName = getDBName();
+ String mapKey = profile + "/" + dbName;
+
+ SQLiteBridge db = null;
+ synchronized (this) {
+ db = mDatabasePerProfile.get(mapKey);
+ if (db != null) {
+ return db;
+ }
+ final String dbPath = getDatabasePathForProfile(profile, dbName);
+ if (dbPath == null) {
+ Log.e(mLogTag, "Failed to get a valid db path for profile '" + profile + "'' dbName '" + dbName + "'");
+ return null;
+ }
+ db = getDB(mContext, dbPath);
+ if (db != null) {
+ mDatabasePerProfile.put(mapKey, db);
+ }
+ }
+ return db;
+ }
+
+ /**
+ * Returns a SQLiteBridge object according to the specified profile path and to the name of db related to the
+ * current provider instance.
+ * @param profilePath
+ * the profilePath to be used to retrieve the related SQLiteBridge
+ * @return the <code>SQLiteBridge</code> related to the specified profile path or <code>null</code> if it was
+ * not possible to retrieve a valid <code>SQLiteBridge</code>
+ */
+ private SQLiteBridge getDatabaseForProfilePath(String profilePath) {
+ File profileDir = new File(profilePath, getDBName());
+ final String dbPath = profileDir.getPath();
+ return getDatabaseForDBPath(dbPath);
+ }
+
+ /**
+ * Returns a SQLiteBridge object according to the specified file path.
+ * @param dbPath
+ * the path of the file to be used to retrieve the related SQLiteBridge
+ * @return the <code>SQLiteBridge</code> related to the specified file path or <code>null</code> if it was
+ * not possible to retrieve a valid <code>SQLiteBridge</code>
+ *
+ */
+ private SQLiteBridge getDatabaseForDBPath(String dbPath) {
+ SQLiteBridge db = null;
+ synchronized (this) {
+ db = mDatabasePerProfile.get(dbPath);
+ if (db != null) {
+ return db;
+ }
+ db = getDB(mContext, dbPath);
+ if (db != null) {
+ mDatabasePerProfile.put(dbPath, db);
+ }
+ }
+ return db;
+ }
+
+ /**
+ * Returns a SQLiteBridge object to be used to perform operations on the given <code>Uri</code>.
+ * @param uri
+ * the <code>Uri</code> to be used to retrieve the related SQLiteBridge
+ * @return a <code>SQLiteBridge</code> object to be used on the given uri or <code>null</code> if it was
+ * not possible to retrieve a valid <code>SQLiteBridge</code>
+ *
+ */
+ private SQLiteBridge getDatabase(Uri uri) {
+ String profile = null;
+ String profilePath = null;
+
+ profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE);
+ profilePath = uri.getQueryParameter(BrowserContract.PARAM_PROFILE_PATH);
+
+ // Testing will specify the absolute profile path
+ if (profilePath != null) {
+ return getDatabaseForProfilePath(profilePath);
+ }
+ return getDatabaseForProfile(profile);
+ }
+
+ @Override
+ public boolean onCreate() {
+ mContext = getContext();
+ synchronized (this) {
+ mDatabasePerProfile = new HashMap<String, SQLiteBridge>();
+ }
+ return true;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return null;
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ int deleted = 0;
+ final SQLiteBridge db = getDatabase(uri);
+ if (db == null) {
+ return deleted;
+ }
+
+ try {
+ deleted = db.delete(getTable(uri), selection, selectionArgs);
+ } catch (SQLiteBridgeException ex) {
+ reportError(ex, TelemetryErrorOp.DELETE);
+ throw ex;
+ }
+
+ return deleted;
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ long id = -1;
+ final SQLiteBridge db = getDatabase(uri);
+
+ // If we can not get a SQLiteBridge instance, its likely that the database
+ // has not been set up and Gecko is not running. We return null and expect
+ // callers to try again later
+ if (db == null) {
+ return null;
+ }
+
+ setupDefaults(uri, values);
+
+ boolean useTransaction = !db.inTransaction();
+ try {
+ if (useTransaction) {
+ db.beginTransaction();
+ }
+
+ // onPreInsert does a check for the item in the deleted table in some cases
+ // so we put it inside this transaction
+ onPreInsert(values, uri, db);
+ id = db.insert(getTable(uri), null, values);
+
+ if (useTransaction) {
+ db.setTransactionSuccessful();
+ }
+ } catch (SQLiteBridgeException ex) {
+ reportError(ex, TelemetryErrorOp.INSERT);
+ throw ex;
+ } finally {
+ if (useTransaction) {
+ db.endTransaction();
+ }
+ }
+
+ return ContentUris.withAppendedId(uri, id);
+ }
+
+ @Override
+ public int bulkInsert(Uri uri, ContentValues[] allValues) {
+ final SQLiteBridge db = getDatabase(uri);
+ // If we can not get a SQLiteBridge instance, its likely that the database
+ // has not been set up and Gecko is not running. We return 0 and expect
+ // callers to try again later
+ if (db == null) {
+ return 0;
+ }
+
+ int rowsAdded = 0;
+
+ String table = getTable(uri);
+
+ try {
+ db.beginTransaction();
+ for (ContentValues initialValues : allValues) {
+ ContentValues values = new ContentValues(initialValues);
+ setupDefaults(uri, values);
+ onPreInsert(values, uri, db);
+ db.insert(table, null, values);
+ rowsAdded++;
+ }
+ db.setTransactionSuccessful();
+ } catch (SQLiteBridgeException ex) {
+ reportError(ex, TelemetryErrorOp.BULKINSERT);
+ throw ex;
+ } finally {
+ db.endTransaction();
+ }
+
+ if (rowsAdded > 0) {
+ final boolean shouldSyncToNetwork = !isCallerSync(uri);
+ mContext.getContentResolver().notifyChange(uri, null, shouldSyncToNetwork);
+ }
+
+ return rowsAdded;
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ int updated = 0;
+ final SQLiteBridge db = getDatabase(uri);
+
+ // If we can not get a SQLiteBridge instance, its likely that the database
+ // has not been set up and Gecko is not running. We return null and expect
+ // callers to try again later
+ if (db == null) {
+ return updated;
+ }
+
+ onPreUpdate(values, uri, db);
+
+ try {
+ updated = db.update(getTable(uri), values, selection, selectionArgs);
+ } catch (SQLiteBridgeException ex) {
+ reportError(ex, TelemetryErrorOp.UPDATE);
+ throw ex;
+ }
+
+ return updated;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ Cursor cursor = null;
+ final SQLiteBridge db = getDatabase(uri);
+
+ // If we can not get a SQLiteBridge instance, its likely that the database
+ // has not been set up and Gecko is not running. We return null and expect
+ // callers to try again later
+ if (db == null) {
+ return cursor;
+ }
+
+ sortOrder = getSortOrder(uri, sortOrder);
+
+ try {
+ cursor = db.query(getTable(uri), projection, selection, selectionArgs, null, null, sortOrder, null);
+ onPostQuery(cursor, uri, db);
+ } catch (SQLiteBridgeException ex) {
+ reportError(ex, TelemetryErrorOp.QUERY);
+ throw ex;
+ }
+
+ return cursor;
+ }
+
+ private String getHistogram(SQLiteBridgeException e) {
+ // If you add values here, make sure to update
+ // toolkit/components/telemetry/Histograms.json.
+ if (ERROR_MESSAGE_DATABASE_IS_LOCKED.equals(e.getMessage())) {
+ return getTelemetryPrefix() + "_LOCKED";
+ }
+ return null;
+ }
+
+ protected void reportError(SQLiteBridgeException e, TelemetryErrorOp op) {
+ Log.e(mLogTag, "Error in database " + op.name(), e);
+ final String histogram = getHistogram(e);
+ if (histogram == null) {
+ return;
+ }
+
+ Telemetry.addToHistogram(histogram, op.getBucket());
+ }
+
+ protected abstract String getDBName();
+
+ protected abstract int getDBVersion();
+
+ protected abstract String getTable(Uri uri);
+
+ protected abstract String getSortOrder(Uri uri, String aRequested);
+
+ protected abstract void setupDefaults(Uri uri, ContentValues values);
+
+ protected abstract void initGecko();
+
+ protected abstract void onPreInsert(ContentValues values, Uri uri, SQLiteBridge db);
+
+ protected abstract void onPreUpdate(ContentValues values, Uri uri, SQLiteBridge db);
+
+ protected abstract void onPostQuery(Cursor cursor, Uri uri, SQLiteBridge db);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java
new file mode 100644
index 000000000..05d31fefd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/SearchHistoryProvider.java
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.SearchHistory;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class SearchHistoryProvider extends SharedBrowserDatabaseProvider {
+ private static final String LOG_TAG = "GeckoSearchProvider";
+ private static final boolean DEBUG_ENABLED = false;
+
+ /**
+ * Collapse whitespace.
+ */
+ private String stripWhitespace(String query) {
+ if (TextUtils.isEmpty(query)) {
+ return "";
+ }
+
+ // Collapse whitespace
+ return query.trim().replaceAll("\\s+", " ");
+ }
+
+
+ @Override
+ public Uri insertInTransaction(Uri uri, ContentValues cv) {
+ final String query = stripWhitespace(cv.getAsString(SearchHistory.QUERY));
+
+ // We don't support inserting empty search queries.
+ if (TextUtils.isEmpty(query)) {
+ return null;
+ }
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ long id = -1;
+
+ /*
+ * Attempt to insert the query. The catch block handles the case when
+ * the query already exists in the DB.
+ */
+ try {
+ cv.put(SearchHistory.QUERY, query);
+ cv.put(SearchHistory.VISITS, 1);
+ cv.put(SearchHistory.DATE_LAST_VISITED, System.currentTimeMillis());
+
+ id = db.insertOrThrow(SearchHistory.TABLE_NAME, null, cv);
+
+ if (id > 0) {
+ return ContentUris.withAppendedId(uri, id);
+ }
+ } catch (SQLException e) {
+ // This happens when the column already exists for this term.
+ if (DEBUG_ENABLED) {
+ Log.w(LOG_TAG, String.format("Query `%s` already in db", query));
+ }
+ }
+
+ /*
+ * Increment the VISITS counter and update the DATE_LAST_VISITED.
+ */
+ final String sql = "UPDATE " + SearchHistory.TABLE_NAME + " SET " +
+ SearchHistory.VISITS + " = " + SearchHistory.VISITS + " + 1, " +
+ SearchHistory.DATE_LAST_VISITED + " = " + System.currentTimeMillis() +
+ " WHERE " + SearchHistory.QUERY + " = ?";
+
+ final Cursor c = db.rawQuery(sql, new String[] { query });
+
+ try {
+ if (c.getCount() > 1) {
+ // There is a UNIQUE constraint on the QUERY column,
+ // so there should only be one match.
+ return null;
+ }
+ if (c.moveToFirst()) {
+ return ContentUris.withAppendedId(uri, c.getInt(c.getColumnIndex(SearchHistory._ID)));
+ }
+ } finally {
+ c.close();
+ }
+
+ return null;
+ }
+
+ @Override
+ public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+ return getWritableDatabase(uri).delete(SearchHistory.TABLE_NAME,
+ selection, selectionArgs);
+ }
+
+ /**
+ * Since we are managing counts and the full-text db, an update
+ * could mangle the internal state. So we disable it.
+ */
+ @Override
+ public int updateInTransaction(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ throw new UnsupportedOperationException("This content provider does not support updating items");
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ final String groupBy = null;
+ final String having = null;
+ final String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+ final Cursor cursor = getReadableDatabase(uri).query(SearchHistory.TABLE_NAME, projection,
+ selection, selectionArgs, groupBy, having, sortOrder, limit);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return cursor;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return SearchHistory.CONTENT_TYPE;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/Searches.java b/mobile/android/base/java/org/mozilla/gecko/db/Searches.java
new file mode 100644
index 000000000..e050a4f93
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/Searches.java
@@ -0,0 +1,12 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentResolver;
+
+public interface Searches {
+ public void insert(ContentResolver cr, String query);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java
new file mode 100644
index 000000000..8be18c089
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/SharedBrowserDatabaseProvider.java
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.db.BrowserContract.CommonColumns;
+import org.mozilla.gecko.db.BrowserContract.SyncColumns;
+import org.mozilla.gecko.db.PerProfileDatabases.DatabaseHelperFactory;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.util.Log;
+
+/**
+ * A ContentProvider subclass that provides per-profile browser.db access
+ * that can be safely shared between multiple providers.
+ *
+ * If multiple ContentProvider classes wish to share a database, it's
+ * vitally important that they use the same SQLiteOpenHelpers for access.
+ *
+ * Failure to do so can cause accidental concurrent writes, with the result
+ * being unexpected SQLITE_BUSY errors.
+ *
+ * This class provides a static {@link PerProfileDatabases} instance, lazily
+ * initialized within {@link SharedBrowserDatabaseProvider#onCreate()}.
+ */
+public abstract class SharedBrowserDatabaseProvider extends AbstractPerProfileDatabaseProvider {
+ private static final String LOGTAG = SharedBrowserDatabaseProvider.class.getSimpleName();
+
+ private static PerProfileDatabases<BrowserDatabaseHelper> databases;
+
+ @Override
+ protected PerProfileDatabases<BrowserDatabaseHelper> getDatabases() {
+ return databases;
+ }
+
+ @Override
+ public void shutdown() {
+ synchronized (SharedBrowserDatabaseProvider.class) {
+ databases.shutdown();
+ databases = null;
+ }
+ }
+
+ @Override
+ public boolean onCreate() {
+ // If necessary, do the shared DB work.
+ synchronized (SharedBrowserDatabaseProvider.class) {
+ if (databases != null) {
+ return true;
+ }
+
+ final DatabaseHelperFactory<BrowserDatabaseHelper> helperFactory = new DatabaseHelperFactory<BrowserDatabaseHelper>() {
+ @Override
+ public BrowserDatabaseHelper makeDatabaseHelper(Context context, String databasePath) {
+ final BrowserDatabaseHelper helper = new BrowserDatabaseHelper(context, databasePath);
+ if (Versions.feature16Plus) {
+ helper.setWriteAheadLoggingEnabled(true);
+ }
+ return helper;
+ }
+ };
+
+ databases = new PerProfileDatabases<BrowserDatabaseHelper>(getContext(), BrowserDatabaseHelper.DATABASE_NAME, helperFactory);
+ }
+
+ return true;
+ }
+
+ /**
+ * Clean up some deleted records from the specified table.
+ *
+ * If called in an existing transaction, it is the caller's responsibility
+ * to ensure that the transaction is already upgraded to a writer, because
+ * this method issues a read followed by a write, and thus is potentially
+ * vulnerable to an unhandled SQLITE_BUSY failure during the upgrade.
+ *
+ * If not called in an existing transaction, no new explicit transaction
+ * will be begun.
+ */
+ protected void cleanUpSomeDeletedRecords(Uri fromUri, String tableName) {
+ Log.d(LOGTAG, "Cleaning up deleted records from " + tableName);
+
+ // We clean up records marked as deleted that are older than a
+ // predefined max age. It's important not be too greedy here and
+ // remove only a few old deleted records at a time.
+
+ // we cleanup records marked as deleted that are older than a
+ // predefined max age. It's important not be too greedy here and
+ // remove only a few old deleted records at a time.
+
+ // Maximum age of deleted records to be cleaned up (20 days in ms)
+ final long MAX_AGE_OF_DELETED_RECORDS = 86400000 * 20;
+
+ // Number of records marked as deleted to be removed
+ final long DELETED_RECORDS_PURGE_LIMIT = 5;
+
+ // Android SQLite doesn't have LIMIT on DELETE. Instead, query for the
+ // IDs of matching rows, then delete them in one go.
+ final long now = System.currentTimeMillis();
+ final String selection = getDeletedItemSelection(now - MAX_AGE_OF_DELETED_RECORDS);
+
+ final String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE);
+ final SQLiteDatabase db = getWritableDatabaseForProfile(profile, isTest(fromUri));
+ final String limit = Long.toString(DELETED_RECORDS_PURGE_LIMIT, 10);
+ final Cursor cursor = db.query(tableName, new String[] { CommonColumns._ID }, selection, null, null, null, null, limit);
+ final String inClause;
+ try {
+ inClause = DBUtils.computeSQLInClauseFromLongs(cursor, CommonColumns._ID);
+ } finally {
+ cursor.close();
+ }
+
+ db.delete(tableName, inClause, null);
+ }
+
+ // Override this, or override cleanUpSomeDeletedRecords.
+ protected String getDeletedItemSelection(long earlierThan) {
+ if (earlierThan == -1L) {
+ return SyncColumns.IS_DELETED + " = 1";
+ }
+ return SyncColumns.IS_DELETED + " = 1 AND " + SyncColumns.DATE_MODIFIED + " <= " + earlierThan;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java b/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
new file mode 100644
index 000000000..89b12904b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/SuggestedSites.java
@@ -0,0 +1,629 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.Context;
+import android.content.ContentResolver;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.RawResource;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+/**
+ * {@code SuggestedSites} provides API to get a list of locale-specific
+ * suggested sites to be used in Fennec's top sites panel. It provides
+ * only a single method to fetch the list as a {@code Cursor}. This cursor
+ * will then be wrapped by {@code TopSitesCursorWrapper} to blend top,
+ * pinned, and suggested sites in the UI. The returned {@code Cursor}
+ * uses its own schema defined in {@code BrowserContract.SuggestedSites}
+ * for clarity.
+ *
+ * Under the hood, {@code SuggestedSites} keeps reference to the
+ * parsed list of sites to avoid reparsing the JSON file on every
+ * {@code get()} call.
+ *
+ * The default list of suggested sites is stored in a raw Android
+ * resource ({@code R.raw.suggestedsites}) which is dynamically
+ * generated at build time for each target locale.
+ *
+ * Changes to the list of suggested sites are saved in SharedPreferences.
+ */
+@RobocopTarget
+public class SuggestedSites {
+ private static final String LOGTAG = "GeckoSuggestedSites";
+
+ // SharedPreference key for suggested sites that should be hidden.
+ public static final String PREF_SUGGESTED_SITES_HIDDEN = GeckoPreferences.NON_PREF_PREFIX + "suggestedSites.hidden";
+ public static final String PREF_SUGGESTED_SITES_HIDDEN_OLD = "suggestedSites.hidden";
+
+ // Locale used to generate the current suggested sites.
+ public static final String PREF_SUGGESTED_SITES_LOCALE = GeckoPreferences.NON_PREF_PREFIX + "suggestedSites.locale";
+ public static final String PREF_SUGGESTED_SITES_LOCALE_OLD = "suggestedSites.locale";
+
+ // File in profile dir with the list of suggested sites.
+ private static final String FILENAME = "suggestedsites.json";
+
+ private static final String[] COLUMNS = new String[] {
+ BrowserContract.SuggestedSites._ID,
+ BrowserContract.SuggestedSites.URL,
+ BrowserContract.SuggestedSites.TITLE,
+ BrowserContract.Combined.HISTORY_ID
+ };
+
+ private static final String JSON_KEY_URL = "url";
+ private static final String JSON_KEY_TITLE = "title";
+ private static final String JSON_KEY_IMAGE_URL = "imageurl";
+ private static final String JSON_KEY_BG_COLOR = "bgcolor";
+ private static final String JSON_KEY_RESTRICTED = "restricted";
+
+ private static class Site {
+ public final String url;
+ public final String title;
+ public final String imageUrl;
+ public final String bgColor;
+ public final boolean restricted;
+
+ public Site(JSONObject json) throws JSONException {
+ this.restricted = !json.isNull(JSON_KEY_RESTRICTED);
+ this.url = json.getString(JSON_KEY_URL);
+ this.title = json.getString(JSON_KEY_TITLE);
+ this.imageUrl = json.getString(JSON_KEY_IMAGE_URL);
+ this.bgColor = json.getString(JSON_KEY_BG_COLOR);
+
+ validate();
+ }
+
+ public Site(String url, String title, String imageUrl, String bgColor) {
+ this.url = url;
+ this.title = title;
+ this.imageUrl = imageUrl;
+ this.bgColor = bgColor;
+ this.restricted = false;
+
+ validate();
+ }
+
+ private void validate() {
+ // Site instances must have non-empty values for all properties except IDs.
+ if (TextUtils.isEmpty(url) ||
+ TextUtils.isEmpty(title) ||
+ TextUtils.isEmpty(imageUrl) ||
+ TextUtils.isEmpty(bgColor)) {
+ throw new IllegalStateException("Suggested sites must have a URL, title, " +
+ "image URL, and background color.");
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "{ url = " + url + "\n" +
+ "restricted = " + restricted + "\n" +
+ "title = " + title + "\n" +
+ "imageUrl = " + imageUrl + "\n" +
+ "bgColor = " + bgColor + " }";
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ if (restricted) {
+ json.put(JSON_KEY_RESTRICTED, true);
+ }
+
+ json.put(JSON_KEY_URL, url);
+ json.put(JSON_KEY_TITLE, title);
+ json.put(JSON_KEY_IMAGE_URL, imageUrl);
+ json.put(JSON_KEY_BG_COLOR, bgColor);
+
+ return json;
+ }
+ }
+
+ final Context context;
+ final Distribution distribution;
+ private File cachedFile;
+ private Map<String, Site> cachedSites;
+ private Set<String> cachedBlacklist;
+
+ public SuggestedSites(Context appContext) {
+ this(appContext, null);
+ }
+
+ public SuggestedSites(Context appContext, Distribution distribution) {
+ this(appContext, distribution, null);
+ }
+
+ public SuggestedSites(Context appContext, Distribution distribution, File file) {
+ this.context = appContext;
+ this.distribution = distribution;
+ this.cachedFile = file;
+ }
+
+ synchronized File getFile() {
+ if (cachedFile == null) {
+ cachedFile = GeckoProfile.get(context).getFile(FILENAME);
+ }
+ return cachedFile;
+ }
+
+ private static boolean isNewLocale(Context context, Locale requestedLocale) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+
+ String locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE_OLD, null);
+ if (locale != null) {
+ // Migrate the old pref and remove it
+ final Editor editor = prefs.edit();
+ editor.remove(PREF_SUGGESTED_SITES_LOCALE_OLD);
+ editor.putString(PREF_SUGGESTED_SITES_LOCALE, locale);
+ editor.apply();
+ } else {
+ locale = prefs.getString(PREF_SUGGESTED_SITES_LOCALE, null);
+ }
+ if (locale == null) {
+ // Initialize config with the current locale
+ updateSuggestedSitesLocale(context);
+ return true;
+ }
+
+ return !TextUtils.equals(requestedLocale.toString(), locale);
+ }
+
+ /**
+ * Return the current locale and its fallback (en_US) in order.
+ */
+ private static List<Locale> getAcceptableLocales() {
+ final List<Locale> locales = new ArrayList<Locale>();
+
+ final Locale defaultLocale = Locale.getDefault();
+ locales.add(defaultLocale);
+
+ if (!defaultLocale.equals(Locale.US)) {
+ locales.add(Locale.US);
+ }
+
+ return locales;
+ }
+
+ private static Map<String, Site> loadSites(File f) throws IOException {
+ Scanner scanner = null;
+
+ try {
+ scanner = new Scanner(f, "UTF-8");
+ return loadSites(scanner.useDelimiter("\\A").next());
+ } finally {
+ if (scanner != null) {
+ scanner.close();
+ }
+ }
+ }
+
+ private static Map<String, Site> loadSites(String jsonString) {
+ if (TextUtils.isEmpty(jsonString)) {
+ return null;
+ }
+
+ Map<String, Site> sites = null;
+
+ try {
+ final JSONArray jsonSites = new JSONArray(jsonString);
+ sites = new LinkedHashMap<String, Site>(jsonSites.length());
+
+ final int count = jsonSites.length();
+ for (int i = 0; i < count; i++) {
+ final Site site = new Site(jsonSites.getJSONObject(i));
+ sites.put(site.url, site);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to refresh suggested sites", e);
+ return null;
+ }
+
+ return sites;
+ }
+
+ /**
+ * Saves suggested sites file to disk. Access to this method should
+ * be synchronized on 'file'.
+ */
+ static void saveSites(File f, Map<String, Site> sites) {
+ ThreadUtils.assertNotOnUiThread();
+
+ if (sites == null || sites.isEmpty()) {
+ return;
+ }
+
+ OutputStreamWriter osw = null;
+
+ try {
+ final JSONArray jsonSites = new JSONArray();
+ for (Site site : sites.values()) {
+ jsonSites.put(site.toJSON());
+ }
+
+ osw = new OutputStreamWriter(new FileOutputStream(f), "UTF-8");
+
+ final String jsonString = jsonSites.toString();
+ osw.write(jsonString, 0, jsonString.length());
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to save suggested sites", e);
+ } finally {
+ if (osw != null) {
+ try {
+ osw.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+
+ private void maybeWaitForDistribution() {
+ if (distribution == null) {
+ return;
+ }
+
+ distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+ @Override
+ public void distributionNotFound() {
+ // If distribution doesn't exist, simply continue to load
+ // suggested sites directly from resources. See refresh().
+ }
+
+ @Override
+ public void distributionFound(Distribution distribution) {
+ Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
+ // Merge suggested sites from distribution with the
+ // default ones. Distribution takes precedence.
+ Map<String, Site> sites = loadFromDistribution(distribution);
+ if (sites == null) {
+ sites = new LinkedHashMap<String, Site>();
+ }
+ sites.putAll(loadFromResource());
+
+ // Update cached list of sites.
+ setCachedSites(sites);
+
+ // Save the result to disk.
+ final File file = getFile();
+ synchronized (file) {
+ saveSites(file, sites);
+ }
+
+ // Then notify any active loaders about the changes.
+ final ContentResolver cr = context.getContentResolver();
+ cr.notifyChange(BrowserContract.SuggestedSites.CONTENT_URI, null);
+ }
+
+ @Override
+ public void distributionArrivedLate(Distribution distribution) {
+ distributionFound(distribution);
+ }
+ });
+ }
+
+ /**
+ * Loads suggested sites from a distribution file either matching the
+ * current locale or with the fallback locale (en-US).
+ *
+ * It's assumed that the given distribution instance is ready to be
+ * used and exists.
+ */
+ static Map<String, Site> loadFromDistribution(Distribution dist) {
+ for (Locale locale : getAcceptableLocales()) {
+ try {
+ final String languageTag = Locales.getLanguageTag(locale);
+ final String path = String.format("suggestedsites/locales/%s/%s",
+ languageTag, FILENAME);
+
+ final File f = dist.getDistributionFile(path);
+ if (f == null) {
+ Log.d(LOGTAG, "No suggested sites for locale: " + languageTag);
+ continue;
+ }
+
+ return loadSites(f);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to open suggested sites for locale " +
+ locale + " in distribution.", e);
+ }
+ }
+
+ return null;
+ }
+
+ private Map<String, Site> loadFromProfile() {
+ try {
+ final File file = getFile();
+ synchronized (file) {
+ return loadSites(file);
+ }
+ } catch (FileNotFoundException e) {
+ maybeWaitForDistribution();
+ } catch (IOException e) {
+ // Fall through, return null.
+ }
+
+ return null;
+ }
+
+ Map<String, Site> loadFromResource() {
+ try {
+ return loadSites(RawResource.getAsString(context, R.raw.suggestedsites));
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private synchronized void setCachedSites(Map<String, Site> sites) {
+ cachedSites = Collections.unmodifiableMap(sites);
+ updateSuggestedSitesLocale(context);
+ }
+
+ /**
+ * Refreshes the cached list of sites either from the default raw
+ * source or standard file location. This will be called on every
+ * cache miss during a {@code get()} call.
+ */
+ private void refresh() {
+ Log.d(LOGTAG, "Refreshing suggested sites from file");
+
+ Map<String, Site> sites = loadFromProfile();
+ if (sites == null) {
+ sites = loadFromResource();
+ }
+
+ // Update cached list of sites.
+ if (sites != null) {
+ setCachedSites(sites);
+ }
+ }
+
+ private static void updateSuggestedSitesLocale(Context context) {
+ final Editor editor = GeckoSharedPrefs.forProfile(context).edit();
+ editor.putString(PREF_SUGGESTED_SITES_LOCALE, Locale.getDefault().toString());
+ editor.apply();
+ }
+
+ private synchronized Site getSiteForUrl(String url) {
+ if (cachedSites == null) {
+ return null;
+ }
+
+ return cachedSites.get(url);
+ }
+
+ /**
+ * Returns a {@code Cursor} with the list of suggested websites.
+ *
+ * @param limit maximum number of suggested sites.
+ */
+ public Cursor get(int limit) {
+ return get(limit, Locale.getDefault());
+ }
+
+ /**
+ * Returns a {@code Cursor} with the list of suggested websites.
+ *
+ * @param limit maximum number of suggested sites.
+ * @param locale the target locale.
+ */
+ public Cursor get(int limit, Locale locale) {
+ return get(limit, locale, null);
+ }
+
+ /**
+ * Returns a {@code Cursor} with the list of suggested websites.
+ *
+ * @param limit maximum number of suggested sites.
+ * @param excludeUrls list of URLs to be excluded from the list.
+ */
+ public Cursor get(int limit, List<String> excludeUrls) {
+ return get(limit, Locale.getDefault(), excludeUrls);
+ }
+
+ /**
+ * Returns a {@code Cursor} with the list of suggested websites.
+ *
+ * @param limit maximum number of suggested sites.
+ * @param locale the target locale.
+ * @param excludeUrls list of URLs to be excluded from the list.
+ */
+ public synchronized Cursor get(int limit, Locale locale, List<String> excludeUrls) {
+ final MatrixCursor cursor = new MatrixCursor(COLUMNS);
+ final boolean isNewLocale = isNewLocale(context, locale);
+
+ // Force the suggested sites file in profile dir to be re-generated
+ // if the locale has changed.
+ if (isNewLocale) {
+ getFile().delete();
+ }
+
+ if (cachedSites == null || isNewLocale) {
+ Log.d(LOGTAG, "No cached sites, refreshing.");
+ refresh();
+ }
+
+ // Return empty cursor if there was an error when
+ // loading the suggested sites or the list is empty.
+ if (cachedSites == null || cachedSites.isEmpty()) {
+ return cursor;
+ }
+
+ excludeUrls = includeBlacklist(excludeUrls);
+
+ final int sitesCount = cachedSites.size();
+ Log.d(LOGTAG, "Number of suggested sites: " + sitesCount);
+
+ final int maxCount = Math.min(limit, sitesCount);
+ // History IDS: real history is positive, -1 is no history id in the combined table
+ // hence we can start at -2 for suggested sites
+ int id = -1;
+ for (Site site : cachedSites.values()) {
+ // Decrement ID here: this ensure we have a consistent ID to URL mapping, even if items
+ // are removed. If we instead decremented at the point of insertion we'd end up with
+ // ID conflicts when a suggested site is removed. (note that cachedSites does not change
+ // while we're already showing topsites)
+ --id;
+ if (cursor.getCount() == maxCount) {
+ break;
+ }
+
+ if (excludeUrls != null && excludeUrls.contains(site.url)) {
+ continue;
+ }
+
+ final boolean restrictedProfile = Restrictions.isRestrictedProfile(context);
+
+ if (restrictedProfile == site.restricted) {
+ final RowBuilder row = cursor.newRow();
+ row.add(id);
+ row.add(site.url);
+ row.add(site.title);
+ row.add(id);
+ }
+ }
+
+ cursor.setNotificationUri(context.getContentResolver(),
+ BrowserContract.SuggestedSites.CONTENT_URI);
+
+ return cursor;
+ }
+
+ public boolean contains(String url) {
+ return (getSiteForUrl(url) != null);
+ }
+
+ public String getImageUrlForUrl(String url) {
+ final Site site = getSiteForUrl(url);
+ return (site != null ? site.imageUrl : null);
+ }
+
+ public String getBackgroundColorForUrl(String url) {
+ final Site site = getSiteForUrl(url);
+ return (site != null ? site.bgColor : null);
+ }
+
+ private Set<String> loadBlacklist() {
+ Log.d(LOGTAG, "Loading blacklisted suggested sites from SharedPreferences.");
+ final Set<String> blacklist = new HashSet<String>();
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+ String sitesString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN_OLD, null);
+ if (sitesString != null) {
+ // Migrate the old pref and remove it
+ final Editor editor = prefs.edit();
+ editor.remove(PREF_SUGGESTED_SITES_HIDDEN_OLD);
+ editor.putString(PREF_SUGGESTED_SITES_HIDDEN, sitesString);
+ editor.apply();
+ } else {
+ sitesString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, null);
+ }
+
+ if (sitesString != null) {
+ for (String site : sitesString.trim().split(" ")) {
+ blacklist.add(Uri.decode(site));
+ }
+ }
+
+ return blacklist;
+ }
+
+ private List<String> includeBlacklist(List<String> originalList) {
+ if (cachedBlacklist == null) {
+ cachedBlacklist = loadBlacklist();
+ }
+
+ if (cachedBlacklist.isEmpty()) {
+ return originalList;
+ }
+
+ if (originalList == null) {
+ originalList = new ArrayList<String>();
+ }
+
+ originalList.addAll(cachedBlacklist);
+ return originalList;
+ }
+
+ /**
+ * Blacklist a suggested site so it will no longer be returned as a suggested site.
+ * This method should only be called from a background thread because it may write
+ * to SharedPreferences.
+ *
+ * Urls that are not Suggested Sites are ignored.
+ *
+ * @param url String url of site to blacklist
+ * @return true is blacklisted, false otherwise
+ */
+ public synchronized boolean hideSite(String url) {
+ ThreadUtils.assertNotOnUiThread();
+
+ if (cachedSites == null) {
+ refresh();
+ if (cachedSites == null) {
+ Log.w(LOGTAG, "Could not load suggested sites!");
+ return false;
+ }
+ }
+
+ if (cachedSites.containsKey(url)) {
+ if (cachedBlacklist == null) {
+ cachedBlacklist = loadBlacklist();
+ }
+
+ // Check if site has already been blacklisted, just in case.
+ if (!cachedBlacklist.contains(url)) {
+
+ saveToBlacklist(url);
+ cachedBlacklist.add(url);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void saveToBlacklist(String url) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+ final String prefString = prefs.getString(PREF_SUGGESTED_SITES_HIDDEN, "");
+ final String siteString = prefString.concat(" " + Uri.encode(url));
+ prefs.edit().putString(PREF_SUGGESTED_SITES_HIDDEN, siteString).apply();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/Table.java b/mobile/android/base/java/org/mozilla/gecko/db/Table.java
new file mode 100644
index 000000000..37a605ee1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/Table.java
@@ -0,0 +1,47 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+// Tables provide a basic wrapper around ContentProvider methods to make it simpler to add new tables into storage.
+// If you create a new Table type, make sure to add it to the sTables list in BrowserProvider to ensure it is queried.
+interface Table {
+ // Provides information to BrowserProvider about the type of URIs this Table can handle.
+ public static class ContentProviderInfo {
+ public final int id; // A number of ID for this table. Used by the UriMatcher in BrowserProvider
+ public final String name; // A name for this table. Will be appended onto uris querying this table
+ // This is also used to define the mimetype of data returned from this db, i.e.
+ // BrowserProvider will return "vnd.android.cursor.item/" + name
+
+ public ContentProviderInfo(int id, String name) {
+ if (name == null) {
+ throw new IllegalArgumentException("Content provider info must specify a name");
+ }
+ this.id = id;
+ this.name = name;
+ }
+ }
+
+ // Return a list of Info about the ContentProvider URIs this will match
+ ContentProviderInfo[] getContentProviderInfo();
+
+ // Called by BrowserDBHelper whenever the database is created or upgraded.
+ // Order in which tables are created/upgraded isn't guaranteed (yet), so be careful if your Table depends on something in a
+ // separate table.
+ void onCreate(SQLiteDatabase db);
+ void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion);
+
+ // Called by BrowserProvider when this database queried/modified
+ // The dbId here should match the dbId's you returned in your getContentProviderInfo() call
+ Cursor query(SQLiteDatabase db, Uri uri, int dbId, String[] projection, String selection, String[] selectionArgs, String sortOrder, String groupBy, String limit);
+ int update(SQLiteDatabase db, Uri uri, int dbId, ContentValues values, String selection, String[] selectionArgs);
+ long insert(SQLiteDatabase db, Uri uri, int dbId, ContentValues values);
+ int delete(SQLiteDatabase db, Uri uri, int dbId, String selection, String[] selectionArgs);
+};
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java b/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java
new file mode 100644
index 000000000..1be004ca7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/TabsAccessor.java
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+
+import org.mozilla.gecko.Tab;
+
+import java.util.List;
+
+public interface TabsAccessor {
+ public interface OnQueryTabsCompleteListener {
+ public void onQueryTabsComplete(List<RemoteClient> clients);
+ }
+
+ public Cursor getRemoteClientsByRecencyCursor(Context context);
+ public Cursor getRemoteTabsCursor(Context context);
+ public Cursor getRemoteTabsCursor(Context context, int limit);
+ public List<RemoteClient> getClientsWithoutTabsByRecencyFromCursor(final Cursor cursor);
+ public List<RemoteClient> getClientsFromCursor(final Cursor cursor);
+ public void getTabs(final Context context, final OnQueryTabsCompleteListener listener);
+ public void getTabs(final Context context, final int limit, final OnQueryTabsCompleteListener listener);
+ public void persistLocalTabs(final ContentResolver cr, final Iterable<Tab> tabs);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java b/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java
new file mode 100644
index 000000000..09e4d9cf5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/TabsProvider.java
@@ -0,0 +1,361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.db.BrowserContract.Clients;
+import org.mozilla.gecko.db.BrowserContract.Tabs;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.text.TextUtils;
+
+public class TabsProvider extends SharedBrowserDatabaseProvider {
+ private static final long ONE_DAY_IN_MILLISECONDS = 1000 * 60 * 60 * 24;
+ private static final long ONE_WEEK_IN_MILLISECONDS = 7 * ONE_DAY_IN_MILLISECONDS;
+ private static final long THREE_WEEKS_IN_MILLISECONDS = 3 * ONE_WEEK_IN_MILLISECONDS;
+
+ static final String TABLE_TABS = "tabs";
+ static final String TABLE_CLIENTS = "clients";
+
+ static final int TABS = 600;
+ static final int TABS_ID = 601;
+ static final int CLIENTS = 602;
+ static final int CLIENTS_ID = 603;
+ static final int CLIENTS_RECENCY = 604;
+
+ // Exclude clients that are more than three weeks old and also any duplicates that are older than one week old.
+ static final String EXCLUDE_STALE_CLIENTS_SUBQUERY =
+ "(SELECT " + Clients.GUID +
+ ", " + Clients.NAME +
+ ", " + Clients.LAST_MODIFIED +
+ ", " + Clients.DEVICE_TYPE +
+ " FROM " + TABLE_CLIENTS +
+ " WHERE " + Clients.LAST_MODIFIED + " > %1$s " +
+ " GROUP BY " + Clients.NAME +
+ " UNION ALL " +
+ " SELECT c." + Clients.GUID + " AS " + Clients.GUID +
+ ", c." + Clients.NAME + " AS " + Clients.NAME +
+ ", c." + Clients.LAST_MODIFIED + " AS " + Clients.LAST_MODIFIED +
+ ", c." + Clients.DEVICE_TYPE + " AS " + Clients.DEVICE_TYPE +
+ " FROM " + TABLE_CLIENTS + " AS c " +
+ " JOIN (" +
+ " SELECT " + Clients.GUID +
+ ", " + "MAX( " + Clients.LAST_MODIFIED + ") AS " + Clients.LAST_MODIFIED +
+ " FROM " + TABLE_CLIENTS +
+ " WHERE (" + Clients.LAST_MODIFIED + " < %1$s" + " AND " + Clients.LAST_MODIFIED + " > %2$s) AND " +
+ Clients.NAME + " NOT IN " + "( SELECT " + Clients.NAME + " FROM " + TABLE_CLIENTS + " WHERE " + Clients.LAST_MODIFIED + " > %1$s)" +
+ " GROUP BY " + Clients.NAME +
+ ") AS c2" +
+ " ON c." + Clients.GUID + " = c2." + Clients.GUID + ")";
+
+ static final String DEFAULT_TABS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC, " + Tabs.LAST_USED + " DESC";
+ static final String DEFAULT_CLIENTS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC";
+ static final String DEFAULT_CLIENTS_RECENCY_SORT_ORDER = "COALESCE(MAX(" + Tabs.LAST_USED + "), " + Clients.LAST_MODIFIED + ") DESC";
+
+ static final String INDEX_TABS_GUID = "tabs_guid_index";
+ static final String INDEX_TABS_POSITION = "tabs_position_index";
+
+ static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ static final Map<String, String> TABS_PROJECTION_MAP;
+ static final Map<String, String> CLIENTS_PROJECTION_MAP;
+ static final Map<String, String> CLIENTS_RECENCY_PROJECTION_MAP;
+
+ static {
+ URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs", TABS);
+ URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs/#", TABS_ID);
+ URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients", CLIENTS);
+ URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients/#", CLIENTS_ID);
+ URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients_recency", CLIENTS_RECENCY);
+
+ HashMap<String, String> map;
+
+ map = new HashMap<String, String>();
+ map.put(Tabs._ID, Tabs._ID);
+ map.put(Tabs.TITLE, Tabs.TITLE);
+ map.put(Tabs.URL, Tabs.URL);
+ map.put(Tabs.HISTORY, Tabs.HISTORY);
+ map.put(Tabs.FAVICON, Tabs.FAVICON);
+ map.put(Tabs.LAST_USED, Tabs.LAST_USED);
+ map.put(Tabs.POSITION, Tabs.POSITION);
+ map.put(Clients.GUID, Clients.GUID);
+ map.put(Clients.NAME, Clients.NAME);
+ map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED);
+ map.put(Clients.DEVICE_TYPE, Clients.DEVICE_TYPE);
+ TABS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ map = new HashMap<String, String>();
+ map.put(Clients.GUID, Clients.GUID);
+ map.put(Clients.NAME, Clients.NAME);
+ map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED);
+ map.put(Clients.DEVICE_TYPE, Clients.DEVICE_TYPE);
+ CLIENTS_PROJECTION_MAP = Collections.unmodifiableMap(map);
+
+ map = new HashMap<>();
+ map.put(Clients.GUID, projectColumn(TABLE_CLIENTS, Clients.GUID) + " AS guid");
+ map.put(Clients.NAME, projectColumn(TABLE_CLIENTS, Clients.NAME) + " AS name");
+ map.put(Clients.LAST_MODIFIED, projectColumn(TABLE_CLIENTS, Clients.LAST_MODIFIED) + " AS last_modified");
+ map.put(Clients.DEVICE_TYPE, projectColumn(TABLE_CLIENTS, Clients.DEVICE_TYPE) + " AS device_type");
+ // last_used is the max of the tab last_used times, or if there are no tabs,
+ // the client's last_modified time.
+ map.put(Tabs.LAST_USED, "COALESCE(MAX(" + projectColumn(TABLE_TABS, Tabs.LAST_USED) + "), " + projectColumn(TABLE_CLIENTS, Clients.LAST_MODIFIED) + ") AS last_used");
+ CLIENTS_RECENCY_PROJECTION_MAP = Collections.unmodifiableMap(map);
+ }
+
+ private static final String projectColumn(String table, String column) {
+ return table + "." + column;
+ }
+
+ private static final String selectColumn(String table, String column) {
+ return projectColumn(table, column) + " = ?";
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ final int match = URI_MATCHER.match(uri);
+
+ trace("Getting URI type: " + uri);
+
+ switch (match) {
+ case TABS:
+ trace("URI is TABS: " + uri);
+ return Tabs.CONTENT_TYPE;
+
+ case TABS_ID:
+ trace("URI is TABS_ID: " + uri);
+ return Tabs.CONTENT_ITEM_TYPE;
+
+ case CLIENTS:
+ trace("URI is CLIENTS: " + uri);
+ return Clients.CONTENT_TYPE;
+
+ case CLIENTS_ID:
+ trace("URI is CLIENTS_ID: " + uri);
+ return Clients.CONTENT_ITEM_TYPE;
+ }
+
+ debug("URI has unrecognized type: " + uri);
+
+ return null;
+ }
+
+ @Override
+ @SuppressWarnings("fallthrough")
+ public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+ trace("Calling delete in transaction on URI: " + uri);
+
+ final int match = URI_MATCHER.match(uri);
+ int deleted = 0;
+
+ switch (match) {
+ case CLIENTS_ID:
+ trace("Delete on CLIENTS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case CLIENTS:
+ trace("Delete on CLIENTS: " + uri);
+ deleted = deleteValues(uri, selection, selectionArgs, TABLE_CLIENTS);
+ break;
+
+ case TABS_ID:
+ trace("Delete on TABS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case TABS:
+ trace("Deleting on TABS: " + uri);
+ deleted = deleteValues(uri, selection, selectionArgs, TABLE_TABS);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown delete URI " + uri);
+ }
+
+ debug("Deleted " + deleted + " rows for URI: " + uri);
+
+ return deleted;
+ }
+
+ @Override
+ public Uri insertInTransaction(Uri uri, ContentValues values) {
+ trace("Calling insert in transaction on URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ int match = URI_MATCHER.match(uri);
+ long id = -1;
+
+ switch (match) {
+ case CLIENTS:
+ String guid = values.getAsString(Clients.GUID);
+ debug("Inserting client in database with GUID: " + guid);
+ id = db.insertOrThrow(TABLE_CLIENTS, Clients.GUID, values);
+ break;
+
+ case TABS:
+ String url = values.getAsString(Tabs.URL);
+ debug("Inserting tab in database with URL: " + url);
+ id = db.insertOrThrow(TABLE_TABS, Tabs.TITLE, values);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown insert URI " + uri);
+ }
+
+ debug("Inserted ID in database: " + id);
+
+ if (id >= 0)
+ return ContentUris.withAppendedId(uri, id);
+
+ return null;
+ }
+
+ @Override
+ public int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ trace("Calling update in transaction on URI: " + uri);
+
+ int match = URI_MATCHER.match(uri);
+ int updated = 0;
+
+ switch (match) {
+ case CLIENTS_ID:
+ trace("Update on CLIENTS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case CLIENTS:
+ trace("Update on CLIENTS: " + uri);
+ updated = updateValues(uri, values, selection, selectionArgs, TABLE_CLIENTS);
+ break;
+
+ case TABS_ID:
+ trace("Update on TABS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case TABS:
+ trace("Update on TABS: " + uri);
+ updated = updateValues(uri, values, selection, selectionArgs, TABLE_TABS);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown update URI " + uri);
+ }
+
+ debug("Updated " + updated + " rows for URI: " + uri);
+
+ return updated;
+ }
+
+ @Override
+ @SuppressWarnings("fallthrough")
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ SQLiteDatabase db = getReadableDatabase(uri);
+ final int match = URI_MATCHER.match(uri);
+
+ String groupBy = null;
+ SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+
+ switch (match) {
+ case TABS_ID:
+ trace("Query is on TABS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case TABS:
+ trace("Query is on TABS: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_TABS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(TABS_PROJECTION_MAP);
+ qb.setTables(TABLE_TABS + " LEFT OUTER JOIN " + TABLE_CLIENTS + " ON (" + TABLE_TABS + "." + Tabs.CLIENT_GUID + " = " + TABLE_CLIENTS + "." + Clients.GUID + ")");
+ break;
+
+ case CLIENTS_ID:
+ trace("Query is on CLIENTS_ID: " + uri);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[] { Long.toString(ContentUris.parseId(uri)) });
+ // fall through
+ case CLIENTS:
+ trace("Query is on CLIENTS: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_CLIENTS_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ qb.setProjectionMap(CLIENTS_PROJECTION_MAP);
+ qb.setTables(TABLE_CLIENTS);
+ break;
+
+ case CLIENTS_RECENCY:
+ trace("Query is on CLIENTS_RECENCY: " + uri);
+ if (TextUtils.isEmpty(sortOrder)) {
+ sortOrder = DEFAULT_CLIENTS_RECENCY_SORT_ORDER;
+ } else {
+ debug("Using sort order " + sortOrder + ".");
+ }
+
+ final long oneWeekAgo = System.currentTimeMillis() - ONE_WEEK_IN_MILLISECONDS;
+ final long threeWeeksAgo = System.currentTimeMillis() - THREE_WEEKS_IN_MILLISECONDS;
+
+ final String excludeStaleClientsTable = String.format(EXCLUDE_STALE_CLIENTS_SUBQUERY, oneWeekAgo, threeWeeksAgo);
+
+ qb.setProjectionMap(CLIENTS_RECENCY_PROJECTION_MAP);
+
+ // Use a subquery to quietly exclude stale duplicate client records.
+ qb.setTables(excludeStaleClientsTable + " AS " + TABLE_CLIENTS + " LEFT OUTER JOIN " + TABLE_TABS +
+ " ON (" + projectColumn(TABLE_CLIENTS, Clients.GUID) +
+ " = " + projectColumn(TABLE_TABS, Tabs.CLIENT_GUID) + ")");
+ groupBy = projectColumn(TABLE_CLIENTS, Clients.GUID);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown query URI " + uri);
+ }
+
+ trace("Running built query.");
+ final Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit);
+ cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.TABS_AUTHORITY_URI);
+
+ return cursor;
+ }
+
+ int updateValues(Uri uri, ContentValues values, String selection, String[] selectionArgs, String table) {
+ trace("Updating tabs on URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.update(table, values, selection, selectionArgs);
+ }
+
+ int deleteValues(Uri uri, String selection, String[] selectionArgs, String table) {
+ debug("Deleting tabs for URI: " + uri);
+
+ final SQLiteDatabase db = getWritableDatabase(uri);
+ beginWrite(db);
+ return db.delete(table, selection, selectionArgs);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java
new file mode 100644
index 000000000..7973839e2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadata.java
@@ -0,0 +1,25 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.mozilla.gecko.db;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONObject;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+import android.content.ContentResolver;
+
+@RobocopTarget
+public interface URLMetadata {
+ public Map<String, Object> fromJSON(JSONObject obj);
+ public Map<String, Map<String, Object>> getForURLs(final ContentResolver cr,
+ final Collection<String> urls,
+ final List<String> columns);
+ public void save(final ContentResolver cr, final Map<String, Object> data);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java
new file mode 100644
index 000000000..49bbb74e7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/URLMetadataTable.java
@@ -0,0 +1,92 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.db;
+
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserContract.History;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+// Holds metadata info about urls. Supports some helper functions for getting back a HashMap of key value data.
+public class URLMetadataTable extends BaseTable {
+ private static final String LOGTAG = "GeckoURLMetadataTable";
+
+ private static final String TABLE = "metadata"; // Name of the table in the db
+ private static final int TABLE_ID_NUMBER = BrowserProvider.METADATA;
+
+ // Uri for querying this table
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(BrowserContract.AUTHORITY_URI, "metadata");
+
+ // Columns in the table
+ public static final String ID_COLUMN = "id";
+ public static final String URL_COLUMN = "url";
+ public static final String TILE_IMAGE_URL_COLUMN = "tileImage";
+ public static final String TILE_COLOR_COLUMN = "tileColor";
+ public static final String TOUCH_ICON_COLUMN = "touchIcon";
+
+ URLMetadataTable() { }
+
+ @Override
+ protected String getTable() {
+ return TABLE;
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ String create = "CREATE TABLE " + TABLE + " (" +
+ ID_COLUMN + " INTEGER PRIMARY KEY, " +
+ URL_COLUMN + " TEXT NON NULL UNIQUE, " +
+ TILE_IMAGE_URL_COLUMN + " STRING, " +
+ TILE_COLOR_COLUMN + " STRING, " +
+ TOUCH_ICON_COLUMN + " STRING);";
+ db.execSQL(create);
+ }
+
+ private void upgradeDatabaseFrom26To27(SQLiteDatabase db) {
+ db.execSQL("ALTER TABLE " + TABLE +
+ " ADD COLUMN " + TOUCH_ICON_COLUMN + " STRING");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // This table was added in v21 of the db. Force its creation if we're coming from an earlier version
+ if (newVersion >= 21 && oldVersion < 21) {
+ onCreate(db);
+ return;
+ }
+
+ // Removed the redundant metadata_url_idx index in version 26
+ if (newVersion >= 26 && oldVersion < 26) {
+ db.execSQL("DROP INDEX IF EXISTS metadata_url_idx");
+ }
+ if (newVersion >= 27 && oldVersion < 27) {
+ upgradeDatabaseFrom26To27(db);
+ }
+ }
+
+ @Override
+ public Table.ContentProviderInfo[] getContentProviderInfo() {
+ return new Table.ContentProviderInfo[] {
+ new Table.ContentProviderInfo(TABLE_ID_NUMBER, TABLE)
+ };
+ }
+
+ public int deleteUnused(final SQLiteDatabase db) {
+ final String selection = URL_COLUMN + " NOT IN " +
+ "(SELECT " + History.URL +
+ " FROM " + History.TABLE_NAME +
+ " WHERE " + History.IS_DELETED + " = 0" +
+ " UNION " +
+ " SELECT " + Bookmarks.URL +
+ " FROM " + Bookmarks.TABLE_NAME +
+ " WHERE " + Bookmarks.IS_DELETED + " = 0 " +
+ " AND " + Bookmarks.URL + " IS NOT NULL)";
+
+ return db.delete(getTable(), selection, null);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
new file mode 100644
index 000000000..dcae6ee79
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/UrlAnnotations.java
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.db;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+
+public interface UrlAnnotations {
+ @RobocopTarget void insertAnnotation(ContentResolver cr, String url, String key, String value);
+
+ Cursor getScreenshots(ContentResolver cr);
+ void insertScreenshot(ContentResolver cr, String pageUrl, String screenshotPath);
+
+ Cursor getFeedSubscriptions(ContentResolver cr);
+ Cursor getWebsitesWithFeedUrl(ContentResolver cr);
+ void deleteFeedUrl(ContentResolver cr, String websiteUrl);
+ boolean hasWebsiteForFeedUrl(ContentResolver cr, String feedUrl);
+ void deleteFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+ void updateFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+ boolean hasFeedSubscription(ContentResolver cr, String feedUrl);
+ void insertFeedSubscription(ContentResolver cr, FeedSubscription subscription);
+ boolean hasFeedUrlForWebsite(ContentResolver cr, String websiteUrl);
+ void insertFeedUrl(ContentResolver cr, String originUrl, String feedUrl);
+
+ void insertReaderViewUrl(ContentResolver cr, String pageURL);
+ void deleteReaderViewUrl(ContentResolver cr, String pageURL);
+
+ /**
+ * Did the user ever interact with this URL in regards to home screen shortcuts?
+ *
+ * @return true if the user has created a home screen shortcut or declined to create one in the
+ * past. This method will still return true if the shortcut has been removed from the
+ * home screen by the user.
+ */
+ boolean hasAcceptedOrDeclinedHomeScreenShortcut(ContentResolver cr, String url);
+
+ /**
+ * Insert an indication that the user has interacted with this URL in regards to home screen
+ * shortcuts.
+ *
+ * @param hasCreatedShortCut True if a home screen shortcut has been created for this URL. False
+ * if the user has actively declined to create a shortcut for this URL.
+ */
+ void insertHomeScreenShortcut(ContentResolver cr, String url, boolean hasCreatedShortCut);
+
+ int getAnnotationCount(ContentResolver cr, BrowserContract.UrlAnnotations.Key key);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java
new file mode 100644
index 000000000..a1c54d3c3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BookmarkStateChangeDelegate.java
@@ -0,0 +1,237 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.delegates;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.design.widget.Snackbar;
+import android.support.v4.content.ContextCompat;
+import android.util.Log;
+import android.view.View;
+import android.widget.ListView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.EditBookmarkDialog;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.home.HomeConfig;
+import org.mozilla.gecko.promotion.SimpleHelperUI;
+import org.mozilla.gecko.prompts.Prompt;
+import org.mozilla.gecko.prompts.PromptListItem;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Delegate to watch for bookmark state changes.
+ *
+ * This is responsible for showing snackbars and helper UIs related to the addition/removal
+ * of bookmarks, or reader view bookmarks.
+ */
+public class BookmarkStateChangeDelegate extends BrowserAppDelegateWithReference implements Tabs.OnTabsChangedListener {
+ private static final String LOGTAG = "BookmarkDelegate";
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ switch (msg) {
+ case BOOKMARK_ADDED:
+ // We always show the special offline snackbar whenever we bookmark a reader page.
+ // It's possible that the page is already stored offline, however this is highly
+ // unlikely, and even so it is probably nicer to show the same offline notification
+ // every time we bookmark an about:reader page.
+ if (!AboutPages.isAboutReader(tab.getURL())) {
+ showBookmarkAddedSnackbar();
+ } else {
+ if (!promoteReaderViewBookmarkAdded()) {
+ showReaderModeBookmarkAddedSnackbar();
+ }
+ }
+ break;
+
+ case BOOKMARK_REMOVED:
+ showBookmarkRemovedSnackbar();
+ break;
+ }
+ }
+
+ @Override
+ public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) {
+ if (requestCode == BrowserApp.ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK) {
+ if (resultCode == BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS) {
+ browserApp.openUrlAndStopEditing("about:home?panel=" + HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS));
+ } else if (resultCode == BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE) {
+ showReaderModeBookmarkAddedSnackbar();
+ }
+ }
+ }
+
+ private boolean promoteReaderViewBookmarkAdded() {
+ final BrowserApp browserApp = getBrowserApp();
+ if (browserApp == null) {
+ return false;
+ }
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(browserApp);
+
+ final boolean hasFirstReaderViewPromptBeenShownBefore = prefs.getBoolean(SimpleHelperUI.PREF_FIRST_RVBP_SHOWN, false);
+
+ if (hasFirstReaderViewPromptBeenShownBefore) {
+ return false;
+ }
+
+ SimpleHelperUI.show(browserApp,
+ SimpleHelperUI.FIRST_RVBP_SHOWN_TELEMETRYEXTRA,
+ BrowserApp.ACTIVITY_REQUEST_FIRST_READERVIEW_BOOKMARK,
+ R.string.helper_first_offline_bookmark_title, R.string.helper_first_offline_bookmark_message,
+ R.drawable.helper_readerview_bookmark, R.string.helper_first_offline_bookmark_button,
+ BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_GOTO_BOOKMARKS,
+ BrowserApp.ACTIVITY_RESULT_FIRST_READERVIEW_BOOKMARKS_IGNORE);
+
+ GeckoSharedPrefs.forProfile(browserApp)
+ .edit()
+ .putBoolean(SimpleHelperUI.PREF_FIRST_RVBP_SHOWN, true)
+ .apply();
+
+ return true;
+ }
+
+ private void showBookmarkAddedSnackbar() {
+ final BrowserApp browserApp = getBrowserApp();
+ if (browserApp == null) {
+ return;
+ }
+
+ // This flow is from the option menu which has check to see if a bookmark was already added.
+ // So, it is safe here to show the snackbar that bookmark_added without any checks.
+ final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.TOAST, "bookmark_options");
+ showBookmarkDialog(browserApp);
+ }
+ };
+
+ SnackbarBuilder.builder(browserApp)
+ .message(R.string.bookmark_added)
+ .duration(Snackbar.LENGTH_LONG)
+ .action(R.string.bookmark_options)
+ .callback(callback)
+ .buildAndShow();
+ }
+
+ private void showBookmarkRemovedSnackbar() {
+ final BrowserApp browserApp = getBrowserApp();
+ if (browserApp == null) {
+ return;
+ }
+
+ SnackbarBuilder.builder(browserApp)
+ .message(R.string.bookmark_removed)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+
+ private static void showBookmarkDialog(final BrowserApp browserApp) {
+ final Resources res = browserApp.getResources();
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+
+ final Prompt ps = new Prompt(browserApp, new Prompt.PromptCallback() {
+ @Override
+ public void onPromptFinished(String result) {
+ int itemId = -1;
+ try {
+ itemId = new JSONObject(result).getInt("button");
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Exception reading bookmark prompt result", ex);
+ }
+
+ if (tab == null) {
+ return;
+ }
+
+ if (itemId == 0) {
+ final String extrasId = res.getResourceEntryName(R.string.contextmenu_edit_bookmark);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION,
+ TelemetryContract.Method.DIALOG, extrasId);
+
+ new EditBookmarkDialog(browserApp).show(tab.getURL());
+ } else if (itemId == 1) {
+ final String extrasId = res.getResourceEntryName(R.string.contextmenu_add_to_launcher);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION,
+ TelemetryContract.Method.DIALOG, extrasId);
+
+ final String url = tab.getURL();
+ final String title = tab.getDisplayTitle();
+
+ if (url != null && title != null) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.createShortcut(title, url);
+ }
+ });
+ }
+ }
+ }
+ });
+
+ final PromptListItem[] items = new PromptListItem[2];
+ items[0] = new PromptListItem(res.getString(R.string.contextmenu_edit_bookmark));
+ items[1] = new PromptListItem(res.getString(R.string.contextmenu_add_to_launcher));
+
+ ps.show("", "", items, ListView.CHOICE_MODE_NONE);
+ }
+
+ private void showReaderModeBookmarkAddedSnackbar() {
+ final BrowserApp browserApp = getBrowserApp();
+ if (browserApp == null) {
+ return;
+ }
+
+ final Drawable iconDownloaded = DrawableUtil.tintDrawable(browserApp, R.drawable.status_icon_readercache, Color.WHITE);
+
+ final SnackbarBuilder.SnackbarCallback callback = new SnackbarBuilder.SnackbarCallback() {
+ @Override
+ public void onClick(View v) {
+ browserApp.openUrlAndStopEditing("about:home?panel=" + HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS));
+ }
+ };
+
+ SnackbarBuilder.builder(browserApp)
+ .message(R.string.reader_saved_offline)
+ .duration(Snackbar.LENGTH_LONG)
+ .action(R.string.reader_switch_to_bookmarks)
+ .callback(callback)
+ .icon(iconDownloaded)
+ .backgroundColor(ContextCompat.getColor(browserApp, R.color.link_blue))
+ .actionColor(Color.WHITE)
+ .buildAndShow();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java
new file mode 100644
index 000000000..70b134992
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegate.java
@@ -0,0 +1,78 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.delegates;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.tabs.TabsPanel;
+
+/**
+ * Abstract class for extending the behavior of BrowserApp without adding additional code to the
+ * already huge class.
+ */
+public abstract class BrowserAppDelegate {
+ /**
+ * Called when the BrowserApp activity is first created.
+ */
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {}
+
+ /**
+ * Called after the BrowserApp activity has been stopped, prior to it being started again.
+ */
+ public void onRestart(BrowserApp browserApp) {}
+
+ /**
+ * Called when the BrowserApp activity is becoming visible to the user.
+ */
+ public void onStart(BrowserApp browserApp) {}
+
+ /**
+ * Called when the BrowserApp activity will start interacting with the user.
+ */
+ public void onResume(BrowserApp browserApp) {}
+
+ /**
+ * Called when the system is about to start resuming a previous activity.
+ */
+ public void onPause(BrowserApp browserApp) {}
+
+ /**
+ * Called when BrowserApp activity is no longer visible to the user.
+ */
+ public void onStop(BrowserApp browserApp) {}
+
+ /**
+ * The final call before the BrowserApp activity is destroyed.
+ */
+ public void onDestroy(BrowserApp browserApp) {}
+
+ /**
+ * Called when BrowserApp already exists and a new Intent to re-launch it was fired.
+ */
+ public void onNewIntent(BrowserApp browserApp, SafeIntent intent) {}
+
+ /**
+ * Called when the tabs tray is opened.
+ */
+ public void onTabsTrayShown(BrowserApp browserApp, TabsPanel tabsPanel) {}
+
+ /**
+ * Called when the tabs tray is closed.
+ */
+ public void onTabsTrayHidden(BrowserApp browserApp, TabsPanel tabsPanel) {}
+
+ /**
+ * Called when an activity started using startActivityForResult() returns.
+ *
+ * Delegates should only use request and result codes declared in BrowserApp itself (as opposed
+ * to declarations in the delegate), in order to avoid conflicts.
+ */
+ public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) {}
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java
new file mode 100644
index 000000000..c67b8a18a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/BrowserAppDelegateWithReference.java
@@ -0,0 +1,29 @@
+package org.mozilla.gecko.delegates;
+
+import android.os.Bundle;
+import android.support.annotation.CallSuper;
+
+import org.mozilla.gecko.BrowserApp;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * BrowserAppDelegate that stores a reference to the parent BrowserApp.
+ */
+public abstract class BrowserAppDelegateWithReference extends BrowserAppDelegate {
+ private WeakReference<BrowserApp> browserApp;
+
+ @Override
+ @CallSuper
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ this.browserApp = new WeakReference<>(browserApp);
+ }
+
+ /**
+ * Obtain the referenced BrowserApp. May return <code>null</code> if the BrowserApp no longer
+ * exists.
+ */
+ protected BrowserApp getBrowserApp() {
+ return browserApp.get();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java
new file mode 100644
index 000000000..5f3aa9c59
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/OfflineTabStatusDelegate.java
@@ -0,0 +1,119 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.delegates;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.annotation.CallSuper;
+import android.support.design.widget.Snackbar;
+import android.support.v4.content.ContextCompat;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.WeakHashMap;
+
+/**
+ * Displays "Showing offline version" message when tabs are loaded from cache while offline.
+ */
+public class OfflineTabStatusDelegate extends TabsTrayVisibilityAwareDelegate implements Tabs.OnTabsChangedListener {
+ private WeakReference<Activity> activityReference;
+ private WeakHashMap<Tab, Void> tabsQueuedForOfflineSnackbar = new WeakHashMap<>();
+
+ @CallSuper
+ @Override
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ super.onCreate(browserApp, savedInstanceState);
+ activityReference = new WeakReference<Activity>(browserApp);
+ }
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ public void onTabChanged(final Tab tab, Tabs.TabEvents event, String data) {
+ if (tab == null) {
+ return;
+ }
+
+ // Ignore tabs loaded regularly.
+ if (!tab.hasLoadedFromCache()) {
+ return;
+ }
+
+ // Ignore tabs displaying about pages
+ if (AboutPages.isAboutPage(tab.getURL())) {
+ return;
+ }
+
+ // We only want to show these notifications for tabs that were loaded successfully.
+ if (tab.getState() != Tab.STATE_SUCCESS) {
+ return;
+ }
+
+ switch (event) {
+ // We listen specifically for the STOP event (as opposed to PAGE_SHOW), because we need
+ // to know if page load actually succeeded. When STOP is triggered, tab.getState()
+ // will return definitive STATE_SUCCESS or STATE_ERROR. When PAGE_SHOW is triggered,
+ // tab.getState() will return STATE_LOADING, which is ambiguous for our purposes.
+ // We don't want to show these notifications for 404 pages, for example. See Bug 1304914.
+ case STOP:
+ // Show offline notification if tab is visible, or queue it for display later.
+ if (!isTabsTrayVisible() && Tabs.getInstance().isSelectedTab(tab)) {
+ showLoadedOfflineSnackbar(activityReference.get());
+ } else {
+ tabsQueuedForOfflineSnackbar.put(tab, null);
+ }
+ break;
+ // Fallthrough; see Bug 1278980 for details on why this event is here.
+ case OPENED_FROM_TABS_TRAY:
+ // When tab is selected and offline notification was queued, display it if possible.
+ // SELECTED event might also fire when we're on a TabStrip, so check first.
+ case SELECTED:
+ if (isTabsTrayVisible()) {
+ break;
+ }
+ if (tabsQueuedForOfflineSnackbar.containsKey(tab)) {
+ showLoadedOfflineSnackbar(activityReference.get());
+ tabsQueuedForOfflineSnackbar.remove(tab);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Displays the notification snackbar and logs a telemetry event.
+ *
+ * @param activity which will be used for displaying the snackbar.
+ */
+ private static void showLoadedOfflineSnackbar(final Activity activity) {
+ if (activity == null) {
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.NETERROR, TelemetryContract.Method.TOAST, "usecache");
+
+ SnackbarBuilder.builder(activity)
+ .message(R.string.tab_offline_version)
+ .duration(Snackbar.LENGTH_INDEFINITE)
+ .backgroundColor(ContextCompat.getColor(activity, R.color.link_blue))
+ .buildAndShow();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java
new file mode 100644
index 000000000..f048372f7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/ScreenshotDelegate.java
@@ -0,0 +1,80 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.delegates;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.design.widget.Snackbar;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ScreenshotObserver;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Delegate for observing screenshots being taken.
+ */
+public class ScreenshotDelegate extends BrowserAppDelegateWithReference implements ScreenshotObserver.OnScreenshotListener {
+ private static final String LOGTAG = "GeckoScreenshotDelegate";
+
+ private final ScreenshotObserver mScreenshotObserver = new ScreenshotObserver();
+
+ @Override
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ super.onCreate(browserApp, savedInstanceState);
+
+ mScreenshotObserver.setListener(browserApp, this);
+ }
+
+ @Override
+ public void onScreenshotTaken(String screenshotPath, String title) {
+ // Treat screenshots as a sharing method.
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.BUTTON, "screenshot");
+
+ if (!AppConstants.SCREENSHOTS_IN_BOOKMARKS_ENABLED) {
+ return;
+ }
+
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab == null) {
+ Log.w(LOGTAG, "Selected tab is null: could not page info to store screenshot.");
+ return;
+ }
+
+ final Activity activity = getBrowserApp();
+ if (activity == null) {
+ return;
+ }
+
+ BrowserDB.from(activity).getUrlAnnotations().insertScreenshot(
+ activity.getContentResolver(), selectedTab.getURL(), screenshotPath);
+
+ SnackbarBuilder.builder(activity)
+ .message(R.string.screenshot_added_to_bookmarks)
+ .duration(Snackbar.LENGTH_SHORT)
+ .buildAndShow();
+ }
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ mScreenshotObserver.start();
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ mScreenshotObserver.stop();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java b/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java
new file mode 100644
index 000000000..ebd3991ea
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/delegates/TabsTrayVisibilityAwareDelegate.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.delegates;
+
+import android.os.Bundle;
+import android.support.annotation.CallSuper;
+
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.tabs.TabsPanel;
+
+public abstract class TabsTrayVisibilityAwareDelegate extends BrowserAppDelegate {
+ private boolean tabsTrayVisible;
+
+ @Override
+ @CallSuper
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ tabsTrayVisible = false;
+ }
+
+ @Override
+ @CallSuper
+ public void onTabsTrayShown(BrowserApp browserApp, TabsPanel tabsPanel) {
+ tabsTrayVisible = true;
+ }
+
+ @Override
+ @CallSuper
+ public void onTabsTrayHidden(BrowserApp browserApp, TabsPanel tabsPanel) {
+ tabsTrayVisible = false;
+ }
+
+ protected boolean isTabsTrayVisible() {
+ return tabsTrayVisible;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java b/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java
new file mode 100644
index 000000000..a7b0fe32d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/Distribution.java
@@ -0,0 +1,1046 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.SocketException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.UnknownHostException;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.jar.JarEntry;
+import java.util.jar.JarInputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import javax.net.ssl.SSLException;
+
+import ch.boye.httpclientandroidlib.protocol.HTTP;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.SystemClock;
+import android.support.annotation.WorkerThread;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+
+/**
+ * Handles distribution file loading and fetching,
+ * and the corresponding hand-offs to Gecko.
+ */
+@RobocopTarget
+public class Distribution {
+ private static final String LOGTAG = "GeckoDistribution";
+
+ private static final int STATE_UNKNOWN = 0;
+ private static final int STATE_NONE = 1;
+ private static final int STATE_SET = 2;
+
+ private static final String FETCH_PROTOCOL = "https";
+ private static final String FETCH_HOSTNAME = "mobile.cdn.mozilla.net";
+ private static final String FETCH_PATH = "/distributions/1/";
+ private static final String FETCH_EXTENSION = ".jar";
+
+ private static final String EXPECTED_CONTENT_TYPE = "application/java-archive";
+
+ private static final String DISTRIBUTION_PATH = "distribution/";
+
+ /**
+ * Telemetry constants.
+ */
+ private static final String HISTOGRAM_REFERRER_INVALID = "FENNEC_DISTRIBUTION_REFERRER_INVALID";
+ private static final String HISTOGRAM_DOWNLOAD_TIME_MS = "FENNEC_DISTRIBUTION_DOWNLOAD_TIME_MS";
+ private static final String HISTOGRAM_CODE_CATEGORY = "FENNEC_DISTRIBUTION_CODE_CATEGORY";
+
+ /**
+ * Success/failure codes. Don't exceed the maximum listed in Histograms.json.
+ */
+ private static final int CODE_CATEGORY_STATUS_OUT_OF_RANGE = 0;
+ // HTTP status 'codes' run from 1 to 5.
+ private static final int CODE_CATEGORY_OFFLINE = 6;
+ private static final int CODE_CATEGORY_FETCH_EXCEPTION = 7;
+
+ // It's a post-fetch exception if we were able to download, but not
+ // able to extract.
+ private static final int CODE_CATEGORY_POST_FETCH_EXCEPTION = 8;
+ private static final int CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION = 9;
+
+ // It's a malformed distribution if we could extract, but couldn't
+ // process the contents.
+ private static final int CODE_CATEGORY_MALFORMED_DISTRIBUTION = 10;
+
+ // Specific fetch errors.
+ private static final int CODE_CATEGORY_FETCH_SOCKET_ERROR = 11;
+ private static final int CODE_CATEGORY_FETCH_SSL_ERROR = 12;
+ private static final int CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE = 13;
+ private static final int CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE = 14;
+
+ // Corresponds to the high value in Histograms.json.
+ private static final long MAX_DOWNLOAD_TIME_MSEC = 40000; // 40 seconds.
+
+ // If this is true, ready callbacks that arrive after our state is initially determined
+ // will be queued for delayed running.
+ // This should only be the case on first run, when we're in STATE_NONE.
+ // Implicitly accessed from any non-UI threads via Distribution.doInit, but in practice only one
+ // will actually perform initialization, and "non-UI thread" really means "background thread".
+ private volatile boolean shouldDelayLateCallbacks = false;
+
+ /**
+ * These tasks can be queued to run when a distribution is available.
+ *
+ * If <code>distributionFound</code> is called, it will be the only call.
+ * If <code>distributionNotFound</code> is called, it might be followed by
+ * a call to <code>distributionArrivedLate</code>.
+ *
+ * When <code>distributionNotFound</code> is called,
+ * {@link org.mozilla.gecko.distribution.Distribution#exists()} will return
+ * false. In the other two callbacks, it will return true.
+ */
+ public interface ReadyCallback {
+ @WorkerThread
+ void distributionNotFound();
+
+ @WorkerThread
+ void distributionFound(Distribution distribution);
+
+ @WorkerThread
+ void distributionArrivedLate(Distribution distribution);
+ }
+
+ /**
+ * Used as a drop-off point for ReferrerReceiver. Checked when we process
+ * first-run distribution.
+ *
+ * This is `protected` so that test code can clear it between runs.
+ */
+ @RobocopTarget
+ protected static volatile ReferrerDescriptor referrer;
+
+ private static Distribution instance;
+
+ private final Context context;
+ private final String packagePath;
+ private final String prefsBranch;
+
+ volatile int state = STATE_UNKNOWN;
+ private File distributionDir;
+
+ private final Queue<ReadyCallback> onDistributionReady = new ConcurrentLinkedQueue<>();
+
+ // Callbacks in this queue have been invoked once as distributionNotFound.
+ // If they're invoked again, it'll be with distributionArrivedLate.
+ private final Queue<ReadyCallback> onLateReady = new ConcurrentLinkedQueue<>();
+
+ /**
+ * This is a little bit of a bad singleton, because in principle a Distribution
+ * can be created with arbitrary paths. So we only have one path to get here, and
+ * it uses the default arguments. Watch out if you're creating your own instances!
+ */
+ public static synchronized Distribution getInstance(Context context) {
+ if (instance == null) {
+ instance = new Distribution(context);
+ }
+ return instance;
+ }
+
+ @RobocopTarget
+ public static class DistributionDescriptor {
+ public final boolean valid;
+ public final String id;
+ public final String version; // Example uses a float, but that's a crazy idea.
+
+ // Default UI-visible description of the distribution.
+ public final String about;
+
+ // Each distribution file can include multiple localized versions of
+ // the 'about' string. These are represented as, e.g., "about.en-US"
+ // keys in the Global object.
+ // Here we map locale to description.
+ public final Map<String, String> localizedAbout;
+
+ @SuppressWarnings("unchecked")
+ public DistributionDescriptor(JSONObject obj) {
+ this.id = obj.optString("id");
+ this.version = obj.optString("version");
+ this.about = obj.optString("about");
+ Map<String, String> loc = new HashMap<String, String>();
+ try {
+ Iterator<String> keys = obj.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ if (key.startsWith("about.")) {
+ String locale = key.substring(6);
+ if (!obj.isNull(locale)) {
+ loc.put(locale, obj.getString(key));
+ }
+ }
+ }
+ } catch (JSONException ex) {
+ Log.w(LOGTAG, "Unable to completely process distribution JSON.", ex);
+ }
+
+ this.localizedAbout = Collections.unmodifiableMap(loc);
+ this.valid = (null != this.id) &&
+ (null != this.version) &&
+ (null != this.about);
+ }
+ }
+
+ private static Distribution init(final Distribution distribution) {
+ // Read/write preferences and files on the background thread.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ boolean distributionSet = distribution.doInit();
+ if (distributionSet) {
+ String preferencesJSON = "";
+ try {
+ final File descFile = distribution.getDistributionFile("preferences.json");
+ preferencesJSON = FileUtils.readStringFromFile(descFile);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error getting distribution descriptor file.", e);
+ }
+ GeckoAppShell.notifyObservers("Distribution:Set", preferencesJSON);
+ }
+ }
+ });
+
+ return distribution;
+ }
+
+ /**
+ * Initializes distribution if it hasn't already been initialized. Sends
+ * messages to Gecko as appropriate.
+ *
+ * @param packagePath where to look for the distribution directory.
+ */
+ @RobocopTarget
+ public static Distribution init(final Context context, final String packagePath, final String prefsPath) {
+ return init(new Distribution(context, packagePath, prefsPath));
+ }
+
+ /**
+ * Use <code>Context.getPackageResourcePath</code> to find an implicit
+ * package path. Reuses the existing Distribution if one exists.
+ */
+ @RobocopTarget
+ public static Distribution init(final Context context) {
+ return init(Distribution.getInstance(context));
+ }
+
+ /**
+ * Returns parsed contents of bookmarks.json.
+ * This method should only be called from a background thread.
+ */
+ public static JSONArray getBookmarks(final Context context) {
+ Distribution dist = new Distribution(context);
+ return dist.getBookmarks();
+ }
+
+ /**
+ * @param packagePath where to look for the distribution directory.
+ */
+ public Distribution(final Context context, final String packagePath, final String prefsBranch) {
+ this.context = context;
+ this.packagePath = packagePath;
+ this.prefsBranch = prefsBranch;
+ }
+
+ public Distribution(final Context context) {
+ this(context, context.getPackageResourcePath(), null);
+ }
+
+ /**
+ * This method is called by ReferrerReceiver when we receive a post-install
+ * notification from Google Play.
+ *
+ * @param ref a parsed referrer value from the store-supplied intent.
+ */
+ public static void onReceivedReferrer(final Context context, final ReferrerDescriptor ref) {
+ // Track the referrer object for distribution handling.
+ referrer = ref;
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final Distribution distribution = Distribution.getInstance(context);
+
+ // This will bail if we aren't delayed, or we already have a distribution.
+ distribution.processDelayedReferrer(ref);
+
+ // On Android 5+ we might receive the referrer intent
+ // and never actually launch the browser, which is the usual signal
+ // for the distribution init process to complete.
+ // Attempt to init here to handle that case.
+ // Profile setup that relies on the distribution will occur
+ // when the browser is eventually launched, via `addOnDistributionReadyCallback`.
+ distribution.doInit();
+ }
+ });
+ }
+
+ /**
+ * Handle a referrer intent that arrives after first use of the distribution.
+ */
+ private void processDelayedReferrer(final ReferrerDescriptor ref) {
+ ThreadUtils.assertOnBackgroundThread();
+ if (state != STATE_NONE) {
+ return;
+ }
+
+ Log.i(LOGTAG, "Processing delayed referrer.");
+
+ if (!checkIntentDistribution(ref)) {
+ // Oh well. No sense keeping these tasks around.
+ this.onLateReady.clear();
+ return;
+ }
+
+ // Persist our new state.
+ this.state = STATE_SET;
+ getSharedPreferences().edit().putInt(getKeyName(), this.state).apply();
+
+ // Just in case this isn't empty but doInit has finished.
+ runReadyQueue();
+
+ // Now process any tasks that already ran while we were in STATE_NONE
+ // to tell them of our good news.
+ runLateReadyQueue();
+
+ // Make sure that changes to search defaults are applied immediately.
+ GeckoAppShell.notifyObservers("Distribution:Changed", "");
+ }
+
+ /**
+ * Helper to grab a file in the distribution directory.
+ *
+ * Returns null if there is no distribution directory or the file
+ * doesn't exist. Ensures init first.
+ */
+ public File getDistributionFile(String name) {
+ Log.d(LOGTAG, "Getting file from distribution.");
+
+ if (this.state == STATE_UNKNOWN) {
+ if (!this.doInit()) {
+ return null;
+ }
+ }
+
+ File dist = ensureDistributionDir();
+ if (dist == null) {
+ return null;
+ }
+
+ File descFile = new File(dist, name);
+ if (!descFile.exists()) {
+ Log.e(LOGTAG, "Distribution directory exists, but no file named " + name);
+ return null;
+ }
+
+ return descFile;
+ }
+
+ public DistributionDescriptor getDescriptor() {
+ File descFile = getDistributionFile("preferences.json");
+ if (descFile == null) {
+ // Logging and existence checks are handled in getDistributionFile.
+ return null;
+ }
+
+ try {
+ JSONObject all = FileUtils.readJSONObjectFromFile(descFile);
+
+ if (!all.has("Global")) {
+ Log.e(LOGTAG, "Distribution preferences.json has no Global entry!");
+ return null;
+ }
+
+ return new DistributionDescriptor(all.getJSONObject("Global"));
+
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error getting distribution descriptor file.", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error parsing preferences.json", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
+ }
+ }
+
+ /**
+ * Get the Android preferences from the preferences.json file, if any exist.
+ * @return The preferences in a JSONObject, or an empty JSONObject if no preferences are defined.
+ */
+ public JSONObject getAndroidPreferences() {
+ final File descFile = getDistributionFile("preferences.json");
+ if (descFile == null) {
+ // Logging and existence checks are handled in getDistributionFile.
+ return new JSONObject();
+ }
+
+ try {
+ final JSONObject all = FileUtils.readJSONObjectFromFile(descFile);
+
+ if (!all.has("AndroidPreferences")) {
+ return new JSONObject();
+ }
+
+ return all.getJSONObject("AndroidPreferences");
+
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error getting distribution descriptor file.", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return new JSONObject();
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error parsing preferences.json", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return new JSONObject();
+ }
+ }
+
+ public JSONArray getBookmarks() {
+ File bookmarks = getDistributionFile("bookmarks.json");
+ if (bookmarks == null) {
+ // Logging and existence checks are handled in getDistributionFile.
+ return null;
+ }
+
+ try {
+ return new JSONArray(FileUtils.readStringFromFile(bookmarks));
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error getting bookmarks", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error parsing bookmarks.json", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
+ return null;
+ }
+ }
+
+ /**
+ * Don't call from the main thread.
+ *
+ * Postcondition: if this returns true, distributionDir will have been
+ * set and populated.
+ *
+ * This method is *only* protected for use from testDistribution.
+ *
+ * @return true if we've set a distribution.
+ */
+ @RobocopTarget
+ protected boolean doInit() {
+ ThreadUtils.assertNotOnUiThread();
+
+ // Bail if we've already tried to initialize the distribution, and
+ // there wasn't one.
+ final SharedPreferences settings = getSharedPreferences();
+
+ final String keyName = getKeyName();
+ this.state = settings.getInt(keyName, STATE_UNKNOWN);
+
+ if (this.state == STATE_NONE) {
+ runReadyQueue();
+ return false;
+ }
+
+ // We've done the work once; don't do it again.
+ if (this.state == STATE_SET) {
+ // Note that we don't compute the distribution directory.
+ // Call `ensureDistributionDir` if you need it.
+ runReadyQueue();
+ return true;
+ }
+
+ // We try to find the install intent, then the APK, then the system directory, and finally
+ // an already copied distribution. Already copied might originate from the bouncer APK.
+ final boolean distributionSet =
+ checkIntentDistribution(referrer) ||
+ copyAndCheckAPKDistribution() ||
+ checkSystemDistribution() ||
+ checkDataDistribution();
+
+ // If this is our first run -- and thus we weren't already in STATE_NONE or STATE_SET above --
+ // and we didn't find a distribution already, then we should hold on to callbacks in case we
+ // get a late distribution.
+ this.shouldDelayLateCallbacks = !distributionSet;
+ this.state = distributionSet ? STATE_SET : STATE_NONE;
+ settings.edit().putInt(keyName, this.state).apply();
+
+ runReadyQueue();
+ return distributionSet;
+ }
+
+ /**
+ * If applicable, download and select the distribution specified in
+ * the referrer intent.
+ *
+ * @return true if a referrer-supplied distribution was selected.
+ */
+ private boolean checkIntentDistribution(final ReferrerDescriptor referrer) {
+ if (referrer == null) {
+ return false;
+ }
+
+ URI uri = getReferredDistribution(referrer);
+ if (uri == null) {
+ return false;
+ }
+
+ long start = SystemClock.uptimeMillis();
+ Log.v(LOGTAG, "Downloading referred distribution: " + uri);
+
+ try {
+ final HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
+
+ // If the Search Activity starts, and we handle the referrer intent, this'll return
+ // null. Recover gracefully in this case.
+ final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
+ final String ua;
+ if (geckoInterface == null) {
+ // Fall back to GeckoApp's default implementation.
+ ua = HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET :
+ AppConstants.USER_AGENT_FENNEC_MOBILE;
+ } else {
+ ua = geckoInterface.getDefaultUAString();
+ }
+
+ connection.setRequestProperty(HTTP.USER_AGENT, ua);
+ connection.setRequestProperty("Accept", EXPECTED_CONTENT_TYPE);
+
+ try {
+ final JarInputStream distro;
+ try {
+ distro = fetchDistribution(uri, connection);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error fetching distribution from network.", e);
+ recordFetchTelemetry(e);
+ return false;
+ }
+
+ long end = SystemClock.uptimeMillis();
+ final long duration = end - start;
+ Log.d(LOGTAG, "Distro fetch took " + duration + "ms; result? " + (distro != null));
+ Telemetry.addToHistogram(HISTOGRAM_DOWNLOAD_TIME_MS, clamp(MAX_DOWNLOAD_TIME_MSEC, duration));
+
+ if (distro == null) {
+ // Nothing to do.
+ return false;
+ }
+
+ // Try to copy distribution files from the fetched stream.
+ try {
+ Log.d(LOGTAG, "Copying files from fetched zip.");
+ if (copyFilesFromStream(distro)) {
+ // We always copy to the data dir, and we only copy files from
+ // a 'distribution' subdirectory. Now determine our actual distribution directory.
+ return checkDataDistribution();
+ }
+ } catch (SecurityException e) {
+ Log.e(LOGTAG, "Security exception copying files. Corrupt or malicious?", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_SECURITY_EXCEPTION);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error copying files from distribution.", e);
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_POST_FETCH_EXCEPTION);
+ } finally {
+ distro.close();
+ }
+ } finally {
+ connection.disconnect();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error copying distribution files from network.", e);
+ recordFetchTelemetry(e);
+ }
+
+ return false;
+ }
+
+ private static final int clamp(long v, long c) {
+ return (int) Math.min(c, v);
+ }
+
+ /**
+ * Fetch the provided URI, returning a {@link JarInputStream} if the response body
+ * is appropriate.
+ *
+ * Protected to allow for mocking.
+ *
+ * @return the entity body as a stream, or null on failure.
+ */
+ @SuppressWarnings("static-method")
+ @RobocopTarget
+ protected JarInputStream fetchDistribution(URI uri, HttpURLConnection connection) throws IOException {
+ final int status = connection.getResponseCode();
+
+ Log.d(LOGTAG, "Distribution fetch: " + status);
+ // We record HTTP statuses as 2xx, 3xx, 4xx, 5xx => 2, 3, 4, 5.
+ final int value;
+ if (status > 599 || status < 100) {
+ Log.wtf(LOGTAG, "Unexpected HTTP status code: " + status);
+ value = CODE_CATEGORY_STATUS_OUT_OF_RANGE;
+ } else {
+ value = status / 100;
+ }
+
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, value);
+
+ if (status != 200) {
+ Log.w(LOGTAG, "Got status " + status + " fetching distribution.");
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE);
+ return null;
+ }
+
+ final String contentType = connection.getContentType();
+ if (contentType == null || !contentType.startsWith(EXPECTED_CONTENT_TYPE)) {
+ Log.w(LOGTAG, "Malformed response: invalid Content-Type.");
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE);
+ return null;
+ }
+
+ return new JarInputStream(new BufferedInputStream(connection.getInputStream()), true);
+ }
+
+ private static void recordFetchTelemetry(final Exception exception) {
+ if (exception == null) {
+ // Should never happen.
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION);
+ return;
+ }
+
+ if (exception instanceof UnknownHostException) {
+ // Unknown host => we're offline.
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_OFFLINE);
+ return;
+ }
+
+ if (exception instanceof SSLException) {
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SSL_ERROR);
+ return;
+ }
+
+ if (exception instanceof ProtocolException ||
+ exception instanceof SocketException) {
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SOCKET_ERROR);
+ return;
+ }
+
+ Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION);
+ }
+
+ /**
+ * @return true if we copied files out of the APK. Sets distributionDir in that case.
+ */
+ private boolean copyAndCheckAPKDistribution() {
+ try {
+ // First, try copying distribution files out of the APK.
+ if (copyFilesFromPackagedAssets()) {
+ // We always copy to the data dir, and we only copy files from
+ // a 'distribution' subdirectory. Now determine our actual distribution directory.
+ return checkDataDistribution();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error copying distribution files from APK.", e);
+ }
+ return false;
+ }
+
+ /**
+ * @return true if we found a data distribution (copied from APK or OTA). Sets distributionDir in that case.
+ */
+ private boolean checkDataDistribution() {
+ return checkDirectories(getDataDistributionDirectories(context));
+ }
+
+ /**
+ * @return true if we found a system distribution. Sets distributionDir in that case.
+ */
+ private boolean checkSystemDistribution() {
+ return checkDirectories(getSystemDistributionDirectories(context));
+ }
+
+ /**
+ * @return true if one of the specified distribution directories exists. Sets distributionDir in that case.
+ */
+ private boolean checkDirectories(String[] directories) {
+ for (String path : directories) {
+ File directory = new File(path);
+ if (directory.exists()) {
+ distributionDir = directory;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Unpack distribution files from a downloaded jar stream.
+ *
+ * The caller is responsible for closing the provided stream.
+ */
+ private boolean copyFilesFromStream(JarInputStream jar) throws FileNotFoundException, IOException {
+ final byte[] buffer = new byte[1024];
+ boolean distributionSet = false;
+ JarEntry entry;
+ while ((entry = jar.getNextJarEntry()) != null) {
+ final String name = entry.getName();
+
+ if (entry.isDirectory()) {
+ // We'll let getDataFile deal with creating the directory hierarchy.
+ // Yes, we can do better, but it can wait.
+ continue;
+ }
+
+ if (!name.startsWith(DISTRIBUTION_PATH)) {
+ continue;
+ }
+
+ File outFile = getDataFile(name);
+ if (outFile == null) {
+ continue;
+ }
+
+ distributionSet = true;
+
+ writeStream(jar, outFile, entry.getTime(), buffer);
+ }
+
+ return distributionSet;
+ }
+
+ /**
+ * Copies the /assets/distribution folder out of the APK and into the app's data directory.
+ * Returns true if distribution files were found and copied.
+ */
+ private boolean copyFilesFromPackagedAssets() throws IOException {
+ final File applicationPackage = new File(packagePath);
+ final ZipFile zip = new ZipFile(applicationPackage);
+
+ final String assetsPrefix = "assets/";
+ final String fullPrefix = assetsPrefix + DISTRIBUTION_PATH;
+
+ boolean distributionSet = false;
+ try {
+ final byte[] buffer = new byte[1024];
+
+ final Enumeration<? extends ZipEntry> zipEntries = zip.entries();
+ while (zipEntries.hasMoreElements()) {
+ final ZipEntry fileEntry = zipEntries.nextElement();
+ final String name = fileEntry.getName();
+
+ if (fileEntry.isDirectory()) {
+ // We'll let getDataFile deal with creating the directory hierarchy.
+ continue;
+ }
+
+ // Read from "assets/distribution/**".
+ if (!name.startsWith(fullPrefix)) {
+ continue;
+ }
+
+ // Write to "distribution/**".
+ final String nameWithoutPrefix = name.substring(assetsPrefix.length());
+ final File outFile = getDataFile(nameWithoutPrefix);
+ if (outFile == null) {
+ continue;
+ }
+
+ distributionSet = true;
+
+ final InputStream fileStream = zip.getInputStream(fileEntry);
+ try {
+ writeStream(fileStream, outFile, fileEntry.getTime(), buffer);
+ } finally {
+ fileStream.close();
+ }
+ }
+ } finally {
+ zip.close();
+ }
+
+ return distributionSet;
+ }
+
+ private void writeStream(InputStream fileStream, File outFile, final long modifiedTime, byte[] buffer)
+ throws FileNotFoundException, IOException {
+ final OutputStream outStream = new FileOutputStream(outFile);
+ try {
+ int count;
+ while ((count = fileStream.read(buffer)) > 0) {
+ outStream.write(buffer, 0, count);
+ }
+
+ outFile.setLastModified(modifiedTime);
+ } finally {
+ outStream.close();
+ }
+ }
+
+ /**
+ * Return a File instance in the data directory, ensuring
+ * that the parent exists.
+ *
+ * @return null if the parents could not be created.
+ */
+ private File getDataFile(final String name) {
+ File outFile = new File(getDataDir(), name);
+ File dir = outFile.getParentFile();
+
+ if (!dir.exists()) {
+ Log.d(LOGTAG, "Creating " + dir.getAbsolutePath());
+ if (!dir.mkdirs()) {
+ Log.e(LOGTAG, "Unable to create directories: " + dir.getAbsolutePath());
+ return null;
+ }
+ }
+
+ return outFile;
+ }
+
+ private URI getReferredDistribution(ReferrerDescriptor descriptor) {
+ final String content = descriptor.content;
+ if (content == null) {
+ return null;
+ }
+
+ // We restrict here to avoid injection attacks. After all,
+ // we're downloading a distribution payload based on intent input.
+ if (!content.matches("^[a-zA-Z0-9]+$")) {
+ Log.e(LOGTAG, "Invalid referrer content: " + content);
+ Telemetry.addToHistogram(HISTOGRAM_REFERRER_INVALID, 1);
+ return null;
+ }
+
+ try {
+ return new URI(FETCH_PROTOCOL, FETCH_HOSTNAME, FETCH_PATH + content + FETCH_EXTENSION, null);
+ } catch (URISyntaxException e) {
+ // This should never occur.
+ Log.wtf(LOGTAG, "Invalid URI with content " + content + "!");
+ return null;
+ }
+ }
+
+ /**
+ * After calling this method, either <code>distributionDir</code>
+ * will be set, or there is no distribution in use.
+ *
+ * Only call after init.
+ */
+ private File ensureDistributionDir() {
+ if (this.distributionDir != null) {
+ return this.distributionDir;
+ }
+
+ if (this.state != STATE_SET) {
+ return null;
+ }
+
+ // After init, we know that either we've copied a distribution out of
+ // the APK, or it exists in /system/.
+ // Look in each location in turn.
+ // (This could be optimized by caching the path in shared prefs.)
+ if (checkDataDistribution() || checkSystemDistribution()) {
+ return distributionDir;
+ }
+
+ return null;
+ }
+
+ private String getDataDir() {
+ return context.getApplicationInfo().dataDir;
+ }
+
+ @JNITarget
+ public static String[] getDistributionDirectories() {
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ final String[] dataDirectories = getDataDistributionDirectories(context);
+ final String[] systemDirectories = getSystemDistributionDirectories(context);
+
+ final String[] directories = new String[dataDirectories.length + systemDirectories.length];
+
+ System.arraycopy(dataDirectories, 0, directories, 0, dataDirectories.length);
+ System.arraycopy(systemDirectories, 0, directories, dataDirectories.length, systemDirectories.length);
+
+ return directories;
+ }
+
+ /**
+ * Get a list of system distribution folder candidates.
+ *
+ * /system/<package>/distribution/<mcc>/<mnc> - For bundled distributions for specific network providers
+ * /system/<package>/distribution/<mcc> - For bundled distributions for specific countries
+ * /system/<package>/distribution/default - For bundled distributions with no matching mcc/mnc
+ * /system/<package>/distribution - Default non-bundled system distribution
+ */
+ private static String[] getSystemDistributionDirectories(Context context) {
+ final String baseDirectory = "/system/" + context.getPackageName() + "/distribution";
+ return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory);
+ }
+
+ /**
+ * Get a list of data distribution folder candidates.
+ *
+ * <dataDir>/distribution/<mcc>/<mnc> - For bundled distributions for specific network providers
+ * <dataDir>/distribution/<mcc> - For bundled distributions for specific countries
+ * <dataDir>/distribution/default - For bundled distributions with no matching mcc/mnc
+ * <dataDir>/distribution - Default non-bundled system distribution
+ */
+ private static String[] getDataDistributionDirectories(Context context) {
+ final String baseDirectory = new File(context.getApplicationInfo().dataDir, DISTRIBUTION_PATH).getAbsolutePath();
+ return getDistributionDirectoriesFromBaseDirectory(context, baseDirectory);
+ }
+
+ /**
+ * Get a list of distribution folder candidates inside the specified base directory.
+ */
+ private static String[] getDistributionDirectoriesFromBaseDirectory(Context context, String baseDirectory) {
+ final TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ if (telephonyManager != null && telephonyManager.getSimState() == TelephonyManager.SIM_STATE_READY) {
+ final String simOperator = telephonyManager.getSimOperator();
+
+ if (simOperator != null && simOperator.length() >= 5) {
+ final String mcc = simOperator.substring(0, 3);
+ final String mnc = simOperator.substring(3);
+
+ return new String[] {
+ baseDirectory + "/" + mcc + "/" + mnc,
+ baseDirectory + "/" + mcc,
+ baseDirectory + "/default",
+ baseDirectory
+ };
+ }
+ }
+
+ return new String[] {
+ baseDirectory + "/default",
+ baseDirectory
+ };
+ }
+
+ /**
+ * The provided <code>ReadyCallback</code> will be queued for execution after
+ * the distribution is ready, or queued for immediate execution if the
+ * distribution has already been processed.
+ *
+ * Each <code>ReadyCallback</code> will be executed on the background thread.
+ */
+ public void addOnDistributionReadyCallback(final ReadyCallback callback) {
+ if (state == STATE_UNKNOWN) {
+ // Queue for later.
+ onDistributionReady.add(callback);
+ } else {
+ invokeCallbackDelayed(callback);
+ }
+ }
+
+ /**
+ * Run our delayed queue, after a delayed distribution arrives.
+ */
+ private void runLateReadyQueue() {
+ ReadyCallback task;
+ while ((task = onLateReady.poll()) != null) {
+ invokeLateCallbackDelayed(task);
+ }
+ }
+
+ /**
+ * Execute tasks that wanted to run when we were done loading
+ * the distribution.
+ */
+ private void runReadyQueue() {
+ ReadyCallback task;
+ while ((task = onDistributionReady.poll()) != null) {
+ invokeCallbackDelayed(task);
+ }
+ }
+
+ private void invokeLateCallbackDelayed(final ReadyCallback callback) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // Sanity.
+ if (state != STATE_SET) {
+ Log.w(LOGTAG, "Refusing to invoke late distro callback in state " + state);
+ return;
+ }
+ callback.distributionArrivedLate(Distribution.this);
+ }
+ });
+ }
+
+ private void invokeCallbackDelayed(final ReadyCallback callback) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @WorkerThread
+ @Override
+ public void run() {
+ switch (state) {
+ case STATE_SET:
+ callback.distributionFound(Distribution.this);
+ break;
+ case STATE_NONE:
+ callback.distributionNotFound();
+ if (shouldDelayLateCallbacks) {
+ onLateReady.add(callback);
+ }
+ break;
+ default:
+ throw new IllegalStateException("Expected STATE_NONE or STATE_SET, got " + state);
+ }
+ }
+ });
+ }
+
+ /**
+ * A safe way for callers to determine if this Distribution instance
+ * represents a real live distribution.
+ */
+ public boolean exists() {
+ return state == STATE_SET;
+ }
+
+ private String getKeyName() {
+ return context.getPackageName() + ".distribution_state";
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ final SharedPreferences settings;
+ if (prefsBranch == null) {
+ settings = GeckoSharedPrefs.forApp(context);
+ } else {
+ settings = context.getSharedPreferences(prefsBranch, Activity.MODE_PRIVATE);
+ }
+ return settings;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java b/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java
new file mode 100644
index 000000000..11ed4811f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/DistributionStoreCallback.java
@@ -0,0 +1,61 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.distribution;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+import org.mozilla.gecko.GeckoSharedPrefs;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * A distribution ready callback that will store the distribution ID to profile-specific shared preferences.
+ */
+public class DistributionStoreCallback implements Distribution.ReadyCallback {
+ private static final String LOGTAG = "Gecko" + DistributionStoreCallback.class.getSimpleName();
+
+ public static final String PREF_DISTRIBUTION_ID = "distribution.id";
+
+ private final WeakReference<Context> contextReference;
+ private final String profileName;
+
+ public DistributionStoreCallback(final Context context, final String profileName) {
+ this.contextReference = new WeakReference<>(context);
+ this.profileName = profileName;
+ }
+
+ public void distributionNotFound() { /* nothing to do here */ }
+
+ @Override
+ public void distributionFound(final Distribution distribution) {
+ storeDistribution(distribution);
+ }
+
+ @Override
+ public void distributionArrivedLate(final Distribution distribution) {
+ storeDistribution(distribution);
+ }
+
+ private void storeDistribution(final Distribution distribution) {
+ final Context context = contextReference.get();
+ if (context == null) {
+ Log.w(LOGTAG, "Context is no longer alive, could retrieve shared prefs to store distribution");
+ return;
+ }
+
+ // While the distribution preferences are per install and not per profile, it's okay to use the
+ // profile-specific prefs because:
+ // 1) We don't really support mulitple profiles for end-users
+ // 2) The TelemetryUploadService already accesses profile-specific shared prefs so this keeps things simple.
+ final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfileName(context, profileName);
+ final Distribution.DistributionDescriptor desc = distribution.getDescriptor();
+ if (desc != null) {
+ sharedPrefs.edit().putString(PREF_DISTRIBUTION_ID, desc.id).apply();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java
new file mode 100644
index 000000000..78a77221d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBookmarksProviderProxy.java
@@ -0,0 +1,322 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.db.BrowserContract;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A proxy for the partner bookmarks provider. Bookmark and folder ids of the partner bookmarks providers
+ * will be transformed so that they do not overlap with the ids from the local database.
+ *
+ * Bookmarks in folder:
+ * content://{PACKAGE_ID}.partnerbookmarks/bookmarks/{folderId}
+ * Icon of bookmark:
+ * content://{PACKAGE_ID}.partnerbookmarks/icons/{bookmarkId}
+ */
+public class PartnerBookmarksProviderProxy extends ContentProvider {
+ /**
+ * The contract between the partner bookmarks provider and applications. Contains the definition
+ * for the supported URIs and columns.
+ */
+ public static class PartnerContract {
+ public static final Uri CONTENT_URI = Uri.parse("content://com.android.partnerbookmarks/bookmarks");
+
+ public static final int TYPE_BOOKMARK = 1;
+ public static final int TYPE_FOLDER = 2;
+
+ public static final int PARENT_ROOT_ID = 0;
+
+ public static final String ID = "_id";
+ public static final String TYPE = "type";
+ public static final String URL = "url";
+ public static final String TITLE = "title";
+ public static final String FAVICON = "favicon";
+ public static final String TOUCHICON = "touchicon";
+ public static final String PARENT = "parent";
+ }
+
+ private static final String AUTHORITY_PREFIX = ".partnerbookmarks";
+
+ private static final int URI_MATCH_BOOKMARKS = 1000;
+ private static final int URI_MATCH_ICON = 1001;
+ private static final int URI_MATCH_BOOKMARK = 1002;
+
+ private static final String PREF_DELETED_PARTNER_BOOKMARKS = "distribution.partner.bookmark.deleted";
+
+ /**
+ * Cursor wrapper for filtering empty folders.
+ */
+ private static class FilteredCursor extends CursorWrapper {
+ private HashSet<Integer> emptyFolderPositions;
+ private int count;
+
+ public FilteredCursor(PartnerBookmarksProviderProxy proxy, Cursor cursor) {
+ super(cursor);
+
+ emptyFolderPositions = new HashSet<>();
+ count = cursor.getCount();
+
+ for (int i = 0; i < cursor.getCount(); i++) {
+ cursor.moveToPosition(i);
+
+ final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID));
+ final int type = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Bookmarks.TYPE));
+
+ if (type == BrowserContract.Bookmarks.TYPE_FOLDER && proxy.isFolderEmpty(id)) {
+ // We do not support deleting folders. So at least hide partner folders that are
+ // empty because all bookmarks inside it are deleted/hidden.
+ // Note that this will still show folders with empty folders in them. But multi-level
+ // partner bookmarks are very unlikely.
+
+ count--;
+ emptyFolderPositions.add(i);
+ }
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return count;
+ }
+
+ @Override
+ public boolean moveToPosition(int position) {
+ final Cursor cursor = getWrappedCursor();
+ final int actualCount = cursor.getCount();
+
+ // Find the next position pointing to a bookmark or a non-empty folder
+ while (position < actualCount && emptyFolderPositions.contains(position)) {
+ position++;
+ }
+
+ return position < actualCount && cursor.moveToPosition(position);
+ }
+ }
+
+ private static String getAuthority(Context context) {
+ return context.getPackageName() + AUTHORITY_PREFIX;
+ }
+
+ public static Uri getUriForBookmarks(Context context, long folderId) {
+ return new Uri.Builder()
+ .scheme("content")
+ .authority(getAuthority(context))
+ .appendPath("bookmarks")
+ .appendPath(String.valueOf(folderId))
+ .build();
+ }
+
+ public static Uri getUriForIcon(Context context, long bookmarkId) {
+ return new Uri.Builder()
+ .scheme("content")
+ .authority(getAuthority(context))
+ .appendPath("icons")
+ .appendPath(String.valueOf(bookmarkId))
+ .build();
+ }
+
+ public static Uri getUriForBookmark(Context context, long bookmarkId) {
+ return new Uri.Builder()
+ .scheme("content")
+ .authority(getAuthority(context))
+ .appendPath("bookmark")
+ .appendPath(String.valueOf(bookmarkId))
+ .build();
+ }
+
+ private final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+ @Override
+ public boolean onCreate() {
+ String authority = getAuthority(assertAndGetContext());
+
+ uriMatcher.addURI(authority, "bookmarks/*", URI_MATCH_BOOKMARKS);
+ uriMatcher.addURI(authority, "icons/*", URI_MATCH_ICON);
+ uriMatcher.addURI(authority, "bookmark/*", URI_MATCH_BOOKMARK);
+
+ return true;
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+ final Context context = assertAndGetContext();
+ final int match = uriMatcher.match(uri);
+
+ final ContentResolver contentResolver = context.getContentResolver();
+
+ switch (match) {
+ case URI_MATCH_BOOKMARKS:
+ final long bookmarkId = ContentUris.parseId(uri);
+ if (bookmarkId == -1) {
+ throw new IllegalArgumentException("Bookmark id is not a number");
+ }
+ final Cursor cursor = getBookmarksInFolder(contentResolver, bookmarkId);
+ cursor.setNotificationUri(context.getContentResolver(), uri);
+ return new FilteredCursor(this, cursor);
+
+ case URI_MATCH_ICON:
+ return getIcon(contentResolver, ContentUris.parseId(uri));
+
+ default:
+ throw new UnsupportedOperationException("Unknown URI " + uri.toString());
+ }
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
+ final int match = uriMatcher.match(uri);
+
+ switch (match) {
+ case URI_MATCH_BOOKMARK:
+ rememberRemovedBookmark(ContentUris.parseId(uri));
+ notifyBookmarkChange();
+ return 1;
+
+ default:
+ throw new UnsupportedOperationException("Unknown URI " + uri.toString());
+ }
+ }
+
+ private void notifyBookmarkChange() {
+ final Context context = assertAndGetContext();
+
+ context.getContentResolver().notifyChange(
+ new Uri.Builder()
+ .scheme("content")
+ .authority(getAuthority(context))
+ .appendPath("bookmarks")
+ .build(),
+ null);
+ }
+
+ private synchronized void rememberRemovedBookmark(long bookmarkId) {
+ Set<String> deletedIds = getRemovedBookmarkIds();
+
+ deletedIds.add(String.valueOf(bookmarkId));
+
+ GeckoSharedPrefs.forProfile(assertAndGetContext())
+ .edit()
+ .putStringSet(PREF_DELETED_PARTNER_BOOKMARKS, deletedIds)
+ .apply();
+ }
+
+ private synchronized Set<String> getRemovedBookmarkIds() {
+ SharedPreferences preferences = GeckoSharedPrefs.forProfile(assertAndGetContext());
+ return preferences.getStringSet(PREF_DELETED_PARTNER_BOOKMARKS, new HashSet<String>());
+ }
+
+ private Cursor getBookmarksInFolder(ContentResolver contentResolver, long folderId) {
+ // Use root folder id or transform negative id into actual (positive) folder id.
+ final long actualFolderId = folderId == BrowserContract.Bookmarks.FIXED_ROOT_ID
+ ? PartnerContract.PARENT_ROOT_ID
+ : BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START - folderId;
+
+ final String removedBookmarkIds = TextUtils.join(",", getRemovedBookmarkIds());
+
+ return contentResolver.query(
+ PartnerContract.CONTENT_URI,
+ new String[] {
+ // Transform ids into negative values starting with FAKE_PARTNER_BOOKMARKS_START.
+ "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.ID + ") as " + BrowserContract.Bookmarks._ID,
+ "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.ID + ") as " + BrowserContract.Combined.BOOKMARK_ID,
+ PartnerContract.TITLE + " as " + BrowserContract.Bookmarks.TITLE,
+ PartnerContract.URL + " as " + BrowserContract.Bookmarks.URL,
+ // Transform parent ids to negative ids as well
+ "(" + BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START + " - " + PartnerContract.PARENT + ") as " + BrowserContract.Bookmarks.PARENT,
+ // Convert types (we use 0-1 and the partner provider 1-2)
+ "(2 - " + PartnerContract.TYPE + ") as " + BrowserContract.Bookmarks.TYPE,
+ // Use the ID of the entry as GUID
+ PartnerContract.ID + " as " + BrowserContract.Bookmarks.GUID
+ },
+ PartnerContract.PARENT + " = ?"
+ // We only want to read bookmarks or folders from the content provider
+ + " AND " + BrowserContract.Bookmarks.TYPE + " IN (?,?)"
+ // Only select entries with non empty title
+ + " AND " + BrowserContract.Bookmarks.TITLE + " <> ''"
+ // Filter all "deleted" ids
+ + " AND " + BrowserContract.Combined.BOOKMARK_ID + " NOT IN (" + removedBookmarkIds + ")",
+ new String[] {
+ String.valueOf(actualFolderId),
+ String.valueOf(PartnerContract.TYPE_BOOKMARK),
+ String.valueOf(PartnerContract.TYPE_FOLDER)
+ },
+ // Same order we use in our content provider (without position)
+ BrowserContract.Bookmarks.TYPE + " ASC, " + BrowserContract.Bookmarks._ID + " ASC");
+ }
+
+ private boolean isFolderEmpty(long folderId) {
+ final Context context = assertAndGetContext();
+ final Cursor cursor = getBookmarksInFolder(context.getContentResolver(), folderId);
+
+ if (cursor == null) {
+ return true;
+ }
+
+ try {
+ return cursor.getCount() == 0;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ private Cursor getIcon(ContentResolver contentResolver, long bookmarkId) {
+ final long actualId = BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START - bookmarkId;
+
+ return contentResolver.query(
+ PartnerContract.CONTENT_URI,
+ new String[] {
+ PartnerContract.TOUCHICON,
+ PartnerContract.FAVICON
+ },
+ PartnerContract.ID + " = ?",
+ new String[] {
+ String.valueOf(actualId)
+ },
+ null);
+ }
+
+ private Context assertAndGetContext() {
+ final Context context = super.getContext();
+
+ if (context == null) {
+ throw new AssertionError("Context is null");
+ }
+
+ return context;
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues values) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java
new file mode 100644
index 000000000..2dad21a48
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/PartnerBrowserCustomizationsClient.java
@@ -0,0 +1,43 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+/**
+ * Client for accessing data from Android's "partner browser customizations" content provider.
+ */
+public class PartnerBrowserCustomizationsClient {
+ private static final Uri CONTENT_URI = Uri.parse("content://com.android.partnerbrowsercustomizations");
+
+ private static final Uri HOMEPAGE_URI = CONTENT_URI.buildUpon().path("homepage").build();
+
+ private static final String COLUMN_HOMEPAGE = "homepage";
+
+ /**
+ * Returns the partner homepage or null if it could not be read from the content provider.
+ */
+ public static String getHomepage(Context context) {
+ Cursor cursor = context.getContentResolver().query(
+ HOMEPAGE_URI, new String[] { COLUMN_HOMEPAGE }, null, null, null);
+
+ if (cursor == null) {
+ return null;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+
+ return cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_HOMEPAGE));
+ } finally {
+ cursor.close();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java
new file mode 100644
index 000000000..4a1be656b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerDescriptor.java
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+import android.net.Uri;
+
+import java.net.URLDecoder;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * Encapsulates access to values encoded in the "referrer" extra of an install intent.
+ *
+ * This object is immutable.
+ *
+ * Example input:
+ *
+ * "utm_source=campsource&utm_medium=campmed&utm_term=term%2Bhere&utm_content=content&utm_campaign=name"
+ */
+@RobocopTarget
+public class ReferrerDescriptor {
+ public final String source;
+ public final String medium;
+ public final String term;
+ public final String content;
+ public final String campaign;
+
+ public ReferrerDescriptor(String referrer) {
+ if (referrer == null) {
+ source = null;
+ medium = null;
+ term = null;
+ content = null;
+ campaign = null;
+ return;
+ }
+
+ try {
+ referrer = URLDecoder.decode(referrer, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // UTF-8 is always supported
+ }
+
+ final Uri u = new Uri.Builder()
+ .scheme("http")
+ .authority("local")
+ .path("/")
+ .encodedQuery(referrer).build();
+
+ source = u.getQueryParameter("utm_source");
+ medium = u.getQueryParameter("utm_medium");
+ term = u.getQueryParameter("utm_term");
+ content = u.getQueryParameter("utm_content");
+ campaign = u.getQueryParameter("utm_campaign");
+ }
+
+ @Override
+ public String toString() {
+ return "{s: " + source + ", m: " + medium + ", t: " + term + ", c: " + content + ", c: " + campaign + "}";
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java
new file mode 100644
index 000000000..3651d6068
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/distribution/ReferrerReceiver.java
@@ -0,0 +1,107 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.distribution;
+
+import org.mozilla.gecko.AdjustConstants;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class ReferrerReceiver extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoReferrerReceiver";
+
+ private static final String ACTION_INSTALL_REFERRER = "com.android.vending.INSTALL_REFERRER";
+
+ // Sent when we're done.
+ @RobocopTarget
+ public static final String ACTION_REFERRER_RECEIVED = "org.mozilla.fennec.REFERRER_RECEIVED";
+
+ /**
+ * If the install intent has this source, it is a Mozilla specific or over
+ * the air distribution referral. We'll track the campaign ID using
+ * Mozilla's metrics systems.
+ *
+ * If the install intent has a source different than this one, it is a
+ * referral from an advertising network. We may track these campaigns using
+ * third-party tracking and metrics systems.
+ */
+ private static final String MOZILLA_UTM_SOURCE = "mozilla";
+
+ /**
+ * If the install intent has this campaign, we'll load the specified distribution.
+ */
+ private static final String DISTRIBUTION_UTM_CAMPAIGN = "distribution";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.v(LOGTAG, "Received intent " + intent);
+ if (!ACTION_INSTALL_REFERRER.equals(intent.getAction())) {
+ // This should never happen.
+ return;
+ }
+
+ // Track the referrer object for distribution handling.
+ ReferrerDescriptor referrer = new ReferrerDescriptor(intent.getStringExtra("referrer"));
+
+ if (!TextUtils.equals(referrer.source, MOZILLA_UTM_SOURCE)) {
+ // Allow the Adjust handler to process the intent.
+ try {
+ AdjustConstants.getAdjustHelper().onReceive(context, intent);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception in Adjust's onReceive; ignoring referrer intent.", e);
+ }
+ return;
+ }
+
+ if (TextUtils.equals(referrer.campaign, DISTRIBUTION_UTM_CAMPAIGN)) {
+ Distribution.onReceivedReferrer(context, referrer);
+ // We want Adjust information for OTA distributions as well
+ try {
+ AdjustConstants.getAdjustHelper().onReceive(context, intent);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception in Adjust's onReceive for distribution.", e);
+ }
+ } else {
+ Log.d(LOGTAG, "Not downloading distribution: non-matching campaign.");
+ // If this is a Mozilla campaign, pass the campaign along to Gecko.
+ // It'll pretend to be a "playstore" distribution for BLP purposes.
+ propagateMozillaCampaign(referrer);
+ }
+
+ // Broadcast a secondary, local intent to allow test code to respond.
+ final Intent receivedIntent = new Intent(ACTION_REFERRER_RECEIVED);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(receivedIntent);
+ }
+
+
+ private void propagateMozillaCampaign(ReferrerDescriptor referrer) {
+ if (referrer.campaign == null) {
+ return;
+ }
+
+ try {
+ final JSONObject data = new JSONObject();
+ data.put("id", "playstore");
+ data.put("version", referrer.campaign);
+ String payload = data.toString();
+
+ // Try to make sure the prefs are written as a group.
+ GeckoAppShell.notifyObservers("Campaign:Set", payload);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error propagating campaign identifier.", e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
new file mode 100644
index 000000000..28d6b238d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/BaseAction.java
@@ -0,0 +1,166 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.support.annotation.IntDef;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.ProxySelector;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+public abstract class BaseAction {
+ private static final String LOGTAG = "GeckoDLCBaseAction";
+
+ /**
+ * Exception indicating a recoverable error has happened. Download of the content will be retried later.
+ */
+ /* package-private */ static class RecoverableDownloadContentException extends Exception {
+ private static final long serialVersionUID = -2246772819507370734L;
+
+ @IntDef({MEMORY, DISK_IO, SERVER, NETWORK})
+ public @interface ErrorType {}
+ public static final int MEMORY = 1;
+ public static final int DISK_IO = 2;
+ public static final int SERVER = 3;
+ public static final int NETWORK = 4;
+
+ private int errorType;
+
+ public RecoverableDownloadContentException(@ErrorType int errorType, String message) {
+ super(message);
+ this.errorType = errorType;
+ }
+
+ public RecoverableDownloadContentException(@ErrorType int errorType, Throwable cause) {
+ super(cause);
+ this.errorType = errorType;
+ }
+
+ @ErrorType
+ public int getErrorType() {
+ return errorType;
+ }
+
+ /**
+ * Should this error be counted as failure? If this type of error will happen multiple times in a row then this
+ * error will be treated as permanently and the operation will not be tried again until the content changes.
+ */
+ public boolean shouldBeCountedAsFailure() {
+ if (NETWORK == errorType) {
+ return false; // Always retry after network errors
+ }
+
+ return true;
+ }
+ }
+
+ /**
+ * If this exception is thrown the content will be marked as unrecoverable, permanently failed and we will not try
+ * downloading it again - until a newer version of the content is available.
+ */
+ /* package-private */ static class UnrecoverableDownloadContentException extends Exception {
+ private static final long serialVersionUID = 8956080754787367105L;
+
+ public UnrecoverableDownloadContentException(String message) {
+ super(message);
+ }
+
+ public UnrecoverableDownloadContentException(Throwable cause) {
+ super(cause);
+ }
+ }
+
+ public abstract void perform(Context context, DownloadContentCatalog catalog);
+
+ protected File getDestinationFile(Context context, DownloadContent content)
+ throws UnrecoverableDownloadContentException, RecoverableDownloadContentException {
+ if (content.isFont()) {
+ File destinationDirectory = new File(context.getApplicationInfo().dataDir, "fonts");
+
+ if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO,
+ "Destination directory does not exist and cannot be created");
+ }
+
+ return new File(destinationDirectory, content.getFilename());
+ }
+
+ // Unrecoverable: We downloaded a file and we don't know what to do with it (Should not happen)
+ throw new UnrecoverableDownloadContentException("Can't determine destination for kind: " + content.getKind());
+ }
+
+ protected boolean verify(File file, String expectedChecksum)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ InputStream inputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(new FileInputStream(file));
+
+ byte[] ctx = NativeCrypto.sha256init();
+ if (ctx == null) {
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.MEMORY,
+ "Could not create SHA-256 context");
+ }
+
+ byte[] buffer = new byte[4096];
+ int read;
+
+ while ((read = inputStream.read(buffer)) != -1) {
+ NativeCrypto.sha256update(ctx, buffer, read);
+ }
+
+ String actualChecksum = Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
+
+ if (!actualChecksum.equalsIgnoreCase(expectedChecksum)) {
+ Log.w(LOGTAG, "Checksums do not match. Expected=" + expectedChecksum + ", Actual=" + actualChecksum);
+ return false;
+ }
+
+ return true;
+ } catch (IOException e) {
+ // Recoverable: Just I/O discontinuation
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ }
+ }
+
+ protected HttpURLConnection buildHttpURLConnection(String url)
+ throws UnrecoverableDownloadContentException, IOException {
+ try {
+ System.setProperty("http.keepAlive", "true");
+
+ HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(new URI(url));
+ connection.setRequestProperty("User-Agent", HardwareUtils.isTablet() ?
+ AppConstants.USER_AGENT_FENNEC_TABLET :
+ AppConstants.USER_AGENT_FENNEC_MOBILE);
+ connection.setRequestMethod("GET");
+ connection.setInstanceFollowRedirects(true);
+ return connection;
+ } catch (MalformedURLException e) {
+ throw new UnrecoverableDownloadContentException(e);
+ } catch (URISyntaxException e) {
+ throw new UnrecoverableDownloadContentException(e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java
new file mode 100644
index 000000000..e44704c6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/CleanupAction.java
@@ -0,0 +1,49 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+
+import java.io.File;
+
+/**
+ * CleanupAction: Remove content that is no longer needed.
+ */
+public class CleanupAction extends BaseAction {
+ @Override
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ for (DownloadContent content : catalog.getContentToDelete()) {
+ if (!content.isAssetArchive()) {
+ continue; // We do not know how to clean up this content. But this means we didn't
+ // download it anyways.
+ }
+
+ try {
+ File file = getDestinationFile(context, content);
+
+ if (!file.exists()) {
+ // File does not exist. As good as deleting.
+ catalog.remove(content);
+ return;
+ }
+
+ if (file.delete()) {
+ // File has been deleted. Now remove it from the catalog.
+ catalog.remove(content);
+ }
+ } catch (UnrecoverableDownloadContentException e) {
+ // We can't recover. Pretend the content is removed. It probably never existed in
+ // the first place.
+ catalog.remove(content);
+ } catch (RecoverableDownloadContentException e) {
+ // Try again next time.
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
new file mode 100644
index 000000000..8618d4699
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
@@ -0,0 +1,325 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.support.v4.net.ConnectivityManagerCompat;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Download content that has been scheduled during "study" or "verify".
+ */
+public class DownloadAction extends BaseAction {
+ private static final String LOGTAG = "DLCDownloadAction";
+
+ private static final String CACHE_DIRECTORY = "downloadContent";
+
+ private static final String CDN_BASE_URL = "https://fennec-catalog.cdn.mozilla.net/";
+
+ public interface Callback {
+ void onContentDownloaded(DownloadContent content);
+ }
+
+ private Callback callback;
+
+ public DownloadAction(Callback callback) {
+ this.callback = callback;
+ }
+
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ Log.d(LOGTAG, "Downloading content..");
+
+ if (!isConnectedToNetwork(context)) {
+ Log.d(LOGTAG, "No connected network available. Postponing download.");
+ // TODO: Reschedule download (bug 1209498)
+ return;
+ }
+
+ if (isActiveNetworkMetered(context)) {
+ Log.d(LOGTAG, "Network is metered. Postponing download.");
+ // TODO: Reschedule download (bug 1209498)
+ return;
+ }
+
+ for (DownloadContent content : catalog.getScheduledDownloads()) {
+ Log.d(LOGTAG, "Downloading: " + content);
+
+ File temporaryFile = null;
+
+ try {
+ File destinationFile = getDestinationFile(context, content);
+ if (destinationFile.exists() && verify(destinationFile, content.getChecksum())) {
+ Log.d(LOGTAG, "Content already exists and is up-to-date.");
+ catalog.markAsDownloaded(content);
+ continue;
+ }
+
+ temporaryFile = createTemporaryFile(context, content);
+
+ if (!hasEnoughDiskSpace(content, destinationFile, temporaryFile)) {
+ Log.d(LOGTAG, "Not enough disk space to save content. Skipping download.");
+ continue;
+ }
+
+ // TODO: Check space on disk before downloading content (bug 1220145)
+ final String url = createDownloadURL(content);
+
+ if (!temporaryFile.exists() || temporaryFile.length() < content.getSize()) {
+ download(url, temporaryFile);
+ }
+
+ if (!verify(temporaryFile, content.getDownloadChecksum())) {
+ Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
+ temporaryFile.delete();
+ continue;
+ }
+
+ if (!content.isAssetArchive()) {
+ Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType());
+ temporaryFile.delete();
+ continue;
+ }
+
+ extract(temporaryFile, destinationFile, content.getChecksum());
+
+ catalog.markAsDownloaded(content);
+
+ Log.d(LOGTAG, "Successfully downloaded: " + content);
+
+ if (callback != null) {
+ callback.onContentDownloaded(content);
+ }
+
+ if (temporaryFile != null && temporaryFile.exists()) {
+ temporaryFile.delete();
+ }
+ } catch (RecoverableDownloadContentException e) {
+ Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e);
+
+ if (e.shouldBeCountedAsFailure()) {
+ catalog.rememberFailure(content, e.getErrorType());
+ }
+
+ // TODO: Reschedule download (bug 1209498)
+ } catch (UnrecoverableDownloadContentException e) {
+ Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);
+
+ catalog.markAsPermanentlyFailed(content);
+
+ if (temporaryFile != null && temporaryFile.exists()) {
+ temporaryFile.delete();
+ }
+ }
+ }
+
+ Log.v(LOGTAG, "Done");
+ }
+
+ protected void download(String source, File temporaryFile)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ InputStream inputStream = null;
+ OutputStream outputStream = null;
+
+ HttpURLConnection connection = null;
+
+ try {
+ connection = buildHttpURLConnection(source);
+
+ final long offset = temporaryFile.exists() ? temporaryFile.length() : 0;
+ if (offset > 0) {
+ connection.setRequestProperty("Range", "bytes=" + offset + "-");
+ }
+
+ final int status = connection.getResponseCode();
+ if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
+ // We are trying to be smart and only retry if this is an error that might resolve in the future.
+ // TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106).
+ if (status >= 500) {
+ // Recoverable: Server errors 5xx
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,
+ "(Recoverable) Download failed. Status code: " + status);
+ } else if (status >= 400) {
+ // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
+ } else {
+ // HttpsUrlConnection: -1 (No valid response code)
+ // Informational 1xx: They have no meaning to us.
+ // Successful 2xx: We don't know how to handle anything but 200.
+ // Redirection 3xx: HttpClient should have followed redirects if possible. We should not see those errors here.
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
+ }
+ }
+
+ inputStream = new BufferedInputStream(connection.getInputStream());
+ outputStream = openFile(temporaryFile, status == HttpURLConnection.HTTP_PARTIAL);
+
+ IOUtils.copy(inputStream, outputStream);
+
+ inputStream.close();
+ outputStream.close();
+ } catch (IOException e) {
+ // Recoverable: Just I/O discontinuation
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ IOUtils.safeStreamClose(outputStream);
+
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ protected OutputStream openFile(File file, boolean append) throws FileNotFoundException {
+ return new BufferedOutputStream(new FileOutputStream(file, append));
+ }
+
+ protected void extract(File sourceFile, File destinationFile, String checksum)
+ throws UnrecoverableDownloadContentException, RecoverableDownloadContentException {
+ InputStream inputStream = null;
+ OutputStream outputStream = null;
+ File temporaryFile = null;
+
+ try {
+ File destinationDirectory = destinationFile.getParentFile();
+ if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+ throw new IOException("Destination directory does not exist and cannot be created");
+ }
+
+ temporaryFile = new File(destinationDirectory, destinationFile.getName() + ".tmp");
+
+ inputStream = new GZIPInputStream(new BufferedInputStream(new FileInputStream(sourceFile)));
+ outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile));
+
+ IOUtils.copy(inputStream, outputStream);
+
+ inputStream.close();
+ outputStream.close();
+
+ if (!verify(temporaryFile, checksum)) {
+ Log.w(LOGTAG, "Checksum of extracted file does not match.");
+ return;
+ }
+
+ move(temporaryFile, destinationFile);
+ } catch (IOException e) {
+ // We could not extract to the destination: Keep temporary file and try again next time we run.
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ IOUtils.safeStreamClose(outputStream);
+
+ if (temporaryFile != null && temporaryFile.exists()) {
+ temporaryFile.delete();
+ }
+ }
+ }
+
+ protected boolean isConnectedToNetwork(Context context) {
+ ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+
+ return networkInfo != null && networkInfo.isConnected();
+ }
+
+ protected boolean isActiveNetworkMetered(Context context) {
+ return ConnectivityManagerCompat.isActiveNetworkMetered(
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
+ }
+
+ protected String createDownloadURL(DownloadContent content) {
+ final String location = content.getLocation();
+
+ return CDN_BASE_URL + content.getLocation();
+ }
+
+ protected File createTemporaryFile(Context context, DownloadContent content)
+ throws RecoverableDownloadContentException {
+ File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);
+
+ if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
+ // Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways.
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO,
+ "Could not create cache directory: " + cacheDirectory);
+ }
+
+ return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId());
+ }
+
+ protected void move(File temporaryFile, File destinationFile)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ if (!temporaryFile.renameTo(destinationFile)) {
+ Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy..");
+ copy(temporaryFile, destinationFile);
+ temporaryFile.delete();
+ }
+ }
+
+ protected void copy(File temporaryFile, File destinationFile)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ InputStream inputStream = null;
+ OutputStream outputStream = null;
+
+ try {
+ File destinationDirectory = destinationFile.getParentFile();
+ if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
+ throw new IOException("Destination directory does not exist and cannot be created");
+ }
+
+ inputStream = new BufferedInputStream(new FileInputStream(temporaryFile));
+ outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile));
+
+ IOUtils.copy(inputStream, outputStream);
+
+ inputStream.close();
+ outputStream.close();
+ } catch (IOException e) {
+ // We could not copy the temporary file to its destination: Keep the temporary file and
+ // try again the next time we run.
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ IOUtils.safeStreamClose(outputStream);
+ }
+ }
+
+ protected boolean hasEnoughDiskSpace(DownloadContent content, File destinationFile, File temporaryFile) {
+ final File temporaryDirectory = temporaryFile.getParentFile();
+ if (temporaryDirectory.getUsableSpace() < content.getSize()) {
+ return false;
+ }
+
+ final File destinationDirectory = destinationFile.getParentFile();
+ // We need some more space to extract the file (getSize() returns the uncompressed size)
+ if (destinationDirectory.getUsableSpace() < content.getSize() * 2) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
new file mode 100644
index 000000000..3729cf2e0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadContentService.java
@@ -0,0 +1,144 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.app.IntentService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/**
+ * Service to handle downloadable content that did not ship with the APK.
+ */
+public class DownloadContentService extends IntentService {
+ private static final String LOGTAG = "GeckoDLCService";
+
+ /**
+ * Study: Scan the catalog for "new" content available for download.
+ */
+ private static final String ACTION_STUDY_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.STUDY";
+
+ /**
+ * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+ */
+ private static final String ACTION_VERIFY_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.VERIFY";
+
+ /**
+ * Download content that has been scheduled during "study" or "verify".
+ */
+ private static final String ACTION_DOWNLOAD_CONTENT = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.DOWNLOAD";
+
+ /**
+ * Sync: Synchronize catalog from a Kinto instance.
+ */
+ private static final String ACTION_SYNCHRONIZE_CATALOG = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.SYNC";
+
+ /**
+ * CleanupAction: Remove content that is no longer needed (e.g. Removed from the catalog after a sync).
+ */
+ private static final String ACTION_CLEANUP_FILES = AppConstants.ANDROID_PACKAGE_NAME + ".DLC.CLEANUP";
+
+ public static void startStudy(Context context) {
+ Intent intent = new Intent(ACTION_STUDY_CATALOG);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ public static void startVerification(Context context) {
+ Intent intent = new Intent(ACTION_VERIFY_CONTENT);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ public static void startDownloads(Context context) {
+ Intent intent = new Intent(ACTION_DOWNLOAD_CONTENT);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ public static void startSync(Context context) {
+ Intent intent = new Intent(ACTION_SYNCHRONIZE_CATALOG);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ public static void startCleanup(Context context) {
+ Intent intent = new Intent(ACTION_CLEANUP_FILES);
+ intent.setComponent(new ComponentName(context, DownloadContentService.class));
+ context.startService(intent);
+ }
+
+ private DownloadContentCatalog catalog;
+
+ public DownloadContentService() {
+ super(LOGTAG);
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ catalog = new DownloadContentCatalog(this);
+ }
+
+ protected void onHandleIntent(Intent intent) {
+ if (!AppConstants.MOZ_ANDROID_DOWNLOAD_CONTENT_SERVICE) {
+ Log.w(LOGTAG, "Download content is not enabled. Stop.");
+ return;
+ }
+
+ if (!HardwareUtils.isSupportedSystem()) {
+ // This service is running very early before checks in BrowserApp can prevent us from running.
+ Log.w(LOGTAG, "System is not supported. Stop.");
+ return;
+ }
+
+ if (intent == null) {
+ return;
+ }
+
+ final BaseAction action;
+
+ switch (intent.getAction()) {
+ case ACTION_STUDY_CATALOG:
+ action = new StudyAction();
+ break;
+
+ case ACTION_DOWNLOAD_CONTENT:
+ action = new DownloadAction(new DownloadAction.Callback() {
+ @Override
+ public void onContentDownloaded(DownloadContent content) {
+ if (content.isFont()) {
+ GeckoAppShell.notifyObservers("Fonts:Reload", "");
+ }
+ }
+ });
+ break;
+
+ case ACTION_VERIFY_CONTENT:
+ action = new VerifyAction();
+ break;
+
+ case ACTION_SYNCHRONIZE_CATALOG:
+ action = new SyncAction();
+ break;
+
+ default:
+ Log.e(LOGTAG, "Unknown action: " + intent.getAction());
+ return;
+ }
+
+ action.perform(this, catalog);
+ catalog.persistChanges();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
new file mode 100644
index 000000000..e15a17bbe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/StudyAction.java
@@ -0,0 +1,81 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.os.Build;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.util.ContextUtils;
+
+/**
+ * Study: Scan the catalog for "new" content available for download.
+ */
+public class StudyAction extends BaseAction {
+ private static final String LOGTAG = "DLCStudyAction";
+
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ Log.d(LOGTAG, "Studying catalog..");
+
+ for (DownloadContent content : catalog.getContentToStudy()) {
+ if (!isMatching(context, content)) {
+ // This content is not for this particular version of the application or system
+ continue;
+ }
+
+ if (content.isAssetArchive() && content.isFont()) {
+ catalog.scheduleDownload(content);
+
+ Log.d(LOGTAG, "Scheduled download: " + content);
+ }
+ }
+
+ if (catalog.hasScheduledDownloads()) {
+ startDownloads(context);
+ }
+
+ Log.v(LOGTAG, "Done");
+ }
+
+ protected boolean isMatching(Context context, DownloadContent content) {
+ final String androidApiPattern = content.getAndroidApiPattern();
+ if (!TextUtils.isEmpty(androidApiPattern)) {
+ final String apiVersion = String.valueOf(Build.VERSION.SDK_INT);
+ if (apiVersion.matches(androidApiPattern)) {
+ Log.d(LOGTAG, String.format("Android API (%s) does not match pattern: %s", apiVersion, androidApiPattern));
+ return false;
+ }
+ }
+
+ final String appIdPattern = content.getAppIdPattern();
+ if (!TextUtils.isEmpty(appIdPattern)) {
+ final String appId = context.getPackageName();
+ if (!appId.matches(appIdPattern)) {
+ Log.d(LOGTAG, String.format("App ID (%s) does not match pattern: %s", appId, appIdPattern));
+ return false;
+ }
+ }
+
+ final String appVersionPattern = content.getAppVersionPattern();
+ if (!TextUtils.isEmpty(appVersionPattern)) {
+ final String appVersion = ContextUtils.getCurrentPackageInfo(context).versionName;
+ if (!appVersion.matches(appVersionPattern)) {
+ Log.d(LOGTAG, String.format("App version (%s) does not match pattern: %s", appVersion, appVersionPattern));
+ return false;
+ }
+ }
+
+ // There are no patterns or all patterns have matched.
+ return true;
+ }
+
+ protected void startDownloads(Context context) {
+ DownloadContentService.startDownloads(context);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
new file mode 100644
index 000000000..104bdad18
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/SyncAction.java
@@ -0,0 +1,263 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+
+import com.keepsafe.switchboard.SwitchBoard;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+
+/**
+ * Sync: Synchronize catalog from a Kinto instance.
+ */
+public class SyncAction extends BaseAction {
+ private static final String LOGTAG = "DLCSyncAction";
+
+ private static final String KINTO_KEY_ID = "id";
+ private static final String KINTO_KEY_DELETED = "deleted";
+ private static final String KINTO_KEY_DATA = "data";
+ private static final String KINTO_KEY_ATTACHMENT = "attachment";
+ private static final String KINTO_KEY_ORIGINAL = "original";
+
+ private static final String KINTO_PARAMETER_SINCE = "_since";
+ private static final String KINTO_PARAMETER_FIELDS = "_fields";
+ private static final String KINTO_PARAMETER_SORT = "_sort";
+
+ /**
+ * Kinto endpoint with online version of downloadable content catalog
+ *
+ * Dev instance:
+ * https://kinto-ota.dev.mozaws.net/v1/buckets/dlc/collections/catalog/records
+ */
+ private static final String CATALOG_ENDPOINT = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/catalog/records";
+
+ @Override
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ Log.d(LOGTAG, "Synchronizing catalog.");
+
+ if (!isSyncEnabledForClient(context)) {
+ Log.d(LOGTAG, "Sync is not enabled for client. Skipping.");
+ return;
+ }
+
+ boolean cleanupRequired = false;
+ boolean studyRequired = false;
+
+ try {
+ long lastModified = catalog.getLastModified();
+
+ // TODO: Consider using ETag here (Bug 1257459)
+ JSONArray rawCatalog = fetchRawCatalog(lastModified);
+
+ Log.d(LOGTAG, "Server returned " + rawCatalog.length() + " records (since " + lastModified + ")");
+
+ for (int i = 0; i < rawCatalog.length(); i++) {
+ JSONObject object = rawCatalog.getJSONObject(i);
+ String id = object.getString(KINTO_KEY_ID);
+
+ final boolean isDeleted = object.optBoolean(KINTO_KEY_DELETED, false);
+
+ if (!isDeleted) {
+ JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
+ if (attachment.isNull(KINTO_KEY_ORIGINAL))
+ throw new JSONException(String.format("Old Attachment Format"));
+ }
+
+ DownloadContent existingContent = catalog.getContentById(id);
+
+ if (isDeleted) {
+ cleanupRequired |= deleteContent(catalog, id);
+ } else if (existingContent != null) {
+ studyRequired |= updateContent(catalog, object, existingContent);
+ } else {
+ studyRequired |= createContent(catalog, object);
+ }
+ }
+ } catch (UnrecoverableDownloadContentException e) {
+ Log.e(LOGTAG, "UnrecoverableDownloadContentException", e);
+ } catch (RecoverableDownloadContentException e) {
+ Log.e(LOGTAG, "RecoverableDownloadContentException");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSONException", e);
+ }
+
+ if (studyRequired) {
+ startStudyAction(context);
+ }
+
+ if (cleanupRequired) {
+ startCleanupAction(context);
+ }
+
+ Log.v(LOGTAG, "Done");
+ }
+
+ protected void startStudyAction(Context context) {
+ DownloadContentService.startStudy(context);
+ }
+
+ protected void startCleanupAction(Context context) {
+ DownloadContentService.startCleanup(context);
+ }
+
+ protected JSONArray fetchRawCatalog(long lastModified)
+ throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
+ HttpURLConnection connection = null;
+
+ try {
+ Uri.Builder builder = Uri.parse(CATALOG_ENDPOINT).buildUpon();
+
+ if (lastModified > 0) {
+ builder.appendQueryParameter(KINTO_PARAMETER_SINCE, String.valueOf(lastModified));
+ }
+ // Only select the fields we are actually going to read.
+ builder.appendQueryParameter(KINTO_PARAMETER_FIELDS,
+ "attachment.location,attachment.original.filename,attachment.original.hash,attachment.hash,type,kind,attachment.original.size,match");
+
+ // We want to process items in the order they have been modified. This is to ensure that
+ // our last_modified values are correct if we processing is interrupted and not all items
+ // have been processed.
+ builder.appendQueryParameter(KINTO_PARAMETER_SORT, "last_modified");
+
+ connection = buildHttpURLConnection(builder.build().toString());
+
+ // TODO: Read 'Alert' header and EOL message if existing (Bug 1249248)
+
+ // TODO: Read and use 'Backoff' header if available (Bug 1249251)
+
+ // TODO: Add support for Next-Page header (Bug 1257495)
+
+ final int responseCode = connection.getResponseCode();
+
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ if (responseCode >= 500) {
+ // A Retry-After header will be added to error responses (>=500), telling the
+ // client how many seconds it should wait before trying again.
+
+ // TODO: Read and obey value in "Retry-After" header (Bug 1249249)
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Server error (" + responseCode + ")");
+ } else if (responseCode == 410) {
+ // A 410 Gone error response can be returned if the client version is too old,
+ // or the service had been replaced with a new and better service using a new
+ // protocol version.
+
+ // TODO: The server is gone. Stop synchronizing the catalog from this server (Bug 1249248).
+ throw new UnrecoverableDownloadContentException("Server is gone (410)");
+ } else if (responseCode >= 400) {
+ // If the HTTP status is >=400 the response contains a JSON response.
+ logErrorResponse(connection);
+
+ // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Catalog sync failed. Status code: " + responseCode);
+ } else if (responseCode < 200) {
+ // If the HTTP status is <200 the response contains a JSON response.
+ logErrorResponse(connection);
+
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Response code: " + responseCode);
+ } else {
+ // HttpsUrlConnection: -1 (No valid response code)
+ // Successful 2xx: We don't know how to handle anything but 200.
+ // Redirection 3xx: We should have followed redirects if possible. We should not see those errors here.
+
+ throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Response code: " + responseCode);
+ }
+ }
+
+ return fetchJSONResponse(connection).getJSONArray(KINTO_KEY_DATA);
+ } catch (JSONException | IOException e) {
+ throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private JSONObject fetchJSONResponse(HttpURLConnection connection) throws IOException, JSONException {
+ InputStream inputStream = null;
+
+ try {
+ inputStream = new BufferedInputStream(connection.getInputStream());
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ IOUtils.copy(inputStream, outputStream);
+ return new JSONObject(outputStream.toString("UTF-8"));
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ }
+ }
+
+ protected boolean updateContent(DownloadContentCatalog catalog, JSONObject object, DownloadContent existingContent)
+ throws JSONException {
+ DownloadContent content = existingContent.buildUpon()
+ .updateFromKinto(object)
+ .build();
+
+ if (existingContent.getLastModified() >= content.getLastModified()) {
+ Log.d(LOGTAG, "Item has not changed: " + content);
+ return false;
+ }
+
+ catalog.update(content);
+
+ return true;
+ }
+
+ protected boolean createContent(DownloadContentCatalog catalog, JSONObject object) throws JSONException {
+ DownloadContent content = new DownloadContentBuilder()
+ .updateFromKinto(object)
+ .build();
+
+ catalog.add(content);
+
+ return true;
+ }
+
+ protected boolean deleteContent(DownloadContentCatalog catalog, String id) {
+ DownloadContent content = catalog.getContentById(id);
+ if (content == null) {
+ return false;
+ }
+
+ catalog.markAsDeleted(content);
+
+ return true;
+ }
+
+ protected boolean isSyncEnabledForClient(Context context) {
+ // Sync action is behind a switchboard flag for staged rollout.
+ return SwitchBoard.isInExperiment(context, Experiments.DOWNLOAD_CONTENT_CATALOG_SYNC);
+ }
+
+ private void logErrorResponse(HttpURLConnection connection) {
+ try {
+ JSONObject error = fetchJSONResponse(connection);
+
+ Log.w(LOGTAG, "Server returned error response:");
+ Log.w(LOGTAG, "- Code: " + error.getInt("code"));
+ Log.w(LOGTAG, "- Errno: " + error.getInt("errno"));
+ Log.w(LOGTAG, "- Error: " + error.optString("error", "-"));
+ Log.w(LOGTAG, "- Message: " + error.optString("message", "-"));
+ Log.w(LOGTAG, "- Info: " + error.optString("info", "-"));
+ } catch (JSONException | IOException e) {
+ Log.w(LOGTAG, "Could not fetch error response", e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java
new file mode 100644
index 000000000..e96a62eae
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/VerifyAction.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.mozilla.gecko.dlc.catalog.DownloadContent;
+import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
+
+import java.io.File;
+
+/**
+ * Verify: Validate downloaded content. Does it still exist and does it have the correct checksum?
+ */
+public class VerifyAction extends BaseAction {
+ private static final String LOGTAG = "DLCVerifyAction";
+
+ @Override
+ public void perform(Context context, DownloadContentCatalog catalog) {
+ Log.d(LOGTAG, "Verifying catalog..");
+
+ for (DownloadContent content : catalog.getDownloadedContent()) {
+ try {
+ File destinationFile = getDestinationFile(context, content);
+
+ if (!destinationFile.exists()) {
+ Log.d(LOGTAG, "Downloaded content does not exist anymore: " + content);
+
+ // This file does not exist even though it is marked as downloaded in the catalog. Scheduling a
+ // download to fetch it again.
+ catalog.scheduleDownload(content);
+ continue;
+ }
+
+ if (!verify(destinationFile, content.getChecksum())) {
+ catalog.scheduleDownload(content);
+ Log.d(LOGTAG, "Wrong checksum. Scheduling download: " + content);
+ continue;
+ }
+
+ Log.v(LOGTAG, "Content okay: " + content);
+ } catch (UnrecoverableDownloadContentException e) {
+ Log.w(LOGTAG, "Unrecoverable exception while verifying downloaded file", e);
+ } catch (RecoverableDownloadContentException e) {
+ // That's okay, we are just verifying already existing content. No log.
+ }
+ }
+
+ if (catalog.hasScheduledDownloads()) {
+ startDownloads(context);
+ }
+
+ Log.v(LOGTAG, "Done");
+ }
+
+ protected void startDownloads(Context context) {
+ DownloadContentService.startDownloads(context);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
new file mode 100644
index 000000000..61f7992ca
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContent.java
@@ -0,0 +1,189 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc.catalog;
+
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringDef;
+
+public class DownloadContent {
+ @IntDef({STATE_NONE, STATE_SCHEDULED, STATE_DOWNLOADED, STATE_FAILED, STATE_UPDATED, STATE_DELETED})
+ public @interface State {}
+ public static final int STATE_NONE = 0;
+ public static final int STATE_SCHEDULED = 1;
+ public static final int STATE_DOWNLOADED = 2;
+ public static final int STATE_FAILED = 3; // Permanently failed for this version of the content
+ public static final int STATE_UPDATED = 4;
+ public static final int STATE_DELETED = 5;
+
+ @StringDef({TYPE_ASSET_ARCHIVE})
+ public @interface Type {}
+ public static final String TYPE_ASSET_ARCHIVE = "asset-archive";
+
+ @StringDef({KIND_FONT, KIND_HYPHENATION_DICTIONARY})
+ public @interface Kind {}
+ public static final String KIND_FONT = "font";
+ public static final String KIND_HYPHENATION_DICTIONARY = "hyphenation";
+
+ private final String id;
+ private final String location;
+ private final String filename;
+ private final String checksum;
+ private final String downloadChecksum;
+ private final long lastModified;
+ private final String type;
+ private final String kind;
+ private final long size;
+ private final String appVersionPattern;
+ private final String androidApiPattern;
+ private final String appIdPattern;
+ private int state;
+ private int failures;
+ private int lastFailureType;
+
+ /* package-private */ DownloadContent(@NonNull String id, @NonNull String location, @NonNull String filename,
+ @NonNull String checksum, @NonNull String downloadChecksum, @NonNull long lastModified,
+ @NonNull String type, @NonNull String kind, long size, int failures, int lastFailureType,
+ @Nullable String appVersionPattern, @Nullable String androidApiPattern, @Nullable String appIdPattern) {
+ this.id = id;
+ this.location = location;
+ this.filename = filename;
+ this.checksum = checksum;
+ this.downloadChecksum = downloadChecksum;
+ this.lastModified = lastModified;
+ this.type = type;
+ this.kind = kind;
+ this.size = size;
+ this.state = STATE_NONE;
+ this.failures = failures;
+ this.lastFailureType = lastFailureType;
+ this.appVersionPattern = appVersionPattern;
+ this.androidApiPattern = androidApiPattern;
+ this.appIdPattern = appIdPattern;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ /* package-private */ void setState(@State int state) {
+ this.state = state;
+ }
+
+ @State
+ public int getState() {
+ return state;
+ }
+
+ public boolean isStateIn(@State int... states) {
+ for (int state : states) {
+ if (this.state == state) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Kind
+ public String getKind() {
+ return kind;
+ }
+
+ @Type
+ public String getType() {
+ return type;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public long getLastModified() {
+ return lastModified;
+ }
+
+ public String getFilename() {
+ return filename;
+ }
+
+ public String getChecksum() {
+ return checksum;
+ }
+
+ public String getDownloadChecksum() {
+ return downloadChecksum;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public boolean isFont() {
+ return KIND_FONT.equals(kind);
+ }
+
+ public boolean isHyphenationDictionary() {
+ return KIND_HYPHENATION_DICTIONARY.equals(kind);
+ }
+
+ /**
+ *Checks whether the content to be downloaded is a known content.
+ *Currently it checks whether the type is "Asset Archive" and is of kind
+ *"Font" or "Hyphenation Dictionary".
+ */
+ public boolean isKnownContent() {
+ return ((isFont() || isHyphenationDictionary()) && isAssetArchive());
+ }
+
+ public boolean isAssetArchive() {
+ return TYPE_ASSET_ARCHIVE.equals(type);
+ }
+
+ /* package-private */ int getFailures() {
+ return failures;
+ }
+
+ /* package-private */ int getLastFailureType() {
+ return lastFailureType;
+ }
+
+ /* package-private */ void rememberFailure(int failureType) {
+ if (lastFailureType != failureType) {
+ lastFailureType = failureType;
+ failures = 1;
+ } else {
+ failures++;
+ }
+ }
+
+ /* package-private */ void resetFailures() {
+ failures = 0;
+ lastFailureType = 0;
+ }
+
+ public String getAppVersionPattern() {
+ return appVersionPattern;
+ }
+
+ public String getAndroidApiPattern() {
+ return androidApiPattern;
+ }
+
+ public String getAppIdPattern() {
+ return appIdPattern;
+ }
+
+ public DownloadContentBuilder buildUpon() {
+ return DownloadContentBuilder.buildUpon(this);
+ }
+
+
+ public String toString() {
+ return String.format("[%s,%s] %s (%d bytes) %s", getType(), getKind(), getId(), getSize(), getChecksum());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
new file mode 100644
index 000000000..40c804573
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBootstrap.java
@@ -0,0 +1,161 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc.catalog;
+
+import android.support.v4.util.ArrayMap;
+
+import org.mozilla.gecko.AppConstants;
+
+import java.util.Arrays;
+import java.util.List;
+
+/* package-private */ class DownloadContentBootstrap {
+ public static ArrayMap<String, DownloadContent> createInitialDownloadContentList() {
+ if (!AppConstants.MOZ_ANDROID_EXCLUDE_FONTS) {
+ // We are packaging fonts. There's nothing we want to download;
+ return new ArrayMap<>();
+ }
+
+ List<DownloadContent> initialList = Arrays.asList(
+ new DownloadContentBuilder()
+ .setId("c40929cf-7f4c-fa72-3dc9-12cadf56905d")
+ .setLocation("fennec/catalog/f63e5f92-793c-4574-a2d7-fbc50242b8cb.gz")
+ .setFilename("CharisSILCompact-B.ttf")
+ .setChecksum("699d958b492eda0cc2823535f8567d0393090e3842f6df3c36dbe7239cb80b6d")
+ .setDownloadChecksum("a9f9b34fed353169a88cc159b8f298cb285cce0b8b0f979c22a7d85de46f0532")
+ .setSize(1676072)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("6d265876-85ed-0917-fdc8-baf583ca2cba")
+ .setLocation("fennec/catalog/19af6c88-09d9-4d6c-805e-cfebb8699a6c.gz")
+ .setFilename("CharisSILCompact-BI.ttf")
+ .setChecksum("82465e747b4f41471dbfd942842b2ee810749217d44b55dbc43623b89f9c7d9b")
+ .setDownloadChecksum("2be26671039a5e2e4d0360a948b4fa42048171133076a3bb6173d93d4b9cd55b")
+ .setSize(1667812)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("8460dc6d-d129-fd1a-24b6-343dbf6531dd")
+ .setLocation("fennec/catalog/f35a384a-90ea-41c6-a957-bb1845de97eb.gz")
+ .setFilename("CharisSILCompact-I.ttf")
+ .setChecksum("ab3ed6f2a4d4c2095b78227bd33155d7ccd05a879c107a291912640d4d64f767")
+ .setDownloadChecksum("38a6469041c02624d43dfd41d2dd745e3e3211655e616188f65789a90952a1e9")
+ .setSize(1693988)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("c906275c-3747-fe27-426f-6187526a6f06")
+ .setLocation("fennec/catalog/8c3bec92-d2df-4789-8c4a-0f523f026d96.gz")
+ .setFilename("CharisSILCompact-R.ttf")
+ .setChecksum("4ed509317f1bb441b185ea13bf1c9d19d1a0b396962efa3b5dc3190ad88f2067")
+ .setDownloadChecksum("7c2ec1f550c2005b75383b878f737266b5f0b1c82679dd886c8bbe30c82e340e")
+ .setSize(1727656)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("ff5deecc-6ecc-d816-bb51-65face460119")
+ .setLocation("fennec/catalog/ea115d71-e2ac-4609-853e-c978780776b1.gz")
+ .setFilename("ClearSans-Bold.ttf")
+ .setChecksum("385d0a293c1714770e198f7c762ab32f7905a0ed9d2993f69d640bd7232b4b70")
+ .setDownloadChecksum("0d3c22bef90e7096f75b331bb7391de3aa43017e10d61041cd3085816db4919a")
+ .setSize(140136)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("a173d1db-373b-ce42-1335-6b3285cfdebd")
+ .setLocation("fennec/catalog/0838e513-2d99-4e53-b58f-6b970f6548c6.gz")
+ .setFilename("ClearSans-BoldItalic.ttf")
+ .setChecksum("7bce66864e38eecd7c94b6657b9b38c35ebfacf8046bfb1247e08f07fe933198")
+ .setDownloadChecksum("de0903164dde1ad3768d0bd6dec949871d6ab7be08f573d9d70f38c138a22e37")
+ .setSize(156124)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("e65c66df-0088-940d-ca5c-207c22118c0e")
+ .setLocation("fennec/catalog/7550fa42-0947-478c-a5f0-5ea1bbb6ba27.gz")
+ .setFilename("ClearSans-Italic.ttf")
+ .setChecksum("87c13c5fbae832e4f85c3bd46fcbc175978234f39be5fe51c4937be4cbff3b68")
+ .setDownloadChecksum("6e323db3115005dd0e96d2422db87a520f9ae426de28a342cd6cc87b55601d87")
+ .setSize(155672)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("25610abb-5dc8-fd75-40e7-990507f010c4")
+ .setLocation("fennec/catalog/dd9bee7d-d784-476b-a3dd-69af8e516487.gz")
+ .setFilename("ClearSans-Light.ttf")
+ .setChecksum("e4885f6188e7a8587f5621c077c6c1f5e8d3739dffc8f4d055c2ba87568c750a")
+ .setDownloadChecksum("19d4f7c67176e9e254c61420da9c7363d9fe5e6b4bb9d61afa4b3b574280714f")
+ .setSize(145976)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("ffe40339-a096-2262-c3f8-54af75c81fe6")
+ .setLocation("fennec/catalog/bc5ada8c-8cfc-443d-93d7-dc5f98138a07.gz")
+ .setFilename("ClearSans-Medium.ttf")
+ .setChecksum("5d0e0115f3a3ed4be3eda6d7eabb899bb9a361292802e763d53c72e00f629da1")
+ .setDownloadChecksum("edec86dab3ad2a97561cb41b584670262a48bed008c57bb587ee05ca47fb067f")
+ .setSize(148892)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("139a94be-ac69-0264-c9cc-8f2d071fd29d")
+ .setLocation("fennec/catalog/0490c768-6178-49c2-af88-9f8769ff3167.gz")
+ .setFilename("ClearSans-MediumItalic.ttf")
+ .setChecksum("937dda88b26469306258527d38e42c95e27e7ebb9f05bd1d7c5d706a3c9541d7")
+ .setDownloadChecksum("34edbd1b325dbffe7791fba8dd2d19852eb3c2fe00cff517ea2161ddc424ee22")
+ .setSize(155228)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("b887012a-01e1-7c94-fdcb-ca44d5b974a2")
+ .setLocation("fennec/catalog/78205bf8-c668-41b1-b68f-afd54f98713b.gz")
+ .setFilename("ClearSans-Regular.ttf")
+ .setChecksum("9b91bbdb95ffa6663da24fdaa8ee06060cd0a4d2dceaf1ffbdda00e04915ee5b")
+ .setDownloadChecksum("a72f1420b4da1ba9e6797adac34f08e72f94128a85e56542d5e6a8080af5f08a")
+ .setSize(142572)
+ .setKind("font")
+ .setType("asset-archive")
+ .build(),
+
+ new DownloadContentBuilder()
+ .setId("c8703652-d317-0356-0bf8-95441a5b2c9b")
+ .setLocation("fennec/catalog/3570f44f-9440-4aa0-abd0-642eaf2a1aa0.gz")
+ .setFilename("ClearSans-Thin.ttf")
+ .setChecksum("07b0db85a3ad99afeb803f0f35631436a7b4c67ac66d0c7f77d26a47357c592a")
+ .setDownloadChecksum("d9f23fd8687d6743f5c281c33539fb16f163304795039959b8caf159e6d62822")
+ .setSize(147004)
+ .setKind("font")
+ .setType("asset-archive")
+ .build());
+
+ ArrayMap<String, DownloadContent> content = new ArrayMap<>();
+ for (DownloadContent currentContent : initialList) {
+ content.put(currentContent.getId(), currentContent);
+ }
+ return content;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java
new file mode 100644
index 000000000..243e2d4eb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentBuilder.java
@@ -0,0 +1,238 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc.catalog;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class DownloadContentBuilder {
+ private static final String LOCAL_KEY_ID = "id";
+ private static final String LOCAL_KEY_LOCATION = "location";
+ private static final String LOCAL_KEY_FILENAME = "filename";
+ private static final String LOCAL_KEY_CHECKSUM = "checksum";
+ private static final String LOCAL_KEY_DOWNLOAD_CHECKSUM = "download_checksum";
+ private static final String LOCAL_KEY_LAST_MODIFIED = "last_modified";
+ private static final String LOCAL_KEY_TYPE = "type";
+ private static final String LOCAL_KEY_KIND = "kind";
+ private static final String LOCAL_KEY_SIZE = "size";
+ private static final String LOCAL_KEY_STATE = "state";
+ private static final String LOCAL_KEY_FAILURES = "failures";
+ private static final String LOCAL_KEY_LAST_FAILURE_TYPE = "last_failure_type";
+ private static final String LOCAL_KEY_PATTERN_APP_ID = "pattern_app_id";
+ private static final String LOCAL_KEY_PATTERN_ANDROID_API = "pattern_android_api";
+ private static final String LOCAL_KEY_PATTERN_APP_VERSION = "pattern_app_version";
+
+ private static final String KINTO_KEY_ID = "id";
+ private static final String KINTO_KEY_ATTACHMENT = "attachment";
+ private static final String KINTO_KEY_ORIGINAL = "original";
+ private static final String KINTO_KEY_LOCATION = "location";
+ private static final String KINTO_KEY_FILENAME = "filename";
+ private static final String KINTO_KEY_HASH = "hash";
+ private static final String KINTO_KEY_LAST_MODIFIED = "last_modified";
+ private static final String KINTO_KEY_TYPE = "type";
+ private static final String KINTO_KEY_KIND = "kind";
+ private static final String KINTO_KEY_SIZE = "size";
+ private static final String KINTO_KEY_MATCH = "match";
+ private static final String KINTO_KEY_APP_ID = "appId";
+ private static final String KINTO_KEY_ANDROID_API = "androidApi";
+ private static final String KINTO_KEY_APP_VERSION = "appVersion";
+
+ private String id;
+ private String location;
+ private String filename;
+ private String checksum;
+ private String downloadChecksum;
+ private long lastModified;
+ private String type;
+ private String kind;
+ private long size;
+ private int state;
+ private int failures;
+ private int lastFailureType;
+ private String appVersionPattern;
+ private String androidApiPattern;
+ private String appIdPattern;
+
+ public static DownloadContentBuilder buildUpon(DownloadContent content) {
+ DownloadContentBuilder builder = new DownloadContentBuilder();
+
+ builder.id = content.getId();
+ builder.location = content.getLocation();
+ builder.filename = content.getFilename();
+ builder.checksum = content.getChecksum();
+ builder.downloadChecksum = content.getDownloadChecksum();
+ builder.lastModified = content.getLastModified();
+ builder.type = content.getType();
+ builder.kind = content.getKind();
+ builder.size = content.getSize();
+ builder.state = content.getState();
+ builder.failures = content.getFailures();
+ builder.lastFailureType = content.getLastFailureType();
+
+ return builder;
+ }
+
+ public static DownloadContent fromJSON(JSONObject object) throws JSONException {
+ return new DownloadContentBuilder()
+ .setId(object.getString(LOCAL_KEY_ID))
+ .setLocation(object.getString(LOCAL_KEY_LOCATION))
+ .setFilename(object.getString(LOCAL_KEY_FILENAME))
+ .setChecksum(object.getString(LOCAL_KEY_CHECKSUM))
+ .setDownloadChecksum(object.getString(LOCAL_KEY_DOWNLOAD_CHECKSUM))
+ .setLastModified(object.getLong(LOCAL_KEY_LAST_MODIFIED))
+ .setType(object.getString(LOCAL_KEY_TYPE))
+ .setKind(object.getString(LOCAL_KEY_KIND))
+ .setSize(object.getLong(LOCAL_KEY_SIZE))
+ .setState(object.getInt(LOCAL_KEY_STATE))
+ .setFailures(object.optInt(LOCAL_KEY_FAILURES), object.optInt(LOCAL_KEY_LAST_FAILURE_TYPE))
+ .setAppVersionPattern(object.optString(LOCAL_KEY_PATTERN_APP_VERSION))
+ .setAppIdPattern(object.optString(LOCAL_KEY_PATTERN_APP_ID))
+ .setAndroidApiPattern(object.optString(LOCAL_KEY_PATTERN_ANDROID_API))
+ .build();
+ }
+
+ public static JSONObject toJSON(DownloadContent content) throws JSONException {
+ final JSONObject object = new JSONObject();
+ object.put(LOCAL_KEY_ID, content.getId());
+ object.put(LOCAL_KEY_LOCATION, content.getLocation());
+ object.put(LOCAL_KEY_FILENAME, content.getFilename());
+ object.put(LOCAL_KEY_CHECKSUM, content.getChecksum());
+ object.put(LOCAL_KEY_DOWNLOAD_CHECKSUM, content.getDownloadChecksum());
+ object.put(LOCAL_KEY_LAST_MODIFIED, content.getLastModified());
+ object.put(LOCAL_KEY_TYPE, content.getType());
+ object.put(LOCAL_KEY_KIND, content.getKind());
+ object.put(LOCAL_KEY_SIZE, content.getSize());
+ object.put(LOCAL_KEY_STATE, content.getState());
+ object.put(LOCAL_KEY_PATTERN_APP_VERSION, content.getAppVersionPattern());
+ object.put(LOCAL_KEY_PATTERN_APP_ID, content.getAppIdPattern());
+ object.put(LOCAL_KEY_PATTERN_ANDROID_API, content.getAndroidApiPattern());
+
+ final int failures = content.getFailures();
+ if (failures > 0) {
+ object.put(LOCAL_KEY_FAILURES, failures);
+ object.put(LOCAL_KEY_LAST_FAILURE_TYPE, content.getLastFailureType());
+ }
+
+ return object;
+ }
+
+ public DownloadContent build() {
+ DownloadContent content = new DownloadContent(id, location, filename, checksum,
+ downloadChecksum, lastModified, type, kind, size, failures, lastFailureType,
+ appVersionPattern, androidApiPattern, appIdPattern);
+ content.setState(state);
+
+ return content;
+ }
+
+ public DownloadContentBuilder setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public DownloadContentBuilder setLocation(String location) {
+ this.location = location;
+ return this;
+ }
+
+ public DownloadContentBuilder setFilename(String filename) {
+ this.filename = filename;
+ return this;
+ }
+
+ public DownloadContentBuilder setChecksum(String checksum) {
+ this.checksum = checksum;
+ return this;
+ }
+
+ public DownloadContentBuilder setDownloadChecksum(String downloadChecksum) {
+ this.downloadChecksum = downloadChecksum;
+ return this;
+ }
+
+ public DownloadContentBuilder setLastModified(long lastModified) {
+ this.lastModified = lastModified;
+ return this;
+ }
+
+ public DownloadContentBuilder setType(String type) {
+ this.type = type;
+ return this;
+ }
+
+ public DownloadContentBuilder setKind(String kind) {
+ this.kind = kind;
+ return this;
+ }
+
+ public DownloadContentBuilder setSize(long size) {
+ this.size = size;
+ return this;
+ }
+
+ public DownloadContentBuilder setState(int state) {
+ this.state = state;
+ return this;
+ }
+
+ /* package-private */ DownloadContentBuilder setFailures(int failures, int lastFailureType) {
+ this.failures = failures;
+ this.lastFailureType = lastFailureType;
+
+ return this;
+ }
+
+ public DownloadContentBuilder setAppVersionPattern(String appVersionPattern) {
+ this.appVersionPattern = appVersionPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder setAndroidApiPattern(String androidApiPattern) {
+ this.androidApiPattern = androidApiPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder setAppIdPattern(String appIdPattern) {
+ this.appIdPattern = appIdPattern;
+ return this;
+ }
+
+ public DownloadContentBuilder updateFromKinto(JSONObject object) throws JSONException {
+ final String objectId = object.getString(KINTO_KEY_ID);
+
+ if (TextUtils.isEmpty(id)) {
+ // New object without an id yet
+ id = objectId;
+ } else if (!id.equals(objectId)) {
+ throw new JSONException(String.format("Record ids do not match: Expected=%s, Actual=%s", id, objectId));
+ }
+
+ setType(object.getString(KINTO_KEY_TYPE));
+ setKind(object.getString(KINTO_KEY_KIND));
+ setLastModified(object.getLong(KINTO_KEY_LAST_MODIFIED));
+
+ JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
+ JSONObject original = attachment.getJSONObject(KINTO_KEY_ORIGINAL);
+
+ setFilename(original.getString(KINTO_KEY_FILENAME));
+ setChecksum(original.getString(KINTO_KEY_HASH));
+ setSize(original.getLong(KINTO_KEY_SIZE));
+
+ setLocation(attachment.getString(KINTO_KEY_LOCATION));
+ setDownloadChecksum(attachment.getString(KINTO_KEY_HASH));
+
+ JSONObject match = object.optJSONObject(KINTO_KEY_MATCH);
+ if (match != null) {
+ setAndroidApiPattern(match.optString(KINTO_KEY_ANDROID_API));
+ setAppIdPattern(match.optString(KINTO_KEY_APP_ID));
+ setAppVersionPattern(match.optString(KINTO_KEY_APP_VERSION));
+ }
+
+ return this;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
new file mode 100644
index 000000000..43ba4e82e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/dlc/catalog/DownloadContentCatalog.java
@@ -0,0 +1,303 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.dlc.catalog;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.AtomicFile;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Catalog of downloadable content (DLC).
+ *
+ * Changing elements returned by the catalog should be guarded by the catalog instance to guarantee visibility when
+ * persisting changes.
+ */
+public class DownloadContentCatalog {
+ private static final String LOGTAG = "GeckoDLCCatalog";
+ private static final String FILE_NAME = "download_content_catalog";
+
+ private static final String JSON_KEY_CONTENT = "content";
+
+ private static final int MAX_FAILURES_UNTIL_PERMANENTLY_FAILED = 10;
+
+ private final AtomicFile file; // Guarded by 'file'
+
+ private ArrayMap<String, DownloadContent> content; // Guarded by 'this'
+ private boolean hasLoadedCatalog; // Guarded by 'this
+ private boolean hasCatalogChanged; // Guarded by 'this'
+
+ public DownloadContentCatalog(Context context) {
+ this(new AtomicFile(new File(context.getApplicationInfo().dataDir, FILE_NAME)));
+
+ startLoadFromDisk();
+ }
+
+ // For injecting mocked AtomicFile objects during test
+ protected DownloadContentCatalog(AtomicFile file) {
+ this.content = new ArrayMap<>();
+ this.file = file;
+ }
+
+ public List<DownloadContent> getContentToStudy() {
+ return filterByState(DownloadContent.STATE_NONE, DownloadContent.STATE_UPDATED);
+ }
+
+ public List<DownloadContent> getContentToDelete() {
+ return filterByState(DownloadContent.STATE_DELETED);
+ }
+
+ public List<DownloadContent> getDownloadedContent() {
+ return filterByState(DownloadContent.STATE_DOWNLOADED);
+ }
+
+ public List<DownloadContent> getScheduledDownloads() {
+ return filterByState(DownloadContent.STATE_SCHEDULED);
+ }
+
+ private synchronized List<DownloadContent> filterByState(@DownloadContent.State int... filterStates) {
+ awaitLoadingCatalogLocked();
+
+ List<DownloadContent> filteredContent = new ArrayList<>();
+
+ for (DownloadContent currentContent : content.values()) {
+ if (currentContent.isStateIn(filterStates)) {
+ filteredContent.add(currentContent);
+ }
+ }
+
+ return filteredContent;
+ }
+
+ public boolean hasScheduledDownloads() {
+ return !filterByState(DownloadContent.STATE_SCHEDULED).isEmpty();
+ }
+
+ public synchronized void add(DownloadContent newContent) {
+ awaitLoadingCatalogLocked();
+
+ content.put(newContent.getId(), newContent);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void update(DownloadContent changedContent) {
+ awaitLoadingCatalogLocked();
+
+ if (!content.containsKey(changedContent.getId())) {
+ Log.w(LOGTAG, "Did not find content with matching id (" + changedContent.getId() + ") to update");
+ return;
+ }
+
+ changedContent.setState(DownloadContent.STATE_UPDATED);
+ changedContent.resetFailures();
+
+ content.put(changedContent.getId(), changedContent);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void remove(DownloadContent removedContent) {
+ awaitLoadingCatalogLocked();
+
+ if (!content.containsKey(removedContent.getId())) {
+ Log.w(LOGTAG, "Did not find content with matching id (" + removedContent.getId() + ") to remove");
+ return;
+ }
+
+ content.remove(removedContent.getId());
+ }
+
+ @Nullable
+ public synchronized DownloadContent getContentById(String id) {
+ return content.get(id);
+ }
+
+ public synchronized long getLastModified() {
+ awaitLoadingCatalogLocked();
+
+ long lastModified = 0;
+
+ for (DownloadContent currentContent : content.values()) {
+ if (currentContent.getLastModified() > lastModified) {
+ lastModified = currentContent.getLastModified();
+ }
+ }
+
+ return lastModified;
+ }
+
+ public synchronized void scheduleDownload(DownloadContent content) {
+ content.setState(DownloadContent.STATE_SCHEDULED);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void markAsDownloaded(DownloadContent content) {
+ content.setState(DownloadContent.STATE_DOWNLOADED);
+ content.resetFailures();
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void markAsPermanentlyFailed(DownloadContent content) {
+ content.setState(DownloadContent.STATE_FAILED);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void markAsDeleted(DownloadContent content) {
+ content.setState(DownloadContent.STATE_DELETED);
+ hasCatalogChanged = true;
+ }
+
+ public synchronized void rememberFailure(DownloadContent content, int failureType) {
+ if (content.getFailures() >= MAX_FAILURES_UNTIL_PERMANENTLY_FAILED) {
+ Log.d(LOGTAG, "Maximum number of failures reached. Marking content has permanently failed.");
+
+ markAsPermanentlyFailed(content);
+ } else {
+ content.rememberFailure(failureType);
+ hasCatalogChanged = true;
+ }
+ }
+
+ public void persistChanges() {
+ new Thread(LOGTAG + "-Persist") {
+ public void run() {
+ writeToDisk();
+ }
+ }.start();
+ }
+
+ private void startLoadFromDisk() {
+ new Thread(LOGTAG + "-Load") {
+ public void run() {
+ loadFromDisk();
+ }
+ }.start();
+ }
+
+ private void awaitLoadingCatalogLocked() {
+ while (!hasLoadedCatalog) {
+ try {
+ Log.v(LOGTAG, "Waiting for catalog to be loaded");
+
+ wait();
+ } catch (InterruptedException e) {
+ // Ignore
+ }
+ }
+ }
+
+ protected synchronized boolean hasCatalogChanged() {
+ return hasCatalogChanged;
+ }
+
+ protected synchronized void loadFromDisk() {
+ Log.d(LOGTAG, "Loading from disk");
+
+ if (hasLoadedCatalog) {
+ return;
+ }
+
+ ArrayMap<String, DownloadContent> loadedContent = new ArrayMap<>();
+
+ try {
+ JSONObject catalog;
+
+ synchronized (file) {
+ catalog = new JSONObject(new String(file.readFully(), "UTF-8"));
+ }
+
+ JSONArray array = catalog.getJSONArray(JSON_KEY_CONTENT);
+ for (int i = 0; i < array.length(); i++) {
+ DownloadContent currentContent = DownloadContentBuilder.fromJSON(array.getJSONObject(i));
+ loadedContent.put(currentContent.getId(), currentContent);
+ }
+ } catch (FileNotFoundException e) {
+ Log.d(LOGTAG, "Catalog file does not exist: Bootstrapping initial catalog");
+ loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e);
+ // Catalog seems to be broken. Re-create catalog:
+ loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
+ hasCatalogChanged = true; // Indicate that we want to persist the new catalog
+ } catch (NullPointerException e) {
+ // Bad content can produce an NPE in JSON code -- bug 1300139
+ Log.w(LOGTAG, "Unable to parse catalog JSON. Re-creating catalog.", e);
+ // Catalog seems to be broken. Re-create catalog:
+ loadedContent = DownloadContentBootstrap.createInitialDownloadContentList();
+ hasCatalogChanged = true; // Indicate that we want to persist the new catalog
+ } catch (UnsupportedEncodingException e) {
+ AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
+ error.initCause(e);
+ throw error;
+ } catch (IOException e) {
+ Log.d(LOGTAG, "Can't read catalog due to IOException", e);
+ }
+
+ onCatalogLoaded(loadedContent);
+
+ notifyAll();
+
+ Log.d(LOGTAG, "Loaded " + content.size() + " elements");
+ }
+
+ protected void onCatalogLoaded(ArrayMap<String, DownloadContent> content) {
+ this.content = content;
+ this.hasLoadedCatalog = true;
+ }
+
+ protected synchronized void writeToDisk() {
+ if (!hasCatalogChanged) {
+ Log.v(LOGTAG, "Not persisting: Catalog has not changed");
+ return;
+ }
+
+ Log.d(LOGTAG, "Writing to disk");
+
+ FileOutputStream outputStream = null;
+
+ synchronized (file) {
+ try {
+ outputStream = file.startWrite();
+
+ JSONArray array = new JSONArray();
+ for (DownloadContent currentContent : content.values()) {
+ array.put(DownloadContentBuilder.toJSON(currentContent));
+ }
+
+ JSONObject catalog = new JSONObject();
+ catalog.put(JSON_KEY_CONTENT, array);
+
+ outputStream.write(catalog.toString().getBytes("UTF-8"));
+
+ file.finishWrite(outputStream);
+
+ hasCatalogChanged = false;
+ } catch (UnsupportedEncodingException e) {
+ AssertionError error = new AssertionError("Should not happen: This device does not speak UTF-8");
+ error.initCause(e);
+ throw error;
+ } catch (IOException | JSONException e) {
+ Log.e(LOGTAG, "IOException during writing catalog", e);
+
+ if (outputStream != null) {
+ file.failWrite(outputStream);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java b/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java
new file mode 100644
index 000000000..d317a21ee
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/ContentNotificationsDelegate.java
@@ -0,0 +1,89 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v4.app.NotificationManagerCompat;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.delegates.BrowserAppDelegate;
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import java.util.List;
+
+/**
+ * BrowserAppDelegate implementation that takes care of handling intents from content notifications.
+ */
+public class ContentNotificationsDelegate extends BrowserAppDelegate {
+ // The application is opened from a content notification
+ public static final String ACTION_CONTENT_NOTIFICATION = AppConstants.ANDROID_PACKAGE_NAME + ".action.CONTENT_NOTIFICATION";
+
+ public static final String EXTRA_READ_BUTTON = "read_button";
+ public static final String EXTRA_URLS = "urls";
+
+ private static final String TELEMETRY_EXTRA_CONTENT_UPDATE = "content_update";
+ private static final String TELEMETRY_EXTRA_READ_NOW_BUTTON = TELEMETRY_EXTRA_CONTENT_UPDATE + "_read_now";
+
+ @Override
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ // This activity is getting restored: We do not want to handle the URLs in the Intent again. The browser
+ // will take care of restoring the tabs we already created.
+ return;
+ }
+
+
+ final Intent unsafeIntent = browserApp.getIntent();
+
+ // Nothing to do.
+ if (unsafeIntent == null) {
+ return;
+ }
+
+ final SafeIntent intent = new SafeIntent(unsafeIntent);
+
+ if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) {
+ openURLsFromIntent(browserApp, intent);
+ }
+ }
+
+ @Override
+ public void onNewIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) {
+ if (ACTION_CONTENT_NOTIFICATION.equals(intent.getAction())) {
+ openURLsFromIntent(browserApp, intent);
+ }
+ }
+
+ private void openURLsFromIntent(BrowserApp browserApp, @NonNull final SafeIntent intent) {
+ final List<String> urls = intent.getStringArrayListExtra(EXTRA_URLS);
+ if (urls != null) {
+ browserApp.openUrls(urls);
+ }
+
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, TELEMETRY_EXTRA_CONTENT_UPDATE);
+
+ if (intent.getBooleanExtra(EXTRA_READ_BUTTON, false)) {
+ // "READ NOW" button in notification was clicked
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_READ_NOW_BUTTON);
+
+ // Android's "auto cancel" won't remove the notification when an action button is pressed. So we do it ourselves here.
+ NotificationManagerCompat.from(browserApp).cancel(R.id.websiteContentNotification);
+ } else {
+ // Notification was clicked
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.NOTIFICATION, TELEMETRY_EXTRA_CONTENT_UPDATE);
+ }
+
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(browserApp));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java
new file mode 100644
index 000000000..d943b4f81
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedAlarmReceiver.java
@@ -0,0 +1,31 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds;
+
+import android.content.Context;
+import android.content.Intent;
+import android.support.v4.content.WakefulBroadcastReceiver;
+import android.util.Log;
+
+/**
+ * Broadcast receiver that will receive broadcasts from the AlarmManager and start the FeedService
+ * with the given action.
+ */
+public class FeedAlarmReceiver extends WakefulBroadcastReceiver {
+ private static final String LOGTAG = "FeedCheckAction";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+
+ Log.d(LOGTAG, "Received alarm with action: " + action);
+
+ final Intent serviceIntent = new Intent(context, FeedService.class);
+ serviceIntent.setAction(action);
+
+ startWakefulService(context, serviceIntent);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java
new file mode 100644
index 000000000..76c1b7e30
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedFetcher.java
@@ -0,0 +1,110 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.feeds.parser.Feed;
+import org.mozilla.gecko.feeds.parser.SimpleFeedParser;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Helper class for fetching and parsing a feed.
+ */
+public class FeedFetcher {
+ private static final int CONNECT_TIMEOUT = 15000;
+ private static final int READ_TIMEOUT = 15000;
+
+ public static class FeedResponse {
+ public final Feed feed;
+ public final String etag;
+ public final String lastModified;
+
+ public FeedResponse(Feed feed, String etag, String lastModified) {
+ this.feed = feed;
+ this.etag = etag;
+ this.lastModified = lastModified;
+ }
+ }
+
+ /**
+ * Fetch and parse a feed from the given URL. Will return null if fetching or parsing failed.
+ */
+ public static FeedResponse fetchAndParseFeed(String url) {
+ return fetchAndParseFeedIfModified(url, null, null);
+ }
+
+ /**
+ * Fetch and parse a feed from the given URL using the given ETag and "Last modified" value.
+ *
+ * Will return null if fetching or parsing failed. Will also return null if the feed has not
+ * changed (ETag / Last-Modified-Since).
+ *
+ * @param eTag The ETag from the last fetch or null if no ETag is available (will always fetch feed)
+ * @param lastModified The "Last modified" header from the last time the feed has been fetch or
+ * null if no value is available (will always fetch feed)
+ * @return A FeedResponse or null if no feed could be fetched (error or no new version available)
+ */
+ @Nullable
+ public static FeedResponse fetchAndParseFeedIfModified(@NonNull String url, @Nullable String eTag, @Nullable String lastModified) {
+ HttpURLConnection connection = null;
+ InputStream stream = null;
+
+ try {
+ connection = (HttpURLConnection) new URL(url).openConnection();
+ connection.setInstanceFollowRedirects(true);
+ connection.setConnectTimeout(CONNECT_TIMEOUT);
+ connection.setReadTimeout(READ_TIMEOUT);
+
+ if (!TextUtils.isEmpty(eTag)) {
+ connection.setRequestProperty("If-None-Match", eTag);
+ }
+
+ if (!TextUtils.isEmpty(lastModified)) {
+ connection.setRequestProperty("If-Modified-Since", lastModified);
+ }
+
+ final int statusCode = connection.getResponseCode();
+
+ if (statusCode != HttpURLConnection.HTTP_OK) {
+ return null;
+ }
+
+ String responseEtag = connection.getHeaderField("ETag");
+ if (!TextUtils.isEmpty(responseEtag) && responseEtag.startsWith("W/")) {
+ // Weak ETag, get actual ETag value
+ responseEtag = responseEtag.substring(2);
+ }
+
+ final String updatedLastModified = connection.getHeaderField("Last-Modified");
+
+ stream = new BufferedInputStream(connection.getInputStream());
+
+ final SimpleFeedParser parser = new SimpleFeedParser();
+ final Feed feed = parser.parse(stream);
+
+ return new FeedResponse(feed, responseEtag, updatedLastModified);
+ } catch (IOException e) {
+ return null;
+ } catch (SimpleFeedParser.ParserException e) {
+ return null;
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ IOUtils.safeStreamClose(stream);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java
new file mode 100644
index 000000000..374486215
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/FeedService.java
@@ -0,0 +1,168 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.support.annotation.Nullable;
+import android.support.v4.net.ConnectivityManagerCompat;
+import android.util.Log;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.feeds.action.FeedAction;
+import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
+import org.mozilla.gecko.feeds.action.EnrollSubscriptionsAction;
+import org.mozilla.gecko.feeds.action.SetupAlarmsAction;
+import org.mozilla.gecko.feeds.action.SubscribeToFeedAction;
+import org.mozilla.gecko.feeds.action.WithdrawSubscriptionsAction;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.Experiments;
+
+/**
+ * Background service for subscribing to and checking website feeds to notify the user about updates.
+ */
+public class FeedService extends IntentService {
+ private static final String LOGTAG = "GeckoFeedService";
+
+ public static final String ACTION_SETUP = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SETUP";
+ public static final String ACTION_SUBSCRIBE = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.SUBSCRIBE";
+ public static final String ACTION_CHECK = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.CHECK";
+ public static final String ACTION_ENROLL = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.ENROLL";
+ public static final String ACTION_WITHDRAW = AppConstants.ANDROID_PACKAGE_NAME + ".FEEDS.WITHDRAW";
+
+ public static void setup(Context context) {
+ Intent intent = new Intent(context, FeedService.class);
+ intent.setAction(ACTION_SETUP);
+ context.startService(intent);
+ }
+
+ public static void subscribe(Context context, String feedUrl) {
+ Intent intent = new Intent(context, FeedService.class);
+ intent.setAction(ACTION_SUBSCRIBE);
+ intent.putExtra(SubscribeToFeedAction.EXTRA_FEED_URL, feedUrl);
+ context.startService(intent);
+ }
+
+ public FeedService() {
+ super(LOGTAG);
+ }
+
+ private BrowserDB browserDB;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+
+ browserDB = BrowserDB.from(this);
+ }
+
+ @Override
+ protected void onHandleIntent(Intent intent) {
+ try {
+ if (intent == null) {
+ return;
+ }
+
+ Log.d(LOGTAG, "Service started with action: " + intent.getAction());
+
+ if (!isInExperiment(this)) {
+ Log.d(LOGTAG, "Not in content notifications experiment. Skipping.");
+ return;
+ }
+
+ FeedAction action = createActionForIntent(intent);
+ if (action == null) {
+ Log.d(LOGTAG, "No action to process");
+ return;
+ }
+
+ if (action.requiresPreferenceEnabled() && !isPreferenceEnabled()) {
+ Log.d(LOGTAG, "Preference is disabled. Skipping.");
+ return;
+ }
+
+ if (action.requiresNetwork() && !isConnectedToUnmeteredNetwork()) {
+ // For now just skip if we are not connected or the network is metered. We do not want
+ // to use precious mobile traffic.
+ Log.d(LOGTAG, "Not connected to a network or network is metered. Skipping.");
+ return;
+ }
+
+ action.perform(browserDB, intent);
+ } finally {
+ FeedAlarmReceiver.completeWakefulIntent(intent);
+ }
+
+ Log.d(LOGTAG, "Done.");
+ }
+
+ @Nullable
+ private FeedAction createActionForIntent(Intent intent) {
+ final Context context = getApplicationContext();
+
+ switch (intent.getAction()) {
+ case ACTION_SETUP:
+ return new SetupAlarmsAction(context);
+
+ case ACTION_SUBSCRIBE:
+ return new SubscribeToFeedAction(context);
+
+ case ACTION_CHECK:
+ return new CheckForUpdatesAction(context);
+
+ case ACTION_ENROLL:
+ return new EnrollSubscriptionsAction(context);
+
+ case ACTION_WITHDRAW:
+ return new WithdrawSubscriptionsAction(context);
+
+ default:
+ throw new AssertionError("Unknown action: " + intent.getAction());
+ }
+ }
+
+ private boolean isConnectedToUnmeteredNetwork() {
+ ConnectivityManager manager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = manager.getActiveNetworkInfo();
+ if (networkInfo == null || !networkInfo.isConnected()) {
+ return false;
+ }
+
+ return !ConnectivityManagerCompat.isActiveNetworkMetered(manager);
+ }
+
+ public static boolean isInExperiment(Context context) {
+ return SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS) ||
+ SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM) ||
+ SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM);
+ }
+
+ public static String getEnabledExperiment(Context context) {
+ String experiment = null;
+
+ if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) {
+ experiment = Experiments.CONTENT_NOTIFICATIONS_12HRS;
+ } else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) {
+ experiment = Experiments.CONTENT_NOTIFICATIONS_8AM;
+ } else if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) {
+ experiment = Experiments.CONTENT_NOTIFICATIONS_5PM;
+ }
+
+ return experiment;
+ }
+
+ private boolean isPreferenceEnabled() {
+ return GeckoSharedPrefs.forApp(this).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_CONTENT, true);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java
new file mode 100644
index 000000000..09a2b12b6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/CheckForUpdatesAction.java
@@ -0,0 +1,281 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.support.v4.content.ContextCompat;
+import android.text.format.DateFormat;
+
+import org.json.JSONException;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.ContentNotificationsDelegate;
+import org.mozilla.gecko.feeds.FeedFetcher;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.parser.Feed;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * CheckForUpdatesAction: Check if feeds we subscribed to have new content available.
+ */
+public class CheckForUpdatesAction extends FeedAction {
+ /**
+ * This extra will be added to Intents fired by the notification.
+ */
+ public static final String EXTRA_CONTENT_NOTIFICATION = "content-notification";
+
+ private final Context context;
+
+ public CheckForUpdatesAction(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void perform(BrowserDB browserDB, Intent intent) {
+ final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
+ final ContentResolver resolver = context.getContentResolver();
+ final List<Feed> updatedFeeds = new ArrayList<>();
+
+ log("Checking feeds for updates..");
+
+ Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
+ if (cursor == null) {
+ return;
+ }
+
+ try {
+ while (cursor.moveToNext()) {
+ FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
+
+ FeedFetcher.FeedResponse response = checkFeedForUpdates(subscription);
+ if (response != null) {
+ final Feed feed = response.feed;
+
+ if (!hasBeenVisited(browserDB, feed.getLastItem().getURL())) {
+ // Only notify about this update if the last item hasn't been visited yet.
+ updatedFeeds.add(feed);
+ } else {
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL,
+ TelemetryContract.Method.SERVICE,
+ "content_update");
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ }
+
+ urlAnnotations.updateFeedSubscription(resolver, subscription);
+ }
+ }
+ } catch (JSONException e) {
+ log("Could not deserialize subscription", e);
+ } finally {
+ cursor.close();
+ }
+
+ showNotification(updatedFeeds);
+ }
+
+ private FeedFetcher.FeedResponse checkFeedForUpdates(FeedSubscription subscription) {
+ log("Checking feed: " + subscription.getFeedTitle());
+
+ FeedFetcher.FeedResponse response = fetchFeed(subscription);
+ if (response == null) {
+ return null;
+ }
+
+ if (subscription.hasBeenUpdated(response)) {
+ log("* Feed has changed. New item: " + response.feed.getLastItem().getTitle());
+
+ subscription.update(response);
+
+ return response;
+
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns true if this URL has been visited before.
+ *
+ * We do an exact match. So this can fail if the feed uses a different URL and redirects to
+ * content. But it's better than no checks at all.
+ */
+ private boolean hasBeenVisited(final BrowserDB browserDB, final String url) {
+ final Cursor cursor = browserDB.getHistoryForURL(context.getContentResolver(), url);
+ if (cursor == null) {
+ return false;
+ }
+
+ try {
+ if (cursor.moveToFirst()) {
+ return cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)) > 0;
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return false;
+ }
+
+ private void showNotification(List<Feed> updatedFeeds) {
+ final int feedCount = updatedFeeds.size();
+ if (feedCount == 0) {
+ return;
+ }
+
+ if (feedCount == 1) {
+ showNotificationForSingleUpdate(updatedFeeds.get(0));
+ } else {
+ showNotificationForMultipleUpdates(updatedFeeds);
+ }
+
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, "content_update");
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ }
+
+ private void showNotificationForSingleUpdate(Feed feed) {
+ final String date = DateFormat.getMediumDateFormat(context).format(new Date(feed.getLastItem().getTimestamp()));
+
+ final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle()
+ .bigText(feed.getLastItem().getTitle())
+ .setBigContentTitle(feed.getTitle())
+ .setSummaryText(context.getString(R.string.content_notification_updated_on, date));
+
+ final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feed), PendingIntent.FLAG_UPDATE_CURRENT);
+
+ final Notification notification = new NotificationCompat.Builder(context)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentTitle(feed.getTitle())
+ .setContentText(feed.getLastItem().getTitle())
+ .setStyle(style)
+ .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .addAction(createOpenAction(feed))
+ .addAction(createNotificationSettingsAction())
+ .build();
+
+ NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
+ }
+
+ private void showNotificationForMultipleUpdates(List<Feed> feeds) {
+ final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
+ for (Feed feed : feeds) {
+ inboxStyle.addLine(StringUtils.stripScheme(feed.getLastItem().getURL(), StringUtils.UrlFlags.STRIP_HTTPS));
+ }
+ inboxStyle.setSummaryText(context.getString(R.string.content_notification_summary));
+
+ final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, createOpenIntent(feeds), PendingIntent.FLAG_UPDATE_CURRENT);
+
+ Notification notification = new NotificationCompat.Builder(context)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentTitle(context.getString(R.string.content_notification_title_plural, feeds.size()))
+ .setContentText(context.getString(R.string.content_notification_summary))
+ .setStyle(inboxStyle)
+ .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .addAction(createOpenAction(feeds))
+ .setNumber(feeds.size())
+ .addAction(createNotificationSettingsAction())
+ .build();
+
+ NotificationManagerCompat.from(context).notify(R.id.websiteContentNotification, notification);
+ }
+
+ private Intent createOpenIntent(Feed feed) {
+ final List<Feed> feeds = new ArrayList<>();
+ feeds.add(feed);
+
+ return createOpenIntent(feeds);
+ }
+
+ private Intent createOpenIntent(List<Feed> feeds) {
+ final ArrayList<String> urls = new ArrayList<>();
+ for (Feed feed : feeds) {
+ urls.add(feed.getLastItem().getURL());
+ }
+
+ final Intent intent = new Intent(context, BrowserApp.class);
+ intent.setAction(ContentNotificationsDelegate.ACTION_CONTENT_NOTIFICATION);
+ intent.putStringArrayListExtra(ContentNotificationsDelegate.EXTRA_URLS, urls);
+
+ return intent;
+ }
+
+ private NotificationCompat.Action createOpenAction(Feed feed) {
+ final List<Feed> feeds = new ArrayList<>();
+ feeds.add(feed);
+
+ return createOpenAction(feeds);
+ }
+
+ private NotificationCompat.Action createOpenAction(List<Feed> feeds) {
+ Intent intent = createOpenIntent(feeds);
+ intent.putExtra(ContentNotificationsDelegate.EXTRA_READ_BUTTON, true);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ return new NotificationCompat.Action(
+ R.drawable.open_in_browser,
+ context.getString(R.string.content_notification_action_read_now),
+ pendingIntent);
+ }
+
+ private NotificationCompat.Action createNotificationSettingsAction() {
+ final Intent intent = new Intent(GeckoApp.ACTION_LAUNCH_SETTINGS);
+ intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ intent.putExtra(EXTRA_CONTENT_NOTIFICATION, true);
+
+ GeckoPreferences.setResourceToOpen(intent, "preferences_notifications");
+
+ PendingIntent settingsIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ return new NotificationCompat.Action(
+ R.drawable.settings_notifications,
+ context.getString(R.string.content_notification_action_settings),
+ settingsIntent);
+ }
+
+ private FeedFetcher.FeedResponse fetchFeed(FeedSubscription subscription) {
+ return FeedFetcher.fetchAndParseFeedIfModified(
+ subscription.getFeedUrl(),
+ subscription.getETag(),
+ subscription.getLastModified()
+ );
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return true;
+ }
+
+ @Override
+ public boolean requiresPreferenceEnabled() {
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java
new file mode 100644
index 000000000..b778938fd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/EnrollSubscriptionsAction.java
@@ -0,0 +1,101 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteBlogger;
+import org.mozilla.gecko.feeds.knownsites.KnownSite;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteMedium;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteTumblr;
+import org.mozilla.gecko.feeds.knownsites.KnownSiteWordpress;
+
+/**
+ * EnrollSubscriptionsAction: Search for bookmarks of known sites we can subscribe to.
+ */
+public class EnrollSubscriptionsAction extends FeedAction {
+ private static final String LOGTAG = "FeedEnrollAction";
+
+ private static final KnownSite[] knownSites = {
+ new KnownSiteMedium(),
+ new KnownSiteBlogger(),
+ new KnownSiteWordpress(),
+ new KnownSiteTumblr(),
+ };
+
+ private Context context;
+
+ public EnrollSubscriptionsAction(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void perform(BrowserDB db, Intent intent) {
+ log("Searching for bookmarks to enroll in updates");
+
+ final ContentResolver contentResolver = context.getContentResolver();
+
+ for (KnownSite knownSite : knownSites) {
+ searchFor(db, contentResolver, knownSite);
+ }
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return false;
+ }
+
+ @Override
+ public boolean requiresPreferenceEnabled() {
+ return true;
+ }
+
+ private void searchFor(BrowserDB db, ContentResolver contentResolver, KnownSite knownSite) {
+ final UrlAnnotations urlAnnotations = db.getUrlAnnotations();
+
+ final Cursor cursor = db.getBookmarksForPartialUrl(contentResolver, knownSite.getURLSearchString());
+ if (cursor == null) {
+ log("Nothing found (" + knownSite.getClass().getSimpleName() + ")");
+ return;
+ }
+
+ try {
+ log("Found " + cursor.getCount() + " websites");
+
+ while (cursor.moveToNext()) {
+
+ final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.Bookmarks.URL));
+
+ log(" URL: " + url);
+
+ String feedUrl = knownSite.getFeedFromURL(url);
+ if (TextUtils.isEmpty(feedUrl)) {
+ log("Could not determine feed for URL: " + url);
+ return;
+ }
+
+ if (!urlAnnotations.hasFeedUrlForWebsite(contentResolver, url)) {
+ urlAnnotations.insertFeedUrl(contentResolver, url, feedUrl);
+ }
+
+ if (!urlAnnotations.hasFeedSubscription(contentResolver, feedUrl)) {
+ FeedService.subscribe(context, feedUrl);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java
new file mode 100644
index 000000000..acfaa8b4d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/FeedAction.java
@@ -0,0 +1,58 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.content.Intent;
+import android.util.Log;
+
+import org.mozilla.gecko.db.BrowserDB;
+
+/**
+ * Interface for actions run by FeedService.
+ */
+public abstract class FeedAction {
+ public static final boolean DEBUG_LOG = false;
+
+ /**
+ * Perform this action.
+ *
+ * @param browserDB database instance to perform the action.
+ * @param intent used to start the service.
+ */
+ public abstract void perform(BrowserDB browserDB, Intent intent);
+
+ /**
+ * Does this action require an active network connection?
+ */
+ public abstract boolean requiresNetwork();
+
+ /**
+ * Should this action only run if the preference is enabled?
+ */
+ public abstract boolean requiresPreferenceEnabled();
+
+ /**
+ * This method will swallow all log messages to avoid logging potential personal information.
+ *
+ * For debugging purposes set {@code DEBUG_LOG} to true.
+ */
+ public void log(String message) {
+ if (DEBUG_LOG) {
+ Log.d("Gecko" + getClass().getSimpleName(), message);
+ }
+ }
+
+ /**
+ * This method will swallow all log messages to avoid logging potential personal information.
+ *
+ * For debugging purposes set {@code DEBUG_LOG} to true.
+ */
+ public void log(String message, Throwable throwable) {
+ if (DEBUG_LOG) {
+ Log.d("Gecko" + getClass().getSimpleName(), message, throwable);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java
new file mode 100644
index 000000000..f5bf39997
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SetupAlarmsAction.java
@@ -0,0 +1,146 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.SystemClock;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.feeds.FeedAlarmReceiver;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.Experiments;
+
+import java.text.DateFormat;
+import java.util.Calendar;
+
+/**
+ * SetupAlarmsAction: Set up alarms to run various actions every now and then.
+ */
+public class SetupAlarmsAction extends FeedAction {
+ private static final String LOGTAG = "FeedSetupAction";
+
+ private Context context;
+
+ public SetupAlarmsAction(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void perform(BrowserDB browserDB, Intent intent) {
+ final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+
+ cancelPreviousAlarms(alarmManager);
+ scheduleAlarms(alarmManager);
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return false;
+ }
+
+ @Override
+ public boolean requiresPreferenceEnabled() {
+ return false;
+ }
+
+ private void cancelPreviousAlarms(AlarmManager alarmManager) {
+ final PendingIntent withdrawIntent = getWithdrawPendingIntent();
+ alarmManager.cancel(withdrawIntent);
+
+ final PendingIntent enrollIntent = getEnrollPendingIntent();
+ alarmManager.cancel(enrollIntent);
+
+ final PendingIntent checkIntent = getCheckPendingIntent();
+ alarmManager.cancel(checkIntent);
+
+ log("Cancelled previous alarms");
+ }
+
+ private void scheduleAlarms(AlarmManager alarmManager) {
+ alarmManager.setInexactRepeating(
+ AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_FIFTEEN_MINUTES,
+ AlarmManager.INTERVAL_DAY,
+ getWithdrawPendingIntent());
+
+ alarmManager.setInexactRepeating(
+ AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HALF_HOUR,
+ AlarmManager.INTERVAL_DAY,
+ getEnrollPendingIntent()
+ );
+
+ if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_12HRS)) {
+ scheduleUpdateCheckEvery12Hours(alarmManager);
+ }
+
+ if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_8AM)) {
+ scheduleUpdateAtFullHour(alarmManager, 8);
+ }
+
+ if (SwitchBoard.isInExperiment(context, Experiments.CONTENT_NOTIFICATIONS_5PM)) {
+ scheduleUpdateAtFullHour(alarmManager, 17);
+ }
+
+
+ log("Scheduled alarms");
+ }
+
+ private void scheduleUpdateCheckEvery12Hours(AlarmManager alarmManager) {
+ alarmManager.setInexactRepeating(
+ AlarmManager.ELAPSED_REALTIME,
+ SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_HOUR,
+ AlarmManager.INTERVAL_HALF_DAY,
+ getCheckPendingIntent()
+ );
+ }
+
+ private void scheduleUpdateAtFullHour(AlarmManager alarmManager, int hourOfDay) {
+ final Calendar calendar = Calendar.getInstance();
+
+ if (calendar.get(Calendar.HOUR_OF_DAY) >= hourOfDay) {
+ // This time has already passed today. Try again tomorrow.
+ calendar.add(Calendar.DAY_OF_MONTH, 1);
+ }
+
+ calendar.set(Calendar.HOUR_OF_DAY, hourOfDay);
+ calendar.set(Calendar.MINUTE, 0);
+ calendar.set(Calendar.SECOND, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+
+ alarmManager.setInexactRepeating(
+ AlarmManager.RTC,
+ calendar.getTimeInMillis(),
+ AlarmManager.INTERVAL_DAY,
+ getCheckPendingIntent()
+ );
+
+ log("Scheduled update alarm at " + DateFormat.getDateTimeInstance().format(calendar.getTime()));
+ }
+
+ private PendingIntent getWithdrawPendingIntent() {
+ Intent intent = new Intent(context, FeedAlarmReceiver.class);
+ intent.setAction(FeedService.ACTION_WITHDRAW);
+ return PendingIntent.getBroadcast(context, 0, intent, 0);
+ }
+
+ private PendingIntent getEnrollPendingIntent() {
+ Intent intent = new Intent(context, FeedAlarmReceiver.class);
+ intent.setAction(FeedService.ACTION_ENROLL);
+ return PendingIntent.getBroadcast(context, 0, intent, 0);
+ }
+
+ private PendingIntent getCheckPendingIntent() {
+ Intent intent = new Intent(context, FeedAlarmReceiver.class);
+ intent.setAction(FeedService.ACTION_CHECK);
+ return PendingIntent.getBroadcast(context, 0, intent, 0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java
new file mode 100644
index 000000000..fbfce1af2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/SubscribeToFeedAction.java
@@ -0,0 +1,79 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.FeedFetcher;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+
+/**
+ * SubscribeToFeedAction: Try to fetch a feed and create a subscription if successful.
+ */
+public class SubscribeToFeedAction extends FeedAction {
+ private static final String LOGTAG = "FeedSubscribeAction";
+
+ public static final String EXTRA_FEED_URL = "feed_url";
+
+ private Context context;
+
+ public SubscribeToFeedAction(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void perform(BrowserDB browserDB, Intent intent) {
+ final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
+
+ final Bundle extras = intent.getExtras();
+ final String feedUrl = extras.getString(EXTRA_FEED_URL);
+
+ if (urlAnnotations.hasFeedSubscription(context.getContentResolver(), feedUrl)) {
+ log("Already subscribed to " + feedUrl + ". Skipping.");
+ return;
+ }
+
+ log("Subscribing to feed: " + feedUrl);
+
+ subscribe(urlAnnotations, feedUrl);
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return true;
+ }
+
+ @Override
+ public boolean requiresPreferenceEnabled() {
+ return true;
+ }
+
+ private void subscribe(UrlAnnotations urlAnnotations, String feedUrl) {
+ FeedFetcher.FeedResponse response = FeedFetcher.fetchAndParseFeed(feedUrl);
+ if (response == null) {
+ log(String.format("Could not fetch feed (%s). Not subscribing for now.", feedUrl));
+ return;
+ }
+
+ log("Subscribing to feed: " + response.feed.getTitle());
+ log(" Last item: " + response.feed.getLastItem().getTitle());
+
+ final FeedSubscription subscription = FeedSubscription.create(feedUrl, response);
+
+ urlAnnotations.insertFeedSubscription(context.getContentResolver(), subscription);
+
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "content_update");
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java b/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java
new file mode 100644
index 000000000..6f955c185
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/action/WithdrawSubscriptionsAction.java
@@ -0,0 +1,109 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.action;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+
+import org.json.JSONException;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.subscriptions.FeedSubscription;
+
+/**
+ * WithdrawSubscriptionsAction: Look for feeds to unsubscribe from.
+ */
+public class WithdrawSubscriptionsAction extends FeedAction {
+ private static final String LOGTAG = "FeedWithdrawAction";
+
+ private Context context;
+
+ public WithdrawSubscriptionsAction(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public void perform(BrowserDB browserDB, Intent intent) {
+ log("Searching for subscriptions to remove..");
+
+ final UrlAnnotations urlAnnotations = browserDB.getUrlAnnotations();
+ final ContentResolver resolver = context.getContentResolver();
+
+ removeFeedsOfUnknownUrls(browserDB, urlAnnotations, resolver);
+ removeSubscriptionsOfRemovedFeeds(urlAnnotations, resolver);
+ }
+
+ /**
+ * Search for website URLs with a feed assigned. Remove entry if website URL is not known anymore:
+ * For now this means the website is not bookmarked.
+ */
+ private void removeFeedsOfUnknownUrls(BrowserDB browserDB, UrlAnnotations urlAnnotations, ContentResolver resolver) {
+ Cursor cursor = urlAnnotations.getWebsitesWithFeedUrl(resolver);
+ if (cursor == null) {
+ return;
+ }
+
+ try {
+ while (cursor.moveToNext()) {
+ final String url = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
+
+ if (!browserDB.isBookmark(resolver, url)) {
+ log("Removing feed for unknown URL: " + url);
+
+ urlAnnotations.deleteFeedUrl(resolver, url);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Remove subscriptions of feed URLs that are not assigned to a website URL (anymore).
+ */
+ private void removeSubscriptionsOfRemovedFeeds(UrlAnnotations urlAnnotations, ContentResolver resolver) {
+ Cursor cursor = urlAnnotations.getFeedSubscriptions(resolver);
+ if (cursor == null) {
+ return;
+ }
+
+ try {
+ while (cursor.moveToNext()) {
+ final FeedSubscription subscription = FeedSubscription.fromCursor(cursor);
+
+ if (!urlAnnotations.hasWebsiteForFeedUrl(resolver, subscription.getFeedUrl())) {
+ log("Removing subscription for feed: " + subscription.getFeedUrl());
+
+ urlAnnotations.deleteFeedSubscription(resolver, subscription);
+
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "content_update");
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(context));
+ }
+ }
+ } catch (JSONException e) {
+ log("Could not deserialize subscription", e);
+ } finally {
+ cursor.close();
+ }
+ }
+
+ @Override
+ public boolean requiresNetwork() {
+ return false;
+ }
+
+ @Override
+ public boolean requiresPreferenceEnabled() {
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java
new file mode 100644
index 000000000..febfbb0c7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSite.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.knownsites;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * A site we know and for which we can guess the feed URL from an arbitrary URL.
+ */
+public interface KnownSite {
+ /**
+ * Get a search string to find URLs of this site in our database. This search string is usually
+ * a partial domain / URL.
+ *
+ * For example we could return "medium.com" to find all URLs that contain this string. This could
+ * obviously find URLs that are not actually medium.com sites. This is acceptable as long as
+ * getFeedFromURL() can handle these inputs and either returns a feed for valid URLs or null for
+ * other matches that are not related to this site.
+ */
+ @NonNull String getURLSearchString();
+
+ /**
+ * Get the Feed URL for this URL. For a known site we can "guess" the feed URL from an URL
+ * pointing to any page. The input URL will be a result from the database found with the value
+ * returned by getURLSearchString().
+ *
+ * Example:
+ * - Input: https://medium.com/@antlam/ux-thoughts-for-2016-1fc1d6e515e8
+ * - Output: https://medium.com/feed/@antlam
+ *
+ * @return the url representing a feed, or null if a feed could not be determined.
+ */
+ @Nullable String getFeedFromURL(String url);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java
new file mode 100644
index 000000000..6bb3629bf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteBlogger.java
@@ -0,0 +1,29 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.knownsites;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Blogger.com
+ */
+public class KnownSiteBlogger implements KnownSite {
+ @Override
+ public String getURLSearchString() {
+ return ".blogspot.com";
+ }
+
+ @Override
+ public String getFeedFromURL(String url) {
+ Pattern pattern = Pattern.compile("https?://(www\\.)?(.*?)\\.blogspot\\.com(/.*)?");
+ Matcher matcher = pattern.matcher(url);
+ if (matcher.matches()) {
+ return String.format("https://%s.blogspot.com/feeds/posts/default", matcher.group(2));
+ }
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java
new file mode 100644
index 000000000..a96e83fcd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteMedium.java
@@ -0,0 +1,29 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.knownsites;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Medium.com
+ */
+public class KnownSiteMedium implements KnownSite {
+ @Override
+ public String getURLSearchString() {
+ return "://medium.com/";
+ }
+
+ @Override
+ public String getFeedFromURL(String url) {
+ Pattern pattern = Pattern.compile("https?://medium.com/([^/]+)(/.*)?");
+ Matcher matcher = pattern.matcher(url);
+ if (matcher.matches()) {
+ return String.format("https://medium.com/feed/%s", matcher.group(1));
+ }
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java
new file mode 100644
index 000000000..c9f480013
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteTumblr.java
@@ -0,0 +1,33 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.knownsites;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Tumblr.com
+ */
+public class KnownSiteTumblr implements KnownSite {
+ @Override
+ public String getURLSearchString() {
+ return ".tumblr.com";
+ }
+
+ @Override
+ public String getFeedFromURL(String url) {
+ final Pattern pattern = Pattern.compile("https?://(.*?).tumblr.com(/.*)?");
+ final Matcher matcher = pattern.matcher(url);
+ if (matcher.matches()) {
+ final String username = matcher.group(1);
+ if (username.equals("www")) {
+ return null;
+ }
+ return "http://" + username + ".tumblr.com/rss";
+ }
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java
new file mode 100644
index 000000000..a74b41a74
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/knownsites/KnownSiteWordpress.java
@@ -0,0 +1,26 @@
+package org.mozilla.gecko.feeds.knownsites;
+
+import android.support.annotation.NonNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Wordpress.com
+ */
+public class KnownSiteWordpress implements KnownSite {
+ @Override
+ public String getURLSearchString() {
+ return ".wordpress.com";
+ }
+
+ @Override
+ public String getFeedFromURL(String url) {
+ Pattern pattern = Pattern.compile("https?://(.*?).wordpress.com(/.*)?");
+ Matcher matcher = pattern.matcher(url);
+ if (matcher.matches()) {
+ return "https://" + matcher.group(1) + ".wordpress.com/feed/";
+ }
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java
new file mode 100644
index 000000000..aefc72aa7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Feed.java
@@ -0,0 +1,70 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.parser;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+public class Feed {
+ private String title;
+ private String websiteURL;
+ private String feedURL;
+ private Item lastItem;
+
+ public static Feed create(String title, String websiteURL, String feedURL, Item lastItem) {
+ Feed feed = new Feed();
+
+ feed.setTitle(title);
+ feed.setWebsiteURL(websiteURL);
+ feed.setFeedURL(feedURL);
+ feed.setLastItem(lastItem);
+
+ return feed;
+ }
+
+ /* package-private */ Feed() {}
+
+ /* package-private */ void setTitle(String title) {
+ this.title = title;
+ }
+
+ /* package-private */ void setWebsiteURL(String websiteURL) {
+ this.websiteURL = websiteURL;
+ }
+
+ /* package-private */ void setFeedURL(String feedURL) {
+ this.feedURL = feedURL;
+ }
+
+ /* package-private */ void setLastItem(Item lastItem) {
+ this.lastItem = lastItem;
+ }
+
+ /**
+ * Is this feed object sufficiently complete so that we can use it?
+ */
+ /* package-private */ boolean isSufficientlyComplete() {
+ return !TextUtils.isEmpty(title) &&
+ lastItem != null &&
+ !TextUtils.isEmpty(lastItem.getURL()) &&
+ !TextUtils.isEmpty(lastItem.getTitle());
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getWebsiteURL() {
+ return websiteURL;
+ }
+
+ public String getFeedURL() {
+ return feedURL;
+ }
+
+ public Item getLastItem() {
+ return lastItem;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java
new file mode 100644
index 000000000..8d8f6d44e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/Item.java
@@ -0,0 +1,49 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.parser;
+
+public class Item {
+ private String title;
+ private String url;
+ private long timestamp;
+
+ public static Item create(String title, String url, long timestamp) {
+ Item item = new Item();
+
+ item.setTitle(title);
+ item.setURL(url);
+ item.setTimestamp(timestamp);
+
+ return item;
+ }
+
+ /* package-private */ void setTitle(String title) {
+ this.title = title;
+ }
+
+ /* package-private */ void setURL(String url) {
+ this.url = url;
+ }
+
+ /* package-private */ void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getURL() {
+ return url;
+ }
+
+ /**
+ * @return the number of milliseconds since Jan. 1, 1970, midnight GMT.
+ */
+ public long getTimestamp() {
+ return timestamp;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java
new file mode 100644
index 000000000..afb1b7cb2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/parser/SimpleFeedParser.java
@@ -0,0 +1,367 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.parser;
+
+import android.util.Log;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * A super simple feed parser written for implementing "content notifications". This XML Pull Parser
+ * can read ATOM and RSS feeds and returns an object describing the feed and the latest entry.
+ */
+public class SimpleFeedParser {
+ /**
+ * Generic exception that's thrown by the parser whenever a stream cannot be parsed.
+ */
+ public static class ParserException extends Exception {
+ private static final long serialVersionUID = -6119538440219805603L;
+
+ public ParserException(Throwable cause) {
+ super(cause);
+ }
+
+ public ParserException(String message) {
+ super(message);
+ }
+ }
+
+ private static final String LOGTAG = "Gecko/FeedParser";
+
+ private static final String TAG_RSS = "rss";
+ private static final String TAG_FEED = "feed";
+ private static final String TAG_RDF = "RDF";
+ private static final String TAG_TITLE = "title";
+ private static final String TAG_ITEM = "item";
+ private static final String TAG_LINK = "link";
+ private static final String TAG_ENTRY = "entry";
+ private static final String TAG_PUBDATE = "pubDate";
+ private static final String TAG_UPDATED = "updated";
+ private static final String TAG_DATE = "date";
+ private static final String TAG_SOURCE = "source";
+ private static final String TAG_IMAGE = "image";
+ private static final String TAG_CONTENT = "content";
+
+ private class ParserState {
+ public Feed feed;
+ public Item currentItem;
+ public boolean isRSS;
+ public boolean isATOM;
+ public boolean inSource;
+ public boolean inImage;
+ public boolean inContent;
+ }
+
+ public Feed parse(InputStream in) throws ParserException, IOException {
+ final ParserState state = new ParserState();
+
+ try {
+ final XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+
+ XmlPullParser parser = factory.newPullParser();
+ parser.setInput(in, null);
+
+ int eventType = parser.getEventType();
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ switch (eventType) {
+ case XmlPullParser.START_DOCUMENT:
+ handleStartDocument(state);
+ break;
+
+ case XmlPullParser.START_TAG:
+ handleStartTag(parser, state);
+ break;
+
+ case XmlPullParser.END_TAG:
+ handleEndTag(parser, state);
+ break;
+ }
+
+ eventType = parser.next();
+ }
+ } catch (XmlPullParserException e) {
+ throw new ParserException(e);
+ }
+
+ if (!state.feed.isSufficientlyComplete()) {
+ throw new ParserException("Feed is not sufficiently complete");
+ }
+
+ return state.feed;
+ }
+
+ private void handleStartDocument(ParserState state) {
+ state.feed = new Feed();
+ }
+
+ private void handleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ switch (parser.getName()) {
+ case TAG_RSS:
+ state.isRSS = true;
+ break;
+
+ case TAG_FEED:
+ state.isATOM = true;
+ break;
+
+ case TAG_RDF:
+ // This is a RSS 1.0 feed
+ state.isRSS = true;
+ break;
+
+ case TAG_ITEM:
+ case TAG_ENTRY:
+ state.currentItem = new Item();
+ break;
+
+ case TAG_TITLE:
+ handleTitleStartTag(parser, state);
+ break;
+
+ case TAG_LINK:
+ handleLinkStartTag(parser, state);
+ break;
+
+ case TAG_PUBDATE:
+ handlePubDateStartTag(parser, state);
+ break;
+
+ case TAG_UPDATED:
+ handleUpdatedStartTag(parser, state);
+ break;
+
+ case TAG_DATE:
+ handleDateStartTag(parser, state);
+ break;
+
+ case TAG_SOURCE:
+ state.inSource = true;
+ break;
+
+ case TAG_IMAGE:
+ state.inImage = true;
+ break;
+
+ case TAG_CONTENT:
+ state.inContent = true;
+ break;
+ }
+ }
+
+ private void handleEndTag(XmlPullParser parser, ParserState state) {
+ switch (parser.getName()) {
+ case TAG_ITEM:
+ case TAG_ENTRY:
+ handleItemOrEntryREndTag(state);
+ break;
+
+ case TAG_SOURCE:
+ state.inSource = false;
+ break;
+
+ case TAG_IMAGE:
+ state.inImage = false;
+ break;
+
+ case TAG_CONTENT:
+ state.inContent = false;
+ break;
+ }
+ }
+
+ private void handleTitleStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ if (state.inSource || state.inImage || state.inContent) {
+ // We do not care about titles in <source>, <image> or <media> tags.
+ return;
+ }
+
+ String title = getTextUntilEndTag(parser, TAG_TITLE);
+
+ title = title.replaceAll("[\r\n]", " ");
+ title = title.replaceAll(" +", " ");
+
+ if (state.currentItem != null) {
+ state.currentItem.setTitle(title);
+ } else {
+ state.feed.setTitle(title);
+ }
+ }
+
+ private void handleLinkStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ if (state.inSource || state.inImage) {
+ // We do not care about links in <source> or <image> tags.
+ return;
+ }
+
+ Map<String, String> attributes = fetchAttributes(parser);
+
+ if (attributes.size() > 0) {
+ String rel = attributes.get("rel");
+
+ if (state.currentItem == null && "self".equals(rel)) {
+ state.feed.setFeedURL(attributes.get("href"));
+ return;
+ }
+
+ if (rel == null || "alternate".equals(rel)) {
+ String type = attributes.get("type");
+ if (type == null || type.equals("text/html")) {
+ String link = attributes.get("href");
+ if (TextUtils.isEmpty(link)) {
+ return;
+ }
+
+ if (state.currentItem != null) {
+ state.currentItem.setURL(link);
+ } else {
+ state.feed.setWebsiteURL(link);
+ }
+
+ return;
+ }
+ }
+ }
+
+ if (state.isRSS) {
+ String link = getTextUntilEndTag(parser, TAG_LINK);
+ if (TextUtils.isEmpty(link)) {
+ return;
+ }
+
+ if (state.currentItem != null) {
+ state.currentItem.setURL(link);
+ } else {
+ state.feed.setWebsiteURL(link);
+ }
+ }
+ }
+
+ private void handleItemOrEntryREndTag(ParserState state) {
+ if (state.feed.getLastItem() == null || state.feed.getLastItem().getTimestamp() < state.currentItem.getTimestamp()) {
+ // Only set this item as "last item" if we do not have an item yet or this item is newer.
+ state.feed.setLastItem(state.currentItem);
+ }
+
+ state.currentItem = null;
+ }
+
+ private void handlePubDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ if (state.currentItem == null) {
+ return;
+ }
+
+ String pubDate = getTextUntilEndTag(parser, TAG_PUBDATE);
+ if (TextUtils.isEmpty(pubDate)) {
+ return;
+ }
+
+ // RFC-822
+ SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
+
+ updateCurrentItemTimestamp(state, pubDate, format);
+ }
+
+ private void handleUpdatedStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ if (state.inSource) {
+ // We do not care about stuff in <source> tags.
+ return;
+ }
+
+ if (state.currentItem == null) {
+ // We are only interested in <updated> values of feed items.
+ return;
+ }
+
+ String updated = getTextUntilEndTag(parser, TAG_UPDATED);
+ if (TextUtils.isEmpty(updated)) {
+ return;
+ }
+
+ SimpleDateFormat[] formats = new SimpleDateFormat[] {
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US)
+ };
+
+ // Fix timezones SimpleDateFormat can't parse:
+ // 2016-01-26T18:56:54Z -> 2016-01-26T18:56:54+0000 (Timezone: Z -> +0000)
+ updated = updated.replaceFirst("Z$", "+0000");
+ // 2016-01-26T18:56:54+01:00 -> 2016-01-26T18:56:54+0100 (Timezone: +01:00 -> +0100)
+ updated = updated.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4");
+
+ updateCurrentItemTimestamp(state, updated, formats);
+ }
+
+ private void handleDateStartTag(XmlPullParser parser, ParserState state) throws IOException, XmlPullParserException {
+ if (state.currentItem == null) {
+ // We are only interested in <updated> values of feed items.
+ return;
+ }
+
+ String text = getTextUntilEndTag(parser, TAG_DATE);
+ if (TextUtils.isEmpty(text)) {
+ return;
+ }
+
+ // Fix timezones SimpleDateFormat can't parse:
+ // 2016-01-26T18:56:54+00:00 -> 2016-01-26T18:56:54+0000
+ text = text.replaceFirst("([0-9]{2})([\\+\\-])([0-9]{2}):([0-9]{2})$", "$1$2$3$4");
+
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);
+
+ updateCurrentItemTimestamp(state, text, format);
+ }
+
+ private void updateCurrentItemTimestamp(ParserState state, String text, SimpleDateFormat... formats) {
+ for (SimpleDateFormat format : formats) {
+ try {
+ Date date = format.parse(text);
+ state.currentItem.setTimestamp(date.getTime());
+ return;
+ } catch (ParseException e) {
+ Log.w(LOGTAG, "Could not parse 'updated': " + text);
+ }
+ }
+ }
+
+ private Map<String, String> fetchAttributes(XmlPullParser parser) {
+ Map<String, String> attributes = new HashMap<>();
+
+ for (int i = 0; i < parser.getAttributeCount(); i++) {
+ attributes.put(parser.getAttributeName(i), parser.getAttributeValue(i));
+ }
+
+ return attributes;
+ }
+
+ private String getTextUntilEndTag(XmlPullParser parser, String tag) throws IOException, XmlPullParserException {
+ StringBuilder builder = new StringBuilder();
+
+ while (parser.next() != XmlPullParser.END_DOCUMENT) {
+ if (parser.getEventType() == XmlPullParser.TEXT) {
+ builder.append(parser.getText());
+ } else if (parser.getEventType() == XmlPullParser.END_TAG && tag.equals(parser.getName())) {
+ break;
+ }
+ }
+
+ return builder.toString().trim();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
new file mode 100644
index 000000000..7ce7f193f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/feeds/subscriptions/FeedSubscription.java
@@ -0,0 +1,130 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.feeds.subscriptions;
+
+import android.database.Cursor;
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.feeds.FeedFetcher;
+import org.mozilla.gecko.feeds.parser.Item;
+
+/**
+ * An object describing a subscription and containing some meta data about the last time we fetched
+ * the feed.
+ */
+public class FeedSubscription {
+ private static final String JSON_KEY_FEED_TITLE = "feed_title";
+ private static final String JSON_KEY_LAST_ITEM_TITLE = "last_item_title";
+ private static final String JSON_KEY_LAST_ITEM_URL = "last_item_url";
+ private static final String JSON_KEY_LAST_ITEM_TIMESTAMP = "last_item_timestamp";
+ private static final String JSON_KEY_ETAG = "etag";
+ private static final String JSON_KEY_LAST_MODIFIED = "last_modified";
+
+ private String feedUrl;
+ private String feedTitle;
+ private String lastItemTitle;
+ private String lastItemUrl;
+ private long lastItemTimestamp;
+ private String etag;
+ private String lastModified;
+
+ public static FeedSubscription create(String feedUrl, FeedFetcher.FeedResponse response) {
+ FeedSubscription subscription = new FeedSubscription();
+ subscription.feedUrl = feedUrl;
+
+ subscription.update(response);
+
+ return subscription;
+ }
+
+ public static FeedSubscription fromCursor(Cursor cursor) throws JSONException {
+ final FeedSubscription subscription = new FeedSubscription();
+ subscription.feedUrl = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.URL));
+
+ final String value = cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE));
+ subscription.fromJSON(new JSONObject(value));
+
+ return subscription;
+ }
+
+ private void fromJSON(JSONObject object) throws JSONException {
+ feedTitle = object.getString(JSON_KEY_FEED_TITLE);
+ lastItemTitle = object.getString(JSON_KEY_LAST_ITEM_TITLE);
+ lastItemUrl = object.getString(JSON_KEY_LAST_ITEM_URL);
+ lastItemTimestamp = object.getLong(JSON_KEY_LAST_ITEM_TIMESTAMP);
+ etag = object.optString(JSON_KEY_ETAG);
+ lastModified = object.optString(JSON_KEY_LAST_MODIFIED);
+ }
+
+ public void update(FeedFetcher.FeedResponse response) {
+ feedTitle = response.feed.getTitle();
+ lastItemTitle = response.feed.getLastItem().getTitle();
+ lastItemUrl = response.feed.getLastItem().getURL();
+ lastItemTimestamp = response.feed.getLastItem().getTimestamp();
+ etag = response.etag;
+ lastModified = response.lastModified;
+ }
+
+ /**
+ * Guesstimate if this response is a newer representation of the feed.
+ */
+ public boolean hasBeenUpdated(FeedFetcher.FeedResponse response) {
+ final Item responseItem = response.feed.getLastItem();
+
+ if (responseItem.getTimestamp() > lastItemTimestamp) {
+ // The timestamp is from a newer date so we expect that this item is a new item. But this
+ // could also mean that the timestamp of an already existing item has been updated. We
+ // accept that and assume that the content will have changed too in this case.
+ return true;
+ }
+
+ if (responseItem.getTimestamp() == lastItemTimestamp && responseItem.getTimestamp() != 0) {
+ // We have a timestamp that is not zero and this item has still the timestamp: It's very
+ // likely that we are looking at the same item. We assume this is not new content.
+ return false;
+ }
+
+ if (!responseItem.getURL().equals(lastItemUrl)) {
+ // The URL changed: It is very likely that this is a new item. At least it has been updated
+ // in a way that we just treat it as new content here.
+ return true;
+ }
+
+ return false;
+ }
+
+ public String getFeedUrl() {
+ return feedUrl;
+ }
+
+ public String getFeedTitle() {
+ return feedTitle;
+ }
+
+ public String getETag() {
+ return etag;
+ }
+
+ public String getLastModified() {
+ return lastModified;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ JSONObject object = new JSONObject();
+
+ object.put(JSON_KEY_FEED_TITLE, feedTitle);
+ object.put(JSON_KEY_LAST_ITEM_TITLE, lastItemTitle);
+ object.put(JSON_KEY_LAST_ITEM_URL, lastItemUrl);
+ object.put(JSON_KEY_LAST_ITEM_TIMESTAMP, lastItemTimestamp);
+ object.put(JSON_KEY_ETAG, etag);
+ object.put(JSON_KEY_LAST_MODIFIED, lastModified);
+
+ return object;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java
new file mode 100644
index 000000000..d5940d758
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/DataPanel.java
@@ -0,0 +1,47 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+public class DataPanel extends FirstrunPanel {
+ private boolean isEnabled = false;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) {
+ final View root = super.onCreateView(inflater, container, savedInstance);
+ final ImageView clickableImage = (ImageView) root.findViewById(R.id.firstrun_image);
+ clickableImage.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ // Set new state.
+ isEnabled = !isEnabled;
+ int newResource = isEnabled ? R.drawable.firstrun_data_on : R.drawable.firstrun_data_off;
+ ((ImageView) view).setImageResource(newResource);
+ if (isEnabled) {
+ // Always block images.
+ PrefsHelper.setPref("browser.image_blocking", 0);
+ } else {
+ // Default: always load images.
+ PrefsHelper.setPref("browser.image_blocking", 1);
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-datasaving-" + isEnabled);
+ }
+ });
+
+ return root;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java
new file mode 100644
index 000000000..93dd0c254
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunAnimationContainer.java
@@ -0,0 +1,94 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.content.Context;
+import android.support.v4.app.FragmentManager;
+import android.util.AttributeSet;
+
+import android.view.View;
+import android.widget.LinearLayout;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.Experiments;
+
+/**
+ * A container for the pager and the entire first run experience.
+ * This is used for animation purposes.
+ */
+public class FirstrunAnimationContainer extends LinearLayout {
+ public static final String PREF_FIRSTRUN_ENABLED = "startpane_enabled";
+
+ public static interface OnFinishListener {
+ public void onFinish();
+ }
+
+ private FirstrunPager pager;
+ private boolean visible;
+ private OnFinishListener onFinishListener;
+
+ public FirstrunAnimationContainer(Context context) {
+ this(context, null);
+ }
+ public FirstrunAnimationContainer(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public void load(Context appContext, FragmentManager fm) {
+ visible = true;
+ pager = (FirstrunPager) findViewById(R.id.firstrun_pager);
+ pager.load(appContext, fm, new OnFinishListener() {
+ @Override
+ public void onFinish() {
+ hide();
+ }
+ });
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ public void hide() {
+ visible = false;
+ if (onFinishListener != null) {
+ onFinishListener.onFinish();
+ }
+ animateHide();
+
+ // Stop all versions of firstrun A/B sessions.
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_B);
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_C);
+ }
+
+ private void animateHide() {
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(this, "alpha", 0);
+ alphaAnimator.setDuration(150);
+ alphaAnimator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ FirstrunAnimationContainer.this.setVisibility(View.GONE);
+ }
+ });
+
+ alphaAnimator.start();
+ }
+
+ public boolean showBrowserHint() {
+ final int currentPage = pager.getCurrentItem();
+ FirstrunPanel currentPanel = (FirstrunPanel) ((FirstrunPager.ViewPagerAdapter) pager.getAdapter()).getItem(currentPage);
+ pager.cleanup();
+ return currentPanel.shouldShowBrowserHint();
+ }
+
+ public void registerOnFinishListener(OnFinishListener listener) {
+ this.onFinishListener = listener;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java
new file mode 100644
index 000000000..c2838ee3e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPager.java
@@ -0,0 +1,174 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.content.Context;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentPagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.home.HomePager.Decor;
+import org.mozilla.gecko.home.TabMenuStrip;
+import org.mozilla.gecko.restrictions.Restrictions;
+
+import java.util.List;
+
+/**
+ * ViewPager containing for our first run pages.
+ *
+ * @see FirstrunPanel for the first run pages that are used in this pager.
+ */
+public class FirstrunPager extends ViewPager {
+
+ private Context context;
+ protected FirstrunPanel.PagerNavigation pagerNavigation;
+ private Decor mDecor;
+
+ public FirstrunPager(Context context) {
+ this(context, null);
+ }
+
+ public FirstrunPager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ this.context = context;
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ if (child instanceof Decor) {
+ ((ViewPager.LayoutParams) params).isDecor = true;
+ mDecor = (Decor) child;
+ mDecor.setOnTitleClickListener(new TabMenuStrip.OnTitleClickListener() {
+ @Override
+ public void onTitleClicked(int index) {
+ setCurrentItem(index, true);
+ }
+ });
+ }
+
+ super.addView(child, index, params);
+ }
+
+ public void load(Context appContext, FragmentManager fm, final FirstrunAnimationContainer.OnFinishListener onFinishListener) {
+ final List<FirstrunPagerConfig.FirstrunPanelConfig> panels;
+
+ if (Restrictions.isRestrictedProfile(context)) {
+ panels = FirstrunPagerConfig.getRestricted();
+ } else {
+ panels = FirstrunPagerConfig.getDefault(appContext);
+ }
+
+ setAdapter(new ViewPagerAdapter(fm, panels));
+ this.pagerNavigation = new FirstrunPanel.PagerNavigation() {
+ @Override
+ public void next() {
+ final int currentPage = FirstrunPager.this.getCurrentItem();
+ if (currentPage < FirstrunPager.this.getAdapter().getCount() - 1) {
+ FirstrunPager.this.setCurrentItem(currentPage + 1);
+ }
+ }
+
+ @Override
+ public void finish() {
+ if (onFinishListener != null) {
+ onFinishListener.onFinish();
+ }
+ }
+ };
+ addOnPageChangeListener(new OnPageChangeListener() {
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ mDecor.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ @Override
+ public void onPageSelected(int i) {
+ mDecor.onPageSelected(i);
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.PANEL, "onboarding." + i);
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int i) {}
+ });
+
+ animateLoad();
+
+ // Record telemetry for first onboarding panel, for baseline.
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.PANEL, "onboarding.0");
+ }
+
+ public void cleanup() {
+ setAdapter(null);
+ }
+
+ private void animateLoad() {
+ setTranslationY(500);
+ setAlpha(0);
+
+ final Animator translateAnimator = ObjectAnimator.ofFloat(this, "translationY", 0);
+ translateAnimator.setDuration(400);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(this, "alpha", 1);
+ alphaAnimator.setStartDelay(200);
+ alphaAnimator.setDuration(600);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(alphaAnimator, translateAnimator);
+ set.setStartDelay(400);
+
+ set.start();
+ }
+
+ protected class ViewPagerAdapter extends FragmentPagerAdapter {
+ private final List<FirstrunPagerConfig.FirstrunPanelConfig> panels;
+ private final Fragment[] fragments;
+
+ public ViewPagerAdapter(FragmentManager fm, List<FirstrunPagerConfig.FirstrunPanelConfig> panels) {
+ super(fm);
+ this.panels = panels;
+ this.fragments = new Fragment[panels.size()];
+ for (FirstrunPagerConfig.FirstrunPanelConfig panel : panels) {
+ mDecor.onAddPagerView(context.getString(panel.getTitleRes()));
+ }
+
+ if (panels.size() > 0) {
+ mDecor.onPageSelected(0);
+ }
+ }
+
+ @Override
+ public Fragment getItem(int i) {
+ Fragment fragment = this.fragments[i];
+ if (fragment == null) {
+ FirstrunPagerConfig.FirstrunPanelConfig panelConfig = panels.get(i);
+ fragment = Fragment.instantiate(context, panelConfig.getClassname(), panelConfig.getArgs());
+ ((FirstrunPanel) fragment).setPagerNavigation(pagerNavigation);
+ fragments[i] = fragment;
+ }
+ return fragment;
+ }
+
+ @Override
+ public int getCount() {
+ return panels.size();
+ }
+
+ @Override
+ public CharSequence getPageTitle(int i) {
+ // Unused now that we use TabMenuStrip.
+ return context.getString(panels.get(i).getTitleRes()).toUpperCase();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java
new file mode 100644
index 000000000..3f901d07b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPagerConfig.java
@@ -0,0 +1,107 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.Experiments;
+
+import java.util.LinkedList;
+import java.util.List;
+
+public class FirstrunPagerConfig {
+ public static final String LOGTAG = "FirstrunPagerConfig";
+
+ public static final String KEY_IMAGE = "imageRes";
+ public static final String KEY_TEXT = "textRes";
+ public static final String KEY_SUBTEXT = "subtextRes";
+
+ public static List<FirstrunPanelConfig> getDefault(Context context) {
+ final List<FirstrunPanelConfig> panels = new LinkedList<>();
+
+ if (Experiments.isInExperimentLocal(context, Experiments.ONBOARDING3_B)) {
+ panels.add(SimplePanelConfigs.urlbarPanelConfig);
+ panels.add(SimplePanelConfigs.bookmarksPanelConfig);
+ panels.add(SimplePanelConfigs.dataPanelConfig);
+ panels.add(SimplePanelConfigs.syncPanelConfig);
+ panels.add(SimplePanelConfigs.signInPanelConfig);
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_B);
+ GeckoSharedPrefs.forProfile(context).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_B).apply();
+ } else if (Experiments.isInExperimentLocal(context, Experiments.ONBOARDING3_C)) {
+ panels.add(SimplePanelConfigs.tabqueuePanelConfig);
+ panels.add(SimplePanelConfigs.readerviewPanelConfig);
+ panels.add(SimplePanelConfigs.accountPanelConfig);
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.ONBOARDING3_C);
+ GeckoSharedPrefs.forProfile(context).edit().putString(Experiments.PREF_ONBOARDING_VERSION, Experiments.ONBOARDING3_C).apply();
+ } else {
+ Log.e(LOGTAG, "Not in an experiment!");
+ panels.add(SimplePanelConfigs.signInPanelConfig);
+ }
+ return panels;
+ }
+
+ public static List<FirstrunPanelConfig> getRestricted() {
+ final List<FirstrunPanelConfig> panels = new LinkedList<>();
+ panels.add(new FirstrunPanelConfig(RestrictedWelcomePanel.class.getName(), RestrictedWelcomePanel.TITLE_RES));
+ return panels;
+ }
+
+ public static class FirstrunPanelConfig {
+
+ private String classname;
+ private int titleRes;
+ private Bundle args;
+
+ public FirstrunPanelConfig(String resource, int titleRes) {
+ this(resource, titleRes, -1, -1, -1, true);
+ }
+
+ public FirstrunPanelConfig(String classname, int titleRes, int imageRes, int textRes, int subtextRes) {
+ this(classname, titleRes, imageRes, textRes, subtextRes, false);
+ }
+
+ private FirstrunPanelConfig(String classname, int titleRes, int imageRes, int textRes, int subtextRes, boolean isCustom) {
+ this.classname = classname;
+ this.titleRes = titleRes;
+
+ if (!isCustom) {
+ this.args = new Bundle();
+ this.args.putInt(KEY_IMAGE, imageRes);
+ this.args.putInt(KEY_TEXT, textRes);
+ this.args.putInt(KEY_SUBTEXT, subtextRes);
+ }
+ }
+
+ public String getClassname() {
+ return this.classname;
+ }
+
+ public int getTitleRes() {
+ return this.titleRes;
+ }
+
+ public Bundle getArgs() {
+ return args;
+ }
+ }
+
+ private static class SimplePanelConfigs {
+ public static final FirstrunPanelConfig urlbarPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_panel_title_welcome, R.drawable.firstrun_urlbar, R.string.firstrun_urlbar_message, R.string.firstrun_urlbar_subtext);
+ public static final FirstrunPanelConfig bookmarksPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_bookmarks_title, R.drawable.firstrun_bookmarks, R.string.firstrun_bookmarks_message, R.string.firstrun_bookmarks_subtext);
+ public static final FirstrunPanelConfig dataPanelConfig = new FirstrunPanelConfig(DataPanel.class.getName(), R.string.firstrun_data_title, R.drawable.firstrun_data_off, R.string.firstrun_data_message, R.string.firstrun_data_subtext);
+ public static final FirstrunPanelConfig syncPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_sync_title, R.drawable.firstrun_sync, R.string.firstrun_sync_message, R.string.firstrun_sync_subtext);
+ public static final FirstrunPanelConfig signInPanelConfig = new FirstrunPanelConfig(SyncPanel.class.getName(), R.string.pref_sync, R.drawable.firstrun_signin, R.string.firstrun_signin_message, R.string.firstrun_welcome_button_browser);
+
+ public static final FirstrunPanelConfig tabqueuePanelConfig = new FirstrunPanelConfig(TabQueuePanel.class.getName(), R.string.firstrun_tabqueue_title, R.drawable.firstrun_tabqueue_off, R.string.firstrun_tabqueue_message_off, R.string.firstrun_tabqueue_subtext_off);
+ public static final FirstrunPanelConfig readerviewPanelConfig = new FirstrunPanelConfig(FirstrunPanel.class.getName(), R.string.firstrun_readerview_title, R.drawable.firstrun_readerview, R.string.firstrun_readerview_message, R.string.firstrun_readerview_subtext);
+ public static final FirstrunPanelConfig accountPanelConfig = new FirstrunPanelConfig(SyncPanel.class.getName(), R.string.firstrun_account_title, R.drawable.firstrun_account, R.string.firstrun_account_message, R.string.firstrun_button_notnow);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java
new file mode 100644
index 000000000..4b27dbc73
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/FirstrunPanel.java
@@ -0,0 +1,80 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+/**
+ * Base class for our first run pages. We call these FirstrunPanel for consistency
+ * with HomePager/HomePanel.
+ *
+ * @see FirstrunPager for the containing pager.
+ */
+public class FirstrunPanel extends Fragment {
+
+ public static final int TITLE_RES = -1;
+ protected boolean showBrowserHint = true;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) {
+ final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.firstrun_basepanel_checkable_fragment, container, false);
+ Bundle args = getArguments();
+ if (args != null) {
+ final int imageRes = args.getInt(FirstrunPagerConfig.KEY_IMAGE);
+ final int textRes = args.getInt(FirstrunPagerConfig.KEY_TEXT);
+ final int subtextRes = args.getInt(FirstrunPagerConfig.KEY_SUBTEXT);
+
+ ((ImageView) root.findViewById(R.id.firstrun_image)).setImageResource(imageRes);
+ ((TextView) root.findViewById(R.id.firstrun_text)).setText(textRes);
+ ((TextView) root.findViewById(R.id.firstrun_subtext)).setText(subtextRes);
+ }
+
+ root.findViewById(R.id.firstrun_link).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-next");
+ pagerNavigation.next();
+ }
+ });
+
+ return root;
+ }
+
+ public interface PagerNavigation {
+ void next();
+ void finish();
+ }
+ protected PagerNavigation pagerNavigation;
+
+ public void setPagerNavigation(PagerNavigation listener) {
+ this.pagerNavigation = listener;
+ }
+
+ protected void next() {
+ if (pagerNavigation != null) {
+ pagerNavigation.next();
+ }
+ }
+
+ protected void close() {
+ if (pagerNavigation != null) {
+ pagerNavigation.finish();
+ }
+ }
+
+ protected boolean shouldShowBrowserHint() {
+ return showBrowserHint;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java
new file mode 100644
index 000000000..efc91d20f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/RestrictedWelcomePanel.java
@@ -0,0 +1,61 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.home.HomePager;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import java.util.EnumSet;
+
+public class RestrictedWelcomePanel extends FirstrunPanel {
+ public static final int TITLE_RES = R.string.firstrun_panel_title_welcome;
+
+ private static final String LEARN_MORE_URL = "https://support.mozilla.org/kb/controlledaccess";
+
+ private HomePager.OnUrlOpenListener onUrlOpenListener;
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ onUrlOpenListener = (HomePager.OnUrlOpenListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement HomePager.OnUrlOpenListener");
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) {
+ final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.restricted_firstrun_welcome_fragment, container, false);
+
+ root.findViewById(R.id.welcome_browse).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ close();
+ }
+ });
+
+ root.findViewById(R.id.learn_more_link).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onUrlOpenListener.onUrlOpen(LEARN_MORE_URL, EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+
+ close();
+ }
+ });
+
+ return root;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java
new file mode 100644
index 000000000..2f489c84e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/SyncPanel.java
@@ -0,0 +1,61 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity;
+
+public class SyncPanel extends FirstrunPanel {
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstance) {
+ final ViewGroup root = (ViewGroup) inflater.inflate(R.layout.firstrun_sync_fragment, container, false);
+ final Bundle args = getArguments();
+ if (args != null) {
+ final int imageRes = args.getInt(FirstrunPagerConfig.KEY_IMAGE);
+ final int textRes = args.getInt(FirstrunPagerConfig.KEY_TEXT);
+ final int subtextRes = args.getInt(FirstrunPagerConfig.KEY_SUBTEXT);
+
+ ((ImageView) root.findViewById(R.id.firstrun_image)).setImageResource(imageRes);
+ ((TextView) root.findViewById(R.id.firstrun_text)).setText(textRes);
+ ((TextView) root.findViewById(R.id.welcome_browse)).setText(subtextRes);
+ }
+
+ root.findViewById(R.id.welcome_account).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-sync");
+ showBrowserHint = false;
+
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_FIRSTRUN);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(intent);
+
+ close();
+ }
+ });
+
+ root.findViewById(R.id.welcome_browse).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-browser");
+ close();
+ }
+ });
+
+ return root;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java b/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java
new file mode 100644
index 000000000..3c2ed8312
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/firstrun/TabQueuePanel.java
@@ -0,0 +1,92 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.firstrun;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.SwitchCompat;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.tabqueue.TabQueuePrompt;
+
+public class TabQueuePanel extends FirstrunPanel {
+ private static final int REQUEST_CODE_TAB_QUEUE = 1;
+ private SwitchCompat toggleSwitch;
+ private ImageView imageView;
+ private TextView messageTextView;
+ private TextView subtextTextView;
+ private Context context;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, final ViewGroup container, Bundle savedInstance) {
+ context = getContext();
+ final View root = super.onCreateView(inflater, container, savedInstance);
+
+ imageView = (ImageView) root.findViewById(R.id.firstrun_image);
+ messageTextView = (TextView) root.findViewById(R.id.firstrun_text);
+ subtextTextView = (TextView) root.findViewById(R.id.firstrun_subtext);
+
+ toggleSwitch = (SwitchCompat) root.findViewById(R.id.firstrun_switch);
+ toggleSwitch.setVisibility(View.VISIBLE);
+ toggleSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions");
+ if (b && !TabQueueHelper.canDrawOverlays(context)) {
+ Intent promptIntent = new Intent(context, TabQueuePrompt.class);
+ startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE);
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-tabqueue-" + b);
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(GeckoPreferences.PREFS_TAB_QUEUE, b).apply();
+
+ // Set image, text, and typeface changes.
+ imageView.setImageResource(b ? R.drawable.firstrun_tabqueue_on : R.drawable.firstrun_tabqueue_off);
+ messageTextView.setText(b ? R.string.firstrun_tabqueue_message_on : R.string.firstrun_tabqueue_message_off);
+ messageTextView.setTypeface(b ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
+ subtextTextView.setText(b ? R.string.firstrun_tabqueue_subtext_on : R.string.firstrun_tabqueue_subtext_off);
+ subtextTextView.setTypeface(b ? Typeface.defaultFromStyle(Typeface.ITALIC) : Typeface.DEFAULT);
+ subtextTextView.setTextColor(b ? ContextCompat.getColor(context, R.color.fennec_ui_orange) : ContextCompat.getColor(context, R.color.placeholder_grey));
+ }
+ });
+
+ return root;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ switch (requestCode) {
+ case REQUEST_CODE_TAB_QUEUE:
+ final boolean accepted = TabQueueHelper.processTabQueuePromptResponse(resultCode, context);
+ if (accepted) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions-yes");
+ toggleSwitch.setChecked(true);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "firstrun-tabqueue-true");
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DIALOG, "firstrun_tabqueue-permissions-" + (accepted ? "accepted" : "rejected"));
+ break;
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java
new file mode 100644
index 000000000..0616cd229
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmInstanceIDListenerService.java
@@ -0,0 +1,35 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gcm;
+
+import android.util.Log;
+
+import com.google.android.gms.iid.InstanceIDListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service is notified by the on-device Google Play Services library if an
+ * in-use token needs to be updated. We simply pass through to AndroidPushService.
+ */
+public class GcmInstanceIDListenerService extends InstanceIDListenerService {
+ /**
+ * Called if InstanceID token is updated. This may occur if the security of
+ * the previous token had been compromised. This call is initiated by the
+ * InstanceID provider.
+ */
+ @Override
+ public void onTokenRefresh() {
+ Log.d("GeckoPushGCM", "Token refresh request received. Processing on background thread.");
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ PushService.getInstance(GcmInstanceIDListenerService.this).onRefresh();
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java
new file mode 100644
index 000000000..7962d7dc3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmMessageListenerService.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gcm;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.android.gms.gcm.GcmListenerService;
+
+import org.mozilla.gecko.push.PushService;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This service actually handles messages directed from the on-device Google
+ * Play Services package. We simply route them to the AndroidPushService.
+ */
+public class GcmMessageListenerService extends GcmListenerService {
+ /**
+ * Called when message is received.
+ *
+ * @param from SenderID of the sender.
+ * @param bundle Data bundle containing message data as key/value pairs.
+ */
+ @Override
+ public void onMessageReceived(final String from, final Bundle bundle) {
+ Log.d("GeckoPushGCM", "Message received. Processing on background thread.");
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ PushService.getInstance(GcmMessageListenerService.this).onMessageReceived(
+ GcmMessageListenerService.this, bundle);
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java
new file mode 100644
index 000000000..024905eb0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/gcm/GcmTokenClient.java
@@ -0,0 +1,131 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gcm;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.gms.gcm.GoogleCloudMessaging;
+import com.google.android.gms.iid.InstanceID;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.push.Fetched;
+
+import java.io.IOException;
+
+/**
+ * Fetch and cache GCM tokens.
+ * <p/>
+ * GCM tokens are stable and long lived. Google Play Services will periodically request that
+ * they are rotated, however: see
+ * <a href="https://developers.google.com/instance-id/guides/android-implementation">https://developers.google.com/instance-id/guides/android-implementation</a>.
+ * <p/>
+ * The GCM token is cached in the App-wide shared preferences. There's no particular harm in
+ * requesting new tokens, so if the user clears the App data, that's fine -- we'll get a fresh
+ * token and Push will react accordingly.
+ */
+public class GcmTokenClient {
+ private static final String LOG_TAG = "GeckoPushGCM";
+
+ private static final String KEY_GCM_TOKEN = "gcm_token";
+ private static final String KEY_GCM_TOKEN_TIMESTAMP = "gcm_token_timestamp";
+
+ private final Context context;
+
+ public GcmTokenClient(Context context) {
+ this.context = context;
+ }
+
+ /**
+ * Check the device to make sure it has the Google Play Services APK.
+ * @param context Android context.
+ */
+ protected void ensurePlayServices(Context context) throws NeedsGooglePlayServicesException {
+ final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+ int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
+ if (resultCode != ConnectionResult.SUCCESS) {
+ Log.w(LOG_TAG, "This device does not support GCM! isGooglePlayServicesAvailable returned: " + resultCode);
+ Log.w(LOG_TAG, "isGooglePlayServicesAvailable message: " + apiAvailability.getErrorString(resultCode));
+ throw new NeedsGooglePlayServicesException(resultCode);
+ }
+ }
+
+ /**
+ * Get a GCM token (possibly cached).
+ *
+ * @param senderID to request token for.
+ * @param debug whether to log debug details.
+ * @return token and timestamp.
+ * @throws NeedsGooglePlayServicesException if user action is needed to use Google Play Services.
+ * @throws IOException if the token fetch failed.
+ */
+ public @NonNull Fetched getToken(@NonNull String senderID, boolean debug) throws NeedsGooglePlayServicesException, IOException {
+ ensurePlayServices(this.context);
+
+ final SharedPreferences sharedPrefs = GeckoSharedPrefs.forApp(context);
+ String token = sharedPrefs.getString(KEY_GCM_TOKEN, null);
+ long timestamp = sharedPrefs.getLong(KEY_GCM_TOKEN_TIMESTAMP, 0L);
+ if (token != null && timestamp > 0L) {
+ if (debug) {
+ Log.i(LOG_TAG, "Cached GCM token exists: " + token);
+ } else {
+ Log.i(LOG_TAG, "Cached GCM token exists.");
+ }
+ return new Fetched(token, timestamp);
+ }
+
+ Log.i(LOG_TAG, "Cached GCM token does not exist; requesting new token with sender ID: " + senderID);
+
+ final InstanceID instanceID = InstanceID.getInstance(context);
+ token = instanceID.getToken(senderID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
+ timestamp = System.currentTimeMillis();
+
+ if (debug) {
+ Log.i(LOG_TAG, "Got fresh GCM token; caching: " + token);
+ } else {
+ Log.i(LOG_TAG, "Got fresh GCM token; caching.");
+ }
+ sharedPrefs
+ .edit()
+ .putString(KEY_GCM_TOKEN, token)
+ .putLong(KEY_GCM_TOKEN_TIMESTAMP, timestamp)
+ .apply();
+
+ return new Fetched(token, timestamp);
+ }
+
+ /**
+ * Remove any cached GCM token.
+ */
+ public void invalidateToken() {
+ final SharedPreferences sharedPrefs = GeckoSharedPrefs.forApp(context);
+ sharedPrefs
+ .edit()
+ .remove(KEY_GCM_TOKEN)
+ .remove(KEY_GCM_TOKEN_TIMESTAMP)
+ .apply();
+ }
+
+ public class NeedsGooglePlayServicesException extends Exception {
+ private static final long serialVersionUID = 4132853166L;
+
+ private final int resultCode;
+
+ NeedsGooglePlayServicesException(int resultCode) {
+ super();
+ this.resultCode = resultCode;
+ }
+
+ public void showErrorNotification() {
+ final GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
+ apiAvailability.showErrorNotification(context, resultCode);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java b/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java
new file mode 100644
index 000000000..a9f5b72f3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/health/HealthRecorder.java
@@ -0,0 +1,40 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.health;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.json.JSONObject;
+
+/**
+ * HealthRecorder is an interface into the Firefox Health Report storage system.
+ */
+public interface HealthRecorder {
+ /**
+ * Returns whether the Health Recorder is actively recording events.
+ */
+ public boolean isEnabled();
+
+ public void setCurrentSession(SessionInformation session);
+ public void checkForOrphanSessions();
+
+ public void recordGeckoStartupTime(long duration);
+ public void recordJavaStartupTime(long duration);
+ public void recordSearch(final String engineID, final String location);
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor);
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment);
+
+ public void onAppLocaleChanged(String to);
+ public void onAddonChanged(String id, JSONObject json);
+ public void onAddonUninstalling(String id);
+ public void onEnvironmentChanged();
+ public void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason);
+
+ public void close(final Context context);
+
+ public void processDelayed();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java b/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java
new file mode 100644
index 000000000..ad65918e1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/health/SessionInformation.java
@@ -0,0 +1,138 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.health;
+
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class SessionInformation {
+ private static final String LOG_TAG = "GeckoSessInfo";
+
+ public static final String PREFS_SESSION_START = "sessionStart";
+
+ public final long wallStartTime; // System wall clock.
+ public final long realStartTime; // Realtime clock.
+
+ private final boolean wasOOM;
+ private final boolean wasStopped;
+
+ private volatile long timedGeckoStartup = -1;
+ private volatile long timedJavaStartup = -1;
+
+ // Current sessions don't (right now) care about wasOOM/wasStopped.
+ // Eventually we might want to lift that logic out of GeckoApp.
+ public SessionInformation(long wallTime, long realTime) {
+ this(wallTime, realTime, false, false);
+ }
+
+ // Previous sessions do...
+ public SessionInformation(long wallTime, long realTime, boolean wasOOM, boolean wasStopped) {
+ this.wallStartTime = wallTime;
+ this.realStartTime = realTime;
+ this.wasOOM = wasOOM;
+ this.wasStopped = wasStopped;
+ }
+
+ /**
+ * Initialize a new SessionInformation instance from the supplied prefs object.
+ *
+ * This includes retrieving OOM/crash data, as well as timings.
+ *
+ * If no wallStartTime was found, that implies that the previous
+ * session was correctly recorded, and an object with a zero
+ * wallStartTime is returned.
+ */
+ public static SessionInformation fromSharedPrefs(SharedPreferences prefs) {
+ boolean wasOOM = prefs.getBoolean(GeckoAppShell.PREFS_OOM_EXCEPTION, false);
+ boolean wasStopped = prefs.getBoolean(GeckoApp.PREFS_WAS_STOPPED, true);
+ long wallStartTime = prefs.getLong(PREFS_SESSION_START, 0L);
+ long realStartTime = 0L;
+ Log.d(LOG_TAG, "Building SessionInformation from prefs: " +
+ wallStartTime + ", " + realStartTime + ", " +
+ wasStopped + ", " + wasOOM);
+ return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped);
+ }
+
+ /**
+ * Initialize a new SessionInformation instance to 'split' the current
+ * session.
+ */
+ public static SessionInformation forRuntimeTransition() {
+ final boolean wasOOM = false;
+ final boolean wasStopped = true;
+ final long wallStartTime = System.currentTimeMillis();
+ final long realStartTime = android.os.SystemClock.elapsedRealtime();
+ Log.v(LOG_TAG, "Recording runtime session transition: " +
+ wallStartTime + ", " + realStartTime);
+ return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped);
+ }
+
+ public boolean wasKilled() {
+ return wasOOM || !wasStopped;
+ }
+
+ /**
+ * Record the beginning of this session to SharedPreferences by
+ * recording our start time. If a session was already recorded, it is
+ * overwritten (there can only be one running session at a time). Does
+ * not commit the editor.
+ */
+ public void recordBegin(SharedPreferences.Editor editor) {
+ Log.d(LOG_TAG, "Recording start of session: " + this.wallStartTime);
+ editor.putLong(PREFS_SESSION_START, this.wallStartTime);
+ }
+
+ /**
+ * Record the completion of this session to SharedPreferences by
+ * deleting our start time. Does not commit the editor.
+ */
+ public void recordCompletion(SharedPreferences.Editor editor) {
+ Log.d(LOG_TAG, "Recording session done: " + this.wallStartTime);
+ editor.remove(PREFS_SESSION_START);
+ }
+
+ /**
+ * Return the JSON that we'll put in the DB for this session.
+ */
+ public JSONObject getCompletionJSON(String reason, long realEndTime) throws JSONException {
+ long durationSecs = (realEndTime - this.realStartTime) / 1000;
+ JSONObject out = new JSONObject();
+ out.put("r", reason);
+ out.put("d", durationSecs);
+ if (this.timedGeckoStartup > 0) {
+ out.put("sg", this.timedGeckoStartup);
+ }
+ if (this.timedJavaStartup > 0) {
+ out.put("sj", this.timedJavaStartup);
+ }
+ return out;
+ }
+
+ public JSONObject getCrashedJSON() throws JSONException {
+ JSONObject out = new JSONObject();
+ // We use ints here instead of booleans, because we're packing
+ // stuff into JSON, and saving bytes in the DB is a worthwhile
+ // goal.
+ out.put("oom", this.wasOOM ? 1 : 0);
+ out.put("stopped", this.wasStopped ? 1 : 0);
+ out.put("r", "A");
+ return out;
+ }
+
+ public void setTimedGeckoStartup(final long duration) {
+ timedGeckoStartup = duration;
+ }
+
+ public void setTimedJavaStartup(final long duration) {
+ timedJavaStartup = duration;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java b/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java
new file mode 100644
index 000000000..65a972985
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/health/StubbedHealthRecorder.java
@@ -0,0 +1,53 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.health;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.json.JSONObject;
+
+/**
+ * StubbedHealthRecorder is an implementation of HealthRecorder that does (you guessed it!)
+ * nothing.
+ */
+public class StubbedHealthRecorder implements HealthRecorder {
+ @Override
+ public boolean isEnabled() { return false; }
+
+ @Override
+ public void setCurrentSession(SessionInformation session) { }
+ @Override
+ public void checkForOrphanSessions() { }
+
+ @Override
+ public void recordGeckoStartupTime(long duration) { }
+ @Override
+ public void recordJavaStartupTime(long duration) { }
+ @Override
+ public void recordSearch(final String engineID, final String location) { }
+ @Override
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor) { }
+ @Override
+ public void recordSessionEnd(String reason, SharedPreferences.Editor editor, final int environment) { }
+
+ @Override
+ public void onAppLocaleChanged(String to) { }
+ @Override
+ public void onAddonChanged(String id, JSONObject json) { }
+ @Override
+ public void onAddonUninstalling(String id) { }
+ @Override
+ public void onEnvironmentChanged() { }
+ @Override
+ public void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) { }
+
+ @Override
+ public void close(final Context context) { }
+
+ @Override
+ public void processDelayed() { }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java
new file mode 100644
index 000000000..566422faf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkFolderView.java
@@ -0,0 +1,147 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+
+public class BookmarkFolderView extends LinearLayout {
+ private static final Set<Integer> FOLDERS_WITH_COUNT;
+
+ static {
+ final Set<Integer> folders = new TreeSet<>();
+ folders.add(BrowserContract.Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID);
+
+ FOLDERS_WITH_COUNT = Collections.unmodifiableSet(folders);
+ }
+
+ public enum FolderState {
+ /**
+ * A standard folder, i.e. a folder in a list of bookmarks and folders.
+ */
+ FOLDER(R.drawable.folder_closed),
+
+ /**
+ * The parent folder: this indicates that you are able to return to the previous
+ * folder ("Back to {name}").
+ */
+ PARENT(R.drawable.bookmark_folder_arrow_up),
+
+ /**
+ * The reading list smartfolder: this displays a reading list icon instead of the
+ * normal folder icon.
+ */
+ READING_LIST(R.drawable.reading_list_folder);
+
+ public final int image;
+
+ FolderState(final int image) { this.image = image; }
+ }
+
+ private final TextView mTitle;
+ private final TextView mSubtitle;
+
+ private final ImageView mIcon;
+
+ public BookmarkFolderView(Context context) {
+ this(context, null);
+ }
+
+ public BookmarkFolderView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.two_line_folder_row, this);
+
+ mTitle = (TextView) findViewById(R.id.title);
+ mSubtitle = (TextView) findViewById(R.id.subtitle);
+ mIcon = (ImageView) findViewById(R.id.icon);
+ }
+
+ public void update(String title, int folderID) {
+ setTitle(title);
+ setID(folderID);
+ }
+
+ private void setTitle(String title) {
+ mTitle.setText(title);
+ }
+
+ private static class ItemCountUpdateTask extends UIAsyncTask.WithoutParams<Integer> {
+ private final WeakReference<TextView> mTextViewReference;
+ private final int mFolderID;
+
+ public ItemCountUpdateTask(final WeakReference<TextView> textViewReference,
+ final int folderID) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ mTextViewReference = textViewReference;
+ mFolderID = folderID;
+ }
+
+ @Override
+ protected Integer doInBackground() {
+ final TextView textView = mTextViewReference.get();
+
+ if (textView == null) {
+ return null;
+ }
+
+ final BrowserDB db = BrowserDB.from(textView.getContext());
+ return db.getBookmarkCountForFolder(textView.getContext().getContentResolver(), mFolderID);
+ }
+
+ @Override
+ protected void onPostExecute(Integer count) {
+ final TextView textView = mTextViewReference.get();
+
+ if (textView == null) {
+ return;
+ }
+
+ final String text;
+ if (count == 1) {
+ text = textView.getContext().getResources().getString(R.string.bookmark_folder_one_item);
+ } else {
+ text = textView.getContext().getResources().getString(R.string.bookmark_folder_items, count);
+ }
+
+ textView.setText(text);
+ textView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void setID(final int folderID) {
+ if (FOLDERS_WITH_COUNT.contains(folderID)) {
+ final WeakReference<TextView> subTitleReference = new WeakReference<TextView>(mSubtitle);
+
+ new ItemCountUpdateTask(subTitleReference, folderID).execute();
+ } else {
+ mSubtitle.setVisibility(View.GONE);
+ }
+ }
+
+ public void setState(@NonNull FolderState state) {
+ mIcon.setImageResource(state.image);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java
new file mode 100644
index 000000000..a1efff049
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarkScreenshotRow.java
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
+
+import java.text.DateFormat;
+import java.text.FieldPosition;
+import java.util.Date;
+
+/**
+ * An entry of the screenshot list in the bookmarks panel.
+ */
+class BookmarkScreenshotRow extends LinearLayout {
+ private TextView titleView;
+ private TextView dateView;
+
+ // This DateFormat uses the current locale at instantiation time, which won't get updated if the locale is changed.
+ // Since it's just a date, it's probably not worth the code complexity to fix that.
+ private static final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
+ private static final DateFormat timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT);
+
+ // This parameter to DateFormat.format has no impact on the result but rather gets mutated by the method to
+ // identify where a certain field starts and ends (by index). This is useful if you want to later modify the String;
+ // I'm not sure why this argument isn't optional.
+ private static final FieldPosition dummyFieldPosition = new FieldPosition(-1);
+
+ public BookmarkScreenshotRow(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void onFinishInflate() {
+ super.onFinishInflate();
+ titleView = (TextView) findViewById(R.id.title);
+ dateView = (TextView) findViewById(R.id.date);
+ }
+
+ public void updateFromCursor(final Cursor c) {
+ titleView.setText(getTitleFromCursor(c));
+ dateView.setText(getDateFromCursor(c));
+ }
+
+ private static String getTitleFromCursor(final Cursor c) {
+ final int index = c.getColumnIndexOrThrow(UrlAnnotations.URL);
+ return c.getString(index);
+ }
+
+ private static String getDateFromCursor(final Cursor c) {
+ final long timestamp = c.getLong(c.getColumnIndexOrThrow(UrlAnnotations.DATE_CREATED));
+ final Date date = new Date(timestamp);
+ final StringBuffer sb = new StringBuffer();
+ dateFormat.format(date, sb, dummyFieldPosition)
+ .append(" - ");
+ timeFormat.format(date, sb, dummyFieldPosition);
+ return sb.toString();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java
new file mode 100644
index 000000000..b31116693
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListAdapter.java
@@ -0,0 +1,352 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.home.BookmarkFolderView.FolderState;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.view.View;
+
+/**
+ * Adapter to back the BookmarksListView with a list of bookmarks.
+ */
+class BookmarksListAdapter extends MultiTypeCursorAdapter {
+ private static final int VIEW_TYPE_BOOKMARK_ITEM = 0;
+ private static final int VIEW_TYPE_FOLDER = 1;
+ private static final int VIEW_TYPE_SCREENSHOT = 2;
+
+ private static final int[] VIEW_TYPES = new int[] { VIEW_TYPE_BOOKMARK_ITEM, VIEW_TYPE_FOLDER, VIEW_TYPE_SCREENSHOT };
+ private static final int[] LAYOUT_TYPES =
+ new int[] { R.layout.bookmark_item_row, R.layout.bookmark_folder_row, R.layout.bookmark_screenshot_row };
+
+ public enum RefreshType implements Parcelable {
+ PARENT,
+ CHILD;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<RefreshType> CREATOR = new Creator<RefreshType>() {
+ @Override
+ public RefreshType createFromParcel(final Parcel source) {
+ return RefreshType.values()[source.readInt()];
+ }
+
+ @Override
+ public RefreshType[] newArray(final int size) {
+ return new RefreshType[size];
+ }
+ };
+ }
+
+ public static class FolderInfo implements Parcelable {
+ public final int id;
+ public final String title;
+
+ public FolderInfo(int id) {
+ this(id, "");
+ }
+
+ public FolderInfo(Parcel in) {
+ this(in.readInt(), in.readString());
+ }
+
+ public FolderInfo(int id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(id);
+ dest.writeString(title);
+ }
+
+ public static final Creator<FolderInfo> CREATOR = new Creator<FolderInfo>() {
+ @Override
+ public FolderInfo createFromParcel(Parcel in) {
+ return new FolderInfo(in);
+ }
+
+ @Override
+ public FolderInfo[] newArray(int size) {
+ return new FolderInfo[size];
+ }
+ };
+ }
+
+ // A listener that knows how to refresh the list for a given folder id.
+ // This is usually implemented by the enclosing fragment/activity.
+ public static interface OnRefreshFolderListener {
+ // The folder id to refresh the list with.
+ public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType);
+ }
+
+ /**
+ * The type of data a bookmarks folder can display. This can be used to
+ * distinguish bookmark folders from "smart folders" that contain non-bookmark
+ * entries but still appear in the Bookmarks panel.
+ */
+ public enum FolderType {
+ BOOKMARKS,
+ SCREENSHOTS,
+ }
+
+ // mParentStack holds folder info instances (id + title) that allow
+ // us to navigate back up the folder hierarchy.
+ private LinkedList<FolderInfo> mParentStack;
+
+ // Refresh folder listener.
+ private OnRefreshFolderListener mListener;
+
+ private FolderType openFolderType = FolderType.BOOKMARKS;
+
+ public BookmarksListAdapter(Context context, Cursor cursor, List<FolderInfo> parentStack) {
+ // Initializing with a null cursor.
+ super(context, cursor, VIEW_TYPES, LAYOUT_TYPES);
+
+ if (parentStack == null) {
+ mParentStack = new LinkedList<FolderInfo>();
+ } else {
+ mParentStack = new LinkedList<FolderInfo>(parentStack);
+ }
+ }
+
+ public void restoreData(List<FolderInfo> parentStack) {
+ mParentStack = new LinkedList<FolderInfo>(parentStack);
+ notifyDataSetChanged();
+ }
+
+ public List<FolderInfo> getParentStack() {
+ return Collections.unmodifiableList(mParentStack);
+ }
+
+ public FolderType getOpenFolderType() {
+ return openFolderType;
+ }
+
+ /**
+ * Moves to parent folder, if one exists.
+ *
+ * @return Whether the adapter successfully moved to a parent folder.
+ */
+ public boolean moveToParentFolder() {
+ // If we're already at the root, we can't move to a parent folder.
+ // An empty parent stack here means we're still waiting for the
+ // initial list of bookmarks and can't go to a parent folder.
+ if (mParentStack.size() <= 1) {
+ return false;
+ }
+
+ if (mListener != null) {
+ // We pick the second folder in the stack as it represents
+ // the parent folder.
+ mListener.onRefreshFolder(mParentStack.get(1), RefreshType.PARENT);
+ }
+
+ return true;
+ }
+
+ /**
+ * Moves to child folder, given a folderId.
+ *
+ * @param folderId The id of the folder to show.
+ * @param folderTitle The title of the folder to show.
+ */
+ public void moveToChildFolder(int folderId, String folderTitle) {
+ FolderInfo folderInfo = new FolderInfo(folderId, folderTitle);
+
+ if (mListener != null) {
+ mListener.onRefreshFolder(folderInfo, RefreshType.CHILD);
+ }
+ }
+
+ /**
+ * Set a listener that can refresh this adapter.
+ *
+ * @param listener The listener that can refresh the adapter.
+ */
+ public void setOnRefreshFolderListener(OnRefreshFolderListener listener) {
+ mListener = listener;
+ }
+
+ private boolean isCurrentFolder(FolderInfo folderInfo) {
+ return (mParentStack.size() > 0 &&
+ mParentStack.peek().id == folderInfo.id);
+ }
+
+ public void swapCursor(Cursor c, FolderInfo folderInfo, RefreshType refreshType) {
+ updateOpenFolderType(folderInfo);
+ switch (refreshType) {
+ case PARENT:
+ if (!isCurrentFolder(folderInfo)) {
+ mParentStack.removeFirst();
+ }
+ break;
+
+ case CHILD:
+ if (!isCurrentFolder(folderInfo)) {
+ mParentStack.addFirst(folderInfo);
+ }
+ break;
+
+ default:
+ // Do nothing;
+ }
+
+ swapCursor(c);
+ }
+
+ private void updateOpenFolderType(final FolderInfo folderInfo) {
+ if (folderInfo.id == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) {
+ openFolderType = FolderType.SCREENSHOTS;
+ } else {
+ openFolderType = FolderType.BOOKMARKS;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ // The position also reflects the opened child folder row.
+ if (isShowingChildFolder()) {
+ if (position == 0) {
+ return VIEW_TYPE_FOLDER;
+ }
+
+ // Accounting for the folder view.
+ position--;
+ }
+
+ if (openFolderType == FolderType.SCREENSHOTS) {
+ return VIEW_TYPE_SCREENSHOT;
+ }
+
+ final Cursor c = getCursor(position);
+ if (c.getInt(c.getColumnIndexOrThrow(Bookmarks.TYPE)) == Bookmarks.TYPE_FOLDER) {
+ return VIEW_TYPE_FOLDER;
+ }
+
+ // Default to returning normal item type.
+ return VIEW_TYPE_BOOKMARK_ITEM;
+ }
+
+ /**
+ * Get the title of the folder given a cursor moved to the position.
+ *
+ * @param context The context of the view.
+ * @param cursor A cursor moved to the required position.
+ * @return The title of the folder at the position.
+ */
+ public String getFolderTitle(Context context, Cursor c) {
+ String guid = c.getString(c.getColumnIndexOrThrow(Bookmarks.GUID));
+
+ // If we don't have a special GUID, just return the folder title from the DB.
+ if (guid == null || guid.length() == 12) {
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE));
+ }
+
+ Resources res = context.getResources();
+
+ // Use localized strings for special folder names.
+ if (guid.equals(Bookmarks.FAKE_DESKTOP_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_desktop);
+ } else if (guid.equals(Bookmarks.MENU_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_menu);
+ } else if (guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_toolbar);
+ } else if (guid.equals(Bookmarks.UNFILED_FOLDER_GUID)) {
+ return res.getString(R.string.bookmarks_folder_unfiled);
+ } else if (guid.equals(Bookmarks.SCREENSHOT_FOLDER_GUID)) {
+ return res.getString(R.string.screenshot_folder_label_in_bookmarks);
+ } else if (guid.equals(Bookmarks.FAKE_READINGLIST_SMARTFOLDER_GUID)) {
+ return res.getString(R.string.readinglist_smartfolder_label_in_bookmarks);
+ }
+
+ // If for some reason we have a folder with a special GUID, but it's not one of
+ // the special folders we expect in the UI, just return the title from the DB.
+ return c.getString(c.getColumnIndexOrThrow(Bookmarks.TITLE));
+ }
+
+ /**
+ * @return true, if currently showing a child folder, false otherwise.
+ */
+ public boolean isShowingChildFolder() {
+ if (mParentStack.size() == 0) {
+ return false;
+ }
+
+ return (mParentStack.peek().id != Bookmarks.FIXED_ROOT_ID);
+ }
+
+ @Override
+ public int getCount() {
+ return super.getCount() + (isShowingChildFolder() ? 1 : 0);
+ }
+
+ @Override
+ public void bindView(View view, Context context, int position) {
+ final int viewType = getItemViewType(position);
+
+ final Cursor cursor;
+ if (isShowingChildFolder()) {
+ if (position == 0) {
+ cursor = null;
+ } else {
+ // Accounting for the folder view.
+ position--;
+ cursor = getCursor(position);
+ }
+ } else {
+ cursor = getCursor(position);
+ }
+
+ if (viewType == VIEW_TYPE_SCREENSHOT) {
+ ((BookmarkScreenshotRow) view).updateFromCursor(cursor);
+ } else if (viewType == VIEW_TYPE_BOOKMARK_ITEM) {
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(cursor);
+ } else {
+ final BookmarkFolderView row = (BookmarkFolderView) view;
+ if (cursor == null) {
+ final Resources res = context.getResources();
+ row.update(res.getString(R.string.home_move_back_to_filter, mParentStack.get(1).title), -1);
+ row.setState(FolderState.PARENT);
+ } else {
+ int id = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+
+ row.update(getFolderTitle(context, cursor), id);
+
+ if (id == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ row.setState(FolderState.READING_LIST);
+ } else {
+ row.setState(FolderState.FOLDER);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java
new file mode 100644
index 000000000..94157be10
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksListView.java
@@ -0,0 +1,218 @@
+ /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.EnumSet;
+import java.util.List;
+
+import android.util.Log;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.HeaderViewListAdapter;
+import android.widget.ListAdapter;
+
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.util.NetworkUtils;
+
+/**
+ * A ListView of bookmarks.
+ */
+public class BookmarksListView extends HomeListView
+ implements AdapterView.OnItemClickListener {
+ public static final String LOGTAG = "GeckoBookmarksListView";
+
+ public BookmarksListView(Context context) {
+ this(context, null);
+ }
+
+ public BookmarksListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.bookmarksListViewStyle);
+ }
+
+ public BookmarksListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ setOnItemClickListener(this);
+
+ setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ final int action = event.getAction();
+
+ // If the user hit the BACK key, try to move to the parent folder.
+ if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return getBookmarksListAdapter().moveToParentFolder();
+ }
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Get the appropriate telemetry extra for a given folder.
+ *
+ * baseFolderID is the ID of the first-level folder in the parent stack, i.e. the first folder
+ * that was selected from the root hierarchy (e.g. Desktop, Reading List, or any mobile first-level
+ * subfolder). If the current folder is a first-level folder, then the fixed root ID may be used
+ * instead.
+ *
+ * We use baseFolderID only to distinguish whether or not we're currently in a desktop subfolder.
+ * If it isn't equal to FAKE_DESKTOP_FOLDER_ID we know we're in a mobile subfolder, or one
+ * of the smartfolders.
+ */
+ private String getTelemetryExtraForFolder(int folderID, int baseFolderID) {
+ if (folderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) {
+ return "folder_desktop";
+ } else if (folderID == Bookmarks.FIXED_SCREENSHOT_FOLDER_ID) {
+ return "folder_screenshots";
+ } else if (folderID == Bookmarks.FAKE_READINGLIST_SMARTFOLDER_ID) {
+ return "folder_reading_list";
+ } else {
+ // The stack depth is 2 for either the fake desktop folder, or any subfolder of mobile
+ // bookmarks, we subtract these offsets so that any direct subfolder of mobile
+ // has a level equal to 1. (Desktop folders will be one level deeper due to the
+ // fake desktop folder, hence subtract 2.)
+ if (baseFolderID == Bookmarks.FAKE_DESKTOP_FOLDER_ID) {
+ return "folder_desktop_subfolder";
+ } else {
+ return "folder_mobile_subfolder";
+ }
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final BookmarksListAdapter adapter = getBookmarksListAdapter();
+ if (adapter.isShowingChildFolder()) {
+ if (position == 0) {
+ // If we tap on an opened folder, move back to parent folder.
+
+ final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack();
+ if (parentStack.size() < 2) {
+ throw new IllegalStateException("Cannot move to parent folder if we are already in the root folder");
+ }
+
+ // The first item (top of stack) is the current folder, we're returning to the next one
+ BookmarksListAdapter.FolderInfo folder = parentStack.get(1);
+ final int parentID = folder.id;
+ final int baseFolderID;
+ if (parentStack.size() > 2) {
+ baseFolderID = parentStack.get(parentStack.size() - 2).id;
+ } else {
+ baseFolderID = Bookmarks.FIXED_ROOT_ID;
+ }
+
+ final String extra = getTelemetryExtraForFolder(parentID, baseFolderID);
+
+ // Move to parent _after_ retrieving stack information
+ adapter.moveToParentFolder();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra);
+ return;
+ }
+
+ // Accounting for the folder view.
+ position--;
+ }
+
+ final Cursor cursor = adapter.getCursor();
+ if (cursor == null) {
+ return;
+ }
+
+ cursor.moveToPosition(position);
+
+ if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "bookmarks-screenshot");
+
+ final String fileUrl = "file://" + cursor.getString(cursor.getColumnIndex(BrowserContract.UrlAnnotations.VALUE));
+ getOnUrlOpenListener().onUrlOpen(fileUrl, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ return;
+ }
+
+ int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE));
+ if (type == Bookmarks.TYPE_FOLDER) {
+ // If we're clicking on a folder, update adapter to move to that folder
+ final int folderId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+ final String folderTitle = adapter.getFolderTitle(parent.getContext(), cursor);
+ adapter.moveToChildFolder(folderId, folderTitle);
+
+ final List<BookmarksListAdapter.FolderInfo> parentStack = ((BookmarksListAdapter) getAdapter()).getParentStack();
+
+ final int baseFolderID;
+ if (parentStack.size() > 2) {
+ baseFolderID = parentStack.get(parentStack.size() - 2).id;
+ } else {
+ baseFolderID = Bookmarks.FIXED_ROOT_ID;
+ }
+
+ final String extra = getTelemetryExtraForFolder(folderId, baseFolderID);
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.LIST_ITEM, extra);
+ } else {
+ // Otherwise, just open the URL
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL));
+
+ final SavedReaderViewHelper rvh = SavedReaderViewHelper.getSavedReaderViewHelper(getContext());
+
+ final String extra;
+ if (rvh.isURLCached(url)) {
+ extra = "bookmarks-reader";
+ } else {
+ extra = "bookmarks";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, extra);
+ Telemetry.addToHistogram("FENNEC_LOAD_SAVED_PAGE", NetworkUtils.isConnected(getContext()) ? 2 : 3);
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ getOnUrlOpenListener().onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Adjust the item position to account for the parent folder row that is inserted
+ // at the top of the list when viewing the contents of a folder.
+ final BookmarksListAdapter adapter = getBookmarksListAdapter();
+ if (adapter.isShowingChildFolder()) {
+ position--;
+ }
+
+ // Temporarily prevent crashes until we figure out what we actually want to do here (bug 1252316).
+ if (adapter.getOpenFolderType() == BookmarksListAdapter.FolderType.SCREENSHOTS) {
+ return false;
+ }
+
+ return super.onItemLongClick(parent, view, position, id);
+ }
+
+ private BookmarksListAdapter getBookmarksListAdapter() {
+ BookmarksListAdapter adapter;
+ ListAdapter listAdapter = getAdapter();
+ if (listAdapter instanceof HeaderViewListAdapter) {
+ adapter = (BookmarksListAdapter) ((HeaderViewListAdapter) listAdapter).getWrappedAdapter();
+ } else {
+ adapter = (BookmarksListAdapter) listAdapter;
+ }
+ return adapter;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java
new file mode 100644
index 000000000..4b4781996
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BookmarksPanel.java
@@ -0,0 +1,316 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.home.BookmarksListAdapter.FolderInfo;
+import org.mozilla.gecko.home.BookmarksListAdapter.OnRefreshFolderListener;
+import org.mozilla.gecko.home.BookmarksListAdapter.RefreshType;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.NonNull;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+/**
+ * A page in about:home that displays a ListView of bookmarks.
+ */
+public class BookmarksPanel extends HomeFragment {
+ public static final String LOGTAG = "GeckoBookmarksPanel";
+
+ // Cursor loader ID for list of bookmarks.
+ private static final int LOADER_ID_BOOKMARKS_LIST = 0;
+
+ // Information about the target bookmarks folder.
+ private static final String BOOKMARKS_FOLDER_INFO = "folder_info";
+
+ // Refresh type for folder refreshing loader.
+ private static final String BOOKMARKS_REFRESH_TYPE = "refresh_type";
+
+ // List of bookmarks.
+ private BookmarksListView mList;
+
+ // Adapter for list of bookmarks.
+ private BookmarksListAdapter mListAdapter;
+
+ // Adapter's parent stack.
+ private List<FolderInfo> mSavedParentStack;
+
+ // Reference to the View to display when there are no results.
+ private View mEmptyView;
+
+ // Callback for cursor loaders.
+ private CursorLoaderCallbacks mLoaderCallbacks;
+
+ @Override
+ public void restoreData(@NonNull Bundle data) {
+ final ArrayList<FolderInfo> stack = data.getParcelableArrayList("parentStack");
+ if (stack == null) {
+ return;
+ }
+
+ if (mListAdapter == null) {
+ mSavedParentStack = new LinkedList<FolderInfo>(stack);
+ } else {
+ mListAdapter.restoreData(stack);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.home_bookmarks_panel, container, false);
+
+ mList = (BookmarksListView) view.findViewById(R.id.bookmarks_list);
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final int type = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks.TYPE));
+ if (type == Bookmarks.TYPE_FOLDER) {
+ // We don't show a context menu for folders
+ return null;
+ }
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(Bookmarks.TITLE));
+ info.bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(Bookmarks._ID));
+ info.itemType = RemoveItemType.BOOKMARKS;
+ return info;
+ }
+ });
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ OnUrlOpenListener listener = null;
+ try {
+ listener = (OnUrlOpenListener) getActivity();
+ } catch (ClassCastException e) {
+ throw new ClassCastException(getActivity().toString()
+ + " must implement HomePager.OnUrlOpenListener");
+ }
+
+ mList.setTag(HomePager.LIST_TAG_BOOKMARKS);
+ mList.setOnUrlOpenListener(listener);
+
+ registerForContextMenu(mList);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final Activity activity = getActivity();
+
+ // Setup the list adapter.
+ mListAdapter = new BookmarksListAdapter(activity, null, mSavedParentStack);
+ mListAdapter.setOnRefreshFolderListener(new OnRefreshFolderListener() {
+ @Override
+ public void onRefreshFolder(FolderInfo folderInfo, RefreshType refreshType) {
+ // Restart the loader with folder as the argument.
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(BOOKMARKS_FOLDER_INFO, folderInfo);
+ bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, refreshType);
+ getLoaderManager().restartLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks);
+ }
+ });
+ mList.setAdapter(mListAdapter);
+
+ // Create callbacks before the initial loader is started.
+ mLoaderCallbacks = new CursorLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void onDestroyView() {
+ mList = null;
+ mListAdapter = null;
+ mEmptyView = null;
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ if (isVisible()) {
+ // The parent stack is saved just so that the folder state can be
+ // restored on rotation.
+ mSavedParentStack = mListAdapter.getParentStack();
+ }
+ }
+
+ @Override
+ protected void load() {
+ final Bundle bundle;
+ if (mSavedParentStack != null && mSavedParentStack.size() > 1) {
+ bundle = new Bundle();
+ bundle.putParcelable(BOOKMARKS_FOLDER_INFO, mSavedParentStack.get(0));
+ bundle.putParcelable(BOOKMARKS_REFRESH_TYPE, RefreshType.CHILD);
+ } else {
+ bundle = null;
+ }
+
+ getLoaderManager().initLoader(LOADER_ID_BOOKMARKS_LIST, bundle, mLoaderCallbacks);
+ }
+
+ private void updateUiFromCursor(Cursor c) {
+ if ((c == null || c.getCount() == 0) && mEmptyView == null) {
+ // Set empty page view. We delay this so that the empty view won't flash.
+ final ViewStub emptyViewStub = (ViewStub) getView().findViewById(R.id.home_empty_view_stub);
+ mEmptyView = emptyViewStub.inflate();
+
+ final ImageView emptyIcon = (ImageView) mEmptyView.findViewById(R.id.home_empty_image);
+ emptyIcon.setImageResource(R.drawable.icon_bookmarks_empty);
+
+ final TextView emptyText = (TextView) mEmptyView.findViewById(R.id.home_empty_text);
+ emptyText.setText(R.string.home_bookmarks_empty);
+
+ mList.setEmptyView(mEmptyView);
+ }
+ }
+
+ /**
+ * Loader for the list for bookmarks.
+ */
+ private static class BookmarksLoader extends SimpleCursorLoader {
+ private final FolderInfo mFolderInfo;
+ private final RefreshType mRefreshType;
+ private final BrowserDB mDB;
+
+ public BookmarksLoader(Context context) {
+ this(context,
+ new FolderInfo(Bookmarks.FIXED_ROOT_ID, context.getResources().getString(R.string.bookmarks_title)),
+ RefreshType.CHILD);
+ }
+
+ public BookmarksLoader(Context context, FolderInfo folderInfo, RefreshType refreshType) {
+ super(context);
+ mFolderInfo = folderInfo;
+ mRefreshType = refreshType;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final boolean isRootFolder = mFolderInfo.id == BrowserContract.Bookmarks.FIXED_ROOT_ID;
+
+ final ContentResolver contentResolver = getContext().getContentResolver();
+
+ Cursor partnerCursor = null;
+ Cursor userCursor = null;
+
+ if (GeckoSharedPrefs.forProfile(getContext()).getBoolean(GeckoPreferences.PREFS_READ_PARTNER_BOOKMARKS_PROVIDER, false)
+ && (isRootFolder || mFolderInfo.id <= Bookmarks.FAKE_PARTNER_BOOKMARKS_START)) {
+ partnerCursor = contentResolver.query(PartnerBookmarksProviderProxy.getUriForBookmarks(getContext(), mFolderInfo.id), null, null, null, null, null);
+ }
+
+ if (isRootFolder || mFolderInfo.id > Bookmarks.FAKE_PARTNER_BOOKMARKS_START) {
+ userCursor = mDB.getBookmarksInFolder(contentResolver, mFolderInfo.id);
+ }
+
+
+ if (partnerCursor == null && userCursor == null) {
+ return null;
+ } else if (partnerCursor == null) {
+ return userCursor;
+ } else if (userCursor == null) {
+ return partnerCursor;
+ } else {
+ return new MergeCursor(new Cursor[] { partnerCursor, userCursor });
+ }
+ }
+
+ @Override
+ public void onContentChanged() {
+ // Invalidate the cached value that keeps track of whether or
+ // not desktop bookmarks exist.
+ mDB.invalidate();
+ super.onContentChanged();
+ }
+
+ public FolderInfo getFolderInfo() {
+ return mFolderInfo;
+ }
+
+ public RefreshType getRefreshType() {
+ return mRefreshType;
+ }
+ }
+
+ /**
+ * Loader callbacks for the LoaderManager of this fragment.
+ */
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (args == null) {
+ return new BookmarksLoader(getActivity());
+ } else {
+ FolderInfo folderInfo = (FolderInfo) args.getParcelable(BOOKMARKS_FOLDER_INFO);
+ RefreshType refreshType = (RefreshType) args.getParcelable(BOOKMARKS_REFRESH_TYPE);
+ return new BookmarksLoader(getActivity(), folderInfo, refreshType);
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ BookmarksLoader bl = (BookmarksLoader) loader;
+ mListAdapter.swapCursor(c, bl.getFolderInfo(), bl.getRefreshType());
+
+ if (mPanelStateChangeListener != null) {
+ final List<FolderInfo> parentStack = mListAdapter.getParentStack();
+ final Bundle bundle = new Bundle();
+
+ // Bundle likes to store ArrayLists or Arrays, but we've got a generic List (which
+ // is actually an unmodifiable wrapper around a LinkedList). We'll need to do a
+ // LinkedList conversion at the other end, when saving we need to use this awkward
+ // syntax to create an Array.
+ bundle.putParcelableArrayList("parentStack", new ArrayList<FolderInfo>(parentStack));
+
+ mPanelStateChangeListener.onStateChanged(bundle);
+ }
+ updateUiFromCursor(c);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mList != null) {
+ mListAdapter.swapCursor(null);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
new file mode 100644
index 000000000..7732932fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/BrowserSearch.java
@@ -0,0 +1,1316 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+
+import android.content.SharedPreferences;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SuggestClient;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.History;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.SearchLoader.SearchCursorLoader;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.toolbar.AutocompleteHandler;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.AsyncTaskLoader;
+import android.support.v4.content.Loader;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.view.WindowManager.LayoutParams;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.widget.AdapterView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+
+/**
+ * Fragment that displays frecency search results in a ListView.
+ */
+public class BrowserSearch extends HomeFragment
+ implements GeckoEventListener,
+ SearchEngineBar.OnSearchBarClickListener {
+
+ @RobocopTarget
+ public interface SuggestClientFactory {
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max);
+ }
+
+ @RobocopTarget
+ public static class DefaultSuggestClientFactory implements SuggestClientFactory {
+ @Override
+ public SuggestClient getSuggestClient(Context context, String template, int timeout, int max) {
+ return new SuggestClient(context, template, timeout, max, true);
+ }
+ }
+
+ /**
+ * Set this to mock the suggestion mechanism. Public for access from tests.
+ */
+ @RobocopTarget
+ public static volatile SuggestClientFactory sSuggestClientFactory = new DefaultSuggestClientFactory();
+
+ // Logging tag name
+ private static final String LOGTAG = "GeckoBrowserSearch";
+
+ // Cursor loader ID for search query
+ private static final int LOADER_ID_SEARCH = 0;
+
+ // AsyncTask loader ID for suggestion query
+ private static final int LOADER_ID_SUGGESTION = 1;
+ private static final int LOADER_ID_SAVED_SUGGESTION = 2;
+
+ // Timeout for the suggestion client to respond
+ private static final int SUGGESTION_TIMEOUT = 3000;
+
+ // Maximum number of suggestions from the search engine's suggestion client. This impacts network traffic and device
+ // data consumption whereas R.integer.max_saved_suggestions controls how many suggestion to show in the UI.
+ private static final int NETWORK_SUGGESTION_MAX = 3;
+
+ // The maximum number of rows deep in a search we'll dig
+ // for an autocomplete result
+ private static final int MAX_AUTOCOMPLETE_SEARCH = 20;
+
+ // Length of https:// + 1 required to make autocomplete
+ // fill in the domain, for both http:// and https://
+ private static final int HTTPS_PREFIX_LENGTH = 9;
+
+ // Duration for fade-in animation
+ private static final int ANIMATION_DURATION = 250;
+
+ // Holds the current search term to use in the query
+ private volatile String mSearchTerm;
+
+ // Adapter for the list of search results
+ private SearchAdapter mAdapter;
+
+ // The view shown by the fragment
+ private LinearLayout mView;
+
+ // The list showing search results
+ private HomeListView mList;
+
+ // The bar on the bottom of the screen displaying search engine options.
+ private SearchEngineBar mSearchEngineBar;
+
+ // Client that performs search suggestion queries.
+ // Public for testing.
+ @RobocopTarget
+ public volatile SuggestClient mSuggestClient;
+
+ // List of search engines from Gecko.
+ // Do not mutate this list.
+ // Access to this member must only occur from the UI thread.
+ private List<SearchEngine> mSearchEngines;
+
+ // Search history suggestions
+ private ArrayList<String> mSearchHistorySuggestions;
+
+ // Track the locale that was last in use when we filled mSearchEngines.
+ // Access to this member must only occur from the UI thread.
+ private Locale mLastLocale;
+
+ // Whether search suggestions are enabled or not
+ private boolean mSuggestionsEnabled;
+
+ // Whether history suggestions are enabled or not
+ private boolean mSavedSearchesEnabled;
+
+ // Callbacks used for the search loader
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ // Callbacks used for the search suggestion loader
+ private SearchEngineSuggestionLoaderCallbacks mSearchEngineSuggestionLoaderCallbacks;
+ private SearchHistorySuggestionLoaderCallbacks mSearchHistorySuggestionLoaderCallback;
+
+ // Autocomplete handler used when filtering results
+ private AutocompleteHandler mAutocompleteHandler;
+
+ // On search listener
+ private OnSearchListener mSearchListener;
+
+ // On edit suggestion listener
+ private OnEditSuggestionListener mEditSuggestionListener;
+
+ // Whether the suggestions will fade in when shown
+ private boolean mAnimateSuggestions;
+
+ // Opt-in prompt view for search suggestions
+ private View mSuggestionsOptInPrompt;
+
+ public interface OnSearchListener {
+ void onSearch(SearchEngine engine, String text, TelemetryContract.Method method);
+ }
+
+ public interface OnEditSuggestionListener {
+ public void onEditSuggestion(String suggestion);
+ }
+
+ public static BrowserSearch newInstance() {
+ BrowserSearch browserSearch = new BrowserSearch();
+
+ final Bundle args = new Bundle();
+ args.putBoolean(HomePager.CAN_LOAD_ARG, true);
+ browserSearch.setArguments(args);
+
+ return browserSearch;
+ }
+
+ public BrowserSearch() {
+ mSearchTerm = "";
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mSearchListener = (OnSearchListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement BrowserSearch.OnSearchListener");
+ }
+
+ try {
+ mEditSuggestionListener = (OnEditSuggestionListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement BrowserSearch.OnEditSuggestionListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+
+ mAutocompleteHandler = null;
+ mSearchListener = null;
+ mEditSuggestionListener = null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mSearchEngines = new ArrayList<SearchEngine>();
+ mSearchHistorySuggestions = new ArrayList<>();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ mSearchEngines = null;
+ }
+
+ @Override
+ public void onHiddenChanged(boolean hidden) {
+ if (!hidden) {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Removes Search Suggestions Loader if in private browsing mode
+ // Loader may have been inserted when browsing in normal tab
+ if (isPrivate) {
+ getLoaderManager().destroyLoader(LOADER_ID_SUGGESTION);
+ }
+
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ }
+ super.onHiddenChanged(hidden);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ mSavedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true);
+
+ // Fetch engines if we need to.
+ if (mSearchEngines.isEmpty() || !Locale.getDefault().equals(mLastLocale)) {
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ } else {
+ updateSearchEngineBar();
+ }
+
+ Telemetry.startUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ Telemetry.stopUISession(TelemetryContract.Session.FRECENCY);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // All list views are styled to look the same with a global activity theme.
+ // If the style of the list changes, inflate it from an XML.
+ mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
+ mList = (HomeListView) mView.findViewById(R.id.home_list_view);
+ mSearchEngineBar = (SearchEngineBar) mView.findViewById(R.id.search_engine_bar);
+
+ return mView;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setAdapter(null);
+ mSearchEngineBar = null;
+
+ mList.setAdapter(null);
+ mList = null;
+
+ mView = null;
+ mSuggestionsOptInPrompt = null;
+ mSuggestClient = null;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ mList.setTag(HomePager.LIST_TAG_BROWSER_SEARCH);
+
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // Perform the user-entered search if the user clicks on a search engine row.
+ // This row will be disabled if suggestions (in addition to the user-entered term) are showing.
+ if (view instanceof SearchEngineRow) {
+ ((SearchEngineRow) view).performUserEnteredSearch();
+ return;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ final Cursor c = mAdapter.getCursor(position);
+ final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "frecency");
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ });
+
+ mList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Don't do anything when the user long-clicks on a search engine row.
+ if (view instanceof SearchEngineRow) {
+ return true;
+ }
+
+ // Account for the search engine rows.
+ position -= getPrimaryEngineCount();
+ return mList.onItemLongClick(parent, view, position, id);
+ }
+ });
+
+ final ListSelectionListener listener = new ListSelectionListener();
+ mList.setOnItemSelectedListener(listener);
+ mList.setOnFocusChangeListener(listener);
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+
+ int bookmarkId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+ info.bookmarkId = bookmarkId;
+
+ int historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ info.historyId = historyId;
+
+ boolean isBookmark = bookmarkId != -1;
+ boolean isHistory = historyId != -1;
+
+ if (isBookmark && isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.COMBINED;
+ } else if (isBookmark) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.BOOKMARKS;
+ } else if (isHistory) {
+ info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
+ }
+
+ return info;
+ }
+ });
+
+ mList.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, android.view.KeyEvent event) {
+ final View selected = mList.getSelectedView();
+
+ if (selected instanceof SearchEngineRow) {
+ return selected.onKeyDown(keyCode, event);
+ }
+ return false;
+ }
+ });
+
+ registerForContextMenu(mList);
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "SearchEngines:Data");
+
+ mSearchEngineBar.setOnSearchBarClickListener(this);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return;
+ }
+
+ HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+
+ MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.browsersearch_contextmenu, menu);
+
+ menu.setHeaderTitle(info.getDisplayTitle());
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return false;
+ }
+
+ final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+ final Context context = getActivity();
+
+ final int itemId = item.getItemId();
+
+ if (itemId == R.id.browsersearch_remove) {
+ // Position for Top Sites grid items, but will always be -1 since this is only for BrowserSearch result
+ final int position = -1;
+
+ new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ // Initialize the search adapter
+ mAdapter = new SearchAdapter(getActivity());
+ mList.setAdapter(mAdapter);
+
+ // Only create an instance when we need it
+ mSearchEngineSuggestionLoaderCallbacks = null;
+ mSearchHistorySuggestionLoaderCallback = null;
+
+ // Create callbacks before the initial loader is started
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void handleMessage(String event, final JSONObject message) {
+ if (event.equals("SearchEngines:Data")) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setSearchEngines(message);
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void load() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void handleAutocomplete(String searchTerm, Cursor c) {
+ if (c == null ||
+ mAutocompleteHandler == null ||
+ TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ // Avoid searching the path if we don't have to. Currently just
+ // decided by whether there is a '/' character in the string.
+ final boolean searchPath = searchTerm.indexOf('/') > 0;
+ final String autocompletion = findAutocompletion(searchTerm, c, searchPath);
+
+ if (autocompletion == null || mAutocompleteHandler == null) {
+ return;
+ }
+
+ // Prefetch auto-completed domain since it's a likely target
+ GeckoAppShell.notifyObservers("Session:Prefetch", "http://" + autocompletion);
+
+ mAutocompleteHandler.onAutocomplete(autocompletion);
+ mAutocompleteHandler = null;
+ }
+
+ /**
+ * Returns the substring of a provided URI, starting at the given offset,
+ * and extending up to the end of the path segment in which the provided
+ * index is found.
+ *
+ * For example, given
+ *
+ * "www.reddit.com/r/boop/abcdef", 0, ?
+ *
+ * this method returns
+ *
+ * ?=2: "www.reddit.com/"
+ * ?=17: "www.reddit.com/r/boop/"
+ * ?=21: "www.reddit.com/r/boop/"
+ * ?=22: "www.reddit.com/r/boop/abcdef"
+ *
+ */
+ private static String uriSubstringUpToMatchedPath(final String url, final int offset, final int begin) {
+ final int afterEnd = url.length();
+
+ // We want to include the trailing slash, but not other characters.
+ int chop = url.indexOf('/', begin);
+ if (chop != -1) {
+ ++chop;
+ if (chop < offset) {
+ // This isn't supposed to happen. Fall back to returning the whole damn thing.
+ return url;
+ }
+ } else {
+ chop = url.indexOf('?', begin);
+ if (chop == -1) {
+ chop = url.indexOf('#', begin);
+ }
+ if (chop == -1) {
+ chop = afterEnd;
+ }
+ }
+
+ return url.substring(offset, chop);
+ }
+
+ LinkedHashSet<String> domains = null;
+ private LinkedHashSet<String> getDomains() {
+ if (domains == null) {
+ domains = new LinkedHashSet<String>(500);
+ BufferedReader buf = null;
+ try {
+ buf = new BufferedReader(new InputStreamReader(getResources().openRawResource(R.raw.topdomains)));
+ String res = null;
+
+ do {
+ res = buf.readLine();
+ if (res != null) {
+ domains.add(res);
+ }
+ } while (res != null);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error reading domains", e);
+ } finally {
+ if (buf != null) {
+ try {
+ buf.close();
+ } catch (IOException e) { }
+ }
+ }
+ }
+ return domains;
+ }
+
+ private String searchDomains(String search) {
+ for (String domain : getDomains()) {
+ if (domain.startsWith(search)) {
+ return domain;
+ }
+ }
+ return null;
+ }
+
+ private String findAutocompletion(String searchTerm, Cursor c, boolean searchPath) {
+ if (!c.moveToFirst()) {
+ // No cursor probably means no history, so let's try the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ final int searchLength = searchTerm.length();
+ final int urlIndex = c.getColumnIndexOrThrow(History.URL);
+ int searchCount = 0;
+
+ do {
+ final String url = c.getString(urlIndex);
+
+ if (searchCount == 0) {
+ // Prefetch the first item in the list since it's weighted the highest
+ GeckoAppShell.notifyObservers("Session:Prefetch", url);
+ }
+
+ // Does the completion match against the whole URL? This will match
+ // about: pages, as well as user input including "http://...".
+ if (url.startsWith(searchTerm)) {
+ return uriSubstringUpToMatchedPath(url, 0,
+ (searchLength > HTTPS_PREFIX_LENGTH) ? searchLength : HTTPS_PREFIX_LENGTH);
+ }
+
+ final Uri uri = Uri.parse(url);
+ final String host = uri.getHost();
+
+ // Host may be null for about pages.
+ if (host == null) {
+ continue;
+ }
+
+ if (host.startsWith(searchTerm)) {
+ return host + "/";
+ }
+
+ final String strippedHost = StringUtils.stripCommonSubdomains(host);
+ if (strippedHost.startsWith(searchTerm)) {
+ return strippedHost + "/";
+ }
+
+ ++searchCount;
+
+ if (!searchPath) {
+ continue;
+ }
+
+ // Otherwise, if we're matching paths, let's compare against the string itself.
+ final int hostOffset = url.indexOf(strippedHost);
+ if (hostOffset == -1) {
+ // This was a URL string that parsed to a different host (normalized?).
+ // Give up.
+ continue;
+ }
+
+ // We already matched the non-stripped host, so now we're
+ // substring-searching in the part of the URL without the common
+ // subdomains.
+ if (url.startsWith(searchTerm, hostOffset)) {
+ // Great! Return including the rest of the path segment.
+ return uriSubstringUpToMatchedPath(url, hostOffset, hostOffset + searchLength);
+ }
+ } while (searchCount < MAX_AUTOCOMPLETE_SEARCH && c.moveToNext());
+
+ // If we can't find an autocompletion domain from history, let's try using the fallback list.
+ return searchDomains(searchTerm);
+ }
+
+ public void resetScrollState() {
+ mSearchEngineBar.scrollToPosition(0);
+ }
+
+ private void filterSuggestions() {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // mSuggestClient may be null if we haven't received our search engine list yet - hence
+ // we need to exit here in that case.
+ if (isPrivate || mSuggestClient == null || (!mSuggestionsEnabled && !mSavedSearchesEnabled)) {
+ mSearchHistorySuggestions.clear();
+ return;
+ }
+
+ // Suggestions from search engine
+ if (mSearchEngineSuggestionLoaderCallbacks == null) {
+ mSearchEngineSuggestionLoaderCallbacks = new SearchEngineSuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SUGGESTION, null, mSearchEngineSuggestionLoaderCallbacks);
+
+ // Saved suggestions
+ if (mSearchHistorySuggestionLoaderCallback == null) {
+ mSearchHistorySuggestionLoaderCallback = new SearchHistorySuggestionLoaderCallbacks();
+ }
+ getLoaderManager().restartLoader(LOADER_ID_SAVED_SUGGESTION, null, mSearchHistorySuggestionLoaderCallback);
+ }
+
+ private void setSuggestions(ArrayList<String> suggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ // mSearchEngines may be null if the setSuggestions calls after onDestroy (bug 1310621).
+ // So drop the suggestions if search engines are not available
+ if (mSearchEngines != null && !mSearchEngines.isEmpty()) {
+ mSearchEngines.get(0).setSuggestions(suggestions);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ }
+
+ private void setSavedSuggestions(ArrayList<String> savedSuggestions) {
+ ThreadUtils.assertOnUiThread();
+
+ mSearchHistorySuggestions = savedSuggestions;
+ mAdapter.notifyDataSetChanged();
+ }
+
+ private boolean shouldUpdateSearchEngine(ArrayList<SearchEngine> searchEngines) {
+ if (searchEngines.size() != mSearchEngines.size()) {
+ return true;
+ }
+
+ int size = searchEngines.size();
+
+ for (int i = 0; i < size; i++) {
+ if (!mSearchEngines.get(i).name.equals(searchEngines.get(i).name)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void setSearchEngines(JSONObject data) {
+ ThreadUtils.assertOnUiThread();
+
+ // This method is called via a Runnable posted from the Gecko thread, so
+ // it's possible the fragment and/or its view has been destroyed by the
+ // time we get here. If so, just abort.
+ if (mView == null) {
+ return;
+ }
+
+ try {
+ final JSONObject suggest = data.getJSONObject("suggest");
+ final String suggestEngine = suggest.optString("engine", null);
+ final String suggestTemplate = suggest.optString("template", null);
+ final boolean suggestionsPrompted = suggest.getBoolean("prompted");
+ final JSONArray engines = data.getJSONArray("searchEngines");
+
+ mSuggestionsEnabled = suggest.getBoolean("enabled");
+
+ ArrayList<SearchEngine> searchEngines = new ArrayList<SearchEngine>();
+ for (int i = 0; i < engines.length(); i++) {
+ final JSONObject engineJSON = engines.getJSONObject(i);
+ final SearchEngine engine = new SearchEngine((Context) getActivity(), engineJSON);
+
+ if (engine.name.equals(suggestEngine) && suggestTemplate != null) {
+ // Suggest engine should be at the front of the list.
+ // We're baking in an assumption here that the suggest engine
+ // is also the default engine.
+ searchEngines.add(0, engine);
+
+ ensureSuggestClientIsSet(suggestTemplate);
+ } else {
+ searchEngines.add(engine);
+ }
+ }
+
+ // checking if the new searchEngine is different from mSearchEngine, will have to re-layout if yes
+ boolean change = shouldUpdateSearchEngine(searchEngines);
+
+ if (mAdapter != null && change) {
+ mSearchEngines = Collections.unmodifiableList(searchEngines);
+ mLastLocale = Locale.getDefault();
+ updateSearchEngineBar();
+
+ mAdapter.notifyDataSetChanged();
+ }
+
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (tab != null && tab.isPrivate());
+
+ // Show suggestions opt-in prompt only if suggestions are not enabled yet,
+ // user hasn't been prompted and we're not on a private browsing tab.
+ // The prompt might have been inflated already when this view was previously called.
+ // Remove the opt-in prompt if it has been inflated in the view and dealt with by the user,
+ // or if we're on a private browsing tab
+ if (!mSuggestionsEnabled && !suggestionsPrompted && !isPrivate) {
+ showSuggestionsOptIn();
+ } else {
+ removeSuggestionsOptIn();
+ }
+
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error getting search engine JSON", e);
+ }
+
+ filterSuggestions();
+ }
+
+ private void updateSearchEngineBar() {
+ final int primaryEngineCount = getPrimaryEngineCount();
+
+ if (primaryEngineCount < mSearchEngines.size()) {
+ mSearchEngineBar.setSearchEngines(
+ mSearchEngines.subList(primaryEngineCount, mSearchEngines.size())
+ );
+ mSearchEngineBar.setVisibility(View.VISIBLE);
+ } else {
+ mSearchEngineBar.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onSearchBarClickListener(final SearchEngine searchEngine) {
+ final TelemetryContract.Method method = TelemetryContract.Method.LIST_ITEM;
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, "searchenginebar");
+ mSearchListener.onSearch(searchEngine, mSearchTerm, method);
+ }
+
+ private void ensureSuggestClientIsSet(final String suggestTemplate) {
+ // Don't update the suggestClient if we already have a client with the correct template
+ if (mSuggestClient != null && suggestTemplate.equals(mSuggestClient.getSuggestTemplate())) {
+ return;
+ }
+
+ mSuggestClient = sSuggestClientFactory.getSuggestClient(getActivity(), suggestTemplate, SUGGESTION_TIMEOUT, NETWORK_SUGGESTION_MAX);
+ }
+
+ private void showSuggestionsOptIn() {
+ // Only make the ViewStub visible again if it has already previously been shown.
+ // (An inflated ViewStub is removed from the View hierarchy so a second call to findViewById will return null,
+ // which also further necessitates handling this separately.)
+ if (mSuggestionsOptInPrompt != null) {
+ mSuggestionsOptInPrompt.setVisibility(View.VISIBLE);
+ return;
+ }
+
+ mSuggestionsOptInPrompt = ((ViewStub) mView.findViewById(R.id.suggestions_opt_in_prompt)).inflate();
+
+ TextView promptText = (TextView) mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_title);
+ promptText.setText(getResources().getString(R.string.suggestions_prompt));
+
+ final View yesButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_yes);
+ final View noButton = mSuggestionsOptInPrompt.findViewById(R.id.suggestions_prompt_no);
+
+ final OnClickListener listener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Prevent the buttons from being clicked multiple times (bug 816902)
+ yesButton.setOnClickListener(null);
+ noButton.setOnClickListener(null);
+
+ setSuggestionsEnabled(v == yesButton);
+ }
+ };
+
+ yesButton.setOnClickListener(listener);
+ noButton.setOnClickListener(listener);
+
+ // If the prompt gains focus, automatically pass focus to the
+ // yes button in the prompt.
+ final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+ prompt.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ yesButton.requestFocus();
+ }
+ }
+ });
+ }
+
+ private void removeSuggestionsOptIn() {
+ if (mSuggestionsOptInPrompt == null) {
+ return;
+ }
+
+ mSuggestionsOptInPrompt.setVisibility(View.GONE);
+ }
+
+ private void setSuggestionsEnabled(final boolean enabled) {
+ // Clicking the yes/no buttons quickly can cause the click events be
+ // queued before the listeners are removed above, so it's possible
+ // setSuggestionsEnabled() can be called twice. mSuggestionsOptInPrompt
+ // can be null if this happens (bug 828480).
+ if (mSuggestionsOptInPrompt == null) {
+ return;
+ }
+
+ // Make suggestions appear immediately after the user opts in
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ SuggestClient client = mSuggestClient;
+ if (client != null) {
+ client.query(mSearchTerm);
+ }
+ }
+ });
+
+ PrefsHelper.setPref("browser.search.suggest.prompted", true);
+ PrefsHelper.setPref("browser.search.suggest.enabled", enabled);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, (enabled ? "suggestions_optin_yes" : "suggestions_optin_no"));
+
+ TranslateAnimation slideAnimation = new TranslateAnimation(0, mSuggestionsOptInPrompt.getWidth(), 0, 0);
+ slideAnimation.setDuration(ANIMATION_DURATION);
+ slideAnimation.setInterpolator(new AccelerateInterpolator());
+ slideAnimation.setFillAfter(true);
+ final View prompt = mSuggestionsOptInPrompt.findViewById(R.id.prompt);
+
+ TranslateAnimation shrinkAnimation = new TranslateAnimation(0, 0, 0, -1 * mSuggestionsOptInPrompt.getHeight());
+ shrinkAnimation.setDuration(ANIMATION_DURATION);
+ shrinkAnimation.setFillAfter(true);
+ shrinkAnimation.setStartOffset(slideAnimation.getDuration());
+ shrinkAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation a) {
+ // Increase the height of the view so a gap isn't shown during animation
+ mView.getLayoutParams().height = mView.getHeight() +
+ mSuggestionsOptInPrompt.getHeight();
+ mView.requestLayout();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation a) {}
+
+ @Override
+ public void onAnimationEnd(Animation a) {
+ // Removing the view immediately results in a NPE in
+ // dispatchDraw(), possibly because this callback executes
+ // before drawing is finished. Posting this as a Runnable fixes
+ // the issue.
+ mView.post(new Runnable() {
+ @Override
+ public void run() {
+ mView.removeView(mSuggestionsOptInPrompt);
+ mList.clearAnimation();
+ mSuggestionsOptInPrompt = null;
+
+ // Reset the view height
+ mView.getLayoutParams().height = LayoutParams.MATCH_PARENT;
+
+ // Show search suggestions and update them
+ if (enabled) {
+ mSuggestionsEnabled = enabled;
+ mAnimateSuggestions = true;
+ mAdapter.notifyDataSetChanged();
+ filterSuggestions();
+ }
+ }
+ });
+ }
+ });
+
+ prompt.startAnimation(slideAnimation);
+ mSuggestionsOptInPrompt.startAnimation(shrinkAnimation);
+ mList.startAnimation(shrinkAnimation);
+ }
+
+ private int getPrimaryEngineCount() {
+ return mSearchEngines.size() > 0 ? 1 : 0;
+ }
+
+ private void restartSearchLoader() {
+ SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ private void initSearchLoader() {
+ SearchLoader.init(getLoaderManager(), LOADER_ID_SEARCH, mCursorLoaderCallbacks, mSearchTerm);
+ }
+
+ public void filter(String searchTerm, AutocompleteHandler handler) {
+ if (TextUtils.isEmpty(searchTerm)) {
+ return;
+ }
+
+ final boolean isNewFilter = !TextUtils.equals(mSearchTerm, searchTerm);
+
+ mSearchTerm = searchTerm;
+ mAutocompleteHandler = handler;
+
+ if (mAdapter != null) {
+ if (isNewFilter) {
+ // The adapter depends on the search term to determine its number
+ // of items. Make it we notify the view about it.
+ mAdapter.notifyDataSetChanged();
+
+ // Restart loaders with the new search term
+ restartSearchLoader();
+ filterSuggestions();
+ } else {
+ // The search term hasn't changed, simply reuse any existing
+ // loader for the current search term. This will ensure autocompletion
+ // is consistently triggered (see bug 933739).
+ initSearchLoader();
+ }
+ }
+ }
+
+ abstract private static class SuggestionAsyncLoader extends AsyncTaskLoader<ArrayList<String>> {
+ protected final String mSearchTerm;
+ private ArrayList<String> mSuggestions;
+
+ public SuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context);
+ mSearchTerm = searchTerm;
+ }
+
+ @Override
+ public void deliverResult(ArrayList<String> suggestions) {
+ mSuggestions = suggestions;
+
+ if (isStarted()) {
+ super.deliverResult(mSuggestions);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mSuggestions != null) {
+ deliverResult(mSuggestions);
+ }
+
+ if (takeContentChanged() || mSuggestions == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ onStopLoading();
+ mSuggestions = null;
+ }
+ }
+
+ private static class SearchEngineSuggestionAsyncLoader extends SuggestionAsyncLoader {
+ private final SuggestClient mSuggestClient;
+
+ public SearchEngineSuggestionAsyncLoader(Context context, SuggestClient suggestClient, String searchTerm) {
+ super(context, searchTerm);
+ mSuggestClient = suggestClient;
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ return mSuggestClient.query(mSearchTerm);
+ }
+ }
+
+ private static class SearchHistorySuggestionAsyncLoader extends SuggestionAsyncLoader {
+ public SearchHistorySuggestionAsyncLoader(Context context, String searchTerm) {
+ super(context, searchTerm);
+ }
+
+ @Override
+ public ArrayList<String> loadInBackground() {
+ final ContentResolver cr = getContext().getContentResolver();
+
+ String[] columns = new String[] { BrowserContract.SearchHistory.QUERY };
+ String actualQuery = BrowserContract.SearchHistory.QUERY + " LIKE ?";
+ String[] queryArgs = new String[] { '%' + mSearchTerm + '%' };
+
+ // For deduplication, the worst case is that all the first NETWORK_SUGGESTION_MAX history suggestions are duplicates
+ // of search engine suggestions, and the there is a duplicate for the search term itself. A duplicate of the
+ // search term can occur if the user has previously searched for the same thing.
+ final int maxSavedSuggestions = NETWORK_SUGGESTION_MAX + 1 + getContext().getResources().getInteger(R.integer.max_saved_suggestions);
+
+ final String sortOrderAndLimit = BrowserContract.SearchHistory.DATE + " DESC LIMIT " + maxSavedSuggestions;
+ final Cursor result = cr.query(BrowserContract.SearchHistory.CONTENT_URI, columns, actualQuery, queryArgs, sortOrderAndLimit);
+
+ if (result == null) {
+ return new ArrayList<>();
+ }
+
+ final ArrayList<String> savedSuggestions = new ArrayList<>();
+ try {
+ if (result.moveToFirst()) {
+ final int searchColumn = result.getColumnIndexOrThrow(BrowserContract.SearchHistory.QUERY);
+ do {
+ final String savedSearch = result.getString(searchColumn);
+ savedSuggestions.add(savedSearch);
+ } while (result.moveToNext());
+ }
+ } finally {
+ result.close();
+ }
+
+ return savedSuggestions;
+ }
+ }
+
+ private class SearchAdapter extends MultiTypeCursorAdapter {
+ private static final int ROW_SEARCH = 0;
+ private static final int ROW_STANDARD = 1;
+ private static final int ROW_SUGGEST = 2;
+
+ public SearchAdapter(Context context) {
+ super(context, null, new int[] { ROW_STANDARD,
+ ROW_SEARCH,
+ ROW_SUGGEST },
+ new int[] { R.layout.home_item_row,
+ R.layout.home_search_item_row,
+ R.layout.home_search_item_row });
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position < getPrimaryEngineCount()) {
+ if (mSuggestionsEnabled && mSearchEngines.get(position).hasSuggestions()) {
+ // Give suggestion views their own type to prevent them from
+ // sharing other recycled search result views. Using other
+ // recycled views for the suggestion row can break animations
+ // (bug 815937).
+
+ return ROW_SUGGEST;
+ } else {
+ return ROW_SEARCH;
+ }
+ }
+
+ return ROW_STANDARD;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ // If we're using a gamepad or keyboard, allow the row to be
+ // focused so it can pass the focus to its child suggestion views.
+ if (!mList.isInTouchMode()) {
+ return true;
+ }
+
+ // If the suggestion row only contains one item (the user-entered
+ // query), allow the entire row to be clickable; clicking the row
+ // has the same effect as clicking the single suggestion. If the
+ // row contains multiple items, clicking the row will do nothing.
+
+ if (position < getPrimaryEngineCount()) {
+ return !mSearchEngines.get(position).hasSuggestions();
+ }
+
+ return true;
+ }
+
+ // Add the search engines to the number of reported results.
+ @Override
+ public int getCount() {
+ final int resultCount = super.getCount();
+
+ // Don't show search engines or suggestions if search field is empty
+ if (TextUtils.isEmpty(mSearchTerm)) {
+ return resultCount;
+ }
+
+ return resultCount + getPrimaryEngineCount();
+ }
+
+ @Override
+ public void bindView(View view, Context context, int position) {
+ final int type = getItemViewType(position);
+
+ if (type == ROW_SEARCH || type == ROW_SUGGEST) {
+ final SearchEngineRow row = (SearchEngineRow) view;
+ row.setOnUrlOpenListener(mUrlOpenListener);
+ row.setOnSearchListener(mSearchListener);
+ row.setOnEditSuggestionListener(mEditSuggestionListener);
+ row.setSearchTerm(mSearchTerm);
+
+ final SearchEngine engine = mSearchEngines.get(position);
+ final boolean haveSuggestions = (engine.hasSuggestions() || !mSearchHistorySuggestions.isEmpty());
+ final boolean animate = (mAnimateSuggestions && haveSuggestions);
+ row.updateSuggestions(mSuggestionsEnabled, engine, mSearchHistorySuggestions, animate);
+ if (animate) {
+ // Only animate suggestions the first time they are shown
+ mAnimateSuggestions = false;
+ }
+ } else {
+ // Account for the search engines
+ position -= getPrimaryEngineCount();
+
+ final Cursor c = getCursor(position);
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(c);
+ }
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ return SearchLoader.createInstance(getActivity(), args);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(c);
+
+ // We should handle autocompletion based on the search term
+ // associated with the loader that has just provided
+ // the results.
+ SearchCursorLoader searchLoader = (SearchCursorLoader) loader;
+ handleAutocomplete(searchLoader.getSearchTerm(), c);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(null);
+ }
+ }
+ }
+
+ private class SearchEngineSuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
+ @Override
+ public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+ // mSuggestClient is set to null in onDestroyView(), so using it
+ // safely here relies on the fact that onCreateLoader() is called
+ // synchronously in restartLoader().
+ return new SearchEngineSuggestionAsyncLoader(getActivity(), mSuggestClient, mSearchTerm);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+ setSuggestions(suggestions);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<ArrayList<String>> loader) {
+ setSuggestions(new ArrayList<String>());
+ }
+ }
+
+ private class SearchHistorySuggestionLoaderCallbacks implements LoaderCallbacks<ArrayList<String>> {
+ @Override
+ public Loader<ArrayList<String>> onCreateLoader(int id, Bundle args) {
+ // mSuggestClient is set to null in onDestroyView(), so using it
+ // safely here relies on the fact that onCreateLoader() is called
+ // synchronously in restartLoader().
+ return new SearchHistorySuggestionAsyncLoader(getActivity(), mSearchTerm);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<ArrayList<String>> loader, ArrayList<String> suggestions) {
+ setSavedSuggestions(suggestions);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<ArrayList<String>> loader) {
+ setSavedSuggestions(new ArrayList<String>());
+ }
+ }
+
+ private static class ListSelectionListener implements View.OnFocusChangeListener,
+ AdapterView.OnItemSelectedListener {
+ private SearchEngineRow mSelectedEngineRow;
+
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ View selectedRow = ((ListView) v).getSelectedView();
+ if (selectedRow != null) {
+ selectRow(selectedRow);
+ }
+ } else {
+ deselectRow();
+ }
+ }
+
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ deselectRow();
+ selectRow(view);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ deselectRow();
+ }
+
+ private void selectRow(View row) {
+ if (row instanceof SearchEngineRow) {
+ mSelectedEngineRow = (SearchEngineRow) row;
+ mSelectedEngineRow.onSelected();
+ }
+ }
+
+ private void deselectRow() {
+ if (mSelectedEngineRow != null) {
+ mSelectedEngineRow.onDeselected();
+ mSelectedEngineRow = null;
+ }
+ }
+ }
+
+ /**
+ * HomeSearchListView is a list view for displaying search engine results on the awesome screen.
+ */
+ public static class HomeSearchListView extends HomeListView {
+ public HomeSearchListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.homeListViewStyle);
+ }
+
+ public HomeSearchListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ // Dismiss the soft keyboard.
+ requestFocus();
+ }
+
+ return super.onTouchEvent(event);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java
new file mode 100644
index 000000000..f288a2745
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/ClientsAdapter.java
@@ -0,0 +1,373 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.support.annotation.UiThread;
+import android.support.v4.util.Pair;
+import android.support.v7.widget.RecyclerView;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.RemoteTab;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType.*;
+
+public class ClientsAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder {
+ public static final String LOGTAG = "GeckoClientsAdapter";
+
+ /**
+ * If a device claims to have synced before this date, we will assume it has never synced.
+ */
+ public static final Date EARLIEST_VALID_SYNCED_DATE;
+ static {
+ final Calendar c = GregorianCalendar.getInstance();
+ c.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
+ EARLIEST_VALID_SYNCED_DATE = c.getTime();
+ }
+
+ List<Pair<String, Integer>> adapterList = new LinkedList<>();
+
+ // List of hidden remote clients.
+ // Only accessed from the UI thread.
+ protected final List<RemoteClient> hiddenClients = new ArrayList<>();
+ private Map<String, RemoteClient> visibleClients = new HashMap<>();
+
+ // Maintain group collapsed and hidden state. Only accessed from the UI thread.
+ protected static RemoteTabsExpandableListState sState;
+
+ private final Context context;
+
+ public ClientsAdapter(Context context) {
+ this.context = context;
+
+ // This races when multiple Fragments are created. That's okay: one
+ // will win, and thereafter, all will be okay. If we create and then
+ // drop an instance the shared SharedPreferences backing all the
+ // instances will maintain the state for us. Since everything happens on
+ // the UI thread, this doesn't even need to be volatile.
+ if (sState == null) {
+ sState = new RemoteTabsExpandableListState(GeckoSharedPrefs.forProfile(context));
+ }
+
+ this.setHasStableIds(true);
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case NAVIGATION_BACK:
+ view = inflater.inflate(R.layout.home_combined_back_item, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case CLIENT:
+ view = inflater.inflate(R.layout.home_remote_tabs_group, parent, false);
+ return new CombinedHistoryItem.ClientItem(view);
+
+ case CHILD:
+ view = inflater.inflate(R.layout.home_item_row, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case HIDDEN_DEVICES:
+ view = inflater.inflate(R.layout.home_remote_tabs_hidden_devices, parent, false);
+ return new CombinedHistoryItem.BasicItem(view);
+ }
+ return null;
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem holder, final int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+
+ switch (itemType) {
+ case CLIENT:
+ final CombinedHistoryItem.ClientItem clientItem = (CombinedHistoryItem.ClientItem) holder;
+ final String clientGuid = adapterList.get(position).first;
+ final RemoteClient client = visibleClients.get(clientGuid);
+ clientItem.bind(context, client, sState.isClientCollapsed(clientGuid));
+ break;
+
+ case CHILD:
+ final Pair<String, Integer> pair = adapterList.get(position);
+ RemoteTab remoteTab = visibleClients.get(pair.first).tabs.get(pair.second);
+ ((CombinedHistoryItem.HistoryItem) holder).bind(remoteTab);
+ break;
+
+ case HIDDEN_DEVICES:
+ final String hiddenDevicesLabel = context.getResources().getString(R.string.home_remote_tabs_many_hidden_devices, hiddenClients.size());
+ ((TextView) holder.itemView).setText(hiddenDevicesLabel);
+ break;
+ }
+ }
+
+ @Override
+ public int getItemCount () {
+ return adapterList.size();
+ }
+
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == 0) {
+ return NAVIGATION_BACK;
+ }
+
+ final Pair<String, Integer> pair = adapterList.get(position);
+ if (pair == null) {
+ return HIDDEN_DEVICES;
+ } else if (pair.second == -1) {
+ return CLIENT;
+ } else {
+ return CHILD;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ @Override
+ public long getItemId(int position) {
+ // RecyclerView.NO_ID is -1, so start our hard-coded IDs at -2.
+ final int NAVIGATION_BACK_ID = -2;
+ final int HIDDEN_DEVICES_ID = -3;
+
+ final String clientGuid;
+ // adapterList is a list of tuples (clientGuid, tabId).
+ final Pair<String, Integer> pair = adapterList.get(position);
+
+ switch (getItemTypeForPosition(position)) {
+ case NAVIGATION_BACK:
+ return NAVIGATION_BACK_ID;
+
+ case HIDDEN_DEVICES:
+ return HIDDEN_DEVICES_ID;
+
+ // For Clients, return hashCode of their GUIDs.
+ case CLIENT:
+ clientGuid = pair.first;
+ return clientGuid.hashCode();
+
+ // For Tabs, return hashCode of their URLs.
+ case CHILD:
+ clientGuid = pair.first;
+ final Integer tabId = pair.second;
+
+ final RemoteClient remoteClient = visibleClients.get(clientGuid);
+ if (remoteClient == null) {
+ return RecyclerView.NO_ID;
+ }
+
+ final RemoteTab remoteTab = remoteClient.tabs.get(tabId);
+ if (remoteTab == null) {
+ return RecyclerView.NO_ID;
+ }
+
+ return remoteTab.url.hashCode();
+
+ default:
+ throw new IllegalStateException("Unexpected Home Panel item type");
+ }
+ }
+
+ public int getClientsCount() {
+ return hiddenClients.size() + visibleClients.size();
+ }
+
+ @UiThread
+ public void setClients(List<RemoteClient> clients) {
+ adapterList.clear();
+ adapterList.add(null);
+
+ hiddenClients.clear();
+ visibleClients.clear();
+
+ for (RemoteClient client : clients) {
+ final String guid = client.guid;
+ if (sState.isClientHidden(guid)) {
+ hiddenClients.add(client);
+ } else {
+ visibleClients.put(guid, client);
+ adapterList.addAll(getVisibleItems(client));
+ }
+ }
+
+ // Add item for unhiding clients.
+ if (!hiddenClients.isEmpty()) {
+ adapterList.add(null);
+ }
+
+ notifyDataSetChanged();
+ }
+
+ private static List<Pair<String, Integer>> getVisibleItems(RemoteClient client) {
+ List<Pair<String, Integer>> list = new LinkedList<>();
+ final String guid = client.guid;
+ list.add(new Pair<>(guid, -1));
+ if (!sState.isClientCollapsed(client.guid)) {
+ for (int i = 0; i < client.tabs.size(); i++) {
+ list.add(new Pair<>(guid, i));
+ }
+ }
+ return list;
+ }
+
+ public List<RemoteClient> getHiddenClients() {
+ return hiddenClients;
+ }
+
+ public void toggleClient(int position) {
+ final Pair<String, Integer> pair = adapterList.get(position);
+ if (pair.second != -1) {
+ return;
+ }
+
+ final String clientGuid = pair.first;
+ final RemoteClient client = visibleClients.get(clientGuid);
+
+ final boolean isCollapsed = sState.isClientCollapsed(clientGuid);
+
+ sState.setClientCollapsed(clientGuid, !isCollapsed);
+ notifyItemChanged(position);
+
+ if (isCollapsed) {
+ for (int i = client.tabs.size() - 1; i > -1; i--) {
+ // Insert child tabs at the index right after the client item that was clicked.
+ adapterList.add(position + 1, new Pair<>(clientGuid, i));
+ }
+ notifyItemRangeInserted(position + 1, client.tabs.size());
+ } else {
+ int i = client.tabs.size();
+ while (i > 0) {
+ adapterList.remove(position + 1);
+ i--;
+ }
+ notifyItemRangeRemoved(position + 1, client.tabs.size());
+ }
+ }
+
+ public void unhideClients(List<RemoteClient> selectedClients) {
+ final int numClients = selectedClients.size();
+ if (numClients == 0) {
+ return;
+ }
+
+ final int insertionIndex = adapterList.size() - 1;
+ int itemCount = numClients;
+
+ for (RemoteClient client : selectedClients) {
+ final String clientGuid = client.guid;
+
+ sState.setClientHidden(clientGuid, false);
+ hiddenClients.remove(client);
+
+ visibleClients.put(clientGuid, client);
+ sState.setClientCollapsed(clientGuid, false);
+ adapterList.addAll(adapterList.size() - 1, getVisibleItems(client));
+
+ itemCount += client.tabs.size();
+ }
+
+ notifyItemRangeInserted(insertionIndex, itemCount);
+
+ final int hiddenDevicesIndex = adapterList.size() - 1;
+ if (hiddenClients.isEmpty()) {
+ // No more hidden clients, remove "unhide" item.
+ adapterList.remove(hiddenDevicesIndex);
+ notifyItemRemoved(hiddenDevicesIndex);
+ } else {
+ // Update "hidden clients" item because number of hidden clients changed.
+ notifyItemChanged(hiddenDevicesIndex);
+ }
+ }
+
+ public void removeItem(int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ switch (itemType) {
+ case CLIENT:
+ final String clientGuid = adapterList.get(position).first;
+ final RemoteClient client = visibleClients.remove(clientGuid);
+ final boolean hadHiddenClients = !hiddenClients.isEmpty();
+
+ int removeCount = sState.isClientCollapsed(clientGuid) ? 1 : client.tabs.size() + 1;
+ int c = removeCount;
+ while (c > 0) {
+ adapterList.remove(position);
+ c--;
+ }
+ notifyItemRangeRemoved(position, removeCount);
+
+ sState.setClientHidden(clientGuid, true);
+ hiddenClients.add(client);
+
+ if (!hadHiddenClients) {
+ // Add item for unhiding clients;
+ adapterList.add(null);
+ notifyItemInserted(adapterList.size() - 1);
+ } else {
+ // Update "hidden clients" item because number of hidden clients changed.
+ notifyItemChanged(adapterList.size() - 1);
+ }
+ break;
+ }
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ HomeContextMenuInfo info;
+ final Pair<String, Integer> pair = adapterList.get(position);
+ switch (itemType) {
+ case CHILD:
+ info = new HomeContextMenuInfo(view, position, -1);
+ return populateChildInfoFromTab(info, visibleClients.get(pair.first).tabs.get(pair.second));
+
+ case CLIENT:
+ info = new CombinedHistoryPanel.RemoteTabsClientContextMenuInfo(view, position, -1, visibleClients.get(pair.first));
+ return info;
+ }
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, RemoteTab tab) {
+ info.url = tab.url;
+ info.title = tab.title;
+ return info;
+ }
+
+ /**
+ * Return a relative "Last synced" time span for the given tab record.
+ *
+ * @param now local time.
+ * @param time to format string for.
+ * @return string describing time span
+ */
+ public static String getLastSyncedString(Context context, long now, long time) {
+ if (new Date(time).before(EARLIEST_VALID_SYNCED_DATE)) {
+ return context.getString(R.string.remote_tabs_never_synced);
+ }
+ final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS);
+ return context.getResources().getString(R.string.remote_tabs_last_synced, relativeTimeSpanString);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
new file mode 100644
index 000000000..402ed26e7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryAdapter.java
@@ -0,0 +1,433 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home;
+
+import android.content.res.Resources;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+
+import android.database.Cursor;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class CombinedHistoryAdapter extends RecyclerView.Adapter<CombinedHistoryItem> implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder {
+ private static final int RECENT_TABS_SMARTFOLDER_INDEX = 0;
+
+ // Array for the time ranges in milliseconds covered by each section.
+ static final HistorySectionsHelper.SectionDateRange[] sectionDateRangeArray = new HistorySectionsHelper.SectionDateRange[SectionHeader.values().length];
+
+ // Semantic names for the time covered by each section
+ public enum SectionHeader {
+ TODAY,
+ YESTERDAY,
+ WEEK,
+ THIS_MONTH,
+ MONTH_AGO,
+ TWO_MONTHS_AGO,
+ THREE_MONTHS_AGO,
+ FOUR_MONTHS_AGO,
+ FIVE_MONTHS_AGO,
+ OLDER_THAN_SIX_MONTHS
+ }
+
+ private HomeFragment.PanelStateChangeListener panelStateChangeListener;
+
+ private Cursor historyCursor;
+ private DevicesUpdateHandler devicesUpdateHandler;
+ private int deviceCount = 0;
+ private RecentTabsUpdateHandler recentTabsUpdateHandler;
+ private int recentTabsCount = 0;
+
+ private LinearLayoutManager linearLayoutManager; // Only used on the UI thread, so no need to be volatile.
+
+ // We use a sparse array to store each section header's position in the panel [more cheaply than a HashMap].
+ private final SparseArray<SectionHeader> sectionHeaders;
+
+ public CombinedHistoryAdapter(Resources resources, int cachedRecentTabsCount) {
+ super();
+ recentTabsCount = cachedRecentTabsCount;
+ sectionHeaders = new SparseArray<>();
+ HistorySectionsHelper.updateRecentSectionOffset(resources, sectionDateRangeArray);
+ this.setHasStableIds(true);
+ }
+
+ public void setPanelStateChangeListener(
+ HomeFragment.PanelStateChangeListener panelStateChangeListener) {
+ this.panelStateChangeListener = panelStateChangeListener;
+ }
+
+ @UiThread
+ public void setLinearLayoutManager(LinearLayoutManager linearLayoutManager) {
+ this.linearLayoutManager = linearLayoutManager;
+ }
+
+ public void setHistory(Cursor history) {
+ historyCursor = history;
+ populateSectionHeaders(historyCursor, sectionHeaders);
+ notifyDataSetChanged();
+ }
+
+ public interface DevicesUpdateHandler {
+ void onDeviceCountUpdated(int count);
+ }
+
+ public DevicesUpdateHandler getDeviceUpdateHandler() {
+ if (devicesUpdateHandler == null) {
+ devicesUpdateHandler = new DevicesUpdateHandler() {
+ @Override
+ public void onDeviceCountUpdated(int count) {
+ deviceCount = count;
+ notifyItemChanged(getSyncedDevicesSmartFolderIndex());
+ }
+ };
+ }
+ return devicesUpdateHandler;
+ }
+
+ public interface RecentTabsUpdateHandler {
+ void onRecentTabsCountUpdated(int count, boolean countReliable);
+ }
+
+ public RecentTabsUpdateHandler getRecentTabsUpdateHandler() {
+ if (recentTabsUpdateHandler != null) {
+ return recentTabsUpdateHandler;
+ }
+
+ recentTabsUpdateHandler = new RecentTabsUpdateHandler() {
+ @Override
+ public void onRecentTabsCountUpdated(final int count, final boolean countReliable) {
+ // Now that other items can move around depending on the visibility of the
+ // Recent Tabs folder, only update the recentTabsCount on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @UiThread
+ @Override
+ public void run() {
+ if (!countReliable && count <= recentTabsCount) {
+ // The final tab count (where countReliable = true) is normally >= than
+ // previous values with countReliable = false. Hence we only want to
+ // update the displayed tab count with a preliminary value if it's larger
+ // than the previous count, so as to avoid the displayed count jumping
+ // downwards and then back up, as well as unnecessary folder animations.
+ return;
+ }
+
+ final boolean prevFolderVisibility = isRecentTabsFolderVisible();
+ recentTabsCount = count;
+ final boolean folderVisible = isRecentTabsFolderVisible();
+
+ if (prevFolderVisibility == folderVisible) {
+ if (prevFolderVisibility) {
+ notifyItemChanged(RECENT_TABS_SMARTFOLDER_INDEX);
+ }
+ return;
+ }
+
+ // If the Recent Tabs smart folder has become hidden/unhidden,
+ // we need to recalculate the history section header positions.
+ populateSectionHeaders(historyCursor, sectionHeaders);
+
+ if (folderVisible) {
+ int scrollPos = -1;
+ if (linearLayoutManager != null) {
+ scrollPos = linearLayoutManager.findFirstCompletelyVisibleItemPosition();
+ }
+
+ notifyItemInserted(RECENT_TABS_SMARTFOLDER_INDEX);
+ // If the list exceeds the display height and we want to show the new
+ // item inserted at position 0, we need to scroll up manually
+ // (see https://code.google.com/p/android/issues/detail?id=174227#c2).
+ // However we only do this if our current scroll position is at the
+ // top of the list.
+ if (linearLayoutManager != null && scrollPos == 0) {
+ linearLayoutManager.scrollToPosition(0);
+ }
+ } else {
+ notifyItemRemoved(RECENT_TABS_SMARTFOLDER_INDEX);
+ }
+
+ if (countReliable && panelStateChangeListener != null) {
+ panelStateChangeListener.setCachedRecentTabsCount(recentTabsCount);
+ }
+ }
+ });
+ }
+ };
+ return recentTabsUpdateHandler;
+ }
+
+ @UiThread
+ private boolean isRecentTabsFolderVisible() {
+ return recentTabsCount > 0;
+ }
+
+ @UiThread
+ // Number of smart folders for determining practical empty state.
+ public int getNumVisibleSmartFolders() {
+ int visibleFolders = 1; // Synced devices folder is always visible.
+
+ if (isRecentTabsFolderVisible()) {
+ visibleFolders += 1;
+ }
+
+ return visibleFolders;
+ }
+
+ @UiThread
+ private int getSyncedDevicesSmartFolderIndex() {
+ return isRecentTabsFolderVisible() ?
+ RECENT_TABS_SMARTFOLDER_INDEX + 1 :
+ RECENT_TABS_SMARTFOLDER_INDEX;
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup viewGroup, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case RECENT_TABS:
+ case SYNCED_DEVICES:
+ view = inflater.inflate(R.layout.home_smartfolder, viewGroup, false);
+ return new CombinedHistoryItem.SmartFolder(view);
+
+ case SECTION_HEADER:
+ view = inflater.inflate(R.layout.home_header_row, viewGroup, false);
+ return new CombinedHistoryItem.BasicItem(view);
+
+ case HISTORY:
+ view = inflater.inflate(R.layout.home_item_row, viewGroup, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+ default:
+ throw new IllegalArgumentException("Unexpected Home Panel item type");
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem viewHolder, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ final int localPosition = transformAdapterPositionForDataStructure(itemType, position);
+
+ switch (itemType) {
+ case RECENT_TABS:
+ ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.icon_recent, R.string.home_closed_tabs_title2, R.string.home_closed_tabs_one, R.string.home_closed_tabs_number, recentTabsCount);
+ break;
+
+ case SYNCED_DEVICES:
+ ((CombinedHistoryItem.SmartFolder) viewHolder).bind(R.drawable.cloud, R.string.home_synced_devices_smartfolder, R.string.home_synced_devices_one, R.string.home_synced_devices_number, deviceCount);
+ break;
+
+ case SECTION_HEADER:
+ ((TextView) viewHolder.itemView).setText(getSectionHeaderTitle(sectionHeaders.get(localPosition)));
+ break;
+
+ case HISTORY:
+ if (historyCursor == null || !historyCursor.moveToPosition(localPosition)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + localPosition);
+ }
+ ((CombinedHistoryItem.HistoryItem) viewHolder).bind(historyCursor);
+ break;
+ }
+ }
+
+ /**
+ * Transform an adapter position to the position for the data structure backing the item type.
+ *
+ * The type is not strictly necessary and could be fetched from <code>getItemTypeForPosition</code>,
+ * but is used for explicitness.
+ *
+ * @param type ItemType of the item
+ * @param position position in the adapter
+ * @return position of the item in the data structure
+ */
+ @UiThread
+ private int transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType type, int position) {
+ if (type == CombinedHistoryItem.ItemType.SECTION_HEADER) {
+ return position;
+ } else if (type == CombinedHistoryItem.ItemType.HISTORY) {
+ return position - getHeadersBefore(position) - getNumVisibleSmartFolders();
+ } else {
+ return position;
+ }
+ }
+
+ @UiThread
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == RECENT_TABS_SMARTFOLDER_INDEX && isRecentTabsFolderVisible()) {
+ return CombinedHistoryItem.ItemType.RECENT_TABS;
+ }
+ if (position == getSyncedDevicesSmartFolderIndex()) {
+ return CombinedHistoryItem.ItemType.SYNCED_DEVICES;
+ }
+ final int sectionPosition = transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.SECTION_HEADER, position);
+ if (sectionHeaders.get(sectionPosition) != null) {
+ return CombinedHistoryItem.ItemType.SECTION_HEADER;
+ }
+ return CombinedHistoryItem.ItemType.HISTORY;
+ }
+
+ @UiThread
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ @UiThread
+ @Override
+ public int getItemCount() {
+ final int historySize = historyCursor == null ? 0 : historyCursor.getCount();
+ return historySize + sectionHeaders.size() + getNumVisibleSmartFolders();
+ }
+
+ /**
+ * Returns stable ID for each position. Data behind historyCursor is a sorted Combined view.
+ *
+ * @param position view item position for which to generate a stable ID
+ * @return stable ID for given position
+ */
+ @UiThread
+ @Override
+ public long getItemId(int position) {
+ // Two randomly selected large primes used to generate non-clashing IDs.
+ final long PRIME_BOOKMARKS = 32416189867L;
+ final long PRIME_SECTION_HEADERS = 32416187737L;
+
+ // RecyclerView.NO_ID is -1, so let's start from -2 for our hard-coded IDs.
+ final int RECENT_TABS_ID = -2;
+ final int SYNCED_DEVICES_ID = -3;
+
+ switch (getItemTypeForPosition(position)) {
+ case RECENT_TABS:
+ return RECENT_TABS_ID;
+ case SYNCED_DEVICES:
+ return SYNCED_DEVICES_ID;
+ case SECTION_HEADER:
+ // We might have multiple section headers, so we try get unique IDs for them.
+ return position * PRIME_SECTION_HEADERS;
+ case HISTORY:
+ final int historyPosition = transformAdapterPositionForDataStructure(
+ CombinedHistoryItem.ItemType.HISTORY, position);
+ if (!historyCursor.moveToPosition(historyPosition)) {
+ return RecyclerView.NO_ID;
+ }
+
+ final int historyIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID);
+ final long historyId = historyCursor.getLong(historyIdCol);
+
+ if (historyId != -1) {
+ return historyId;
+ }
+
+ final int bookmarkIdCol = historyCursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
+ final long bookmarkId = historyCursor.getLong(bookmarkIdCol);
+
+ // Avoid clashing with historyId.
+ return bookmarkId * PRIME_BOOKMARKS;
+ default:
+ throw new IllegalStateException("Unexpected Home Panel item type");
+ }
+ }
+
+ /**
+ * Add only the SectionHeaders that have history items within their range to a SparseArray, where the
+ * array index is the position of the header in the history-only (no clients) ordering.
+ * @param c data Cursor
+ * @param sparseArray SparseArray to populate
+ */
+ @UiThread
+ private void populateSectionHeaders(Cursor c, SparseArray<SectionHeader> sparseArray) {
+ ThreadUtils.assertOnUiThread();
+
+ sparseArray.clear();
+
+ if (c == null || !c.moveToFirst()) {
+ return;
+ }
+
+ SectionHeader section = null;
+
+ do {
+ final int historyPosition = c.getPosition();
+ final long visitTime = c.getLong(c.getColumnIndexOrThrow(BrowserContract.History.DATE_LAST_VISITED));
+ final SectionHeader itemSection = getSectionFromTime(visitTime);
+
+ if (section != itemSection) {
+ section = itemSection;
+ sparseArray.append(historyPosition + sparseArray.size() + getNumVisibleSmartFolders(), section);
+ }
+
+ if (section == SectionHeader.OLDER_THAN_SIX_MONTHS) {
+ break;
+ }
+ } while (c.moveToNext());
+ }
+
+ private static String getSectionHeaderTitle(SectionHeader section) {
+ return sectionDateRangeArray[section.ordinal()].displayName;
+ }
+
+ private static SectionHeader getSectionFromTime(long time) {
+ for (int i = 0; i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) {
+ if (time > sectionDateRangeArray[i].start) {
+ return SectionHeader.values()[i];
+ }
+ }
+
+ return SectionHeader.OLDER_THAN_SIX_MONTHS;
+ }
+
+ /**
+ * Returns the number of section headers before the given history item at the adapter position.
+ * @param position position in the adapter
+ */
+ private int getHeadersBefore(int position) {
+ // Skip the first header case because there will always be a header.
+ for (int i = 1; i < sectionHeaders.size(); i++) {
+ // If the position of the header is greater than the history position,
+ // return the number of headers tested.
+ if (sectionHeaders.keyAt(i) > position) {
+ return i;
+ }
+ }
+ return sectionHeaders.size();
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ if (itemType == CombinedHistoryItem.ItemType.HISTORY) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, -1);
+
+ historyCursor.moveToPosition(transformAdapterPositionForDataStructure(CombinedHistoryItem.ItemType.HISTORY, position));
+ return populateHistoryInfoFromCursor(info, historyCursor);
+ }
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateHistoryInfoFromCursor(HomeContextMenuInfo info, Cursor cursor) {
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ info.itemType = HomeContextMenuInfo.RemoveItemType.HISTORY;
+ final int bookmarkIdCol = cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID);
+ if (cursor.isNull(bookmarkIdCol)) {
+ // If this is a combined cursor, we may get a history item without a
+ // bookmark, in which case the bookmarks ID column value will be null.
+ info.bookmarkId = -1;
+ } else {
+ info.bookmarkId = cursor.getInt(bookmarkIdCol);
+ }
+ return info;
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
new file mode 100644
index 000000000..a2c1b72c2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryItem.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.RemoteTab;
+import org.mozilla.gecko.home.RecentTabsAdapter.ClosedTab;
+
+public abstract class CombinedHistoryItem extends RecyclerView.ViewHolder {
+ private static final String LOGTAG = "CombinedHistoryItem";
+
+ public CombinedHistoryItem(View view) {
+ super(view);
+ }
+
+ public enum ItemType {
+ CLIENT, HIDDEN_DEVICES, SECTION_HEADER, HISTORY, NAVIGATION_BACK, CHILD, SYNCED_DEVICES,
+ RECENT_TABS, CLOSED_TAB;
+
+ public static ItemType viewTypeToItemType(int viewType) {
+ if (viewType >= ItemType.values().length) {
+ Log.e(LOGTAG, "No corresponding ItemType!");
+ }
+ return ItemType.values()[viewType];
+ }
+
+ public static int itemTypeToViewType(ItemType itemType) {
+ return itemType.ordinal();
+ }
+ }
+
+ public static class BasicItem extends CombinedHistoryItem {
+ public BasicItem(View view) {
+ super(view);
+ }
+ }
+
+ public static class SmartFolder extends CombinedHistoryItem {
+ final Context context;
+ final ImageView icon;
+ final TextView title;
+ final TextView subtext;
+
+ public SmartFolder(View view) {
+ super(view);
+ context = view.getContext();
+
+ icon = (ImageView) view.findViewById(R.id.device_type);
+ title = (TextView) view.findViewById(R.id.title);
+ subtext = (TextView) view.findViewById(R.id.subtext);
+ }
+
+ public void bind(int drawableRes, int titleRes, int singleDeviceRes, int multiDeviceRes, int numDevices) {
+ icon.setImageResource(drawableRes);
+ title.setText(titleRes);
+ final String subtitle = numDevices == 1 ? context.getString(singleDeviceRes) : context.getString(multiDeviceRes, numDevices);
+ subtext.setText(subtitle);
+ }
+ }
+
+ public static class HistoryItem extends CombinedHistoryItem {
+ public HistoryItem(View view) {
+ super(view);
+ }
+
+ public void bind(Cursor historyCursor) {
+ final TwoLinePageRow pageRow = (TwoLinePageRow) this.itemView;
+ pageRow.setShowIcons(true);
+ pageRow.updateFromCursor(historyCursor);
+ }
+
+ public void bind(RemoteTab remoteTab) {
+ final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView;
+ childPageRow.setShowIcons(true);
+ childPageRow.update(remoteTab.title, remoteTab.url);
+ }
+
+ public void bind(ClosedTab closedTab) {
+ final TwoLinePageRow childPageRow = (TwoLinePageRow) this.itemView;
+ childPageRow.setShowIcons(false);
+ childPageRow.update(closedTab.title, closedTab.url);
+ }
+ }
+
+ public static class ClientItem extends CombinedHistoryItem {
+ final TextView nameView;
+ final ImageView deviceTypeView;
+ final TextView lastModifiedView;
+ final ImageView deviceExpanded;
+
+ public ClientItem(View view) {
+ super(view);
+ nameView = (TextView) view.findViewById(R.id.client);
+ deviceTypeView = (ImageView) view.findViewById(R.id.device_type);
+ lastModifiedView = (TextView) view.findViewById(R.id.last_synced);
+ deviceExpanded = (ImageView) view.findViewById(R.id.device_expanded);
+ }
+
+ public void bind(Context context, RemoteClient client, boolean isCollapsed) {
+ this.nameView.setText(client.name);
+ final long now = System.currentTimeMillis();
+ this.lastModifiedView.setText(ClientsAdapter.getLastSyncedString(context, now, client.lastModified));
+
+ if (client.isDesktop()) {
+ deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_desktop_inactive : R.drawable.sync_desktop);
+ } else {
+ deviceTypeView.setImageResource(isCollapsed ? R.drawable.sync_mobile_inactive : R.drawable.sync_mobile);
+ }
+
+ nameView.setTextColor(ContextCompat.getColor(context, isCollapsed ? R.color.tabs_tray_icon_grey : R.color.placeholder_active_grey));
+ if (client.tabs.size() > 0) {
+ deviceExpanded.setImageResource(isCollapsed ? R.drawable.home_group_collapsed : R.drawable.arrow_down);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
new file mode 100644
index 000000000..c9afecd63
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryPanel.java
@@ -0,0 +1,697 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.accounts.Account;
+import android.app.AlertDialog;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.UiThread;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.support.v7.widget.DefaultItemAnimator;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.text.SpannableStringBuilder;
+import android.text.TextPaint;
+import android.text.method.LinkMovementMethod;
+import android.text.style.ClickableSpan;
+import android.text.style.UnderlineSpan;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.RemoteClientsDialogFragment;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.SyncStatusListener;
+import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.widget.HistoryDividerItemDecoration;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+
+public class CombinedHistoryPanel extends HomeFragment implements RemoteClientsDialogFragment.RemoteClientsListener {
+ private static final String LOGTAG = "GeckoCombinedHistoryPnl";
+
+ private static final String[] STAGES_TO_SYNC_ON_REFRESH = new String[] { "clients", "tabs" };
+ private final int LOADER_ID_HISTORY = 0;
+ private final int LOADER_ID_REMOTE = 1;
+
+ // String placeholders to mark formatting.
+ private final static String FORMAT_S1 = "%1$s";
+ private final static String FORMAT_S2 = "%2$s";
+
+ private CombinedHistoryRecyclerView mRecyclerView;
+ private CombinedHistoryAdapter mHistoryAdapter;
+ private ClientsAdapter mClientsAdapter;
+ private RecentTabsAdapter mRecentTabsAdapter;
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ private Bundle mSavedRestoreBundle;
+
+ private PanelLevel mPanelLevel;
+ private Button mPanelFooterButton;
+
+ private PanelStateUpdateHandler mPanelStateUpdateHandler;
+
+ // Child refresh layout view.
+ protected SwipeRefreshLayout mRefreshLayout;
+
+ // Sync listener that stops refreshing when a sync is completed.
+ protected RemoteTabsSyncListener mSyncStatusListener;
+
+ // Reference to the View to display when there are no results.
+ private View mHistoryEmptyView;
+ private View mClientsEmptyView;
+ private View mRecentTabsEmptyView;
+
+ public interface OnPanelLevelChangeListener {
+ enum PanelLevel {
+ PARENT, CHILD_SYNC, CHILD_RECENT_TABS
+ }
+
+ /**
+ * Propagates level changes.
+ * @param level
+ * @return true if level changed, false otherwise.
+ */
+ boolean changeLevel(PanelLevel level);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstance) {
+ super.onCreate(savedInstance);
+
+ int cachedRecentTabsCount = 0;
+ if (mPanelStateChangeListener != null ) {
+ cachedRecentTabsCount = mPanelStateChangeListener.getCachedRecentTabsCount();
+ }
+ mHistoryAdapter = new CombinedHistoryAdapter(getResources(), cachedRecentTabsCount);
+ if (mPanelStateChangeListener != null) {
+ mHistoryAdapter.setPanelStateChangeListener(mPanelStateChangeListener);
+ }
+
+ mClientsAdapter = new ClientsAdapter(getContext());
+ // The RecentTabsAdapter doesn't use a cursor and therefore can't use the CursorLoader's
+ // onLoadFinished() callback for updating the panel state when the closed tab count changes.
+ // Instead, we provide it with independent callbacks as necessary.
+ mRecentTabsAdapter = new RecentTabsAdapter(getContext(),
+ mHistoryAdapter.getRecentTabsUpdateHandler(), getPanelStateUpdateHandler());
+
+ mSyncStatusListener = new RemoteTabsSyncListener();
+ FirefoxAccounts.addSyncStatusListener(mSyncStatusListener);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.home_combined_history_panel, container, false);
+ }
+
+ @UiThread
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mRecyclerView = (CombinedHistoryRecyclerView) view.findViewById(R.id.combined_recycler_view);
+ setUpRecyclerView();
+
+ mRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refresh_layout);
+ setUpRefreshLayout();
+
+ mClientsEmptyView = view.findViewById(R.id.home_clients_empty_view);
+ mHistoryEmptyView = view.findViewById(R.id.home_history_empty_view);
+ mRecentTabsEmptyView = view.findViewById(R.id.home_recent_tabs_empty_view);
+ setUpEmptyViews();
+
+ mPanelFooterButton = (Button) view.findViewById(R.id.history_panel_footer_button);
+ mPanelFooterButton.setText(R.string.home_clear_history_button);
+ mPanelFooterButton.setOnClickListener(new OnFooterButtonClickListener());
+
+ mRecentTabsAdapter.startListeningForClosedTabs();
+ mRecentTabsAdapter.startListeningForHistorySanitize();
+
+ if (mSavedRestoreBundle != null) {
+ setPanelStateFromBundle(mSavedRestoreBundle);
+ mSavedRestoreBundle = null;
+ }
+ }
+
+ @UiThread
+ private void setUpRecyclerView() {
+ if (mPanelLevel == null) {
+ mPanelLevel = PARENT;
+ }
+
+ mRecyclerView.setAdapter(mPanelLevel == PARENT ? mHistoryAdapter :
+ mPanelLevel == CHILD_SYNC ? mClientsAdapter : mRecentTabsAdapter);
+
+ final RecyclerView.ItemAnimator animator = new DefaultItemAnimator();
+ animator.setAddDuration(100);
+ animator.setChangeDuration(100);
+ animator.setMoveDuration(100);
+ animator.setRemoveDuration(100);
+ mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
+ mHistoryAdapter.setLinearLayoutManager((LinearLayoutManager) mRecyclerView.getLayoutManager());
+ mRecyclerView.setItemAnimator(animator);
+ mRecyclerView.addItemDecoration(new HistoryDividerItemDecoration(getContext()));
+ mRecyclerView.setOnHistoryClickedListener(mUrlOpenListener);
+ mRecyclerView.setOnPanelLevelChangeListener(new OnLevelChangeListener());
+ mRecyclerView.setHiddenClientsDialogBuilder(new HiddenClientsHelper());
+ mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ super.onScrolled(recyclerView, dx, dy);
+ final LinearLayoutManager llm = (LinearLayoutManager) recyclerView.getLayoutManager();
+ if ((mPanelLevel == PARENT) && (llm.findLastCompletelyVisibleItemPosition() == HistoryCursorLoader.HISTORY_LIMIT)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.LIST, "history_scroll_max");
+ }
+
+ }
+ });
+ registerForContextMenu(mRecyclerView);
+ }
+
+ private void setUpRefreshLayout() {
+ mRefreshLayout.setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange);
+ mRefreshLayout.setOnRefreshListener(new RemoteTabsRefreshListener());
+ mRefreshLayout.setEnabled(false);
+ }
+
+ private void setUpEmptyViews() {
+ // Set up history empty view.
+ final ImageView historyIcon = (ImageView) mHistoryEmptyView.findViewById(R.id.home_empty_image);
+ historyIcon.setVisibility(View.GONE);
+
+ final TextView historyText = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_text);
+ historyText.setText(R.string.home_most_recent_empty);
+
+ final TextView historyHint = (TextView) mHistoryEmptyView.findViewById(R.id.home_empty_hint);
+
+ if (!Restrictions.isAllowed(getActivity(), Restrictable.PRIVATE_BROWSING)) {
+ historyHint.setVisibility(View.GONE);
+ } else {
+ final String hintText = getResources().getString(R.string.home_most_recent_emptyhint);
+ final SpannableStringBuilder hintBuilder = formatHintText(hintText);
+ if (hintBuilder != null) {
+ historyHint.setText(hintBuilder);
+ historyHint.setMovementMethod(LinkMovementMethod.getInstance());
+ historyHint.setVisibility(View.VISIBLE);
+ }
+ }
+
+ // Set up Clients empty view.
+ final Button syncSetupButton = (Button) mClientsEmptyView.findViewById(R.id.sync_setup_button);
+ syncSetupButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "history_syncsetup");
+ // This Activity will redirect to the correct Activity as needed.
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ startActivity(intent);
+ }
+ });
+
+ // Set up Recent Tabs empty view.
+ final ImageView recentTabsIcon = (ImageView) mRecentTabsEmptyView.findViewById(R.id.home_empty_image);
+ recentTabsIcon.setImageResource(R.drawable.icon_remote_tabs_empty);
+
+ final TextView recentTabsText = (TextView) mRecentTabsEmptyView.findViewById(R.id.home_empty_text);
+ recentTabsText.setText(R.string.home_last_tabs_empty);
+ }
+
+ @Override
+ public void setPanelStateChangeListener(
+ PanelStateChangeListener panelStateChangeListener) {
+ super.setPanelStateChangeListener(panelStateChangeListener);
+ if (mHistoryAdapter != null) {
+ mHistoryAdapter.setPanelStateChangeListener(panelStateChangeListener);
+ }
+ }
+
+ @Override
+ public void restoreData(Bundle data) {
+ if (mRecyclerView != null) {
+ setPanelStateFromBundle(data);
+ } else {
+ mSavedRestoreBundle = data;
+ }
+ }
+
+ private void setPanelStateFromBundle(Bundle data) {
+ if (data != null && data.getBoolean("goToRecentTabs", false) && mPanelLevel != CHILD_RECENT_TABS) {
+ mPanelLevel = CHILD_RECENT_TABS;
+ mRecyclerView.swapAdapter(mRecentTabsAdapter, true);
+ updateEmptyView(CHILD_RECENT_TABS);
+ updateButtonFromLevel();
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ }
+
+ @Override
+ protected void load() {
+ getLoaderManager().initLoader(LOADER_ID_HISTORY, null, mCursorLoaderCallbacks);
+ getLoaderManager().initLoader(LOADER_ID_REMOTE, null, mCursorLoaderCallbacks);
+ }
+
+ private static class RemoteTabsCursorLoader extends SimpleCursorLoader {
+ private final GeckoProfile mProfile;
+
+ public RemoteTabsCursorLoader(Context context) {
+ super(context);
+ mProfile = GeckoProfile.get(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ return BrowserDB.from(mProfile).getTabsAccessor().getRemoteTabsCursor(getContext());
+ }
+ }
+
+ private static class HistoryCursorLoader extends SimpleCursorLoader {
+ // Max number of history results
+ public static final int HISTORY_LIMIT = 100;
+ private final BrowserDB mDB;
+
+ public HistoryCursorLoader(Context context) {
+ super(context);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final ContentResolver cr = getContext().getContentResolver();
+ return mDB.getRecentHistory(cr, HISTORY_LIMIT);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ private BrowserDB mDB; // Pseudo-final: set in onCreateLoader.
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (mDB == null) {
+ mDB = BrowserDB.from(getActivity());
+ }
+
+ switch (id) {
+ case LOADER_ID_HISTORY:
+ return new HistoryCursorLoader(getContext());
+ case LOADER_ID_REMOTE:
+ return new RemoteTabsCursorLoader(getContext());
+ default:
+ Log.e(LOGTAG, "Unknown loader id!");
+ return null;
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ final int loaderId = loader.getId();
+ switch (loaderId) {
+ case LOADER_ID_HISTORY:
+ mHistoryAdapter.setHistory(c);
+ updateEmptyView(PARENT);
+ break;
+
+ case LOADER_ID_REMOTE:
+ final List<RemoteClient> clients = mDB.getTabsAccessor().getClientsFromCursor(c);
+ mHistoryAdapter.getDeviceUpdateHandler().onDeviceCountUpdated(clients.size());
+ mClientsAdapter.setClients(clients);
+ updateEmptyView(CHILD_SYNC);
+ break;
+ }
+
+ updateButtonFromLevel();
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ mClientsAdapter.setClients(Collections.<RemoteClient>emptyList());
+ mHistoryAdapter.setHistory(null);
+ }
+ }
+
+ public interface PanelStateUpdateHandler {
+ void onPanelStateUpdated(PanelLevel level);
+ }
+
+ public PanelStateUpdateHandler getPanelStateUpdateHandler() {
+ if (mPanelStateUpdateHandler == null) {
+ mPanelStateUpdateHandler = new PanelStateUpdateHandler() {
+ @Override
+ public void onPanelStateUpdated(PanelLevel level) {
+ updateEmptyView(level);
+ updateButtonFromLevel();
+ }
+ };
+ }
+ return mPanelStateUpdateHandler;
+ }
+
+ protected class OnLevelChangeListener implements OnPanelLevelChangeListener {
+ @Override
+ public boolean changeLevel(PanelLevel level) {
+ if (level == mPanelLevel) {
+ return false;
+ }
+
+ mPanelLevel = level;
+ switch (level) {
+ case PARENT:
+ mRecyclerView.swapAdapter(mHistoryAdapter, true);
+ mRefreshLayout.setEnabled(false);
+ break;
+ case CHILD_SYNC:
+ mRecyclerView.swapAdapter(mClientsAdapter, true);
+ mRefreshLayout.setEnabled(mClientsAdapter.getClientsCount() > 0);
+ break;
+ case CHILD_RECENT_TABS:
+ mRecyclerView.swapAdapter(mRecentTabsAdapter, true);
+ break;
+ }
+
+ updateEmptyView(level);
+ updateButtonFromLevel();
+ return true;
+ }
+ }
+
+ private void updateButtonFromLevel() {
+ switch (mPanelLevel) {
+ case PARENT:
+ final boolean historyRestricted = !Restrictions.isAllowed(getActivity(), Restrictable.CLEAR_HISTORY);
+ if (historyRestricted || mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders()) {
+ mPanelFooterButton.setVisibility(View.GONE);
+ } else {
+ mPanelFooterButton.setText(R.string.home_clear_history_button);
+ mPanelFooterButton.setVisibility(View.VISIBLE);
+ }
+ break;
+ case CHILD_RECENT_TABS:
+ if (mRecentTabsAdapter.getClosedTabsCount() > 1) {
+ mPanelFooterButton.setText(R.string.home_restore_all);
+ mPanelFooterButton.setVisibility(View.VISIBLE);
+ } else {
+ mPanelFooterButton.setVisibility(View.GONE);
+ }
+ break;
+ case CHILD_SYNC:
+ mPanelFooterButton.setVisibility(View.GONE);
+ break;
+ }
+ }
+
+ private class OnFooterButtonClickListener implements View.OnClickListener {
+ @Override
+ public void onClick(View view) {
+ switch (mPanelLevel) {
+ case PARENT:
+ final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity());
+ dialogBuilder.setMessage(R.string.home_clear_history_confirm);
+ dialogBuilder.setNegativeButton(R.string.button_cancel, new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+ }
+ });
+
+ dialogBuilder.setPositiveButton(R.string.button_ok, new AlertDialog.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ dialog.dismiss();
+
+ // Send message to Java to clear history.
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("history", true);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+
+ GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString());
+ Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.BUTTON, "history");
+ }
+ });
+
+ dialogBuilder.show();
+ break;
+ case CHILD_RECENT_TABS:
+ final String telemetryExtra = mRecentTabsAdapter.restoreAllTabs();
+ if (telemetryExtra != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.BUTTON, telemetryExtra);
+ }
+ break;
+ }
+ }
+ }
+
+ private void updateEmptyView(PanelLevel level) {
+ boolean showEmptyHistoryView = (mPanelLevel == PARENT && mHistoryEmptyView.isShown());
+ boolean showEmptyClientsView = (mPanelLevel == CHILD_SYNC && mClientsEmptyView.isShown());
+ boolean showEmptyRecentTabsView = (mPanelLevel == CHILD_RECENT_TABS && mRecentTabsEmptyView.isShown());
+
+ if (mPanelLevel == level) {
+ switch (mPanelLevel) {
+ case PARENT:
+ showEmptyHistoryView = mHistoryAdapter.getItemCount() == mHistoryAdapter.getNumVisibleSmartFolders();
+ break;
+
+ case CHILD_SYNC:
+ showEmptyClientsView = mClientsAdapter.getItemCount() == 1;
+ break;
+
+ case CHILD_RECENT_TABS:
+ showEmptyRecentTabsView = mRecentTabsAdapter.getClosedTabsCount() == 0;
+ break;
+ }
+ }
+
+ final boolean showEmptyView = showEmptyClientsView || showEmptyHistoryView || showEmptyRecentTabsView;
+ mRecyclerView.setOverScrollMode(showEmptyView ? View.OVER_SCROLL_NEVER : View.OVER_SCROLL_IF_CONTENT_SCROLLS);
+
+ mHistoryEmptyView.setVisibility(showEmptyHistoryView ? View.VISIBLE : View.GONE);
+ mClientsEmptyView.setVisibility(showEmptyClientsView ? View.VISIBLE : View.GONE);
+ mRecentTabsEmptyView.setVisibility(showEmptyRecentTabsView ? View.VISIBLE : View.GONE);
+ }
+
+ /**
+ * Make Span that is clickable, and underlined
+ * between the string markers <code>FORMAT_S1</code> and
+ * <code>FORMAT_S2</code>.
+ *
+ * @param text String to format
+ * @return formatted SpannableStringBuilder, or null if there
+ * is not any text to format.
+ */
+ private SpannableStringBuilder formatHintText(String text) {
+ // Set formatting as marked by string placeholders.
+ final int underlineStart = text.indexOf(FORMAT_S1);
+ final int underlineEnd = text.indexOf(FORMAT_S2);
+
+ // Check that there is text to be formatted.
+ if (underlineStart >= underlineEnd) {
+ return null;
+ }
+
+ final SpannableStringBuilder ssb = new SpannableStringBuilder(text);
+
+ // Set clickable text.
+ final ClickableSpan clickableSpan = new ClickableSpan() {
+ @Override
+ public void onClick(View widget) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "hint_private_browsing");
+ try {
+ final JSONObject json = new JSONObject();
+ json.put("type", "Menu:Open");
+ GeckoApp.getEventDispatcher().dispatchEvent(json, null);
+ EventDispatcher.getInstance().dispatchEvent(json, null);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error forming JSON for Private Browsing contextual hint", e);
+ }
+ }
+ };
+
+ ssb.setSpan(clickableSpan, 0, text.length(), 0);
+
+ // Remove underlining set by ClickableSpan.
+ final UnderlineSpan noUnderlineSpan = new UnderlineSpan() {
+ @Override
+ public void updateDrawState(TextPaint textPaint) {
+ textPaint.setUnderlineText(false);
+ }
+ };
+
+ ssb.setSpan(noUnderlineSpan, 0, text.length(), 0);
+
+ // Add underlining for "Private Browsing".
+ ssb.setSpan(new UnderlineSpan(), underlineStart, underlineEnd, 0);
+
+ ssb.delete(underlineEnd, underlineEnd + FORMAT_S2.length());
+ ssb.delete(underlineStart, underlineStart + FORMAT_S1.length());
+
+ return ssb;
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) {
+ // Long pressed item was not a RemoteTabsGroup item. Superclass
+ // can handle this.
+ super.onCreateContextMenu(menu, view, menuInfo);
+ return;
+ }
+
+ // Long pressed item was a remote client; provide the appropriate menu.
+ final MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.home_remote_tabs_client_contextmenu, menu);
+
+ final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo;
+ menu.setHeaderTitle(info.client.name);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ if (super.onContextItemSelected(item)) {
+ // HomeFragment was able to handle to selected item.
+ return true;
+ }
+
+ final ContextMenu.ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof RemoteTabsClientContextMenuInfo)) {
+ return false;
+ }
+
+ final RemoteTabsClientContextMenuInfo info = (RemoteTabsClientContextMenuInfo) menuInfo;
+
+ final int itemId = item.getItemId();
+ if (itemId == R.id.home_remote_tabs_hide_client) {
+ mClientsAdapter.removeItem(info.position);
+ return true;
+ }
+
+ return false;
+ }
+
+ interface DialogBuilder<E> {
+ void createAndShowDialog(List<E> items);
+ }
+
+ protected class HiddenClientsHelper implements DialogBuilder<RemoteClient> {
+ @Override
+ public void createAndShowDialog(List<RemoteClient> clientsList) {
+ final RemoteClientsDialogFragment dialog = RemoteClientsDialogFragment.newInstance(
+ getResources().getString(R.string.home_remote_tabs_hidden_devices_title),
+ getResources().getString(R.string.home_remote_tabs_unhide_selected_devices),
+ RemoteClientsDialogFragment.ChoiceMode.MULTIPLE, new ArrayList<>(clientsList));
+ dialog.setTargetFragment(CombinedHistoryPanel.this, 0);
+ dialog.show(getActivity().getSupportFragmentManager(), "show-clients");
+ }
+ }
+
+ @Override
+ public void onClients(List<RemoteClient> clients) {
+ mClientsAdapter.unhideClients(clients);
+ }
+
+ /**
+ * Stores information regarding the creation of the context menu for a remote client.
+ */
+ protected static class RemoteTabsClientContextMenuInfo extends HomeContextMenuInfo {
+ protected final RemoteClient client;
+
+ public RemoteTabsClientContextMenuInfo(View targetView, int position, long id, RemoteClient client) {
+ super(targetView, position, id);
+ this.client = client;
+ }
+ }
+
+ protected class RemoteTabsRefreshListener implements SwipeRefreshLayout.OnRefreshListener {
+ @Override
+ public void onRefresh() {
+ if (FirefoxAccounts.firefoxAccountsExist(getActivity())) {
+ final Account account = FirefoxAccounts.getFirefoxAccount(getActivity());
+ FirefoxAccounts.requestImmediateSync(account, STAGES_TO_SYNC_ON_REFRESH, null);
+ } else {
+ Log.wtf(LOGTAG, "No Firefox Account found; this should never happen. Ignoring.");
+ mRefreshLayout.setRefreshing(false);
+ }
+ }
+ }
+
+ protected class RemoteTabsSyncListener implements SyncStatusListener {
+ @Override
+ public Context getContext() {
+ return getActivity();
+ }
+
+ @Override
+ public Account getAccount() {
+ return FirefoxAccounts.getFirefoxAccount(getContext());
+ }
+
+ @Override
+ public void onSyncStarted() {
+ }
+
+ @Override
+ public void onSyncFinished() {
+ mRefreshLayout.setRefreshing(false);
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ mRecentTabsAdapter.stopListeningForClosedTabs();
+ mRecentTabsAdapter.stopListeningForHistorySanitize();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (mSyncStatusListener != null) {
+ FirefoxAccounts.removeSyncStatusListener(mSyncStatusListener);
+ mSyncStatusListener = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java
new file mode 100644
index 000000000..e813e4c44
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/CombinedHistoryRecyclerView.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.View;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_SYNC;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.PARENT;
+
+public class CombinedHistoryRecyclerView extends RecyclerView
+ implements RecyclerViewClickSupport.OnItemClickListener, RecyclerViewClickSupport.OnItemLongClickListener {
+ public static String LOGTAG = "CombinedHistoryRecycView";
+
+ protected interface AdapterContextMenuBuilder {
+ HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position);
+ }
+
+ protected HomePager.OnUrlOpenListener mOnUrlOpenListener;
+ protected OnPanelLevelChangeListener mOnPanelLevelChangeListener;
+ protected CombinedHistoryPanel.DialogBuilder<RemoteClient> mDialogBuilder;
+ protected HomeContextMenuInfo mContextMenuInfo;
+
+ public CombinedHistoryRecyclerView(Context context) {
+ super(context);
+ init(context);
+ }
+
+ public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet) {
+ super(context, attributeSet);
+ init(context);
+ }
+
+ public CombinedHistoryRecyclerView(Context context, AttributeSet attributeSet, int defStyle) {
+ super(context, attributeSet, defStyle);
+ init(context);
+ }
+
+ private void init(Context context) {
+ LinearLayoutManager layoutManager = new LinearLayoutManager(context);
+ layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
+ setLayoutManager(layoutManager);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this)
+ .setOnItemLongClickListener(this);
+
+ setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ final int action = event.getAction();
+
+ // If the user hit the BACK key, try to move to the parent folder.
+ if (action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return mOnPanelLevelChangeListener.changeLevel(PARENT);
+ }
+ return false;
+ }
+ });
+ }
+
+ public void setOnHistoryClickedListener(HomePager.OnUrlOpenListener listener) {
+ this.mOnUrlOpenListener = listener;
+ }
+
+ public void setOnPanelLevelChangeListener(OnPanelLevelChangeListener listener) {
+ this.mOnPanelLevelChangeListener = listener;
+ }
+
+ public void setHiddenClientsDialogBuilder(CombinedHistoryPanel.DialogBuilder<RemoteClient> builder) {
+ mDialogBuilder = builder;
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ final int viewType = getAdapter().getItemViewType(position);
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+ final String telemetryExtra;
+
+ switch (itemType) {
+ case RECENT_TABS:
+ mOnPanelLevelChangeListener.changeLevel(CHILD_RECENT_TABS);
+ break;
+
+ case SYNCED_DEVICES:
+ mOnPanelLevelChangeListener.changeLevel(CHILD_SYNC);
+ break;
+
+ case CLIENT:
+ ((ClientsAdapter) getAdapter()).toggleClient(position);
+ break;
+
+ case HIDDEN_DEVICES:
+ if (mDialogBuilder != null) {
+ mDialogBuilder.createAndShowDialog(((ClientsAdapter) getAdapter()).getHiddenClients());
+ }
+ break;
+
+ case NAVIGATION_BACK:
+ mOnPanelLevelChangeListener.changeLevel(PARENT);
+ break;
+
+ case CHILD:
+ case HISTORY:
+ if (mOnUrlOpenListener != null) {
+ final TwoLinePageRow historyItem = (TwoLinePageRow) v;
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "history");
+ mOnUrlOpenListener.onUrlOpen(historyItem.getUrl(), EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ break;
+
+ case CLOSED_TAB:
+ telemetryExtra = ((RecentTabsAdapter) getAdapter()).restoreTabFromPosition(position);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, telemetryExtra);
+ break;
+ }
+ }
+
+ @Override
+ public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) {
+ mContextMenuInfo = ((AdapterContextMenuBuilder) getAdapter()).makeContextMenuInfoFromPosition(v, position);
+ return showContextMenuForChild(this);
+ }
+
+ @Override
+ public HomeContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java
new file mode 100644
index 000000000..d2c136219
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/DynamicPanel.java
@@ -0,0 +1,393 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.PanelLayout.ContextMenuRegistry;
+import org.mozilla.gecko.home.PanelLayout.DatasetHandler;
+import org.mozilla.gecko.home.PanelLayout.DatasetRequest;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.Loader;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+/**
+ * Fragment that displays dynamic content specified by a {@code PanelConfig}.
+ * The {@code DynamicPanel} UI is built based on the given {@code LayoutType}
+ * and its associated list of {@code ViewConfig}.
+ *
+ * {@code DynamicPanel} manages all necessary Loaders to load panel datasets
+ * from their respective content providers. Each panel dataset has its own
+ * associated Loader. This is enforced by defining the Loader IDs based on
+ * their associated dataset IDs.
+ *
+ * The {@code PanelLayout} can make load and reset requests on datasets via
+ * the provided {@code DatasetHandler}. This way it doesn't need to know the
+ * details of how datasets are loaded and reset. Each time a dataset is
+ * requested, {@code DynamicPanel} restarts a Loader with the respective ID (see
+ * {@code PanelDatasetHandler}).
+ *
+ * See {@code PanelLayout} for more details on how {@code DynamicPanel}
+ * receives dataset requests and delivers them back to the {@code PanelLayout}.
+ */
+public class DynamicPanel extends HomeFragment {
+ private static final String LOGTAG = "GeckoDynamicPanel";
+
+ // Dataset ID to be used by the loader
+ private static final String DATASET_REQUEST = "dataset_request";
+
+ // Max number of items to display in the panel
+ private static final int RESULT_LIMIT = 100;
+
+ // The main view for this fragment. This contains the PanelLayout and PanelAuthLayout.
+ private FrameLayout mView;
+
+ // The panel layout associated with this panel
+ private PanelLayout mPanelLayout;
+
+ // The layout used to show authentication UI for this panel
+ private PanelAuthLayout mPanelAuthLayout;
+
+ // Cache used to keep track of whether or not the user has been authenticated.
+ private PanelAuthCache mPanelAuthCache;
+
+ // Hold a reference to the UiAsyncTask we use to check the state of the
+ // PanelAuthCache, so that we can cancel it if necessary.
+ private UIAsyncTask.WithoutParams<Boolean> mAuthStateTask;
+
+ // The configuration associated with this panel
+ private PanelConfig mPanelConfig;
+
+ // Callbacks used for the loader
+ private PanelLoaderCallbacks mLoaderCallbacks;
+
+ // The current UI mode in the fragment
+ private UIMode mUIMode;
+
+ /*
+ * Different UI modes to display depending on the authentication state.
+ *
+ * PANEL: Layout to display panel data.
+ * AUTH: Authentication UI.
+ */
+ private enum UIMode {
+ PANEL,
+ AUTH
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle args = getArguments();
+ if (args != null) {
+ mPanelConfig = (PanelConfig) args.getParcelable(HomePager.PANEL_CONFIG_ARG);
+ }
+
+ if (mPanelConfig == null) {
+ throw new IllegalStateException("Can't create a DynamicPanel without a PanelConfig");
+ }
+
+ mPanelAuthCache = new PanelAuthCache(getActivity());
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mView = new FrameLayout(getActivity());
+ return mView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ // Restore whatever the UI mode the fragment had before
+ // a device rotation.
+ if (mUIMode != null) {
+ setUIMode(mUIMode);
+ }
+
+ mPanelAuthCache.setOnChangeListener(new PanelAuthChangeListener());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mView = null;
+ mPanelLayout = null;
+ mPanelAuthLayout = null;
+
+ mPanelAuthCache.setOnChangeListener(null);
+
+ if (mAuthStateTask != null) {
+ mAuthStateTask.cancel();
+ mAuthStateTask = null;
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ // Create callbacks before the initial loader is started.
+ mLoaderCallbacks = new PanelLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ protected void load() {
+ Log.d(LOGTAG, "Loading layout");
+
+ if (requiresAuth()) {
+ mAuthStateTask = new UIAsyncTask.WithoutParams<Boolean>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public synchronized Boolean doInBackground() {
+ return mPanelAuthCache.isAuthenticated(mPanelConfig.getId());
+ }
+
+ @Override
+ public void onPostExecute(Boolean isAuthenticated) {
+ mAuthStateTask = null;
+ setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH);
+ }
+ };
+ mAuthStateTask.execute();
+ } else {
+ setUIMode(UIMode.PANEL);
+ }
+ }
+
+ /**
+ * @return true if this panel requires authentication.
+ */
+ private boolean requiresAuth() {
+ return mPanelConfig.getAuthConfig() != null;
+ }
+
+ /**
+ * Lazily creates layout for panel data.
+ */
+ private void createPanelLayout() {
+ final ContextMenuRegistry contextMenuRegistry = new ContextMenuRegistry() {
+ @Override
+ public void register(View view) {
+ registerForContextMenu(view);
+ }
+ };
+
+ switch (mPanelConfig.getLayoutType()) {
+ case FRAME:
+ final PanelDatasetHandler datasetHandler = new PanelDatasetHandler();
+ mPanelLayout = new FramePanelLayout(getActivity(), mPanelConfig, datasetHandler,
+ mUrlOpenListener, contextMenuRegistry);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized layout type in DynamicPanel");
+ }
+
+ Log.d(LOGTAG, "Created layout of type: " + mPanelConfig.getLayoutType());
+ mView.addView(mPanelLayout);
+ }
+
+ /**
+ * Lazily creates layout for authentication UI.
+ */
+ private void createPanelAuthLayout() {
+ mPanelAuthLayout = new PanelAuthLayout(getActivity(), mPanelConfig);
+ mView.addView(mPanelAuthLayout, 0);
+ }
+
+ private void setUIMode(UIMode mode) {
+ switch (mode) {
+ case PANEL:
+ if (mPanelAuthLayout != null) {
+ mPanelAuthLayout.setVisibility(View.GONE);
+ }
+ if (mPanelLayout == null) {
+ createPanelLayout();
+ }
+ mPanelLayout.setVisibility(View.VISIBLE);
+
+ // Only trigger a reload if the UI mode has changed
+ // (e.g. auth cache changes) and the fragment is allowed
+ // to load its contents. Any loaders associated with the
+ // panel layout will be automatically re-bound after a
+ // device rotation, no need to explicitly load it again.
+ if (mUIMode != mode && canLoad()) {
+ mPanelLayout.load();
+ }
+ break;
+
+ case AUTH:
+ if (mPanelLayout != null) {
+ mPanelLayout.setVisibility(View.GONE);
+ }
+ if (mPanelAuthLayout == null) {
+ createPanelAuthLayout();
+ }
+ mPanelAuthLayout.setVisibility(View.VISIBLE);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized UIMode in DynamicPanel");
+ }
+
+ mUIMode = mode;
+ }
+
+ /**
+ * Used by the PanelLayout to make load and reset requests to
+ * the holding fragment.
+ */
+ private class PanelDatasetHandler implements DatasetHandler {
+ @Override
+ public void requestDataset(DatasetRequest request) {
+ Log.d(LOGTAG, "Requesting request: " + request);
+
+ final Bundle bundle = new Bundle();
+ bundle.putParcelable(DATASET_REQUEST, request);
+
+ getLoaderManager().restartLoader(request.getViewIndex(),
+ bundle, mLoaderCallbacks);
+ }
+
+ @Override
+ public void resetDataset(int viewIndex) {
+ Log.d(LOGTAG, "Resetting dataset: " + viewIndex);
+
+ final LoaderManager lm = getLoaderManager();
+
+ // Release any resources associated with the dataset if
+ // it's currently loaded in memory.
+ final Loader<?> datasetLoader = lm.getLoader(viewIndex);
+ if (datasetLoader != null) {
+ datasetLoader.reset();
+ }
+ }
+ }
+
+ /**
+ * Cursor loader for the panel datasets.
+ */
+ private static class PanelDatasetLoader extends SimpleCursorLoader {
+ private DatasetRequest mRequest;
+
+ public PanelDatasetLoader(Context context, DatasetRequest request) {
+ super(context);
+ mRequest = request;
+ }
+
+ public DatasetRequest getRequest() {
+ return mRequest;
+ }
+
+ @Override
+ public void onContentChanged() {
+ // Ensure the refresh request doesn't affect the view's filter
+ // stack (i.e. use DATASET_LOAD type) but keep the current
+ // dataset ID and filter.
+ final DatasetRequest newRequest =
+ new DatasetRequest(mRequest.getViewIndex(),
+ DatasetRequest.Type.DATASET_LOAD,
+ mRequest.getDatasetId(),
+ mRequest.getFilterDetail());
+
+ mRequest = newRequest;
+ super.onContentChanged();
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final ContentResolver cr = getContext().getContentResolver();
+
+ final String selection;
+ final String[] selectionArgs;
+
+ // Null represents the root filter
+ if (mRequest.getFilter() == null) {
+ selection = HomeItems.FILTER + " IS NULL";
+ selectionArgs = null;
+ } else {
+ selection = HomeItems.FILTER + " = ?";
+ selectionArgs = new String[] { mRequest.getFilter() };
+ }
+
+ final Uri queryUri = HomeItems.CONTENT_URI.buildUpon()
+ .appendQueryParameter(BrowserContract.PARAM_DATASET_ID,
+ mRequest.getDatasetId())
+ .appendQueryParameter(BrowserContract.PARAM_LIMIT,
+ String.valueOf(RESULT_LIMIT))
+ .build();
+
+ // XXX: You can use HomeItems.CONTENT_FAKE_URI for development
+ // to pull items from fake_home_items.json.
+ return cr.query(queryUri, null, selection, selectionArgs, null);
+ }
+ }
+
+ /**
+ * LoaderCallbacks implementation that interacts with the LoaderManager.
+ */
+ private class PanelLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final DatasetRequest request = (DatasetRequest) args.getParcelable(DATASET_REQUEST);
+
+ Log.d(LOGTAG, "Creating loader for request: " + request);
+ return new PanelDatasetLoader(getActivity(), request);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ final DatasetRequest request = getRequestFromLoader(loader);
+ Log.d(LOGTAG, "Finished loader for request: " + request);
+
+ if (mPanelLayout != null) {
+ mPanelLayout.deliverDataset(request, cursor);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ final DatasetRequest request = getRequestFromLoader(loader);
+ Log.d(LOGTAG, "Resetting loader for request: " + request);
+
+ if (mPanelLayout != null) {
+ mPanelLayout.releaseDataset(request.getViewIndex());
+ }
+ }
+
+ private DatasetRequest getRequestFromLoader(Loader<Cursor> loader) {
+ final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader;
+ return datasetLoader.getRequest();
+ }
+ }
+
+ private class PanelAuthChangeListener implements PanelAuthCache.OnChangeListener {
+ @Override
+ public void onChange(String panelId, boolean isAuthenticated) {
+ if (!mPanelConfig.getId().equals(panelId)) {
+ return;
+ }
+
+ setUIMode(isAuthenticated ? UIMode.PANEL : UIMode.AUTH);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java
new file mode 100644
index 000000000..7168c1576
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/FramePanelLayout.java
@@ -0,0 +1,52 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.View;
+
+class FramePanelLayout extends PanelLayout {
+ private static final String LOGTAG = "GeckoFramePanelLayout";
+
+ private final View mChildView;
+ private final ViewConfig mChildConfig;
+
+ public FramePanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler,
+ OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) {
+ super(context, panelConfig, datasetHandler, urlOpenListener, contextMenuRegistry);
+
+ // This layout can only hold one view so we simply
+ // take the first defined view from PanelConfig.
+ mChildConfig = panelConfig.getViewAt(0);
+ if (mChildConfig == null) {
+ throw new IllegalStateException("FramePanelLayout requires a view in PanelConfig");
+ }
+
+ mChildView = createPanelView(mChildConfig);
+ addView(mChildView);
+ }
+
+ @Override
+ public void load() {
+ Log.d(LOGTAG, "Loading");
+
+ if (mChildView instanceof DatasetBacked) {
+ final FilterDetail filter = new FilterDetail(mChildConfig.getFilter(), null);
+
+ final DatasetRequest request = new DatasetRequest(mChildConfig.getIndex(),
+ mChildConfig.getDatasetId(),
+ filter);
+
+ Log.d(LOGTAG, "Requesting child request: " + request);
+ requestDataset(request);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
new file mode 100644
index 000000000..7a49559f6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HistorySectionsHelper.java
@@ -0,0 +1,80 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.res.Resources;
+
+import org.mozilla.gecko.home.CombinedHistoryAdapter.SectionHeader;
+import org.mozilla.gecko.R;
+
+import java.util.Calendar;
+import java.util.Locale;
+
+
+public class HistorySectionsHelper {
+
+ // Constants for different time sections.
+ private static final long MS_PER_DAY = 86400000;
+ private static final long MS_PER_WEEK = MS_PER_DAY * 7;
+
+ public static class SectionDateRange {
+ public final long start;
+ public final long end;
+ public final String displayName;
+
+ private SectionDateRange(long start, long end, String displayName) {
+ this.start = start;
+ this.end = end;
+ this.displayName = displayName;
+ }
+ }
+
+ /**
+ * Updates the time range in milliseconds covered by each section header and sets the title.
+ * @param res Resources for fetching string names
+ * @param sectionsArray Array of section bookkeeping objects
+ */
+ public static void updateRecentSectionOffset(final Resources res, SectionDateRange[] sectionsArray) {
+ final long now = System.currentTimeMillis();
+ final Calendar cal = Calendar.getInstance();
+
+ // Update calendar to this day.
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 1);
+ final long currentDayMS = cal.getTimeInMillis();
+
+ // Calculate the start and end time for each section header and set its display text.
+ sectionsArray[SectionHeader.TODAY.ordinal()] =
+ new SectionDateRange(currentDayMS, now, res.getString(R.string.history_today_section));
+
+ sectionsArray[SectionHeader.YESTERDAY.ordinal()] =
+ new SectionDateRange(currentDayMS - MS_PER_DAY, currentDayMS, res.getString(R.string.history_yesterday_section));
+
+ sectionsArray[SectionHeader.WEEK.ordinal()] =
+ new SectionDateRange(currentDayMS - MS_PER_WEEK, now, res.getString(R.string.history_week_section));
+
+ // Update the calendar to beginning of next month to avoid problems calculating the last day of this month.
+ cal.add(Calendar.MONTH, 1);
+ cal.set(Calendar.DAY_OF_MONTH, cal.getMinimum(Calendar.DAY_OF_MONTH));
+
+ // Iterate over the remaining history sections, moving backwards in time.
+ for (int i = SectionHeader.THIS_MONTH.ordinal(); i < SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal(); i++) {
+ final long end = cal.getTimeInMillis();
+
+ cal.add(Calendar.MONTH, -1);
+ final long start = cal.getTimeInMillis();
+
+ final String displayName = cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault());
+
+ sectionsArray[i] = new SectionDateRange(start, end, displayName);
+ }
+
+ sectionsArray[SectionHeader.OLDER_THAN_SIX_MONTHS.ordinal()] =
+ new SectionDateRange(0L, cal.getTimeInMillis(), res.getString(R.string.history_older_section));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java
new file mode 100644
index 000000000..98d1ae6d8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeAdapter.java
@@ -0,0 +1,224 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.home.activitystream.ActivityStreamHomeFragment;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentStatePagerAdapter;
+import android.view.ViewGroup;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class HomeAdapter extends FragmentStatePagerAdapter {
+
+ private final Context mContext;
+ private final ArrayList<PanelInfo> mPanelInfos;
+ private final Map<String, HomeFragment> mPanels;
+ private final Map<String, Bundle> mRestoreBundles;
+
+ private boolean mCanLoadHint;
+
+ private OnAddPanelListener mAddPanelListener;
+
+ private HomeFragment.PanelStateChangeListener mPanelStateChangeListener = null;
+
+ public interface OnAddPanelListener {
+ void onAddPanel(String title);
+ }
+
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+ mPanelStateChangeListener = listener;
+
+ for (Fragment fragment : mPanels.values()) {
+ ((HomeFragment) fragment).setPanelStateChangeListener(listener);
+ }
+ }
+
+ public HomeAdapter(Context context, FragmentManager fm) {
+ super(fm);
+
+ mContext = context;
+ mCanLoadHint = HomeFragment.DEFAULT_CAN_LOAD_HINT;
+
+ mPanelInfos = new ArrayList<>();
+ mPanels = new HashMap<>();
+ mRestoreBundles = new HashMap<>();
+ }
+
+ @Override
+ public int getCount() {
+ return mPanelInfos.size();
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ PanelInfo info = mPanelInfos.get(position);
+ return Fragment.instantiate(mContext, info.getClassName(mContext), info.getArgs());
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ if (mPanelInfos.size() > 0) {
+ PanelInfo info = mPanelInfos.get(position);
+ return info.getTitle().toUpperCase();
+ }
+
+ return null;
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ final HomeFragment fragment = (HomeFragment) super.instantiateItem(container, position);
+ fragment.setPanelStateChangeListener(mPanelStateChangeListener);
+
+ final String id = mPanelInfos.get(position).getId();
+ mPanels.put(id, fragment);
+
+ if (mRestoreBundles.containsKey(id)) {
+ fragment.restoreData(mRestoreBundles.get(id));
+ mRestoreBundles.remove(id);
+ }
+
+ return fragment;
+ }
+
+ public void setRestoreData(int position, Bundle data) {
+ final String id = mPanelInfos.get(position).getId();
+ final HomeFragment fragment = mPanels.get(id);
+
+ // We have no guarantees as to whether our desired fragment is instantiated yet: therefore
+ // we might need to either pass data to the fragment, or store it for later.
+ if (fragment != null) {
+ fragment.restoreData(data);
+ } else {
+ mRestoreBundles.put(id, data);
+ }
+
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ final String id = mPanelInfos.get(position).getId();
+
+ super.destroyItem(container, position, object);
+ mPanels.remove(id);
+ }
+
+ public void setOnAddPanelListener(OnAddPanelListener listener) {
+ mAddPanelListener = listener;
+ }
+
+ public int getItemPosition(String panelId) {
+ for (int i = 0; i < mPanelInfos.size(); i++) {
+ final String id = mPanelInfos.get(i).getId();
+ if (id.equals(panelId)) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ public String getPanelIdAtPosition(int position) {
+ // getPanelIdAtPosition() might be called before HomeAdapter
+ // has got its initial list of PanelConfigs. Just bail.
+ if (mPanelInfos.isEmpty()) {
+ return null;
+ }
+
+ return mPanelInfos.get(position).getId();
+ }
+
+ private void addPanel(PanelInfo info) {
+ mPanelInfos.add(info);
+
+ if (mAddPanelListener != null) {
+ mAddPanelListener.onAddPanel(info.getTitle());
+ }
+ }
+
+ public void update(List<PanelConfig> panelConfigs) {
+ mPanels.clear();
+ mPanelInfos.clear();
+
+ if (panelConfigs != null) {
+ for (PanelConfig panelConfig : panelConfigs) {
+ final PanelInfo info = new PanelInfo(panelConfig);
+ addPanel(info);
+ }
+ }
+
+ notifyDataSetChanged();
+ }
+
+ public boolean getCanLoadHint() {
+ return mCanLoadHint;
+ }
+
+ public void setCanLoadHint(boolean canLoadHint) {
+ // We cache the last hint value so that we can use it when
+ // creating new panels. See PanelInfo.getArgs().
+ mCanLoadHint = canLoadHint;
+
+ // Enable/disable loading on all existing panels
+ for (Fragment panelFragment : mPanels.values()) {
+ final HomeFragment panel = (HomeFragment) panelFragment;
+ panel.setCanLoadHint(canLoadHint);
+ }
+ }
+
+ private final class PanelInfo {
+ private final PanelConfig mPanelConfig;
+
+ PanelInfo(PanelConfig panelConfig) {
+ mPanelConfig = panelConfig;
+ }
+
+ public String getId() {
+ return mPanelConfig.getId();
+ }
+
+ public String getTitle() {
+ return mPanelConfig.getTitle();
+ }
+
+ public String getClassName(Context context) {
+ final PanelType type = mPanelConfig.getType();
+
+ // Override top_sites with ActivityStream panel when enabled
+ // PanelType.toString() returns the panel id
+ if (type.toString() == "top_sites" &&
+ ActivityStream.isEnabled(context) &&
+ ActivityStream.isHomePanel()) {
+ return ActivityStreamHomeFragment.class.getName();
+ }
+ return type.getPanelClass().getName();
+ }
+
+ public Bundle getArgs() {
+ final Bundle args = new Bundle();
+
+ args.putBoolean(HomePager.CAN_LOAD_ARG, mCanLoadHint);
+
+ // Only DynamicPanels need the PanelConfig argument
+ if (mPanelConfig.isDynamic()) {
+ args.putParcelable(HomePager.PANEL_CONFIG_ARG, mPanelConfig);
+ }
+
+ return args;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java
new file mode 100644
index 000000000..10f5db39e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeBanner.java
@@ -0,0 +1,315 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.Property;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.EllipsisTextView;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.Html;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+public class HomeBanner extends LinearLayout
+ implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoHomeBanner";
+
+ // Used for tracking scroll length
+ private float mTouchY = -1;
+
+ // Used to detect for upwards scroll to push banner all the way up
+ private boolean mSnapBannerToTop;
+
+ // Tracks whether or not the banner should be shown on the current panel.
+ private boolean mActive;
+
+ // The user is currently swiping between HomePager pages
+ private boolean mScrollingPages;
+
+ // Tracks whether the user swiped the banner down, preventing us from autoshowing when the user
+ // switches back to the default page.
+ private boolean mUserSwipedDown;
+
+ // We must use this custom TextView to address an issue on 2.3 and lower where ellipsized text
+ // will not wrap more than 2 lines.
+ private final EllipsisTextView mTextView;
+ private final ImageView mIconView;
+
+ // The height of the banner view.
+ private final float mHeight;
+
+ // Listener that gets called when the banner is dismissed from the close button.
+ private OnDismissListener mOnDismissListener;
+
+ public interface OnDismissListener {
+ public void onDismiss();
+ }
+
+ public HomeBanner(Context context) {
+ this(context, null);
+ }
+
+ public HomeBanner(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.home_banner_content, this);
+
+ mTextView = (EllipsisTextView) findViewById(R.id.text);
+ mIconView = (ImageView) findViewById(R.id.icon);
+
+ mHeight = getResources().getDimensionPixelSize(R.dimen.home_banner_height);
+
+ // Disable the banner until a message is set.
+ setEnabled(false);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ // Tapping on the close button will ensure that the banner is never
+ // showed again on this session.
+ final ImageButton closeButton = (ImageButton) findViewById(R.id.close);
+
+ // The drawable should have 50% opacity.
+ closeButton.getDrawable().setAlpha(127);
+
+ closeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ HomeBanner.this.dismiss();
+
+ // Send the current message id back to JS.
+ GeckoAppShell.notifyObservers("HomeBanner:Dismiss", (String) getTag());
+ }
+ });
+
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ HomeBanner.this.dismiss();
+
+ // Send the current message id back to JS.
+ GeckoAppShell.notifyObservers("HomeBanner:Click", (String) getTag());
+ }
+ });
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, "HomeBanner:Data");
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, "HomeBanner:Data");
+ }
+
+ public void setScrollingPages(boolean scrollingPages) {
+ mScrollingPages = scrollingPages;
+ }
+
+ public void setOnDismissListener(OnDismissListener listener) {
+ mOnDismissListener = listener;
+ }
+
+ /**
+ * Hides and disables the banner.
+ */
+ private void dismiss() {
+ setVisibility(View.GONE);
+ setEnabled(false);
+
+ if (mOnDismissListener != null) {
+ mOnDismissListener.onDismiss();
+ }
+ }
+
+ /**
+ * Sends a message to gecko to request a new banner message. UI is updated in handleMessage.
+ */
+ public void update() {
+ GeckoAppShell.notifyObservers("HomeBanner:Get", null);
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ final String id = message.optString("id");
+ final String text = message.optString("text");
+ final String iconURI = message.optString("iconURI");
+
+ // Don't update the banner if the message doesn't have valid id and text.
+ if (TextUtils.isEmpty(id) || TextUtils.isEmpty(text)) {
+ return;
+ }
+
+ // Update the banner message on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Store the current message id to pass back to JS in the view's OnClickListener.
+ setTag(id);
+ mTextView.setOriginalText(Html.fromHtml(text));
+
+ ResourceDrawableUtils.getDrawable(getContext(), iconURI, new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(final Drawable d) {
+ // Hide the image view if we don't have an icon to show.
+ if (d == null) {
+ mIconView.setVisibility(View.GONE);
+ } else {
+ mIconView.setImageDrawable(d);
+ mIconView.setVisibility(View.VISIBLE);
+ }
+ }
+ });
+
+ GeckoAppShell.notifyObservers("HomeBanner:Shown", id);
+
+ // Enable the banner after a message is set.
+ setEnabled(true);
+
+ // Animate the banner if it is currently active.
+ if (mActive) {
+ animateUp();
+ }
+ }
+ });
+ }
+
+ public void setActive(boolean active) {
+ // No need to animate if not changing
+ if (mActive == active) {
+ return;
+ }
+
+ mActive = active;
+
+ // Don't animate if the banner isn't enabled.
+ if (!isEnabled()) {
+ return;
+ }
+
+ if (active) {
+ animateUp();
+ } else {
+ animateDown();
+ }
+ }
+
+ private void ensureVisible() {
+ // The banner visibility is set to GONE after it is animated off screen,
+ // so we need to make it visible again.
+ if (getVisibility() == View.GONE) {
+ // Translate the banner off screen before setting it to VISIBLE.
+ ViewHelper.setTranslationY(this, mHeight);
+ setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void animateUp() {
+ // Don't try to animate if the user swiped the banner down previously to hide it.
+ if (mUserSwipedDown) {
+ return;
+ }
+
+ ensureVisible();
+
+ final PropertyAnimator animator = new PropertyAnimator(100);
+ animator.attach(this, Property.TRANSLATION_Y, 0);
+ animator.start();
+ }
+
+ private void animateDown() {
+ if (ViewHelper.getTranslationY(this) == mHeight) {
+ // Hide the banner to avoid intercepting clicks on pre-honeycomb devices.
+ setVisibility(View.GONE);
+ return;
+ }
+
+ final PropertyAnimator animator = new PropertyAnimator(100);
+ animator.attach(this, Property.TRANSLATION_Y, mHeight);
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ // Hide the banner to avoid intercepting clicks on pre-honeycomb devices.
+ setVisibility(View.GONE);
+ }
+ });
+ animator.start();
+ }
+
+ public void handleHomeTouch(MotionEvent event) {
+ if (!mActive || !isEnabled() || mScrollingPages) {
+ return;
+ }
+
+ ensureVisible();
+
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ // Track the beginning of the touch
+ mTouchY = event.getRawY();
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ final float curY = event.getRawY();
+ final float delta = mTouchY - curY;
+ mSnapBannerToTop = delta <= 0.0f;
+
+ float newTranslationY = ViewHelper.getTranslationY(this) + delta;
+
+ // Clamp the values to be between 0 and height.
+ if (newTranslationY < 0.0f) {
+ newTranslationY = 0.0f;
+ } else if (newTranslationY > mHeight) {
+ newTranslationY = mHeight;
+ }
+
+ // Don't change this value if it wasn't a significant movement
+ if (delta >= 10 || delta <= -10) {
+ mUserSwipedDown = (newTranslationY == mHeight);
+ }
+
+ ViewHelper.setTranslationY(this, newTranslationY);
+ mTouchY = curY;
+ break;
+ }
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL: {
+ mTouchY = -1;
+ if (mSnapBannerToTop) {
+ animateUp();
+ } else {
+ animateDown();
+ mUserSwipedDown = true;
+ }
+ break;
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java
new file mode 100644
index 000000000..08e79be3a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfig.java
@@ -0,0 +1,1694 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Pair;
+
+public final class HomeConfig {
+ public static final String PREF_KEY_BOOKMARKS_PANEL_ENABLED = "bookmarksPanelEnabled";
+ public static final String PREF_KEY_HISTORY_PANEL_ENABLED = "combinedHistoryPanelEnabled";
+
+ /**
+ * Used to determine what type of HomeFragment subclass to use when creating
+ * a given panel. With the exception of DYNAMIC, all of these types correspond
+ * to a default set of built-in panels. The DYNAMIC panel type is used by
+ * third-party services to create panels with varying types of content.
+ */
+ @RobocopTarget
+ public static enum PanelType implements Parcelable {
+ TOP_SITES("top_sites", TopSitesPanel.class),
+ BOOKMARKS("bookmarks", BookmarksPanel.class),
+ COMBINED_HISTORY("combined_history", CombinedHistoryPanel.class),
+ DYNAMIC("dynamic", DynamicPanel.class),
+ // Deprecated panels that should no longer exist but are kept around for
+ // migration code. Class references have been replaced with new version of the panel.
+ DEPRECATED_REMOTE_TABS("remote_tabs", CombinedHistoryPanel.class),
+ DEPRECATED_HISTORY("history", CombinedHistoryPanel.class),
+ DEPRECATED_READING_LIST("reading_list", BookmarksPanel.class),
+ DEPRECATED_RECENT_TABS("recent_tabs", CombinedHistoryPanel.class);
+
+ private final String mId;
+ private final Class<?> mPanelClass;
+
+ PanelType(String id, Class<?> panelClass) {
+ mId = id;
+ mPanelClass = panelClass;
+ }
+
+ public static PanelType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to PanelType");
+ }
+
+ for (PanelType panelType : PanelType.values()) {
+ if (TextUtils.equals(panelType.mId, id.toLowerCase())) {
+ return panelType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to PanelType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ public Class<?> getPanelClass() {
+ return mPanelClass;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<PanelType> CREATOR = new Creator<PanelType>() {
+ @Override
+ public PanelType createFromParcel(final Parcel source) {
+ return PanelType.values()[source.readInt()];
+ }
+
+ @Override
+ public PanelType[] newArray(final int size) {
+ return new PanelType[size];
+ }
+ };
+ }
+
+ public static class PanelConfig implements Parcelable {
+ private final PanelType mType;
+ private final String mTitle;
+ private final String mId;
+ private final LayoutType mLayoutType;
+ private final List<ViewConfig> mViews;
+ private final AuthConfig mAuthConfig;
+ private final EnumSet<Flags> mFlags;
+ private final int mPosition;
+
+ static final String JSON_KEY_TYPE = "type";
+ static final String JSON_KEY_TITLE = "title";
+ static final String JSON_KEY_ID = "id";
+ static final String JSON_KEY_LAYOUT = "layout";
+ static final String JSON_KEY_VIEWS = "views";
+ static final String JSON_KEY_AUTH_CONFIG = "authConfig";
+ static final String JSON_KEY_DEFAULT = "default";
+ static final String JSON_KEY_DISABLED = "disabled";
+ static final String JSON_KEY_POSITION = "position";
+
+ public enum Flags {
+ DEFAULT_PANEL,
+ DISABLED_PANEL
+ }
+
+ public PanelConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ final String panelType = json.optString(JSON_KEY_TYPE, null);
+ if (TextUtils.isEmpty(panelType)) {
+ mType = PanelType.DYNAMIC;
+ } else {
+ mType = PanelType.fromId(panelType);
+ }
+
+ mTitle = json.getString(JSON_KEY_TITLE);
+ mId = json.getString(JSON_KEY_ID);
+
+ final String layoutTypeId = json.optString(JSON_KEY_LAYOUT, null);
+ if (layoutTypeId != null) {
+ mLayoutType = LayoutType.fromId(layoutTypeId);
+ } else {
+ mLayoutType = null;
+ }
+
+ final JSONArray jsonViews = json.optJSONArray(JSON_KEY_VIEWS);
+ if (jsonViews != null) {
+ mViews = new ArrayList<ViewConfig>();
+
+ final int viewCount = jsonViews.length();
+ for (int i = 0; i < viewCount; i++) {
+ final JSONObject jsonViewConfig = (JSONObject) jsonViews.get(i);
+ final ViewConfig viewConfig = new ViewConfig(i, jsonViewConfig);
+ mViews.add(viewConfig);
+ }
+ } else {
+ mViews = null;
+ }
+
+ final JSONObject jsonAuthConfig = json.optJSONObject(JSON_KEY_AUTH_CONFIG);
+ if (jsonAuthConfig != null) {
+ mAuthConfig = new AuthConfig(jsonAuthConfig);
+ } else {
+ mAuthConfig = null;
+ }
+
+ mFlags = EnumSet.noneOf(Flags.class);
+
+ if (json.optBoolean(JSON_KEY_DEFAULT, false)) {
+ mFlags.add(Flags.DEFAULT_PANEL);
+ }
+
+ if (json.optBoolean(JSON_KEY_DISABLED, false)) {
+ mFlags.add(Flags.DISABLED_PANEL);
+ }
+
+ mPosition = json.optInt(JSON_KEY_POSITION, -1);
+
+ validate();
+ }
+
+ @SuppressWarnings("unchecked")
+ public PanelConfig(Parcel in) {
+ mType = (PanelType) in.readParcelable(getClass().getClassLoader());
+ mTitle = in.readString();
+ mId = in.readString();
+ mLayoutType = (LayoutType) in.readParcelable(getClass().getClassLoader());
+
+ mViews = new ArrayList<ViewConfig>();
+ in.readTypedList(mViews, ViewConfig.CREATOR);
+
+ mAuthConfig = (AuthConfig) in.readParcelable(getClass().getClassLoader());
+
+ mFlags = (EnumSet<Flags>) in.readSerializable();
+ mPosition = in.readInt();
+
+ validate();
+ }
+
+ public PanelConfig(PanelConfig panelConfig) {
+ mType = panelConfig.mType;
+ mTitle = panelConfig.mTitle;
+ mId = panelConfig.mId;
+ mLayoutType = panelConfig.mLayoutType;
+
+ mViews = new ArrayList<ViewConfig>();
+ List<ViewConfig> viewConfigs = panelConfig.mViews;
+ if (viewConfigs != null) {
+ for (ViewConfig viewConfig : viewConfigs) {
+ mViews.add(new ViewConfig(viewConfig));
+ }
+ }
+
+ mAuthConfig = panelConfig.mAuthConfig;
+ mFlags = panelConfig.mFlags.clone();
+ mPosition = panelConfig.mPosition;
+
+ validate();
+ }
+
+ public PanelConfig(PanelType type, String title, String id) {
+ this(type, title, id, EnumSet.noneOf(Flags.class));
+ }
+
+ public PanelConfig(PanelType type, String title, String id, EnumSet<Flags> flags) {
+ this(type, title, id, null, null, null, flags, -1);
+ }
+
+ public PanelConfig(PanelType type, String title, String id, LayoutType layoutType,
+ List<ViewConfig> views, AuthConfig authConfig, EnumSet<Flags> flags, int position) {
+ mType = type;
+ mTitle = title;
+ mId = id;
+ mLayoutType = layoutType;
+ mViews = views;
+ mAuthConfig = authConfig;
+ mFlags = flags;
+ mPosition = position;
+
+ validate();
+ }
+
+ private void validate() {
+ if (mType == null) {
+ throw new IllegalArgumentException("Can't create PanelConfig with null type");
+ }
+
+ if (TextUtils.isEmpty(mTitle)) {
+ throw new IllegalArgumentException("Can't create PanelConfig with empty title");
+ }
+
+ if (TextUtils.isEmpty(mId)) {
+ throw new IllegalArgumentException("Can't create PanelConfig with empty id");
+ }
+
+ if (mLayoutType == null && mType == PanelType.DYNAMIC) {
+ throw new IllegalArgumentException("Can't create a dynamic PanelConfig with null layout type");
+ }
+
+ if ((mViews == null || mViews.size() == 0) && mType == PanelType.DYNAMIC) {
+ throw new IllegalArgumentException("Can't create a dynamic PanelConfig with no views");
+ }
+
+ if (mFlags == null) {
+ throw new IllegalArgumentException("Can't create PanelConfig with null flags");
+ }
+ }
+
+ public PanelType getType() {
+ return mType;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getId() {
+ return mId;
+ }
+
+ public LayoutType getLayoutType() {
+ return mLayoutType;
+ }
+
+ public int getViewCount() {
+ return (mViews != null ? mViews.size() : 0);
+ }
+
+ public ViewConfig getViewAt(int index) {
+ return (mViews != null ? mViews.get(index) : null);
+ }
+
+ public EnumSet<Flags> getFlags() {
+ return mFlags.clone();
+ }
+
+ public boolean isDynamic() {
+ return (mType == PanelType.DYNAMIC);
+ }
+
+ public boolean isDefault() {
+ return mFlags.contains(Flags.DEFAULT_PANEL);
+ }
+
+ private void setIsDefault(boolean isDefault) {
+ if (isDefault) {
+ mFlags.add(Flags.DEFAULT_PANEL);
+ } else {
+ mFlags.remove(Flags.DEFAULT_PANEL);
+ }
+ }
+
+ public boolean isDisabled() {
+ return mFlags.contains(Flags.DISABLED_PANEL);
+ }
+
+ private void setIsDisabled(boolean isDisabled) {
+ if (isDisabled) {
+ mFlags.add(Flags.DISABLED_PANEL);
+ } else {
+ mFlags.remove(Flags.DISABLED_PANEL);
+ }
+ }
+
+ public AuthConfig getAuthConfig() {
+ return mAuthConfig;
+ }
+
+ public int getPosition() {
+ return mPosition;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TYPE, mType.toString());
+ json.put(JSON_KEY_TITLE, mTitle);
+ json.put(JSON_KEY_ID, mId);
+
+ if (mLayoutType != null) {
+ json.put(JSON_KEY_LAYOUT, mLayoutType.toString());
+ }
+
+ if (mViews != null) {
+ final JSONArray jsonViews = new JSONArray();
+
+ final int viewCount = mViews.size();
+ for (int i = 0; i < viewCount; i++) {
+ final ViewConfig viewConfig = mViews.get(i);
+ final JSONObject jsonViewConfig = viewConfig.toJSON();
+ jsonViews.put(jsonViewConfig);
+ }
+
+ json.put(JSON_KEY_VIEWS, jsonViews);
+ }
+
+ if (mAuthConfig != null) {
+ json.put(JSON_KEY_AUTH_CONFIG, mAuthConfig.toJSON());
+ }
+
+ if (mFlags.contains(Flags.DEFAULT_PANEL)) {
+ json.put(JSON_KEY_DEFAULT, true);
+ }
+
+ if (mFlags.contains(Flags.DISABLED_PANEL)) {
+ json.put(JSON_KEY_DISABLED, true);
+ }
+
+ json.put(JSON_KEY_POSITION, mPosition);
+
+ return json;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null) {
+ return false;
+ }
+
+ if (this == o) {
+ return true;
+ }
+
+ if (!(o instanceof PanelConfig)) {
+ return false;
+ }
+
+ final PanelConfig other = (PanelConfig) o;
+ return mId.equals(other.mId);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mTitle);
+ dest.writeString(mId);
+ dest.writeParcelable(mLayoutType, 0);
+ dest.writeTypedList(mViews);
+ dest.writeParcelable(mAuthConfig, 0);
+ dest.writeSerializable(mFlags);
+ dest.writeInt(mPosition);
+ }
+
+ public static final Creator<PanelConfig> CREATOR = new Creator<PanelConfig>() {
+ @Override
+ public PanelConfig createFromParcel(final Parcel in) {
+ return new PanelConfig(in);
+ }
+
+ @Override
+ public PanelConfig[] newArray(final int size) {
+ return new PanelConfig[size];
+ }
+ };
+ }
+
+ public static enum LayoutType implements Parcelable {
+ FRAME("frame");
+
+ private final String mId;
+
+ LayoutType(String id) {
+ mId = id;
+ }
+
+ public static LayoutType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to LayoutType");
+ }
+
+ for (LayoutType layoutType : LayoutType.values()) {
+ if (TextUtils.equals(layoutType.mId, id.toLowerCase())) {
+ return layoutType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to LayoutType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<LayoutType> CREATOR = new Creator<LayoutType>() {
+ @Override
+ public LayoutType createFromParcel(final Parcel source) {
+ return LayoutType.values()[source.readInt()];
+ }
+
+ @Override
+ public LayoutType[] newArray(final int size) {
+ return new LayoutType[size];
+ }
+ };
+ }
+
+ public static enum ViewType implements Parcelable {
+ LIST("list"),
+ GRID("grid");
+
+ private final String mId;
+
+ ViewType(String id) {
+ mId = id;
+ }
+
+ public static ViewType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ViewType");
+ }
+
+ for (ViewType viewType : ViewType.values()) {
+ if (TextUtils.equals(viewType.mId, id.toLowerCase())) {
+ return viewType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ViewType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ViewType> CREATOR = new Creator<ViewType>() {
+ @Override
+ public ViewType createFromParcel(final Parcel source) {
+ return ViewType.values()[source.readInt()];
+ }
+
+ @Override
+ public ViewType[] newArray(final int size) {
+ return new ViewType[size];
+ }
+ };
+ }
+
+ public static enum ItemType implements Parcelable {
+ ARTICLE("article"),
+ IMAGE("image"),
+ ICON("icon");
+
+ private final String mId;
+
+ ItemType(String id) {
+ mId = id;
+ }
+
+ public static ItemType fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ItemType");
+ }
+
+ for (ItemType itemType : ItemType.values()) {
+ if (TextUtils.equals(itemType.mId, id.toLowerCase())) {
+ return itemType;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ItemType");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ItemType> CREATOR = new Creator<ItemType>() {
+ @Override
+ public ItemType createFromParcel(final Parcel source) {
+ return ItemType.values()[source.readInt()];
+ }
+
+ @Override
+ public ItemType[] newArray(final int size) {
+ return new ItemType[size];
+ }
+ };
+ }
+
+ public static enum ItemHandler implements Parcelable {
+ BROWSER("browser"),
+ INTENT("intent");
+
+ private final String mId;
+
+ ItemHandler(String id) {
+ mId = id;
+ }
+
+ public static ItemHandler fromId(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("Could not convert null String to ItemHandler");
+ }
+
+ for (ItemHandler itemHandler : ItemHandler.values()) {
+ if (TextUtils.equals(itemHandler.mId, id.toLowerCase())) {
+ return itemHandler;
+ }
+ }
+
+ throw new IllegalArgumentException("Could not convert String id to ItemHandler");
+ }
+
+ @Override
+ public String toString() {
+ return mId;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<ItemHandler> CREATOR = new Creator<ItemHandler>() {
+ @Override
+ public ItemHandler createFromParcel(final Parcel source) {
+ return ItemHandler.values()[source.readInt()];
+ }
+
+ @Override
+ public ItemHandler[] newArray(final int size) {
+ return new ItemHandler[size];
+ }
+ };
+ }
+
+ public static class ViewConfig implements Parcelable {
+ private final int mIndex;
+ private final ViewType mType;
+ private final String mDatasetId;
+ private final ItemType mItemType;
+ private final ItemHandler mItemHandler;
+ private final String mBackImageUrl;
+ private final String mFilter;
+ private final EmptyViewConfig mEmptyViewConfig;
+ private final HeaderConfig mHeaderConfig;
+ private final EnumSet<Flags> mFlags;
+
+ static final String JSON_KEY_TYPE = "type";
+ static final String JSON_KEY_DATASET = "dataset";
+ static final String JSON_KEY_ITEM_TYPE = "itemType";
+ static final String JSON_KEY_ITEM_HANDLER = "itemHandler";
+ static final String JSON_KEY_BACK_IMAGE_URL = "backImageUrl";
+ static final String JSON_KEY_FILTER = "filter";
+ static final String JSON_KEY_EMPTY = "empty";
+ static final String JSON_KEY_HEADER = "header";
+ static final String JSON_KEY_REFRESH_ENABLED = "refreshEnabled";
+
+ public enum Flags {
+ REFRESH_ENABLED
+ }
+
+ public ViewConfig(int index, JSONObject json) throws JSONException, IllegalArgumentException {
+ mIndex = index;
+ mType = ViewType.fromId(json.getString(JSON_KEY_TYPE));
+ mDatasetId = json.getString(JSON_KEY_DATASET);
+ mItemType = ItemType.fromId(json.getString(JSON_KEY_ITEM_TYPE));
+ mItemHandler = ItemHandler.fromId(json.getString(JSON_KEY_ITEM_HANDLER));
+ mBackImageUrl = json.optString(JSON_KEY_BACK_IMAGE_URL, null);
+ mFilter = json.optString(JSON_KEY_FILTER, null);
+
+ final JSONObject jsonEmptyViewConfig = json.optJSONObject(JSON_KEY_EMPTY);
+ if (jsonEmptyViewConfig != null) {
+ mEmptyViewConfig = new EmptyViewConfig(jsonEmptyViewConfig);
+ } else {
+ mEmptyViewConfig = null;
+ }
+
+ final JSONObject jsonHeaderConfig = json.optJSONObject(JSON_KEY_HEADER);
+ mHeaderConfig = jsonHeaderConfig != null ? new HeaderConfig(jsonHeaderConfig) : null;
+
+ mFlags = EnumSet.noneOf(Flags.class);
+ if (json.optBoolean(JSON_KEY_REFRESH_ENABLED, false)) {
+ mFlags.add(Flags.REFRESH_ENABLED);
+ }
+
+ validate();
+ }
+
+ @SuppressWarnings("unchecked")
+ public ViewConfig(Parcel in) {
+ mIndex = in.readInt();
+ mType = (ViewType) in.readParcelable(getClass().getClassLoader());
+ mDatasetId = in.readString();
+ mItemType = (ItemType) in.readParcelable(getClass().getClassLoader());
+ mItemHandler = (ItemHandler) in.readParcelable(getClass().getClassLoader());
+ mBackImageUrl = in.readString();
+ mFilter = in.readString();
+ mEmptyViewConfig = (EmptyViewConfig) in.readParcelable(getClass().getClassLoader());
+ mHeaderConfig = (HeaderConfig) in.readParcelable(getClass().getClassLoader());
+ mFlags = (EnumSet<Flags>) in.readSerializable();
+
+ validate();
+ }
+
+ public ViewConfig(ViewConfig viewConfig) {
+ mIndex = viewConfig.mIndex;
+ mType = viewConfig.mType;
+ mDatasetId = viewConfig.mDatasetId;
+ mItemType = viewConfig.mItemType;
+ mItemHandler = viewConfig.mItemHandler;
+ mBackImageUrl = viewConfig.mBackImageUrl;
+ mFilter = viewConfig.mFilter;
+ mEmptyViewConfig = viewConfig.mEmptyViewConfig;
+ mHeaderConfig = viewConfig.mHeaderConfig;
+ mFlags = viewConfig.mFlags.clone();
+
+ validate();
+ }
+
+ private void validate() {
+ if (mType == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null type");
+ }
+
+ if (TextUtils.isEmpty(mDatasetId)) {
+ throw new IllegalArgumentException("Can't create ViewConfig with empty dataset ID");
+ }
+
+ if (mItemType == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null item type");
+ }
+
+ if (mItemHandler == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null item handler");
+ }
+
+ if (mFlags == null) {
+ throw new IllegalArgumentException("Can't create ViewConfig with null flags");
+ }
+ }
+
+ public int getIndex() {
+ return mIndex;
+ }
+
+ public ViewType getType() {
+ return mType;
+ }
+
+ public String getDatasetId() {
+ return mDatasetId;
+ }
+
+ public ItemType getItemType() {
+ return mItemType;
+ }
+
+ public ItemHandler getItemHandler() {
+ return mItemHandler;
+ }
+
+ public String getBackImageUrl() {
+ return mBackImageUrl;
+ }
+
+ public String getFilter() {
+ return mFilter;
+ }
+
+ public EmptyViewConfig getEmptyViewConfig() {
+ return mEmptyViewConfig;
+ }
+
+ public HeaderConfig getHeaderConfig() {
+ return mHeaderConfig;
+ }
+
+ public boolean hasHeaderConfig() {
+ return mHeaderConfig != null;
+ }
+
+ public boolean isRefreshEnabled() {
+ return mFlags.contains(Flags.REFRESH_ENABLED);
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TYPE, mType.toString());
+ json.put(JSON_KEY_DATASET, mDatasetId);
+ json.put(JSON_KEY_ITEM_TYPE, mItemType.toString());
+ json.put(JSON_KEY_ITEM_HANDLER, mItemHandler.toString());
+
+ if (!TextUtils.isEmpty(mBackImageUrl)) {
+ json.put(JSON_KEY_BACK_IMAGE_URL, mBackImageUrl);
+ }
+
+ if (!TextUtils.isEmpty(mFilter)) {
+ json.put(JSON_KEY_FILTER, mFilter);
+ }
+
+ if (mEmptyViewConfig != null) {
+ json.put(JSON_KEY_EMPTY, mEmptyViewConfig.toJSON());
+ }
+
+ if (mHeaderConfig != null) {
+ json.put(JSON_KEY_HEADER, mHeaderConfig.toJSON());
+ }
+
+ if (mFlags.contains(Flags.REFRESH_ENABLED)) {
+ json.put(JSON_KEY_REFRESH_ENABLED, true);
+ }
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mIndex);
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mDatasetId);
+ dest.writeParcelable(mItemType, 0);
+ dest.writeParcelable(mItemHandler, 0);
+ dest.writeString(mBackImageUrl);
+ dest.writeString(mFilter);
+ dest.writeParcelable(mEmptyViewConfig, 0);
+ dest.writeParcelable(mHeaderConfig, 0);
+ dest.writeSerializable(mFlags);
+ }
+
+ public static final Creator<ViewConfig> CREATOR = new Creator<ViewConfig>() {
+ @Override
+ public ViewConfig createFromParcel(final Parcel in) {
+ return new ViewConfig(in);
+ }
+
+ @Override
+ public ViewConfig[] newArray(final int size) {
+ return new ViewConfig[size];
+ }
+ };
+ }
+
+ public static class EmptyViewConfig implements Parcelable {
+ private final String mText;
+ private final String mImageUrl;
+
+ static final String JSON_KEY_TEXT = "text";
+ static final String JSON_KEY_IMAGE_URL = "imageUrl";
+
+ public EmptyViewConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ mText = json.optString(JSON_KEY_TEXT, null);
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null);
+ }
+
+ public EmptyViewConfig(Parcel in) {
+ mText = in.readString();
+ mImageUrl = in.readString();
+ }
+
+ public EmptyViewConfig(EmptyViewConfig emptyViewConfig) {
+ mText = emptyViewConfig.mText;
+ mImageUrl = emptyViewConfig.mImageUrl;
+ }
+
+ public EmptyViewConfig(String text, String imageUrl) {
+ mText = text;
+ mImageUrl = imageUrl;
+ }
+
+ public String getText() {
+ return mText;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_TEXT, mText);
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mText);
+ dest.writeString(mImageUrl);
+ }
+
+ public static final Creator<EmptyViewConfig> CREATOR = new Creator<EmptyViewConfig>() {
+ @Override
+ public EmptyViewConfig createFromParcel(final Parcel in) {
+ return new EmptyViewConfig(in);
+ }
+
+ @Override
+ public EmptyViewConfig[] newArray(final int size) {
+ return new EmptyViewConfig[size];
+ }
+ };
+ }
+
+ public static class HeaderConfig implements Parcelable {
+ static final String JSON_KEY_IMAGE_URL = "image_url";
+ static final String JSON_KEY_URL = "url";
+
+ private final String mImageUrl;
+ private final String mUrl;
+
+ public HeaderConfig(JSONObject json) {
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL);
+ mUrl = json.optString(JSON_KEY_URL);
+ }
+
+ public HeaderConfig(Parcel in) {
+ mImageUrl = in.readString();
+ mUrl = in.readString();
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public String getUrl() {
+ return mUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+ json.put(JSON_KEY_URL, mUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mImageUrl);
+ dest.writeString(mUrl);
+ }
+
+ public static final Creator<HeaderConfig> CREATOR = new Creator<HeaderConfig>() {
+ @Override
+ public HeaderConfig createFromParcel(Parcel source) {
+ return new HeaderConfig(source);
+ }
+
+ @Override
+ public HeaderConfig[] newArray(int size) {
+ return new HeaderConfig[size];
+ }
+ };
+ }
+
+ public static class AuthConfig implements Parcelable {
+ private final String mMessageText;
+ private final String mButtonText;
+ private final String mImageUrl;
+
+ static final String JSON_KEY_MESSAGE_TEXT = "messageText";
+ static final String JSON_KEY_BUTTON_TEXT = "buttonText";
+ static final String JSON_KEY_IMAGE_URL = "imageUrl";
+
+ public AuthConfig(JSONObject json) throws JSONException, IllegalArgumentException {
+ mMessageText = json.optString(JSON_KEY_MESSAGE_TEXT);
+ mButtonText = json.optString(JSON_KEY_BUTTON_TEXT);
+ mImageUrl = json.optString(JSON_KEY_IMAGE_URL, null);
+ }
+
+ public AuthConfig(Parcel in) {
+ mMessageText = in.readString();
+ mButtonText = in.readString();
+ mImageUrl = in.readString();
+
+ validate();
+ }
+
+ public AuthConfig(AuthConfig authConfig) {
+ mMessageText = authConfig.mMessageText;
+ mButtonText = authConfig.mButtonText;
+ mImageUrl = authConfig.mImageUrl;
+
+ validate();
+ }
+
+ public AuthConfig(String messageText, String buttonText, String imageUrl) {
+ mMessageText = messageText;
+ mButtonText = buttonText;
+ mImageUrl = imageUrl;
+
+ validate();
+ }
+
+ private void validate() {
+ if (mMessageText == null) {
+ throw new IllegalArgumentException("Can't create AuthConfig with null message text");
+ }
+
+ if (mButtonText == null) {
+ throw new IllegalArgumentException("Can't create AuthConfig with null button text");
+ }
+ }
+
+ public String getMessageText() {
+ return mMessageText;
+ }
+
+ public String getButtonText() {
+ return mButtonText;
+ }
+
+ public String getImageUrl() {
+ return mImageUrl;
+ }
+
+ public JSONObject toJSON() throws JSONException {
+ final JSONObject json = new JSONObject();
+
+ json.put(JSON_KEY_MESSAGE_TEXT, mMessageText);
+ json.put(JSON_KEY_BUTTON_TEXT, mButtonText);
+ json.put(JSON_KEY_IMAGE_URL, mImageUrl);
+
+ return json;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mMessageText);
+ dest.writeString(mButtonText);
+ dest.writeString(mImageUrl);
+ }
+
+ public static final Creator<AuthConfig> CREATOR = new Creator<AuthConfig>() {
+ @Override
+ public AuthConfig createFromParcel(final Parcel in) {
+ return new AuthConfig(in);
+ }
+
+ @Override
+ public AuthConfig[] newArray(final int size) {
+ return new AuthConfig[size];
+ }
+ };
+ }
+ /**
+ * Immutable representation of the current state of {@code HomeConfig}.
+ * This is what HomeConfig returns from a load() call and takes as
+ * input to save a new state.
+ *
+ * Users of {@code State} should use an {@code Iterator} to iterate
+ * through the contained {@code PanelConfig} instances.
+ *
+ * {@code State} is immutable i.e. you can't add, remove, or update
+ * contained elements directly. You have to use an {@code Editor} to
+ * change the state, which can be created through the {@code edit()}
+ * method.
+ */
+ public static class State implements Iterable<PanelConfig> {
+ private HomeConfig mHomeConfig;
+ private final List<PanelConfig> mPanelConfigs;
+ private final boolean mIsDefault;
+
+ State(List<PanelConfig> panelConfigs, boolean isDefault) {
+ this(null, panelConfigs, isDefault);
+ }
+
+ private State(HomeConfig homeConfig, List<PanelConfig> panelConfigs, boolean isDefault) {
+ mHomeConfig = homeConfig;
+ mPanelConfigs = Collections.unmodifiableList(panelConfigs);
+ mIsDefault = isDefault;
+ }
+
+ private void setHomeConfig(HomeConfig homeConfig) {
+ if (mHomeConfig != null) {
+ throw new IllegalStateException("Can't set HomeConfig more than once");
+ }
+
+ mHomeConfig = homeConfig;
+ }
+
+ @Override
+ public Iterator<PanelConfig> iterator() {
+ return mPanelConfigs.iterator();
+ }
+
+ /**
+ * Returns whether this {@code State} instance represents the default
+ * {@code HomeConfig} configuration or not.
+ */
+ public boolean isDefault() {
+ return mIsDefault;
+ }
+
+ /**
+ * Creates an {@code Editor} for this state.
+ */
+ public Editor edit() {
+ return new Editor(mHomeConfig, this);
+ }
+ }
+
+ /**
+ * {@code Editor} allows you to make changes to a {@code State}. You
+ * can create {@code Editor} by calling {@code edit()} on the target
+ * {@code State} instance.
+ *
+ * {@code Editor} works on a copy of the {@code State} that originated
+ * it. This means that adding, removing, or updating panels in an
+ * {@code Editor} will never change the {@code State} which you
+ * created the {@code Editor} from. Calling {@code commit()} or
+ * {@code apply()} will cause the new {@code State} instance to be
+ * created and saved using the {@code HomeConfig} instance that
+ * created the source {@code State}.
+ *
+ * {@code Editor} is *not* thread-safe. You can only make calls on it
+ * from the thread where it was originally created. It will throw an
+ * exception if you don't follow this invariant.
+ */
+ public static class Editor implements Iterable<PanelConfig> {
+ private final HomeConfig mHomeConfig;
+ private final Map<String, PanelConfig> mConfigMap;
+ private final List<String> mConfigOrder;
+ private final Thread mOriginalThread;
+
+ // Each Pair represents parameters to a GeckoAppShell.notifyObservers call;
+ // the first String is the observer topic and the second string is the notification data.
+ private List<Pair<String, String>> mNotificationQueue;
+ private PanelConfig mDefaultPanel;
+ private int mEnabledCount;
+
+ private boolean mHasChanged;
+ private final boolean mIsFromDefault;
+
+ private Editor(HomeConfig homeConfig, State configState) {
+ mHomeConfig = homeConfig;
+ mOriginalThread = Thread.currentThread();
+ mConfigMap = new HashMap<String, PanelConfig>();
+ mConfigOrder = new LinkedList<String>();
+ mNotificationQueue = new ArrayList<>();
+
+ mIsFromDefault = configState.isDefault();
+
+ initFromState(configState);
+ }
+
+ /**
+ * Initialize the initial state of the editor from the given
+ * {@sode State}. A HashMap is used to represent the list of
+ * panels as it provides fast access, and a LinkedList is used to
+ * keep track of order. We keep a reference to the default panel
+ * and the number of enabled panels to avoid iterating through the
+ * map every time we need those.
+ *
+ * @param configState The source State to load the editor from.
+ */
+ private void initFromState(State configState) {
+ for (PanelConfig panelConfig : configState) {
+ final PanelConfig panelCopy = new PanelConfig(panelConfig);
+
+ if (!panelCopy.isDisabled()) {
+ mEnabledCount++;
+ }
+
+ if (panelCopy.isDefault()) {
+ if (mDefaultPanel == null) {
+ mDefaultPanel = panelCopy;
+ } else {
+ throw new IllegalStateException("Multiple default panels in HomeConfig state");
+ }
+ }
+
+ final String panelId = panelConfig.getId();
+ mConfigOrder.add(panelId);
+ mConfigMap.put(panelId, panelCopy);
+ }
+
+ // We should always have a defined default panel if there's
+ // at least one enabled panel around.
+ if (mEnabledCount > 0 && mDefaultPanel == null) {
+ throw new IllegalStateException("Default panel in HomeConfig state is undefined");
+ }
+ }
+
+ private PanelConfig getPanelOrThrow(String panelId) {
+ final PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (panelConfig == null) {
+ throw new IllegalStateException("Tried to access non-existing panel: " + panelId);
+ }
+
+ return panelConfig;
+ }
+
+ private boolean isCurrentDefaultPanel(PanelConfig panelConfig) {
+ if (mDefaultPanel == null) {
+ return false;
+ }
+
+ return mDefaultPanel.equals(panelConfig);
+ }
+
+ private void findNewDefault() {
+ // Pick the first panel that is neither disabled nor currently
+ // set as default.
+ for (PanelConfig panelConfig : makeOrderedCopy(false)) {
+ if (!panelConfig.isDefault() && !panelConfig.isDisabled()) {
+ setDefault(panelConfig.getId());
+ return;
+ }
+ }
+
+ mDefaultPanel = null;
+ }
+
+ /**
+ * Makes an ordered list of PanelConfigs that can be references
+ * or deep copied objects.
+ *
+ * @param deepCopy true to make deep-copied objects
+ * @return ordered List of PanelConfigs
+ */
+ private List<PanelConfig> makeOrderedCopy(boolean deepCopy) {
+ final List<PanelConfig> copiedList = new ArrayList<PanelConfig>(mConfigOrder.size());
+ for (String panelId : mConfigOrder) {
+ PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (deepCopy) {
+ panelConfig = new PanelConfig(panelConfig);
+ }
+ copiedList.add(panelConfig);
+ }
+
+ return copiedList;
+ }
+
+ private void setPanelIsDisabled(PanelConfig panelConfig, boolean disabled) {
+ if (panelConfig.isDisabled() == disabled) {
+ return;
+ }
+
+ panelConfig.setIsDisabled(disabled);
+ mEnabledCount += (disabled ? -1 : 1);
+ }
+
+ /**
+ * Gets the ID of the current default panel.
+ */
+ public String getDefaultPanelId() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (mDefaultPanel == null) {
+ return null;
+ }
+
+ return mDefaultPanel.getId();
+ }
+
+ /**
+ * Set a new default panel.
+ *
+ * @param panelId the ID of the new default panel.
+ */
+ public void setDefault(String panelId) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = getPanelOrThrow(panelId);
+ if (isCurrentDefaultPanel(panelConfig)) {
+ return;
+ }
+
+ if (mDefaultPanel != null) {
+ mDefaultPanel.setIsDefault(false);
+ }
+
+ panelConfig.setIsDefault(true);
+ setPanelIsDisabled(panelConfig, false);
+
+ mDefaultPanel = panelConfig;
+ mHasChanged = true;
+ }
+
+ /**
+ * Toggles disabled state for a panel.
+ *
+ * @param panelId the ID of the target panel.
+ * @param disabled true to disable the panel.
+ */
+ public void setDisabled(String panelId, boolean disabled) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = getPanelOrThrow(panelId);
+ if (panelConfig.isDisabled() == disabled) {
+ return;
+ }
+
+ setPanelIsDisabled(panelConfig, disabled);
+
+ if (disabled) {
+ if (isCurrentDefaultPanel(panelConfig)) {
+ panelConfig.setIsDefault(false);
+ findNewDefault();
+ }
+ } else if (mEnabledCount == 1) {
+ setDefault(panelId);
+ }
+
+ mHasChanged = true;
+ }
+
+ /**
+ * Adds a new {@code PanelConfig}. It will do nothing if the
+ * {@code Editor} already contains a panel with the same ID.
+ *
+ * @param panelConfig the {@code PanelConfig} instance to be added.
+ * @return true if the item has been added.
+ */
+ public boolean install(PanelConfig panelConfig) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (panelConfig == null) {
+ throw new IllegalStateException("Can't install a null panel");
+ }
+
+ if (!panelConfig.isDynamic()) {
+ throw new IllegalStateException("Can't install a built-in panel: " + panelConfig.getId());
+ }
+
+ if (panelConfig.isDisabled()) {
+ throw new IllegalStateException("Can't install a disabled panel: " + panelConfig.getId());
+ }
+
+ boolean installed = false;
+
+ final String id = panelConfig.getId();
+ if (!mConfigMap.containsKey(id)) {
+ mConfigMap.put(id, panelConfig);
+
+ final int position = panelConfig.getPosition();
+ if (position < 0 || position >= mConfigOrder.size()) {
+ mConfigOrder.add(id);
+ } else {
+ mConfigOrder.add(position, id);
+ }
+
+ mEnabledCount++;
+ if (mEnabledCount == 1 || panelConfig.isDefault()) {
+ setDefault(panelConfig.getId());
+ }
+
+ installed = true;
+
+ // Add an event to the queue if a new panel is successfully installed.
+ mNotificationQueue.add(new Pair<String, String>(
+ "HomePanels:Installed", panelConfig.getId()));
+ }
+
+ mHasChanged = true;
+ return installed;
+ }
+
+ /**
+ * Removes an existing panel.
+ *
+ * @return true if the item has been removed.
+ */
+ public boolean uninstall(String panelId) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final PanelConfig panelConfig = mConfigMap.get(panelId);
+ if (panelConfig == null) {
+ return false;
+ }
+
+ if (!panelConfig.isDynamic()) {
+ throw new IllegalStateException("Can't uninstall a built-in panel: " + panelConfig.getId());
+ }
+
+ mConfigMap.remove(panelId);
+ mConfigOrder.remove(panelId);
+
+ if (!panelConfig.isDisabled()) {
+ mEnabledCount--;
+ }
+
+ if (isCurrentDefaultPanel(panelConfig)) {
+ findNewDefault();
+ }
+
+ // Add an event to the queue if a panel is successfully uninstalled.
+ mNotificationQueue.add(new Pair<String, String>("HomePanels:Uninstalled", panelId));
+
+ mHasChanged = true;
+ return true;
+ }
+
+ /**
+ * Moves panel associated with panelId to the specified position.
+ *
+ * @param panelId Id of panel
+ * @param destIndex Destination position
+ * @return true if move succeeded
+ */
+ public boolean moveTo(String panelId, int destIndex) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (!mConfigOrder.contains(panelId)) {
+ return false;
+ }
+
+ mConfigOrder.remove(panelId);
+ mConfigOrder.add(destIndex, panelId);
+ mHasChanged = true;
+
+ return true;
+ }
+
+ /**
+ * Replaces an existing panel with a new {@code PanelConfig} instance.
+ *
+ * @return true if the item has been updated.
+ */
+ public boolean update(PanelConfig panelConfig) {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ if (panelConfig == null) {
+ throw new IllegalStateException("Can't update a null panel");
+ }
+
+ boolean updated = false;
+
+ final String id = panelConfig.getId();
+ if (mConfigMap.containsKey(id)) {
+ final PanelConfig oldPanelConfig = mConfigMap.put(id, panelConfig);
+
+ // The disabled and default states can't never be
+ // changed by an update operation.
+ panelConfig.setIsDefault(oldPanelConfig.isDefault());
+ panelConfig.setIsDisabled(oldPanelConfig.isDisabled());
+
+ updated = true;
+ }
+
+ mHasChanged = true;
+ return updated;
+ }
+
+ /**
+ * Saves the current {@code Editor} state asynchronously in the
+ * background thread.
+ *
+ * @return the resulting {@code State} instance.
+ */
+ public State apply() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ // We're about to save the current state in the background thread
+ // so we should use a deep copy of the PanelConfig instances to
+ // avoid saving corrupted state.
+ final State newConfigState =
+ new State(mHomeConfig, makeOrderedCopy(true), isDefault());
+
+ // Copy the event queue to a new list, so that we only modify mNotificationQueue on
+ // the original thread where it was created.
+ final List<Pair<String, String>> copiedQueue = mNotificationQueue;
+ mNotificationQueue = new ArrayList<>();
+
+ ThreadUtils.getBackgroundHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ mHomeConfig.save(newConfigState);
+
+ // Send pending events after the new config is saved.
+ sendNotificationsToGecko(copiedQueue);
+ }
+ });
+
+ return newConfigState;
+ }
+
+ /**
+ * Saves the current {@code Editor} state synchronously in the
+ * current thread.
+ *
+ * @return the resulting {@code State} instance.
+ */
+ public State commit() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ final State newConfigState =
+ new State(mHomeConfig, makeOrderedCopy(false), isDefault());
+
+ // This is a synchronous blocking operation, hence no
+ // need to deep copy the current PanelConfig instances.
+ mHomeConfig.save(newConfigState);
+
+ // Send pending events after the new config is saved.
+ sendNotificationsToGecko(mNotificationQueue);
+ mNotificationQueue.clear();
+
+ return newConfigState;
+ }
+
+ /**
+ * Returns whether the {@code Editor} represents the default
+ * {@code HomeConfig} configuration without any unsaved changes.
+ */
+ public boolean isDefault() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ return (!mHasChanged && mIsFromDefault);
+ }
+
+ public boolean isEmpty() {
+ return mConfigMap.isEmpty();
+ }
+
+ private void sendNotificationsToGecko(List<Pair<String, String>> notifications) {
+ for (Pair<String, String> p : notifications) {
+ GeckoAppShell.notifyObservers(p.first, p.second);
+ }
+ }
+
+ private class EditorIterator implements Iterator<PanelConfig> {
+ private final Iterator<String> mOrderIterator;
+
+ public EditorIterator() {
+ mOrderIterator = mConfigOrder.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return mOrderIterator.hasNext();
+ }
+
+ @Override
+ public PanelConfig next() {
+ final String panelId = mOrderIterator.next();
+ return mConfigMap.get(panelId);
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException("Can't 'remove' from on Editor iterator.");
+ }
+ }
+
+ @Override
+ public Iterator<PanelConfig> iterator() {
+ ThreadUtils.assertOnThread(mOriginalThread);
+
+ return new EditorIterator();
+ }
+ }
+
+ public interface OnReloadListener {
+ public void onReload();
+ }
+
+ public interface HomeConfigBackend {
+ public State load();
+ public void save(State configState);
+ public String getLocale();
+ public void setOnReloadListener(OnReloadListener listener);
+ }
+
+ // UUIDs used to create PanelConfigs for default built-in panels. These are
+ // public because they can be used in "about:home?panel=UUID" query strings
+ // to open specific panels without querying the active Home Panel
+ // configuration. Because they don't consider the active configuration, it
+ // is only sensible to do this for built-in panels (and not for dynamic
+ // panels).
+ private static final String TOP_SITES_PANEL_ID = "4becc86b-41eb-429a-a042-88fe8b5a094e";
+ private static final String BOOKMARKS_PANEL_ID = "7f6d419a-cd6c-4e34-b26f-f68b1b551907";
+ private static final String HISTORY_PANEL_ID = "f134bf20-11f7-4867-ab8b-e8e705d7fbe8";
+ private static final String COMBINED_HISTORY_PANEL_ID = "4d716ce2-e063-486d-9e7c-b190d7b04dc6";
+ private static final String RECENT_TABS_PANEL_ID = "5c2601a5-eedc-4477-b297-ce4cef52adf8";
+ private static final String REMOTE_TABS_PANEL_ID = "72429afd-8d8b-43d8-9189-14b779c563d0";
+ private static final String DEPRECATED_READING_LIST_PANEL_ID = "20f4549a-64ad-4c32-93e4-1dcef792733b";
+
+ private final HomeConfigBackend mBackend;
+
+ public HomeConfig(HomeConfigBackend backend) {
+ mBackend = backend;
+ }
+
+ public State load() {
+ final State configState = mBackend.load();
+ configState.setHomeConfig(this);
+
+ return configState;
+ }
+
+ public String getLocale() {
+ return mBackend.getLocale();
+ }
+
+ public void save(State configState) {
+ mBackend.save(configState);
+ }
+
+ public void setOnReloadListener(OnReloadListener listener) {
+ mBackend.setOnReloadListener(listener);
+ }
+
+ public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType) {
+ return createBuiltinPanelConfig(context, panelType, EnumSet.noneOf(PanelConfig.Flags.class));
+ }
+
+ public static int getTitleResourceIdForBuiltinPanelType(PanelType panelType) {
+ switch (panelType) {
+ case TOP_SITES:
+ return R.string.home_top_sites_title;
+
+ case BOOKMARKS:
+ case DEPRECATED_READING_LIST:
+ return R.string.bookmarks_title;
+
+ case DEPRECATED_HISTORY:
+ case DEPRECATED_REMOTE_TABS:
+ case DEPRECATED_RECENT_TABS:
+ case COMBINED_HISTORY:
+ return R.string.home_history_title;
+
+ default:
+ throw new IllegalArgumentException("Only for built-in panel types: " + panelType);
+ }
+ }
+
+ public static String getIdForBuiltinPanelType(PanelType panelType) {
+ switch (panelType) {
+ case TOP_SITES:
+ return TOP_SITES_PANEL_ID;
+
+ case BOOKMARKS:
+ return BOOKMARKS_PANEL_ID;
+
+ case DEPRECATED_HISTORY:
+ return HISTORY_PANEL_ID;
+
+ case COMBINED_HISTORY:
+ return COMBINED_HISTORY_PANEL_ID;
+
+ case DEPRECATED_REMOTE_TABS:
+ return REMOTE_TABS_PANEL_ID;
+
+ case DEPRECATED_READING_LIST:
+ return DEPRECATED_READING_LIST_PANEL_ID;
+
+ case DEPRECATED_RECENT_TABS:
+ return RECENT_TABS_PANEL_ID;
+
+ default:
+ throw new IllegalArgumentException("Only for built-in panel types: " + panelType);
+ }
+ }
+
+ public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType, EnumSet<PanelConfig.Flags> flags) {
+ final int titleId = getTitleResourceIdForBuiltinPanelType(panelType);
+ final String id = getIdForBuiltinPanelType(panelType);
+
+ return new PanelConfig(panelType, context.getString(titleId), id, flags);
+ }
+
+ public static HomeConfig getDefault(Context context) {
+ return new HomeConfig(new HomeConfigPrefsBackend(context));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java
new file mode 100644
index 000000000..914d0fdd1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigLoader.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.home.HomeConfig.OnReloadListener;
+
+import android.content.Context;
+import android.support.v4.content.AsyncTaskLoader;
+
+public class HomeConfigLoader extends AsyncTaskLoader<HomeConfig.State> {
+ private final HomeConfig mConfig;
+ private HomeConfig.State mConfigState;
+
+ private final Context mContext;
+
+ public HomeConfigLoader(Context context, HomeConfig homeConfig) {
+ super(context);
+ mContext = context;
+ mConfig = homeConfig;
+ }
+
+ @Override
+ public HomeConfig.State loadInBackground() {
+ return mConfig.load();
+ }
+
+ @Override
+ public void deliverResult(HomeConfig.State configState) {
+ if (isReset()) {
+ mConfigState = null;
+ return;
+ }
+
+ mConfigState = configState;
+ mConfig.setOnReloadListener(new ForceReloadListener());
+
+ if (isStarted()) {
+ super.deliverResult(configState);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mConfigState != null) {
+ deliverResult(mConfigState);
+ }
+
+ if (takeContentChanged() || mConfigState == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(HomeConfig.State configState) {
+ mConfigState = null;
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped.
+ onStopLoading();
+
+ mConfigState = null;
+ mConfig.setOnReloadListener(null);
+ }
+
+ private class ForceReloadListener implements OnReloadListener {
+ @Override
+ public void onReload() {
+ onContentChanged();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java
new file mode 100644
index 000000000..a2d80788c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeConfigPrefsBackend.java
@@ -0,0 +1,663 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.Locale;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.home.HomeConfig.HomeConfigBackend;
+import org.mozilla.gecko.home.HomeConfig.OnReloadListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelType;
+import org.mozilla.gecko.home.HomeConfig.State;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.support.annotation.CheckResult;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class HomeConfigPrefsBackend implements HomeConfigBackend {
+ private static final String LOGTAG = "GeckoHomeConfigBackend";
+
+ // Increment this to trigger a migration.
+ @VisibleForTesting
+ static final int VERSION = 8;
+
+ // This key was originally used to store only an array of panel configs.
+ public static final String PREFS_CONFIG_KEY_OLD = "home_panels";
+
+ // This key is now used to store a version number with the array of panel configs.
+ public static final String PREFS_CONFIG_KEY = "home_panels_with_version";
+
+ // Keys used with JSON object stored in prefs.
+ private static final String JSON_KEY_PANELS = "panels";
+ private static final String JSON_KEY_VERSION = "version";
+
+ private static final String PREFS_LOCALE_KEY = "home_locale";
+
+ private static final String RELOAD_BROADCAST = "HomeConfigPrefsBackend:Reload";
+
+ private final Context mContext;
+ private ReloadBroadcastReceiver mReloadBroadcastReceiver;
+ private OnReloadListener mReloadListener;
+
+ private static boolean sMigrationDone;
+
+ public HomeConfigPrefsBackend(Context context) {
+ mContext = context;
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forProfile(mContext);
+ }
+
+ private State loadDefaultConfig() {
+ final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>();
+
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.TOP_SITES,
+ EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)));
+
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.BOOKMARKS));
+ panelConfigs.add(createBuiltinPanelConfig(mContext, PanelType.COMBINED_HISTORY));
+
+
+ return new State(panelConfigs, true);
+ }
+
+ /**
+ * Iterate through the panels to check if they are all disabled.
+ */
+ private static boolean allPanelsAreDisabled(JSONArray jsonPanels) throws JSONException {
+ final int count = jsonPanels.length();
+ for (int i = 0; i < count; i++) {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+
+ if (!jsonPanelConfig.optBoolean(PanelConfig.JSON_KEY_DISABLED, false)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected enum Position {
+ NONE, // Not present.
+ FRONT, // At the front of the list of panels.
+ BACK, // At the back of the list of panels.
+ }
+
+ /**
+ * Create and insert a built-in panel configuration.
+ *
+ * @param context Android context.
+ * @param jsonPanels array of JSON panels to update in place.
+ * @param panelType to add.
+ * @param positionOnPhones where to place the new panel on phones.
+ * @param positionOnTablets where to place the new panel on tablets.
+ * @throws JSONException
+ */
+ protected static void addBuiltinPanelConfig(Context context, JSONArray jsonPanels,
+ PanelType panelType, Position positionOnPhones, Position positionOnTablets) throws JSONException {
+ // Add the new panel.
+ final JSONObject jsonPanelConfig =
+ createBuiltinPanelConfig(context, panelType).toJSON();
+
+ // If any panel is enabled, then we should make the new panel enabled.
+ jsonPanelConfig.put(PanelConfig.JSON_KEY_DISABLED,
+ allPanelsAreDisabled(jsonPanels));
+
+ final boolean isTablet = HardwareUtils.isTablet();
+ final boolean isPhone = !isTablet;
+
+ // Maybe add the new panel to the front of the array.
+ if ((isPhone && positionOnPhones == Position.FRONT) ||
+ (isTablet && positionOnTablets == Position.FRONT)) {
+ // This is an inefficient way to stretch [a, b, c] to [a, a, b, c].
+ for (int i = jsonPanels.length(); i >= 1; i--) {
+ jsonPanels.put(i, jsonPanels.get(i - 1));
+ }
+ // And this inserts [d, a, b, c].
+ jsonPanels.put(0, jsonPanelConfig);
+ }
+
+ // Maybe add the new panel to the back of the array.
+ if ((isPhone && positionOnPhones == Position.BACK) ||
+ (isTablet && positionOnTablets == Position.BACK)) {
+ jsonPanels.put(jsonPanelConfig);
+ }
+ }
+
+ /**
+ * Updates the panels to combine the History and Sync panels into the (Combined) History panel.
+ *
+ * Tries to replace the History panel with the Combined History panel if visible, or falls back to
+ * replacing the Sync panel if it's visible. That way, we minimize panel reordering during a migration.
+ * @param context Android context
+ * @param jsonPanels array of original JSON panels
+ * @return new array of updated JSON panels
+ * @throws JSONException
+ */
+ private static JSONArray combineHistoryAndSyncPanels(Context context, JSONArray jsonPanels) throws JSONException {
+ EnumSet<PanelConfig.Flags> historyFlags = null;
+ EnumSet<PanelConfig.Flags> syncFlags = null;
+
+ int historyIndex = -1;
+ int syncIndex = -1;
+
+ // Determine state and location of History and Sync panels.
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ JSONObject panelObj = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(panelObj);
+ final PanelType type = panelConfig.getType();
+ if (type == PanelType.DEPRECATED_HISTORY) {
+ historyIndex = i;
+ historyFlags = panelConfig.getFlags();
+ } else if (type == PanelType.DEPRECATED_REMOTE_TABS) {
+ syncIndex = i;
+ syncFlags = panelConfig.getFlags();
+ } else if (type == PanelType.COMBINED_HISTORY) {
+ // Partial landing of bug 1220928 combined the History and Sync panels of users who didn't
+ // have home panel customizations (including new users), thus they don't this migration.
+ return jsonPanels;
+ }
+ }
+
+ if (historyIndex == -1 || syncIndex == -1) {
+ throw new IllegalArgumentException("Expected both History and Sync panels to be present prior to Combined History.");
+ }
+
+ PanelConfig newPanel;
+ int replaceIndex;
+ int removeIndex;
+
+ if (historyFlags.contains(PanelConfig.Flags.DISABLED_PANEL) && !syncFlags.contains(PanelConfig.Flags.DISABLED_PANEL)) {
+ // Replace the Sync panel if it's visible and the History panel is disabled.
+ replaceIndex = syncIndex;
+ removeIndex = historyIndex;
+ newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, syncFlags);
+ } else {
+ // Otherwise, just replace the History panel.
+ replaceIndex = historyIndex;
+ removeIndex = syncIndex;
+ newPanel = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, historyFlags);
+ }
+
+ // Copy the array with updated panel and removed panel.
+ final JSONArray newArray = new JSONArray();
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ if (i == replaceIndex) {
+ newArray.put(newPanel.toJSON());
+ } else if (i == removeIndex) {
+ continue;
+ } else {
+ newArray.put(jsonPanels.get(i));
+ }
+ }
+
+ return newArray;
+ }
+
+ /**
+ * Iterate over all homepanels to verify that there is at least one default panel. If there is
+ * no default panel, set History as the default panel. (This is only relevant for two botched
+ * migrations where the history panel should have been made the default panel, but wasn't.)
+ */
+ private static void ensureDefaultPanelForV5orV8(Context context, JSONArray jsonPanels) throws JSONException {
+ int historyIndex = -1;
+
+ // If all panels are disabled, there is no default panel - this is the only valid state
+ // that has no default. We can use this flag to track whether any visible panels have been
+ // found.
+ boolean enabledPanelsFound = false;
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final PanelConfig panelConfig = new PanelConfig(jsonPanels.getJSONObject(i));
+ if (panelConfig.isDefault()) {
+ return;
+ }
+
+ if (!panelConfig.isDisabled()) {
+ enabledPanelsFound = true;
+ }
+
+ if (panelConfig.getType() == PanelType.COMBINED_HISTORY) {
+ historyIndex = i;
+ }
+ }
+
+ if (!enabledPanelsFound) {
+ // No panels are enabled, hence there can be no default (see noEnabledPanelsFound declaration
+ // for more information).
+ return;
+ }
+
+ // Make the History panel default. We can't modify existing PanelConfigs, so make a new one.
+ final PanelConfig historyPanelConfig = createBuiltinPanelConfig(context, PanelType.COMBINED_HISTORY, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL));
+ jsonPanels.put(historyIndex, historyPanelConfig.toJSON());
+ }
+
+ /**
+ * Removes a panel from the home panel config.
+ * If the removed panel was set as the default home panel, we provide a replacement for it.
+ *
+ * @param context Android context
+ * @param jsonPanels array of original JSON panels
+ * @param panelToRemove The home panel to be removed.
+ * @param replacementPanel The panel which will replace it if the removed panel
+ * was the default home panel.
+ * @param alwaysUnhide If true, the replacement panel will always be unhidden,
+ * otherwise only if we turn it into the new default panel.
+ * @return new array of updated JSON panels
+ * @throws JSONException
+ */
+ private static JSONArray removePanel(Context context, JSONArray jsonPanels,
+ PanelType panelToRemove, PanelType replacementPanel, boolean alwaysUnhide) throws JSONException {
+ boolean wasDefault = false;
+ boolean wasDisabled = false;
+ int replacementPanelIndex = -1;
+ boolean replacementWasDefault = false;
+
+ // JSONArrary doesn't provide remove() for API < 19, therefore we need to manually copy all
+ // the items we don't want deleted into a new array.
+ final JSONArray newJSONPanels = new JSONArray();
+
+ for (int i = 0; i < jsonPanels.length(); i++) {
+ final JSONObject panelJSON = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(panelJSON);
+
+ if (panelConfig.getType() == panelToRemove) {
+ // If this panel was the default we'll need to assign a new default:
+ wasDefault = panelConfig.isDefault();
+ wasDisabled = panelConfig.isDisabled();
+ } else {
+ if (panelConfig.getType() == replacementPanel) {
+ replacementPanelIndex = newJSONPanels.length();
+ if (panelConfig.isDefault()) {
+ replacementWasDefault = true;
+ }
+ }
+
+ newJSONPanels.put(panelJSON);
+ }
+ }
+
+ // Unless alwaysUnhide is true, we make the replacement panel visible only if it is going
+ // to be the new default panel, since a hidden default panel doesn't make sense.
+ // This is to allow preserving the behaviour of the original reading list migration function.
+ if ((wasDefault || alwaysUnhide) && !wasDisabled) {
+ final JSONObject replacementPanelConfig;
+ if (wasDefault) {
+ // If the removed panel was the default, the replacement has to be made the new default
+ replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL)).toJSON();
+ } else {
+ final EnumSet<HomeConfig.PanelConfig.Flags> flags;
+ if (replacementWasDefault) {
+ // However if the replacement panel was already default, we need to preserve it's default status
+ // (By rewriting the PanelConfig, we lose all existing flags, so we need to make sure desired
+ // flags are retained - in this case there's only DEFAULT_PANEL, which is mutually
+ // exclusive with the DISABLE_PANEL case).
+ flags = EnumSet.of(PanelConfig.Flags.DEFAULT_PANEL);
+ } else {
+ flags = EnumSet.noneOf(PanelConfig.Flags.class);
+ }
+
+ // The panel is visible since we don't set Flags.DISABLED_PANEL.
+ replacementPanelConfig = createBuiltinPanelConfig(context, replacementPanel, flags).toJSON();
+ }
+
+ if (replacementPanelIndex != -1) {
+ newJSONPanels.put(replacementPanelIndex, replacementPanelConfig);
+ } else {
+ newJSONPanels.put(replacementPanelConfig);
+ }
+ }
+
+ return newJSONPanels;
+ }
+
+ /**
+ * Checks to see if the reading list panel already exists.
+ *
+ * @param jsonPanels JSONArray array representing the curent set of panel configs.
+ *
+ * @return boolean Whether or not the reading list panel exists.
+ */
+ private static boolean readingListPanelExists(JSONArray jsonPanels) {
+ final int count = jsonPanels.length();
+ for (int i = 0; i < count; i++) {
+ try {
+ final JSONObject jsonPanelConfig = jsonPanels.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig);
+ if (panelConfig.getType() == PanelType.DEPRECATED_READING_LIST) {
+ return true;
+ }
+ } catch (Exception e) {
+ // It's okay to ignore this exception, since an invalid reading list
+ // panel config is equivalent to no reading list panel.
+ Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e);
+ }
+ }
+ return false;
+ }
+
+ @CheckResult
+ static synchronized JSONArray migratePrefsFromVersionToVersion(final Context context, final int currentVersion, final int newVersion,
+ final JSONArray jsonPanelsIn, final SharedPreferences.Editor prefsEditor) throws JSONException {
+
+ JSONArray jsonPanels = jsonPanelsIn;
+
+ for (int v = currentVersion + 1; v <= newVersion; v++) {
+ Log.d(LOGTAG, "Migrating to version = " + v);
+
+ switch (v) {
+ case 1:
+ // Add "Recent Tabs" panel.
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_RECENT_TABS, Position.FRONT, Position.BACK);
+
+ // Remove the old pref key.
+ prefsEditor.remove(PREFS_CONFIG_KEY_OLD);
+ break;
+
+ case 2:
+ // Add "Remote Tabs"/"Synced Tabs" panel.
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_REMOTE_TABS, Position.FRONT, Position.BACK);
+ break;
+
+ case 3:
+ // Add the "Reading List" panel if it does not exist. At one time,
+ // the Reading List panel was shown only to devices that were not
+ // considered "low memory". Now, we expose the panel to all devices.
+ // This migration should only occur for "low memory" devices.
+ // Note: This will not agree with the default configuration, which
+ // has DEPRECATED_REMOTE_TABS after DEPRECATED_READING_LIST on some devices.
+ if (!readingListPanelExists(jsonPanels)) {
+ addBuiltinPanelConfig(context, jsonPanels,
+ PanelType.DEPRECATED_READING_LIST, Position.BACK, Position.BACK);
+ }
+ break;
+
+ case 4:
+ // Combine the History and Sync panels. In order to minimize an unexpected reordering
+ // of panels, we try to replace the History panel if it's visible, and fall back to
+ // the Sync panel if that's visible.
+ jsonPanels = combineHistoryAndSyncPanels(context, jsonPanels);
+ break;
+
+ case 5:
+ // This is the fix for bug 1264136 where we lost track of the default panel during some migrations.
+ ensureDefaultPanelForV5orV8(context, jsonPanels);
+ break;
+
+ case 6:
+ jsonPanels = removePanel(context, jsonPanels,
+ PanelType.DEPRECATED_READING_LIST, PanelType.BOOKMARKS, false);
+ break;
+
+ case 7:
+ jsonPanels = removePanel(context, jsonPanels,
+ PanelType.DEPRECATED_RECENT_TABS, PanelType.COMBINED_HISTORY, true);
+ break;
+
+ case 8:
+ // Similar to "case 5" above, this time 1304777 - once again we lost track
+ // of the history panel
+ ensureDefaultPanelForV5orV8(context, jsonPanels);
+ break;
+ }
+ }
+
+ return jsonPanels;
+ }
+
+ /**
+ * Migrates JSON config data storage.
+ *
+ * @param context Context used to get shared preferences and create built-in panel.
+ * @param jsonString String currently stored in preferences.
+ *
+ * @return JSONArray array representing new set of panel configs.
+ */
+ private static synchronized JSONArray maybePerformMigration(Context context, String jsonString) throws JSONException {
+ // If the migration is already done, we're at the current version.
+ if (sMigrationDone) {
+ final JSONObject json = new JSONObject(jsonString);
+ return json.getJSONArray(JSON_KEY_PANELS);
+ }
+
+ // Make sure we only do this version check once.
+ sMigrationDone = true;
+
+ JSONArray jsonPanels;
+ final int version;
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+ if (prefs.contains(PREFS_CONFIG_KEY_OLD)) {
+ // Our original implementation did not contain versioning, so this is implicitly version 0.
+ jsonPanels = new JSONArray(jsonString);
+ version = 0;
+ } else {
+ final JSONObject json = new JSONObject(jsonString);
+ jsonPanels = json.getJSONArray(JSON_KEY_PANELS);
+ version = json.getInt(JSON_KEY_VERSION);
+ }
+
+ if (version == VERSION) {
+ return jsonPanels;
+ }
+
+ Log.d(LOGTAG, "Performing migration");
+
+ final SharedPreferences.Editor prefsEditor = prefs.edit();
+
+ jsonPanels = migratePrefsFromVersionToVersion(context, version, VERSION, jsonPanels, prefsEditor);
+
+ // Save the new panel config and the new version number.
+ final JSONObject newJson = new JSONObject();
+ newJson.put(JSON_KEY_PANELS, jsonPanels);
+ newJson.put(JSON_KEY_VERSION, VERSION);
+
+ prefsEditor.putString(PREFS_CONFIG_KEY, newJson.toString());
+ prefsEditor.apply();
+
+ return jsonPanels;
+ }
+
+ private State loadConfigFromString(String jsonString) {
+ final JSONArray jsonPanelConfigs;
+ try {
+ jsonPanelConfigs = maybePerformMigration(mContext, jsonString);
+ updatePrefsFromConfig(jsonPanelConfigs);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error loading the list of home panels from JSON prefs", e);
+
+ // Fallback to default config
+ return loadDefaultConfig();
+ }
+
+ final ArrayList<PanelConfig> panelConfigs = new ArrayList<PanelConfig>();
+
+ final int count = jsonPanelConfigs.length();
+ for (int i = 0; i < count; i++) {
+ try {
+ final JSONObject jsonPanelConfig = jsonPanelConfigs.getJSONObject(i);
+ final PanelConfig panelConfig = new PanelConfig(jsonPanelConfig);
+ panelConfigs.add(panelConfig);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception loading PanelConfig from JSON", e);
+ }
+ }
+
+ return new State(panelConfigs, false);
+ }
+
+ @Override
+ public State load() {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ final String key = (prefs.contains(PREFS_CONFIG_KEY_OLD) ? PREFS_CONFIG_KEY_OLD : PREFS_CONFIG_KEY);
+ final String jsonString = prefs.getString(key, null);
+
+ final State configState;
+ if (TextUtils.isEmpty(jsonString)) {
+ configState = loadDefaultConfig();
+ } else {
+ configState = loadConfigFromString(jsonString);
+ }
+
+ return configState;
+ }
+
+ @Override
+ public void save(State configState) {
+ final SharedPreferences prefs = getSharedPreferences();
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ // No need to save the state to disk if it represents the default
+ // HomeConfig configuration. Simply force all existing HomeConfigLoader
+ // instances to refresh their contents.
+ if (!configState.isDefault()) {
+ final JSONArray jsonPanelConfigs = new JSONArray();
+
+ for (PanelConfig panelConfig : configState) {
+ try {
+ final JSONObject jsonPanelConfig = panelConfig.toJSON();
+ jsonPanelConfigs.put(jsonPanelConfig);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception converting PanelConfig to JSON", e);
+ }
+ }
+
+ try {
+ final JSONObject json = new JSONObject();
+ json.put(JSON_KEY_PANELS, jsonPanelConfigs);
+ json.put(JSON_KEY_VERSION, VERSION);
+
+ editor.putString(PREFS_CONFIG_KEY, json.toString());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception saving PanelConfig state", e);
+ }
+ }
+
+ editor.putString(PREFS_LOCALE_KEY, Locale.getDefault().toString());
+ editor.apply();
+
+ // Trigger reload listeners on all live backend instances
+ sendReloadBroadcast();
+ }
+
+ @Override
+ public String getLocale() {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ String locale = prefs.getString(PREFS_LOCALE_KEY, null);
+ if (locale == null) {
+ // Initialize config with the current locale
+ final String currentLocale = Locale.getDefault().toString();
+
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(PREFS_LOCALE_KEY, currentLocale);
+ editor.apply();
+
+ // If the user has saved HomeConfig before, return null this
+ // one time to trigger a refresh and ensure we use the
+ // correct locale for the saved state. For more context,
+ // see HomePanelsManager.onLocaleReady().
+ if (!prefs.contains(PREFS_CONFIG_KEY)) {
+ locale = currentLocale;
+ }
+ }
+
+ return locale;
+ }
+
+ @Override
+ public void setOnReloadListener(OnReloadListener listener) {
+ if (mReloadListener != null) {
+ unregisterReloadReceiver();
+ mReloadBroadcastReceiver = null;
+ }
+
+ mReloadListener = listener;
+
+ if (mReloadListener != null) {
+ mReloadBroadcastReceiver = new ReloadBroadcastReceiver();
+ registerReloadReceiver();
+ }
+ }
+
+ /**
+ * Update prefs that depend on home panels state.
+ *
+ * This includes the prefs that keep track of whether bookmarks or history are enabled, which are
+ * used to control the visibility of the corresponding menu items.
+ */
+ private void updatePrefsFromConfig(JSONArray panelsArray) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(mContext);
+ if (!prefs.contains(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED)
+ || !prefs.contains(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED)) {
+
+ final String bookmarkType = PanelType.BOOKMARKS.toString();
+ final String historyType = PanelType.COMBINED_HISTORY.toString();
+ try {
+ for (int i = 0; i < panelsArray.length(); i++) {
+ final JSONObject panelObj = panelsArray.getJSONObject(i);
+ final String panelType = panelObj.optString(PanelConfig.JSON_KEY_TYPE, null);
+ if (panelType == null) {
+ break;
+ }
+ final boolean isDisabled = panelObj.optBoolean(PanelConfig.JSON_KEY_DISABLED, false);
+ if (bookmarkType.equals(panelType)) {
+ prefs.edit().putBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, !isDisabled).apply();
+ } else if (historyType.equals(panelType)) {
+ prefs.edit().putBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, !isDisabled).apply();
+ }
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error fetching panel from config to update prefs");
+ }
+ }
+ }
+
+
+ private void sendReloadBroadcast() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ final Intent reloadIntent = new Intent(RELOAD_BROADCAST);
+ lbm.sendBroadcast(reloadIntent);
+ }
+
+ private void registerReloadReceiver() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ lbm.registerReceiver(mReloadBroadcastReceiver, new IntentFilter(RELOAD_BROADCAST));
+ }
+
+ private void unregisterReloadReceiver() {
+ final LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(mContext);
+ lbm.unregisterReceiver(mReloadBroadcastReceiver);
+ }
+
+ private class ReloadBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ mReloadListener.onReload();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java
new file mode 100644
index 000000000..cefa0329d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeContextMenuInfo.java
@@ -0,0 +1,82 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.database.Cursor;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.AdapterView.AdapterContextMenuInfo;
+import android.widget.ExpandableListAdapter;
+import android.widget.ListAdapter;
+
+/**
+ * A ContextMenuInfo for HomeListView
+ */
+public class HomeContextMenuInfo extends AdapterContextMenuInfo {
+
+ public String url;
+ public String title;
+ public boolean isFolder;
+ public int historyId = -1;
+ public int bookmarkId = -1;
+ public RemoveItemType itemType = null;
+
+ // Item type to be handled with "Remove" selection.
+ public static enum RemoveItemType {
+ BOOKMARKS, COMBINED, HISTORY
+ }
+
+ public HomeContextMenuInfo(View targetView, int position, long id) {
+ super(targetView, position, id);
+ }
+
+ public boolean hasBookmarkId() {
+ return bookmarkId > -1;
+ }
+
+ public boolean hasHistoryId() {
+ return historyId > -1;
+ }
+
+ public boolean hasPartnerBookmarkId() {
+ return bookmarkId <= BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START;
+ }
+
+ public boolean canRemove() {
+ return hasBookmarkId() || hasHistoryId() || hasPartnerBookmarkId();
+ }
+
+ public String getDisplayTitle() {
+ if (!TextUtils.isEmpty(title)) {
+ return title;
+ }
+ return StringUtils.stripCommonSubdomains(StringUtils.stripScheme(url, StringUtils.UrlFlags.STRIP_HTTPS));
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from cursors.
+ */
+ public interface Factory {
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor);
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from ListAdapters.
+ */
+ public interface ListFactory extends Factory {
+ public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ListAdapter adapter);
+ }
+
+ /**
+ * Interface for creating ContextMenuInfo instances from ExpandableListAdapters.
+ */
+ public interface ExpandableFactory {
+ public HomeContextMenuInfo makeInfoForAdapter(View view, int position, long id, ExpandableListAdapter adapter);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java
new file mode 100644
index 000000000..7badd6929
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeExpandableListView.java
@@ -0,0 +1,68 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemLongClickListener;
+import android.widget.ExpandableListView;
+
+/**
+ * <code>HomeExpandableListView</code> is a custom extension of
+ * <code>ExpandableListView<code>, that packs a <code>HomeContextMenuInfo</code>
+ * when any of its rows is long pressed.
+ * <p>
+ * This is the <code>ExpandableListView</code> equivalent of
+ * <code>HomeListView</code>.
+ */
+public class HomeExpandableListView extends ExpandableListView
+ implements OnItemLongClickListener {
+
+ // ContextMenuInfo associated with the currently long pressed list item.
+ private HomeContextMenuInfo mContextMenuInfo;
+
+ // ContextMenuInfo factory.
+ private HomeContextMenuInfo.ExpandableFactory mContextMenuInfoFactory;
+
+ public HomeExpandableListView(Context context) {
+ this(context, null);
+ }
+
+ public HomeExpandableListView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public HomeExpandableListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ setOnItemLongClickListener(this);
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mContextMenuInfoFactory == null) {
+ return false;
+ }
+
+ // HomeExpandableListView items can correspond to groups and children.
+ // The factory can determine whether to add context menu for either,
+ // both, or none by unpacking the given position.
+ mContextMenuInfo = mContextMenuInfoFactory.makeInfoForAdapter(view, position, id, getExpandableListAdapter());
+ return showContextMenuForChild(HomeExpandableListView.this);
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ public void setContextMenuInfoFactory(final HomeContextMenuInfo.ExpandableFactory factory) {
+ mContextMenuInfoFactory = factory;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java
new file mode 100644
index 000000000..da6e9b703
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeFragment.java
@@ -0,0 +1,498 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.EditBookmarkDialog;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.IntentHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserContract.SuggestedSites;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.reader.ReadingListHelper;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.support.design.widget.Snackbar;
+import android.support.v4.app.Fragment;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+/**
+ * HomeFragment is an empty fragment that can be added to the HomePager.
+ * Subclasses can add their own views.
+ * <p>
+ * The containing activity <b>must</b> implement {@link OnUrlOpenListener}.
+ */
+public abstract class HomeFragment extends Fragment {
+ // Log Tag.
+ private static final String LOGTAG = "GeckoHomeFragment";
+
+ // Share MIME type.
+ protected static final String SHARE_MIME_TYPE = "text/plain";
+
+ // Default value for "can load" hint
+ static final boolean DEFAULT_CAN_LOAD_HINT = false;
+
+ // Whether the fragment can load its content or not
+ // This is used to defer data loading until the editing
+ // mode animation ends.
+ private boolean mCanLoadHint;
+
+ // Whether the fragment has loaded its content
+ private boolean mIsLoaded;
+
+ // On URL open listener
+ protected OnUrlOpenListener mUrlOpenListener;
+
+ // Helper for opening a tab in the background.
+ protected OnUrlOpenInBackgroundListener mUrlOpenInBackgroundListener;
+
+ protected PanelStateChangeListener mPanelStateChangeListener = null;
+
+ /**
+ * Listener to notify when a home panels' state has changed in a way that needs to be stored
+ * for history/restoration. E.g. when a folder is opened/closed in bookmarks.
+ */
+ public interface PanelStateChangeListener {
+
+ /**
+ * @param bundle Data that should be persisted, and passed to this panel if restored at a later
+ * stage.
+ */
+ void onStateChanged(Bundle bundle);
+
+ void setCachedRecentTabsCount(int count);
+
+ int getCachedRecentTabsCount();
+ }
+
+ public void restoreData(Bundle data) {
+ // Do nothing
+ }
+
+ public void setPanelStateChangeListener(
+ PanelStateChangeListener mPanelStateChangeListener) {
+ this.mPanelStateChangeListener = mPanelStateChangeListener;
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ try {
+ mUrlOpenListener = (OnUrlOpenListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement HomePager.OnUrlOpenListener");
+ }
+
+ try {
+ mUrlOpenInBackgroundListener = (OnUrlOpenInBackgroundListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString()
+ + " must implement HomePager.OnUrlOpenInBackgroundListener");
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ mUrlOpenListener = null;
+ mUrlOpenInBackgroundListener = null;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Bundle args = getArguments();
+ if (args != null) {
+ mCanLoadHint = args.getBoolean(HomePager.CAN_LOAD_ARG, DEFAULT_CAN_LOAD_HINT);
+ } else {
+ mCanLoadHint = DEFAULT_CAN_LOAD_HINT;
+ }
+
+ mIsLoaded = false;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return;
+ }
+
+ HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+
+ // Don't show the context menu for folders.
+ if (info.isFolder) {
+ return;
+ }
+
+ MenuInflater inflater = new MenuInflater(view.getContext());
+ inflater.inflate(R.menu.home_contextmenu, menu);
+
+ menu.setHeaderTitle(info.getDisplayTitle());
+
+ // Hide unused menu items.
+ menu.findItem(R.id.top_sites_edit).setVisible(false);
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+
+ // Hide the "Edit" menuitem if this item isn't a bookmark,
+ // or if this is a reading list item.
+ if (!info.hasBookmarkId()) {
+ menu.findItem(R.id.home_edit_bookmark).setVisible(false);
+ }
+
+ // Hide the "Remove" menuitem if this item not removable.
+ if (!info.canRemove()) {
+ menu.findItem(R.id.home_remove).setVisible(false);
+ }
+
+ if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) {
+ menu.findItem(R.id.home_share).setVisible(false);
+ }
+
+ if (!Restrictions.isAllowed(view.getContext(), Restrictable.PRIVATE_BROWSING)) {
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ // onContextItemSelected() is first dispatched to the activity and
+ // then dispatched to its fragments. Since fragments cannot "override"
+ // menu item selection handling, it's better to avoid menu id collisions
+ // between the activity and its fragments.
+
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+ if (!(menuInfo instanceof HomeContextMenuInfo)) {
+ return false;
+ }
+
+ final HomeContextMenuInfo info = (HomeContextMenuInfo) menuInfo;
+ final Context context = getActivity();
+
+ final int itemId = item.getItemId();
+
+ // Track the menu action. We don't know much about the context, but we can use this to determine
+ // the frequency of use for various actions.
+ String extras = getResources().getResourceEntryName(itemId);
+ if (TextUtils.equals(extras, "home_open_private_tab")) {
+ // Mask private browsing
+ extras = "home_open_new_tab";
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, extras);
+
+ if (itemId == R.id.home_copyurl) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't copy address because URL is null");
+ return false;
+ }
+
+ Clipboard.setText(info.url);
+ return true;
+ }
+
+ if (itemId == R.id.home_share) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't share because URL is null");
+ return false;
+ } else {
+ IntentHelper.openUriExternal(info.url, SHARE_MIME_TYPE, "", "",
+ Intent.ACTION_SEND, info.getDisplayTitle(), false);
+
+ // Context: Sharing via chrome homepage contextmenu list (home session should be active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "home_contextmenu");
+ return true;
+ }
+ }
+
+ if (itemId == R.id.home_add_to_launcher) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't add to home screen because URL is null");
+ return false;
+ }
+
+ // Fetch an icon big enough for use as a home screen icon.
+ final String displayTitle = info.getDisplayTitle();
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.createShortcut(displayTitle, info.url);
+
+ }
+ });
+
+ return true;
+ }
+
+ if (itemId == R.id.home_open_private_tab || itemId == R.id.home_open_new_tab) {
+ if (info.url == null) {
+ Log.e(LOGTAG, "Can't open in new tab because URL is null");
+ return false;
+ }
+
+ // Some pinned site items have "user-entered" urls. URLs entered in
+ // the PinSiteDialog are wrapped in a special URI until we can get a
+ // valid URL. If the url is a user-entered url, decode the URL
+ // before loading it.
+ final String url = StringUtils.decodeUserEnteredUrl(info.url);
+
+ final EnumSet<OnUrlOpenInBackgroundListener.Flags> flags = EnumSet.noneOf(OnUrlOpenInBackgroundListener.Flags.class);
+ if (item.getItemId() == R.id.home_open_private_tab) {
+ flags.add(OnUrlOpenInBackgroundListener.Flags.PRIVATE);
+ }
+
+ mUrlOpenInBackgroundListener.onUrlOpenInBackground(url, flags);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU);
+
+ return true;
+ }
+
+ if (itemId == R.id.home_edit_bookmark) {
+ // UI Dialog associates to the activity context, not the applications'.
+ new EditBookmarkDialog(context).show(info.url);
+ return true;
+ }
+
+ if (itemId == R.id.home_remove) {
+ // For Top Sites grid items, position is required in case item is Pinned.
+ final int position = info instanceof TopSitesGridContextMenuInfo ? info.position : -1;
+
+ if (info.hasPartnerBookmarkId()) {
+ new RemovePartnerBookmarkTask(context, info.bookmarkId).execute();
+ } else {
+ new RemoveItemByUrlTask(context, info.url, info.itemType, position).execute();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setUserVisibleHint (boolean isVisibleToUser) {
+ if (isVisibleToUser == getUserVisibleHint()) {
+ return;
+ }
+
+ super.setUserVisibleHint(isVisibleToUser);
+ loadIfVisible();
+ }
+
+ /**
+ * Handle a configuration change by detaching and re-attaching.
+ * <p>
+ * A HomeFragment only needs to handle onConfiguration change (i.e.,
+ * re-attach) if its UI needs to change (i.e., re-inflate layouts, use
+ * different styles, etc) for different device orientations. Handling
+ * configuration changes in all HomeFragments will simply cause some
+ * redundant re-inflations on device rotation. This slight inefficiency
+ * avoids potentially not handling a needed onConfigurationChanged in a
+ * subclass.
+ */
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ // Reattach the fragment, forcing a re-inflation of its view.
+ // We use commitAllowingStateLoss() instead of commit() here to avoid
+ // an IllegalStateException. If the phone is rotated while Fennec
+ // is in the background, onConfigurationChanged() is fired.
+ // onConfigurationChanged() is called before onResume(), so
+ // using commit() would throw an IllegalStateException since it can't
+ // be used between the Activity's onSaveInstanceState() and
+ // onResume().
+ if (isVisible()) {
+ getFragmentManager().beginTransaction()
+ .detach(this)
+ .attach(this)
+ .commitAllowingStateLoss();
+ }
+ }
+
+ void setCanLoadHint(boolean canLoadHint) {
+ if (mCanLoadHint == canLoadHint) {
+ return;
+ }
+
+ mCanLoadHint = canLoadHint;
+ loadIfVisible();
+ }
+
+ boolean getCanLoadHint() {
+ return mCanLoadHint;
+ }
+
+ protected abstract void load();
+
+ protected boolean canLoad() {
+ return (mCanLoadHint && isVisible() && getUserVisibleHint());
+ }
+
+ protected void loadIfVisible() {
+ if (!canLoad() || mIsLoaded) {
+ return;
+ }
+
+ load();
+ mIsLoaded = true;
+ }
+
+ protected static class RemoveItemByUrlTask extends UIAsyncTask.WithoutParams<Void> {
+ private final Context mContext;
+ private final String mUrl;
+ private final RemoveItemType mType;
+ private final int mPosition;
+ private final BrowserDB mDB;
+
+ /**
+ * Remove bookmark/history/reading list type item by url, and also unpin the
+ * Top Sites grid item at index <code>position</code>.
+ */
+ public RemoveItemByUrlTask(Context context, String url, RemoveItemType type, int position) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ mContext = context;
+ mUrl = url;
+ mType = type;
+ mPosition = position;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Void doInBackground() {
+ ContentResolver cr = mContext.getContentResolver();
+
+ if (mPosition > -1) {
+ mDB.unpinSite(cr, mPosition);
+ if (mDB.hideSuggestedSite(mUrl)) {
+ cr.notifyChange(SuggestedSites.CONTENT_URI, null);
+ }
+ }
+
+ switch (mType) {
+ case BOOKMARKS:
+ removeBookmark(cr);
+ break;
+
+ case HISTORY:
+ removeHistory(cr);
+ break;
+
+ case COMBINED:
+ removeBookmark(cr);
+ removeHistory(cr);
+ break;
+
+ default:
+ Log.e(LOGTAG, "Can't remove item type " + mType.toString());
+ break;
+ }
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(Void result) {
+ SnackbarBuilder.builder((Activity) mContext)
+ .message(R.string.page_removed)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+
+ private void removeBookmark(ContentResolver cr) {
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(mContext);
+ final boolean isReaderViewPage = rch.isURLCached(mUrl);
+
+ final String extra;
+ if (isReaderViewPage) {
+ extra = "bookmark_reader";
+ } else {
+ extra = "bookmark";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.CONTEXT_MENU, extra);
+ mDB.removeBookmarksWithURL(cr, mUrl);
+
+ if (isReaderViewPage) {
+ ReadingListHelper.removeCachedReaderItem(mUrl, mContext);
+ }
+ }
+
+ private void removeHistory(ContentResolver cr) {
+ mDB.removeHistoryEntry(cr, mUrl);
+ }
+ }
+
+ private static class RemovePartnerBookmarkTask extends UIAsyncTask.WithoutParams<Void> {
+ private Context context;
+ private long bookmarkId;
+
+ public RemovePartnerBookmarkTask(Context context, long bookmarkId) {
+ super(ThreadUtils.getBackgroundHandler());
+
+ this.context = context;
+ this.bookmarkId = bookmarkId;
+ }
+
+ @Override
+ protected Void doInBackground() {
+ context.getContentResolver().delete(
+ PartnerBookmarksProviderProxy.getUriForBookmark(context, bookmarkId),
+ null,
+ null
+ );
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ SnackbarBuilder.builder((Activity) context)
+ .message(R.string.page_removed)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java
new file mode 100644
index 000000000..d179a27ce
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeListView.java
@@ -0,0 +1,138 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemLongClickListener;
+import android.widget.ListView;
+
+/**
+ * HomeListView is a custom extension of ListView, that packs a HomeContextMenuInfo
+ * when any of its rows is long pressed.
+ */
+public class HomeListView extends ListView
+ implements OnItemLongClickListener {
+
+ // ContextMenuInfo associated with the currently long pressed list item.
+ private HomeContextMenuInfo mContextMenuInfo;
+
+ // On URL open listener
+ protected OnUrlOpenListener mUrlOpenListener;
+
+ // Top divider
+ private final boolean mShowTopDivider;
+
+ // ContextMenuInfo maker
+ private HomeContextMenuInfo.Factory mContextMenuInfoFactory;
+
+ public HomeListView(Context context) {
+ this(context, null);
+ }
+
+ public HomeListView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.homeListViewStyle);
+ }
+
+ public HomeListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HomeListView, defStyle, 0);
+ mShowTopDivider = a.getBoolean(R.styleable.HomeListView_topDivider, false);
+ a.recycle();
+
+ setOnItemLongClickListener(this);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ final Drawable divider = getDivider();
+ if (mShowTopDivider && divider != null) {
+ final int dividerHeight = getDividerHeight();
+ final View view = new View(getContext());
+ view.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, dividerHeight));
+ addHeaderView(view);
+ }
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ mUrlOpenListener = null;
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ Object item = parent.getItemAtPosition(position);
+
+ // HomeListView could hold headers too. Add a context menu info only for its children.
+ if (item instanceof Cursor) {
+ Cursor cursor = (Cursor) item;
+ if (cursor == null || mContextMenuInfoFactory == null) {
+ mContextMenuInfo = null;
+ return false;
+ }
+
+ mContextMenuInfo = mContextMenuInfoFactory.makeInfoForCursor(view, position, id, cursor);
+ return showContextMenuForChild(HomeListView.this);
+
+ } else if (mContextMenuInfoFactory instanceof HomeContextMenuInfo.ListFactory) {
+ mContextMenuInfo = ((HomeContextMenuInfo.ListFactory) mContextMenuInfoFactory).makeInfoForAdapter(view, position, id, getAdapter());
+ return showContextMenuForChild(HomeListView.this);
+ } else {
+ mContextMenuInfo = null;
+ return false;
+ }
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ @Override
+ public void setOnItemClickListener(final AdapterView.OnItemClickListener listener) {
+ if (listener == null) {
+ super.setOnItemClickListener(null);
+ return;
+ }
+
+ super.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mShowTopDivider) {
+ position--;
+ }
+
+ listener.onItemClick(parent, view, position, id);
+ }
+ });
+ }
+
+ public void setContextMenuInfoFactory(final HomeContextMenuInfo.Factory factory) {
+ mContextMenuInfoFactory = factory;
+ }
+
+ public OnUrlOpenListener getOnUrlOpenListener() {
+ return mUrlOpenListener;
+ }
+
+ public void setOnUrlOpenListener(OnUrlOpenListener listener) {
+ mUrlOpenListener = listener;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java
new file mode 100644
index 000000000..4915f0c91
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePager.java
@@ -0,0 +1,564 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.home.HomeAdapter.OnAddPanelListener;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+public class HomePager extends ViewPager implements HomeScreen {
+
+ @Override
+ public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
+ return super.requestFocus(direction, previouslyFocusedRect);
+ }
+
+ private static final int LOADER_ID_CONFIG = 0;
+
+ private final Context mContext;
+ private volatile boolean mVisible;
+ private Decor mDecor;
+ private View mTabStrip;
+ private HomeBanner mHomeBanner;
+ private int mDefaultPageIndex = -1;
+
+ private final OnAddPanelListener mAddPanelListener;
+
+ private final HomeConfig mConfig;
+ private final ConfigLoaderCallbacks mConfigLoaderCallbacks;
+
+ private String mInitialPanelId;
+ private Bundle mRestoreData;
+
+ // Cached original ViewPager background.
+ private final Drawable mOriginalBackground;
+
+ // Telemetry session for current panel.
+ private TelemetryContract.Session mCurrentPanelSession;
+ private String mCurrentPanelSessionSuffix;
+
+ // Current load state of HomePager.
+ private LoadState mLoadState;
+
+ // Listens for when the current panel changes.
+ private OnPanelChangeListener mPanelChangedListener;
+
+ private HomeFragment.PanelStateChangeListener mPanelStateChangeListener;
+
+ // This is mostly used by UI tests to easily fetch
+ // specific list views at runtime.
+ public static final String LIST_TAG_HISTORY = "history";
+ public static final String LIST_TAG_BOOKMARKS = "bookmarks";
+ public static final String LIST_TAG_TOP_SITES = "top_sites";
+ public static final String LIST_TAG_RECENT_TABS = "recent_tabs";
+ public static final String LIST_TAG_BROWSER_SEARCH = "browser_search";
+ public static final String LIST_TAG_REMOTE_TABS = "remote_tabs";
+
+ public interface OnUrlOpenListener {
+ public enum Flags {
+ ALLOW_SWITCH_TO_TAB,
+ OPEN_WITH_INTENT,
+ /**
+ * Ensure that the raw URL is opened. If not set, then the reader view version of the page
+ * might be opened if the URL is stored as an offline reader-view bookmark.
+ */
+ NO_READER_VIEW
+ }
+
+ public void onUrlOpen(String url, EnumSet<Flags> flags);
+ }
+
+ /**
+ * Interface for requesting a new tab be opened in the background.
+ * <p>
+ * This is the <code>HomeFragment</code> equivalent of opening a new tab by
+ * long clicking a link and selecting the "Open new [private] tab" context
+ * menu option.
+ */
+ public interface OnUrlOpenInBackgroundListener {
+ public enum Flags {
+ PRIVATE,
+ }
+
+ /**
+ * Open a new tab with the given URL
+ *
+ * @param url to open.
+ * @param flags to open new tab with.
+ */
+ public void onUrlOpenInBackground(String url, EnumSet<Flags> flags);
+ }
+
+ /**
+ * Special type of child views that could be added as pager decorations by default.
+ */
+ public interface Decor {
+ void onAddPagerView(String title);
+ void removeAllPagerViews();
+ void onPageSelected(int position);
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
+ void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener);
+ }
+
+ /**
+ * State of HomePager with respect to loading its configuration.
+ */
+ private enum LoadState {
+ UNLOADED,
+ LOADING,
+ LOADED
+ }
+
+ public static final String CAN_LOAD_ARG = "canLoad";
+ public static final String PANEL_CONFIG_ARG = "panelConfig";
+
+ public HomePager(Context context) {
+ this(context, null);
+ }
+
+ public HomePager(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+
+ mConfig = HomeConfig.getDefault(mContext);
+ mConfigLoaderCallbacks = new ConfigLoaderCallbacks();
+
+ mAddPanelListener = new OnAddPanelListener() {
+ @Override
+ public void onAddPanel(String title) {
+ if (mDecor != null) {
+ mDecor.onAddPagerView(title);
+ }
+ }
+ };
+
+ // This is to keep all 4 panels in memory after they are
+ // selected in the pager.
+ setOffscreenPageLimit(3);
+
+ // We can call HomePager.requestFocus to steal focus from the URL bar and drop the soft
+ // keyboard. However, if there are no focusable views (e.g. an empty reading list), the
+ // URL bar will be refocused. Therefore, we make the HomePager container focusable to
+ // ensure there is always a focusable view. This would ordinarily be done via an XML
+ // attribute, but it is not working properly.
+ setFocusableInTouchMode(true);
+
+ mOriginalBackground = getBackground();
+ setOnPageChangeListener(new PageChangeListener());
+
+ mLoadState = LoadState.UNLOADED;
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ if (child instanceof Decor) {
+ ((ViewPager.LayoutParams) params).isDecor = true;
+ mDecor = (Decor) child;
+ mTabStrip = child;
+
+ mDecor.setOnTitleClickListener(new TabMenuStrip.OnTitleClickListener() {
+ @Override
+ public void onTitleClicked(int index) {
+ setCurrentItem(index, true);
+ }
+ });
+ }
+
+ super.addView(child, index, params);
+ }
+
+ /**
+ * Loads and initializes the pager.
+ *
+ * @param fm FragmentManager for the adapter
+ */
+ @Override
+ public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator) {
+ mLoadState = LoadState.LOADING;
+
+ mVisible = true;
+ mInitialPanelId = panelId;
+ mRestoreData = restoreData;
+
+ // Update the home banner message each time the HomePager is loaded.
+ if (mHomeBanner != null) {
+ mHomeBanner.update();
+ }
+
+ // Only animate on post-HC devices, when a non-null animator is given
+ final boolean shouldAnimate = animator != null;
+
+ final HomeAdapter adapter = new HomeAdapter(mContext, fm);
+ adapter.setOnAddPanelListener(mAddPanelListener);
+ adapter.setPanelStateChangeListener(mPanelStateChangeListener);
+ adapter.setCanLoadHint(true);
+ setAdapter(adapter);
+
+ // Don't show the tabs strip until we have the
+ // list of panels in place.
+ mTabStrip.setVisibility(View.INVISIBLE);
+
+ // Load list of panels from configuration
+ lm.initLoader(LOADER_ID_CONFIG, null, mConfigLoaderCallbacks);
+
+ if (shouldAnimate) {
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ setLayerType(View.LAYER_TYPE_NONE, null);
+ }
+ });
+
+ ViewHelper.setAlpha(this, 0.0f);
+
+ animator.attach(this,
+ PropertyAnimator.Property.ALPHA,
+ 1.0f);
+ }
+ }
+
+ /**
+ * Removes all child fragments to free memory.
+ */
+ @Override
+ public void unload() {
+ mVisible = false;
+ setAdapter(null);
+ mLoadState = LoadState.UNLOADED;
+
+ // Stop UI Telemetry sessions.
+ stopCurrentPanelTelemetrySession();
+ }
+
+ /**
+ * Determines whether the pager is visible.
+ *
+ * Unlike getVisibility(), this method does not need to be called on the UI
+ * thread.
+ *
+ * @return Whether the pager and its fragments are loaded
+ */
+ public boolean isVisible() {
+ return mVisible;
+ }
+
+ @Override
+ public void setCurrentItem(int item, boolean smoothScroll) {
+ super.setCurrentItem(item, smoothScroll);
+
+ if (mDecor != null) {
+ mDecor.onPageSelected(item);
+ }
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(item == mDefaultPageIndex);
+ }
+ }
+
+ private void restorePanelData(int item, Bundle data) {
+ ((HomeAdapter) getAdapter()).setRestoreData(item, data);
+ }
+
+ /**
+ * Shows a home panel. If the given panelId is null,
+ * the default panel will be shown. No action will be taken if:
+ * * HomePager has not loaded yet
+ * * Panel with the given panelId cannot be found
+ *
+ * If you're trying to open a built-in panel, consider loading the panel url directly with
+ * {@link org.mozilla.gecko.AboutPages#getURLForBuiltinPanelType(HomeConfig.PanelType)}.
+ *
+ * @param panelId of the home panel to be shown.
+ */
+ @Override
+ public void showPanel(String panelId, Bundle restoreData) {
+ if (!mVisible) {
+ return;
+ }
+
+ switch (mLoadState) {
+ case LOADING:
+ mInitialPanelId = panelId;
+ mRestoreData = restoreData;
+ break;
+
+ case LOADED:
+ int position = mDefaultPageIndex;
+ if (panelId != null) {
+ position = ((HomeAdapter) getAdapter()).getItemPosition(panelId);
+ }
+
+ if (position > -1) {
+ setCurrentItem(position);
+ if (restoreData != null) {
+ restorePanelData(position, restoreData);
+ }
+ }
+ break;
+
+ default:
+ // Do nothing.
+ }
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ // Drop the soft keyboard by stealing focus from the URL bar.
+ requestFocus();
+ }
+
+ return super.onInterceptTouchEvent(event);
+ }
+
+ public void setBanner(HomeBanner banner) {
+ mHomeBanner = banner;
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent event) {
+ if (mHomeBanner != null) {
+ mHomeBanner.handleHomeTouch(event);
+ }
+
+ return super.dispatchTouchEvent(event);
+ }
+
+ @Override
+ public void onToolbarFocusChange(boolean hasFocus) {
+ if (mHomeBanner == null) {
+ return;
+ }
+
+ // We should only make the banner active if the toolbar is not focused and we are on the default page
+ final boolean active = !hasFocus && getCurrentItem() == mDefaultPageIndex;
+ mHomeBanner.setActive(active);
+ }
+
+ private void updateUiFromConfigState(HomeConfig.State configState) {
+ // We only care about the adapter if HomePager is currently
+ // loaded, which means it's visible in the activity.
+ if (!mVisible) {
+ return;
+ }
+
+ if (mDecor != null) {
+ mDecor.removeAllPagerViews();
+ }
+
+ final HomeAdapter adapter = (HomeAdapter) getAdapter();
+
+ // Disable any fragment loading until we have the initial
+ // panel selection done.
+ adapter.setCanLoadHint(false);
+
+ // Destroy any existing panels currently loaded
+ // in the pager.
+ setAdapter(null);
+
+ // Only keep enabled panels.
+ final List<PanelConfig> enabledPanels = new ArrayList<PanelConfig>();
+
+ for (PanelConfig panelConfig : configState) {
+ if (!panelConfig.isDisabled()) {
+ enabledPanels.add(panelConfig);
+ }
+ }
+
+ // Update the adapter with the new panel configs
+ adapter.update(enabledPanels);
+
+ final int count = enabledPanels.size();
+ if (count == 0) {
+ // Set firefox watermark as background.
+ setBackgroundResource(R.drawable.home_pager_empty_state);
+ // Hide the tab strip as there are no panels.
+ mTabStrip.setVisibility(View.INVISIBLE);
+ } else {
+ mTabStrip.setVisibility(View.VISIBLE);
+ // Restore original background.
+ setBackgroundDrawable(mOriginalBackground);
+ }
+
+ // Re-install the adapter with the final state
+ // in the pager.
+ setAdapter(adapter);
+
+ if (count == 0) {
+ mDefaultPageIndex = -1;
+
+ // Hide the banner if there are no enabled panels.
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(false);
+ }
+ } else {
+ for (int i = 0; i < count; i++) {
+ if (enabledPanels.get(i).isDefault()) {
+ mDefaultPageIndex = i;
+ break;
+ }
+ }
+
+ // Use the default panel if the initial panel wasn't explicitly set by the
+ // load() caller, or if the initial panel is not found in the adapter.
+ final int itemPosition = (mInitialPanelId == null) ? -1 : adapter.getItemPosition(mInitialPanelId);
+ if (itemPosition > -1) {
+ setCurrentItem(itemPosition, false);
+ if (mRestoreData != null) {
+ restorePanelData(itemPosition, mRestoreData);
+ mRestoreData = null; // Release data since it's no longer needed
+ }
+ mInitialPanelId = null;
+ } else {
+ setCurrentItem(mDefaultPageIndex, false);
+ }
+ }
+
+ // The selection is updated asynchronously so we need to post to
+ // UI thread to give the pager time to commit the new page selection
+ // internally and load the right initial panel.
+ ThreadUtils.getUiHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ adapter.setCanLoadHint(true);
+ }
+ });
+ }
+
+ @Override
+ public void setOnPanelChangeListener(OnPanelChangeListener listener) {
+ mPanelChangedListener = listener;
+ }
+
+ @Override
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+ mPanelStateChangeListener = listener;
+
+ HomeAdapter adapter = (HomeAdapter) getAdapter();
+ if (adapter != null) {
+ adapter.setPanelStateChangeListener(listener);
+ }
+ }
+
+ /**
+ * Notify listeners of newly selected panel.
+ *
+ * @param position of the newly selected panel
+ */
+ private void notifyPanelSelected(int position) {
+ if (mDecor != null) {
+ mDecor.onPageSelected(position);
+ }
+
+ if (mPanelChangedListener != null) {
+ final String panelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position);
+ mPanelChangedListener.onPanelSelected(panelId);
+ }
+ }
+
+ private class ConfigLoaderCallbacks implements LoaderCallbacks<HomeConfig.State> {
+ @Override
+ public Loader<HomeConfig.State> onCreateLoader(int id, Bundle args) {
+ return new HomeConfigLoader(mContext, mConfig);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<HomeConfig.State> loader, HomeConfig.State configState) {
+ mLoadState = LoadState.LOADED;
+ updateUiFromConfigState(configState);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<HomeConfig.State> loader) {
+ mLoadState = LoadState.UNLOADED;
+ }
+ }
+
+ private class PageChangeListener implements ViewPager.OnPageChangeListener {
+ @Override
+ public void onPageSelected(int position) {
+ notifyPanelSelected(position);
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setActive(position == mDefaultPageIndex);
+ }
+
+ // Start a UI telemetry session for the newly selected panel.
+ final String newPanelId = ((HomeAdapter) getAdapter()).getPanelIdAtPosition(position);
+ startNewPanelTelemetrySession(newPanelId);
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (mDecor != null) {
+ mDecor.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ if (mHomeBanner != null) {
+ mHomeBanner.setScrollingPages(positionOffsetPixels != 0);
+ }
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) { }
+ }
+
+ /**
+ * Start UI telemetry session for the a panel.
+ * If there is currently a session open for a panel,
+ * it will be stopped before a new one is started.
+ *
+ * @param panelId of panel to start a session for
+ */
+ private void startNewPanelTelemetrySession(String panelId) {
+ // Stop the current panel's session if we have one.
+ stopCurrentPanelTelemetrySession();
+
+ mCurrentPanelSession = TelemetryContract.Session.HOME_PANEL;
+ mCurrentPanelSessionSuffix = panelId;
+ Telemetry.startUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix);
+ }
+
+ /**
+ * Stop the current panel telemetry session if one exists.
+ */
+ private void stopCurrentPanelTelemetrySession() {
+ if (mCurrentPanelSession != null) {
+ Telemetry.stopUISession(mCurrentPanelSession, mCurrentPanelSessionSuffix);
+ mCurrentPanelSession = null;
+ mCurrentPanelSessionSuffix = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java
new file mode 100644
index 000000000..bfd6c5624
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomePanelsManager.java
@@ -0,0 +1,368 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import static org.mozilla.gecko.home.HomeConfig.createBuiltinPanelConfig;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.db.HomeProvider;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.PanelInfoManager.PanelInfo;
+import org.mozilla.gecko.home.PanelInfoManager.RequestCallback;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.Handler;
+import android.util.Log;
+
+public class HomePanelsManager implements GeckoEventListener {
+ public static final String LOGTAG = "HomePanelsManager";
+
+ private static final HomePanelsManager sInstance = new HomePanelsManager();
+
+ private static final int INVALIDATION_DELAY_MSEC = 500;
+ private static final int PANEL_INFO_TIMEOUT_MSEC = 1000;
+
+ private static final String EVENT_HOMEPANELS_INSTALL = "HomePanels:Install";
+ private static final String EVENT_HOMEPANELS_UNINSTALL = "HomePanels:Uninstall";
+ private static final String EVENT_HOMEPANELS_UPDATE = "HomePanels:Update";
+ private static final String EVENT_HOMEPANELS_REFRESH = "HomePanels:RefreshDataset";
+
+ private static final String JSON_KEY_PANEL = "panel";
+ private static final String JSON_KEY_PANEL_ID = "id";
+
+ private enum ChangeType {
+ UNINSTALL,
+ INSTALL,
+ UPDATE,
+ REFRESH
+ }
+
+ private enum InvalidationMode {
+ DELAYED,
+ IMMEDIATE
+ }
+
+ private static class ConfigChange {
+ private final ChangeType type;
+ private final Object target;
+
+ public ConfigChange(ChangeType type) {
+ this(type, null);
+ }
+
+ public ConfigChange(ChangeType type, Object target) {
+ this.type = type;
+ this.target = target;
+ }
+ }
+
+ private Context mContext;
+ private HomeConfig mHomeConfig;
+ private boolean mInitialized;
+
+ private final Queue<ConfigChange> mPendingChanges = new ConcurrentLinkedQueue<ConfigChange>();
+ private final Runnable mInvalidationRunnable = new InvalidationRunnable();
+
+ public static HomePanelsManager getInstance() {
+ return sInstance;
+ }
+
+ public void init(Context context) {
+ if (mInitialized) {
+ return;
+ }
+
+ mContext = context;
+ mHomeConfig = HomeConfig.getDefault(context);
+
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ EVENT_HOMEPANELS_INSTALL,
+ EVENT_HOMEPANELS_UNINSTALL,
+ EVENT_HOMEPANELS_UPDATE,
+ EVENT_HOMEPANELS_REFRESH);
+
+ mInitialized = true;
+ }
+
+ public void onLocaleReady(final String locale) {
+ ThreadUtils.getBackgroundHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ final String configLocale = mHomeConfig.getLocale();
+ if (configLocale == null || !configLocale.equals(locale)) {
+ handleLocaleChange();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals(EVENT_HOMEPANELS_INSTALL)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_INSTALL);
+ handlePanelInstall(createPanelConfigFromMessage(message), InvalidationMode.DELAYED);
+ } else if (event.equals(EVENT_HOMEPANELS_UNINSTALL)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_UNINSTALL);
+ final String panelId = message.getString(JSON_KEY_PANEL_ID);
+ handlePanelUninstall(panelId);
+ } else if (event.equals(EVENT_HOMEPANELS_UPDATE)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_UPDATE);
+ handlePanelUpdate(createPanelConfigFromMessage(message));
+ } else if (event.equals(EVENT_HOMEPANELS_REFRESH)) {
+ Log.d(LOGTAG, EVENT_HOMEPANELS_REFRESH);
+ handleDatasetRefresh(message);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to handle event " + event, e);
+ }
+ }
+
+ private PanelConfig createPanelConfigFromMessage(JSONObject message) throws JSONException {
+ final JSONObject json = message.getJSONObject(JSON_KEY_PANEL);
+ return new PanelConfig(json);
+ }
+
+ /**
+ * Adds a new PanelConfig to the HomeConfig.
+ *
+ * This posts the invalidation of HomeConfig immediately.
+ *
+ * @param panelConfig panel to add
+ */
+ public void installPanel(PanelConfig panelConfig) {
+ Log.d(LOGTAG, "installPanel: " + panelConfig.getTitle());
+ handlePanelInstall(panelConfig, InvalidationMode.IMMEDIATE);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelInstall(PanelConfig panelConfig, InvalidationMode mode) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.INSTALL, panelConfig));
+ Log.d(LOGTAG, "handlePanelInstall: " + mPendingChanges.size());
+
+ scheduleInvalidation(mode);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelUninstall(String panelId) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.UNINSTALL, panelId));
+ Log.d(LOGTAG, "handlePanelUninstall: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.DELAYED);
+ }
+
+ /**
+ * Runs in the gecko thread.
+ */
+ private void handlePanelUpdate(PanelConfig panelConfig) {
+ mPendingChanges.offer(new ConfigChange(ChangeType.UPDATE, panelConfig));
+ Log.d(LOGTAG, "handlePanelUpdate: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.DELAYED);
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void handleLocaleChange() {
+ mPendingChanges.offer(new ConfigChange(ChangeType.REFRESH));
+ Log.d(LOGTAG, "handleLocaleChange: " + mPendingChanges.size());
+
+ scheduleInvalidation(InvalidationMode.IMMEDIATE);
+ }
+
+
+ /**
+ * Handles a dataset refresh request from Gecko. This is usually
+ * triggered by a HomeStorage.save() call in an add-on.
+ *
+ * Runs in the gecko thread.
+ */
+ private void handleDatasetRefresh(JSONObject message) {
+ final String datasetId;
+ try {
+ datasetId = message.getString("datasetId");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Failed to handle dataset refresh", e);
+ return;
+ }
+
+ Log.d(LOGTAG, "Refresh request for dataset: " + datasetId);
+
+ final ContentResolver cr = mContext.getContentResolver();
+ cr.notifyChange(HomeProvider.getDatasetNotificationUri(datasetId), null);
+ }
+
+ /**
+ * Runs in the gecko or main thread.
+ */
+ private void scheduleInvalidation(InvalidationMode mode) {
+ final Handler handler = ThreadUtils.getBackgroundHandler();
+
+ handler.removeCallbacks(mInvalidationRunnable);
+
+ if (mode == InvalidationMode.IMMEDIATE) {
+ handler.post(mInvalidationRunnable);
+ } else {
+ handler.postDelayed(mInvalidationRunnable, INVALIDATION_DELAY_MSEC);
+ }
+
+ Log.d(LOGTAG, "scheduleInvalidation: scheduled new invalidation: " + mode);
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void executePendingChanges(HomeConfig.Editor editor) {
+ boolean shouldRefresh = false;
+
+ while (!mPendingChanges.isEmpty()) {
+ final ConfigChange pendingChange = mPendingChanges.poll();
+
+ switch (pendingChange.type) {
+ case UNINSTALL: {
+ final String panelId = (String) pendingChange.target;
+ if (editor.uninstall(panelId)) {
+ Log.d(LOGTAG, "executePendingChanges: uninstalled panel " + panelId);
+ }
+ break;
+ }
+
+ case INSTALL: {
+ final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
+ if (editor.install(panelConfig)) {
+ Log.d(LOGTAG, "executePendingChanges: added panel " + panelConfig.getId());
+ }
+ break;
+ }
+
+ case UPDATE: {
+ final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
+ if (editor.update(panelConfig)) {
+ Log.w(LOGTAG, "executePendingChanges: updated panel " + panelConfig.getId());
+ }
+ break;
+ }
+
+ case REFRESH: {
+ shouldRefresh = true;
+ }
+ }
+ }
+
+ // The editor still represents the default HomeConfig
+ // configuration and hasn't been changed by any operation
+ // above. No need to refresh as the HomeConfig backend will
+ // take of forcing all existing HomeConfigLoader instances to
+ // refresh their contents.
+ if (shouldRefresh && !editor.isDefault()) {
+ executeRefresh(editor);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void refreshFromPanelInfos(HomeConfig.Editor editor, List<PanelInfo> panelInfos) {
+ Log.d(LOGTAG, "refreshFromPanelInfos");
+
+ for (PanelConfig panelConfig : editor) {
+ PanelConfig refreshedPanelConfig = null;
+
+ if (panelConfig.isDynamic()) {
+ for (PanelInfo panelInfo : panelInfos) {
+ if (panelInfo.getId().equals(panelConfig.getId())) {
+ refreshedPanelConfig = panelInfo.toPanelConfig();
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshing from panel info: " + panelInfo.getId());
+ break;
+ }
+ }
+ } else {
+ refreshedPanelConfig = createBuiltinPanelConfig(mContext, panelConfig.getType());
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshing built-in panel: " + panelConfig.getId());
+ }
+
+ if (refreshedPanelConfig == null) {
+ Log.d(LOGTAG, "refreshFromPanelInfos: no refreshed panel, falling back: " + panelConfig.getId());
+ continue;
+ }
+
+ Log.d(LOGTAG, "refreshFromPanelInfos: refreshed panel " + refreshedPanelConfig.getId());
+ editor.update(refreshedPanelConfig);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private void executeRefresh(HomeConfig.Editor editor) {
+ if (editor.isEmpty()) {
+ return;
+ }
+
+ Log.d(LOGTAG, "executeRefresh");
+
+ final Set<String> ids = new HashSet<String>();
+ for (PanelConfig panelConfig : editor) {
+ ids.add(panelConfig.getId());
+ }
+
+ final Object panelRequestLock = new Object();
+ final List<PanelInfo> latestPanelInfos = new ArrayList<PanelInfo>();
+
+ final PanelInfoManager pm = new PanelInfoManager();
+ pm.requestPanelsById(ids, new RequestCallback() {
+ @Override
+ public void onComplete(List<PanelInfo> panelInfos) {
+ synchronized (panelRequestLock) {
+ latestPanelInfos.addAll(panelInfos);
+ Log.d(LOGTAG, "executeRefresh: fetched panel infos: " + panelInfos.size());
+
+ panelRequestLock.notifyAll();
+ }
+ }
+ });
+
+ try {
+ synchronized (panelRequestLock) {
+ panelRequestLock.wait(PANEL_INFO_TIMEOUT_MSEC);
+
+ Log.d(LOGTAG, "executeRefresh: done fetching panel infos");
+ refreshFromPanelInfos(editor, latestPanelInfos);
+ }
+ } catch (InterruptedException e) {
+ Log.e(LOGTAG, "Failed to fetch panels from gecko", e);
+ }
+ }
+
+ /**
+ * Runs in the background thread.
+ */
+ private class InvalidationRunnable implements Runnable {
+ @Override
+ public void run() {
+ final HomeConfig.Editor editor = mHomeConfig.load().edit();
+ executePendingChanges(editor);
+ editor.apply();
+ }
+ };
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java
new file mode 100644
index 000000000..1525969a0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/HomeScreen.java
@@ -0,0 +1,57 @@
+package org.mozilla.gecko.home;
+
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.view.View;
+
+import org.mozilla.gecko.animation.PropertyAnimator;
+
+/**
+ * Generic interface for any View that can be used as the homescreen.
+ *
+ * In the past we had the HomePager, which contained the usual homepanels (multiple panels: TopSites,
+ * bookmarks, history, etc.), which could be swiped between.
+ *
+ * This interface allows easily switching between different homepanel implementations. For example
+ * the prototype activity-stream panel (which will be a single panel combining the functionality
+ * of the previous panels).
+ */
+public interface HomeScreen {
+ /**
+ * Interface for listening into ViewPager panel changes
+ */
+ public interface OnPanelChangeListener {
+ /**
+ * Called when a new panel is selected.
+ *
+ * @param panelId of the newly selected panel
+ */
+ public void onPanelSelected(String panelId);
+ }
+
+ // The following two methods are actually methods of View. Since there is no View interface
+ // we're forced to do this instead of "extending" View. Any class implementing HomeScreen
+ // will have to implement these and pass them through to the underlying View.
+ boolean isVisible();
+ boolean requestFocus();
+
+ void onToolbarFocusChange(boolean hasFocus);
+
+ // The following three methods are HomePager specific. The persistence framework might need
+ // refactoring/generalising at some point, but it isn't entirely clear what other panels
+ // might need so we can leave these as is for now.
+ void showPanel(String panelId, Bundle restoreData);
+ void setOnPanelChangeListener(OnPanelChangeListener listener);
+ void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener);
+
+ /**
+ * Set a banner that may be displayed at the bottom of the HomeScreen. This can be used
+ * e.g. to show snippets.
+ */
+ void setBanner(HomeBanner banner);
+
+ void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData, PropertyAnimator animator);
+
+ void unload();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java
new file mode 100644
index 000000000..2bbd82a8d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/ImageLoader.java
@@ -0,0 +1,164 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.DisplayMetrics;
+import android.util.Log;
+
+import com.squareup.picasso.LruCache;
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Downloader.Response;
+import com.squareup.picasso.UrlConnectionDownloader;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.mozilla.gecko.distribution.Distribution;
+
+public class ImageLoader {
+ private static final String LOGTAG = "GeckoImageLoader";
+
+ private static final String DISTRIBUTION_SCHEME = "gecko.distribution";
+ private static final String SUGGESTED_SITES_AUTHORITY = "suggestedsites";
+
+ // The order of density factors to try when looking for an image resource
+ // in the distribution directory. It looks for an exact match first (1.0) then
+ // tries to find images with higher density (2.0 and 1.5). If no image is found,
+ // try a lower density (0.5). See loadDistributionImage().
+ private static final float[] densityFactors = new float[] { 1.0f, 2.0f, 1.5f, 0.5f };
+
+ private static enum Density {
+ MDPI,
+ HDPI,
+ XHDPI,
+ XXHDPI;
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase();
+ }
+ }
+
+ // Picasso instance and LruCache lrucache are protected by synchronization.
+ private static Picasso instance;
+ private static LruCache lrucache;
+
+ public static synchronized Picasso with(Context context) {
+ if (instance == null) {
+ lrucache = new LruCache(context);
+ Picasso.Builder builder = new Picasso.Builder(context).memoryCache(lrucache);
+
+ final Distribution distribution = Distribution.getInstance(context.getApplicationContext());
+ builder.downloader(new ImageDownloader(context, distribution));
+ instance = builder.build();
+ }
+
+ return instance;
+ }
+
+ public static synchronized void clearLruCache() {
+ if (lrucache != null) {
+ lrucache.evictAll();
+ }
+ }
+
+ /**
+ * Custom Downloader built on top of Picasso's UrlConnectionDownloader
+ * that supports loading images from custom URIs.
+ */
+ public static class ImageDownloader extends UrlConnectionDownloader {
+ private final Context context;
+ private final Distribution distribution;
+
+ public ImageDownloader(Context context, Distribution distribution) {
+ super(context);
+ this.context = context;
+ this.distribution = distribution;
+ }
+
+ private Density getDensity(float factor) {
+ final DisplayMetrics dm = context.getResources().getDisplayMetrics();
+ final float densityDpi = dm.densityDpi * factor;
+
+ if (densityDpi >= DisplayMetrics.DENSITY_XXHIGH) {
+ return Density.XXHDPI;
+ } else if (densityDpi >= DisplayMetrics.DENSITY_XHIGH) {
+ return Density.XHDPI;
+ } else if (densityDpi >= DisplayMetrics.DENSITY_HIGH) {
+ return Density.HDPI;
+ }
+
+ // Fallback to mdpi, no need to handle ldpi.
+ return Density.MDPI;
+ }
+
+ @Override
+ public Response load(Uri uri, boolean localCacheOnly) throws IOException {
+ final String scheme = uri.getScheme();
+ if (DISTRIBUTION_SCHEME.equals(scheme)) {
+ return loadDistributionImage(uri);
+ }
+
+ return super.load(uri, localCacheOnly);
+ }
+
+ private static String getPathForDensity(String basePath, Density density,
+ String filename) {
+ final File dir = new File(basePath, density.toString());
+ return String.format("%s/%s.png", dir.toString(), filename);
+ }
+
+ /**
+ * Handle distribution URIs in Picasso. The expected format is:
+ *
+ * gecko.distribution://<basepath>/<imagename>
+ *
+ * Which will look for the following file in the distribution:
+ *
+ * <distribution-root-dir>/<basepath>/<device-density>/<imagename>.png
+ */
+ private Response loadDistributionImage(Uri uri) throws IOException {
+ // Eliminate the leading '//'
+ final String ssp = uri.getSchemeSpecificPart().substring(2);
+
+ final String filename;
+ final String basePath;
+
+ final int slashIndex = ssp.lastIndexOf('/');
+ if (slashIndex == -1) {
+ filename = ssp;
+ basePath = "";
+ } else {
+ filename = ssp.substring(slashIndex + 1);
+ basePath = ssp.substring(0, slashIndex);
+ }
+
+ Set<Density> triedDensities = EnumSet.noneOf(Density.class);
+
+ for (int i = 0; i < densityFactors.length; i++) {
+ final Density density = getDensity(densityFactors[i]);
+ if (!triedDensities.add(density)) {
+ continue;
+ }
+
+ final String path = getPathForDensity(basePath, density, filename);
+ Log.d(LOGTAG, "Trying to load image from distribution " + path);
+
+ final File f = distribution.getDistributionFile(path);
+ if (f != null) {
+ return new Response(new FileInputStream(f), true);
+ }
+ }
+
+ throw new ResponseException("Couldn't find suggested site image in distribution");
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java
new file mode 100644
index 000000000..26edf13ff
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/MultiTypeCursorAdapter.java
@@ -0,0 +1,100 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.CursorAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * MultiTypeCursorAdapter wraps a cursor and any meta data associated with it.
+ * A set of view types (corresponding to the cursor and its meta data)
+ * are mapped to a set of layouts.
+ */
+abstract class MultiTypeCursorAdapter extends CursorAdapter {
+ private final int[] mViewTypes;
+ private final int[] mLayouts;
+
+ // Bind the view for the given position.
+ abstract public void bindView(View view, Context context, int position);
+
+ public MultiTypeCursorAdapter(Context context, Cursor cursor, int[] viewTypes, int[] layouts) {
+ super(context, cursor, 0);
+
+ if (viewTypes.length != layouts.length) {
+ throw new IllegalStateException("The view types and the layouts should be of same size");
+ }
+
+ mViewTypes = viewTypes;
+ mLayouts = layouts;
+ }
+
+ @Override
+ public final int getViewTypeCount() {
+ return mViewTypes.length;
+ }
+
+ /**
+ * @return Cursor for the given position.
+ */
+ public final Cursor getCursor(int position) {
+ final Cursor cursor = getCursor();
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ return cursor;
+ }
+
+ @Override
+ public final View getView(int position, View convertView, ViewGroup parent) {
+ final Context context = parent.getContext();
+ if (convertView == null) {
+ convertView = newView(context, position, parent);
+ }
+
+ bindView(convertView, context, position);
+ return convertView;
+ }
+
+ @Override
+ public final void bindView(View view, Context context, Cursor cursor) {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public final View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return null;
+ }
+
+ /**
+ * Inflate a new view from a set of view types and layouts based on the position.
+ *
+ * @param context Context for inflating the view.
+ * @param position Position of the view.
+ * @param parent Parent view group that will hold this view.
+ */
+ private View newView(Context context, int position, ViewGroup parent) {
+ final int type = getItemViewType(position);
+ final int count = mViewTypes.length;
+
+ for (int i = 0; i < count; i++) {
+ if (mViewTypes[i] == type) {
+ return LayoutInflater.from(context).inflate(mLayouts[i], parent, false);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java
new file mode 100644
index 000000000..d66919344
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthCache.java
@@ -0,0 +1,82 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+
+/**
+ * Cache used to store authentication state of dynamic panels. The values
+ * in this cache are set in JS through the Home.panels API.
+ *
+ * {@code DynamicPanel} uses this cache to determine whether or not to
+ * show authentication UI for dynamic panels, including listening for
+ * changes in authentication state.
+ */
+class PanelAuthCache {
+ private static final String LOGTAG = "GeckoPanelAuthCache";
+
+ // Keep this in sync with the constant defined in Home.jsm
+ private static final String PREFS_PANEL_AUTH_PREFIX = "home_panels_auth_";
+
+ private final Context mContext;
+ private SharedPrefsListener mSharedPrefsListener;
+ private OnChangeListener mChangeListener;
+
+ public interface OnChangeListener {
+ public void onChange(String panelId, boolean isAuthenticated);
+ }
+
+ public PanelAuthCache(Context context) {
+ mContext = context;
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return GeckoSharedPrefs.forProfile(mContext);
+ }
+
+ private String getPanelAuthKey(String panelId) {
+ return PREFS_PANEL_AUTH_PREFIX + panelId;
+ }
+
+ public boolean isAuthenticated(String panelId) {
+ final SharedPreferences prefs = getSharedPreferences();
+ return prefs.getBoolean(getPanelAuthKey(panelId), false);
+ }
+
+ public void setOnChangeListener(OnChangeListener listener) {
+ final SharedPreferences prefs = getSharedPreferences();
+
+ if (mChangeListener != null) {
+ prefs.unregisterOnSharedPreferenceChangeListener(mSharedPrefsListener);
+ mSharedPrefsListener = null;
+ }
+
+ mChangeListener = listener;
+
+ if (mChangeListener != null) {
+ mSharedPrefsListener = new SharedPrefsListener();
+ prefs.registerOnSharedPreferenceChangeListener(mSharedPrefsListener);
+ }
+ }
+
+ private class SharedPrefsListener implements OnSharedPreferenceChangeListener {
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
+ if (key.startsWith(PREFS_PANEL_AUTH_PREFIX)) {
+ final String panelId = key.substring(PREFS_PANEL_AUTH_PREFIX.length());
+ final boolean isAuthenticated = prefs.getBoolean(key, false);
+
+ Log.d(LOGTAG, "Auth state changed: panelId=" + panelId + ", isAuthenticated=" + isAuthenticated);
+ mChangeListener.onChange(panelId, isAuthenticated);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java
new file mode 100644
index 000000000..1ad91b7ca
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelAuthLayout.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomeConfig.AuthConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.squareup.picasso.Picasso;
+
+class PanelAuthLayout extends LinearLayout {
+
+ public PanelAuthLayout(Context context, PanelConfig panelConfig) {
+ super(context);
+
+ final AuthConfig authConfig = panelConfig.getAuthConfig();
+ if (authConfig == null) {
+ throw new IllegalStateException("Can't create PanelAuthLayout without a valid AuthConfig");
+ }
+
+ setOrientation(LinearLayout.VERTICAL);
+ LayoutInflater.from(context).inflate(R.layout.panel_auth_layout, this);
+
+ final TextView messageView = (TextView) findViewById(R.id.message);
+ messageView.setText(authConfig.getMessageText());
+
+ final Button buttonView = (Button) findViewById(R.id.button);
+ buttonView.setText(authConfig.getButtonText());
+
+ final String panelId = panelConfig.getId();
+ buttonView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GeckoAppShell.notifyObservers("HomePanels:Authenticate", panelId);
+ }
+ });
+
+ final ImageView imageView = (ImageView) findViewById(R.id.image);
+ final String imageUrl = authConfig.getImageUrl();
+
+ if (TextUtils.isEmpty(imageUrl)) {
+ // Use a default image if an image URL isn't specified.
+ imageView.setImageResource(R.drawable.icon_home_empty_firefox);
+ } else {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .into(imageView);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java
new file mode 100644
index 000000000..4772e08ab
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelBackItemView.java
@@ -0,0 +1,48 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.PanelLayout.FilterDetail;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.squareup.picasso.Picasso;
+
+class PanelBackItemView extends LinearLayout {
+ private final TextView title;
+
+ public PanelBackItemView(Context context, String backImageUrl) {
+ super(context);
+
+ LayoutInflater.from(context).inflate(R.layout.panel_back_item, this);
+ setOrientation(HORIZONTAL);
+
+ title = (TextView) findViewById(R.id.title);
+
+ final ImageView image = (ImageView) findViewById(R.id.image);
+
+ if (TextUtils.isEmpty(backImageUrl)) {
+ image.setImageResource(R.drawable.arrow_up);
+ } else {
+ ImageLoader.with(getContext())
+ .load(backImageUrl)
+ .placeholder(R.drawable.arrow_up)
+ .into(image);
+ }
+ }
+
+ public void updateFromFilter(FilterDetail filter) {
+ final String backText = getResources()
+ .getString(R.string.home_move_back_to_filter, filter.title);
+ title.setText(backText);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java
new file mode 100644
index 000000000..50c4dbc07
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelHeaderView.java
@@ -0,0 +1,28 @@
+package org.mozilla.gecko.home;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.widget.ImageView;
+
+@SuppressLint("ViewConstructor") // View is only created from code
+public class PanelHeaderView extends ImageView {
+ public PanelHeaderView(Context context, HomeConfig.HeaderConfig config) {
+ super(context);
+
+ setAdjustViewBounds(true);
+
+ ImageLoader.with(context)
+ .load(config.getImageUrl())
+ .into(this);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int width = MeasureSpec.getSize(widthMeasureSpec);
+
+ // Always span the whole width and adjust height as needed.
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
+
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java
new file mode 100644
index 000000000..089e17837
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelInfoManager.java
@@ -0,0 +1,162 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.util.Log;
+import android.util.SparseArray;
+
+public class PanelInfoManager implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoPanelInfoManager";
+
+ public class PanelInfo {
+ private final String mId;
+ private final String mTitle;
+ private final JSONObject mJSONData;
+
+ public PanelInfo(String id, String title, JSONObject jsonData) {
+ mId = id;
+ mTitle = title;
+ mJSONData = jsonData;
+ }
+
+ public String getId() {
+ return mId;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public PanelConfig toPanelConfig() {
+ try {
+ return new PanelConfig(mJSONData);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to convert PanelInfo to PanelConfig", e);
+ return null;
+ }
+ }
+ }
+
+ public interface RequestCallback {
+ public void onComplete(List<PanelInfo> panelInfos);
+ }
+
+ private static final AtomicInteger sRequestId = new AtomicInteger(0);
+
+ // Stores set of pending request callbacks.
+ private static final SparseArray<RequestCallback> sCallbacks = new SparseArray<RequestCallback>();
+
+ /**
+ * Asynchronously fetches list of available panels from Gecko
+ * for the given IDs.
+ *
+ * @param ids list of panel ids to be fetched. A null value will fetch all
+ * available panels.
+ * @param callback onComplete will be called on the UI thread.
+ */
+ public void requestPanelsById(Set<String> ids, RequestCallback callback) {
+ final int requestId = sRequestId.getAndIncrement();
+
+ synchronized (sCallbacks) {
+ // If there are no pending callbacks, register the event listener.
+ if (sCallbacks.size() == 0) {
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "HomePanels:Data");
+ }
+ sCallbacks.put(requestId, callback);
+ }
+
+ final JSONObject message = new JSONObject();
+ try {
+ message.put("requestId", requestId);
+
+ if (ids != null && ids.size() > 0) {
+ JSONArray idsArray = new JSONArray();
+ for (String id : ids) {
+ idsArray.put(id);
+ }
+
+ message.put("ids", idsArray);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Failed to build event to request panels by id", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("HomePanels:Get", message.toString());
+ }
+
+ /**
+ * Asynchronously fetches list of available panels from Gecko.
+ *
+ * @param callback onComplete will be called on the UI thread.
+ */
+ public void requestAvailablePanels(RequestCallback callback) {
+ requestPanelsById(null, callback);
+ }
+
+ /**
+ * Handles "HomePanels:Data" events.
+ */
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ final ArrayList<PanelInfo> panelInfos = new ArrayList<PanelInfo>();
+
+ try {
+ final JSONArray panels = message.getJSONArray("panels");
+ final int count = panels.length();
+ for (int i = 0; i < count; i++) {
+ final PanelInfo panelInfo = getPanelInfoFromJSON(panels.getJSONObject(i));
+ panelInfos.add(panelInfo);
+ }
+
+ final RequestCallback callback;
+ final int requestId = message.getInt("requestId");
+
+ synchronized (sCallbacks) {
+ callback = sCallbacks.get(requestId);
+ sCallbacks.delete(requestId);
+
+ // Unregister the event listener if there are no more pending callbacks.
+ if (sCallbacks.size() == 0) {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "HomePanels:Data");
+ }
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ callback.onComplete(panelInfos);
+ }
+ });
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception handling " + event + " message", e);
+ }
+ }
+
+ private PanelInfo getPanelInfoFromJSON(JSONObject jsonPanelInfo) throws JSONException {
+ final String id = jsonPanelInfo.getString("id");
+ final String title = jsonPanelInfo.getString("title");
+
+ return new PanelInfo(id, title, jsonPanelInfo);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java
new file mode 100644
index 000000000..2a97d42bc
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelItemView.java
@@ -0,0 +1,136 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ItemType;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+class PanelItemView extends LinearLayout {
+ private final TextView titleView;
+ private final TextView descriptionView;
+ private final ImageView imageView;
+ private final LinearLayout titleDescContainerView;
+ private final ImageView backgroundView;
+
+ private PanelItemView(Context context, int layoutId) {
+ super(context);
+
+ LayoutInflater.from(context).inflate(layoutId, this);
+ titleView = (TextView) findViewById(R.id.title);
+ descriptionView = (TextView) findViewById(R.id.description);
+ imageView = (ImageView) findViewById(R.id.image);
+ backgroundView = (ImageView) findViewById(R.id.background);
+ titleDescContainerView = (LinearLayout) findViewById(R.id.title_desc_container);
+ }
+
+ public void updateFromCursor(Cursor cursor) {
+ int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE);
+ final String titleText = cursor.getString(titleIndex);
+
+ // Only show title if the item has one
+ final boolean hasTitle = !TextUtils.isEmpty(titleText);
+ titleView.setVisibility(hasTitle ? View.VISIBLE : View.GONE);
+ if (hasTitle) {
+ titleView.setText(titleText);
+ }
+
+ int descriptionIndex = cursor.getColumnIndexOrThrow(HomeItems.DESCRIPTION);
+ final String descriptionText = cursor.getString(descriptionIndex);
+
+ // Only show description if the item has one
+ // Descriptions are not supported for IconItemView objects (Bug 1157539)
+ final boolean hasDescription = !TextUtils.isEmpty(descriptionText);
+ if (descriptionView != null) {
+ descriptionView.setVisibility(hasDescription ? View.VISIBLE : View.GONE);
+ if (hasDescription) {
+ descriptionView.setText(descriptionText);
+ }
+ }
+ if (titleDescContainerView != null) {
+ titleDescContainerView.setVisibility(hasTitle || hasDescription ? View.VISIBLE : View.GONE);
+ }
+
+ int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL);
+ final String imageUrl = cursor.getString(imageIndex);
+
+ // Only try to load the image if the item has define image URL
+ final boolean hasImageUrl = !TextUtils.isEmpty(imageUrl);
+ imageView.setVisibility(hasImageUrl ? View.VISIBLE : View.GONE);
+
+ if (hasImageUrl) {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .into(imageView);
+ }
+
+ final int columnIndexBackgroundColor = cursor.getColumnIndex(HomeItems.BACKGROUND_COLOR);
+ if (columnIndexBackgroundColor != -1) {
+ final String color = cursor.getString(columnIndexBackgroundColor);
+ if (!TextUtils.isEmpty(color)) {
+ setBackgroundColor(Color.parseColor(color));
+ }
+ }
+
+ // Backgrounds are only supported for IconItemView objects (Bug 1157539)
+ final int columnIndexBackgroundUrl = cursor.getColumnIndex(HomeItems.BACKGROUND_URL);
+ if (columnIndexBackgroundUrl != -1) {
+ final String backgroundUrl = cursor.getString(columnIndexBackgroundUrl);
+ if (backgroundView != null && !TextUtils.isEmpty(backgroundUrl)) {
+ ImageLoader.with(getContext())
+ .load(backgroundUrl)
+ .fit()
+ .into(backgroundView);
+ }
+ }
+ }
+
+ private static class ArticleItemView extends PanelItemView {
+ private ArticleItemView(Context context) {
+ super(context, R.layout.panel_article_item);
+ setOrientation(LinearLayout.HORIZONTAL);
+ }
+ }
+
+ private static class ImageItemView extends PanelItemView {
+ private ImageItemView(Context context) {
+ super(context, R.layout.panel_image_item);
+ setOrientation(LinearLayout.VERTICAL);
+ }
+ }
+
+ private static class IconItemView extends PanelItemView {
+ private IconItemView(Context context) {
+ super(context, R.layout.panel_icon_item);
+ }
+ }
+
+ public static PanelItemView create(Context context, ItemType itemType) {
+ switch (itemType) {
+ case ARTICLE:
+ return new ArticleItemView(context);
+
+ case IMAGE:
+ return new ImageItemView(context);
+
+ case ICON:
+ return new IconItemView(context);
+
+ default:
+ throw new IllegalArgumentException("Could not create panel item view from " + itemType);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java
new file mode 100644
index 000000000..2c2d89ae0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelLayout.java
@@ -0,0 +1,747 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.HomeConfig.EmptyViewConfig;
+import org.mozilla.gecko.home.HomeConfig.ItemHandler;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.lang.ref.SoftReference;
+import java.util.EnumSet;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import com.squareup.picasso.Picasso;
+
+/**
+ * {@code PanelLayout} is the base class for custom layouts to be
+ * used in {@code DynamicPanel}. It provides the basic framework
+ * that enables custom layouts to request and reset datasets and
+ * create panel views. Furthermore, it automates most of the process
+ * of binding panel views with their respective datasets.
+ *
+ * {@code PanelLayout} abstracts the implemention details of how
+ * datasets are actually loaded through the {@DatasetHandler} interface.
+ * {@code DatasetHandler} provides two operations: request and reset.
+ * The results of the dataset requests done via the {@code DatasetHandler}
+ * are delivered to the {@code PanelLayout} with the {@code deliverDataset()}
+ * method.
+ *
+ * Subclasses of {@code PanelLayout} should simply use the utilities
+ * provided by {@code PanelLayout}. Namely:
+ *
+ * {@code requestDataset()} - To fetch datasets and auto-bind them to
+ * the existing panel views backed by them.
+ *
+ * {@code resetDataset()} - To release any resources associated with a
+ * previously loaded dataset.
+ *
+ * {@code createPanelView()} - To create a panel view for a ViewConfig
+ * associated with the panel.
+ *
+ * {@code disposePanelView()} - To dispose any dataset references associated
+ * with the given view.
+ *
+ * {@code PanelLayout} subclasses should always use {@code createPanelView()}
+ * to create the views dynamically created based on {@code ViewConfig}. This
+ * allows {@code PanelLayout} to auto-bind datasets with panel views.
+ * {@code PanelLayout} subclasses are free to have any type of views to arrange
+ * the panel views in different ways.
+ */
+abstract class PanelLayout extends FrameLayout {
+ private static final String LOGTAG = "GeckoPanelLayout";
+
+ protected final SparseArray<ViewState> mViewStates;
+ private final PanelConfig mPanelConfig;
+ private final DatasetHandler mDatasetHandler;
+ private final OnUrlOpenListener mUrlOpenListener;
+ private final ContextMenuRegistry mContextMenuRegistry;
+
+ /**
+ * To be used by panel views to express that they are
+ * backed by datasets.
+ */
+ public interface DatasetBacked {
+ public void setDataset(Cursor cursor);
+ public void setFilterManager(FilterManager manager);
+ }
+
+ /**
+ * To be used by requests made to {@code DatasetHandler}s to couple dataset ID with current
+ * filter for queries on the database.
+ */
+ public static class DatasetRequest implements Parcelable {
+ public enum Type implements Parcelable {
+ DATASET_LOAD,
+ FILTER_PUSH,
+ FILTER_POP;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<Type> CREATOR = new Creator<Type>() {
+ @Override
+ public Type createFromParcel(final Parcel source) {
+ return Type.values()[source.readInt()];
+ }
+
+ @Override
+ public Type[] newArray(final int size) {
+ return new Type[size];
+ }
+ };
+ }
+
+ private final int mViewIndex;
+ private final Type mType;
+ private final String mDatasetId;
+ private final FilterDetail mFilterDetail;
+
+ private DatasetRequest(Parcel in) {
+ this.mViewIndex = in.readInt();
+ this.mType = (Type) in.readParcelable(getClass().getClassLoader());
+ this.mDatasetId = in.readString();
+ this.mFilterDetail = (FilterDetail) in.readParcelable(getClass().getClassLoader());
+ }
+
+ public DatasetRequest(int index, String datasetId, FilterDetail filterDetail) {
+ this(index, Type.DATASET_LOAD, datasetId, filterDetail);
+ }
+
+ public DatasetRequest(int index, Type type, String datasetId, FilterDetail filterDetail) {
+ this.mViewIndex = index;
+ this.mType = type;
+ this.mDatasetId = datasetId;
+ this.mFilterDetail = filterDetail;
+ }
+
+ public int getViewIndex() {
+ return mViewIndex;
+ }
+
+ public Type getType() {
+ return mType;
+ }
+
+ public String getDatasetId() {
+ return mDatasetId;
+ }
+
+ public String getFilter() {
+ return (mFilterDetail != null ? mFilterDetail.filter : null);
+ }
+
+ public FilterDetail getFilterDetail() {
+ return mFilterDetail;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mViewIndex);
+ dest.writeParcelable(mType, 0);
+ dest.writeString(mDatasetId);
+ dest.writeParcelable(mFilterDetail, 0);
+ }
+
+ public String toString() {
+ return "{ index: " + mViewIndex +
+ ", type: " + mType +
+ ", dataset: " + mDatasetId +
+ ", filter: " + mFilterDetail +
+ " }";
+ }
+
+ public static final Creator<DatasetRequest> CREATOR = new Creator<DatasetRequest>() {
+ @Override
+ public DatasetRequest createFromParcel(Parcel in) {
+ return new DatasetRequest(in);
+ }
+
+ @Override
+ public DatasetRequest[] newArray(int size) {
+ return new DatasetRequest[size];
+ }
+ };
+ }
+
+ /**
+ * Defines the contract with the component that is responsible
+ * for handling datasets requests.
+ */
+ public interface DatasetHandler {
+ /**
+ * Requests a dataset to be fetched and auto-bound to the
+ * panel views backed by it.
+ */
+ public void requestDataset(DatasetRequest request);
+
+ /**
+ * Releases any resources associated with a panel view. It will
+ * do nothing if the view with the given index been created
+ * before.
+ */
+ public void resetDataset(int viewIndex);
+ }
+
+ public interface PanelView {
+ public void setOnItemOpenListener(OnItemOpenListener listener);
+ public void setOnKeyListener(OnKeyListener listener);
+ public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory);
+ }
+
+ public interface FilterManager {
+ public FilterDetail getPreviousFilter();
+ public boolean canGoBack();
+ public void goBack();
+ }
+
+ public interface ContextMenuRegistry {
+ public void register(View view);
+ }
+
+ public PanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler,
+ OnUrlOpenListener urlOpenListener, ContextMenuRegistry contextMenuRegistry) {
+ super(context);
+ mViewStates = new SparseArray<ViewState>();
+ mPanelConfig = panelConfig;
+ mDatasetHandler = datasetHandler;
+ mUrlOpenListener = urlOpenListener;
+ mContextMenuRegistry = contextMenuRegistry;
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ final int count = mViewStates.size();
+ for (int i = 0; i < count; i++) {
+ final ViewState viewState = mViewStates.valueAt(i);
+
+ final View view = viewState.getView();
+ if (view != null) {
+ maybeSetDataset(view, null);
+ }
+ }
+ mViewStates.clear();
+ }
+
+ /**
+ * Delivers the dataset as a {@code Cursor} to be bound to the
+ * panel view backed by it. This is used by the {@code DatasetHandler}
+ * in response to a dataset request.
+ */
+ public final void deliverDataset(DatasetRequest request, Cursor cursor) {
+ Log.d(LOGTAG, "Delivering request: " + request);
+ final ViewState viewState = mViewStates.get(request.getViewIndex());
+ if (viewState == null) {
+ return;
+ }
+
+ switch (request.getType()) {
+ case FILTER_PUSH:
+ viewState.pushFilter(request.getFilterDetail());
+ break;
+ case FILTER_POP:
+ viewState.popFilter();
+ break;
+ }
+
+ final View activeView = viewState.getActiveView();
+ if (activeView == null) {
+ throw new IllegalStateException("No active view for view state: " + viewState.getIndex());
+ }
+
+ final ViewConfig viewConfig = viewState.getViewConfig();
+
+ final View newView;
+ if (cursor == null || cursor.getCount() == 0) {
+ newView = createEmptyView(viewConfig);
+ maybeSetDataset(activeView, null);
+ } else {
+ newView = createPanelView(viewConfig);
+ maybeSetDataset(newView, cursor);
+ }
+
+ if (activeView != newView) {
+ replacePanelView(activeView, newView);
+ }
+ }
+
+ /**
+ * Releases any references to the given dataset from all
+ * existing panel views.
+ */
+ public final void releaseDataset(int viewIndex) {
+ Log.d(LOGTAG, "Releasing dataset: " + viewIndex);
+ final ViewState viewState = mViewStates.get(viewIndex);
+ if (viewState == null) {
+ return;
+ }
+
+ final View view = viewState.getView();
+ if (view != null) {
+ maybeSetDataset(view, null);
+ }
+ }
+
+ /**
+ * Requests a dataset to be loaded and bound to any existing
+ * panel view backed by it.
+ */
+ protected final void requestDataset(DatasetRequest request) {
+ Log.d(LOGTAG, "Requesting request: " + request);
+ if (mViewStates.get(request.getViewIndex()) == null) {
+ return;
+ }
+
+ mDatasetHandler.requestDataset(request);
+ }
+
+ /**
+ * Releases any resources associated with a panel view.
+ * e.g. close any associated {@code Cursor}.
+ */
+ protected final void resetDataset(int viewIndex) {
+ Log.d(LOGTAG, "Resetting view with index: " + viewIndex);
+ if (mViewStates.get(viewIndex) == null) {
+ return;
+ }
+
+ mDatasetHandler.resetDataset(viewIndex);
+ }
+
+ /**
+ * Factory method to create instance of panels from a given
+ * {@code ViewConfig}. All panel views defined in {@code PanelConfig}
+ * should be created using this method so that {@PanelLayout} can
+ * keep track of panel views and their associated datasets.
+ */
+ protected final View createPanelView(ViewConfig viewConfig) {
+ Log.d(LOGTAG, "Creating panel view: " + viewConfig.getType());
+
+ ViewState viewState = mViewStates.get(viewConfig.getIndex());
+ if (viewState == null) {
+ viewState = new ViewState(viewConfig);
+ mViewStates.put(viewConfig.getIndex(), viewState);
+ }
+
+ View view = viewState.getView();
+ if (view == null) {
+ switch (viewConfig.getType()) {
+ case LIST:
+ view = new PanelListView(getContext(), viewConfig);
+ break;
+
+ case GRID:
+ view = new PanelRecyclerView(getContext(), viewConfig);
+ break;
+
+ default:
+ throw new IllegalStateException("Unrecognized view type in " + getClass().getSimpleName());
+ }
+
+ PanelView panelView = (PanelView) view;
+ panelView.setOnItemOpenListener(new PanelOnItemOpenListener(viewState));
+ panelView.setOnKeyListener(new PanelKeyListener(viewState));
+ panelView.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(HomeItems.TITLE));
+ return info;
+ }
+ });
+
+ mContextMenuRegistry.register(view);
+
+ if (view instanceof DatasetBacked) {
+ DatasetBacked datasetBacked = (DatasetBacked) view;
+ datasetBacked.setFilterManager(new PanelFilterManager(viewState));
+
+ if (viewConfig.isRefreshEnabled()) {
+ view = new PanelRefreshLayout(getContext(), view,
+ mPanelConfig.getId(), viewConfig.getIndex());
+ }
+ }
+
+ viewState.setView(view);
+ }
+
+ return view;
+ }
+
+ /**
+ * Dispose any dataset references associated with the
+ * given view.
+ */
+ protected final void disposePanelView(View view) {
+ Log.d(LOGTAG, "Disposing panel view");
+ final int count = mViewStates.size();
+ for (int i = 0; i < count; i++) {
+ final ViewState viewState = mViewStates.valueAt(i);
+
+ if (viewState.getView() == view) {
+ maybeSetDataset(view, null);
+ mViewStates.remove(viewState.getIndex());
+ break;
+ }
+ }
+ }
+
+ private void maybeSetDataset(View view, Cursor cursor) {
+ if (view instanceof DatasetBacked) {
+ final DatasetBacked dsb = (DatasetBacked) view;
+ dsb.setDataset(cursor);
+ }
+ }
+
+ private View createEmptyView(ViewConfig viewConfig) {
+ Log.d(LOGTAG, "Creating empty view: " + viewConfig.getType());
+
+ ViewState viewState = mViewStates.get(viewConfig.getIndex());
+ if (viewState == null) {
+ throw new IllegalStateException("No view state found for view index: " + viewConfig.getIndex());
+ }
+
+ View view = viewState.getEmptyView();
+ if (view == null) {
+ view = LayoutInflater.from(getContext()).inflate(R.layout.home_empty_panel, null);
+
+ final EmptyViewConfig emptyViewConfig = viewConfig.getEmptyViewConfig();
+
+ // XXX: Refactor this into a custom view (bug 985134)
+ final String text = (emptyViewConfig == null) ? null : emptyViewConfig.getText();
+ final TextView textView = (TextView) view.findViewById(R.id.home_empty_text);
+ if (TextUtils.isEmpty(text)) {
+ textView.setText(R.string.home_default_empty);
+ } else {
+ textView.setText(text);
+ }
+
+ final String imageUrl = (emptyViewConfig == null) ? null : emptyViewConfig.getImageUrl();
+ final ImageView imageView = (ImageView) view.findViewById(R.id.home_empty_image);
+
+ if (TextUtils.isEmpty(imageUrl)) {
+ imageView.setImageResource(R.drawable.icon_home_empty_firefox);
+ } else {
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .error(R.drawable.icon_home_empty_firefox)
+ .into(imageView);
+ }
+
+ viewState.setEmptyView(view);
+ }
+
+ return view;
+ }
+
+ private void replacePanelView(View currentView, View newView) {
+ final ViewGroup parent = (ViewGroup) currentView.getParent();
+ parent.addView(newView, parent.indexOfChild(currentView), currentView.getLayoutParams());
+ parent.removeView(currentView);
+ }
+
+ /**
+ * Must be implemented by {@code PanelLayout} subclasses to define
+ * what happens then the layout is first loaded. Should set initial
+ * UI state and request any necessary datasets.
+ */
+ public abstract void load();
+
+ /**
+ * Represents a 'live' instance of a panel view associated with
+ * the {@code PanelLayout}. Is responsible for tracking the history stack of filters.
+ */
+ protected class ViewState {
+ private final ViewConfig mViewConfig;
+ private SoftReference<View> mView;
+ private SoftReference<View> mEmptyView;
+ private LinkedList<FilterDetail> mFilterStack;
+
+ public ViewState(ViewConfig viewConfig) {
+ mViewConfig = viewConfig;
+ mView = new SoftReference<View>(null);
+ mEmptyView = new SoftReference<View>(null);
+ }
+
+ public ViewConfig getViewConfig() {
+ return mViewConfig;
+ }
+
+ public int getIndex() {
+ return mViewConfig.getIndex();
+ }
+
+ public View getView() {
+ return mView.get();
+ }
+
+ public void setView(View view) {
+ mView = new SoftReference<View>(view);
+ }
+
+ public View getEmptyView() {
+ return mEmptyView.get();
+ }
+
+ public void setEmptyView(View view) {
+ mEmptyView = new SoftReference<View>(view);
+ }
+
+ public View getActiveView() {
+ final View view = getView();
+ if (view != null && view.getParent() != null) {
+ return view;
+ }
+
+ final View emptyView = getEmptyView();
+ if (emptyView != null && emptyView.getParent() != null) {
+ return emptyView;
+ }
+
+ return null;
+ }
+
+ public String getDatasetId() {
+ return mViewConfig.getDatasetId();
+ }
+
+ public ItemHandler getItemHandler() {
+ return mViewConfig.getItemHandler();
+ }
+
+ /**
+ * Get the current filter that this view is displaying, or null if none.
+ */
+ public FilterDetail getCurrentFilter() {
+ if (mFilterStack == null) {
+ return null;
+ } else {
+ return mFilterStack.peek();
+ }
+ }
+
+ /**
+ * Get the previous filter that this view was displaying, or null if none.
+ */
+ public FilterDetail getPreviousFilter() {
+ if (!canPopFilter()) {
+ return null;
+ }
+
+ return mFilterStack.get(1);
+ }
+
+ /**
+ * Adds a filter to the history stack for this view.
+ */
+ public void pushFilter(FilterDetail filter) {
+ if (mFilterStack == null) {
+ mFilterStack = new LinkedList<FilterDetail>();
+
+ // Initialize with the initial filter.
+ mFilterStack.push(new FilterDetail(mViewConfig.getFilter(),
+ mPanelConfig.getTitle()));
+ }
+
+ mFilterStack.push(filter);
+ }
+
+ /**
+ * Remove the most recent filter from the stack.
+ *
+ * @return whether the filter was popped
+ */
+ public boolean popFilter() {
+ if (!canPopFilter()) {
+ return false;
+ }
+
+ mFilterStack.pop();
+ return true;
+ }
+
+ public boolean canPopFilter() {
+ return (mFilterStack != null && mFilterStack.size() > 1);
+ }
+ }
+
+ static class FilterDetail implements Parcelable {
+ final String filter;
+ final String title;
+
+ private FilterDetail(Parcel in) {
+ this.filter = in.readString();
+ this.title = in.readString();
+ }
+
+ public FilterDetail(String filter, String title) {
+ this.filter = filter;
+ this.title = title;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(filter);
+ dest.writeString(title);
+ }
+
+ public static final Creator<FilterDetail> CREATOR = new Creator<FilterDetail>() {
+ @Override
+ public FilterDetail createFromParcel(Parcel in) {
+ return new FilterDetail(in);
+ }
+
+ @Override
+ public FilterDetail[] newArray(int size) {
+ return new FilterDetail[size];
+ }
+ };
+ }
+
+ /**
+ * Pushes filter to {@code ViewState}'s stack and makes request for new filter value.
+ */
+ private void pushFilterOnView(ViewState viewState, FilterDetail filterDetail) {
+ final int index = viewState.getIndex();
+ final String datasetId = viewState.getDatasetId();
+
+ mDatasetHandler.requestDataset(new DatasetRequest(index,
+ DatasetRequest.Type.FILTER_PUSH,
+ datasetId,
+ filterDetail));
+ }
+
+ /**
+ * Pops filter from {@code ViewState}'s stack and makes request for previous filter value.
+ *
+ * @return whether the filter has changed
+ */
+ private boolean popFilterOnView(ViewState viewState) {
+ if (viewState.canPopFilter()) {
+ final int index = viewState.getIndex();
+ final String datasetId = viewState.getDatasetId();
+ final FilterDetail filterDetail = viewState.getPreviousFilter();
+
+ mDatasetHandler.requestDataset(new DatasetRequest(index,
+ DatasetRequest.Type.FILTER_POP,
+ datasetId,
+ filterDetail));
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public interface OnItemOpenListener {
+ public void onItemOpen(String url, String title);
+ }
+
+ private class PanelOnItemOpenListener implements OnItemOpenListener {
+ private final ViewState mViewState;
+
+ public PanelOnItemOpenListener(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public void onItemOpen(String url, String title) {
+ if (StringUtils.isFilterUrl(url)) {
+ FilterDetail filterDetail = new FilterDetail(StringUtils.getFilterFromUrl(url), title);
+ pushFilterOnView(mViewState, filterDetail);
+ } else {
+ EnumSet<OnUrlOpenListener.Flags> flags = EnumSet.noneOf(OnUrlOpenListener.Flags.class);
+ if (mViewState.getItemHandler() == ItemHandler.INTENT) {
+ flags.add(OnUrlOpenListener.Flags.OPEN_WITH_INTENT);
+ }
+
+ mUrlOpenListener.onUrlOpen(url, flags);
+ }
+ }
+ }
+
+ private class PanelKeyListener implements View.OnKeyListener {
+ private final ViewState mViewState;
+
+ public PanelKeyListener(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ return popFilterOnView(mViewState);
+ }
+
+ return false;
+ }
+ }
+
+ private class PanelFilterManager implements FilterManager {
+ private final ViewState mViewState;
+
+ public PanelFilterManager(ViewState viewState) {
+ mViewState = viewState;
+ }
+
+ @Override
+ public FilterDetail getPreviousFilter() {
+ return mViewState.getPreviousFilter();
+ }
+
+ @Override
+ public boolean canGoBack() {
+ return mViewState.canPopFilter();
+ }
+
+ @Override
+ public void goBack() {
+ popFilterOnView(mViewState);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java
new file mode 100644
index 000000000..505fb9b0d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelListView.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ItemHandler;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener;
+import org.mozilla.gecko.home.PanelLayout.PanelView;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.util.Log;
+import android.view.View;
+import android.widget.AdapterView;
+
+public class PanelListView extends HomeListView
+ implements DatasetBacked, PanelView {
+
+ private static final String LOGTAG = "GeckoPanelListView";
+
+ private final ViewConfig viewConfig;
+ private final PanelViewAdapter adapter;
+ private final PanelViewItemHandler itemHandler;
+ private OnItemOpenListener itemOpenListener;
+
+ public PanelListView(Context context, ViewConfig viewConfig) {
+ super(context);
+
+ this.viewConfig = viewConfig;
+ itemHandler = new PanelViewItemHandler();
+
+ adapter = new PanelViewAdapter(context, viewConfig);
+ setAdapter(adapter);
+
+ setOnItemClickListener(new PanelListItemClickListener());
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ itemHandler.setOnItemOpenListener(itemOpenListener);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ itemHandler.setOnItemOpenListener(null);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ Log.d(LOGTAG, "Setting dataset: " + viewConfig.getDatasetId());
+ adapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void setOnItemOpenListener(OnItemOpenListener listener) {
+ itemHandler.setOnItemOpenListener(listener);
+ itemOpenListener = listener;
+ }
+
+ @Override
+ public void setFilterManager(FilterManager filterManager) {
+ adapter.setFilterManager(filterManager);
+ itemHandler.setFilterManager(filterManager);
+ }
+
+ private class PanelListItemClickListener implements AdapterView.OnItemClickListener {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ itemHandler.openItemAtPosition(adapter.getCursor(), position);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java
new file mode 100644
index 000000000..9145ab1e1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerView.java
@@ -0,0 +1,178 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.PanelView;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemClickListener;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport.OnItemLongClickListener;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+/**
+ * RecyclerView implementation for grid home panels.
+ */
+@SuppressLint("ViewConstructor") // View is only created from code
+public class PanelRecyclerView extends RecyclerView
+ implements DatasetBacked, PanelView, OnItemClickListener, OnItemLongClickListener {
+ private final PanelRecyclerViewAdapter adapter;
+ private final GridLayoutManager layoutManager;
+ private final PanelViewItemHandler itemHandler;
+ private final float columnWidth;
+ private final boolean autoFit;
+ private final HomeConfig.ViewConfig viewConfig;
+
+ private PanelLayout.OnItemOpenListener itemOpenListener;
+ private HomeContextMenuInfo contextMenuInfo;
+ private HomeContextMenuInfo.Factory contextMenuInfoFactory;
+
+ public PanelRecyclerView(Context context, HomeConfig.ViewConfig viewConfig) {
+ super(context);
+
+ this.viewConfig = viewConfig;
+
+ final Resources resources = context.getResources();
+
+ int spanCount;
+ if (viewConfig.getItemType() == HomeConfig.ItemType.ICON) {
+ autoFit = false;
+ spanCount = getResources().getInteger(R.integer.panel_icon_grid_view_columns);
+ } else {
+ autoFit = true;
+ spanCount = 1;
+ }
+
+ columnWidth = resources.getDimension(R.dimen.panel_grid_view_column_width);
+ layoutManager = new GridLayoutManager(context, spanCount);
+ adapter = new PanelRecyclerViewAdapter(context, viewConfig);
+ itemHandler = new PanelViewItemHandler();
+
+ layoutManager.setSpanSizeLookup(new PanelSpanSizeLookup());
+
+ setLayoutManager(layoutManager);
+ setAdapter(adapter);
+
+ int horizontalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_horizontal_spacing);
+ int verticalSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_vertical_spacing);
+ int outerSpacing = (int) resources.getDimension(R.dimen.panel_grid_view_outer_spacing);
+
+ addItemDecoration(new SpacingDecoration(horizontalSpacing, verticalSpacing));
+
+ setPadding(outerSpacing, outerSpacing, outerSpacing, outerSpacing);
+ setClipToPadding(false);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this)
+ .setOnItemLongClickListener(this);
+ }
+
+ @Override
+ protected void onMeasure(int widthSpec, int heightSpec) {
+ super.onMeasure(widthSpec, heightSpec);
+
+ if (autoFit) {
+ // Adjust span based on space available (What GridView does when you say numColumns="auto_fit")
+ final int spanCount = (int) Math.max(1, getMeasuredWidth() / columnWidth);
+ layoutManager.setSpanCount(spanCount);
+ }
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ itemHandler.setOnItemOpenListener(itemOpenListener);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ itemHandler.setOnItemOpenListener(null);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ adapter.swapCursor(cursor);
+ }
+
+ @Override
+ public void setFilterManager(PanelLayout.FilterManager manager) {
+ adapter.setFilterManager(manager);
+ itemHandler.setFilterManager(manager);
+ }
+
+ @Override
+ public void setOnItemOpenListener(PanelLayout.OnItemOpenListener listener) {
+ itemOpenListener = listener;
+ itemHandler.setOnItemOpenListener(listener);
+ }
+
+ @Override
+ public HomeContextMenuInfo getContextMenuInfo() {
+ return contextMenuInfo;
+ }
+
+ @Override
+ public void setContextMenuInfoFactory(HomeContextMenuInfo.Factory factory) {
+ contextMenuInfoFactory = factory;
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ itemOpenListener.onItemOpen(viewConfig.getHeaderConfig().getUrl(), null);
+ return;
+ }
+
+ position--;
+ }
+
+ itemHandler.openItemAtPosition(adapter.getCursor(), position);
+ }
+
+ @Override
+ public boolean onItemLongClicked(RecyclerView recyclerView, int position, View v) {
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ final HomeConfig.HeaderConfig headerConfig = viewConfig.getHeaderConfig();
+
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(v, position, -1);
+ info.url = headerConfig.getUrl();
+ info.title = headerConfig.getUrl();
+
+ contextMenuInfo = info;
+ return showContextMenuForChild(this);
+ }
+
+ position--;
+ }
+
+ Cursor cursor = adapter.getCursor();
+ cursor.moveToPosition(position);
+
+ contextMenuInfo = contextMenuInfoFactory.makeInfoForCursor(recyclerView, position, -1, cursor);
+ return showContextMenuForChild(this);
+ }
+
+ private class PanelSpanSizeLookup extends GridLayoutManager.SpanSizeLookup {
+ @Override
+ public int getSpanSize(int position) {
+ if (position == 0 && viewConfig.hasHeaderConfig()) {
+ return layoutManager.getSpanCount();
+ }
+
+ return 1;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java
new file mode 100644
index 000000000..fa632bccd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRecyclerViewAdapter.java
@@ -0,0 +1,137 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+public class PanelRecyclerViewAdapter extends RecyclerView.Adapter<PanelRecyclerViewAdapter.PanelViewHolder> {
+ private static final int VIEW_TYPE_ITEM = 0;
+ private static final int VIEW_TYPE_BACK = 1;
+ private static final int VIEW_TYPE_HEADER = 2;
+
+ public static class PanelViewHolder extends RecyclerView.ViewHolder {
+ public static PanelViewHolder create(View itemView) {
+
+ // Wrap in a FrameLayout that will handle the highlight on touch
+ FrameLayout frameLayout = (FrameLayout) LayoutInflater.from(itemView.getContext())
+ .inflate(R.layout.panel_item_container, null);
+
+ frameLayout.addView(itemView, 0, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ return new PanelViewHolder(frameLayout);
+ }
+
+ private PanelViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+
+ private final Context context;
+ private final HomeConfig.ViewConfig viewConfig;
+ private PanelLayout.FilterManager filterManager;
+ private Cursor cursor;
+
+ public PanelRecyclerViewAdapter(Context context, HomeConfig.ViewConfig viewConfig) {
+ this.context = context;
+ this.viewConfig = viewConfig;
+ }
+
+ public void setFilterManager(PanelLayout.FilterManager filterManager) {
+ this.filterManager = filterManager;
+ }
+
+ private boolean isShowingBack() {
+ return filterManager != null && filterManager.canGoBack();
+ }
+
+ public void swapCursor(Cursor cursor) {
+ this.cursor = cursor;
+
+ notifyDataSetChanged();
+ }
+
+ public Cursor getCursor() {
+ return cursor;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (viewConfig.hasHeaderConfig() && position == 0) {
+ return VIEW_TYPE_HEADER;
+ } else if (isShowingBack() && position == getBackPosition()) {
+ return VIEW_TYPE_BACK;
+ } else {
+ return VIEW_TYPE_ITEM;
+ }
+ }
+
+ @Override
+ public PanelViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
+ switch (viewType) {
+ case VIEW_TYPE_HEADER:
+ return PanelViewHolder.create(new PanelHeaderView(context, viewConfig.getHeaderConfig()));
+ case VIEW_TYPE_BACK:
+ return PanelViewHolder.create(new PanelBackItemView(context, viewConfig.getBackImageUrl()));
+ case VIEW_TYPE_ITEM:
+ return PanelViewHolder.create(PanelItemView.create(context, viewConfig.getItemType()));
+ default:
+ throw new IllegalArgumentException("Unknown view type: " + viewType);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(PanelViewHolder panelViewHolder, int position) {
+ final View view = ((FrameLayout) panelViewHolder.itemView).getChildAt(0);
+
+ if (viewConfig.hasHeaderConfig()) {
+ if (position == 0) {
+ // Nothing to do here, the header is static
+ return;
+ }
+ }
+
+ if (isShowingBack()) {
+ if (position == getBackPosition()) {
+ final PanelBackItemView item = (PanelBackItemView) view;
+ item.updateFromFilter(filterManager.getPreviousFilter());
+ return;
+ }
+ }
+
+ int actualPosition = position
+ - (isShowingBack() ? 1 : 0)
+ - (viewConfig.hasHeaderConfig() ? 1 : 0);
+
+ cursor.moveToPosition(actualPosition);
+
+ final PanelItemView panelItemView = (PanelItemView) view;
+ panelItemView.updateFromCursor(cursor);
+ }
+
+ private int getBackPosition() {
+ return viewConfig.hasHeaderConfig() ? 1 : 0;
+ }
+
+ @Override
+ public int getItemCount() {
+ if (cursor == null) {
+ return 0;
+ }
+
+ return cursor.getCount()
+ + (isShowingBack() ? 1 : 0)
+ + (viewConfig.hasHeaderConfig() ? 1 : 0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java
new file mode 100644
index 000000000..d43a97f31
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelRefreshLayout.java
@@ -0,0 +1,90 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.home.PanelLayout.DatasetBacked;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.SwipeRefreshLayout;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * Used to wrap a {@code DatasetBacked} ListView or GridView to give the child view swipe-to-refresh
+ * capabilities.
+ *
+ * This view acts as a decorator to forward the {@code DatasetBacked} methods to the child view
+ * while providing the refresh gesture support on top of it.
+ */
+class PanelRefreshLayout extends SwipeRefreshLayout implements DatasetBacked {
+ private static final String LOGTAG = "GeckoPanelRefreshLayout";
+
+ private static final String JSON_KEY_PANEL_ID = "panelId";
+ private static final String JSON_KEY_VIEW_INDEX = "viewIndex";
+
+ private final String panelId;
+ private final int viewIndex;
+ private final DatasetBacked datasetBacked;
+
+ /**
+ * @param context Android context.
+ * @param childView ListView or GridView. Must implement {@code DatasetBacked}.
+ * @param panelId The ID from the {@code PanelConfig}.
+ * @param viewIndex The index from the {@code ViewConfig}.
+ */
+ public PanelRefreshLayout(Context context, View childView, String panelId, int viewIndex) {
+ super(context);
+
+ if (!(childView instanceof DatasetBacked)) {
+ throw new IllegalArgumentException("View must implement DatasetBacked to be refreshed");
+ }
+
+ this.panelId = panelId;
+ this.viewIndex = viewIndex;
+ this.datasetBacked = (DatasetBacked) childView;
+
+ setOnRefreshListener(new RefreshListener());
+ addView(childView);
+
+ // Must be called after the child view has been added.
+ setColorSchemeResources(R.color.fennec_ui_orange, R.color.action_orange);
+ }
+
+ @Override
+ public void setDataset(Cursor cursor) {
+ datasetBacked.setDataset(cursor);
+ setRefreshing(false);
+ }
+
+ @Override
+ public void setFilterManager(FilterManager manager) {
+ datasetBacked.setFilterManager(manager);
+ }
+
+ private class RefreshListener implements OnRefreshListener {
+ @Override
+ public void onRefresh() {
+ final JSONObject response = new JSONObject();
+ try {
+ response.put(JSON_KEY_PANEL_ID, panelId);
+ response.put(JSON_KEY_VIEW_INDEX, viewIndex);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Could not create refresh message", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("HomePanels:RefreshView", response.toString());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java
new file mode 100644
index 000000000..cf03c50c0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewAdapter.java
@@ -0,0 +1,113 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.home.HomeConfig.ItemType;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.CursorAdapter;
+import android.view.View;
+import android.view.ViewGroup;
+
+class PanelViewAdapter extends CursorAdapter {
+ private static final int VIEW_TYPE_ITEM = 0;
+ private static final int VIEW_TYPE_BACK = 1;
+
+ private final ViewConfig viewConfig;
+ private FilterManager filterManager;
+ private final Context context;
+
+ public PanelViewAdapter(Context context, ViewConfig viewConfig) {
+ super(context, null, 0);
+ this.context = context;
+ this.viewConfig = viewConfig;
+ }
+
+ public void setFilterManager(FilterManager manager) {
+ this.filterManager = manager;
+ }
+
+ @Override
+ public final int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ public int getCount() {
+ return super.getCount() + (isShowingBack() ? 1 : 0);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (isShowingBack() && position == 0) {
+ return VIEW_TYPE_BACK;
+ } else {
+ return VIEW_TYPE_ITEM;
+ }
+ }
+
+ @Override
+ public final View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = newView(parent.getContext(), position, parent);
+ }
+
+ bindView(convertView, position);
+ return convertView;
+ }
+
+ private View newView(Context context, int position, ViewGroup parent) {
+ if (getItemViewType(position) == VIEW_TYPE_BACK) {
+ return new PanelBackItemView(context, viewConfig.getBackImageUrl());
+ } else {
+ return PanelItemView.create(context, viewConfig.getItemType());
+ }
+ }
+
+ private void bindView(View view, int position) {
+ if (isShowingBack()) {
+ if (position == 0) {
+ final PanelBackItemView item = (PanelBackItemView) view;
+ item.updateFromFilter(filterManager.getPreviousFilter());
+ return;
+ }
+
+ position--;
+ }
+
+ final Cursor cursor = getCursor(position);
+ final PanelItemView item = (PanelItemView) view;
+ item.updateFromCursor(cursor);
+ }
+
+ private boolean isShowingBack() {
+ return filterManager != null && filterManager.canGoBack();
+ }
+
+ private final Cursor getCursor(int position) {
+ final Cursor cursor = getCursor();
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ return cursor;
+ }
+
+ @Override
+ public final void bindView(View view, Context context, Cursor cursor) {
+ // Do nothing.
+ }
+
+ @Override
+ public final View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java
new file mode 100644
index 000000000..a69db0b41
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PanelViewItemHandler.java
@@ -0,0 +1,59 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.db.BrowserContract.HomeItems;
+import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PanelLayout.FilterManager;
+import org.mozilla.gecko.home.PanelLayout.OnItemOpenListener;
+
+import android.database.Cursor;
+
+import java.util.EnumSet;
+
+class PanelViewItemHandler {
+ private OnItemOpenListener mItemOpenListener;
+ private FilterManager mFilterManager;
+
+ public void setOnItemOpenListener(OnItemOpenListener listener) {
+ mItemOpenListener = listener;
+ }
+
+ public void setFilterManager(FilterManager manager) {
+ mFilterManager = manager;
+ }
+
+ /**
+ * If item at this position is a back item, perform the go back action via the
+ * {@code FilterManager}. Otherwise, prepare the url to be opened by the
+ * {@code OnUrlOpenListener}.
+ */
+ public void openItemAtPosition(Cursor cursor, int position) {
+ if (mFilterManager != null && mFilterManager.canGoBack()) {
+ if (position == 0) {
+ mFilterManager.goBack();
+ return;
+ }
+
+ position--;
+ }
+
+ if (cursor == null || !cursor.moveToPosition(position)) {
+ throw new IllegalStateException("Couldn't move cursor to position " + position);
+ }
+
+ int urlIndex = cursor.getColumnIndexOrThrow(HomeItems.URL);
+ final String url = cursor.getString(urlIndex);
+
+ int titleIndex = cursor.getColumnIndexOrThrow(HomeItems.TITLE);
+ final String title = cursor.getString(titleIndex);
+
+ if (mItemOpenListener != null) {
+ mItemOpenListener.onItemOpen(url, title);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java
new file mode 100644
index 000000000..230b1d329
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/PinSiteDialog.java
@@ -0,0 +1,256 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.db.BrowserDB.FilterFlags;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.app.DialogFragment;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.CursorAdapter;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.AdapterView;
+import android.widget.EditText;
+import android.widget.ListView;
+
+/**
+ * Dialog fragment that displays frecency search results, for pinning a site, in a GridView.
+ */
+class PinSiteDialog extends DialogFragment {
+ // Listener for url selection
+ public static interface OnSiteSelectedListener {
+ public void onSiteSelected(String url, String title);
+ }
+
+ // Cursor loader ID for search query
+ private static final int LOADER_ID_SEARCH = 0;
+
+ // Holds the current search term to use in the query
+ private String mSearchTerm;
+
+ // Adapter for the list of search results
+ private SearchAdapter mAdapter;
+
+ // Search entry
+ private EditText mSearch;
+
+ // Search results
+ private ListView mList;
+
+ // Callbacks used for the search loader
+ private CursorLoaderCallbacks mLoaderCallbacks;
+
+ // Bookmark selected listener
+ private OnSiteSelectedListener mOnSiteSelectedListener;
+
+ public static PinSiteDialog newInstance() {
+ return new PinSiteDialog();
+ }
+
+ private PinSiteDialog() {
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setStyle(DialogFragment.STYLE_NO_TITLE, android.R.style.Theme_Holo_Light_Dialog);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ // All list views are styled to look the same with a global activity theme.
+ // If the style of the list changes, inflate it from an XML.
+ return inflater.inflate(R.layout.pin_site_dialog, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mSearch = (EditText) view.findViewById(R.id.search);
+ mSearch.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ setSearchTerm(mSearch.getText().toString());
+ filter(mSearchTerm);
+ }
+ });
+
+ mSearch.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (keyCode != KeyEvent.KEYCODE_ENTER || mOnSiteSelectedListener == null) {
+ return false;
+ }
+
+ // If the user manually entered a search term or URL, wrap the value in
+ // a special URI until we can get a valid URL for this bookmark.
+ final String text = mSearch.getText().toString().trim();
+ if (!TextUtils.isEmpty(text)) {
+ final String url = StringUtils.encodeUserEnteredUrl(text);
+ mOnSiteSelectedListener.onSiteSelected(url, text);
+ dismiss();
+ }
+
+ return true;
+ }
+ });
+
+ mSearch.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ // On rotation, the view gets destroyed and we could be in a race to get the dialog
+ // and window (see bug 1072959).
+ Dialog dialog = getDialog();
+ if (dialog == null) {
+ return;
+ }
+ Window window = dialog.getWindow();
+ if (window == null) {
+ return;
+ }
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
+ }
+ }
+ });
+
+ mList = (HomeListView) view.findViewById(R.id.list);
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ if (mOnSiteSelectedListener != null) {
+ final Cursor c = mAdapter.getCursor();
+ if (c == null || !c.moveToPosition(position)) {
+ return;
+ }
+
+ final String url = c.getString(c.getColumnIndexOrThrow(URLColumns.URL));
+ final String title = c.getString(c.getColumnIndexOrThrow(URLColumns.TITLE));
+ mOnSiteSelectedListener.onSiteSelected(url, title);
+ }
+
+ // Dismiss the fragment and the dialog.
+ dismiss();
+ }
+ });
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final LoaderManager manager = getLoaderManager();
+
+ // Initialize the search adapter
+ mAdapter = new SearchAdapter(getActivity());
+ mList.setAdapter(mAdapter);
+
+ // Create callbacks before the initial loader is started
+ mLoaderCallbacks = new CursorLoaderCallbacks();
+
+ // Reconnect to the loader only if present
+ manager.initLoader(LOADER_ID_SEARCH, null, mLoaderCallbacks);
+
+ // If there is a search term, put it in the text field
+ if (!TextUtils.isEmpty(mSearchTerm)) {
+ mSearch.setText(mSearchTerm);
+ mSearch.selectAll();
+ }
+
+ // Always start with an empty filter
+ filter("");
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ // Discard any additional site selection as the dialog
+ // is getting destroyed (see bug 935542).
+ setOnSiteSelectedListener(null);
+ }
+
+ public void setSearchTerm(String searchTerm) {
+ mSearchTerm = searchTerm;
+ }
+
+ private void filter(String searchTerm) {
+ // Restart loaders with the new search term
+ SearchLoader.restart(getLoaderManager(), LOADER_ID_SEARCH,
+ mLoaderCallbacks, searchTerm,
+ EnumSet.of(FilterFlags.EXCLUDE_PINNED_SITES));
+ }
+
+ public void setOnSiteSelectedListener(OnSiteSelectedListener listener) {
+ mOnSiteSelectedListener = listener;
+ }
+
+ private static class SearchAdapter extends CursorAdapter {
+ private final LayoutInflater mInflater;
+
+ public SearchAdapter(Context context) {
+ super(context, null, 0);
+ mInflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ TwoLinePageRow row = (TwoLinePageRow) view;
+ row.setShowIcons(false);
+ row.updateFromCursor(cursor);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return (TwoLinePageRow) mInflater.inflate(R.layout.home_item_row, parent, false);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ return SearchLoader.createInstance(getActivity(), args);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ mAdapter.swapCursor(c);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ mAdapter.swapCursor(null);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
new file mode 100755
index 000000000..3091f77da
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/RecentTabsAdapter.java
@@ -0,0 +1,454 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SessionParser;
+import org.mozilla.gecko.home.CombinedHistoryAdapter.RecentTabsUpdateHandler;
+import org.mozilla.gecko.home.CombinedHistoryPanel.PanelStateUpdateHandler;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.mozilla.gecko.home.CombinedHistoryItem.ItemType;
+import static org.mozilla.gecko.home.CombinedHistoryPanel.OnPanelLevelChangeListener.PanelLevel.CHILD_RECENT_TABS;
+
+public class RecentTabsAdapter extends RecyclerView.Adapter<CombinedHistoryItem>
+ implements CombinedHistoryRecyclerView.AdapterContextMenuBuilder, NativeEventListener {
+ private static final String LOGTAG = "GeckoRecentTabsAdapter";
+
+ private static final int NAVIGATION_BACK_BUTTON_INDEX = 0;
+
+ private static final String TELEMETRY_EXTRA_LAST_TIME = "recent_tabs_last_time";
+ private static final String TELEMETRY_EXTRA_RECENTLY_CLOSED = "recent_closed_tabs";
+ private static final String TELEMETRY_EXTRA_MIXED = "recent_tabs_mixed";
+
+ // Recently closed tabs from Gecko.
+ private ClosedTab[] recentlyClosedTabs;
+ private boolean recentlyClosedTabsReceived = false;
+
+ // "Tabs from last time".
+ private ClosedTab[] lastSessionTabs;
+
+ public static final class ClosedTab {
+ public final String url;
+ public final String title;
+ public final String data;
+
+ public ClosedTab(String url, String title, String data) {
+ this.url = url;
+ this.title = title;
+ this.data = data;
+ }
+ }
+
+ private final Context context;
+ private final RecentTabsUpdateHandler recentTabsUpdateHandler;
+ private final PanelStateUpdateHandler panelStateUpdateHandler;
+
+ public RecentTabsAdapter(Context context,
+ RecentTabsUpdateHandler recentTabsUpdateHandler,
+ PanelStateUpdateHandler panelStateUpdateHandler) {
+ this.context = context;
+ this.recentTabsUpdateHandler = recentTabsUpdateHandler;
+ this.panelStateUpdateHandler = panelStateUpdateHandler;
+ recentlyClosedTabs = new ClosedTab[0];
+ lastSessionTabs = new ClosedTab[0];
+
+ readPreviousSessionData();
+ }
+
+ public void startListeningForClosedTabs() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(this, "ClosedTabs:Data");
+ GeckoAppShell.notifyObservers("ClosedTabs:StartNotifications", null);
+ }
+
+ public void stopListeningForClosedTabs() {
+ GeckoAppShell.notifyObservers("ClosedTabs:StopNotifications", null);
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "ClosedTabs:Data");
+ recentlyClosedTabsReceived = false;
+ }
+
+ public void startListeningForHistorySanitize() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(this, "Sanitize:Finished");
+ }
+
+ public void stopListeningForHistorySanitize() {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(this, "Sanitize:Finished");
+ }
+
+ @Override
+ public void handleMessage(String event, NativeJSObject message, EventCallback callback) {
+ switch (event) {
+ case "ClosedTabs:Data":
+ updateRecentlyClosedTabs(message);
+ break;
+ case "Sanitize:Finished":
+ clearLastSessionData();
+ break;
+ }
+ }
+
+ private void updateRecentlyClosedTabs(NativeJSObject message) {
+ final NativeJSObject[] tabs = message.getObjectArray("tabs");
+ final int length = tabs.length;
+
+ final ClosedTab[] closedTabs = new ClosedTab[length];
+ for (int i = 0; i < length; i++) {
+ final NativeJSObject tab = tabs[i];
+ closedTabs[i] = new ClosedTab(tab.getString("url"), tab.getString("title"), tab.getObject("data").toString());
+ }
+
+ // Only modify recentlyClosedTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = recentlyClosedTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ recentlyClosedTabs = closedTabs;
+ recentlyClosedTabsReceived = true;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding/unhiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Update the "Recently closed" part of the tab list.
+ updateTabsList(prevClosedTabsCount, recentlyClosedTabs.length, getFirstRecentTabIndex(), getLastRecentTabIndex());
+ }
+ });
+ }
+
+ private void readPreviousSessionData() {
+ // If we happen to initialise before GeckoApp, waiting on either the main or the background
+ // thread can lead to a deadlock, so we have to run on a separate thread instead.
+ final Thread parseThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ // Make sure that the start up code has had a chance to update sessionstore.old as necessary.
+ GeckoProfile.get(context).waitForOldSessionDataProcessing();
+
+ final String jsonString = GeckoProfile.get(context).readPreviousSessionFile();
+ if (jsonString == null) {
+ // No previous session data.
+ return;
+ }
+
+ final List<ClosedTab> parsedTabs = new ArrayList<>();
+
+ new SessionParser() {
+ @Override
+ public void onTabRead(SessionTab tab) {
+ final String url = tab.getUrl();
+
+ // Don't show last tabs for about:home
+ if (AboutPages.isAboutHome(url)) {
+ return;
+ }
+
+ parsedTabs.add(new ClosedTab(url, tab.getTitle(), tab.getTabObject().toString()));
+ }
+ }.parse(jsonString);
+
+ final ClosedTab[] closedTabs = parsedTabs.toArray(new ClosedTab[parsedTabs.size()]);
+
+ // Only modify lastSessionTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = lastSessionTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ lastSessionTabs = closedTabs;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding/unhiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Update the "Tabs from last time" part of the tab list.
+ updateTabsList(prevClosedTabsCount, lastSessionTabs.length, getFirstLastSessionTabIndex(), getLastLastSessionTabIndex());
+ }
+ });
+ }
+ }, "LastSessionTabsThread");
+
+ parseThread.start();
+ }
+
+ private void clearLastSessionData() {
+ final ClosedTab[] emptyLastSessionTabs = new ClosedTab[0];
+
+ // Only modify mLastSessionTabs on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ // Save some data about the old panel state, so we can be
+ // smarter about notifying the recycler view which bits changed.
+ int prevClosedTabsCount = lastSessionTabs.length;
+ boolean prevSectionHeaderVisibility = isSectionHeaderVisible();
+ int prevSectionHeaderIndex = getSectionHeaderIndex();
+
+ lastSessionTabs = emptyLastSessionTabs;
+ recentTabsUpdateHandler.onRecentTabsCountUpdated(
+ getClosedTabsCount(), recentlyClosedTabsReceived);
+ panelStateUpdateHandler.onPanelStateUpdated(CHILD_RECENT_TABS);
+
+ // Handle the section header hiding.
+ updateHeaderVisibility(prevSectionHeaderVisibility, prevSectionHeaderIndex);
+
+ // Handle the "tabs from last time" being cleared.
+ if (prevClosedTabsCount > 0) {
+ notifyItemRangeRemoved(getFirstLastSessionTabIndex(), prevClosedTabsCount);
+ }
+ }
+ });
+ }
+
+ private void updateHeaderVisibility(boolean prevSectionHeaderVisibility, int prevSectionHeaderIndex) {
+ if (prevSectionHeaderVisibility && !isSectionHeaderVisible()) {
+ notifyItemRemoved(prevSectionHeaderIndex);
+ } else if (!prevSectionHeaderVisibility && isSectionHeaderVisible()) {
+ notifyItemInserted(getSectionHeaderIndex());
+ }
+ }
+
+ /**
+ * Updates the tab list as necessary to account for any changes in tab count in a particular data source.
+ *
+ * Since the session store only sends out full updates, we don't know for sure what has changed compared
+ * to the last data set, so we can only animate if the tab count actually changes.
+ *
+ * @param prevClosedTabsCount The previous number of closed tabs from that data source.
+ * @param closedTabsCount The current number of closed tabs contained in that data source.
+ * @param firstTabListIndex The current position of that data source's first item in the RecyclerView.
+ * @param lastTabListIndex The current position of that data source's last item in the RecyclerView.
+ */
+ private void updateTabsList(int prevClosedTabsCount, int closedTabsCount, int firstTabListIndex, int lastTabListIndex) {
+ final int closedTabsCountChange = closedTabsCount - prevClosedTabsCount;
+
+ if (closedTabsCountChange <= 0) {
+ notifyItemRangeRemoved(lastTabListIndex + 1, -closedTabsCountChange); // Remove tabs from the bottom of the list.
+ notifyItemRangeChanged(firstTabListIndex, closedTabsCount); // Update the contents of the remaining items.
+ } else { // closedTabsCountChange > 0
+ notifyItemRangeInserted(firstTabListIndex, closedTabsCountChange); // Add additional tabs at the top of the list.
+ notifyItemRangeChanged(firstTabListIndex + closedTabsCountChange, prevClosedTabsCount); // Update any previous list items.
+ }
+ }
+
+ public String restoreTabFromPosition(int position) {
+ final List<String> dataList = new ArrayList<>(1);
+ dataList.add(getClosedTabForPosition(position).data);
+
+ final String telemetryExtra =
+ position > getLastRecentTabIndex() ? TELEMETRY_EXTRA_LAST_TIME : TELEMETRY_EXTRA_RECENTLY_CLOSED;
+
+ restoreSessionWithHistory(dataList);
+
+ return telemetryExtra;
+ }
+
+ public String restoreAllTabs() {
+ if (recentlyClosedTabs.length == 0 && lastSessionTabs.length == 0) {
+ return null;
+ }
+
+ final List<String> dataList = new ArrayList<>(getClosedTabsCount());
+ addTabDataToList(dataList, recentlyClosedTabs);
+ addTabDataToList(dataList, lastSessionTabs);
+
+ final String telemetryExtra = recentlyClosedTabs.length > 0 && lastSessionTabs.length > 0 ? TELEMETRY_EXTRA_MIXED :
+ recentlyClosedTabs.length > 0 ? TELEMETRY_EXTRA_RECENTLY_CLOSED : TELEMETRY_EXTRA_LAST_TIME;
+
+ restoreSessionWithHistory(dataList);
+
+ return telemetryExtra;
+ }
+
+ private void addTabDataToList(List<String> dataList, ClosedTab[] closedTabs) {
+ for (ClosedTab closedTab : closedTabs) {
+ dataList.add(closedTab.data);
+ }
+ }
+
+ private static void restoreSessionWithHistory(List<String> dataList) {
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("tabs", new JSONArray(dataList));
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+
+ GeckoAppShell.notifyObservers("Session:RestoreRecentTabs", json.toString());
+ }
+
+ @Override
+ public CombinedHistoryItem onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final View view;
+
+ final CombinedHistoryItem.ItemType itemType = CombinedHistoryItem.ItemType.viewTypeToItemType(viewType);
+
+ switch (itemType) {
+ case NAVIGATION_BACK:
+ view = inflater.inflate(R.layout.home_combined_back_item, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+
+ case SECTION_HEADER:
+ view = inflater.inflate(R.layout.home_header_row, parent, false);
+ return new CombinedHistoryItem.BasicItem(view);
+
+ case CLOSED_TAB:
+ view = inflater.inflate(R.layout.home_item_row, parent, false);
+ return new CombinedHistoryItem.HistoryItem(view);
+ }
+ return null;
+ }
+
+ @Override
+ public void onBindViewHolder(CombinedHistoryItem holder, final int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+
+ switch (itemType) {
+ case SECTION_HEADER:
+ ((TextView) holder.itemView).setText(context.getString(R.string.home_closed_tabs_title2));
+ break;
+
+ case CLOSED_TAB:
+ final ClosedTab closedTab = getClosedTabForPosition(position);
+ ((CombinedHistoryItem.HistoryItem) holder).bind(closedTab);
+ break;
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ int itemCount = 1; // NAVIGATION_BACK button is always visible.
+
+ if (isSectionHeaderVisible()) {
+ itemCount += 1;
+ }
+
+ itemCount += getClosedTabsCount();
+
+ return itemCount;
+ }
+
+ private CombinedHistoryItem.ItemType getItemTypeForPosition(int position) {
+ if (position == NAVIGATION_BACK_BUTTON_INDEX) {
+ return ItemType.NAVIGATION_BACK;
+ }
+
+ if (position == getSectionHeaderIndex() && isSectionHeaderVisible()) {
+ return ItemType.SECTION_HEADER;
+ }
+
+ return ItemType.CLOSED_TAB;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return CombinedHistoryItem.ItemType.itemTypeToViewType(getItemTypeForPosition(position));
+ }
+
+ public int getClosedTabsCount() {
+ return recentlyClosedTabs.length + lastSessionTabs.length;
+ }
+
+ private boolean isSectionHeaderVisible() {
+ return recentlyClosedTabs.length > 0 || lastSessionTabs.length > 0;
+ }
+
+ private int getSectionHeaderIndex() {
+ return isSectionHeaderVisible() ?
+ NAVIGATION_BACK_BUTTON_INDEX + 1 :
+ NAVIGATION_BACK_BUTTON_INDEX;
+ }
+
+ private int getFirstRecentTabIndex() {
+ return getSectionHeaderIndex() + 1;
+ }
+
+ private int getLastRecentTabIndex() {
+ return getSectionHeaderIndex() + recentlyClosedTabs.length;
+ }
+
+ private int getFirstLastSessionTabIndex() {
+ return getLastRecentTabIndex() + 1;
+ }
+
+ private int getLastLastSessionTabIndex() {
+ return getLastRecentTabIndex() + lastSessionTabs.length;
+ }
+
+ /**
+ * Get the closed tab corresponding to a RecyclerView list item.
+ *
+ * The Recent Tab folder combines two data sources, so if we want to get the ClosedTab object
+ * behind a certain list item, we need to route this request to the corresponding data source
+ * and also transform the global list position into a local array index.
+ */
+ private ClosedTab getClosedTabForPosition(int position) {
+ final ClosedTab closedTab;
+ if (position <= getLastRecentTabIndex()) { // Upper part of the list is "Recently closed tabs".
+ closedTab = recentlyClosedTabs[position - getFirstRecentTabIndex()];
+ } else { // Lower part is "Tabs from last time".
+ closedTab = lastSessionTabs[position - getFirstLastSessionTabIndex()];
+ }
+
+ return closedTab;
+ }
+
+ @Override
+ public HomeContextMenuInfo makeContextMenuInfoFromPosition(View view, int position) {
+ final CombinedHistoryItem.ItemType itemType = getItemTypeForPosition(position);
+ final HomeContextMenuInfo info;
+
+ switch (itemType) {
+ case CLOSED_TAB:
+ info = new HomeContextMenuInfo(view, position, -1);
+ ClosedTab closedTab = getClosedTabForPosition(position);
+ return populateChildInfoFromTab(info, closedTab);
+ }
+
+ return null;
+ }
+
+ protected static HomeContextMenuInfo populateChildInfoFromTab(HomeContextMenuInfo info, ClosedTab tab) {
+ info.url = tab.url;
+ info.title = tab.title;
+ return info;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java
new file mode 100644
index 000000000..43497ae6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/RemoteTabsExpandableListState.java
@@ -0,0 +1,163 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.mozilla.gecko.util.PrefUtils;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+/**
+ * Encapsulate visual state maintained by the Remote Tabs home panel.
+ * <p>
+ * This state should persist across database updates by Sync and the like. This
+ * state could be stored in a separate "clients_metadata" table and served by
+ * the Tabs provider, but that is heavy-weight for what we want to achieve. Such
+ * a scheme would require either an expensive table join, or a tricky
+ * co-ordination between multiple cursors. In contrast, this is easy and cheap
+ * enough to do on the main thread.
+ * <p>
+ * This state is "per SharedPreferences" object. In practice, there should exist
+ * one state object per Gecko Profile; since we can't change profiles without
+ * killing our process, this can be a static singleton.
+ */
+public class RemoteTabsExpandableListState {
+ private static final String PREF_COLLAPSED_CLIENT_GUIDS = "remote_tabs_collapsed_client_guids";
+ private static final String PREF_HIDDEN_CLIENT_GUIDS = "remote_tabs_hidden_client_guids";
+ private static final String PREF_SELECTED_CLIENT_GUID = "remote_tabs_selected_client_guid";
+
+ protected final SharedPreferences sharedPrefs;
+
+ // Synchronized by the state instance. The default is to expand a clients
+ // tabs, so "not present" means "expanded".
+ // Only accessed from the UI thread.
+ protected final Set<String> collapsedClients;
+
+ // Synchronized by the state instance. The default is to show a client, so
+ // "not present" means "shown".
+ // Only accessed from the UI thread.
+ protected final Set<String> hiddenClients;
+
+ // Synchronized by the state instance. The last user selected client guid.
+ // The selectedClient may be invalid or null.
+ protected String selectedClient;
+
+ public RemoteTabsExpandableListState(SharedPreferences sharedPrefs) {
+ if (null == sharedPrefs) {
+ throw new IllegalArgumentException("sharedPrefs must not be null");
+ }
+ this.sharedPrefs = sharedPrefs;
+
+ this.collapsedClients = getStringSet(PREF_COLLAPSED_CLIENT_GUIDS);
+ this.hiddenClients = getStringSet(PREF_HIDDEN_CLIENT_GUIDS);
+ this.selectedClient = sharedPrefs.getString(PREF_SELECTED_CLIENT_GUID, null);
+ }
+
+ /**
+ * Extract a string set from shared preferences.
+ * <p>
+ * Nota bene: it is not OK to modify the set returned by {@link SharedPreferences#getStringSet(String, Set)}.
+ *
+ * @param pref to read from.
+ * @returns string set; never null.
+ */
+ protected Set<String> getStringSet(String pref) {
+ final Set<String> loaded = PrefUtils.getStringSet(sharedPrefs, pref, null);
+ if (loaded != null) {
+ return new HashSet<String>(loaded);
+ } else {
+ return new HashSet<String>();
+ }
+ }
+
+ /**
+ * Update client membership in a set.
+ *
+ * @param pref
+ * to write updated set to.
+ * @param clients
+ * set to update membership in.
+ * @param clientGuid
+ * to update membership of.
+ * @param isMember
+ * whether the client is a member of the set.
+ * @return true if the set of clients was modified.
+ */
+ protected boolean updateClientMembership(String pref, Set<String> clients, String clientGuid, boolean isMember) {
+ final boolean modified;
+ if (isMember) {
+ modified = clients.add(clientGuid);
+ } else {
+ modified = clients.remove(clientGuid);
+ }
+
+ if (modified) {
+ // This starts an asynchronous write. We don't care if we drop the
+ // write, and we don't really care if we race between writes, since
+ // we will return results from our in-memory cache.
+ final Editor editor = sharedPrefs.edit();
+ PrefUtils.putStringSet(editor, pref, clients);
+ editor.apply();
+ }
+
+ return modified;
+ }
+
+ /**
+ * Mark a client as collapsed.
+ *
+ * @param clientGuid
+ * to update.
+ * @param collapsed
+ * whether the client is collapsed.
+ * @return true if the set of collapsed clients was modified.
+ */
+ protected synchronized boolean setClientCollapsed(String clientGuid, boolean collapsed) {
+ return updateClientMembership(PREF_COLLAPSED_CLIENT_GUIDS, collapsedClients, clientGuid, collapsed);
+ }
+
+ /**
+ * Mark a client as the selected.
+ *
+ * @param clientGuid
+ * to update.
+ */
+ protected synchronized void setClientAsSelected(String clientGuid) {
+ if (hiddenClients.contains(clientGuid)) {
+ selectedClient = null;
+ } else {
+ selectedClient = clientGuid;
+ }
+
+ final Editor editor = sharedPrefs.edit();
+ editor.putString(PREF_SELECTED_CLIENT_GUID, selectedClient);
+ editor.apply();
+ }
+
+ public synchronized boolean isClientCollapsed(String clientGuid) {
+ return collapsedClients.contains(clientGuid);
+ }
+
+ /**
+ * Mark a client as hidden.
+ *
+ * @param clientGuid
+ * to update.
+ * @param hidden
+ * whether the client is hidden.
+ * @return true if the set of hidden clients was modified.
+ */
+ protected synchronized boolean setClientHidden(String clientGuid, boolean hidden) {
+ return updateClientMembership(PREF_HIDDEN_CLIENT_GUIDS, hiddenClients, clientGuid, hidden);
+ }
+
+ public synchronized boolean isClientHidden(String clientGuid) {
+ return hiddenClients.contains(clientGuid);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java
new file mode 100644
index 000000000..9b2d2746a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngine.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.support.annotation.NonNull;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.R;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SearchEngine {
+ public static final String LOG_TAG = "GeckoSearchEngine";
+
+ public final String name; // Never null.
+ public final String identifier; // Can be null.
+
+ private final Bitmap icon;
+ private volatile List<String> suggestions = new ArrayList<String>(); // Never null.
+
+ public SearchEngine(final Context context, final JSONObject engineJSON) throws JSONException {
+ if (engineJSON == null) {
+ throw new IllegalArgumentException("Can't instantiate SearchEngine from null JSON.");
+ }
+
+ this.name = getString(engineJSON, "name");
+ if (this.name == null) {
+ throw new IllegalArgumentException("Cannot have an unnamed search engine.");
+ }
+
+ this.identifier = getString(engineJSON, "identifier");
+
+ final String iconURI = getString(engineJSON, "iconURI");
+ if (iconURI == null) {
+ Log.w(LOG_TAG, "iconURI is null for search engine " + this.name);
+ }
+ final Bitmap tempIcon = BitmapUtils.getBitmapFromDataURI(iconURI);
+
+ this.icon = (tempIcon != null) ? tempIcon : getDefaultFavicon(context);
+ }
+
+ private Bitmap getDefaultFavicon(final Context context) {
+ return BitmapFactory.decodeResource(context.getResources(), R.drawable.search_icon_inactive);
+ }
+
+ private static String getString(JSONObject data, String key) throws JSONException {
+ if (data.isNull(key)) {
+ return null;
+ }
+ return data.getString(key);
+ }
+
+ /**
+ * @return a non-null string suitable for use by FHR.
+ */
+ @NonNull
+ public String getEngineIdentifier() {
+ if (this.identifier != null) {
+ return this.identifier;
+ }
+ if (this.name != null) {
+ return "other-" + this.name;
+ }
+ return "other";
+ }
+
+ public boolean hasSuggestions() {
+ return !this.suggestions.isEmpty();
+ }
+
+ public int getSuggestionsCount() {
+ return this.suggestions.size();
+ }
+
+ public Iterable<String> getSuggestions() {
+ return this.suggestions;
+ }
+
+ public void setSuggestions(List<String> suggestions) {
+ if (suggestions == null) {
+ this.suggestions = new ArrayList<String>();
+ return;
+ }
+ this.suggestions = suggestions;
+ }
+
+ public Bitmap getIcon() {
+ return this.icon;
+ }
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java
new file mode 100644
index 000000000..be5b3b461
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineAdapter.java
@@ -0,0 +1,122 @@
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.graphics.drawable.DrawableCompat;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import org.mozilla.gecko.R;
+
+import java.util.Collections;
+import java.util.List;
+
+public class SearchEngineAdapter
+ extends RecyclerView.Adapter<SearchEngineAdapter.SearchEngineViewHolder> {
+
+ private static final String LOGTAG = SearchEngineAdapter.class.getSimpleName();
+
+ private static final int VIEW_TYPE_SEARCH_ENGINE = 0;
+ private static final int VIEW_TYPE_LABEL = 1;
+ private final Context mContext;
+
+ private int mContainerWidth;
+ private List<SearchEngine> mSearchEngines = Collections.emptyList();
+
+ public void setSearchEngines(List<SearchEngine> searchEngines) {
+ mSearchEngines = searchEngines;
+ notifyDataSetChanged();
+ }
+
+ /**
+ * The container width is used for setting the appropriate calculated amount of width that
+ * a search engine icon can have. This varies depending on the space available in the
+ * {@link SearchEngineBar}. The setter exists for this attribute, in creating the view in the
+ * adapter after said calculation is done when the search bar is created.
+ * @param iconContainerWidth Width of each search icon.
+ */
+ void setIconContainerWidth(int iconContainerWidth) {
+ mContainerWidth = iconContainerWidth;
+ }
+
+ public static class SearchEngineViewHolder extends RecyclerView.ViewHolder {
+ final private ImageView faviconView;
+
+ public void bindItem(SearchEngine searchEngine) {
+ faviconView.setImageBitmap(searchEngine.getIcon());
+ final String desc = itemView.getResources().getString(R.string.search_bar_item_desc,
+ searchEngine.getEngineIdentifier());
+ itemView.setContentDescription(desc);
+ }
+
+ public SearchEngineViewHolder(View itemView) {
+ super(itemView);
+ faviconView = (ImageView) itemView.findViewById(R.id.search_engine_icon);
+ }
+ }
+
+ public SearchEngineAdapter(Context context) {
+ mContext = context;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return position == 0 ? VIEW_TYPE_LABEL : VIEW_TYPE_SEARCH_ENGINE;
+ }
+
+ public SearchEngine getItem(int position) {
+ // We omit the first position which is where the label currently is.
+ return position == 0 ? null : mSearchEngines.get(position - 1);
+ }
+
+ @Override
+ public SearchEngineViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ switch (viewType) {
+ case VIEW_TYPE_LABEL:
+ return new SearchEngineViewHolder(createLabelView(parent));
+ case VIEW_TYPE_SEARCH_ENGINE:
+ return new SearchEngineViewHolder(createSearchEngineView(parent));
+ default:
+ throw new IllegalArgumentException("Unknown view type: " + viewType);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(SearchEngineViewHolder holder, int position) {
+ if (position != 0) {
+ holder.bindItem(getItem(position));
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return mSearchEngines.size() + 1;
+ }
+
+ private View createLabelView(ViewGroup parent) {
+ View view = LayoutInflater.from(mContext)
+ .inflate(R.layout.search_engine_bar_label, parent, false);
+ final Drawable icon = DrawableCompat.wrap(
+ ContextCompat.getDrawable(mContext, R.drawable.search_icon_active).mutate());
+ DrawableCompat.setTint(icon, ContextCompat.getColor(mContext, R.color.disabled_grey));
+
+ final ImageView iconView = (ImageView) view.findViewById(R.id.search_engine_label);
+ iconView.setImageDrawable(icon);
+ return view;
+ }
+
+ private View createSearchEngineView(ViewGroup parent) {
+ View view = LayoutInflater.from(mContext)
+ .inflate(R.layout.search_engine_bar_item, parent, false);
+
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.width = mContainerWidth;
+ view.setLayoutParams(params);
+
+ return view;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java
new file mode 100644
index 000000000..6a6509bcb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineBar.java
@@ -0,0 +1,148 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.view.View;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.List;
+
+public class SearchEngineBar extends RecyclerView
+ implements RecyclerViewClickSupport.OnItemClickListener {
+ private static final String LOGTAG = SearchEngineBar.class.getSimpleName();
+
+ private static final float ICON_CONTAINER_MIN_WIDTH_DP = 72;
+ private static final float LABEL_CONTAINER_WIDTH_DP = 48;
+
+ public interface OnSearchBarClickListener {
+ void onSearchBarClickListener(SearchEngine searchEngine);
+ }
+
+ private final SearchEngineAdapter mAdapter;
+ private final LinearLayoutManager mLayoutManager;
+ private final Paint mDividerPaint;
+ private final float mMinIconContainerWidth;
+ private final float mDividerHeight;
+ private final int mLabelContainerWidth;
+
+ private int mIconContainerWidth;
+ private OnSearchBarClickListener mOnSearchBarClickListener;
+
+ public SearchEngineBar(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ mDividerPaint = new Paint();
+ mDividerPaint.setColor(ContextCompat.getColor(context, R.color.toolbar_divider_grey));
+ mDividerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+
+ final DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
+ mMinIconContainerWidth = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, ICON_CONTAINER_MIN_WIDTH_DP, displayMetrics);
+ mDividerHeight = context.getResources().getDimension(R.dimen.page_row_divider_height);
+ mLabelContainerWidth = Math.round(TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, LABEL_CONTAINER_WIDTH_DP, displayMetrics));
+
+ mIconContainerWidth = Math.round(mMinIconContainerWidth);
+
+ mAdapter = new SearchEngineAdapter(context);
+ mAdapter.setIconContainerWidth(mIconContainerWidth);
+ mLayoutManager = new LinearLayoutManager(context);
+ mLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
+
+ setAdapter(mAdapter);
+ setLayoutManager(mLayoutManager);
+
+ RecyclerViewClickSupport.addTo(this)
+ .setOnItemClickListener(this);
+ }
+
+ public void setSearchEngines(List<SearchEngine> searchEngines) {
+ mAdapter.setSearchEngines(searchEngines);
+ }
+
+ public void setOnSearchBarClickListener(OnSearchBarClickListener listener) {
+ mOnSearchBarClickListener = listener;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int searchEngineCount = mAdapter.getItemCount() - 1;
+
+ if (searchEngineCount > 0) {
+ final int availableWidth = getMeasuredWidth() - mLabelContainerWidth;
+
+ if (searchEngineCount * mMinIconContainerWidth <= availableWidth) {
+ // All search engines fit int: So let's just display all.
+ mIconContainerWidth = (int) mMinIconContainerWidth;
+ } else {
+ // If only (n) search engines fit into the available space then display only (x)
+ // search engines with (x) picked so that the last search engine will be cut-off
+ // (we only display half of it) to show the ability to scroll this view.
+
+ final double searchEnginesToDisplay = Math.floor((availableWidth / mMinIconContainerWidth) - 0.5) + 0.5;
+ // Use all available width and spread search engine icons
+ mIconContainerWidth = (int) (availableWidth / searchEnginesToDisplay);
+ }
+
+ mAdapter.setIconContainerWidth(mIconContainerWidth);
+ }
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ canvas.drawRect(0, 0, getWidth(), mDividerHeight, mDividerPaint);
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (mOnSearchBarClickListener == null) {
+ throw new IllegalStateException(
+ OnSearchBarClickListener.class.getSimpleName() + " is not initializer."
+ );
+ }
+
+ if (position == 0) {
+ final Intent settingsIntent = new Intent(getContext(), GeckoPreferences.class);
+ GeckoPreferences.setResourceToOpen(settingsIntent, "preferences_search");
+ getContext().startActivity(settingsIntent);
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "searchenginebar-settings");
+ return;
+ }
+
+ final SearchEngine searchEngine = mAdapter.getItem(position);
+ mOnSearchBarClickListener.onSearchBarClickListener(searchEngine);
+ }
+
+ /**
+ * We manually add the override for getAdapter because we see this method getting stripped
+ * out during compile time by aggressive proguard rules.
+ */
+ @RobocopTarget
+ @Override
+ public SearchEngineAdapter getAdapter() {
+ return mAdapter;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java
new file mode 100644
index 000000000..5b97a8f5f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchEngineRow.java
@@ -0,0 +1,494 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.home.BrowserSearch.OnEditSuggestionListener;
+import org.mozilla.gecko.home.BrowserSearch.OnSearchListener;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.widget.AnimatedHeightLayout;
+import org.mozilla.gecko.widget.FaviconView;
+import org.mozilla.gecko.widget.FlowLayout;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.graphics.Typeface;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.util.AttributeSet;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.animation.AlphaAnimation;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.regex.Pattern;
+
+class SearchEngineRow extends AnimatedHeightLayout {
+ // Duration for fade-in animation
+ private static final int ANIMATION_DURATION = 250;
+
+ // Inner views
+ private final FlowLayout mSuggestionView;
+ private final FaviconView mIconView;
+ private final LinearLayout mUserEnteredView;
+ private final TextView mUserEnteredTextView;
+
+ // Inflater used when updating from suggestions
+ private final LayoutInflater mInflater;
+
+ // Search engine associated with this view
+ private SearchEngine mSearchEngine;
+
+ // Event listeners for suggestion views
+ private final OnClickListener mClickListener;
+ private final OnLongClickListener mLongClickListener;
+
+ // On URL open listener
+ private OnUrlOpenListener mUrlOpenListener;
+
+ // On search listener
+ private OnSearchListener mSearchListener;
+
+ // On edit suggestion listener
+ private OnEditSuggestionListener mEditSuggestionListener;
+
+ // Selected suggestion view
+ private int mSelectedView;
+
+ // android:backgroundTint only works in Android 21 and higher so we can't do this statically in the xml
+ private Drawable mSearchHistorySuggestionIcon;
+
+ // Maximums for suggestions
+ private int mMaxSavedSuggestions;
+ private int mMaxSearchSuggestions;
+
+ private final List<Integer> mOccurrences = new ArrayList<Integer>();
+
+ public SearchEngineRow(Context context) {
+ this(context, null);
+ }
+
+ public SearchEngineRow(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SearchEngineRow(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ mClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final String suggestion = getSuggestionTextFromView(v);
+
+ // If we're not clicking the user-entered view (the first suggestion item)
+ // and the search matches a URL pattern, go to that URL. Otherwise, do a
+ // search for the term.
+ if (v != mUserEnteredView && !StringUtils.isSearchQuery(suggestion, true)) {
+ if (mUrlOpenListener != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "url");
+
+ mUrlOpenListener.onUrlOpen(suggestion, EnumSet.noneOf(OnUrlOpenListener.Flags.class));
+ }
+ } else if (mSearchListener != null) {
+ if (v == mUserEnteredView) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user");
+ } else {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, (String) v.getTag());
+ }
+ mSearchListener.onSearch(mSearchEngine, suggestion, TelemetryContract.Method.SUGGESTION);
+ }
+ }
+ };
+
+ mLongClickListener = new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (mEditSuggestionListener != null) {
+ final String suggestion = getSuggestionTextFromView(v);
+ mEditSuggestionListener.onEditSuggestion(suggestion);
+ return true;
+ }
+
+ return false;
+ }
+ };
+
+ mInflater = LayoutInflater.from(context);
+ mInflater.inflate(R.layout.search_engine_row, this);
+
+ mSuggestionView = (FlowLayout) findViewById(R.id.suggestion_layout);
+ mIconView = (FaviconView) findViewById(R.id.suggestion_icon);
+
+ // User-entered search term is first suggestion
+ mUserEnteredView = (LinearLayout) findViewById(R.id.suggestion_user_entered);
+ mUserEnteredView.setOnClickListener(mClickListener);
+
+ mUserEnteredTextView = (TextView) findViewById(R.id.suggestion_text);
+ mSearchHistorySuggestionIcon = DrawableUtil.tintDrawableWithColorRes(getContext(), R.drawable.icon_most_recent_empty, R.color.tabs_tray_icon_grey);
+
+ // Suggestion limits
+ mMaxSavedSuggestions = getResources().getInteger(R.integer.max_saved_suggestions);
+ mMaxSearchSuggestions = getResources().getInteger(R.integer.max_search_suggestions);
+ }
+
+ private void setDescriptionOnSuggestion(View v, String suggestion) {
+ v.setContentDescription(getResources().getString(R.string.suggestion_for_engine,
+ mSearchEngine.name, suggestion));
+ }
+
+ private String getSuggestionTextFromView(View v) {
+ final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+ return suggestionText.getText().toString();
+ }
+
+ /**
+ * Finds all occurrences of pattern in string and returns a list of the starting indices
+ * of each occurrence.
+ *
+ * @param pattern The pattern that is searched for
+ * @param string The string where we search for the pattern
+ */
+ private void refreshOccurrencesWith(String pattern, String string) {
+ mOccurrences.clear();
+
+ // Don't try to search for an empty string - String.indexOf will return 0, which would result
+ // in us iterating with lastIndexOfMatch = 0, which eventually results in an OOM.
+ if (TextUtils.isEmpty(pattern)) {
+ return;
+ }
+
+ final int patternLength = pattern.length();
+
+ int indexOfMatch = 0;
+ int lastIndexOfMatch = 0;
+ while (indexOfMatch != -1) {
+ indexOfMatch = string.indexOf(pattern, lastIndexOfMatch);
+ lastIndexOfMatch = indexOfMatch + patternLength;
+ if (indexOfMatch != -1) {
+ mOccurrences.add(indexOfMatch);
+ }
+ }
+ }
+
+ /**
+ * Sets the content for the suggestion view.
+ *
+ * If the suggestion doesn't contain mUserSearchTerm, nothing is made bold.
+ * All instances of mUserSearchTerm in the suggestion are not bold.
+ *
+ * @param v The View that needs to be populated
+ * @param suggestion The suggestion text that will be placed in the view
+ * @param isUserSavedSearch whether the suggestion is from history or not
+ */
+ private void setSuggestionOnView(View v, String suggestion, boolean isUserSavedSearch) {
+ final ImageView historyIcon = (ImageView) v.findViewById(R.id.suggestion_item_icon);
+ if (isUserSavedSearch) {
+ historyIcon.setImageDrawable(mSearchHistorySuggestionIcon);
+ historyIcon.setVisibility(View.VISIBLE);
+ } else {
+ historyIcon.setVisibility(View.GONE);
+ }
+
+ final TextView suggestionText = (TextView) v.findViewById(R.id.suggestion_text);
+ final String searchTerm = getSuggestionTextFromView(mUserEnteredView);
+ final int searchTermLength = searchTerm.length();
+ refreshOccurrencesWith(searchTerm, suggestion);
+ if (mOccurrences.size() > 0) {
+ final SpannableStringBuilder sb = new SpannableStringBuilder(suggestion);
+ int nextStartSpanIndex = 0;
+ // Done to make sure that the stretch of text after the last occurrence, till the end of the suggestion, is made bold
+ mOccurrences.add(suggestion.length());
+ for (int occurrence : mOccurrences) {
+ // Even though they're the same style, SpannableStringBuilder will interpret there as being only one Span present if we re-use a StyleSpan
+ StyleSpan boldSpan = new StyleSpan(Typeface.BOLD);
+ sb.setSpan(boldSpan, nextStartSpanIndex, occurrence, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ nextStartSpanIndex = occurrence + searchTermLength;
+ }
+ mOccurrences.clear();
+ suggestionText.setText(sb);
+ } else {
+ suggestionText.setText(suggestion);
+ }
+
+ setDescriptionOnSuggestion(suggestionText, suggestion);
+ }
+
+ /**
+ * Perform a search for the user-entered term.
+ */
+ public void performUserEnteredSearch() {
+ String searchTerm = getSuggestionTextFromView(mUserEnteredView);
+ if (mSearchListener != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.SUGGESTION, "user");
+ mSearchListener.onSearch(mSearchEngine, searchTerm, TelemetryContract.Method.SUGGESTION);
+ }
+ }
+
+ public void setSearchTerm(String searchTerm) {
+ mUserEnteredTextView.setText(searchTerm);
+
+ // mSearchEngine is not set in the first call to this method; the content description
+ // is instead initially set in updateSuggestions().
+ if (mSearchEngine != null) {
+ setDescriptionOnSuggestion(mUserEnteredTextView, searchTerm);
+ }
+ }
+
+ public void setOnUrlOpenListener(OnUrlOpenListener listener) {
+ mUrlOpenListener = listener;
+ }
+
+ public void setOnSearchListener(OnSearchListener listener) {
+ mSearchListener = listener;
+ }
+
+ public void setOnEditSuggestionListener(OnEditSuggestionListener listener) {
+ mEditSuggestionListener = listener;
+ }
+
+ private void bindSuggestionView(String suggestion, boolean animate, int recycledSuggestionCount, Integer previousSuggestionChildIndex, boolean isUserSavedSearch, String telemetryTag) {
+ final View suggestionItem;
+
+ // Reuse suggestion views from recycled view, if possible.
+ if (previousSuggestionChildIndex + 1 < recycledSuggestionCount) {
+ suggestionItem = mSuggestionView.getChildAt(previousSuggestionChildIndex + 1);
+ suggestionItem.setVisibility(View.VISIBLE);
+ } else {
+ suggestionItem = mInflater.inflate(R.layout.suggestion_item, null);
+
+ suggestionItem.setOnClickListener(mClickListener);
+ suggestionItem.setOnLongClickListener(mLongClickListener);
+
+ suggestionItem.setTag(telemetryTag);
+
+ mSuggestionView.addView(suggestionItem);
+ }
+
+ setSuggestionOnView(suggestionItem, suggestion, isUserSavedSearch);
+
+ if (animate) {
+ AlphaAnimation anim = new AlphaAnimation(0, 1);
+ anim.setDuration(ANIMATION_DURATION);
+ anim.setStartOffset(previousSuggestionChildIndex * ANIMATION_DURATION);
+ suggestionItem.startAnimation(anim);
+ }
+ }
+
+ private void hideRecycledSuggestions(int lastVisibleChildIndex, int recycledSuggestionCount) {
+ // Hide extra suggestions that have been recycled.
+ for (int i = lastVisibleChildIndex + 1; i < recycledSuggestionCount; ++i) {
+ mSuggestionView.getChildAt(i).setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Displays search suggestions from previous searches.
+ *
+ * @param savedSuggestions The List to iterate over for saved search suggestions to display. This function does not
+ * enforce a ui maximum or filter. It will show all the suggestions in this list.
+ * @param suggestionStartIndex global index of where to start adding suggestion "buttons" in the search engine row. Also
+ * acts as a counter for total number of suggestions visible.
+ * @param animate whether or not to animate suggestions for visual polish
+ * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls
+ */
+ private void updateFromSavedSearches(List<String> savedSuggestions, boolean animate, int suggestionStartIndex, int recycledSuggestionCount) {
+ if (savedSuggestions == null || savedSuggestions.isEmpty()) {
+ hideRecycledSuggestions(suggestionStartIndex, recycledSuggestionCount);
+ return;
+ }
+
+ final int numSavedSearches = savedSuggestions.size();
+ int indexOfPreviousSuggestion = 0;
+ for (int i = 0; i < numSavedSearches; i++) {
+ String telemetryTag = "history." + i;
+ final String suggestion = savedSuggestions.get(i);
+ indexOfPreviousSuggestion = suggestionStartIndex + i;
+ bindSuggestionView(suggestion, animate, recycledSuggestionCount, indexOfPreviousSuggestion, true, telemetryTag);
+ }
+
+ hideRecycledSuggestions(indexOfPreviousSuggestion + 1, recycledSuggestionCount);
+ }
+
+ /**
+ * Displays suggestions supplied by the search engine, relative to number of suggestions from search history.
+ *
+ * @param animate whether or not to animate suggestions for visual polish
+ * @param recycledSuggestionCount How many suggestion "button" views we could recycle from previous calls
+ * @param savedSuggestionCount how many saved searches this searchTerm has
+ * @return the global count of how many suggestions have been bound/shown in the search engine row
+ */
+ private int updateFromSearchEngine(boolean animate, List<String> searchEngineSuggestions, int recycledSuggestionCount, int savedSuggestionCount) {
+ int maxSuggestions = mMaxSearchSuggestions;
+ // If there are less than max saved searches on phones, fill the space with more search engine suggestions
+ if (!HardwareUtils.isTablet() && savedSuggestionCount < mMaxSavedSuggestions) {
+ maxSuggestions += mMaxSavedSuggestions - savedSuggestionCount;
+ }
+
+ final int numSearchEngineSuggestions = searchEngineSuggestions.size();
+ int relativeIndex;
+ for (relativeIndex = 0; relativeIndex < numSearchEngineSuggestions; relativeIndex++) {
+ if (relativeIndex == maxSuggestions) {
+ break;
+ }
+
+ // Since the search engine suggestions are listed first, their relative index is their global index
+ String telemetryTag = "engine." + relativeIndex;
+ final String suggestion = searchEngineSuggestions.get(relativeIndex);
+ bindSuggestionView(suggestion, animate, recycledSuggestionCount, relativeIndex, false, telemetryTag);
+ }
+
+ hideRecycledSuggestions(relativeIndex + 1, recycledSuggestionCount);
+
+ // Make sure mSelectedView is still valid.
+ if (mSelectedView >= mSuggestionView.getChildCount()) {
+ mSelectedView = mSuggestionView.getChildCount() - 1;
+ }
+
+ return relativeIndex;
+ }
+
+ /**
+ * Updates the whole suggestions UI, the search engine UI, suggestions from the default search engine,
+ * and suggestions from search history.
+ *
+ * This can be called before the opt-in permission prompt is shown or set.
+ * Even if both suggestion types are disabled, we need to update the search engine, its image, and the content description.
+ *
+ * @param searchSuggestionsEnabled whether or not suggestions from the default search engine are enabled
+ * @param searchEngine the search engine to use throughout the SearchEngineRow class
+ * @param rawSearchHistorySuggestions search history suggestions
+ * @param animate whether or not to use animations
+ **/
+ public void updateSuggestions(boolean searchSuggestionsEnabled, SearchEngine searchEngine, @Nullable List<String> rawSearchHistorySuggestions, boolean animate) {
+ mSearchEngine = searchEngine;
+ // Set the search engine icon (e.g., Google) for the row.
+
+ mIconView.updateAndScaleImage(IconResponse.create(mSearchEngine.getIcon()));
+ // Set the initial content description.
+ setDescriptionOnSuggestion(mUserEnteredTextView, mUserEnteredTextView.getText().toString());
+
+ final int recycledSuggestionCount = mSuggestionView.getChildCount();
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ final boolean savedSearchesEnabled = prefs.getBoolean(GeckoPreferences.PREFS_HISTORY_SAVED_SEARCH, true);
+
+ // Remove duplicates of search engine suggestions from saved searches.
+ List<String> searchHistorySuggestions = (rawSearchHistorySuggestions != null) ? rawSearchHistorySuggestions : new ArrayList<String>();
+
+ // Filter out URLs and long search suggestions
+ Iterator<String> searchHistoryIterator = searchHistorySuggestions.iterator();
+ while (searchHistoryIterator.hasNext()) {
+ final String currentSearchHistory = searchHistoryIterator.next();
+
+ if (currentSearchHistory.length() > 50 || Pattern.matches("^(https?|ftp|file)://.*", currentSearchHistory)) {
+ searchHistoryIterator.remove();
+ }
+ }
+
+
+ List<String> searchEngineSuggestions = new ArrayList<String>();
+ for (String suggestion : searchEngine.getSuggestions()) {
+ searchHistorySuggestions.remove(suggestion);
+ searchEngineSuggestions.add(suggestion);
+ }
+ // Make sure the search term itself isn't duplicated. This is more important on phones than tablets where screen
+ // space is more precious.
+ searchHistorySuggestions.remove(getSuggestionTextFromView(mUserEnteredView));
+
+ // Trim the history suggestions down to the maximum allowed.
+ if (searchHistorySuggestions.size() >= mMaxSavedSuggestions) {
+ // The second index to subList() is exclusive, so this looks like an off by one error but it is not.
+ searchHistorySuggestions = searchHistorySuggestions.subList(0, mMaxSavedSuggestions);
+ }
+ final int searchHistoryCount = searchHistorySuggestions.size();
+
+ if (searchSuggestionsEnabled && savedSearchesEnabled) {
+ final int suggestionViewCount = updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, searchHistoryCount);
+ updateFromSavedSearches(searchHistorySuggestions, animate, suggestionViewCount, recycledSuggestionCount);
+ } else if (savedSearchesEnabled) {
+ updateFromSavedSearches(searchHistorySuggestions, animate, 0, recycledSuggestionCount);
+ } else if (searchSuggestionsEnabled) {
+ updateFromSearchEngine(animate, searchEngineSuggestions, recycledSuggestionCount, 0);
+ } else {
+ // The current search term is treated separately from the suggestions list, hence we can
+ // recycle ALL suggestion items here. (We always show the current search term, i.e. 1 item,
+ // in front of the search engine suggestions and/or the search history.)
+ hideRecycledSuggestions(0, recycledSuggestionCount);
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, android.view.KeyEvent event) {
+ final View suggestion = mSuggestionView.getChildAt(mSelectedView);
+
+ if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ final View nextSuggestion = mSuggestionView.getChildAt(mSelectedView + 1);
+ if (nextSuggestion != null) {
+ changeSelectedSuggestion(suggestion, nextSuggestion);
+ mSelectedView++;
+ return true;
+ }
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ final View prevSuggestion = mSuggestionView.getChildAt(mSelectedView - 1);
+ if (prevSuggestion != null) {
+ changeSelectedSuggestion(suggestion, prevSuggestion);
+ mSelectedView--;
+ return true;
+ }
+ break;
+
+ case KeyEvent.KEYCODE_BUTTON_A:
+ // TODO: handle long pressing for editing suggestions
+ return suggestion.performClick();
+ }
+
+ return false;
+ }
+
+ private void changeSelectedSuggestion(View oldSuggestion, View newSuggestion) {
+ oldSuggestion.setDuplicateParentStateEnabled(false);
+ newSuggestion.setDuplicateParentStateEnabled(true);
+ oldSuggestion.refreshDrawableState();
+ newSuggestion.refreshDrawableState();
+ }
+
+ public void onSelected() {
+ mSelectedView = 0;
+ mUserEnteredView.setDuplicateParentStateEnabled(true);
+ mUserEnteredView.refreshDrawableState();
+ }
+
+ public void onDeselected() {
+ final View suggestion = mSuggestionView.getChildAt(mSelectedView);
+ suggestion.setDuplicateParentStateEnabled(false);
+ suggestion.refreshDrawableState();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java
new file mode 100644
index 000000000..f7b5b6586
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SearchLoader.java
@@ -0,0 +1,114 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.util.EnumSet;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.BrowserDB.FilterFlags;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+
+/**
+ * Encapsulates the implementation of the search cursor loader.
+ */
+class SearchLoader {
+ public static final String LOGTAG = "GeckoSearchLoader";
+
+ private static final String KEY_SEARCH_TERM = "search_term";
+ private static final String KEY_FILTER_FLAGS = "flags";
+
+ private SearchLoader() {
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Loader<Cursor> createInstance(Context context, Bundle args) {
+ if (args != null) {
+ final String searchTerm = args.getString(KEY_SEARCH_TERM);
+ final EnumSet<FilterFlags> flags =
+ (EnumSet<FilterFlags>) args.getSerializable(KEY_FILTER_FLAGS);
+ return new SearchCursorLoader(context, searchTerm, flags);
+ } else {
+ return new SearchCursorLoader(context, "", EnumSet.noneOf(FilterFlags.class));
+ }
+ }
+
+ private static Bundle createArgs(String searchTerm, EnumSet<FilterFlags> flags) {
+ Bundle args = new Bundle();
+ args.putString(SearchLoader.KEY_SEARCH_TERM, searchTerm);
+ args.putSerializable(SearchLoader.KEY_FILTER_FLAGS, flags);
+
+ return args;
+ }
+
+ public static void init(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm) {
+ init(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class));
+ }
+
+ public static void init(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm,
+ EnumSet<FilterFlags> flags) {
+ final Bundle args = createArgs(searchTerm, flags);
+ manager.initLoader(loaderId, args, callbacks);
+ }
+
+ public static void restart(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm) {
+ restart(manager, loaderId, callbacks, searchTerm, EnumSet.noneOf(FilterFlags.class));
+ }
+
+ public static void restart(LoaderManager manager, int loaderId,
+ LoaderCallbacks<Cursor> callbacks, String searchTerm,
+ EnumSet<FilterFlags> flags) {
+ final Bundle args = createArgs(searchTerm, flags);
+ manager.restartLoader(loaderId, args, callbacks);
+ }
+
+ public static class SearchCursorLoader extends SimpleCursorLoader {
+ private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_SEARCH_LOADER_TIME_MS";
+
+ // Max number of search results.
+ private static final int SEARCH_LIMIT = 100;
+
+ // The target search term associated with the loader.
+ private final String mSearchTerm;
+
+ // The filter flags associated with the loader.
+ private final EnumSet<FilterFlags> mFlags;
+ private final GeckoProfile mProfile;
+
+ public SearchCursorLoader(Context context, String searchTerm, EnumSet<FilterFlags> flags) {
+ super(context);
+ mSearchTerm = searchTerm;
+ mFlags = flags;
+ mProfile = GeckoProfile.get(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final long start = SystemClock.uptimeMillis();
+ final Cursor cursor = BrowserDB.from(mProfile).filter(getContext().getContentResolver(), mSearchTerm, SEARCH_LIMIT, mFlags);
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE));
+ return cursor;
+ }
+
+ public String getSearchTerm() {
+ return mSearchTerm;
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java
new file mode 100644
index 000000000..b8889c033
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SimpleCursorLoader.java
@@ -0,0 +1,147 @@
+/*
+ * This is an adapted version of Android's original CursorLoader
+ * without all the ContentProvider-specific bits.
+ *
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.content.AsyncTaskLoader;
+
+import org.mozilla.gecko.GeckoApplication;
+
+/**
+ * A copy of the framework's {@link android.content.CursorLoader} that
+ * instead allows the caller to load the Cursor themselves via the abstract
+ * {@link #loadCursor()} method, rather than calling out to a ContentProvider via
+ * class methods.
+ *
+ * For new code, prefer {@link android.content.CursorLoader} (see @deprecated).
+ *
+ * This was originally created to re-use existing code which loaded Cursors manually.
+ *
+ * @deprecated since the framework provides an implementation, we'd like to eventually remove
+ * this class to reduce maintenance burden. Originally planned for bug 1239491, but
+ * it'd be more efficient to do this over time, rather than all at once.
+ */
+@Deprecated
+public abstract class SimpleCursorLoader extends AsyncTaskLoader<Cursor> {
+ final ForceLoadContentObserver mObserver;
+ Cursor mCursor;
+
+ public SimpleCursorLoader(Context context) {
+ super(context);
+ mObserver = new ForceLoadContentObserver();
+ }
+
+ /**
+ * Loads the target cursor for this loader. This method is called
+ * on a worker thread.
+ */
+ protected abstract Cursor loadCursor();
+
+ /* Runs on a worker thread */
+ @Override
+ public Cursor loadInBackground() {
+ Cursor cursor = loadCursor();
+
+ if (cursor != null) {
+ // Ensure the cursor window is filled
+ cursor.getCount();
+ cursor.registerContentObserver(mObserver);
+ }
+
+ return cursor;
+ }
+
+ /* Runs on the UI thread */
+ @Override
+ public void deliverResult(Cursor cursor) {
+ if (isReset()) {
+ // An async query came in while the loader is stopped
+ if (cursor != null) {
+ cursor.close();
+ }
+
+ return;
+ }
+
+ Cursor oldCursor = mCursor;
+ mCursor = cursor;
+
+ if (isStarted()) {
+ super.deliverResult(cursor);
+ }
+
+ if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) {
+ oldCursor.close();
+
+ // Trying to read from the closed cursor will cause crashes, hence we should make
+ // sure that no adapters/LoaderCallbacks are holding onto this cursor.
+ GeckoApplication.getRefWatcher(getContext()).watch(oldCursor);
+ }
+ }
+
+ /**
+ * Starts an asynchronous load of the list data. When the result is ready the callbacks
+ * will be called on the UI thread. If a previous load has been completed and is still valid
+ * the result may be passed to the callbacks immediately.
+ *
+ * Must be called from the UI thread
+ */
+ @Override
+ protected void onStartLoading() {
+ if (mCursor != null) {
+ deliverResult(mCursor);
+ }
+
+ if (takeContentChanged() || mCursor == null) {
+ forceLoad();
+ }
+ }
+
+ /**
+ * Must be called from the UI thread
+ */
+ @Override
+ protected void onStopLoading() {
+ // Attempt to cancel the current load task if possible.
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(Cursor cursor) {
+ if (cursor != null && !cursor.isClosed()) {
+ cursor.close();
+ }
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped
+ onStopLoading();
+
+ if (mCursor != null && !mCursor.isClosed()) {
+ mCursor.close();
+ }
+
+ mCursor = null;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java
new file mode 100644
index 000000000..039b65e82
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/SpacingDecoration.java
@@ -0,0 +1,20 @@
+package org.mozilla.gecko.home;
+
+import android.graphics.Rect;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+public class SpacingDecoration extends RecyclerView.ItemDecoration {
+ private final int horizontalSpacing;
+ private final int verticalSpacing;
+
+ public SpacingDecoration(int horizontalSpacing, int verticalSpacing) {
+ this.horizontalSpacing = horizontalSpacing;
+ this.verticalSpacing = verticalSpacing;
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
+ outRect.set(horizontalSpacing, verticalSpacing, horizontalSpacing, verticalSpacing);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java
new file mode 100644
index 000000000..b302d3522
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStrip.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/**
+ * {@code TabMenuStrip} is the view used to display {@code HomePager} tabs
+ * on tablets. See {@code TabMenuStripLayout} for details about how the
+ * tabs are created and updated.
+ */
+public class TabMenuStrip extends HorizontalScrollView
+ implements HomePager.Decor {
+
+ // Offset between the selected tab title and the edge of the screen,
+ // except for the first and last tab in the tab strip.
+ private static final int TITLE_OFFSET_DIPS = 24;
+
+ private final int titleOffset;
+ private final TabMenuStripLayout layout;
+
+ private final Paint shadowPaint;
+ private final int shadowSize;
+
+ public interface OnTitleClickListener {
+ void onTitleClicked(int index);
+ }
+
+ public TabMenuStrip(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ // Disable the scroll bar.
+ setHorizontalScrollBarEnabled(false);
+ setFillViewport(true);
+
+ final Resources res = getResources();
+
+ titleOffset = (int) (TITLE_OFFSET_DIPS * res.getDisplayMetrics().density);
+
+ layout = new TabMenuStripLayout(context, attrs);
+ addView(layout, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+
+ shadowSize = res.getDimensionPixelSize(R.dimen.tabs_strip_shadow_size);
+
+ shadowPaint = new Paint();
+ shadowPaint.setColor(ContextCompat.getColor(context, R.color.url_bar_shadow));
+ shadowPaint.setStrokeWidth(0.0f);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ final int height = getHeight();
+ canvas.drawRect(0, height - shadowSize, layout.getWidth(), height, shadowPaint);
+ }
+
+ @Override
+ public void onAddPagerView(String title) {
+ layout.onAddPagerView(title);
+ }
+
+ @Override
+ public void removeAllPagerViews() {
+ layout.removeAllViews();
+ }
+
+ @Override
+ public void onPageSelected(final int position) {
+ layout.onPageSelected(position);
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ layout.onPageScrolled(position, positionOffset, positionOffsetPixels);
+
+ final View selectedTitle = layout.getChildAt(position);
+ if (selectedTitle == null) {
+ return;
+ }
+
+ final int selectedTitleOffset = (int) (positionOffset * selectedTitle.getWidth());
+
+ int titleLeft = selectedTitle.getLeft() + selectedTitleOffset;
+ if (position > 0) {
+ titleLeft -= titleOffset;
+ }
+
+ int titleRight = selectedTitle.getRight() + selectedTitleOffset;
+ if (position < layout.getChildCount() - 1) {
+ titleRight += titleOffset;
+ }
+
+ final int scrollX = getScrollX();
+ if (titleLeft < scrollX) {
+ // Tab strip overflows to the left.
+ scrollTo(titleLeft, 0);
+ } else if (titleRight > scrollX + getWidth()) {
+ // Tab strip overflows to the right.
+ scrollTo(titleRight - getWidth(), 0);
+ }
+ }
+
+ @Override
+ public void setOnTitleClickListener(OnTitleClickListener onTitleClickListener) {
+ layout.setOnTitleClickListener(onTitleClickListener);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java
new file mode 100644
index 000000000..a09add80b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TabMenuStripLayout.java
@@ -0,0 +1,246 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import android.content.res.ColorStateList;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+/**
+ * {@code TabMenuStripLayout} is the view that draws the {@code HomePager}
+ * tabs that are displayed in {@code TabMenuStrip}.
+ */
+class TabMenuStripLayout extends LinearLayout
+ implements View.OnFocusChangeListener {
+
+ private TabMenuStrip.OnTitleClickListener onTitleClickListener;
+ private Drawable strip;
+ private TextView selectedView;
+
+ // Data associated with the scrolling of the strip drawable.
+ private View toTab;
+ private View fromTab;
+ private int fromPosition;
+ private int toPosition;
+ private float progress;
+
+ // This variable is used to predict the direction of scroll.
+ private float prevProgress;
+ private int tabContentStart;
+ private boolean titlebarFill;
+ private int activeTextColor;
+ private ColorStateList inactiveTextColor;
+
+ TabMenuStripLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabMenuStrip);
+ final int stripResId = a.getResourceId(R.styleable.TabMenuStrip_strip, -1);
+
+ titlebarFill = a.getBoolean(R.styleable.TabMenuStrip_titlebarFill, false);
+ tabContentStart = a.getDimensionPixelSize(R.styleable.TabMenuStrip_tabsMarginLeft, 0);
+ activeTextColor = a.getColor(R.styleable.TabMenuStrip_activeTextColor, R.color.text_and_tabs_tray_grey);
+ inactiveTextColor = a.getColorStateList(R.styleable.TabMenuStrip_inactiveTextColor);
+ a.recycle();
+
+ if (stripResId != -1) {
+ strip = getResources().getDrawable(stripResId);
+ }
+
+ setWillNotDraw(false);
+ }
+
+ void onAddPagerView(String title) {
+ final TextView button = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.tab_menu_strip, this, false);
+ button.setText(title.toUpperCase());
+ button.setTextColor(inactiveTextColor);
+
+ // Set titles width to weight, or wrap text width.
+ if (titlebarFill) {
+ button.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, 1.0f));
+ } else {
+ button.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ }
+
+ if (getChildCount() == 0) {
+ button.setPadding(button.getPaddingLeft() + tabContentStart,
+ button.getPaddingTop(),
+ button.getPaddingRight(),
+ button.getPaddingBottom());
+ }
+
+ addView(button);
+ button.setOnClickListener(new ViewClickListener(getChildCount() - 1));
+ button.setOnFocusChangeListener(this);
+ }
+
+ void onPageSelected(final int position) {
+ if (selectedView != null) {
+ selectedView.setTextColor(inactiveTextColor);
+ }
+
+ selectedView = (TextView) getChildAt(position);
+ selectedView.setTextColor(activeTextColor);
+
+ // Callback to measure and draw the strip after the view is visible.
+ ViewTreeObserver vto = selectedView.getViewTreeObserver();
+ if (vto.isAlive()) {
+ vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ selectedView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+
+ if (strip != null) {
+ strip.setBounds(selectedView.getLeft() + (position == 0 ? tabContentStart : 0),
+ selectedView.getTop(),
+ selectedView.getRight(),
+ selectedView.getBottom());
+ }
+
+ prevProgress = position;
+ }
+ });
+ }
+ }
+
+ // Page scroll animates the drawable and its bounds from the previous to next child view.
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ if (strip == null) {
+ return;
+ }
+
+ setScrollingData(position, positionOffset);
+
+ if (fromTab == null || toTab == null) {
+ return;
+ }
+
+ final int fromTabLeft = fromTab.getLeft();
+ final int fromTabRight = fromTab.getRight();
+
+ final int toTabLeft = toTab.getLeft();
+ final int toTabRight = toTab.getRight();
+
+ // The first tab has a padding applied (tabContentStart). We don't want the 'strip' to jump around so we remove
+ // this padding slowly (modifier) when scrolling to or from the first tab.
+ final int modifier;
+
+ if (fromPosition == 0 && toPosition == 1) {
+ // Slowly remove extra padding (tabContentStart) based on scroll progress
+ modifier = (int) (tabContentStart * (1 - progress));
+ } else if (fromPosition == 1 && toPosition == 0) {
+ // Slowly add extra padding (tabContentStart) based on scroll progress
+ modifier = (int) (tabContentStart * progress);
+ } else {
+ // We are not scrolling tab 0 in any way, no modifier needed
+ modifier = 0;
+ }
+
+ strip.setBounds((int) (fromTabLeft + ((toTabLeft - fromTabLeft) * progress)) + modifier,
+ 0,
+ (int) (fromTabRight + ((toTabRight - fromTabRight) * progress)),
+ getHeight());
+ invalidate();
+ }
+
+ /*
+ * position + positionOffset goes from 0 to 2 as we scroll from page 1 to 3.
+ * Normalized progress is relative to the the direction the page is being scrolled towards.
+ * For this, we maintain direction of scroll with a state, and the child view we are moving towards and away from.
+ */
+ void setScrollingData(int position, float positionOffset) {
+ if (position >= getChildCount() - 1) {
+ return;
+ }
+
+ final float currProgress = position + positionOffset;
+
+ if (prevProgress > currProgress) {
+ toPosition = position;
+ fromPosition = position + 1;
+ progress = 1 - positionOffset;
+ } else {
+ toPosition = position + 1;
+ fromPosition = position;
+ progress = positionOffset;
+ }
+
+ toTab = getChildAt(toPosition);
+ fromTab = getChildAt(fromPosition);
+
+ prevProgress = currProgress;
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (strip != null) {
+ strip.draw(canvas);
+ }
+ }
+
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (v == this && hasFocus && getChildCount() > 0) {
+ selectedView.requestFocus();
+ return;
+ }
+
+ if (!hasFocus) {
+ return;
+ }
+
+ int i = 0;
+ final int numTabs = getChildCount();
+
+ while (i < numTabs) {
+ View view = getChildAt(i);
+ if (view == v) {
+ view.requestFocus();
+ if (isShown()) {
+ // A view is focused so send an event to announce the menu strip state.
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
+ }
+ break;
+ }
+
+ i++;
+ }
+ }
+
+ void setOnTitleClickListener(TabMenuStrip.OnTitleClickListener onTitleClickListener) {
+ this.onTitleClickListener = onTitleClickListener;
+ }
+
+ private class ViewClickListener implements OnClickListener {
+ private final int mIndex;
+
+ public ViewClickListener(int index) {
+ mIndex = index;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (onTitleClickListener != null) {
+ onTitleClickListener.onTitleClicked(mIndex);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
new file mode 100644
index 000000000..c17aff209
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridItemView.java
@@ -0,0 +1,312 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.widget.ImageView.ScaleType;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+
+import java.util.concurrent.Future;
+
+/**
+ * A view that displays the thumbnail and the title/url for a top/pinned site.
+ * If the title/url is longer than the width of the view, they are faded out.
+ * If there is no valid url, a default string is shown at 50% opacity.
+ * This is denoted by the empty state.
+ */
+public class TopSitesGridItemView extends RelativeLayout implements IconCallback {
+ private static final String LOGTAG = "GeckoTopSitesGridItemView";
+
+ // Empty state, to denote there is no valid url.
+ private static final int[] STATE_EMPTY = { android.R.attr.state_empty };
+
+ private static final ScaleType SCALE_TYPE_FAVICON = ScaleType.CENTER;
+ private static final ScaleType SCALE_TYPE_RESOURCE = ScaleType.CENTER;
+ private static final ScaleType SCALE_TYPE_THUMBNAIL = ScaleType.CENTER_CROP;
+ private static final ScaleType SCALE_TYPE_URL = ScaleType.CENTER_INSIDE;
+
+ // Child views.
+ private final TextView mTitleView;
+ private final TopSitesThumbnailView mThumbnailView;
+
+ // Data backing this view.
+ private String mTitle;
+ private String mUrl;
+
+ private boolean mThumbnailSet;
+
+ // Matches BrowserContract.TopSites row types
+ private int mType = -1;
+
+ // Dirty state.
+ private boolean mIsDirty;
+
+ private Future<IconResponse> mOngoingIconRequest;
+
+ public TopSitesGridItemView(Context context) {
+ this(context, null);
+ }
+
+ public TopSitesGridItemView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesGridItemViewStyle);
+ }
+
+ public TopSitesGridItemView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ LayoutInflater.from(context).inflate(R.layout.top_sites_grid_item_view, this);
+
+ mTitleView = (TextView) findViewById(R.id.title);
+ mThumbnailView = (TopSitesThumbnailView) findViewById(R.id.thumbnail);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (mType == TopSites.TYPE_BLANK) {
+ mergeDrawableStates(drawableState, STATE_EMPTY);
+ }
+
+ return drawableState;
+ }
+
+ /**
+ * @return The title shown by this view.
+ */
+ public String getTitle() {
+ return (!TextUtils.isEmpty(mTitle) ? mTitle : mUrl);
+ }
+
+ /**
+ * @return The url shown by this view.
+ */
+ public String getUrl() {
+ return mUrl;
+ }
+
+ /**
+ * @return The site type associated with this view.
+ */
+ public int getType() {
+ return mType;
+ }
+
+ /**
+ * @param title The title for this view.
+ */
+ public void setTitle(String title) {
+ if (mTitle != null && mTitle.equals(title)) {
+ return;
+ }
+
+ mTitle = title;
+ updateTitleView();
+ }
+
+ /**
+ * @param url The url for this view.
+ */
+ public void setUrl(String url) {
+ if (mUrl != null && mUrl.equals(url)) {
+ return;
+ }
+
+ mUrl = url;
+ updateTitleView();
+ }
+
+ public void blankOut() {
+ mUrl = "";
+ mTitle = "";
+ updateType(TopSites.TYPE_BLANK);
+ updateTitleView();
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+ displayThumbnail(R.drawable.top_site_add);
+
+ }
+
+ public void markAsDirty() {
+ mIsDirty = true;
+ }
+
+ /**
+ * Updates the title, URL, and pinned state of this view.
+ *
+ * Also resets our loadId to NOT_LOADING.
+ *
+ * Returns true if any fields changed.
+ */
+ public boolean updateState(final String title, final String url, final int type, final TopSitesPanel.ThumbnailInfo thumbnail) {
+ boolean changed = false;
+ if (mUrl == null || !mUrl.equals(url)) {
+ mUrl = url;
+ changed = true;
+ }
+
+ if (mTitle == null || !mTitle.equals(title)) {
+ mTitle = title;
+ changed = true;
+ }
+
+ if (thumbnail != null) {
+ if (thumbnail.imageUrl != null) {
+ displayThumbnail(thumbnail.imageUrl, thumbnail.bgColor);
+ } else if (thumbnail.bitmap != null) {
+ displayThumbnail(thumbnail.bitmap);
+ }
+ } else if (changed) {
+ // Because we'll have a new favicon or thumbnail arriving shortly, and
+ // we need to not reject it because we already had a thumbnail.
+ mThumbnailSet = false;
+ }
+
+ if (changed) {
+ updateTitleView();
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+ }
+
+ if (updateType(type)) {
+ changed = true;
+ }
+
+ // The dirty state forces the state update to return true
+ // so that the adapter loads favicons once the thumbnails
+ // are loaded in TopSitesPanel/TopSitesGridAdapter.
+ changed = (changed || mIsDirty);
+ mIsDirty = false;
+
+ return changed;
+ }
+
+ /**
+ * Try to load an icon for the given page URL.
+ */
+ public void loadFavicon(String pageUrl) {
+ mOngoingIconRequest = Icons.with(getContext())
+ .pageUrl(pageUrl)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ private void cancelIconLoading() {
+ if (mOngoingIconRequest != null) {
+ mOngoingIconRequest.cancel(true);
+ }
+ }
+
+ /**
+ * Display the thumbnail from a resource.
+ *
+ * @param resId Resource ID of the drawable to show.
+ */
+ public void displayThumbnail(int resId) {
+ mThumbnailView.setScaleType(SCALE_TYPE_RESOURCE);
+ mThumbnailView.setImageResource(resId);
+ mThumbnailView.setBackgroundColor(0x0);
+ mThumbnailSet = false;
+ }
+
+ /**
+ * Display the thumbnail from a bitmap.
+ *
+ * @param thumbnail The bitmap to show as thumbnail.
+ */
+ public void displayThumbnail(Bitmap thumbnail) {
+ if (thumbnail == null) {
+ return;
+ }
+
+ mThumbnailSet = true;
+
+ cancelIconLoading();
+ ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
+
+ mThumbnailView.setScaleType(SCALE_TYPE_THUMBNAIL);
+ mThumbnailView.setImageBitmap(thumbnail, true);
+ mThumbnailView.setBackgroundDrawable(null);
+ }
+
+ /**
+ * Display the thumbnail from a URL.
+ *
+ * @param imageUrl URL of the image to show.
+ * @param bgColor background color to use in the view.
+ */
+ public void displayThumbnail(final String imageUrl, final int bgColor) {
+ mThumbnailView.setScaleType(SCALE_TYPE_URL);
+ mThumbnailView.setBackgroundColor(bgColor);
+ mThumbnailSet = true;
+
+ ImageLoader.with(getContext())
+ .load(imageUrl)
+ .noFade()
+ .into(mThumbnailView);
+ }
+
+ /**
+ * Update the item type associated with this view. Returns true if
+ * the type has changed, false otherwise.
+ */
+ private boolean updateType(int type) {
+ if (mType == type) {
+ return false;
+ }
+
+ mType = type;
+ refreshDrawableState();
+
+ int pinResourceId = (type == TopSites.TYPE_PINNED ? R.drawable.pin : 0);
+ mTitleView.setCompoundDrawablesWithIntrinsicBounds(pinResourceId, 0, 0, 0);
+
+ return true;
+ }
+
+ /**
+ * Update the title shown by this view. If both title and url
+ * are empty, mark the state as STATE_EMPTY and show a default text.
+ */
+ private void updateTitleView() {
+ String title = getTitle();
+ if (!TextUtils.isEmpty(title)) {
+ mTitleView.setText(title);
+ } else {
+ mTitleView.setText(R.string.home_top_sites_add);
+ }
+ }
+
+ /**
+ * Display the loaded icon (if no thumbnail is set).
+ */
+ @Override
+ public void onIconResponse(IconResponse response) {
+ if (mThumbnailSet) {
+ // Already showing a thumbnail; do nothing.
+ return;
+ }
+
+ mThumbnailView.setScaleType(SCALE_TYPE_FAVICON);
+ mThumbnailView.setImageBitmap(response.getBitmap(), false);
+ mThumbnailView.setBackgroundColorWithOpacityFilter(response.getColor());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java
new file mode 100644
index 000000000..58a05b198
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesGridView.java
@@ -0,0 +1,169 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View;
+import android.widget.AbsListView;
+import android.widget.GridView;
+
+/**
+ * A grid view of top and pinned sites.
+ * Each cell in the grid is a TopSitesGridItemView.
+ */
+public class TopSitesGridView extends GridView {
+ private static final String LOGTAG = "GeckoTopSitesGridView";
+
+ // Listener for editing pinned sites.
+ public static interface OnEditPinnedSiteListener {
+ public void onEditPinnedSite(int position, String searchTerm);
+ }
+
+ // Max number of top sites that needs to be shown.
+ private final int mMaxSites;
+
+ // Number of columns to show.
+ private final int mNumColumns;
+
+ // Horizontal spacing in between the rows.
+ private final int mHorizontalSpacing;
+
+ // Vertical spacing in between the rows.
+ private final int mVerticalSpacing;
+
+ // Measured width of this view.
+ private int mMeasuredWidth;
+
+ // Measured height of this view.
+ private int mMeasuredHeight;
+
+ // A dummy View used to measure the required size of the child Views.
+ private final TopSitesGridItemView dummyChildView;
+
+ // Context menu info.
+ private TopSitesGridContextMenuInfo mContextMenuInfo;
+
+ // Whether we're handling focus changes or not. This is used
+ // to avoid infinite re-layouts when using this GridView as
+ // a ListView header view (see bug 918044).
+ private boolean mIsHandlingFocusChange;
+
+ public TopSitesGridView(Context context) {
+ this(context, null);
+ }
+
+ public TopSitesGridView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesGridViewStyle);
+ }
+
+ public TopSitesGridView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ mMaxSites = getResources().getInteger(R.integer.number_of_top_sites);
+ mNumColumns = getResources().getInteger(R.integer.number_of_top_sites_cols);
+ setNumColumns(mNumColumns);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TopSitesGridView, defStyle, 0);
+ mHorizontalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_horizontalSpacing, 0x00);
+ mVerticalSpacing = a.getDimensionPixelOffset(R.styleable.TopSitesGridView_android_verticalSpacing, 0x00);
+ a.recycle();
+
+ dummyChildView = new TopSitesGridItemView(context);
+ // Set a default LayoutParams on the child, if it doesn't have one on its own.
+ AbsListView.LayoutParams params = (AbsListView.LayoutParams) dummyChildView.getLayoutParams();
+ if (params == null) {
+ params = new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT,
+ AbsListView.LayoutParams.WRAP_CONTENT);
+ dummyChildView.setLayoutParams(params);
+ }
+ }
+
+ @Override
+ protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
+ mIsHandlingFocusChange = true;
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+ mIsHandlingFocusChange = false;
+ }
+
+ @Override
+ public void requestLayout() {
+ if (!mIsHandlingFocusChange) {
+ super.requestLayout();
+ }
+ }
+
+ @Override
+ public int getColumnWidth() {
+ // This method will be called from onMeasure() too.
+ // It's better to use getMeasuredWidth(), as it is safe in this case.
+ final int totalHorizontalSpacing = mNumColumns > 0 ? (mNumColumns - 1) * mHorizontalSpacing : 0;
+ return (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - totalHorizontalSpacing) / mNumColumns;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // Sets the padding for this view.
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int measuredWidth = getMeasuredWidth();
+ if (measuredWidth == mMeasuredWidth) {
+ // Return the cached values as the width is the same.
+ setMeasuredDimension(mMeasuredWidth, mMeasuredHeight);
+ return;
+ }
+
+ final int columnWidth = getColumnWidth();
+
+ // Measure the exact width of the child, and the height based on the width.
+ // Note: the child (and TopSitesThumbnailView) takes care of calculating its height.
+ int childWidthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY);
+ int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ dummyChildView.measure(childWidthSpec, childHeightSpec);
+ final int childHeight = dummyChildView.getMeasuredHeight();
+
+ // This is the maximum width of the contents of each child in the grid.
+ // Use this as the target width for thumbnails.
+ final int thumbnailWidth = dummyChildView.getMeasuredWidth() - dummyChildView.getPaddingLeft() - dummyChildView.getPaddingRight();
+ ThumbnailHelper.getInstance().setThumbnailWidth(thumbnailWidth);
+
+ // Number of rows required to show these top sites.
+ final int rows = (int) Math.ceil((double) mMaxSites / mNumColumns);
+ final int childrenHeight = childHeight * rows;
+ final int totalVerticalSpacing = rows > 0 ? (rows - 1) * mVerticalSpacing : 0;
+
+ // Total height of this view.
+ final int measuredHeight = childrenHeight + getPaddingTop() + getPaddingBottom() + totalVerticalSpacing;
+ setMeasuredDimension(measuredWidth, measuredHeight);
+ mMeasuredWidth = measuredWidth;
+ mMeasuredHeight = measuredHeight;
+ }
+
+ @Override
+ public ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ public void setContextMenuInfo(TopSitesGridContextMenuInfo contextMenuInfo) {
+ mContextMenuInfo = contextMenuInfo;
+ }
+
+ /**
+ * Stores information regarding the creation of the context menu for a GridView item.
+ */
+ public static class TopSitesGridContextMenuInfo extends HomeContextMenuInfo {
+ public int type = -1;
+
+ public TopSitesGridContextMenuInfo(View targetView, int position, long id) {
+ super(targetView, position, id);
+ this.itemType = RemoveItemType.HISTORY;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java
new file mode 100644
index 000000000..f39e51ac5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesPanel.java
@@ -0,0 +1,968 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_COLOR_COLUMN;
+import static org.mozilla.gecko.db.URLMetadataTable.TILE_IMAGE_URL_COLUMN;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Future;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserContract.Thumbnails;
+import org.mozilla.gecko.db.BrowserContract.TopSites;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
+import org.mozilla.gecko.home.PinSiteDialog.OnSiteSelectedListener;
+import org.mozilla.gecko.home.TopSitesGridView.OnEditPinnedSiteListener;
+import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.AsyncTaskLoader;
+import android.support.v4.content.Loader;
+import android.support.v4.widget.CursorAdapter;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ListView;
+
+/**
+ * Fragment that displays frecency search results in a ListView.
+ */
+public class TopSitesPanel extends HomeFragment {
+ // Logging tag name
+ private static final String LOGTAG = "GeckoTopSitesPanel";
+
+ // Cursor loader ID for the top sites
+ private static final int LOADER_ID_TOP_SITES = 0;
+
+ // Loader ID for thumbnails
+ private static final int LOADER_ID_THUMBNAILS = 1;
+
+ // Key for thumbnail urls
+ private static final String THUMBNAILS_URLS_KEY = "urls";
+
+ // Adapter for the list of top sites
+ private VisitedAdapter mListAdapter;
+
+ // Adapter for the grid of top sites
+ private TopSitesGridAdapter mGridAdapter;
+
+ // List of top sites
+ private HomeListView mList;
+
+ // Grid of top sites
+ private TopSitesGridView mGrid;
+
+ // Callbacks used for the search and favicon cursor loaders
+ private CursorLoaderCallbacks mCursorLoaderCallbacks;
+
+ // Callback for thumbnail loader
+ private ThumbnailsLoaderCallbacks mThumbnailsLoaderCallbacks;
+
+ // Listener for editing pinned sites.
+ private EditPinnedSiteListener mEditPinnedSiteListener;
+
+ // Max number of entries shown in the grid from the cursor.
+ private int mMaxGridEntries;
+
+ // Time in ms until the Gecko thread is reset to normal priority.
+ private static final long PRIORITY_RESET_TIMEOUT = 10000;
+
+ public static TopSitesPanel newInstance() {
+ return new TopSitesPanel();
+ }
+
+ private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
+ private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
+
+ private static void debug(final String message) {
+ if (logDebug) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ private static void trace(final String message) {
+ if (logVerbose) {
+ Log.v(LOGTAG, message);
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+
+ mMaxGridEntries = activity.getResources().getInteger(R.integer.number_of_top_sites);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.home_top_sites_panel, container, false);
+
+ mList = (HomeListView) view.findViewById(R.id.list);
+
+ mGrid = new TopSitesGridView(getActivity());
+ mList.addHeaderView(mGrid);
+
+ return view;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ mEditPinnedSiteListener = new EditPinnedSiteListener();
+
+ mList.setTag(HomePager.LIST_TAG_TOP_SITES);
+ mList.setHeaderDividersEnabled(false);
+
+ mList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final ListView list = (ListView) parent;
+ final int headerCount = list.getHeaderViewsCount();
+ if (position < headerCount) {
+ // The click is on a header, don't do anything.
+ return;
+ }
+
+ // Absolute position for the adapter.
+ position += (mGridAdapter.getCount() - headerCount);
+
+ final Cursor c = mListAdapter.getCursor();
+ if (c == null || !c.moveToPosition(position)) {
+ return;
+ }
+
+ final String url = c.getString(c.getColumnIndexOrThrow(TopSites.URL));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "top_sites");
+
+ // This item is a TwoLinePageRow, so we allow switch-to-tab.
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+ });
+
+ mList.setContextMenuInfoFactory(new HomeContextMenuInfo.Factory() {
+ @Override
+ public HomeContextMenuInfo makeInfoForCursor(View view, int position, long id, Cursor cursor) {
+ final HomeContextMenuInfo info = new HomeContextMenuInfo(view, position, id);
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID));
+ info.itemType = RemoveItemType.HISTORY;
+ final int bookmarkIdCol = cursor.getColumnIndexOrThrow(TopSites.BOOKMARK_ID);
+ if (cursor.isNull(bookmarkIdCol)) {
+ // If this is a combined cursor, we may get a history item without a
+ // bookmark, in which case the bookmarks ID column value will be null.
+ info.bookmarkId = -1;
+ } else {
+ info.bookmarkId = cursor.getInt(bookmarkIdCol);
+ }
+ return info;
+ }
+ });
+
+ mGrid.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ TopSitesGridItemView item = (TopSitesGridItemView) view;
+
+ // Decode "user-entered" URLs before loading them.
+ String url = StringUtils.decodeUserEnteredUrl(item.getUrl());
+ int type = item.getType();
+
+ // If the url is empty, the user can pin a site.
+ // If not, navigate to the page given by the url.
+ if (type != TopSites.TYPE_BLANK) {
+ if (mUrlOpenListener != null) {
+ final TelemetryContract.Method method;
+ if (type == TopSites.TYPE_SUGGESTED) {
+ method = TelemetryContract.Method.SUGGESTION;
+ } else {
+ method = TelemetryContract.Method.GRID_ITEM;
+ }
+
+ String extra = Integer.toString(position);
+ if (type == TopSites.TYPE_PINNED) {
+ extra += "-pinned";
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, extra);
+
+ mUrlOpenListener.onUrlOpen(url, EnumSet.of(OnUrlOpenListener.Flags.NO_READER_VIEW));
+ }
+ } else {
+ if (mEditPinnedSiteListener != null) {
+ mEditPinnedSiteListener.onEditPinnedSite(position, "");
+ }
+ }
+ }
+ });
+
+ mGrid.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+
+ Cursor cursor = (Cursor) parent.getItemAtPosition(position);
+
+ TopSitesGridItemView item = (TopSitesGridItemView) view;
+ if (cursor == null || item.getType() == TopSites.TYPE_BLANK) {
+ mGrid.setContextMenuInfo(null);
+ return false;
+ }
+
+ TopSitesGridContextMenuInfo contextMenuInfo = new TopSitesGridContextMenuInfo(view, position, id);
+ updateContextMenuFromCursor(contextMenuInfo, cursor);
+ mGrid.setContextMenuInfo(contextMenuInfo);
+ return mGrid.showContextMenuForChild(mGrid);
+ }
+
+ /*
+ * Update the fields of a TopSitesGridContextMenuInfo object
+ * from a cursor.
+ *
+ * @param info context menu info object to be updated
+ * @param cursor used to update the context menu info object
+ */
+ private void updateContextMenuFromCursor(TopSitesGridContextMenuInfo info, Cursor cursor) {
+ info.url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ info.title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ info.type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE));
+ info.historyId = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.HISTORY_ID));
+ }
+ });
+
+ registerForContextMenu(mList);
+ registerForContextMenu(mGrid);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+
+ // Discard any additional item clicks on the list as the
+ // panel is getting destroyed (see bugs 930160 & 1096958).
+ mList.setOnItemClickListener(null);
+ mGrid.setOnItemClickListener(null);
+
+ mList = null;
+ mGrid = null;
+ mListAdapter = null;
+ mGridAdapter = null;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ final Activity activity = getActivity();
+
+ // Setup the top sites grid adapter.
+ mGridAdapter = new TopSitesGridAdapter(activity, null);
+ mGrid.setAdapter(mGridAdapter);
+
+ // Setup the top sites list adapter.
+ mListAdapter = new VisitedAdapter(activity, null);
+ mList.setAdapter(mListAdapter);
+
+ // Create callbacks before the initial loader is started
+ mCursorLoaderCallbacks = new CursorLoaderCallbacks();
+ mThumbnailsLoaderCallbacks = new ThumbnailsLoaderCallbacks();
+ loadIfVisible();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+ if (menuInfo == null) {
+ return;
+ }
+
+ if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) {
+ // Long pressed item was not a Top Sites GridView item. Superclass
+ // can handle this.
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ if (!Restrictions.isAllowed(view.getContext(), Restrictable.CLEAR_HISTORY)) {
+ menu.findItem(R.id.home_remove).setVisible(false);
+ }
+
+ return;
+ }
+
+ final Context context = view.getContext();
+
+ // Long pressed item was a Top Sites GridView item, handle it.
+ MenuInflater inflater = new MenuInflater(context);
+ inflater.inflate(R.menu.home_contextmenu, menu);
+
+ // Hide unused menu items.
+ menu.findItem(R.id.home_edit_bookmark).setVisible(false);
+
+ menu.findItem(R.id.home_remove).setVisible(Restrictions.isAllowed(context, Restrictable.CLEAR_HISTORY));
+
+ TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo;
+ menu.setHeaderTitle(info.getDisplayTitle());
+
+ if (info.type != TopSites.TYPE_BLANK) {
+ if (info.type == TopSites.TYPE_PINNED) {
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ } else {
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+ }
+ } else {
+ menu.findItem(R.id.home_open_new_tab).setVisible(false);
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ menu.findItem(R.id.top_sites_pin).setVisible(false);
+ menu.findItem(R.id.top_sites_unpin).setVisible(false);
+ }
+
+ if (!StringUtils.isShareableUrl(info.url) || GeckoProfile.get(getActivity()).inGuestMode()) {
+ menu.findItem(R.id.home_share).setVisible(false);
+ }
+
+ if (!Restrictions.isAllowed(context, Restrictable.PRIVATE_BROWSING)) {
+ menu.findItem(R.id.home_open_private_tab).setVisible(false);
+ }
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ if (super.onContextItemSelected(item)) {
+ // HomeFragment was able to handle to selected item.
+ return true;
+ }
+
+ ContextMenuInfo menuInfo = item.getMenuInfo();
+
+ if (!(menuInfo instanceof TopSitesGridContextMenuInfo)) {
+ return false;
+ }
+
+ TopSitesGridContextMenuInfo info = (TopSitesGridContextMenuInfo) menuInfo;
+
+ final int itemId = item.getItemId();
+ final BrowserDB db = BrowserDB.from(getActivity());
+
+ if (itemId == R.id.top_sites_pin) {
+ final String url = info.url;
+ final String title = info.title;
+ final int position = info.position;
+ final Context context = getActivity().getApplicationContext();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.pinSite(context.getContentResolver(), url, title, position);
+ }
+ });
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PIN);
+ return true;
+ }
+
+ if (itemId == R.id.top_sites_unpin) {
+ final int position = info.position;
+ final Context context = getActivity().getApplicationContext();
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.unpinSite(context.getContentResolver(), position);
+ }
+ });
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNPIN);
+
+ return true;
+ }
+
+ if (itemId == R.id.top_sites_edit) {
+ // Decode "user-entered" URLs before showing them.
+ mEditPinnedSiteListener.onEditPinnedSite(info.position,
+ StringUtils.decodeUserEnteredUrl(info.url));
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.EDIT);
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void load() {
+ getLoaderManager().initLoader(LOADER_ID_TOP_SITES, null, mCursorLoaderCallbacks);
+
+ // Since this is the primary fragment that loads whenever about:home is
+ // visited, we want to load it as quickly as possible. Heavy load on
+ // the Gecko thread can slow down the time it takes for thumbnails to
+ // appear, especially during startup (bug 897162). By minimizing the
+ // Gecko thread priority, we ensure that the UI appears quickly. The
+ // priority is reset to normal once thumbnails are loaded.
+ ThreadUtils.reduceGeckoPriority(PRIORITY_RESET_TIMEOUT);
+ }
+
+ /**
+ * Listener for editing pinned sites.
+ */
+ private class EditPinnedSiteListener implements OnEditPinnedSiteListener,
+ OnSiteSelectedListener {
+ // Tag for the PinSiteDialog fragment.
+ private static final String TAG_PIN_SITE = "pin_site";
+
+ // Position of the pin.
+ private int mPosition;
+
+ @Override
+ public void onEditPinnedSite(int position, String searchTerm) {
+ final FragmentManager manager = getChildFragmentManager();
+ PinSiteDialog dialog = (PinSiteDialog) manager.findFragmentByTag(TAG_PIN_SITE);
+ if (dialog == null) {
+ mPosition = position;
+
+ dialog = PinSiteDialog.newInstance();
+ dialog.setOnSiteSelectedListener(this);
+ dialog.setSearchTerm(searchTerm);
+ dialog.show(manager, TAG_PIN_SITE);
+ }
+ }
+
+ @Override
+ public void onSiteSelected(final String url, final String title) {
+ final int position = mPosition;
+ final Context context = getActivity().getApplicationContext();
+ final BrowserDB db = BrowserDB.from(getActivity());
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ db.pinSite(context.getContentResolver(), url, title, position);
+ }
+ });
+ }
+ }
+
+ private void updateUiFromCursor(Cursor c) {
+ mList.setHeaderDividersEnabled(c != null && c.getCount() > mMaxGridEntries);
+ }
+
+ private void updateUiWithThumbnails(Map<String, ThumbnailInfo> thumbnails) {
+ if (mGridAdapter != null) {
+ mGridAdapter.updateThumbnails(thumbnails);
+ }
+
+ // Once thumbnails have finished loading, the UI is ready. Reset
+ // Gecko to normal priority.
+ ThreadUtils.resetGeckoPriority();
+ }
+
+ private static class TopSitesLoader extends SimpleCursorLoader {
+ // Max number of search results.
+ private static final int SEARCH_LIMIT = 30;
+ private static final String TELEMETRY_HISTOGRAM_LOAD_CURSOR = "FENNEC_TOPSITES_LOADER_TIME_MS";
+ private final BrowserDB mDB;
+ private final int mMaxGridEntries;
+
+ public TopSitesLoader(Context context) {
+ super(context);
+ mMaxGridEntries = context.getResources().getInteger(R.integer.number_of_top_sites);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Cursor loadCursor() {
+ final long start = SystemClock.uptimeMillis();
+ final Cursor cursor = mDB.getTopSites(getContext().getContentResolver(), mMaxGridEntries, SEARCH_LIMIT);
+ final long end = SystemClock.uptimeMillis();
+ final long took = end - start;
+ Telemetry.addToHistogram(TELEMETRY_HISTOGRAM_LOAD_CURSOR, (int) Math.min(took, Integer.MAX_VALUE));
+ return cursor;
+ }
+ }
+
+ private class VisitedAdapter extends CursorAdapter {
+ public VisitedAdapter(Context context, Cursor cursor) {
+ super(context, cursor, 0);
+ }
+
+ @Override
+ public int getCount() {
+ return Math.max(0, super.getCount() - mMaxGridEntries);
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return super.getItem(position + mMaxGridEntries);
+ }
+
+ /**
+ * We have to override default getItemId implementation, since for a given position, it returns
+ * value of the _id column. In our case _id is always 0 (see Combined view).
+ */
+ @Override
+ public long getItemId(int position) {
+ final int adjustedPosition = position + mMaxGridEntries;
+ final Cursor cursor = getCursor();
+
+ cursor.moveToPosition(adjustedPosition);
+ return getItemIdForTopSitesCursor(cursor);
+ }
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ final int position = cursor.getPosition();
+ cursor.moveToPosition(position + mMaxGridEntries);
+
+ final TwoLinePageRow row = (TwoLinePageRow) view;
+ row.updateFromCursor(cursor);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return LayoutInflater.from(context).inflate(R.layout.bookmark_item_row, parent, false);
+ }
+ }
+
+ public class TopSitesGridAdapter extends CursorAdapter {
+ private final BrowserDB mDB;
+ // Cache to store the thumbnails.
+ // Ensure that this is only accessed from the UI thread.
+ private Map<String, ThumbnailInfo> mThumbnailInfos;
+
+ public TopSitesGridAdapter(Context context, Cursor cursor) {
+ super(context, cursor, 0);
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public int getCount() {
+ return Math.min(mMaxGridEntries, super.getCount());
+ }
+
+ @Override
+ protected void onContentChanged() {
+ // Don't do anything. We don't want to regenerate every time
+ // our database is updated.
+ return;
+ }
+
+ /**
+ * Update the thumbnails returned by the db.
+ *
+ * @param thumbnails A map of urls and their thumbnail bitmaps.
+ */
+ public void updateThumbnails(Map<String, ThumbnailInfo> thumbnails) {
+ mThumbnailInfos = thumbnails;
+
+ final int count = mGrid.getChildCount();
+ for (int i = 0; i < count; i++) {
+ TopSitesGridItemView gridItem = (TopSitesGridItemView) mGrid.getChildAt(i);
+
+ // All the views have already got their initial state at this point.
+ // This will force each view to load favicons for the missing
+ // thumbnails if necessary.
+ gridItem.markAsDirty();
+ }
+
+ notifyDataSetChanged();
+ }
+
+ /**
+ * We have to override default getItemId implementation, since for a given position, it returns
+ * value of the _id column. In our case _id is always 0 (see Combined view).
+ */
+ @Override
+ public long getItemId(int position) {
+ final Cursor cursor = getCursor();
+ cursor.moveToPosition(position);
+
+ return getItemIdForTopSitesCursor(cursor);
+ }
+
+ @Override
+ public void bindView(View bindView, Context context, Cursor cursor) {
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.URL));
+ final String title = cursor.getString(cursor.getColumnIndexOrThrow(TopSites.TITLE));
+ final int type = cursor.getInt(cursor.getColumnIndexOrThrow(TopSites.TYPE));
+
+ final TopSitesGridItemView view = (TopSitesGridItemView) bindView;
+
+ // If there is no url, then show "add bookmark".
+ if (type == TopSites.TYPE_BLANK) {
+ view.blankOut();
+ return;
+ }
+
+ // Show the thumbnail, if any.
+ ThumbnailInfo thumbnail = (mThumbnailInfos != null ? mThumbnailInfos.get(url) : null);
+
+ // Debounce bindView calls to avoid redundant redraws and favicon
+ // fetches.
+ final boolean updated = view.updateState(title, url, type, thumbnail);
+
+ // Thumbnails are delivered late, so we can't short-circuit any
+ // sooner than this. But we can avoid a duplicate favicon
+ // fetch...
+ if (!updated) {
+ debug("bindView called twice for same values; short-circuiting.");
+ return;
+ }
+
+ // Make sure we query suggested images without the user-entered wrapper.
+ final String decodedUrl = StringUtils.decodeUserEnteredUrl(url);
+
+ // Suggested images have precedence over thumbnails, no need to wait
+ // for them to be loaded. See: CursorLoaderCallbacks.onLoadFinished()
+ final String imageUrl = mDB.getSuggestedImageUrlForUrl(decodedUrl);
+ if (!TextUtils.isEmpty(imageUrl)) {
+ final int bgColor = mDB.getSuggestedBackgroundColorForUrl(decodedUrl);
+ view.displayThumbnail(imageUrl, bgColor);
+ return;
+ }
+
+ // If thumbnails are still being loaded, don't try to load favicons
+ // just yet. If we sent in a thumbnail, we're done now.
+ if (mThumbnailInfos == null || thumbnail != null) {
+ return;
+ }
+
+ view.loadFavicon(url);
+ }
+
+ @Override
+ public View newView(Context context, Cursor cursor, ViewGroup parent) {
+ return new TopSitesGridItemView(context);
+ }
+ }
+
+ private class CursorLoaderCallbacks implements LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ trace("Creating TopSitesLoader: " + id);
+ return new TopSitesLoader(getActivity());
+ }
+
+ /**
+ * This method is called *twice* in some circumstances.
+ *
+ * If you try to avoid that through some kind of boolean flag,
+ * sometimes (e.g., returning to the activity) you'll *not* be called
+ * twice, and thus you'll never draw thumbnails.
+ *
+ * The root cause is TopSitesLoader.loadCursor being called twice.
+ * Why that is... dunno.
+ */
+ public void onLoadFinished(Loader<Cursor> loader, Cursor c) {
+ debug("onLoadFinished: " + c.getCount() + " rows.");
+
+ mListAdapter.swapCursor(c);
+ mGridAdapter.swapCursor(c);
+ updateUiFromCursor(c);
+
+ final int col = c.getColumnIndexOrThrow(TopSites.URL);
+
+ // Load the thumbnails.
+ // Even though the cursor we're given is supposed to be fresh,
+ // we getIcon a bad first value unless we reset its position.
+ // Using move(-1) and moveToNext() doesn't work correctly under
+ // rotation, so we use moveToFirst.
+ if (!c.moveToFirst()) {
+ return;
+ }
+
+ final ArrayList<String> urls = new ArrayList<String>();
+ int i = 1;
+ do {
+ final String url = c.getString(col);
+
+ // Only try to fetch thumbnails for non-empty URLs that
+ // don't have an associated suggested image URL.
+ final GeckoProfile profile = GeckoProfile.get(getActivity());
+ if (TextUtils.isEmpty(url) || BrowserDB.from(profile).hasSuggestedImageUrl(url)) {
+ continue;
+ }
+
+ urls.add(url);
+ } while (i++ < mMaxGridEntries && c.moveToNext());
+
+ if (urls.isEmpty()) {
+ // Short-circuit empty results to the UI.
+ updateUiWithThumbnails(new HashMap<String, ThumbnailInfo>());
+ return;
+ }
+
+ Bundle bundle = new Bundle();
+ bundle.putStringArrayList(THUMBNAILS_URLS_KEY, urls);
+ getLoaderManager().restartLoader(LOADER_ID_THUMBNAILS, bundle, mThumbnailsLoaderCallbacks);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (mListAdapter != null) {
+ mListAdapter.swapCursor(null);
+ }
+
+ if (mGridAdapter != null) {
+ mGridAdapter.swapCursor(null);
+ }
+ }
+ }
+
+ static class ThumbnailInfo {
+ public final Bitmap bitmap;
+ public final String imageUrl;
+ public final int bgColor;
+
+ public ThumbnailInfo(final Bitmap bitmap) {
+ this.bitmap = bitmap;
+ this.imageUrl = null;
+ this.bgColor = Color.TRANSPARENT;
+ }
+
+ public ThumbnailInfo(final String imageUrl, final int bgColor) {
+ this.bitmap = null;
+ this.imageUrl = imageUrl;
+ this.bgColor = bgColor;
+ }
+
+ public static ThumbnailInfo fromMetadata(final Map<String, Object> data) {
+ if (data == null) {
+ return null;
+ }
+
+ final String imageUrl = (String) data.get(TILE_IMAGE_URL_COLUMN);
+ if (imageUrl == null) {
+ return null;
+ }
+
+ int bgColor = Color.WHITE;
+ final String colorString = (String) data.get(TILE_COLOR_COLUMN);
+ try {
+ bgColor = Color.parseColor(colorString);
+ } catch (Exception ex) {
+ }
+
+ return new ThumbnailInfo(imageUrl, bgColor);
+ }
+ }
+
+ /**
+ * An AsyncTaskLoader to load the thumbnails from a cursor.
+ */
+ static class ThumbnailsLoader extends AsyncTaskLoader<Map<String, ThumbnailInfo>> {
+ private final BrowserDB mDB;
+ private Map<String, ThumbnailInfo> mThumbnailInfos;
+ private final ArrayList<String> mUrls;
+
+ private static final List<String> COLUMNS;
+ static {
+ final ArrayList<String> tempColumns = new ArrayList<>(2);
+ tempColumns.add(TILE_IMAGE_URL_COLUMN);
+ tempColumns.add(TILE_COLOR_COLUMN);
+ COLUMNS = Collections.unmodifiableList(tempColumns);
+ }
+
+ public ThumbnailsLoader(Context context, ArrayList<String> urls) {
+ super(context);
+ mUrls = urls;
+ mDB = BrowserDB.from(context);
+ }
+
+ @Override
+ public Map<String, ThumbnailInfo> loadInBackground() {
+ final Map<String, ThumbnailInfo> thumbnails = new HashMap<String, ThumbnailInfo>();
+ if (mUrls == null || mUrls.size() == 0) {
+ return thumbnails;
+ }
+
+ // We need to query metadata based on the URL without any refs, hence we create a new
+ // mapping and list of these URLs (we need to preserve the original URL for display purposes)
+ final Map<String, String> queryURLs = new HashMap<>();
+ for (final String pageURL : mUrls) {
+ queryURLs.put(pageURL, StringUtils.stripRef(pageURL));
+ }
+
+ // Query the DB for tile images.
+ final ContentResolver cr = getContext().getContentResolver();
+ // Use the stripped URLs for querying the DB
+ final Map<String, Map<String, Object>> metadata = mDB.getURLMetadata().getForURLs(cr, queryURLs.values(), COLUMNS);
+
+ // Keep a list of urls that don't have tiles images. We'll use thumbnails for them instead.
+ final List<String> thumbnailUrls = new ArrayList<String>();
+ for (final String pageURL : mUrls) {
+ final String queryURL = queryURLs.get(pageURL);
+
+ ThumbnailInfo info = ThumbnailInfo.fromMetadata(metadata.get(queryURL));
+ if (info == null) {
+ // If we didn't find metadata, we'll look for a thumbnail for this url.
+ thumbnailUrls.add(pageURL);
+ continue;
+ }
+
+ thumbnails.put(pageURL, info);
+ }
+
+ if (thumbnailUrls.size() == 0) {
+ return thumbnails;
+ }
+
+ // Query the DB for tile thumbnails.
+ final Cursor cursor = mDB.getThumbnailsForUrls(cr, thumbnailUrls);
+ if (cursor == null) {
+ return thumbnails;
+ }
+
+ try {
+ final int urlIndex = cursor.getColumnIndexOrThrow(Thumbnails.URL);
+ final int dataIndex = cursor.getColumnIndexOrThrow(Thumbnails.DATA);
+
+ while (cursor.moveToNext()) {
+ String url = cursor.getString(urlIndex);
+
+ // This should never be null, but if it is...
+ final byte[] b = cursor.getBlob(dataIndex);
+ if (b == null) {
+ continue;
+ }
+
+ final Bitmap bitmap = BitmapUtils.decodeByteArray(b);
+
+ // Our thumbnails are never null, so if we getIcon a null decoded
+ // bitmap, it's because we hit an OOM or some other disaster.
+ // Give up immediately rather than hammering on.
+ if (bitmap == null) {
+ Log.w(LOGTAG, "Aborting thumbnail load; decode failed.");
+ break;
+ }
+
+ thumbnails.put(url, new ThumbnailInfo(bitmap));
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return thumbnails;
+ }
+
+ @Override
+ public void deliverResult(Map<String, ThumbnailInfo> thumbnails) {
+ if (isReset()) {
+ mThumbnailInfos = null;
+ return;
+ }
+
+ mThumbnailInfos = thumbnails;
+
+ if (isStarted()) {
+ super.deliverResult(thumbnails);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mThumbnailInfos != null) {
+ deliverResult(mThumbnailInfos);
+ }
+
+ if (takeContentChanged() || mThumbnailInfos == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ public void onCanceled(Map<String, ThumbnailInfo> thumbnails) {
+ mThumbnailInfos = null;
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+
+ // Ensure the loader is stopped.
+ onStopLoading();
+
+ mThumbnailInfos = null;
+ }
+ }
+
+ /**
+ * Loader callbacks for the thumbnails on TopSitesGridView.
+ */
+ private class ThumbnailsLoaderCallbacks implements LoaderCallbacks<Map<String, ThumbnailInfo>> {
+ @Override
+ public Loader<Map<String, ThumbnailInfo>> onCreateLoader(int id, Bundle args) {
+ return new ThumbnailsLoader(getActivity(), args.getStringArrayList(THUMBNAILS_URLS_KEY));
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Map<String, ThumbnailInfo>> loader, Map<String, ThumbnailInfo> thumbnails) {
+ updateUiWithThumbnails(thumbnails);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Map<String, ThumbnailInfo>> loader) {
+ if (mGridAdapter != null) {
+ mGridAdapter.updateThumbnails(null);
+ }
+ }
+ }
+
+ /**
+ * We are trying to return stable IDs so that Android can recycle views appropriately:
+ * - If we have a history ID then we return it
+ * - If we only have a bookmark ID then we negate it and return it. We negate it in order
+ * to avoid clashing/conflicting with history IDs.
+ *
+ * @param cursorInPosition Cursor already moved to position for which we're getting a stable ID
+ * @return Stable ID for a given cursor
+ */
+ private static long getItemIdForTopSitesCursor(final Cursor cursorInPosition) {
+ final int historyIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.HISTORY_ID);
+ final long historyId = cursorInPosition.getLong(historyIdCol);
+ if (historyId != 0) {
+ return historyId;
+ }
+
+ final int bookmarkIdCol = cursorInPosition.getColumnIndexOrThrow(TopSites.BOOKMARK_ID);
+ final long bookmarkId = cursorInPosition.getLong(bookmarkIdCol);
+ return -1 * bookmarkId;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java
new file mode 100644
index 000000000..dd45014b0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TopSitesThumbnailView.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import org.mozilla.gecko.widget.CropImageView;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+/**
+ * A width constrained ImageView to show thumbnails of top and pinned sites.
+ */
+public class TopSitesThumbnailView extends CropImageView {
+ private static final String LOGTAG = "GeckoTopSitesThumbnailView";
+
+ // 27.34% opacity filter for the dominant color.
+ private static final int COLOR_FILTER = 0x46FFFFFF;
+
+ // Default filter color for "Add a bookmark" views.
+ private final int mDefaultColor = ContextCompat.getColor(getContext(), R.color.top_site_default);
+
+ // Stroke width for the border.
+ private final float mStrokeWidth = getResources().getDisplayMetrics().density * 2;
+
+ // Paint for drawing the border.
+ private final Paint mBorderPaint;
+
+ public TopSitesThumbnailView(Context context) {
+ this(context, null);
+
+ // A border will be drawn if needed.
+ setWillNotDraw(false);
+ }
+
+ public TopSitesThumbnailView(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.topSitesThumbnailViewStyle);
+ }
+
+ public TopSitesThumbnailView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ // Initialize the border paint.
+ final Resources res = getResources();
+ mBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ mBorderPaint.setColor(ContextCompat.getColor(context, R.color.top_site_border));
+ mBorderPaint.setStyle(Paint.Style.STROKE);
+ }
+
+ @Override
+ protected float getAspectRatio() {
+ return ThumbnailHelper.TOP_SITES_THUMBNAIL_ASPECT_RATIO;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (getBackground() == null) {
+ mBorderPaint.setStrokeWidth(mStrokeWidth);
+ canvas.drawRect(0, 0, getWidth(), getHeight(), mBorderPaint);
+ }
+ }
+
+ /**
+ * Sets the background color with a filter to reduce the color opacity.
+ *
+ * @param color the color filter to apply over the drawable.
+ */
+ public void setBackgroundColorWithOpacityFilter(int color) {
+ setBackgroundColor(color & COLOR_FILTER);
+ }
+
+ /**
+ * Sets the background to a Drawable by applying the specified color as a filter.
+ *
+ * @param color the color filter to apply over the drawable.
+ */
+ @Override
+ public void setBackgroundColor(int color) {
+ if (color == 0) {
+ color = mDefaultColor;
+ }
+
+ Drawable drawable = getResources().getDrawable(R.drawable.top_sites_thumbnail_bg);
+ drawable.setColorFilter(color, Mode.SRC_ATOP);
+ setBackgroundDrawable(drawable);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java
new file mode 100644
index 000000000..68eb8daa5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/TwoLinePageRow.java
@@ -0,0 +1,324 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home;
+
+import java.lang.ref.WeakReference;
+import java.util.concurrent.Future;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Combined;
+import org.mozilla.gecko.db.BrowserContract.URLColumns;
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.reader.SavedReaderViewHelper;
+import org.mozilla.gecko.widget.FaviconView;
+
+public class TwoLinePageRow extends LinearLayout
+ implements Tabs.OnTabsChangedListener {
+
+ protected static final int NO_ICON = 0;
+
+ private final TextView mTitle;
+ private final TextView mUrl;
+ private final ImageView mStatusIcon;
+
+ private int mSwitchToTabIconId;
+
+ private final FaviconView mFavicon;
+ private Future<IconResponse> mOngoingIconLoad;
+
+ private boolean mShowIcons;
+
+ // The URL for the page corresponding to this view.
+ private String mPageUrl;
+
+ private boolean mHasReaderCacheItem;
+
+ public TwoLinePageRow(Context context) {
+ this(context, null);
+ }
+
+ public TwoLinePageRow(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setGravity(Gravity.CENTER_VERTICAL);
+
+ LayoutInflater.from(context).inflate(R.layout.two_line_page_row, this);
+ // Merge layouts lose their padding, so set it dynamically.
+ setPadding(0, 0, (int) getResources().getDimension(R.dimen.page_row_edge_padding), 0);
+
+ mTitle = (TextView) findViewById(R.id.title);
+ mUrl = (TextView) findViewById(R.id.url);
+ mStatusIcon = (ImageView) findViewById(R.id.status_icon_bookmark);
+
+ mSwitchToTabIconId = NO_ICON;
+ mShowIcons = true;
+
+ mFavicon = (FaviconView) findViewById(R.id.icon);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ // Tabs' listener array is safe to modify during use: its
+ // iteration pattern is based on snapshots.
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ /**
+ * Update the row in response to a tab change event.
+ * <p>
+ * This method is always invoked on the UI thread.
+ */
+ @Override
+ public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) {
+ // Carefully check if this tab event is relevant to this row.
+ final String pageUrl = mPageUrl;
+ if (pageUrl == null) {
+ return;
+ }
+ if (tab == null) {
+ return;
+ }
+
+ // Return early if the page URL doesn't match the current tab URL,
+ // or the old tab URL.
+ // data is an empty String for ADDED/CLOSED, and contains the previous/old URL during
+ // LOCATION_CHANGE (the new URL is retrieved using tab.getURL()).
+ // tabURL and data may be about:reader URLs if the current or old tab page was a reader view
+ // page, however pageUrl will always be a plain URL (i.e. we only add about:reader when opening
+ // a reader view bookmark, at all other times it's a normal bookmark with normal URL).
+ final String tabUrl = tab.getURL();
+ if (!pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(tabUrl)) &&
+ !pageUrl.equals(ReaderModeUtils.stripAboutReaderUrl(data))) {
+ return;
+ }
+
+ // Note: we *might* need to update the display status (i.e. switch-to-tab icon/label) if
+ // a matching tab has been opened/closed/switched to a different page. updateDisplayedUrl() will
+ // determine the changes (if any) that actually need to be made. A tab change with a matching URL
+ // does not imply that any changes are needed - e.g. if a given URL is already open in one tab, and
+ // is also opened in a second tab, the switch-to-tab status doesn't change, closing 1 of 2 tabs with a URL
+ // similarly doesn't change the switch-to-tab display, etc. (However closing the last tab for
+ // a given URL does require a status change, as does opening the first tab with that URL.)
+ switch (msg) {
+ case ADDED:
+ case CLOSED:
+ case LOCATION_CHANGE:
+ updateDisplayedUrl();
+ break;
+ default:
+ break;
+ }
+ }
+
+ private void setTitle(String text) {
+ mTitle.setText(text);
+ }
+
+ protected void setUrl(String text) {
+ mUrl.setText(text);
+ }
+
+ protected void setUrl(int stringId) {
+ mUrl.setText(stringId);
+ }
+
+ protected String getUrl() {
+ return mPageUrl;
+ }
+
+ protected void setSwitchToTabIcon(int iconId) {
+ if (mSwitchToTabIconId == iconId) {
+ return;
+ }
+
+ mSwitchToTabIconId = iconId;
+ mUrl.setCompoundDrawablesWithIntrinsicBounds(mSwitchToTabIconId, 0, 0, 0);
+ }
+
+ private void updateStatusIcon(boolean isBookmark, boolean isReaderItem) {
+ if (isReaderItem) {
+ mStatusIcon.setImageResource(R.drawable.status_icon_readercache);
+ } else if (isBookmark) {
+ mStatusIcon.setImageResource(R.drawable.star_blue);
+ }
+
+ if (mShowIcons && (isBookmark || isReaderItem)) {
+ mStatusIcon.setVisibility(View.VISIBLE);
+ } else if (mShowIcons) {
+ // We use INVISIBLE to have consistent padding for our items. This means text/URLs
+ // fade consistently in the same location, regardless of them being bookmarked.
+ mStatusIcon.setVisibility(View.INVISIBLE);
+ } else {
+ mStatusIcon.setVisibility(View.GONE);
+ }
+
+ }
+
+ /**
+ * Stores the page URL, so that we can use it to replace "Switch to tab" if the open
+ * tab changes or is closed.
+ */
+ private void updateDisplayedUrl(String url, boolean hasReaderCacheItem) {
+ mPageUrl = url;
+ mHasReaderCacheItem = hasReaderCacheItem;
+ updateDisplayedUrl();
+ }
+
+ /**
+ * Replaces the page URL with "Switch to tab" if there is already a tab open with that URL.
+ * Only looks for tabs that are either private or non-private, depending on the current
+ * selected tab.
+ */
+ protected void updateDisplayedUrl() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ final boolean isPrivate = (selectedTab != null) && (selectedTab.isPrivate());
+
+ // We always want to display the underlying page url, however for readermode pages
+ // we navigate to the about:reader equivalent, hence we need to use that url when finding
+ // existing tabs
+ final String navigationUrl = mHasReaderCacheItem ? ReaderModeUtils.getAboutReaderForUrl(mPageUrl) : mPageUrl;
+ Tab tab = Tabs.getInstance().getFirstTabForUrl(navigationUrl, isPrivate);
+
+
+ if (!mShowIcons || tab == null) {
+ setUrl(mPageUrl);
+ setSwitchToTabIcon(NO_ICON);
+ } else {
+ setUrl(R.string.switch_to_tab);
+ setSwitchToTabIcon(R.drawable.ic_url_bar_tab);
+ }
+ }
+
+ public void setShowIcons(boolean showIcons) {
+ mShowIcons = showIcons;
+ }
+
+ /**
+ * Update the data displayed by this row.
+ * <p>
+ * This method must be invoked on the UI thread.
+ *
+ * @param title to display.
+ * @param url to display.
+ */
+ public void update(String title, String url) {
+ update(title, url, 0, false);
+ }
+
+ protected void update(String title, String url, long bookmarkId, boolean hasReaderCacheItem) {
+ if (mShowIcons) {
+ // The bookmark id will be 0 (null in database) when the url
+ // is not a bookmark and negative for 'fake' bookmarks.
+ final boolean isBookmark = bookmarkId > 0;
+
+ updateStatusIcon(isBookmark, hasReaderCacheItem);
+ } else {
+ updateStatusIcon(false, false);
+ }
+
+ // Use the URL instead of an empty title for consistency with the normal URL
+ // bar view - this is the equivalent of getDisplayTitle() in Tab.java
+ setTitle(TextUtils.isEmpty(title) ? url : title);
+
+ // No point updating the below things if URL has not changed. Prevents evil Favicon flicker.
+ if (url.equals(mPageUrl)) {
+ return;
+ }
+
+ // Blank the Favicon, so we don't show the wrong Favicon if we scroll and miss DB.
+ mFavicon.clearImage();
+
+ if (mOngoingIconLoad != null) {
+ mOngoingIconLoad.cancel(true);
+ }
+
+ // Displayed RecentTabsPanel URLs may refer to pages opened in reader mode, so we
+ // remove the about:reader prefix to ensure the Favicon loads properly.
+ final String pageURL = ReaderModeUtils.stripAboutReaderUrl(url);
+
+ if (TextUtils.isEmpty(pageURL)) {
+ // If url is empty, display the item as-is but do not load an icon if we do not have a page URL (bug 1310622)
+ } else if (bookmarkId < BrowserContract.Bookmarks.FAKE_PARTNER_BOOKMARKS_START) {
+ mOngoingIconLoad = Icons.with(getContext())
+ .pageUrl(pageURL)
+ .skipNetwork()
+ .privileged(true)
+ .icon(IconDescriptor.createGenericIcon(
+ PartnerBookmarksProviderProxy.getUriForIcon(getContext(), bookmarkId).toString()))
+ .build()
+ .execute(mFavicon.createIconCallback());
+ } else {
+ mOngoingIconLoad = Icons.with(getContext())
+ .pageUrl(pageURL)
+ .skipNetwork()
+ .build()
+ .execute(mFavicon.createIconCallback());
+
+ }
+
+ updateDisplayedUrl(url, hasReaderCacheItem);
+ }
+
+ /**
+ * Update the data displayed by this row.
+ * <p>
+ * This method must be invoked on the UI thread.
+ *
+ * @param cursor to extract data from.
+ */
+ public void updateFromCursor(Cursor cursor) {
+ if (cursor == null) {
+ return;
+ }
+
+ int titleIndex = cursor.getColumnIndexOrThrow(URLColumns.TITLE);
+ final String title = cursor.getString(titleIndex);
+
+ int urlIndex = cursor.getColumnIndexOrThrow(URLColumns.URL);
+ final String url = cursor.getString(urlIndex);
+
+ final long bookmarkId;
+ final int bookmarkIdIndex = cursor.getColumnIndex(Combined.BOOKMARK_ID);
+ if (bookmarkIdIndex != -1) {
+ bookmarkId = cursor.getLong(bookmarkIdIndex);
+ } else {
+ bookmarkId = 0;
+ }
+
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(getContext());
+ final boolean hasReaderCacheItem = rch.isURLCached(url);
+
+ update(title, url, bookmarkId, hasReaderCacheItem);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
new file mode 100644
index 000000000..ef0c105d3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStream.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+ package org.mozilla.gecko.home.activitystream;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v4.app.LoaderManager;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.content.Loader;
+import android.support.v4.graphics.ColorUtils;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.TypedValue;
+import android.widget.FrameLayout;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
+import org.mozilla.gecko.util.ContextUtils;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+public class ActivityStream extends FrameLayout {
+ private final StreamRecyclerAdapter adapter;
+
+ private static final int LOADER_ID_HIGHLIGHTS = 0;
+ private static final int LOADER_ID_TOPSITES = 1;
+
+ private static final int MINIMUM_TILES = 4;
+ private static final int MAXIMUM_TILES = 6;
+
+ private int desiredTileWidth;
+ private int desiredTilesHeight;
+ private int tileMargin;
+
+ public ActivityStream(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setBackgroundColor(ContextCompat.getColor(context, R.color.about_page_header_grey));
+
+ inflate(context, R.layout.as_content, this);
+
+ adapter = new StreamRecyclerAdapter();
+
+ RecyclerView rv = (RecyclerView) findViewById(R.id.activity_stream_main_recyclerview);
+
+ rv.setAdapter(adapter);
+ rv.setLayoutManager(new LinearLayoutManager(getContext()));
+ rv.setHasFixedSize(true);
+
+ RecyclerViewClickSupport.addTo(rv)
+ .setOnItemClickListener(adapter);
+
+ final Resources resources = getResources();
+ desiredTileWidth = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_width);
+ desiredTilesHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_desired_tile_height);
+ tileMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+ }
+
+ void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ adapter.setOnUrlOpenListeners(onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ public void load(LoaderManager lm) {
+ CursorLoaderCallbacks callbacks = new CursorLoaderCallbacks();
+
+ lm.initLoader(LOADER_ID_HIGHLIGHTS, null, callbacks);
+ lm.initLoader(LOADER_ID_TOPSITES, null, callbacks);
+ }
+
+ public void unload() {
+ adapter.swapHighlightsCursor(null);
+ adapter.swapTopSitesCursor(null);
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ int tiles = (w - tileMargin) / (desiredTileWidth + tileMargin);
+
+ if (tiles < MINIMUM_TILES) {
+ tiles = MINIMUM_TILES;
+
+ setPadding(0, 0, 0, 0);
+ } else if (tiles > MAXIMUM_TILES) {
+ tiles = MAXIMUM_TILES;
+
+ // Use the remaining space as padding
+ int needed = tiles * (desiredTileWidth + tileMargin) + tileMargin;
+ int padding = (w - needed) / 2;
+ w = needed;
+
+ setPadding(padding, 0, padding, 0);
+ } else {
+ setPadding(0, 0, 0, 0);
+ }
+
+ final float ratio = (float) desiredTilesHeight / (float) desiredTileWidth;
+ final int tilesWidth = (w - (tiles * tileMargin) - tileMargin) / tiles;
+ final int tilesHeight = (int) (ratio * tilesWidth);
+
+ adapter.setTileSize(tiles, tilesWidth, tilesHeight);
+ }
+
+ private class CursorLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> {
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ final Context context = getContext();
+ if (id == LOADER_ID_HIGHLIGHTS) {
+ return BrowserDB.from(context).getHighlights(context, 10);
+ } else if (id == LOADER_ID_TOPSITES) {
+ return BrowserDB.from(context).getActivityStreamTopSites(
+ context, TopSitesPagerAdapter.PAGES * MAXIMUM_TILES);
+ } else {
+ throw new IllegalArgumentException("Can't handle loader id " + id);
+ }
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+ adapter.swapHighlightsCursor(data);
+ } else if (loader.getId() == LOADER_ID_TOPSITES) {
+ adapter.swapTopSitesCursor(data);
+ }
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (loader.getId() == LOADER_ID_HIGHLIGHTS) {
+ adapter.swapHighlightsCursor(null);
+ } else if (loader.getId() == LOADER_ID_TOPSITES) {
+ adapter.swapTopSitesCursor(null);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java
new file mode 100644
index 000000000..09f6705d7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeFragment.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomeFragment;
+
+/**
+ * Simple wrapper around the ActivityStream view that allows embedding as a HomePager panel.
+ */
+public class ActivityStreamHomeFragment
+ extends HomeFragment {
+ private ActivityStream activityStream;
+
+ @Override
+ protected void load() {
+ activityStream.load(getLoaderManager());
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ if (activityStream == null) {
+ activityStream = (ActivityStream) inflater.inflate(R.layout.activity_stream, container, false);
+ activityStream.setOnUrlOpenListeners(mUrlOpenListener, mUrlOpenInBackgroundListener);
+ }
+
+ return activityStream;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java
new file mode 100644
index 000000000..4decc8218
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/ActivityStreamHomeScreen.java
@@ -0,0 +1,73 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.LoaderManager;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.home.HomeBanner;
+import org.mozilla.gecko.home.HomeFragment;
+import org.mozilla.gecko.home.HomeScreen;
+
+/**
+ * HomeScreen implementation that displays ActivityStream.
+ */
+public class ActivityStreamHomeScreen
+ extends ActivityStream
+ implements HomeScreen {
+
+ private boolean visible = false;
+
+ public ActivityStreamHomeScreen(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public void onToolbarFocusChange(boolean hasFocus) {
+
+ }
+
+ @Override
+ public void showPanel(String panelId, Bundle restoreData) {
+
+ }
+
+ @Override
+ public void setOnPanelChangeListener(OnPanelChangeListener listener) {
+
+ }
+
+ @Override
+ public void setPanelStateChangeListener(HomeFragment.PanelStateChangeListener listener) {
+
+ }
+
+ @Override
+ public void setBanner(HomeBanner banner) {
+
+ }
+
+ @Override
+ public void load(LoaderManager lm, FragmentManager fm, String panelId, Bundle restoreData,
+ PropertyAnimator animator) {
+ super.load(lm);
+ visible = true;
+ }
+
+ @Override
+ public void unload() {
+ super.unload();
+ visible = false;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
new file mode 100644
index 000000000..24348dfe0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
@@ -0,0 +1,196 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream;
+
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.support.v4.view.ViewPager;
+import android.support.v7.widget.RecyclerView;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream.LabelCallback;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu;
+import org.mozilla.gecko.home.activitystream.topsites.CirclePageIndicator;
+import org.mozilla.gecko.home.activitystream.topsites.TopSitesPagerAdapter;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.ViewUtil;
+import org.mozilla.gecko.util.TouchTargetUtil;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.concurrent.Future;
+
+import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel;
+
+public abstract class StreamItem extends RecyclerView.ViewHolder {
+ public StreamItem(View itemView) {
+ super(itemView);
+ }
+
+ public static class HighlightsTitle extends StreamItem {
+ public static final int LAYOUT_ID = R.layout.activity_stream_main_highlightstitle;
+
+ public HighlightsTitle(View itemView) {
+ super(itemView);
+ }
+ }
+
+ public static class TopPanel extends StreamItem {
+ public static final int LAYOUT_ID = R.layout.activity_stream_main_toppanel;
+
+ private final ViewPager topSitesPager;
+
+ public TopPanel(View itemView, HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(itemView);
+
+ topSitesPager = (ViewPager) itemView.findViewById(R.id.topsites_pager);
+ topSitesPager.setAdapter(new TopSitesPagerAdapter(itemView.getContext(), onUrlOpenListener, onUrlOpenInBackgroundListener));
+
+ CirclePageIndicator indicator = (CirclePageIndicator) itemView.findViewById(R.id.topsites_indicator);
+ indicator.setViewPager(topSitesPager);
+ }
+
+ public void bind(Cursor cursor, int tiles, int tilesWidth, int tilesHeight) {
+ final TopSitesPagerAdapter adapter = (TopSitesPagerAdapter) topSitesPager.getAdapter();
+ adapter.setTilesSize(tiles, tilesWidth, tilesHeight);
+ adapter.swapCursor(cursor);
+
+ final Resources resources = itemView.getResources();
+ final int tilesMargin = resources.getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+ final int textHeight = resources.getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height);
+
+ ViewGroup.LayoutParams layoutParams = topSitesPager.getLayoutParams();
+ layoutParams.height = tilesHeight + tilesMargin + textHeight;
+ topSitesPager.setLayoutParams(layoutParams);
+ }
+ }
+
+ public static class HighlightItem extends StreamItem implements IconCallback {
+ public static final int LAYOUT_ID = R.layout.activity_stream_card_history_item;
+
+ String title;
+ String url;
+
+ final FaviconView vIconView;
+ final TextView vLabel;
+ final TextView vTimeSince;
+ final TextView vSourceView;
+ final TextView vPageView;
+ final ImageView vSourceIconView;
+
+ private Future<IconResponse> ongoingIconLoad;
+ private int tilesMargin;
+
+ public HighlightItem(final View itemView,
+ final HomePager.OnUrlOpenListener onUrlOpenListener,
+ final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(itemView);
+
+ tilesMargin = itemView.getResources().getDimensionPixelSize(R.dimen.activity_stream_base_margin);
+
+ vLabel = (TextView) itemView.findViewById(R.id.card_history_label);
+ vTimeSince = (TextView) itemView.findViewById(R.id.card_history_time_since);
+ vIconView = (FaviconView) itemView.findViewById(R.id.icon);
+ vSourceView = (TextView) itemView.findViewById(R.id.card_history_source);
+ vPageView = (TextView) itemView.findViewById(R.id.page);
+ vSourceIconView = (ImageView) itemView.findViewById(R.id.source_icon);
+
+ final ImageView menuButton = (ImageView) itemView.findViewById(R.id.menu);
+
+ menuButton.setImageDrawable(
+ DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, Color.LTGRAY));
+
+ TouchTargetUtil.ensureTargetHitArea(menuButton, itemView);
+
+ menuButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ActivityStreamContextMenu.show(v.getContext(),
+ menuButton,
+ ActivityStreamContextMenu.MenuMode.HIGHLIGHT,
+ title, url, onUrlOpenListener, onUrlOpenInBackgroundListener,
+ vIconView.getWidth(), vIconView.getHeight());
+ }
+ });
+
+ ViewUtil.enableTouchRipple(menuButton);
+ }
+
+ public void bind(Cursor cursor, int tilesWidth, int tilesHeight) {
+
+ final long time = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Highlights.DATE));
+ final String ago = DateUtils.getRelativeTimeSpanString(time, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, 0).toString();
+
+ title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.History.TITLE));
+ url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+ vLabel.setText(title);
+ vTimeSince.setText(ago);
+
+ ViewGroup.LayoutParams layoutParams = vIconView.getLayoutParams();
+ layoutParams.width = tilesWidth - tilesMargin;
+ layoutParams.height = tilesHeight;
+ vIconView.setLayoutParams(layoutParams);
+
+ updateSource(cursor);
+ updatePage(url);
+
+ if (ongoingIconLoad != null) {
+ ongoingIconLoad.cancel(true);
+ }
+
+ ongoingIconLoad = Icons.with(itemView.getContext())
+ .pageUrl(url)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ private void updateSource(final Cursor cursor) {
+ final boolean isBookmark = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+ final boolean isHistory = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+
+ if (isBookmark) {
+ vSourceView.setText(R.string.activity_stream_highlight_label_bookmarked);
+ vSourceView.setVisibility(View.VISIBLE);
+ vSourceIconView.setImageResource(R.drawable.ic_as_bookmarked);
+ } else if (isHistory) {
+ vSourceView.setText(R.string.activity_stream_highlight_label_visited);
+ vSourceView.setVisibility(View.VISIBLE);
+ vSourceIconView.setImageResource(R.drawable.ic_as_visited);
+ } else {
+ vSourceView.setVisibility(View.INVISIBLE);
+ vSourceIconView.setImageResource(0);
+ }
+
+ vSourceView.setText(vSourceView.getText());
+ }
+
+ private void updatePage(final String url) {
+ extractLabel(itemView.getContext(), url, false, new LabelCallback() {
+ @Override
+ public void onLabelExtracted(String label) {
+ vPageView.setText(TextUtils.isEmpty(label) ? url : label);
+ }
+ });
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ vIconView.updateImage(response);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
new file mode 100644
index 000000000..f7cda2e7f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamRecyclerAdapter.java
@@ -0,0 +1,135 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.home.activitystream;
+
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.StreamItem.HighlightItem;
+import org.mozilla.gecko.home.activitystream.StreamItem.TopPanel;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+public class StreamRecyclerAdapter extends RecyclerView.Adapter<StreamItem> implements RecyclerViewClickSupport.OnItemClickListener {
+ private Cursor highlightsCursor;
+ private Cursor topSitesCursor;
+
+ private HomePager.OnUrlOpenListener onUrlOpenListener;
+ private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+
+ void setOnUrlOpenListeners(HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ public void setTileSize(int tiles, int tilesWidth, int tilesHeight) {
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.tiles = tiles;
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (position == 0) {
+ return TopPanel.LAYOUT_ID;
+ } else if (position == 1) {
+ return StreamItem.HighlightsTitle.LAYOUT_ID;
+ } else {
+ return HighlightItem.LAYOUT_ID;
+ }
+ }
+
+ @Override
+ public StreamItem onCreateViewHolder(ViewGroup parent, final int type) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ if (type == TopPanel.LAYOUT_ID) {
+ return new TopPanel(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener);
+ } else if (type == StreamItem.HighlightsTitle.LAYOUT_ID) {
+ return new StreamItem.HighlightsTitle(inflater.inflate(type, parent, false));
+ } else if (type == HighlightItem.LAYOUT_ID) {
+ return new HighlightItem(inflater.inflate(type, parent, false), onUrlOpenListener, onUrlOpenInBackgroundListener);
+ } else {
+ throw new IllegalStateException("Missing inflation for ViewType " + type);
+ }
+ }
+
+ private int translatePositionToCursor(int position) {
+ if (position == 0) {
+ throw new IllegalArgumentException("Requested cursor position for invalid item");
+ }
+
+ // We have two blank panels at the top, hence remove that to obtain the cursor position
+ return position - 2;
+ }
+
+ @Override
+ public void onBindViewHolder(StreamItem holder, int position) {
+ int type = getItemViewType(position);
+
+ if (type == HighlightItem.LAYOUT_ID) {
+ final int cursorPosition = translatePositionToCursor(position);
+
+ highlightsCursor.moveToPosition(cursorPosition);
+ ((HighlightItem) holder).bind(highlightsCursor, tilesWidth, tilesHeight);
+ } else if (type == TopPanel.LAYOUT_ID) {
+ ((TopPanel) holder).bind(topSitesCursor, tiles, tilesWidth, tilesHeight);
+ }
+ }
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ if (position < 1) {
+ // The header contains top sites and has its own click handling.
+ return;
+ }
+
+ highlightsCursor.moveToPosition(
+ translatePositionToCursor(position));
+
+ final String url = highlightsCursor.getString(
+ highlightsCursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+
+ onUrlOpenListener.onUrlOpen(url, EnumSet.of(HomePager.OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB));
+ }
+
+ @Override
+ public int getItemCount() {
+ final int highlightsCount;
+
+ if (highlightsCursor != null) {
+ highlightsCount = highlightsCursor.getCount();
+ } else {
+ highlightsCount = 0;
+ }
+
+ return highlightsCount + 2;
+ }
+
+ public void swapHighlightsCursor(Cursor cursor) {
+ highlightsCursor = cursor;
+
+ notifyDataSetChanged();
+ }
+
+ public void swapTopSitesCursor(Cursor cursor) {
+ this.topSitesCursor = cursor;
+
+ notifyItemChanged(0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
new file mode 100644
index 000000000..525d3b426
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
@@ -0,0 +1,239 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.menu;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.support.annotation.NonNull;
+import android.support.design.widget.NavigationView;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.IntentHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import java.util.EnumSet;
+
+@RobocopTarget
+public abstract class ActivityStreamContextMenu
+ implements NavigationView.OnNavigationItemSelectedListener {
+
+ public enum MenuMode {
+ HIGHLIGHT,
+ TOPSITE
+ }
+
+ final Context context;
+
+ final String title;
+ final String url;
+
+ final HomePager.OnUrlOpenListener onUrlOpenListener;
+ final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ boolean isAlreadyBookmarked; // default false;
+
+ public abstract MenuItem getItemByID(int id);
+
+ public abstract void show();
+
+ public abstract void dismiss();
+
+ final MenuMode mode;
+
+ /* package-private */ ActivityStreamContextMenu(final Context context,
+ final MenuMode mode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.context = context;
+
+ this.mode = mode;
+
+ this.title = title;
+ this.url = url;
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ /**
+ * Must be called before the menu is shown.
+ * <p/>
+ * Your implementation must be ready to return items from getItemByID() before postInit() is
+ * called, i.e. you should probably inflate your menu items before this call.
+ */
+ protected void postInit() {
+ // Disable "dismiss" for topsites until we have decided on its behaviour for topsites
+ // (currently "dismiss" adds the URL to a highlights-specific blocklist, which the topsites
+ // query has no knowledge of).
+ if (mode == MenuMode.TOPSITE) {
+ final MenuItem dismissItem = getItemByID(R.id.dismiss);
+ dismissItem.setVisible(false);
+ }
+
+ // Disable the bookmark item until we know its bookmark state
+ final MenuItem bookmarkItem = getItemByID(R.id.bookmark);
+ bookmarkItem.setEnabled(false);
+
+ (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ protected Void doInBackground() {
+ isAlreadyBookmarked = BrowserDB.from(context).isBookmark(context.getContentResolver(), url);
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ if (isAlreadyBookmarked) {
+ bookmarkItem.setTitle(R.string.bookmark_remove);
+ }
+
+ bookmarkItem.setEnabled(true);
+ }
+ }).execute();
+
+ // Only show the "remove from history" item if a page actually has history
+ final MenuItem deleteHistoryItem = getItemByID(R.id.delete);
+ deleteHistoryItem.setVisible(false);
+
+ (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ boolean hasHistory;
+
+ @Override
+ protected Void doInBackground() {
+ final Cursor cursor = BrowserDB.from(context).getHistoryForURL(context.getContentResolver(), url);
+ try {
+ if (cursor != null &&
+ cursor.getCount() == 1) {
+ hasHistory = true;
+ } else {
+ hasHistory = false;
+ }
+ } finally {
+ cursor.close();
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ if (hasHistory) {
+ deleteHistoryItem.setVisible(true);
+ }
+ }
+ }).execute();
+ }
+
+
+ @Override
+ public boolean onNavigationItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.share:
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "menu");
+ IntentHelper.openUriExternal(url, "text/plain", "", "", Intent.ACTION_SEND, title, false);
+ break;
+
+ case R.id.bookmark:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final BrowserDB db = BrowserDB.from(context);
+
+ if (isAlreadyBookmarked) {
+ db.removeBookmarksWithURL(context.getContentResolver(), url);
+ } else {
+ db.addBookmark(context.getContentResolver(), title, url);
+ }
+
+ }
+ });
+ break;
+
+ case R.id.copy_url:
+ Clipboard.setText(url);
+ break;
+
+ case R.id.add_homescreen:
+ GeckoAppShell.createShortcut(title, url);
+ break;
+
+ case R.id.open_new_tab:
+ onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.noneOf(HomePager.OnUrlOpenInBackgroundListener.Flags.class));
+ break;
+
+ case R.id.open_new_private_tab:
+ onUrlOpenInBackgroundListener.onUrlOpenInBackground(url, EnumSet.of(HomePager.OnUrlOpenInBackgroundListener.Flags.PRIVATE));
+ break;
+
+ case R.id.dismiss:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ BrowserDB.from(context)
+ .blockActivityStreamSite(context.getContentResolver(),
+ url);
+ }
+ });
+ break;
+
+ case R.id.delete:
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ BrowserDB.from(context)
+ .removeHistoryEntry(context.getContentResolver(),
+ url);
+ }
+ });
+ break;
+
+ default:
+ throw new IllegalArgumentException("Menu item with ID=" + item.getItemId() + " not handled");
+ }
+
+ dismiss();
+ return true;
+ }
+
+
+ @RobocopTarget
+ public static ActivityStreamContextMenu show(Context context,
+ View anchor,
+ final MenuMode menuMode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
+ final int tilesWidth, final int tilesHeight) {
+ final ActivityStreamContextMenu menu;
+
+ if (!HardwareUtils.isTablet()) {
+ menu = new BottomSheetContextMenu(context,
+ menuMode,
+ title, url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener,
+ tilesWidth, tilesHeight);
+ } else {
+ menu = new PopupContextMenu(context,
+ anchor,
+ menuMode,
+ title, url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ menu.show();
+ return menu;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
new file mode 100644
index 000000000..e95867c36
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.menu;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.design.widget.BottomSheetBehavior;
+import android.support.design.widget.BottomSheetDialog;
+import android.support.design.widget.NavigationView;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.widget.FaviconView;
+
+import static org.mozilla.gecko.activitystream.ActivityStream.extractLabel;
+
+/* package-private */ class BottomSheetContextMenu
+ extends ActivityStreamContextMenu {
+
+
+ private final BottomSheetDialog bottomSheetDialog;
+
+ private final NavigationView navigationView;
+
+ public BottomSheetContextMenu(final Context context,
+ final MenuMode mode,
+ final String title, @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
+ final int tilesWidth, final int tilesHeight) {
+
+ super(context,
+ mode,
+ title,
+ url,
+ onUrlOpenListener,
+ onUrlOpenInBackgroundListener);
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ final View content = inflater.inflate(R.layout.activity_stream_contextmenu_bottomsheet, null);
+
+ bottomSheetDialog = new BottomSheetDialog(context);
+ bottomSheetDialog.setContentView(content);
+
+ ((TextView) content.findViewById(R.id.title)).setText(title);
+
+ extractLabel(context, url, false, new ActivityStream.LabelCallback() {
+ public void onLabelExtracted(String label) {
+ ((TextView) content.findViewById(R.id.url)).setText(label);
+ }
+ });
+
+ // Copy layouted parameters from the Highlights / TopSites items to ensure consistency
+ final FaviconView faviconView = (FaviconView) content.findViewById(R.id.icon);
+ ViewGroup.LayoutParams layoutParams = faviconView.getLayoutParams();
+ layoutParams.width = tilesWidth;
+ layoutParams.height = tilesHeight;
+ faviconView.setLayoutParams(layoutParams);
+
+ Icons.with(context)
+ .pageUrl(url)
+ .skipNetwork()
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ faviconView.updateImage(response);
+ }
+ });
+
+ navigationView = (NavigationView) content.findViewById(R.id.menu);
+ navigationView.setNavigationItemSelectedListener(this);
+
+ super.postInit();
+ }
+
+ @Override
+ public MenuItem getItemByID(int id) {
+ return navigationView.getMenu().findItem(id);
+ }
+
+ @Override
+ public void show() {
+ bottomSheetDialog.show();
+ }
+
+ public void dismiss() {
+ bottomSheetDialog.dismiss();
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
new file mode 100644
index 000000000..56615937b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
@@ -0,0 +1,76 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.menu;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.support.annotation.NonNull;
+import android.support.design.widget.NavigationView;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.PopupWindow;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager;
+
+/* package-private */ class PopupContextMenu
+ extends ActivityStreamContextMenu {
+
+ private final PopupWindow popupWindow;
+ private final NavigationView navigationView;
+
+ private final View anchor;
+
+ public PopupContextMenu(final Context context,
+ View anchor,
+ final MenuMode mode,
+ final String title,
+ @NonNull final String url,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(context,
+ mode,
+ title,
+ url,
+ onUrlOpenListener,
+ onUrlOpenInBackgroundListener);
+
+ this.anchor = anchor;
+
+ final LayoutInflater inflater = LayoutInflater.from(context);
+
+ View card = inflater.inflate(R.layout.activity_stream_contextmenu_popupmenu, null);
+ navigationView = (NavigationView) card.findViewById(R.id.menu);
+ navigationView.setNavigationItemSelectedListener(this);
+
+ popupWindow = new PopupWindow(context);
+ popupWindow.setContentView(card);
+ popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ popupWindow.setFocusable(true);
+
+ super.postInit();
+ }
+
+ @Override
+ public MenuItem getItemByID(int id) {
+ return navigationView.getMenu().findItem(id);
+ }
+
+ @Override
+ public void show() {
+ // By default popupWindow follows the pre-material convention of displaying the popup
+ // below a View. We need to shift it over the view:
+ popupWindow.showAsDropDown(anchor,
+ 0,
+ -(anchor.getHeight() + anchor.getPaddingBottom()));
+ }
+
+ public void dismiss() {
+ popupWindow.dismiss();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java
new file mode 100644
index 000000000..096f0c597
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/CirclePageIndicator.java
@@ -0,0 +1,568 @@
+/*
+ * Copyright (C) 2011 Patrik Akerfeldt
+ * Copyright (C) 2011 Jake Wharton
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.drawable.Drawable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.ViewConfigurationCompat;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import org.mozilla.gecko.R;
+
+import static android.graphics.Paint.ANTI_ALIAS_FLAG;
+import static android.widget.LinearLayout.HORIZONTAL;
+import static android.widget.LinearLayout.VERTICAL;
+
+/**
+ * Draws circles (one for each view). The current view position is filled and
+ * others are only stroked.
+ *
+ * This file was imported from Jake Wharton's ViewPagerIndicator library:
+ * https://github.com/JakeWharton/ViewPagerIndicator
+ * It was modified to not extend the PageIndicator interface (as we only use one single Indicator)
+ * implementation, and has had some minor appearance related modifications added alter.
+ */
+public class CirclePageIndicator
+ extends View
+ implements ViewPager.OnPageChangeListener {
+
+ /**
+ * Separation between circles, as a factor of the circle radius. By default CirclePageIndicator
+ * shipped with a separation factor of 3, however we want to be able to tweak this for
+ * ActivityStream.
+ *
+ * If/when we reuse this indicator elsewhere, this should probably become a configurable property.
+ */
+ private static final int SEPARATION_FACTOR = 7;
+
+ private static final int INVALID_POINTER = -1;
+
+ private float mRadius;
+ private final Paint mPaintPageFill = new Paint(ANTI_ALIAS_FLAG);
+ private final Paint mPaintStroke = new Paint(ANTI_ALIAS_FLAG);
+ private final Paint mPaintFill = new Paint(ANTI_ALIAS_FLAG);
+ private ViewPager mViewPager;
+ private ViewPager.OnPageChangeListener mListener;
+ private int mCurrentPage;
+ private int mSnapPage;
+ private float mPageOffset;
+ private int mScrollState;
+ private int mOrientation;
+ private boolean mCentered;
+ private boolean mSnap;
+
+ private int mTouchSlop;
+ private float mLastMotionX = -1;
+ private int mActivePointerId = INVALID_POINTER;
+ private boolean mIsDragging;
+
+
+ public CirclePageIndicator(Context context) {
+ this(context, null);
+ }
+
+ public CirclePageIndicator(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.vpiCirclePageIndicatorStyle);
+ }
+
+ public CirclePageIndicator(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ if (isInEditMode()) return;
+
+ //Load defaults from resources
+ final Resources res = getResources();
+ final int defaultPageColor = res.getColor(R.color.default_circle_indicator_page_color);
+ final int defaultFillColor = res.getColor(R.color.default_circle_indicator_fill_color);
+ final int defaultOrientation = res.getInteger(R.integer.default_circle_indicator_orientation);
+ final int defaultStrokeColor = res.getColor(R.color.default_circle_indicator_stroke_color);
+ final float defaultStrokeWidth = res.getDimension(R.dimen.default_circle_indicator_stroke_width);
+ final float defaultRadius = res.getDimension(R.dimen.default_circle_indicator_radius);
+ final boolean defaultCentered = res.getBoolean(R.bool.default_circle_indicator_centered);
+ final boolean defaultSnap = res.getBoolean(R.bool.default_circle_indicator_snap);
+
+ //Retrieve styles attributes
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CirclePageIndicator, defStyle, 0);
+
+ mCentered = a.getBoolean(R.styleable.CirclePageIndicator_centered, defaultCentered);
+ mOrientation = a.getInt(R.styleable.CirclePageIndicator_android_orientation, defaultOrientation);
+ mPaintPageFill.setStyle(Style.FILL);
+ mPaintPageFill.setColor(a.getColor(R.styleable.CirclePageIndicator_pageColor, defaultPageColor));
+ mPaintStroke.setStyle(Style.STROKE);
+ mPaintStroke.setColor(a.getColor(R.styleable.CirclePageIndicator_strokeColor, defaultStrokeColor));
+ mPaintStroke.setStrokeWidth(a.getDimension(R.styleable.CirclePageIndicator_strokeWidth, defaultStrokeWidth));
+ mPaintFill.setStyle(Style.FILL);
+ mPaintFill.setColor(a.getColor(R.styleable.CirclePageIndicator_fillColor, defaultFillColor));
+ mRadius = a.getDimension(R.styleable.CirclePageIndicator_radius, defaultRadius);
+ mSnap = a.getBoolean(R.styleable.CirclePageIndicator_snap, defaultSnap);
+
+ Drawable background = a.getDrawable(R.styleable.CirclePageIndicator_android_background);
+ if (background != null) {
+ setBackgroundDrawable(background);
+ }
+
+ a.recycle();
+
+ final ViewConfiguration configuration = ViewConfiguration.get(context);
+ mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
+ }
+
+
+ public void setCentered(boolean centered) {
+ mCentered = centered;
+ invalidate();
+ }
+
+ public boolean isCentered() {
+ return mCentered;
+ }
+
+ public void setPageColor(int pageColor) {
+ mPaintPageFill.setColor(pageColor);
+ invalidate();
+ }
+
+ public int getPageColor() {
+ return mPaintPageFill.getColor();
+ }
+
+ public void setFillColor(int fillColor) {
+ mPaintFill.setColor(fillColor);
+ invalidate();
+ }
+
+ public int getFillColor() {
+ return mPaintFill.getColor();
+ }
+
+ public void setOrientation(int orientation) {
+ switch (orientation) {
+ case HORIZONTAL:
+ case VERTICAL:
+ mOrientation = orientation;
+ requestLayout();
+ break;
+
+ default:
+ throw new IllegalArgumentException("Orientation must be either HORIZONTAL or VERTICAL.");
+ }
+ }
+
+ public int getOrientation() {
+ return mOrientation;
+ }
+
+ public void setStrokeColor(int strokeColor) {
+ mPaintStroke.setColor(strokeColor);
+ invalidate();
+ }
+
+ public int getStrokeColor() {
+ return mPaintStroke.getColor();
+ }
+
+ public void setStrokeWidth(float strokeWidth) {
+ mPaintStroke.setStrokeWidth(strokeWidth);
+ invalidate();
+ }
+
+ public float getStrokeWidth() {
+ return mPaintStroke.getStrokeWidth();
+ }
+
+ public void setRadius(float radius) {
+ mRadius = radius;
+ invalidate();
+ }
+
+ public float getRadius() {
+ return mRadius;
+ }
+
+ public void setSnap(boolean snap) {
+ mSnap = snap;
+ invalidate();
+ }
+
+ public boolean isSnap() {
+ return mSnap;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (mViewPager == null) {
+ return;
+ }
+ final int count = mViewPager.getAdapter().getCount();
+ if (count == 0) {
+ return;
+ }
+
+ if (mCurrentPage >= count) {
+ setCurrentItem(count - 1);
+ return;
+ }
+
+ int longSize;
+ int longPaddingBefore;
+ int longPaddingAfter;
+ int shortPaddingBefore;
+ if (mOrientation == HORIZONTAL) {
+ longSize = getWidth();
+ longPaddingBefore = getPaddingLeft();
+ longPaddingAfter = getPaddingRight();
+ shortPaddingBefore = getPaddingTop();
+ } else {
+ longSize = getHeight();
+ longPaddingBefore = getPaddingTop();
+ longPaddingAfter = getPaddingBottom();
+ shortPaddingBefore = getPaddingLeft();
+ }
+
+ final float threeRadius = mRadius * SEPARATION_FACTOR;
+ final float shortOffset = shortPaddingBefore + mRadius;
+ float longOffset = longPaddingBefore + mRadius;
+ if (mCentered) {
+ longOffset += ((longSize - longPaddingBefore - longPaddingAfter) / 2.0f) - ((count * threeRadius) / 2.0f);
+ }
+
+ float dX;
+ float dY;
+
+ float pageFillRadius = mRadius;
+ if (mPaintStroke.getStrokeWidth() > 0) {
+ pageFillRadius -= mPaintStroke.getStrokeWidth() / 2.0f;
+ }
+
+ //Draw stroked circles
+ for (int iLoop = 0; iLoop < count; iLoop++) {
+ float drawLong = longOffset + (iLoop * threeRadius);
+ if (mOrientation == HORIZONTAL) {
+ dX = drawLong;
+ dY = shortOffset;
+ } else {
+ dX = shortOffset;
+ dY = drawLong;
+ }
+ // Only paint fill if not completely transparent
+ if (mPaintPageFill.getAlpha() > 0) {
+ canvas.drawCircle(dX, dY, pageFillRadius, mPaintPageFill);
+ }
+
+ // Only paint stroke if a stroke width was non-zero
+ if (pageFillRadius != mRadius) {
+ canvas.drawCircle(dX, dY, mRadius, mPaintStroke);
+ }
+ }
+
+ //Draw the filled circle according to the current scroll
+ float cx = (mSnap ? mSnapPage : mCurrentPage) * threeRadius;
+ if (!mSnap) {
+ cx += mPageOffset * threeRadius;
+ }
+ if (mOrientation == HORIZONTAL) {
+ dX = longOffset + cx;
+ dY = shortOffset;
+ } else {
+ dX = shortOffset;
+ dY = longOffset + cx;
+ }
+ canvas.drawCircle(dX, dY, mRadius, mPaintFill);
+ }
+
+ public boolean onTouchEvent(android.view.MotionEvent ev) {
+ if (super.onTouchEvent(ev)) {
+ return true;
+ }
+ if ((mViewPager == null) || (mViewPager.getAdapter().getCount() == 0)) {
+ return false;
+ }
+
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ mLastMotionX = ev.getX();
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
+ final float x = MotionEventCompat.getX(ev, activePointerIndex);
+ final float deltaX = x - mLastMotionX;
+
+ if (!mIsDragging) {
+ if (Math.abs(deltaX) > mTouchSlop) {
+ mIsDragging = true;
+ }
+ }
+
+ if (mIsDragging) {
+ mLastMotionX = x;
+ if (mViewPager.isFakeDragging() || mViewPager.beginFakeDrag()) {
+ mViewPager.fakeDragBy(deltaX);
+ }
+ }
+
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (!mIsDragging) {
+ final int count = mViewPager.getAdapter().getCount();
+ final int width = getWidth();
+ final float halfWidth = width / 2f;
+ final float sixthWidth = width / 6f;
+
+ if ((mCurrentPage > 0) && (ev.getX() < halfWidth - sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage - 1);
+ }
+ return true;
+ } else if ((mCurrentPage < count - 1) && (ev.getX() > halfWidth + sixthWidth)) {
+ if (action != MotionEvent.ACTION_CANCEL) {
+ mViewPager.setCurrentItem(mCurrentPage + 1);
+ }
+ return true;
+ }
+ }
+
+ mIsDragging = false;
+ mActivePointerId = INVALID_POINTER;
+ if (mViewPager.isFakeDragging()) mViewPager.endFakeDrag();
+ break;
+
+ case MotionEventCompat.ACTION_POINTER_DOWN: {
+ final int index = MotionEventCompat.getActionIndex(ev);
+ mLastMotionX = MotionEventCompat.getX(ev, index);
+ mActivePointerId = MotionEventCompat.getPointerId(ev, index);
+ break;
+ }
+
+ case MotionEventCompat.ACTION_POINTER_UP:
+ final int pointerIndex = MotionEventCompat.getActionIndex(ev);
+ final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
+ if (pointerId == mActivePointerId) {
+ final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+ mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
+ }
+ mLastMotionX = MotionEventCompat.getX(ev, MotionEventCompat.findPointerIndex(ev, mActivePointerId));
+ break;
+ }
+
+ return true;
+ }
+
+ public void setViewPager(ViewPager view) {
+ if (mViewPager == view) {
+ return;
+ }
+ if (mViewPager != null) {
+ mViewPager.setOnPageChangeListener(null);
+ }
+ if (view.getAdapter() == null) {
+ throw new IllegalStateException("ViewPager does not have adapter instance.");
+ }
+ mViewPager = view;
+ mViewPager.setOnPageChangeListener(this);
+ invalidate();
+ }
+
+ public void setViewPager(ViewPager view, int initialPosition) {
+ setViewPager(view);
+ setCurrentItem(initialPosition);
+ }
+
+ public void setCurrentItem(int item) {
+ if (mViewPager == null) {
+ throw new IllegalStateException("ViewPager has not been bound.");
+ }
+ mViewPager.setCurrentItem(item);
+ mCurrentPage = item;
+ invalidate();
+ }
+
+ public void notifyDataSetChanged() {
+ invalidate();
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ mScrollState = state;
+
+ if (mListener != null) {
+ mListener.onPageScrollStateChanged(state);
+ }
+ }
+
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ mCurrentPage = position;
+ mPageOffset = positionOffset;
+ invalidate();
+
+ if (mListener != null) {
+ mListener.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ if (mSnap || mScrollState == ViewPager.SCROLL_STATE_IDLE) {
+ mCurrentPage = position;
+ mSnapPage = position;
+ invalidate();
+ }
+
+ if (mListener != null) {
+ mListener.onPageSelected(position);
+ }
+ }
+
+ public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) {
+ mListener = listener;
+ }
+
+ /*
+ * (non-Javadoc)
+ *
+ * @see android.view.View#onMeasure(int, int)
+ */
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (mOrientation == HORIZONTAL) {
+ setMeasuredDimension(measureLong(widthMeasureSpec), measureShort(heightMeasureSpec));
+ } else {
+ setMeasuredDimension(measureShort(widthMeasureSpec), measureLong(heightMeasureSpec));
+ }
+ }
+
+ /**
+ * Determines the width of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The width of the view, honoring constraints from measureSpec
+ */
+ private int measureLong(int measureSpec) {
+ int result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if ((specMode == MeasureSpec.EXACTLY) || (mViewPager == null)) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Calculate the width according the views count
+ final int count = mViewPager.getAdapter().getCount();
+ result = (int)(getPaddingLeft() + getPaddingRight()
+ + (count * 2 * mRadius) + (count - 1) * mRadius + 1);
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Determines the height of this view
+ *
+ * @param measureSpec
+ * A measureSpec packed into an int
+ * @return The height of the view, honoring constraints from measureSpec
+ */
+ private int measureShort(int measureSpec) {
+ int result;
+ int specMode = MeasureSpec.getMode(measureSpec);
+ int specSize = MeasureSpec.getSize(measureSpec);
+
+ if (specMode == MeasureSpec.EXACTLY) {
+ //We were told how big to be
+ result = specSize;
+ } else {
+ //Measure the height
+ result = (int)(2 * mRadius + getPaddingTop() + getPaddingBottom() + 1);
+ //Respect AT_MOST value if that was what is called for by measureSpec
+ if (specMode == MeasureSpec.AT_MOST) {
+ result = Math.min(result, specSize);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ SavedState savedState = (SavedState)state;
+ super.onRestoreInstanceState(savedState.getSuperState());
+ mCurrentPage = savedState.currentPage;
+ mSnapPage = savedState.currentPage;
+ requestLayout();
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ SavedState savedState = new SavedState(superState);
+ savedState.currentPage = mCurrentPage;
+ return savedState;
+ }
+
+ static class SavedState extends BaseSavedState {
+ int currentPage;
+
+ public SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ private SavedState(Parcel in) {
+ super(in);
+ currentPage = in.readInt();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeInt(currentPage);
+ }
+
+ @SuppressWarnings("UnusedDeclaration")
+ public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
new file mode 100644
index 000000000..b436a466f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
@@ -0,0 +1,105 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.graphics.Color;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.ViewUtil;
+import org.mozilla.gecko.util.TouchTargetUtil;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.EnumSet;
+import java.util.concurrent.Future;
+
+class TopSitesCard extends RecyclerView.ViewHolder
+ implements IconCallback, View.OnClickListener {
+ private final FaviconView faviconView;
+
+ private final TextView title;
+ private final ImageView menuButton;
+ private Future<IconResponse> ongoingIconLoad;
+
+ private String url;
+
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesCard(FrameLayout card, final HomePager.OnUrlOpenListener onUrlOpenListener, final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ super(card);
+
+ faviconView = (FaviconView) card.findViewById(R.id.favicon);
+
+ title = (TextView) card.findViewById(R.id.title);
+ menuButton = (ImageView) card.findViewById(R.id.menu);
+
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+
+ card.setOnClickListener(this);
+
+ TouchTargetUtil.ensureTargetHitArea(menuButton, card);
+ menuButton.setOnClickListener(this);
+
+ ViewUtil.enableTouchRipple(menuButton);
+ }
+
+ void bind(final TopSitesPageAdapter.TopSite topSite) {
+ ActivityStream.extractLabel(itemView.getContext(), topSite.url, true, new ActivityStream.LabelCallback() {
+ @Override
+ public void onLabelExtracted(String label) {
+ title.setText(label);
+ }
+ });
+
+ this.url = topSite.url;
+
+ if (ongoingIconLoad != null) {
+ ongoingIconLoad.cancel(true);
+ }
+
+ ongoingIconLoad = Icons.with(itemView.getContext())
+ .pageUrl(topSite.url)
+ .skipNetwork()
+ .build()
+ .execute(this);
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ faviconView.updateImage(response);
+
+ final int tintColor = !response.hasColor() || response.getColor() == Color.WHITE ? Color.LTGRAY : Color.WHITE;
+
+ menuButton.setImageDrawable(
+ DrawableUtil.tintDrawable(menuButton.getContext(), R.drawable.menu, tintColor));
+ }
+
+ @Override
+ public void onClick(View clickedView) {
+ if (clickedView == itemView) {
+ onUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(HomePager.OnUrlOpenListener.Flags.class));
+ } else if (clickedView == menuButton) {
+ ActivityStreamContextMenu.show(clickedView.getContext(),
+ menuButton,
+ ActivityStreamContextMenu.MenuMode.TOPSITE,
+ title.getText().toString(), url,
+ onUrlOpenListener, onUrlOpenInBackgroundListener,
+ faviconView.getWidth(), faviconView.getHeight());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
new file mode 100644
index 000000000..45fdc0d1a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPage.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.View;
+
+import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import java.util.EnumSet;
+
+public class TopSitesPage
+ extends RecyclerView {
+ public TopSitesPage(Context context,
+ @Nullable AttributeSet attrs) {
+ super(context, attrs);
+
+ setLayoutManager(new GridLayoutManager(context, 1));
+ }
+
+ public void setTiles(int tiles) {
+ setLayoutManager(new GridLayoutManager(getContext(), tiles));
+ }
+
+ private HomePager.OnUrlOpenListener onUrlOpenListener;
+ private HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesPageAdapter getAdapter() {
+ return (TopSitesPageAdapter) super.getAdapter();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
new file mode 100644
index 000000000..29e6aca3d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.home.HomePager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TopSitesPageAdapter extends RecyclerView.Adapter<TopSitesCard> {
+ static final class TopSite {
+ public final long id;
+ public final String url;
+ public final String title;
+
+ TopSite(long id, String url, String title) {
+ this.id = id;
+ this.url = url;
+ this.title = title;
+ }
+ }
+
+ private List<TopSite> topSites;
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+ private int textHeight;
+
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ public TopSitesPageAdapter(Context context, int tiles, int tilesWidth, int tilesHeight,
+ HomePager.OnUrlOpenListener onUrlOpenListener, HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ setHasStableIds(true);
+
+ this.topSites = new ArrayList<>();
+ this.tiles = tiles;
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.textHeight = context.getResources().getDimensionPixelSize(R.dimen.activity_stream_top_sites_text_height);
+
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ /**
+ *
+ * @param cursor
+ * @param startIndex The first item that this topsites group should show. This item, and the following
+ * 3 items will be displayed by this adapter.
+ */
+ public void swapCursor(Cursor cursor, int startIndex) {
+ topSites.clear();
+
+ if (cursor == null) {
+ return;
+ }
+
+ for (int i = 0; i < tiles && startIndex + i < cursor.getCount(); i++) {
+ cursor.moveToPosition(startIndex + i);
+
+ // The Combined View only contains pages that have been visited at least once, i.e. any
+ // page in the TopSites query will contain a HISTORY_ID. _ID however will be 0 for all rows.
+ final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+ final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
+ final String title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
+
+ topSites.add(new TopSite(id, url, title));
+ }
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onBindViewHolder(TopSitesCard holder, int position) {
+ holder.bind(topSites.get(position));
+ }
+
+ @Override
+ public TopSitesCard onCreateViewHolder(ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ final FrameLayout card = (FrameLayout) inflater.inflate(R.layout.activity_stream_topsites_card, parent, false);
+ final View content = card.findViewById(R.id.content);
+
+ ViewGroup.LayoutParams layoutParams = content.getLayoutParams();
+ layoutParams.width = tilesWidth;
+ layoutParams.height = tilesHeight + textHeight;
+ content.setLayoutParams(layoutParams);
+
+ return new TopSitesCard(card, onUrlOpenListener, onUrlOpenInBackgroundListener);
+ }
+
+ @Override
+ public int getItemCount() {
+ return topSites.size();
+ }
+
+ @Override
+ @UiThread
+ public long getItemId(int position) {
+ return topSites.get(position).id;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
new file mode 100644
index 000000000..dc824d902
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPagerAdapter.java
@@ -0,0 +1,124 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.home.activitystream.topsites;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.view.PagerAdapter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.HomePager;
+
+import java.util.LinkedList;
+
+/**
+ * The primary / top-level TopSites adapter: it handles the ViewPager, and also handles
+ * all lower-level Adapters that populate the individual topsite items.
+ */
+public class TopSitesPagerAdapter extends PagerAdapter {
+ public static final int PAGES = 4;
+
+ private int tiles;
+ private int tilesWidth;
+ private int tilesHeight;
+
+ private LinkedList<TopSitesPage> pages = new LinkedList<>();
+
+ private final Context context;
+ private final HomePager.OnUrlOpenListener onUrlOpenListener;
+ private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
+
+ private int count = 0;
+
+ public TopSitesPagerAdapter(Context context,
+ HomePager.OnUrlOpenListener onUrlOpenListener,
+ HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
+ this.context = context;
+ this.onUrlOpenListener = onUrlOpenListener;
+ this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
+ }
+
+ public void setTilesSize(int tiles, int tilesWidth, int tilesHeight) {
+ this.tilesWidth = tilesWidth;
+ this.tilesHeight = tilesHeight;
+ this.tiles = tiles;
+ }
+
+ @Override
+ public int getCount() {
+ return Math.min(count, 4);
+ }
+
+ @Override
+ public boolean isViewFromObject(View view, Object object) {
+ return view == object;
+ }
+
+ @Override
+ public Object instantiateItem(ViewGroup container, int position) {
+ TopSitesPage page = pages.get(position);
+
+ container.addView(page);
+
+ return page;
+ }
+
+ @Override
+ public int getItemPosition(Object object) {
+ return PagerAdapter.POSITION_NONE;
+ }
+
+ @Override
+ public void destroyItem(ViewGroup container, int position, Object object) {
+ container.removeView((View) object);
+ }
+
+ public void swapCursor(Cursor cursor) {
+ // Divide while rounding up: 0 items = 0 pages, 1-ITEMS_PER_PAGE items = 1 page, etc.
+ if (cursor != null) {
+ count = (cursor.getCount() - 1) / tiles + 1;
+ } else {
+ count = 0;
+ }
+
+ pages.clear();
+ final int pageDelta = count;
+
+ if (pageDelta > 0) {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ for (int i = 0; i < pageDelta; i++) {
+ final TopSitesPage page = (TopSitesPage) inflater.inflate(R.layout.activity_stream_topsites_page, null, false);
+
+ page.setTiles(tiles);
+ final TopSitesPageAdapter adapter = new TopSitesPageAdapter(context, tiles, tilesWidth, tilesHeight,
+ onUrlOpenListener, onUrlOpenInBackgroundListener);
+ page.setAdapter(adapter);
+ pages.add(page);
+ }
+ } else if (pageDelta < 0) {
+ for (int i = 0; i > pageDelta; i--) {
+ final TopSitesPage page = pages.getLast();
+
+ // Ensure the page doesn't use the old/invalid cursor anymore
+ page.getAdapter().swapCursor(null, 0);
+
+ pages.removeLast();
+ }
+ } else {
+ // do nothing: we will be updating all the pages below
+ }
+
+ int startIndex = 0;
+ for (TopSitesPage page : pages) {
+ page.getAdapter().swapCursor(cursor, startIndex);
+ startIndex += tiles;
+ }
+
+ notifyDataSetChanged();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java
new file mode 100644
index 000000000..0232a4ea6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconCallback.java
@@ -0,0 +1,13 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+/**
+ * Interface for a callback that will be executed once an icon has been loaded successfully.
+ */
+public interface IconCallback {
+ void onIconResponse(IconResponse response);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
new file mode 100644
index 000000000..359c47e53
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptor.java
@@ -0,0 +1,96 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.support.annotation.IntDef;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+/**
+ * A class describing the location and properties of an icon that can be loaded.
+ */
+public class IconDescriptor {
+ @IntDef({ TYPE_GENERIC, TYPE_FAVICON, TYPE_TOUCHICON, TYPE_LOOKUP })
+ @interface IconType {}
+
+ // The type values are used for ranking icons (higher values = try to load first).
+ @VisibleForTesting static final int TYPE_GENERIC = 0;
+ @VisibleForTesting static final int TYPE_LOOKUP = 1;
+ @VisibleForTesting static final int TYPE_FAVICON = 5;
+ @VisibleForTesting static final int TYPE_TOUCHICON = 10;
+
+ private final String url;
+ private final int size;
+ private final String mimeType;
+ private final int type;
+
+ /**
+ * Create a generic icon located at the given URL. No MIME type or size is known.
+ */
+ public static IconDescriptor createGenericIcon(String url) {
+ return new IconDescriptor(TYPE_GENERIC, url, 0, null);
+ }
+
+ /**
+ * Create a favicon located at the given URL and with a known size and MIME type.
+ */
+ public static IconDescriptor createFavicon(String url, int size, String mimeType) {
+ return new IconDescriptor(TYPE_FAVICON, url, size, mimeType);
+ }
+
+ /**
+ * Create a touch icon located at the given URL and with a known MIME type and size.
+ */
+ public static IconDescriptor createTouchicon(String url, int size, String mimeType) {
+ return new IconDescriptor(TYPE_TOUCHICON, url, size, mimeType);
+ }
+
+ /**
+ * Create an icon located at an URL that has been returned from a disk or memory storage. This
+ * is an icon with an URL we loaded an icon from previously. Therefore we give it a little higher
+ * ranking than a generic icon - even though we do not know the MIME type or size of the icon.
+ */
+ public static IconDescriptor createLookupIcon(String url) {
+ return new IconDescriptor(TYPE_LOOKUP, url, 0, null);
+ }
+
+ private IconDescriptor(@IconType int type, String url, int size, String mimeType) {
+ this.type = type;
+ this.url = url;
+ this.size = size;
+ this.mimeType = mimeType;
+ }
+
+ /**
+ * Get the URL of the icon.
+ */
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * Get the (assumed) size of the icon. Returns 0 if no size is known.
+ */
+ public int getSize() {
+ return size;
+ }
+
+ /**
+ * Get the type of the icon (favicon, touch icon, generic, lookup).
+ */
+ @IconType
+ public int getType() {
+ return type;
+ }
+
+ /**
+ * Get the (assumed) MIME type of the icon. Returns null if no MIME type is known.
+ */
+ @Nullable
+ public String getMimeType() {
+ return mimeType;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java
new file mode 100644
index 000000000..3c6064825
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconDescriptorComparator.java
@@ -0,0 +1,67 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import java.util.Comparator;
+
+/**
+ * This comparator implementation compares IconDescriptor objects in order to determine which icon
+ * to load first.
+ *
+ * In general this comparator will try touch icons before favicons (they usually have a higher resolution)
+ * and prefers larger icons over smaller ones.
+ */
+/* package-private */ class IconDescriptorComparator implements Comparator<IconDescriptor> {
+ @Override
+ public int compare(final IconDescriptor lhs, final IconDescriptor rhs) {
+ if (lhs.getUrl().equals(rhs.getUrl())) {
+ // Two descriptors pointing to the same URL are always referencing the same icon. So treat
+ // them as equal.
+ return 0;
+ }
+
+ // First compare the types. We prefer touch icons because they tend to have a higher resolution
+ // than ordinary favicons.
+ if (lhs.getType() != rhs.getType()) {
+ return compareType(lhs, rhs);
+ }
+
+ // If one of them is larger than pick the larger icon.
+ if (lhs.getSize() != rhs.getSize()) {
+ return compareSizes(lhs, rhs);
+ }
+
+ // If there's no other way to choose, we prefer container types. They *might* contain
+ // an image larger than the size given in the <link> tag.
+ final boolean lhsContainer = IconsHelper.isContainerType(lhs.getMimeType());
+ final boolean rhsContainer = IconsHelper.isContainerType(rhs.getMimeType());
+
+ if (lhsContainer != rhsContainer) {
+ return lhsContainer ? -1 : 1;
+ }
+
+ // There's no way to know which icon might be better. However we need to pick a consistent
+ // one to avoid breaking the TreeSet implementation (See Bug 1331808). Therefore we are
+ // picking one by just comparing the URLs.
+ return lhs.getUrl().compareTo(rhs.getUrl());
+ }
+
+ private int compareType(IconDescriptor lhs, IconDescriptor rhs) {
+ if (lhs.getType() > rhs.getType()) {
+ return -1;
+ } else {
+ return 1;
+ }
+ }
+
+ private int compareSizes(IconDescriptor lhs, IconDescriptor rhs) {
+ if (lhs.getSize() > rhs.getSize()) {
+ return -1;
+ } else {
+ return 1;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java
new file mode 100644
index 000000000..be000642e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequest.java
@@ -0,0 +1,181 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.content.Context;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.R;
+
+import java.util.Iterator;
+import java.util.TreeSet;
+import java.util.concurrent.Future;
+
+/**
+ * A class describing a request to load an icon for a website.
+ */
+public class IconRequest {
+ private Context context;
+
+ // Those values are written by the IconRequestBuilder class.
+ /* package-private */ String pageUrl;
+ /* package-private */ boolean privileged;
+ /* package-private */ TreeSet<IconDescriptor> icons;
+ /* package-private */ boolean skipNetwork;
+ /* package-private */ boolean backgroundThread;
+ /* package-private */ boolean skipDisk;
+ /* package-private */ boolean skipMemory;
+ /* package-private */ int targetSize;
+ /* package-private */ boolean prepareOnly;
+ private IconCallback callback;
+
+ /* package-private */ IconRequest(Context context) {
+ this.context = context.getApplicationContext();
+ this.icons = new TreeSet<>(new IconDescriptorComparator());
+
+ // Setting some sensible defaults.
+ this.privileged = false;
+ this.skipMemory = false;
+ this.skipDisk = false;
+ this.skipNetwork = false;
+ this.targetSize = context.getResources().getDimensionPixelSize(R.dimen.favicon_bg);
+ this.prepareOnly = false;
+ }
+
+ /**
+ * Execute this request and try to load an icon. Once an icon has been loaded successfully the
+ * callback will be executed.
+ *
+ * The returned Future can be used to cancel the job.
+ */
+ public Future<IconResponse> execute(IconCallback callback) {
+ setCallback(callback);
+
+ return IconRequestExecutor.submit(this);
+ }
+
+ @VisibleForTesting void setCallback(IconCallback callback) {
+ this.callback = callback;
+ }
+
+ /**
+ * Get the (application) context associated with this request.
+ */
+ public Context getContext() {
+ return context;
+ }
+
+ /**
+ * Get the descriptor for the potentially best icon. This is the icon that should be loaded if
+ * possible.
+ */
+ public IconDescriptor getBestIcon() {
+ return icons.first();
+ }
+
+ /**
+ * Get the URL of the page for which an icon should be loaded.
+ */
+ public String getPageUrl() {
+ return pageUrl;
+ }
+
+ /**
+ * Is this request allowed to load icons from internal data sources like the omni.ja?
+ */
+ public boolean isPrivileged() {
+ return privileged;
+ }
+
+ /**
+ * Get the number of icon descriptors associated with this request.
+ */
+ public int getIconCount() {
+ return icons.size();
+ }
+
+ /**
+ * Get the required target size of the icon.
+ */
+ public int getTargetSize() {
+ return targetSize;
+ }
+
+ /**
+ * Should a loader access the network to load this icon?
+ */
+ public boolean shouldSkipNetwork() {
+ return skipNetwork;
+ }
+
+ /**
+ * Should a loader access the disk to load this icon?
+ */
+ public boolean shouldSkipDisk() {
+ return skipDisk;
+ }
+
+ /**
+ * Should a loader access the memory cache to load this icon?
+ */
+ public boolean shouldSkipMemory() {
+ return skipMemory;
+ }
+
+ /**
+ * Get an iterator to iterate over all icon descriptors associated with this request.
+ */
+ public Iterator<IconDescriptor> getIconIterator() {
+ return icons.iterator();
+ }
+
+ /**
+ * Create a builder to modify this request.
+ *
+ * Calling methods on the builder will modify this object and not create a copy.
+ */
+ public IconRequestBuilder modify() {
+ return new IconRequestBuilder(this);
+ }
+
+ /**
+ * Should the callback be executed on a background thread? By default a callback is always
+ * executed on the UI thread because an icon is usually loaded in order to display it somewhere
+ * in the UI.
+ */
+ /* package-private */ boolean shouldRunOnBackgroundThread() {
+ return backgroundThread;
+ }
+
+ /* package-private */ IconCallback getCallback() {
+ return callback;
+ }
+
+ /* package-private */ boolean hasIconDescriptors() {
+ return !icons.isEmpty();
+ }
+
+ /**
+ * Move to the next icon. This method is called after all loaders for the current best icon
+ * have failed. After calling this method getBestIcon() will return the next icon to try.
+ * hasIconDescriptors() should be called before requesting the next icon.
+ */
+ /* package-private */ void moveToNextIcon() {
+ if (!icons.remove(getBestIcon())) {
+ // Calling this method when there's no next icon is an error (use hasIconDescriptors()).
+ // Theoretically this method can fail even if there's a next icon (like it did in bug 1331808).
+ // In this case crashing to see and fix the issue is desired.
+ throw new IllegalStateException("Moving to next icon failed. Could not remove first icon from set.");
+ }
+ }
+
+ /**
+ * Should this request be prepared but not actually load an icon?
+ */
+ /* package-private */ boolean shouldPrepareOnly() {
+ return prepareOnly;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java
new file mode 100644
index 000000000..d9fd9ec5a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestBuilder.java
@@ -0,0 +1,143 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.content.Context;
+import android.support.annotation.CheckResult;
+
+import org.mozilla.gecko.GeckoAppShell;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Builder for creating a request to load an icon.
+ */
+public class IconRequestBuilder {
+ private final IconRequest request;
+
+ /* package-private */ IconRequestBuilder(Context context) {
+ this(new IconRequest(context));
+ }
+
+ /* package-private */ IconRequestBuilder(IconRequest request) {
+ this.request = request;
+ }
+
+ /**
+ * Set the URL of the page for which the icon should be loaded.
+ */
+ @CheckResult
+ public IconRequestBuilder pageUrl(String pageUrl) {
+ request.pageUrl = pageUrl;
+ return this;
+ }
+
+ /**
+ * Set whether this request is allowed to load icons from non http(s) URLs (e.g. the omni.ja).
+ *
+ * For example web content referencing internal URLs should not lead to us loading icons from
+ * internal data structures like the omni.ja.
+ */
+ @CheckResult
+ public IconRequestBuilder privileged(boolean privileged) {
+ request.privileged = privileged;
+ return this;
+ }
+
+ /**
+ * Add an icon descriptor describing the location and properties of an icon. All descriptors
+ * will be ranked and tried in order of their rank. Executing the request will modify the list
+ * of icons (filter or add additional descriptors).
+ */
+ @CheckResult
+ public IconRequestBuilder icon(IconDescriptor descriptor) {
+ request.icons.add(descriptor);
+ return this;
+ }
+
+ /**
+ * Skip the network and do not load an icon from a network connection.
+ */
+ @CheckResult
+ public IconRequestBuilder skipNetwork() {
+ request.skipNetwork = true;
+ return this;
+ }
+
+ /**
+ * Skip the disk cache and do not load an icon from disk.
+ */
+ @CheckResult
+ public IconRequestBuilder skipDisk() {
+ request.skipDisk = true;
+ return this;
+ }
+
+ /**
+ * Skip the memory cache and do not return a previously loaded icon.
+ */
+ @CheckResult
+ public IconRequestBuilder skipMemory() {
+ request.skipMemory = true;
+ return this;
+ }
+
+ /**
+ * The icon will be used as (Android) launcher icon. The loaded icon will be scaled to the
+ * preferred Android launcher icon size.
+ */
+ public IconRequestBuilder forLauncherIcon() {
+ request.targetSize = GeckoAppShell.getPreferredIconSize();
+ return this;
+ }
+
+ /**
+ * Execute the callback on the background thread. By default the callback is always executed on
+ * the UI thread in order to add the loaded icon to a view easily.
+ */
+ @CheckResult
+ public IconRequestBuilder executeCallbackOnBackgroundThread() {
+ request.backgroundThread = true;
+ return this;
+ }
+
+ /**
+ * When executing the request then only prepare executing it but do not actually load an icon.
+ * This mode is only used for some legacy code that uses the icon URL and therefore needs to
+ * perform a lookup of the URL but doesn't want to load the icon yet.
+ */
+ public IconRequestBuilder prepareOnly() {
+ request.prepareOnly = true;
+ return this;
+ }
+
+ /**
+ * Return the request built with this builder.
+ */
+ @CheckResult
+ public IconRequest build() {
+ if (TextUtils.isEmpty(request.pageUrl)) {
+ throw new IllegalStateException("Page URL is required");
+ }
+
+ return request;
+ }
+
+ /**
+ * This is a no-op method.
+ *
+ * All builder methods are annotated with @CheckResult to denote that the
+ * methods return the builder object and that it is typically an error to not call another method
+ * on it until eventually calling build().
+ *
+ * However in some situations code can keep a reference
+ * to the builder object and call methods only when a specific event occurs. To make this explicit
+ * and avoid lint errors this method can be called.
+ */
+ public void deferBuild() {
+ // No op
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java
new file mode 100644
index 000000000..aad784980
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconRequestExecutor.java
@@ -0,0 +1,152 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.support.annotation.NonNull;
+
+import org.mozilla.gecko.icons.loader.ContentProviderLoader;
+import org.mozilla.gecko.icons.loader.DataUriLoader;
+import org.mozilla.gecko.icons.loader.DiskLoader;
+import org.mozilla.gecko.icons.loader.IconDownloader;
+import org.mozilla.gecko.icons.loader.IconGenerator;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.loader.JarLoader;
+import org.mozilla.gecko.icons.loader.LegacyLoader;
+import org.mozilla.gecko.icons.loader.MemoryLoader;
+import org.mozilla.gecko.icons.preparation.AboutPagesPreparer;
+import org.mozilla.gecko.icons.preparation.AddDefaultIconUrl;
+import org.mozilla.gecko.icons.preparation.FilterKnownFailureUrls;
+import org.mozilla.gecko.icons.preparation.FilterMimeTypes;
+import org.mozilla.gecko.icons.preparation.FilterPrivilegedUrls;
+import org.mozilla.gecko.icons.preparation.LookupIconUrl;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.ColorProcessor;
+import org.mozilla.gecko.icons.processing.DiskProcessor;
+import org.mozilla.gecko.icons.processing.MemoryProcessor;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.mozilla.gecko.icons.processing.ResizingProcessor;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Executor for icon requests.
+ */
+/* package-private */ class IconRequestExecutor {
+ /**
+ * Loader implementation that generates an icon if none could be loaded.
+ */
+ private static final IconLoader GENERATOR = new IconGenerator();
+
+ /**
+ * Ordered list of prepares that run before any icon is loaded.
+ */
+ private static final List<Preparer> PREPARERS = Arrays.asList(
+ // First we look into our memory and disk caches if there are some known icon URLs for
+ // the page URL of the request.
+ new LookupIconUrl(),
+
+ // For all icons with MIME type we filter entries with unknown MIME type that we probably
+ // cannot decode anyways.
+ new FilterMimeTypes(),
+
+ // If this is not a request that is allowed to load icons from privileged locations (omni.jar)
+ // then filter such icon URLs.
+ new FilterPrivilegedUrls(),
+
+ // This preparer adds an icon URL for about pages. It's added after the filter for privileged
+ // URLs. We always want to be able to load those specific icons.
+ new AboutPagesPreparer(),
+
+ // Add the default favicon URL (*/favicon.ico) to the list of icon URLs; with a low priority,
+ // this icon URL should be tried last.
+ new AddDefaultIconUrl(),
+
+ // Finally we filter all URLs that failed to load recently (4xx / 5xx errors).
+ new FilterKnownFailureUrls()
+ );
+
+ /**
+ * Ordered list of loaders. If a loader returns a response object then subsequent loaders are not run.
+ */
+ private static final List<IconLoader> LOADERS = Arrays.asList(
+ // First we try to load an icon that is already in the memory. That's cheap.
+ new MemoryLoader(),
+
+ // Try to decode the icon if it is a data: URI.
+ new DataUriLoader(),
+
+ // Try to load the icon from the omni.ha if it's a jar:jar URI.
+ new JarLoader(),
+
+ // Try to load the icon from a content provider (if applicable).
+ new ContentProviderLoader(),
+
+ // Try to load the icon from the disk cache.
+ new DiskLoader(),
+
+ // If the icon is not in any of our cashes and can't be decoded then look into the
+ // database (legacy). Maybe this icon was loaded before the new code was deployed.
+ new LegacyLoader(),
+
+ // Download the icon from the web.
+ new IconDownloader()
+ );
+
+ /**
+ * Ordered list of processors that run after an icon has been loaded.
+ */
+ private static final List<Processor> PROCESSORS = Arrays.asList(
+ // Store the icon (and mapping) in the disk cache if needed
+ new DiskProcessor(),
+
+ // Resize the icon to match the target size (if possible)
+ new ResizingProcessor(),
+
+ // Extract the dominant color from the icon
+ new ColorProcessor(),
+
+ // Store the icon in the memory cache
+ new MemoryProcessor()
+ );
+
+ private static final ExecutorService EXECUTOR;
+ static {
+ final ThreadFactory factory = new ThreadFactory() {
+ @Override
+ public Thread newThread(@NonNull Runnable runnable) {
+ Thread thread = new Thread(runnable, "GeckoIconTask");
+ thread.setDaemon(false);
+ thread.setPriority(Thread.NORM_PRIORITY);
+ return thread;
+ }
+ };
+
+ // Single thread executor
+ EXECUTOR = new ThreadPoolExecutor(
+ 1, /* corePoolSize */
+ 1, /* maximumPoolSize */
+ 0L, /* keepAliveTime */
+ TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<Runnable>(),
+ factory);
+ }
+
+ /**
+ * Submit the request for execution.
+ */
+ /* package-private */ static Future<IconResponse> submit(IconRequest request) {
+ return EXECUTOR.submit(
+ new IconTask(request, PREPARERS, LOADERS, PROCESSORS, GENERATOR)
+ );
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java
new file mode 100644
index 000000000..726619eb9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconResponse.java
@@ -0,0 +1,167 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+/**
+ * Response object containing a successful loaded icon and meta data.
+ */
+public class IconResponse {
+ /**
+ * Create a response for a plain bitmap.
+ */
+ public static IconResponse create(@NonNull Bitmap bitmap) {
+ return new IconResponse(bitmap);
+ }
+
+ /**
+ * Create a response for a bitmap that has been loaded from the network by requesting a specific URL.
+ */
+ public static IconResponse createFromNetwork(@NonNull Bitmap bitmap, @NonNull String url) {
+ final IconResponse response = new IconResponse(bitmap);
+ response.url = url;
+ response.fromNetwork = true;
+ return response;
+ }
+
+ /**
+ * Create a response for a generated bitmap with a dominant color.
+ */
+ public static IconResponse createGenerated(@NonNull Bitmap bitmap, int color) {
+ final IconResponse response = new IconResponse(bitmap);
+ response.color = color;
+ response.generated = true;
+ return response;
+ }
+
+ /**
+ * Create a response for a bitmap that has been loaded from the memory cache.
+ */
+ public static IconResponse createFromMemory(@NonNull Bitmap bitmap, @NonNull String url, int color) {
+ final IconResponse response = new IconResponse(bitmap);
+ response.url = url;
+ response.color = color;
+ response.fromMemory = true;
+ return response;
+ }
+
+ /**
+ * Create a response for a bitmap that has been loaded from the disk cache.
+ */
+ public static IconResponse createFromDisk(@NonNull Bitmap bitmap, @NonNull String url) {
+ final IconResponse response = new IconResponse(bitmap);
+ response.url = url;
+ response.fromDisk = true;
+ return response;
+ }
+
+ private Bitmap bitmap;
+ private int color;
+ private boolean fromNetwork;
+ private boolean fromMemory;
+ private boolean fromDisk;
+ private boolean generated;
+ private String url;
+
+ private IconResponse(Bitmap bitmap) {
+ if (bitmap == null) {
+ throw new NullPointerException("Bitmap is null");
+ }
+
+ this.bitmap = bitmap;
+ this.color = 0;
+ this.url = null;
+ this.fromNetwork = false;
+ this.fromMemory = false;
+ this.fromDisk = false;
+ this.generated = false;
+ }
+
+ /**
+ * Get the icon bitmap. This method will always return a bitmap.
+ */
+ @NonNull
+ public Bitmap getBitmap() {
+ return bitmap;
+ }
+
+ /**
+ * Get the dominant color of the icon. Will return 0 if no color could be extracted.
+ */
+ public int getColor() {
+ return color;
+ }
+
+ /**
+ * Does this response contain a dominant color?
+ */
+ public boolean hasColor() {
+ return color != 0;
+ }
+
+ /**
+ * Has this icon been loaded from the network?
+ */
+ public boolean isFromNetwork() {
+ return fromNetwork;
+ }
+
+ /**
+ * Has this icon been generated?
+ */
+ public boolean isGenerated() {
+ return generated;
+ }
+
+ /**
+ * Has this icon been loaded from memory (cache)?
+ */
+ public boolean isFromMemory() {
+ return fromMemory;
+ }
+
+ /**
+ * Has this icon been loaded from disk (cache)?
+ */
+ public boolean isFromDisk() {
+ return fromDisk;
+ }
+
+ /**
+ * Get the URL this icon has been loaded from.
+ */
+ @Nullable
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * Does this response contain an URL from which the icon has been loaded?
+ */
+ public boolean hasUrl() {
+ return !TextUtils.isEmpty(url);
+ }
+
+ /**
+ * Update the color of this response. This method is called by processors updating meta data
+ * after the icon has been loaded.
+ */
+ public void updateColor(int color) {
+ this.color = color;
+ }
+
+ /**
+ * Update the bitmap of this response. This method is called by processors that modify the
+ * loaded icon.
+ */
+ public void updateBitmap(Bitmap bitmap) {
+ this.bitmap = bitmap;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java
new file mode 100644
index 000000000..411a31980
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconTask.java
@@ -0,0 +1,222 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.icons.loader.IconLoader;
+import org.mozilla.gecko.icons.preparation.Preparer;
+import org.mozilla.gecko.icons.processing.Processor;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.List;
+import java.util.concurrent.Callable;
+
+/**
+ * Task that will be run by the IconRequestExecutor for every icon request.
+ */
+/* package-private */ class IconTask implements Callable<IconResponse> {
+ private static final String LOGTAG = "Gecko/IconTask";
+ private static final boolean DEBUG = false;
+
+ private final List<Preparer> preparers;
+ private final List<IconLoader> loaders;
+ private final List<Processor> processors;
+ private final IconLoader generator;
+ private final IconRequest request;
+
+ /* package-private */ IconTask(
+ @NonNull IconRequest request,
+ @NonNull List<Preparer> preparers,
+ @NonNull List<IconLoader> loaders,
+ @NonNull List<Processor> processors,
+ @NonNull IconLoader generator) {
+ this.request = request;
+ this.preparers = preparers;
+ this.loaders = loaders;
+ this.processors = processors;
+ this.generator = generator;
+ }
+
+ @Override
+ public IconResponse call() {
+ try {
+ logRequest(request);
+
+ prepareRequest(request);
+
+ if (request.shouldPrepareOnly()) {
+ // This request should only be prepared but not load an actual icon.
+ return null;
+ }
+
+ final IconResponse response = loadIcon(request);
+
+ if (response != null) {
+ processIcon(request, response);
+ executeCallback(request, response);
+
+ logResponse(response);
+
+ return response;
+ }
+ } catch (InterruptedException e) {
+ Log.d(LOGTAG, "IconTask was interrupted", e);
+
+ // Clear interrupt thread.
+ Thread.interrupted();
+ } catch (Throwable e) {
+ handleException(e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if this thread was interrupted (e.g. this task was cancelled). Throws an InterruptedException
+ * to stop executing the task in this case.
+ */
+ private void ensureNotInterrupted() throws InterruptedException {
+ if (Thread.currentThread().isInterrupted()) {
+ throw new InterruptedException("Task has been cancelled");
+ }
+ }
+
+ private void executeCallback(IconRequest request, final IconResponse response) {
+ final IconCallback callback = request.getCallback();
+
+ if (callback != null) {
+ if (request.shouldRunOnBackgroundThread()) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ callback.onIconResponse(response);
+ }
+ });
+ } else {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ callback.onIconResponse(response);
+ }
+ });
+ }
+ }
+ }
+
+ private void prepareRequest(IconRequest request) throws InterruptedException {
+ for (Preparer preparer : preparers) {
+ ensureNotInterrupted();
+
+ preparer.prepare(request);
+
+ logPreparer(request, preparer);
+ }
+ }
+
+ private IconResponse loadIcon(IconRequest request) throws InterruptedException {
+ while (request.hasIconDescriptors()) {
+ for (IconLoader loader : loaders) {
+ ensureNotInterrupted();
+
+ IconResponse response = loader.load(request);
+
+ logLoader(request, loader, response);
+
+ if (response != null) {
+ return response;
+ }
+ }
+
+ request.moveToNextIcon();
+ }
+
+ return generator.load(request);
+ }
+
+ private void processIcon(IconRequest request, IconResponse response) throws InterruptedException {
+ for (Processor processor : processors) {
+ ensureNotInterrupted();
+
+ processor.process(request, response);
+
+ logProcessor(processor);
+ }
+ }
+
+ private void handleException(final Throwable t) {
+ if (AppConstants.NIGHTLY_BUILD) {
+ // We want to be aware of problems: Let's re-throw the exception on the main thread to
+ // force an app crash. However we only do this in Nightly builds. Every other build
+ // (especially release builds) should just carry on and log the error.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ throw new RuntimeException("Icon task thread crashed", t);
+ }
+ });
+ } else {
+ Log.e(LOGTAG, "Icon task crashed", t);
+ }
+ }
+
+ private boolean shouldLog() {
+ // Do not log anything if debugging is disabled and never log anything in a non-nightly build.
+ return DEBUG && AppConstants.NIGHTLY_BUILD;
+ }
+
+ private void logPreparer(IconRequest request, Preparer preparer) {
+ if (!shouldLog()) {
+ return;
+ }
+
+ Log.d(LOGTAG, String.format(" PREPARE %s" + " (%s)",
+ preparer.getClass().getSimpleName(),
+ request.getIconCount()));
+ }
+
+ private void logLoader(IconRequest request, IconLoader loader, IconResponse response) {
+ if (!shouldLog()) {
+ return;
+ }
+
+ Log.d(LOGTAG, String.format(" LOAD [%s] %s : %s",
+ response != null ? "X" : " ",
+ loader.getClass().getSimpleName(),
+ request.getBestIcon().getUrl()));
+ }
+
+ private void logProcessor(Processor processor) {
+ if (!shouldLog()) {
+ return;
+ }
+
+ Log.d(LOGTAG, " PROCESS " + processor.getClass().getSimpleName());
+ }
+
+ private void logResponse(IconResponse response) {
+ if (!shouldLog()) {
+ return;
+ }
+
+ final Bitmap bitmap = response.getBitmap();
+
+ Log.d(LOGTAG, String.format("=> ICON: %sx%s", bitmap.getWidth(), bitmap.getHeight()));
+ }
+
+ private void logRequest(IconRequest request) {
+ if (!shouldLog()) {
+ return;
+ }
+
+ Log.d(LOGTAG, String.format("REQUEST (%s) %s",
+ request.getIconCount(),
+ request.getPageUrl()));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java b/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java
new file mode 100644
index 000000000..a5505a694
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/Icons.java
@@ -0,0 +1,35 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.content.Context;
+import android.support.annotation.CheckResult;
+
+/**
+ * Entry point for loading icons for websites (just high quality icons, can be favicons or
+ * touch icons).
+ *
+ * The API is loosely inspired by Picasso's builder.
+ *
+ * Example:
+ *
+ * Icons.with(context)
+ * .pageUrl(pageURL)
+ * .skipNetwork()
+ * .privileged(true)
+ * .icon(IconDescriptor.createGenericIcon(url))
+ * .build()
+ * .execute(callback);
+ */
+public abstract class Icons {
+ /**
+ * Create a new request for loading a website icon.
+ */
+ @CheckResult
+ public static IconRequestBuilder with(Context context) {
+ return new IconRequestBuilder(context);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java b/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java
new file mode 100644
index 000000000..d351eb4b7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/IconsHelper.java
@@ -0,0 +1,140 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons;
+
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.util.HashSet;
+
+/**
+ * Helper methods for icon related tasks.
+ */
+public class IconsHelper {
+ private static final String LOGTAG = "Gecko/IconsHelper";
+
+ // Mime types of things we are capable of decoding.
+ private static final HashSet<String> sDecodableMimeTypes = new HashSet<>();
+
+ // Mime types of things we are both capable of decoding and are container formats (May contain
+ // multiple different sizes of image)
+ private static final HashSet<String> sContainerMimeTypes = new HashSet<>();
+
+ static {
+ // MIME types extracted from http://filext.com - ostensibly all in-use mime types for the
+ // corresponding formats.
+ // ICO
+ sContainerMimeTypes.add("image/vnd.microsoft.icon");
+ sContainerMimeTypes.add("image/ico");
+ sContainerMimeTypes.add("image/icon");
+ sContainerMimeTypes.add("image/x-icon");
+ sContainerMimeTypes.add("text/ico");
+ sContainerMimeTypes.add("application/ico");
+
+ // Add supported container types to the set of supported types.
+ sDecodableMimeTypes.addAll(sContainerMimeTypes);
+
+ // PNG
+ sDecodableMimeTypes.add("image/png");
+ sDecodableMimeTypes.add("application/png");
+ sDecodableMimeTypes.add("application/x-png");
+
+ // GIF
+ sDecodableMimeTypes.add("image/gif");
+
+ // JPEG
+ sDecodableMimeTypes.add("image/jpeg");
+ sDecodableMimeTypes.add("image/jpg");
+ sDecodableMimeTypes.add("image/pipeg");
+ sDecodableMimeTypes.add("image/vnd.swiftview-jpeg");
+ sDecodableMimeTypes.add("application/jpg");
+ sDecodableMimeTypes.add("application/x-jpg");
+
+ // BMP
+ sDecodableMimeTypes.add("application/bmp");
+ sDecodableMimeTypes.add("application/x-bmp");
+ sDecodableMimeTypes.add("application/x-win-bitmap");
+ sDecodableMimeTypes.add("image/bmp");
+ sDecodableMimeTypes.add("image/x-bmp");
+ sDecodableMimeTypes.add("image/x-bitmap");
+ sDecodableMimeTypes.add("image/x-xbitmap");
+ sDecodableMimeTypes.add("image/x-win-bitmap");
+ sDecodableMimeTypes.add("image/x-windows-bitmap");
+ sDecodableMimeTypes.add("image/x-ms-bitmap");
+ sDecodableMimeTypes.add("image/x-ms-bmp");
+ sDecodableMimeTypes.add("image/ms-bmp");
+ }
+
+ /**
+ * Helper method to getIcon the default Favicon URL for a given pageURL. Generally: somewhere.com/favicon.ico
+ *
+ * @param pageURL Page URL for which a default Favicon URL is requested
+ * @return The default Favicon URL or null if no default URL could be guessed.
+ */
+ @Nullable
+ public static String guessDefaultFaviconURL(String pageURL) {
+ if (TextUtils.isEmpty(pageURL)) {
+ return null;
+ }
+
+ // Special-casing for about: pages. The favicon for about:pages which don't provide a link tag
+ // is bundled in the database, keyed only by page URL, hence the need to return the page URL
+ // here. If the database ever migrates to stop being silly in this way, this can plausibly
+ // be removed.
+ if (AboutPages.isAboutPage(pageURL) || pageURL.startsWith("jar:")) {
+ return pageURL;
+ }
+
+ if (!StringUtils.isHttpOrHttps(pageURL)) {
+ // Guessing a default URL only makes sense for http(s) URLs.
+ return null;
+ }
+
+ try {
+ // Fall back to trying "someScheme:someDomain.someExtension/favicon.ico".
+ Uri uri = Uri.parse(pageURL);
+ if (uri.getAuthority().isEmpty()) {
+ return null;
+ }
+
+ return uri.buildUpon()
+ .path("favicon.ico")
+ .clearQuery()
+ .fragment("")
+ .build()
+ .toString();
+ } catch (Exception e) {
+ Log.d(LOGTAG, "Exception getting default favicon URL");
+ return null;
+ }
+ }
+
+ /**
+ * Helper function to determine if the provided mime type is that of a format that can contain
+ * multiple image types. At time of writing, the only such type is ICO.
+ * @param mimeType Mime type to check.
+ * @return true if the given mime type is a container type, false otherwise.
+ */
+ public static boolean isContainerType(@NonNull String mimeType) {
+ return sContainerMimeTypes.contains(mimeType);
+ }
+
+ /**
+ * Helper function to determine if we can decode a particular mime type.
+ *
+ * @param imgType Mime type to check for decodability.
+ * @return false if the given mime type is certainly not decodable, true if it might be.
+ */
+ public static boolean canDecodeType(@NonNull String imgType) {
+ return sDecodableMimeTypes.contains(imgType);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java
new file mode 100644
index 000000000..43f5d0ac6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/FaviconDecoder.java
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.decoders;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.util.Base64;
+import android.util.Log;
+
+import org.mozilla.gecko.gfx.BitmapUtils;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+/**
+ * Class providing static utility methods for decoding favicons.
+ */
+public class FaviconDecoder {
+ private static final String LOG_TAG = "GeckoFaviconDecoder";
+
+ static enum ImageMagicNumbers {
+ // It is irritating that Java bytes are signed...
+ PNG(new byte[] {(byte) (0x89 & 0xFF), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}),
+ GIF(new byte[] {0x47, 0x49, 0x46, 0x38}),
+ JPEG(new byte[] {-0x1, -0x28, -0x1, -0x20}),
+ BMP(new byte[] {0x42, 0x4d}),
+ WEB(new byte[] {0x57, 0x45, 0x42, 0x50, 0x0a});
+
+ public byte[] value;
+
+ private ImageMagicNumbers(byte[] value) {
+ this.value = value;
+ }
+ }
+
+ /**
+ * Check for image format magic numbers of formats supported by Android.
+ * @param buffer Byte buffer to check for magic numbers
+ * @param offset Offset at which to look for magic numbers.
+ * @return true if the buffer contains a bitmap decodable by Android (Or at least, a sequence
+ * starting with the magic numbers thereof). false otherwise.
+ */
+ private static boolean isDecodableByAndroid(byte[] buffer, int offset) {
+ for (ImageMagicNumbers m : ImageMagicNumbers.values()) {
+ if (bufferStartsWith(buffer, m.value, offset)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Utility function to check for the existence of a test byte sequence at a given offset in a
+ * buffer.
+ *
+ * @param buffer Byte buffer to search.
+ * @param test Byte sequence to search for.
+ * @param bufferOffset Index in input buffer to expect test sequence.
+ * @return true if buffer contains the byte sequence given in test at offset bufferOffset, false
+ * otherwise.
+ */
+ static boolean bufferStartsWith(byte[] buffer, byte[] test, int bufferOffset) {
+ if (buffer.length < test.length) {
+ return false;
+ }
+
+ for (int i = 0; i < test.length; ++i) {
+ if (buffer[bufferOffset + i] != test[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Decode the favicon present in the region of the provided byte[] starting at offset and
+ * proceeding for length bytes, if any. Returns either the resulting LoadFaviconResult or null if the
+ * given range does not contain a bitmap we know how to decode.
+ *
+ * @param buffer Byte array containing the favicon to decode.
+ * @param offset The index of the first byte in the array of the region of interest.
+ * @param length The length of the region in the array to decode.
+ * @return The decoded version of the bitmap in the described region, or null if none can be
+ * decoded.
+ */
+ public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer, int offset, int length) {
+ LoadFaviconResult result;
+ if (isDecodableByAndroid(buffer, offset)) {
+ result = new LoadFaviconResult();
+ result.offset = offset;
+ result.length = length;
+ result.isICO = false;
+
+ Bitmap decodedImage = BitmapUtils.decodeByteArray(buffer, offset, length);
+ if (decodedImage == null) {
+ // What we got wasn't decodable after all. Probably corrupted image, or we got a muffled OOM.
+ return null;
+ }
+
+ // We assume here that decodeByteArray doesn't hold on to the entire supplied
+ // buffer -- worst case, each of our buffers will be twice the necessary size.
+ result.bitmapsDecoded = new SingleBitmapIterator(decodedImage);
+ result.faviconBytes = buffer;
+
+ return result;
+ }
+
+ // If it's not decodable by Android, it might be an ICO. Let's try.
+ ICODecoder decoder = new ICODecoder(context, buffer, offset, length);
+
+ result = decoder.decode();
+
+ if (result == null) {
+ return null;
+ }
+
+ return result;
+ }
+
+ public static LoadFaviconResult decodeDataURI(Context context, String uri) {
+ if (uri == null) {
+ Log.w(LOG_TAG, "Can't decode null data: URI.");
+ return null;
+ }
+
+ if (!uri.startsWith("data:image/")) {
+ // Can't decode non-image data: URI.
+ return null;
+ }
+
+ // Otherwise, let's attack this blindly. Strictly we should be parsing.
+ int offset = uri.indexOf(',') + 1;
+ if (offset == 0) {
+ Log.w(LOG_TAG, "No ',' in data: URI; malformed?");
+ return null;
+ }
+
+ try {
+ String base64 = uri.substring(offset);
+ byte[] raw = Base64.decode(base64, Base64.DEFAULT);
+ return decodeFavicon(context, raw);
+ } catch (Exception e) {
+ Log.w(LOG_TAG, "Couldn't decode data: URI.", e);
+ return null;
+ }
+ }
+
+ public static LoadFaviconResult decodeFavicon(Context context, byte[] buffer) {
+ return decodeFavicon(context, buffer, 0, buffer.length);
+ }
+
+ /**
+ * Iterator to hold a single bitmap.
+ */
+ static class SingleBitmapIterator implements Iterator<Bitmap> {
+ private Bitmap bitmap;
+
+ public SingleBitmapIterator(Bitmap b) {
+ bitmap = b;
+ }
+
+ /**
+ * Slightly cheating here - this iterator supports peeking (Handy in a couple of obscure
+ * places where the runtime type of the Iterator under consideration is known and
+ * destruction of it is discouraged.
+ *
+ * @return The bitmap carried by this SingleBitmapIterator.
+ */
+ public Bitmap peek() {
+ return bitmap;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return bitmap != null;
+ }
+
+ @Override
+ public Bitmap next() {
+ if (bitmap == null) {
+ throw new NoSuchElementException("Element already returned from SingleBitmapIterator.");
+ }
+
+ Bitmap ret = bitmap;
+ bitmap = null;
+ return ret;
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException("remove() not supported on SingleBitmapIterator.");
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java
new file mode 100644
index 000000000..44e3f1252
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/ICODecoder.java
@@ -0,0 +1,396 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.decoders;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.support.annotation.VisibleForTesting;
+import android.util.SparseArray;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.R;
+
+/**
+ * Utility class for determining the region of a provided array which contains the largest bitmap,
+ * assuming the provided array is a valid ICO and the bitmap desired is square, and for pruning
+ * unwanted entries from ICO files, if desired.
+ *
+ * An ICO file is a container format that may hold up to 255 images in either BMP or PNG format.
+ * A mixture of image types may not exist.
+ *
+ * The format consists of a header specifying the number, n, of images, followed by the Icon Directory.
+ *
+ * The Icon Directory consists of n Icon Directory Entries, each 16 bytes in length, specifying, for
+ * the corresponding image, the dimensions, colour information, payload size, and location in the file.
+ *
+ * All numerical fields follow a little-endian byte ordering.
+ *
+ * Header format:
+ *
+ * 0 1 2 3
+ * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Reserved field. Must be zero | Type (1 for ICO, 2 for CUR) |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Image count (n) |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ *
+ * The type field is expected to always be 1. CUR format images should not be used for Favicons.
+ *
+ *
+ * Icon Directory Entry format:
+ *
+ * 0 1 2 3
+ * 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Image width | Image height | Palette size | Reserved (0) |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Colour plane count | Bits per pixel |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Size of image data, in bytes |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * | Start of image data, as an offset from start of file |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ *
+ * Image dimensions of zero are to be interpreted as image dimensions of 256.
+ *
+ * The palette size field records the number of colours in the stored BMP, if a palette is used. Zero
+ * if the payload is a PNG or no palette is in use.
+ *
+ * The number of colour planes is, usually, 0 (Not in use) or 1. Values greater than 1 are to be
+ * interpreted not as a colour plane count, but as a multiplying factor on the bits per pixel field.
+ * (Apparently 65535 was not deemed a sufficiently large maximum value of bits per pixel.)
+ *
+ *
+ * The Icon Directory consists of n-many Icon Directory Entries in sequence, with no gaps.
+ *
+ * This class is not thread safe.
+ */
+public class ICODecoder implements Iterable<Bitmap> {
+ // The number of bytes that compacting will save for us to bother doing it.
+ public static final int COMPACT_THRESHOLD = 4000;
+
+ // Some geometry of an ICO file.
+ public static final int ICO_HEADER_LENGTH_BYTES = 6;
+ public static final int ICO_ICONDIRENTRY_LENGTH_BYTES = 16;
+
+ // The buffer containing bytes to attempt to decode.
+ private byte[] decodand;
+
+ // The region of the decodand to decode.
+ private int offset;
+ private int len;
+
+ IconDirectoryEntry[] iconDirectory;
+ private boolean isValid;
+ private boolean hasDecoded;
+ private int largestFaviconSize;
+
+ @RobocopTarget
+ public ICODecoder(Context context, byte[] decodand, int offset, int len) {
+ this.decodand = decodand;
+ this.offset = offset;
+ this.len = len;
+ this.largestFaviconSize = context.getResources()
+ .getDimensionPixelSize(R.dimen.favicon_largest_interesting_size);
+ }
+
+ /**
+ * Decode the Icon Directory for this ICO and store the result in iconDirectory.
+ *
+ * @return true if ICO decoding was considered to probably be a success, false if it certainly
+ * was a failure.
+ */
+ private boolean decodeIconDirectoryAndPossiblyPrune() {
+ hasDecoded = true;
+
+ // Fail if the end of the described range is out of bounds.
+ if (offset + len > decodand.length) {
+ return false;
+ }
+
+ // Fail if we don't have enough space for the header.
+ if (len < ICO_HEADER_LENGTH_BYTES) {
+ return false;
+ }
+
+ // Check that the reserved fields in the header are indeed zero, and that the type field
+ // specifies ICO. If not, we've probably been given something that isn't really an ICO.
+ if (decodand[offset] != 0 ||
+ decodand[offset + 1] != 0 ||
+ decodand[offset + 2] != 1 ||
+ decodand[offset + 3] != 0) {
+ return false;
+ }
+
+ // Here, and in many other places, byte values are ANDed with 0xFF. This is because Java
+ // bytes are signed - to obtain a numerical value of a longer type which holds the unsigned
+ // interpretation of the byte of interest, we do this.
+ int numEncodedImages = (decodand[offset + 4] & 0xFF) |
+ (decodand[offset + 5] & 0xFF) << 8;
+
+
+ // Fail if there are no images or the field is corrupt.
+ if (numEncodedImages <= 0) {
+ return false;
+ }
+
+ final int headerAndDirectorySize = ICO_HEADER_LENGTH_BYTES + (numEncodedImages * ICO_ICONDIRENTRY_LENGTH_BYTES);
+
+ // Fail if there is not enough space in the buffer for the stated number of icondir entries,
+ // let alone the data.
+ if (len < headerAndDirectorySize) {
+ return false;
+ }
+
+ // Put the pointer on the first byte of the first Icon Directory Entry.
+ int bufferIndex = offset + ICO_HEADER_LENGTH_BYTES;
+
+ // We now iterate over the Icon Directory, decoding each entry as we go. We also need to
+ // discard all entries except one >= the maximum interesting size.
+
+ // Size of the smallest image larger than the limit encountered.
+ int minimumMaximum = Integer.MAX_VALUE;
+
+ // Used to track the best entry for each size. The entries we want to keep.
+ SparseArray<IconDirectoryEntry> preferenceArray = new SparseArray<IconDirectoryEntry>();
+
+ for (int i = 0; i < numEncodedImages; i++, bufferIndex += ICO_ICONDIRENTRY_LENGTH_BYTES) {
+ // Decode the Icon Directory Entry at this offset.
+ IconDirectoryEntry newEntry = IconDirectoryEntry.createFromBuffer(decodand, offset, len, bufferIndex);
+ newEntry.index = i;
+
+ if (newEntry.isErroneous) {
+ continue;
+ }
+
+ if (newEntry.width > largestFaviconSize) {
+ // If we already have a smaller image larger than the maximum size of interest, we
+ // don't care about the new one which is larger than the smallest image larger than
+ // the maximum size.
+ if (newEntry.width >= minimumMaximum) {
+ continue;
+ }
+
+ // Remove the previous minimum-maximum.
+ preferenceArray.delete(minimumMaximum);
+
+ minimumMaximum = newEntry.width;
+ }
+
+ IconDirectoryEntry oldEntry = preferenceArray.get(newEntry.width);
+ if (oldEntry == null) {
+ preferenceArray.put(newEntry.width, newEntry);
+ continue;
+ }
+
+ if (oldEntry.compareTo(newEntry) < 0) {
+ preferenceArray.put(newEntry.width, newEntry);
+ }
+ }
+
+ final int count = preferenceArray.size();
+
+ // Abort if no entries are desired (Perhaps all are corrupt?)
+ if (count == 0) {
+ return false;
+ }
+
+ // Allocate space for the icon directory entries in the decoded directory.
+ iconDirectory = new IconDirectoryEntry[count];
+
+ // The size of the data in the buffer that we find useful.
+ int retainedSpace = ICO_HEADER_LENGTH_BYTES;
+
+ for (int i = 0; i < count; i++) {
+ IconDirectoryEntry e = preferenceArray.valueAt(i);
+ retainedSpace += ICO_ICONDIRENTRY_LENGTH_BYTES + e.payloadSize;
+ iconDirectory[i] = e;
+ }
+
+ isValid = true;
+
+ // Set the number of images field in the buffer to reflect the number of retained entries.
+ decodand[offset + 4] = (byte) iconDirectory.length;
+ decodand[offset + 5] = (byte) (iconDirectory.length >>> 8);
+
+ if ((len - retainedSpace) > COMPACT_THRESHOLD) {
+ compactingCopy(retainedSpace);
+ }
+
+ return true;
+ }
+
+ /**
+ * Copy the buffer into a new array of exactly the required size, omitting any unwanted data.
+ */
+ private void compactingCopy(int spaceRetained) {
+ byte[] buf = new byte[spaceRetained];
+
+ // Copy the header.
+ System.arraycopy(decodand, offset, buf, 0, ICO_HEADER_LENGTH_BYTES);
+
+ int headerPtr = ICO_HEADER_LENGTH_BYTES;
+
+ int payloadPtr = ICO_HEADER_LENGTH_BYTES + (iconDirectory.length * ICO_ICONDIRENTRY_LENGTH_BYTES);
+
+ int ind = 0;
+ for (IconDirectoryEntry entry : iconDirectory) {
+ // Copy this entry.
+ System.arraycopy(decodand, offset + entry.getOffset(), buf, headerPtr, ICO_ICONDIRENTRY_LENGTH_BYTES);
+
+ // Copy its payload.
+ System.arraycopy(decodand, offset + entry.payloadOffset, buf, payloadPtr, entry.payloadSize);
+
+ // Update the offset field.
+ buf[headerPtr + 12] = (byte) payloadPtr;
+ buf[headerPtr + 13] = (byte) (payloadPtr >>> 8);
+ buf[headerPtr + 14] = (byte) (payloadPtr >>> 16);
+ buf[headerPtr + 15] = (byte) (payloadPtr >>> 24);
+
+ entry.payloadOffset = payloadPtr;
+ entry.index = ind;
+
+ payloadPtr += entry.payloadSize;
+ headerPtr += ICO_ICONDIRENTRY_LENGTH_BYTES;
+ ind++;
+ }
+
+ decodand = buf;
+ offset = 0;
+ len = spaceRetained;
+ }
+
+ /**
+ * Decode and return the bitmap represented by the given index in the Icon Directory, if valid.
+ *
+ * @param index The index into the Icon Directory of the image of interest.
+ * @return The decoded Bitmap object for this image, or null if the entry is invalid or decoding
+ * fails.
+ */
+ public Bitmap decodeBitmapAtIndex(int index) {
+ final IconDirectoryEntry iconDirEntry = iconDirectory[index];
+
+ if (iconDirEntry.payloadIsPNG) {
+ // PNG payload. Simply extract it and decode it.
+ return BitmapUtils.decodeByteArray(decodand, offset + iconDirEntry.payloadOffset, iconDirEntry.payloadSize);
+ }
+
+ // The payload is a BMP, so we need to do some magic to get the decoder to do what we want.
+ // We construct an ICO containing just the image we want, and let Android do the rest.
+ byte[] decodeTarget = new byte[ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES + iconDirEntry.payloadSize];
+
+ // Set the type field in the ICO header.
+ decodeTarget[2] = 1;
+
+ // Set the num-images field in the header to 1.
+ decodeTarget[4] = 1;
+
+ // Copy the ICONDIRENTRY we need into the new buffer.
+ System.arraycopy(decodand, offset + iconDirEntry.getOffset(), decodeTarget, ICO_HEADER_LENGTH_BYTES, ICO_ICONDIRENTRY_LENGTH_BYTES);
+
+ // Copy the payload into the new buffer.
+ final int singlePayloadOffset = ICO_HEADER_LENGTH_BYTES + ICO_ICONDIRENTRY_LENGTH_BYTES;
+ System.arraycopy(decodand, offset + iconDirEntry.payloadOffset, decodeTarget, singlePayloadOffset, iconDirEntry.payloadSize);
+
+ // Update the offset field of the ICONDIRENTRY to make the new ICO valid.
+ decodeTarget[ICO_HEADER_LENGTH_BYTES + 12] = singlePayloadOffset;
+ decodeTarget[ICO_HEADER_LENGTH_BYTES + 13] = (singlePayloadOffset >>> 8);
+ decodeTarget[ICO_HEADER_LENGTH_BYTES + 14] = (singlePayloadOffset >>> 16);
+ decodeTarget[ICO_HEADER_LENGTH_BYTES + 15] = (singlePayloadOffset >>> 24);
+
+ // Decode the newly-constructed singleton-ICO.
+ return BitmapUtils.decodeByteArray(decodeTarget);
+ }
+
+ /**
+ * Fetch an iterator over the images in this ICO, or null if this ICO seems to be invalid.
+ *
+ * @return An iterator over the Bitmaps stored in this ICO, or null if decoding fails.
+ */
+ @Override
+ public ICOIterator iterator() {
+ // If a previous call to decode concluded this ICO is invalid, abort.
+ if (hasDecoded && !isValid) {
+ return null;
+ }
+
+ // If we've not been decoded before, but now fail to make any sense of the ICO, abort.
+ if (!hasDecoded) {
+ if (!decodeIconDirectoryAndPossiblyPrune()) {
+ return null;
+ }
+ }
+
+ // If decoding was a success, return an iterator over the images in this ICO.
+ return new ICOIterator();
+ }
+
+ /**
+ * Decode this ICO and return the result as a LoadFaviconResult.
+ * @return A LoadFaviconResult representing the decoded ICO.
+ */
+ public LoadFaviconResult decode() {
+ // The call to iterator returns null if decoding fails.
+ Iterator<Bitmap> bitmaps = iterator();
+ if (bitmaps == null) {
+ return null;
+ }
+
+ LoadFaviconResult result = new LoadFaviconResult();
+
+ result.bitmapsDecoded = bitmaps;
+ result.faviconBytes = decodand;
+ result.offset = offset;
+ result.length = len;
+ result.isICO = true;
+
+ return result;
+ }
+
+ @VisibleForTesting
+ @RobocopTarget
+ public IconDirectoryEntry[] getIconDirectory() {
+ return iconDirectory;
+ }
+
+ @VisibleForTesting
+ @RobocopTarget
+ public int getLargestFaviconSize() {
+ return largestFaviconSize;
+ }
+
+ /**
+ * Inner class to iterate over the elements in the ICO represented by the enclosing instance.
+ */
+ private class ICOIterator implements Iterator<Bitmap> {
+ private int mIndex;
+
+ @Override
+ public boolean hasNext() {
+ return mIndex < iconDirectory.length;
+ }
+
+ @Override
+ public Bitmap next() {
+ if (mIndex > iconDirectory.length) {
+ throw new NoSuchElementException("No more elements in this ICO.");
+ }
+ return decodeBitmapAtIndex(mIndex++);
+ }
+
+ @Override
+ public void remove() {
+ if (iconDirectory[mIndex] == null) {
+ throw new IllegalStateException("Remove already called for element " + mIndex);
+ }
+ iconDirectory[mIndex] = null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java
new file mode 100644
index 000000000..82ff91a55
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/IconDirectoryEntry.java
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.decoders;
+
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+/**
+ * Representation of an ICO file ICONDIRENTRY structure.
+ */
+public class IconDirectoryEntry implements Comparable<IconDirectoryEntry> {
+
+ public static int maxBPP;
+
+ int width;
+ int height;
+ int paletteSize;
+ int bitsPerPixel;
+ int payloadSize;
+ int payloadOffset;
+ boolean payloadIsPNG;
+
+ // Tracks the index in the Icon Directory of this entry. Useful only for pruning.
+ int index;
+ boolean isErroneous;
+
+ @RobocopTarget
+ public IconDirectoryEntry(int width, int height, int paletteSize, int bitsPerPixel, int payloadSize, int payloadOffset, boolean payloadIsPNG) {
+ this.width = width;
+ this.height = height;
+ this.paletteSize = paletteSize;
+ this.bitsPerPixel = bitsPerPixel;
+ this.payloadSize = payloadSize;
+ this.payloadOffset = payloadOffset;
+ this.payloadIsPNG = payloadIsPNG;
+ }
+
+ /**
+ * Method to get a dummy Icon Directory Entry with the Erroneous bit set.
+ *
+ * @return An erroneous placeholder Icon Directory Entry.
+ */
+ public static IconDirectoryEntry getErroneousEntry() {
+ IconDirectoryEntry ret = new IconDirectoryEntry(-1, -1, -1, -1, -1, -1, false);
+ ret.isErroneous = true;
+
+ return ret;
+ }
+
+ /**
+ * Create an IconDirectoryEntry object from a byte[]. Interprets the buffer starting at the given
+ * offset as an IconDirectoryEntry and returns the result.
+ *
+ * @param buffer Byte array containing the icon directory entry to decode.
+ * @param regionOffset Offset into the byte array of the valid region of the buffer.
+ * @param regionLength Length of the valid region in the buffer.
+ * @param entryOffset Offset of the icon directory entry to decode within the buffer.
+ * @return An IconDirectoryEntry object representing the entry specified, or null if the entry
+ * is obviously invalid.
+ */
+ public static IconDirectoryEntry createFromBuffer(byte[] buffer, int regionOffset, int regionLength, int entryOffset) {
+ // Verify that the reserved field is really zero.
+ if (buffer[entryOffset + 3] != 0) {
+ return getErroneousEntry();
+ }
+
+ // Verify that the entry points to a region that actually exists in the buffer, else bin it.
+ int fieldPtr = entryOffset + 8;
+ int entryLength = (buffer[fieldPtr] & 0xFF) |
+ (buffer[fieldPtr + 1] & 0xFF) << 8 |
+ (buffer[fieldPtr + 2] & 0xFF) << 16 |
+ (buffer[fieldPtr + 3] & 0xFF) << 24;
+
+ // Advance to the offset field.
+ fieldPtr += 4;
+
+ int payloadOffset = (buffer[fieldPtr] & 0xFF) |
+ (buffer[fieldPtr + 1] & 0xFF) << 8 |
+ (buffer[fieldPtr + 2] & 0xFF) << 16 |
+ (buffer[fieldPtr + 3] & 0xFF) << 24;
+
+ // Fail if the entry describes a region outside the buffer.
+ if (payloadOffset < 0 || entryLength < 0 || payloadOffset + entryLength > regionOffset + regionLength) {
+ return getErroneousEntry();
+ }
+
+ // Extract the image dimensions.
+ int imageWidth = buffer[entryOffset] & 0xFF;
+ int imageHeight = buffer[entryOffset + 1] & 0xFF;
+
+ // Because Microsoft, a size value of zero represents an image size of 256.
+ if (imageWidth == 0) {
+ imageWidth = 256;
+ }
+
+ if (imageHeight == 0) {
+ imageHeight = 256;
+ }
+
+ // If the image uses a colour palette, this is the number of colours, otherwise this is zero.
+ int paletteSize = buffer[entryOffset + 2] & 0xFF;
+
+ // The plane count - usually 0 or 1. When > 1, taken as multiplier on bitsPerPixel.
+ int colorPlanes = buffer[entryOffset + 4] & 0xFF;
+
+ int bitsPerPixel = (buffer[entryOffset + 6] & 0xFF) |
+ (buffer[entryOffset + 7] & 0xFF) << 8;
+
+ if (colorPlanes > 1) {
+ bitsPerPixel *= colorPlanes;
+ }
+
+ // Look for PNG magic numbers at the start of the payload.
+ boolean payloadIsPNG = FaviconDecoder.bufferStartsWith(buffer, FaviconDecoder.ImageMagicNumbers.PNG.value, regionOffset + payloadOffset);
+
+ return new IconDirectoryEntry(imageWidth, imageHeight, paletteSize, bitsPerPixel, entryLength, payloadOffset, payloadIsPNG);
+ }
+
+ /**
+ * Get the number of bytes from the start of the ICO file to the beginning of this entry.
+ */
+ public int getOffset() {
+ return ICODecoder.ICO_HEADER_LENGTH_BYTES + (index * ICODecoder.ICO_ICONDIRENTRY_LENGTH_BYTES);
+ }
+
+ @Override
+ public int compareTo(IconDirectoryEntry another) {
+ if (width > another.width) {
+ return 1;
+ }
+
+ if (width < another.width) {
+ return -1;
+ }
+
+ // Where both images exceed the max BPP, take the smaller of the two BPP values.
+ if (bitsPerPixel >= maxBPP && another.bitsPerPixel >= maxBPP) {
+ if (bitsPerPixel < another.bitsPerPixel) {
+ return 1;
+ }
+
+ if (bitsPerPixel > another.bitsPerPixel) {
+ return -1;
+ }
+ }
+
+ // Otherwise, take the larger of the BPP values.
+ if (bitsPerPixel > another.bitsPerPixel) {
+ return 1;
+ }
+
+ if (bitsPerPixel < another.bitsPerPixel) {
+ return -1;
+ }
+
+ // Prefer large palettes.
+ if (paletteSize > another.paletteSize) {
+ return 1;
+ }
+
+ if (paletteSize < another.paletteSize) {
+ return -1;
+ }
+
+ // Prefer smaller payloads.
+ if (payloadSize < another.payloadSize) {
+ return 1;
+ }
+
+ if (payloadSize > another.payloadSize) {
+ return -1;
+ }
+
+ // If all else fails, prefer PNGs over BMPs. They tend to be smaller.
+ if (payloadIsPNG && !another.payloadIsPNG) {
+ return 1;
+ }
+
+ if (!payloadIsPNG && another.payloadIsPNG) {
+ return -1;
+ }
+
+ return 0;
+ }
+
+ public static void setMaxBPP(int maxBPP) {
+ IconDirectoryEntry.maxBPP = maxBPP;
+ }
+
+ @VisibleForTesting
+ @RobocopTarget
+ public int getWidth() {
+ return width;
+ }
+
+ @Override
+ public String toString() {
+ return "IconDirectoryEntry{" +
+ "\nwidth=" + width +
+ ", \nheight=" + height +
+ ", \npaletteSize=" + paletteSize +
+ ", \nbitsPerPixel=" + bitsPerPixel +
+ ", \npayloadSize=" + payloadSize +
+ ", \npayloadOffset=" + payloadOffset +
+ ", \npayloadIsPNG=" + payloadIsPNG +
+ ", \nindex=" + index +
+ '}';
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java
new file mode 100644
index 000000000..cc196b91e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/decoders/LoadFaviconResult.java
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.decoders;
+
+import android.graphics.Bitmap;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.SparseArray;
+
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Class representing the result of loading a favicon.
+ * This operation will produce either a collection of favicons, a single favicon, or no favicon.
+ * It is necessary to model single favicons differently to a collection of one favicon (An entity
+ * that may not exist with this scheme) since the in-database representation of these things differ.
+ * (In particular, collections of favicons are stored in encoded ICO format, whereas single icons are
+ * stored as decoded bitmap blobs.)
+ */
+public class LoadFaviconResult {
+ private static final String LOGTAG = "LoadFaviconResult";
+
+ byte[] faviconBytes;
+ int offset;
+ int length;
+
+ boolean isICO;
+ Iterator<Bitmap> bitmapsDecoded;
+
+ public Iterator<Bitmap> getBitmaps() {
+ return bitmapsDecoded;
+ }
+
+ /**
+ * Return a representation of this result suitable for storing in the database.
+ *
+ * @return A byte array containing the bytes from which this result was decoded,
+ * or null if re-encoding failed.
+ */
+ public byte[] getBytesForDatabaseStorage() {
+ // Begin by normalising the buffer.
+ if (offset != 0 || length != faviconBytes.length) {
+ final byte[] normalised = new byte[length];
+ System.arraycopy(faviconBytes, offset, normalised, 0, length);
+ offset = 0;
+ faviconBytes = normalised;
+ }
+
+ // For results containing multiple images, we store the result verbatim. (But cutting the
+ // buffer to size first).
+ // We may instead want to consider re-encoding the entire ICO as a collection of efficiently
+ // encoded PNGs. This may not be worth the CPU time (Indeed, the encoding of single-image
+ // favicons may also not be worth the time/space tradeoff.).
+ if (isICO) {
+ return faviconBytes;
+ }
+
+ // For results containing a single image, we re-encode the
+ // result as a PNG in an effort to save space.
+ final Bitmap favicon = ((FaviconDecoder.SingleBitmapIterator) bitmapsDecoded).peek();
+ final ByteArrayOutputStream stream = new ByteArrayOutputStream();
+
+ try {
+ if (favicon.compress(Bitmap.CompressFormat.PNG, 100, stream)) {
+ return stream.toByteArray();
+ }
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Out of memory re-compressing favicon.");
+ }
+
+ Log.w(LOGTAG, "Favicon re-compression failed.");
+ return null;
+ }
+
+ @Nullable
+ public Bitmap getBestBitmap(int targetWidthAndHeight) {
+ final SparseArray<Bitmap> iconMap = new SparseArray<>();
+ final List<Integer> sizes = new ArrayList<>();
+
+ while (bitmapsDecoded.hasNext()) {
+ final Bitmap b = bitmapsDecoded.next();
+
+ // It's possible to receive null, most likely due to OOM or a zero-sized image,
+ // from BitmapUtils.decodeByteArray(byte[], int, int, BitmapFactory.Options)
+ if (b != null) {
+ iconMap.put(b.getWidth(), b);
+ sizes.add(b.getWidth());
+ }
+ }
+
+ int bestSize = selectBestSizeFromList(sizes, targetWidthAndHeight);
+
+ if (bestSize == -1) {
+ // No icons found: this could occur if we weren't able to process any of the
+ // supplied icons.
+ return null;
+ }
+
+ return iconMap.get(bestSize);
+ }
+
+ /**
+ * Select the closest icon size from a list of icon sizes.
+ * We just find the first icon that is larger than the preferred size if available, or otherwise select the
+ * largest icon (if all icons are smaller than the preferred size).
+ *
+ * @return The closest icon size, or -1 if no sizes are supplied.
+ */
+ public static int selectBestSizeFromList(final List<Integer> sizes, final int preferredSize) {
+ if (sizes.isEmpty()) {
+ // This isn't ideal, however current code assumes this as an error value for now.
+ return -1;
+ }
+
+ Collections.sort(sizes);
+
+ for (int size : sizes) {
+ if (size >= preferredSize) {
+ return size;
+ }
+ }
+
+ // If all icons are smaller than the preferred size then we don't have an icon
+ // selected yet, therefore just take the largest (last) icon.
+ return sizes.get(sizes.size() - 1);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java
new file mode 100644
index 000000000..be8e6d7de
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/ContentProviderLoader.java
@@ -0,0 +1,96 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.distribution.PartnerBookmarksProviderProxy;
+import org.mozilla.gecko.icons.decoders.FaviconDecoder;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Loader for loading icons from a content provider. This loader was primarily written to load icons
+ * from the partner bookmarks provider. However it can load icons from arbitrary content providers
+ * as long as they return a cursor with a "favicon" or "touchicon" column (blob).
+ */
+public class ContentProviderLoader implements IconLoader {
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.shouldSkipDisk()) {
+ // If we should not load data from disk then we do not load from content providers either.
+ return null;
+ }
+
+ final String iconUrl = request.getBestIcon().getUrl();
+ final Context context = request.getContext();
+ final int targetSize = request.getTargetSize();
+
+ if (TextUtils.isEmpty(iconUrl) || !iconUrl.startsWith("content://")) {
+ return null;
+ }
+
+ Cursor cursor = context.getContentResolver().query(
+ Uri.parse(iconUrl),
+ new String[] {
+ PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON,
+ PartnerBookmarksProviderProxy.PartnerContract.FAVICON,
+ },
+ null,
+ null,
+ null
+ );
+
+ if (cursor == null) {
+ return null;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+
+ // Try the touch icon first. It has a higher resolution usually.
+ Bitmap icon = decodeFromCursor(request.getContext(), cursor, PartnerBookmarksProviderProxy.PartnerContract.TOUCHICON, targetSize);
+ if (icon != null) {
+ return IconResponse.create(icon);
+ }
+
+ icon = decodeFromCursor(request.getContext(), cursor, PartnerBookmarksProviderProxy.PartnerContract.FAVICON, targetSize);
+ if (icon != null) {
+ return IconResponse.create(icon);
+ }
+ } finally {
+ cursor.close();
+ }
+
+ return null;
+ }
+
+ private Bitmap decodeFromCursor(Context context, Cursor cursor, String column, int targetWidthAndHeight) {
+ final int index = cursor.getColumnIndex(column);
+ if (index == -1) {
+ return null;
+ }
+
+ if (cursor.isNull(index)) {
+ return null;
+ }
+
+ final byte[] data = cursor.getBlob(index);
+ LoadFaviconResult result = FaviconDecoder.decodeFavicon(context, data, 0, data.length);
+ if (result == null) {
+ return null;
+ }
+
+ return result.getBestBitmap(targetWidthAndHeight);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java
new file mode 100644
index 000000000..9ddc138ec
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DataUriLoader.java
@@ -0,0 +1,36 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.mozilla.gecko.icons.decoders.FaviconDecoder;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Loader for loading icons from a data URI. This loader will try to decode any data with an
+ * "image/*" MIME type.
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
+ */
+public class DataUriLoader implements IconLoader {
+ @Override
+ public IconResponse load(IconRequest request) {
+ final String iconUrl = request.getBestIcon().getUrl();
+
+ if (!iconUrl.startsWith("data:image/")) {
+ return null;
+ }
+
+ LoadFaviconResult loadFaviconResult = FaviconDecoder.decodeDataURI(request.getContext(), iconUrl);
+ if (loadFaviconResult == null) {
+ return null;
+ }
+
+ return IconResponse.create(
+ loadFaviconResult.getBestBitmap(request.getTargetSize()));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java
new file mode 100644
index 000000000..18a38e32b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/DiskLoader.java
@@ -0,0 +1,27 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+
+/**
+ * Loader implementation for loading icons from the disk cache (Implemented by DiskStorage).
+ */
+public class DiskLoader implements IconLoader {
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.shouldSkipDisk()) {
+ return null;
+ }
+
+ final DiskStorage storage = DiskStorage.get(request.getContext());
+ final String iconUrl = request.getBestIcon().getUrl();
+
+ return storage.getIcon(iconUrl);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java
new file mode 100644
index 000000000..3ae9d15d0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconDownloader.java
@@ -0,0 +1,219 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.icons.decoders.FaviconDecoder;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.FailureCache;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.ProxySelector;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashSet;
+
+/**
+ * This loader implementation downloads icons from http(s) URLs.
+ */
+public class IconDownloader implements IconLoader {
+ private static final String LOGTAG = "Gecko/Downloader";
+
+ /**
+ * The maximum number of http redirects (3xx) until we give up.
+ */
+ private static final int MAX_REDIRECTS_TO_FOLLOW = 5;
+
+ /**
+ * The default size of the buffer to use for downloading Favicons in the event no size is given
+ * by the server. */
+ private static final int DEFAULT_FAVICON_BUFFER_SIZE_BYTES = 25000;
+
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.shouldSkipNetwork()) {
+ return null;
+ }
+
+ final String iconUrl = request.getBestIcon().getUrl();
+
+ if (!StringUtils.isHttpOrHttps(iconUrl)) {
+ return null;
+ }
+
+ try {
+ final LoadFaviconResult result = downloadAndDecodeImage(request.getContext(), iconUrl);
+ if (result == null) {
+ return null;
+ }
+
+ final Bitmap bitmap = result.getBestBitmap(request.getTargetSize());
+ if (bitmap == null) {
+ return null;
+ }
+
+ return IconResponse.createFromNetwork(bitmap, iconUrl);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Error reading favicon", e);
+ } catch (OutOfMemoryError e) {
+ Log.e(LOGTAG, "Insufficient memory to process favicon");
+ }
+
+ return null;
+ }
+
+ /**
+ * Download the Favicon from the given URL and pass it to the decoder function.
+ *
+ * @param targetFaviconURL URL of the favicon to download.
+ * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or
+ * null if no or corrupt data was received.
+ * @throws IOException If attempts to fully read the stream result in such an exception, such as
+ * in the event of a transient connection failure.
+ * @throws URISyntaxException If the underlying call to tryDownload retries and raises such an
+ * exception trying a fallback URL.
+ */
+ @VisibleForTesting
+ LoadFaviconResult downloadAndDecodeImage(Context context, String targetFaviconURL) throws IOException, URISyntaxException {
+ // Try the URL we were given.
+ final HttpURLConnection connection = tryDownload(targetFaviconURL);
+ if (connection == null) {
+ return null;
+ }
+
+ InputStream stream = null;
+
+ // Decode the image from the fetched response.
+ try {
+ stream = connection.getInputStream();
+ return decodeImageFromResponse(context, stream, connection.getHeaderFieldInt("Content-Length", -1));
+ } finally {
+ // Close the stream and free related resources.
+ IOUtils.safeStreamClose(stream);
+ connection.disconnect();
+ }
+ }
+
+ /**
+ * Helper method for trying the download request to grab a Favicon.
+ *
+ * @param faviconURI URL of Favicon to try and download
+ * @return The HttpResponse containing the downloaded Favicon if successful, null otherwise.
+ */
+ private HttpURLConnection tryDownload(String faviconURI) throws URISyntaxException, IOException {
+ final HashSet<String> visitedLinkSet = new HashSet<>();
+ visitedLinkSet.add(faviconURI);
+ return tryDownloadRecurse(faviconURI, visitedLinkSet);
+ }
+
+ /**
+ * Try to download from the favicon URL and recursively follow redirects.
+ */
+ private HttpURLConnection tryDownloadRecurse(String faviconURI, HashSet<String> visited) throws URISyntaxException, IOException {
+ if (visited.size() == MAX_REDIRECTS_TO_FOLLOW) {
+ return null;
+ }
+
+ final HttpURLConnection connection = connectTo(faviconURI);
+
+ // Was the response a failure?
+ final int status = connection.getResponseCode();
+
+ // Handle HTTP status codes requesting a redirect.
+ if (status >= 300 && status < 400) {
+ final String newURI = connection.getHeaderField("Location");
+
+ // Handle mad web servers.
+ try {
+ if (newURI == null || newURI.equals(faviconURI)) {
+ return null;
+ }
+
+ if (visited.contains(newURI)) {
+ // Already been redirected here - abort.
+ return null;
+ }
+
+ visited.add(newURI);
+ } finally {
+ connection.disconnect();
+ }
+
+ return tryDownloadRecurse(newURI, visited);
+ }
+
+ if (status >= 400) {
+ // Client or Server error. Let's not retry loading from this URL again for some time.
+ FailureCache.get().rememberFailure(faviconURI);
+
+ connection.disconnect();
+ return null;
+ }
+
+ return connection;
+ }
+
+ @VisibleForTesting
+ HttpURLConnection connectTo(String faviconURI) throws URISyntaxException, IOException {
+ final HttpURLConnection connection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(
+ new URI(faviconURI));
+
+ connection.setRequestProperty("User-Agent", GeckoAppShell.getGeckoInterface().getDefaultUAString());
+
+ // We implemented or own way of following redirects back when this code was using HttpClient.
+ // Nowadays we should let HttpUrlConnection do the work - assuming that it doesn't follow
+ // redirects in loops forever.
+ connection.setInstanceFollowRedirects(false);
+
+ connection.connect();
+
+ return connection;
+ }
+
+ /**
+ * Copies the favicon stream to a buffer and decodes downloaded content into bitmaps using the
+ * FaviconDecoder.
+ *
+ * @param stream to decode
+ * @param contentLength as reported by the server (or -1)
+ * @return A LoadFaviconResult containing the bitmap(s) extracted from the downloaded file, or
+ * null if no or corrupt data were received.
+ * @throws IOException If attempts to fully read the stream result in such an exception, such as
+ * in the event of a transient connection failure.
+ */
+ private LoadFaviconResult decodeImageFromResponse(Context context, InputStream stream, int contentLength) throws IOException {
+ // This may not be provided, but if it is, it's useful.
+ final int bufferSize;
+ if (contentLength > 0) {
+ // The size was reported and sane, so let's use that.
+ // Integer overflow should not be a problem for Favicon sizes...
+ bufferSize = contentLength + 1;
+ } else {
+ // No declared size, so guess and reallocate later if it turns out to be too small.
+ bufferSize = DEFAULT_FAVICON_BUFFER_SIZE_BYTES;
+ }
+
+ // Read the InputStream into a byte[].
+ final IOUtils.ConsumedInputStream result = IOUtils.readFully(stream, bufferSize);
+ if (result == null) {
+ return null;
+ }
+
+ // Having downloaded the image, decode it.
+ return FaviconDecoder.decodeFavicon(context, result.getData(), 0, result.consumedLength);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java
new file mode 100644
index 000000000..e0139345d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconGenerator.java
@@ -0,0 +1,168 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.TypedValue;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * This loader will generate an icon in case no icon could be loaded. In order to do so this needs
+ * to be the last loader that will be tried.
+ */
+public class IconGenerator implements IconLoader {
+ // Mozilla's Visual Design Colour Palette
+ // http://firefoxux.github.io/StyleGuide/#/visualDesign/colours
+ private static final int[] COLORS = {
+ 0xFFc33c32,
+ 0xFFf25820,
+ 0xFFff9216,
+ 0xFFffcb00,
+ 0xFF57bd35,
+ 0xFF01bdad,
+ 0xFF0996f8,
+ 0xFF02538b,
+ 0xFF1f386e,
+ 0xFF7a2f7a,
+ 0xFFea385e,
+ };
+
+ // List of common prefixes of host names. Those prefixes will be striped before a prepresentative
+ // character for an URL is determined.
+ private static final String[] COMMON_PREFIXES = {
+ "www.",
+ "m.",
+ "mobile.",
+ };
+
+ private static final int TEXT_SIZE_DP = 12;
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.getIconCount() > 1) {
+ // There are still other icons to try. We will only generate an icon if there's only one
+ // icon left and all previous loaders have failed (assuming this is the last one).
+ return null;
+ }
+
+ return generate(request.getContext(), request.getPageUrl());
+ }
+
+ /**
+ * Generate default favicon for the given page URL.
+ */
+ @VisibleForTesting static IconResponse generate(Context context, String pageURL) {
+ final Resources resources = context.getResources();
+ final int widthAndHeight = resources.getDimensionPixelSize(R.dimen.favicon_bg);
+ final int roundedCorners = resources.getDimensionPixelOffset(R.dimen.favicon_corner_radius);
+
+ final Bitmap favicon = Bitmap.createBitmap(widthAndHeight, widthAndHeight, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(favicon);
+
+ final int color = pickColor(pageURL);
+
+ final Paint paint = new Paint();
+ paint.setColor(color);
+
+ canvas.drawRoundRect(new RectF(0, 0, widthAndHeight, widthAndHeight), roundedCorners, roundedCorners, paint);
+
+ paint.setColor(Color.WHITE);
+
+ final String character = getRepresentativeCharacter(pageURL);
+
+ final float textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TEXT_SIZE_DP, context.getResources().getDisplayMetrics());
+
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setTextSize(textSize);
+ paint.setAntiAlias(true);
+
+ canvas.drawText(character,
+ canvas.getWidth() / 2,
+ (int) ((canvas.getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2)),
+ paint);
+
+ return IconResponse.createGenerated(favicon, color);
+ }
+
+ /**
+ * Get a representative character for the given URL.
+ *
+ * For example this method will return "f" for "http://m.facebook.com/foobar".
+ */
+ @VisibleForTesting static String getRepresentativeCharacter(String url) {
+ if (TextUtils.isEmpty(url)) {
+ return "?";
+ }
+
+ final String snippet = getRepresentativeSnippet(url);
+ for (int i = 0; i < snippet.length(); i++) {
+ char c = snippet.charAt(i);
+
+ if (Character.isLetterOrDigit(c)) {
+ return String.valueOf(Character.toUpperCase(c));
+ }
+ }
+
+ // Nothing found..
+ return "?";
+ }
+
+ /**
+ * Return a color for this URL. Colors will be based on the host. URLs with the same host will
+ * return the same color.
+ */
+ @VisibleForTesting static int pickColor(String url) {
+ if (TextUtils.isEmpty(url)) {
+ return COLORS[0];
+ }
+
+ final String snippet = getRepresentativeSnippet(url);
+ final int color = Math.abs(snippet.hashCode() % COLORS.length);
+
+ return COLORS[color];
+ }
+
+ /**
+ * Get the representative part of the URL. Usually this is the host (without common prefixes).
+ */
+ private static String getRepresentativeSnippet(@NonNull String url) {
+ Uri uri = Uri.parse(url);
+
+ // Use the host if available
+ String snippet = uri.getHost();
+
+ if (TextUtils.isEmpty(snippet)) {
+ // If the uri does not have a host (e.g. file:// uris) then use the path
+ snippet = uri.getPath();
+ }
+
+ if (TextUtils.isEmpty(snippet)) {
+ // If we still have no snippet then just return the question mark
+ return "?";
+ }
+
+ // Strip common prefixes that we do not want to use to determine the representative character
+ for (String prefix : COMMON_PREFIXES) {
+ if (snippet.startsWith(prefix)) {
+ snippet = snippet.substring(prefix.length());
+ }
+ }
+
+ return snippet;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java
new file mode 100644
index 000000000..8158babc3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/IconLoader.java
@@ -0,0 +1,23 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Generic interface for classes that can load icons.
+ */
+public interface IconLoader {
+ /**
+ * Loads the icon for this request or returns null if this loader can't load an icon for this
+ * request or just failed this time.
+ */
+ @Nullable
+ IconResponse load(IconRequest request);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java
new file mode 100644
index 000000000..882d32da5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/JarLoader.java
@@ -0,0 +1,45 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+/**
+ * Loader implementation for loading icons from the omni.ja (jar:jar: URLs).
+ *
+ * https://developer.mozilla.org/en-US/docs/Mozilla/About_omni.ja_(formerly_omni.jar)
+ */
+public class JarLoader implements IconLoader {
+ private static final String LOGTAG = "Gecko/JarLoader";
+
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.shouldSkipDisk()) {
+ return null;
+ }
+
+ final String iconUrl = request.getBestIcon().getUrl();
+
+ if (!iconUrl.startsWith("jar:jar:")) {
+ return null;
+ }
+
+ try {
+ final Context context = request.getContext();
+ return IconResponse.create(
+ GeckoJarReader.getBitmap(context, context.getResources(), iconUrl));
+ } catch (Exception e) {
+ // Just about anything could happen here.
+ Log.w(LOGTAG, "Error fetching favicon from JAR.", e);
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java
new file mode 100644
index 000000000..d1efc3ad9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/LegacyLoader.java
@@ -0,0 +1,74 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.graphics.Bitmap;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * This legacy loader loads icons from the abandoned database storage. This loader should only exist
+ * for a couple of releases and be removed afterwards.
+ *
+ * When updating to an app version with the new loaders our initial storage won't have any data so
+ * we need to continue loading from the database storage until the new storage has a good set of data.
+ */
+public class LegacyLoader implements IconLoader {
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (!request.shouldSkipNetwork()) {
+ // If we are allowed to load from the network for this request then just ommit the legacy
+ // loader and fetch a fresh new icon.
+ return null;
+ }
+
+ if (request.shouldSkipDisk()) {
+ return null;
+ }
+
+ if (request.getIconCount() > 1) {
+ // There are still other icon URLs to try. Let's try to load from the legacy loader only
+ // if there's one icon left and the other loads have failed. We will ignore the icon URL
+ // anyways and try to receive the legacy icon URL from the database.
+ return null;
+ }
+
+ final Bitmap bitmap = loadBitmapFromDatabase(request);
+
+ if (bitmap == null) {
+ return null;
+ }
+
+ return IconResponse.create(bitmap);
+ }
+
+ /* package-private */ Bitmap loadBitmapFromDatabase(IconRequest request) {
+ final Context context = request.getContext();
+ final ContentResolver contentResolver = context.getContentResolver();
+ final BrowserDB db = BrowserDB.from(context);
+
+ // We ask the database for the favicon URL and ignore the icon URL in the request object:
+ // As we are not updating the database anymore the icon might be stored under a different URL.
+ final String legacyFaviconUrl = db.getFaviconURLFromPageURL(contentResolver, request.getPageUrl());
+ if (legacyFaviconUrl == null) {
+ // No URL -> Nothing to load.
+ return null;
+ }
+
+ final LoadFaviconResult result = db.getFaviconForUrl(context, context.getContentResolver(), legacyFaviconUrl);
+ if (result == null) {
+ return null;
+ }
+
+ return result.getBestBitmap(request.getTargetSize());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java b/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java
new file mode 100644
index 000000000..98b651fc7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/loader/MemoryLoader.java
@@ -0,0 +1,31 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.loader;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+/**
+ * Loader implementation for loading icons from an in-memory cached (Implemented by MemoryStorage).
+ */
+public class MemoryLoader implements IconLoader {
+ private final MemoryStorage storage;
+
+ public MemoryLoader() {
+ storage = MemoryStorage.get();
+ }
+
+ @Override
+ public IconResponse load(IconRequest request) {
+ if (request.shouldSkipMemory()) {
+ return null;
+ }
+
+ final String iconUrl = request.getBestIcon().getUrl();
+ return storage.getIcon(iconUrl);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java
new file mode 100644
index 000000000..d335cbf51
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AboutPagesPreparer.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Preparer implementation for adding the omni.ja URL for internal about: pages.
+ */
+public class AboutPagesPreparer implements Preparer {
+ private Set<String> aboutUrls;
+
+ public AboutPagesPreparer() {
+ aboutUrls = new HashSet<>();
+
+ Collections.addAll(aboutUrls, AboutPages.DEFAULT_ICON_PAGES);
+ }
+
+ @Override
+ public void prepare(IconRequest request) {
+ if (aboutUrls.contains(request.getPageUrl())) {
+ final String iconUrl = GeckoJarReader.getJarURL(request.getContext(), "chrome/chrome/content/branding/favicon64.png");
+
+ request.modify()
+ .icon(IconDescriptor.createLookupIcon(iconUrl))
+ .deferBuild();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java
new file mode 100644
index 000000000..5bc7d1c1f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/AddDefaultIconUrl.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconsHelper;
+import org.mozilla.gecko.util.StringUtils;
+
+/**
+ * Preparer to add the "default/guessed" favicon URL (domain/favicon.ico) to the list of URLs to
+ * try loading the favicon from.
+ *
+ * The default URL will be added with a very low priority so that we will only try to load from this
+ * URL if all other options failed.
+ */
+public class AddDefaultIconUrl implements Preparer {
+ @Override
+ public void prepare(IconRequest request) {
+ if (!StringUtils.isHttpOrHttps(request.getPageUrl())) {
+ return;
+ }
+
+ final String defaultFaviconUrl = IconsHelper.guessDefaultFaviconURL(request.getPageUrl());
+ if (TextUtils.isEmpty(defaultFaviconUrl)) {
+ // We couldn't generate a default favicon URL for this URL. Nothing to do here.
+ return;
+ }
+
+ request.modify()
+ .icon(IconDescriptor.createGenericIcon(defaultFaviconUrl))
+ .deferBuild();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java
new file mode 100644
index 000000000..effd31a03
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterKnownFailureUrls.java
@@ -0,0 +1,29 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.storage.FailureCache;
+
+import java.util.Iterator;
+
+public class FilterKnownFailureUrls implements Preparer {
+ @Override
+ public void prepare(IconRequest request) {
+ final FailureCache failureCache = FailureCache.get();
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+ while (iterator.hasNext()) {
+ final IconDescriptor descriptor = iterator.next();
+
+ if (failureCache.isKnownFailure(descriptor.getUrl())) {
+ // Loading from this URL has failed in the past. Do not try again.
+ iterator.remove();
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java
new file mode 100644
index 000000000..a12dad2ad
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterMimeTypes.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import android.text.TextUtils;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconsHelper;
+
+import java.util.Iterator;
+
+/**
+ * Preparer implementation to filter unknown MIME types to avoid loading images that we cannot decode.
+ */
+public class FilterMimeTypes implements Preparer {
+ @Override
+ public void prepare(IconRequest request) {
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+ while (iterator.hasNext()) {
+ final IconDescriptor descriptor = iterator.next();
+ final String mimeType = descriptor.getMimeType();
+
+ if (TextUtils.isEmpty(mimeType)) {
+ // We do not have a MIME type for this icon, so we cannot know in advance if we are able
+ // to decode it. Let's just continue.
+ continue;
+ }
+
+ if (!IconsHelper.canDecodeType(mimeType)) {
+ iterator.remove();
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java
new file mode 100644
index 000000000..abf34c038
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/FilterPrivilegedUrls.java
@@ -0,0 +1,30 @@
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.util.Iterator;
+
+/**
+ * Filter non http/https URLs if the request is not from privileged code.
+ */
+public class FilterPrivilegedUrls implements Preparer {
+ @Override
+ public void prepare(IconRequest request) {
+ if (request.isPrivileged()) {
+ // This request is privileged. No need to filter anything.
+ return;
+ }
+
+ final Iterator<IconDescriptor> iterator = request.getIconIterator();
+
+ while (iterator.hasNext()) {
+ IconDescriptor descriptor = iterator.next();
+
+ if (!StringUtils.isHttpOrHttps(descriptor.getUrl())) {
+ iterator.remove();
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java
new file mode 100644
index 000000000..0c7641112
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/LookupIconUrl.java
@@ -0,0 +1,56 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+/**
+ * Preparer implementation to lookup the icon URL for the page URL in the request. This class tries
+ * to locate the icon URL by looking through previously stored mappings on disk and in memory.
+ */
+public class LookupIconUrl implements Preparer {
+ @Override
+ public void prepare(IconRequest request) {
+ if (lookupFromMemory(request)) {
+ return;
+ }
+
+ lookupFromDisk(request);
+ }
+
+ private boolean lookupFromMemory(IconRequest request) {
+ final String iconUrl = MemoryStorage.get()
+ .getMapping(request.getPageUrl());
+
+ if (iconUrl != null) {
+ request.modify()
+ .icon(IconDescriptor.createLookupIcon(iconUrl))
+ .deferBuild();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean lookupFromDisk(IconRequest request) {
+ final String iconUrl = DiskStorage.get(request.getContext())
+ .getMapping(request.getPageUrl());
+
+ if (iconUrl != null) {
+ request.modify()
+ .icon(IconDescriptor.createLookupIcon(iconUrl))
+ .deferBuild();
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java
new file mode 100644
index 000000000..466307ead
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/preparation/Preparer.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.preparation;
+
+import org.mozilla.gecko.icons.IconRequest;
+
+/**
+ * Generic interface for a class "preparing" a request before we try to load icons. A class
+ * implementing this interface can modify the request (e.g. filter or add icon URLs).
+ */
+public interface Preparer {
+ /**
+ * Inspects or modifies the request before any icon is loaded.
+ */
+ void prepare(IconRequest request);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java
new file mode 100644
index 000000000..3f7110034
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ColorProcessor.java
@@ -0,0 +1,61 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.support.v7.graphics.Palette;
+import android.util.Log;
+
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.util.HardwareUtils;
+
+/**
+ * Processor implementation to extract the dominant color from the icon and attach it to the icon
+ * response object.
+ */
+public class ColorProcessor implements Processor {
+ private static final String LOGTAG = "GeckoColorProcessor";
+ private static final int DEFAULT_COLOR = 0; // 0 == No color
+
+ @Override
+ public void process(IconRequest request, IconResponse response) {
+ if (response.hasColor()) {
+ return;
+ }
+
+ if (HardwareUtils.isX86System()) {
+ // (Bug 1318667) We are running into crashes when using the palette library with
+ // specific icons on x86 devices. They take down the whole VM and are not recoverable.
+ // Unfortunately our release icon is triggering this crash. Until we can switch to a
+ // newer version of the support library where this does not happen, we are using our
+ // own slower implementation.
+ extractColorUsingCustomImplementation(response);
+ } else {
+ extractColorUsingPaletteSupportLibrary(response);
+ }
+ }
+
+ private void extractColorUsingPaletteSupportLibrary(final IconResponse response) {
+ try {
+ final Palette palette = Palette.from(response.getBitmap()).generate();
+ response.updateColor(palette.getVibrantColor(DEFAULT_COLOR));
+ } catch (ArrayIndexOutOfBoundsException e) {
+ // We saw the palette library fail with an ArrayIndexOutOfBoundsException intermittently
+ // in automation. In this case lets just swallow the exception and move on without a
+ // color. This is a valid condition and callers should handle this gracefully (Bug 1318560).
+ Log.e(LOGTAG, "Palette generation failed with ArrayIndexOutOfBoundsException", e);
+
+ response.updateColor(DEFAULT_COLOR);
+ }
+ }
+
+ private void extractColorUsingCustomImplementation(final IconResponse response) {
+ final int dominantColor = BitmapUtils.getDominantColor(response.getBitmap());
+
+ response.updateColor(dominantColor);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java
new file mode 100644
index 000000000..150aa503b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/DiskProcessor.java
@@ -0,0 +1,36 @@
+package org.mozilla.gecko.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+import org.mozilla.gecko.util.StringUtils;
+
+public class DiskProcessor implements Processor {
+ @Override
+ public void process(IconRequest request, IconResponse response) {
+ if (request.shouldSkipDisk()) {
+ return;
+ }
+
+ if (!response.hasUrl() || !StringUtils.isHttpOrHttps(response.getUrl())) {
+ // If the response does not contain an URL from which the icon was loaded or if this is
+ // not a http(s) URL then we cannot store this or do not need to (because it's already
+ // stored somewhere else, like for URLs pointing inside the omni.ja).
+ return;
+ }
+
+ final DiskStorage storage = DiskStorage.get(request.getContext());
+
+ if (response.isFromNetwork()) {
+ // The icon has been loaded from the network. Store it on the disk now.
+ storage.putIcon(response);
+ }
+
+ if (response.isFromMemory() || response.isFromDisk() || response.isFromNetwork()) {
+ // Remember mapping between page URL and storage URL. Even when this icon has been loaded
+ // from memory or disk this does not mean that we stored this mapping already: We could
+ // have loaded this icon for a different page URL previously.
+ storage.putMapping(request, response.getUrl());
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java
new file mode 100644
index 000000000..245faded5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/MemoryProcessor.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.storage.MemoryStorage;
+
+public class MemoryProcessor implements Processor {
+ private final MemoryStorage storage;
+
+ public MemoryProcessor() {
+ storage = MemoryStorage.get();
+ }
+
+ @Override
+ public void process(IconRequest request, IconResponse response) {
+ if (request.shouldSkipMemory() || request.getIconCount() == 0 || response.isGenerated()) {
+ // Do not cache this icon in memory if we should skip the memory cache or if this icon
+ // has been generated. We can re-generate it if needed.
+ return;
+ }
+
+ final String iconUrl = request.getBestIcon().getUrl();
+
+ if (iconUrl.startsWith("data:image/")) {
+ // The image data is encoded in the URL. It doesn't make sense to store the URL and the
+ // bitmap in cache.
+ return;
+ }
+
+ storage.putMapping(request, iconUrl);
+ storage.putIcon(iconUrl, response);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java
new file mode 100644
index 000000000..df7d63c6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/Processor.java
@@ -0,0 +1,21 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.processing;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Generic interface for a class that processes a response object after an icon has been loaded and
+ * decoded. A class implementing this interface can attach additional data to the response or modify
+ * the bitmap (e.g. resizing).
+ */
+public interface Processor {
+ /**
+ * Process a response object containing an icon loaded for this request.
+ */
+ void process(IconRequest request, IconResponse response);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java
new file mode 100644
index 000000000..63b479021
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/processing/ResizingProcessor.java
@@ -0,0 +1,68 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.processing;
+
+import android.graphics.Bitmap;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Processor implementation for resizing the loaded icon based on the target size.
+ */
+public class ResizingProcessor implements Processor {
+ @Override
+ public void process(IconRequest request, IconResponse response) {
+ if (response.isFromMemory()) {
+ // This bitmap has been loaded from memory, so it has already gone through the resizing
+ // process. We do not want to resize the image every time we hit the memory cache.
+ return;
+ }
+
+ final Bitmap originalBitmap = response.getBitmap();
+ final int size = originalBitmap.getWidth();
+
+ final int targetSize = request.getTargetSize();
+
+ if (size == targetSize) {
+ // The bitmap has exactly the size we are looking for.
+ return;
+ }
+
+ final Bitmap resizedBitmap;
+
+ if (size > targetSize) {
+ resizedBitmap = resize(originalBitmap, targetSize);
+ } else {
+ // Our largest primary is smaller than the desired size. Upscale by a maximum of 2x.
+ // 'largestSize' now reflects the maximum size we can upscale to.
+ final int largestSize = size * 2;
+
+ if (largestSize > targetSize) {
+ // Perfect! We can upscale by less than 2x and reach the needed size. Do it.
+ resizedBitmap = resize(originalBitmap, targetSize);
+ } else {
+ // We don't have enough information to make the target size look non terrible. Best effort:
+ resizedBitmap = resize(originalBitmap, largestSize);
+ }
+ }
+
+ response.updateBitmap(resizedBitmap);
+
+ originalBitmap.recycle();
+ }
+
+ @VisibleForTesting Bitmap resize(Bitmap bitmap, int targetSize) {
+ try {
+ return Bitmap.createScaledBitmap(bitmap, targetSize, targetSize, true);
+ } catch (OutOfMemoryError error) {
+ // There's not enough memory to create a resized copy of the bitmap in memory. Let's just
+ // use what we have.
+ return bitmap;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java
new file mode 100644
index 000000000..3c0e2a554
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/DiskStorage.java
@@ -0,0 +1,293 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.storage;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.support.annotation.CheckResult;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import com.jakewharton.disklrucache.DiskLruCache;
+
+import org.mozilla.gecko.background.nativecode.NativeCrypto;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.IOUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+
+/**
+ * Least Recently Used (LRU) disk cache for icons and the mappings from page URLs to icon URLs.
+ */
+public class DiskStorage {
+ private static final String LOGTAG = "Gecko/DiskStorage";
+
+ /**
+ * Maximum size (in bytes) of the cache. This cache is located in the cache directory of the
+ * application and can be cleared by the user.
+ */
+ private static final int DISK_CACHE_SIZE = 50 * 1024 * 1024;
+
+ /**
+ * Version of the cache. Updating the version will invalidate all existing items.
+ */
+ private static final int CACHE_VERSION = 1;
+
+ private static final String KEY_PREFIX_ICON = "icon:";
+ private static final String KEY_PREFIX_MAPPING = "mapping:";
+
+ private static DiskStorage instance;
+
+ public static DiskStorage get(Context context) {
+ if (instance == null) {
+ instance = new DiskStorage(context);
+ }
+
+ return instance;
+ }
+
+ private Context context;
+ private DiskLruCache cache;
+
+ private DiskStorage(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ @CheckResult
+ private synchronized DiskLruCache ensureCacheIsReady() throws IOException {
+ if (cache == null || cache.isClosed()) {
+ cache = DiskLruCache.open(
+ new File(context.getCacheDir(), "icons"),
+ CACHE_VERSION,
+ 1,
+ DISK_CACHE_SIZE);
+ }
+
+ return cache;
+ }
+
+ /**
+ * Store a mapping from page URL to icon URL in the cache.
+ */
+ public void putMapping(IconRequest request, String iconUrl) {
+ putMapping(request.getPageUrl(), iconUrl);
+ }
+
+ /**
+ * Store a mapping from page URL to icon URL in the cache.
+ */
+ public void putMapping(String pageUrl, String iconUrl) {
+ DiskLruCache.Editor editor = null;
+
+ try {
+ final DiskLruCache cache = ensureCacheIsReady();
+
+ final String key = createKey(KEY_PREFIX_MAPPING, pageUrl);
+ if (key == null) {
+ return;
+ }
+
+ editor = cache.edit(key);
+ if (editor == null) {
+ return;
+ }
+
+ editor.set(0, iconUrl);
+ editor.commit();
+ } catch (IOException e) {
+ Log.w(LOGTAG, "IOException while accessing disk cache", e);
+
+ abortSilently(editor);
+ }
+ }
+
+ /**
+ * Store an icon in the cache (uses the icon URL as key).
+ */
+ public void putIcon(IconResponse response) {
+ putIcon(response.getUrl(), response.getBitmap());
+ }
+
+ /**
+ * Store an icon in the cache (uses the icon URL as key).
+ */
+ public void putIcon(String iconUrl, Bitmap bitmap) {
+ OutputStream outputStream = null;
+ DiskLruCache.Editor editor = null;
+
+ try {
+ final DiskLruCache cache = ensureCacheIsReady();
+
+ final String key = createKey(KEY_PREFIX_ICON, iconUrl);
+ if (key == null) {
+ return;
+ }
+
+ editor = cache.edit(key);
+ if (editor == null) {
+ return;
+ }
+
+ outputStream = editor.newOutputStream(0);
+ boolean success = bitmap.compress(Bitmap.CompressFormat.PNG, 100 /* quality; ignored. PNG is lossless */, outputStream);
+
+ outputStream.close();
+
+ if (success) {
+ editor.commit();
+ } else {
+ editor.abort();
+ }
+ } catch (IOException e) {
+ Log.w(LOGTAG, "IOException while accessing disk cache", e);
+
+ abortSilently(editor);
+ } finally {
+ IOUtils.safeStreamClose(outputStream);
+ }
+ }
+
+
+
+ /**
+ * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL.
+ */
+ @Nullable
+ public IconResponse getIcon(String iconUrl) {
+ InputStream inputStream = null;
+
+ try {
+ final DiskLruCache cache = ensureCacheIsReady();
+
+ final String key = createKey(KEY_PREFIX_ICON, iconUrl);
+ if (key == null) {
+ return null;
+ }
+
+ if (cache.isClosed()) {
+ throw new RuntimeException("CLOSED");
+ }
+
+ final DiskLruCache.Snapshot snapshot = cache.get(key);
+ if (snapshot == null) {
+ return null;
+ }
+
+ inputStream = snapshot.getInputStream(0);
+
+ final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
+ if (bitmap == null) {
+ return null;
+ }
+
+ return IconResponse.createFromDisk(bitmap, iconUrl);
+ } catch (IOException e) {
+ Log.w(LOGTAG, "IOException while accessing disk cache", e);
+ } finally {
+ IOUtils.safeStreamClose(inputStream);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the icon URL for this page URL. Returns null if no mapping is in the cache.
+ */
+ @Nullable
+ public String getMapping(String pageUrl) {
+ try {
+ final DiskLruCache cache = ensureCacheIsReady();
+
+ final String key = createKey(KEY_PREFIX_MAPPING, pageUrl);
+ if (key == null) {
+ return null;
+ }
+
+ DiskLruCache.Snapshot snapshot = cache.get(key);
+ if (snapshot == null) {
+ return null;
+ }
+
+ return snapshot.getString(0);
+ } catch (IOException e) {
+ Log.w(LOGTAG, "IOException while accessing disk cache", e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Remove all entries from this cache.
+ */
+ public void evictAll() {
+ try {
+ final DiskLruCache cache = ensureCacheIsReady();
+
+ cache.delete();
+
+ } catch (IOException e) {
+ Log.w(LOGTAG, "IOException while accessing disk cache", e);
+ }
+ }
+
+ /**
+ * Create a key for this URL using the given prefix.
+ *
+ * The disk cache requires valid file names to be used as key. Therefore we hash the created key
+ * (SHA-256).
+ */
+ @Nullable
+ private String createKey(String prefix, String url) {
+ try {
+ // We use our own crypto implementation to avoid the penalty of loading the java crypto
+ // framework.
+ byte[] ctx = NativeCrypto.sha256init();
+ if (ctx == null) {
+ return null;
+ }
+
+ byte[] data = prefix.getBytes("UTF-8");
+ NativeCrypto.sha256update(ctx, data, data.length);
+
+ data = url.getBytes("UTF-8");
+ NativeCrypto.sha256update(ctx, data, data.length);
+ return Utils.byte2Hex(NativeCrypto.sha256finalize(ctx));
+ } catch (NoClassDefFoundError | ExceptionInInitializerError error) {
+ // We could not load libmozglue.so. Let's use Java's MessageDigest as fallback. We do
+ // this primarily for our unit tests that can't load native libraries. On an device
+ // we will have a lot of other problems if we can't load libmozglue.so
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ md.update(prefix.getBytes("UTF-8"));
+ md.update(url.getBytes("UTF-8"));
+ return Utils.byte2Hex(md.digest());
+ } catch (Exception e) {
+ // Just give up. And let everyone know.
+ throw new RuntimeException(e);
+ }
+ } catch (UnsupportedEncodingException e) {
+ throw new AssertionError("Should not happen: Device does not understand UTF-8");
+ }
+ }
+
+ private void abortSilently(DiskLruCache.Editor editor) {
+ if (editor != null) {
+ try {
+ editor.abort();
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
new file mode 100644
index 000000000..b45cb0fce
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/FailureCache.java
@@ -0,0 +1,70 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.storage;
+
+import android.os.SystemClock;
+import android.support.annotation.VisibleForTesting;
+import android.util.LruCache;
+
+/**
+ * In-memory cache to remember URLs from which loading icons has failed recently.
+ */
+public class FailureCache {
+ /**
+ * Retry loading failed icons after 4 hours.
+ */
+ private static final long FAILURE_RETRY_MILLISECONDS = 1000 * 60 * 60 * 4;
+
+ private static final int MAX_ENTRIES = 25;
+
+ private static FailureCache instance;
+
+ public static synchronized FailureCache get() {
+ if (instance == null) {
+ instance = new FailureCache();
+ }
+
+ return instance;
+ }
+
+ private final LruCache<String, Long> cache;
+
+ private FailureCache() {
+ cache = new LruCache<>(MAX_ENTRIES);
+ }
+
+ /**
+ * Remember this icon URL after loading from it (over the network) has failed.
+ */
+ public void rememberFailure(String iconUrl) {
+ cache.put(iconUrl, SystemClock.elapsedRealtime());
+ }
+
+ /**
+ * Has loading from this URL failed previously and recently?
+ */
+ public boolean isKnownFailure(String iconUrl) {
+ synchronized (cache) {
+ final Long failedAt = cache.get(iconUrl);
+ if (failedAt == null) {
+ return false;
+ }
+
+ if (failedAt + FAILURE_RETRY_MILLISECONDS < SystemClock.elapsedRealtime()) {
+ // The wait time has passed and we can retry loading from this URL.
+ cache.remove(iconUrl);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @VisibleForTesting
+ public void evictAll() {
+ cache.evictAll();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java b/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java
new file mode 100644
index 000000000..e0a96f7c7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/icons/storage/MemoryStorage.java
@@ -0,0 +1,112 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.icons.storage;
+
+import android.graphics.Bitmap;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.LruCache;
+
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.IconResponse;
+
+/**
+ * Least Recently Used (LRU) memory cache for icons and the mappings from page URLs to icon URLs.
+ */
+public class MemoryStorage {
+ /**
+ * Maximum number of items in the cache for mapping page URLs to icon URLs.
+ */
+ private static final int MAPPING_CACHE_SIZE = 500;
+
+ private static MemoryStorage instance;
+
+ public static synchronized MemoryStorage get() {
+ if (instance == null) {
+ instance = new MemoryStorage();
+ }
+
+ return instance;
+ }
+
+ /**
+ * Class representing an cached icon. We store the original bitmap and the color in cache only.
+ */
+ private static class CacheEntry {
+ private final Bitmap bitmap;
+ private final int color;
+
+ private CacheEntry(Bitmap bitmap, int color) {
+ this.bitmap = bitmap;
+ this.color = color;
+ }
+ }
+
+ private final LruCache<String, CacheEntry> iconCache; // Guarded by 'this'
+ private final LruCache<String, String> mappingCache; // Guarded by 'this'
+
+ private MemoryStorage() {
+ iconCache = new LruCache<String, CacheEntry>(calculateCacheSize()) {
+ @Override
+ protected int sizeOf(String key, CacheEntry value) {
+ return value.bitmap.getByteCount() / 1024;
+ }
+ };
+
+ mappingCache = new LruCache<>(MAPPING_CACHE_SIZE);
+ }
+
+ private int calculateCacheSize() {
+ // Use a maximum of 1/8 of the available memory for storing cached icons.
+ int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
+ return maxMemory / 8;
+ }
+
+ /**
+ * Store a mapping from page URL to icon URL in the cache.
+ */
+ public synchronized void putMapping(IconRequest request, String iconUrl) {
+ mappingCache.put(request.getPageUrl(), iconUrl);
+ }
+
+ /**
+ * Get the icon URL for this page URL. Returns null if no mapping is in the cache.
+ */
+ @Nullable
+ public synchronized String getMapping(String pageUrl) {
+ return mappingCache.get(pageUrl);
+ }
+
+ /**
+ * Store an icon in the cache (uses the icon URL as key).
+ */
+ public synchronized void putIcon(String url, IconResponse response) {
+ final CacheEntry entry = new CacheEntry(response.getBitmap(), response.getColor());
+
+ iconCache.put(url, entry);
+ }
+
+ /**
+ * Get an icon for the icon URL from the cache. Returns null if no icon is cached for this URL.
+ */
+ @Nullable
+ public synchronized IconResponse getIcon(String iconUrl) {
+ final CacheEntry entry = iconCache.get(iconUrl);
+ if (entry == null) {
+ return null;
+ }
+
+ return IconResponse.createFromMemory(entry.bitmap, iconUrl, entry.color);
+ }
+
+ /**
+ * Remove all entries from this cache.
+ */
+ public synchronized void evictAll() {
+ iconCache.evictAll();
+ mappingCache.evictAll();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java
new file mode 100644
index 000000000..33a97955f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManager.java
@@ -0,0 +1,195 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.javaaddons;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.util.Log;
+import dalvik.system.DexClassLoader;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoEventListener;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * The manager for addon-provided Java code.
+ *
+ * Java code in addons can be loaded using the Dex:Load message, and unloaded
+ * via the Dex:Unload message. Addon classes loaded are checked for a constructor
+ * that takes a Map&lt;String, Handler.Callback&gt;. If such a constructor
+ * exists, it is called and the objects populated into the map by the constructor
+ * are registered as event listeners. If no such constructor exists, the default
+ * constructor is invoked instead.
+ *
+ * Note: The Map and Handler.Callback classes were used in this API definition
+ * rather than defining a custom class. This was done explicitly so that the
+ * addon code can be compiled against the android.jar provided in the Android
+ * SDK, rather than having to be compiled against Fennec source code.
+ *
+ * The Handler.Callback instances provided (as described above) are invoked with
+ * Message objects when the corresponding events are dispatched. The Bundle
+ * object attached to the Message will contain the "primitive" values from the
+ * JSON of the event. ("primitive" includes bool/int/long/double/String). If
+ * the addon callback wishes to synchronously return a value back to the event
+ * dispatcher, they can do so by inserting the response string into the bundle
+ * under the key "response".
+ */
+public class JavaAddonManager implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoJavaAddonManager";
+
+ private static JavaAddonManager sInstance;
+
+ private final EventDispatcher mDispatcher;
+ private final Map<String, Map<String, GeckoEventListener>> mAddonCallbacks;
+
+ private Context mApplicationContext;
+
+ public static JavaAddonManager getInstance() {
+ if (sInstance == null) {
+ sInstance = new JavaAddonManager();
+ }
+ return sInstance;
+ }
+
+ private JavaAddonManager() {
+ mDispatcher = EventDispatcher.getInstance();
+ mAddonCallbacks = new HashMap<String, Map<String, GeckoEventListener>>();
+ }
+
+ public void init(Context applicationContext) {
+ if (mApplicationContext != null) {
+ // we've already done this registration. don't do it again
+ return;
+ }
+ mApplicationContext = applicationContext;
+ mDispatcher.registerGeckoThreadListener(this,
+ "Dex:Load",
+ "Dex:Unload");
+ JavaAddonManagerV1.getInstance().init(applicationContext);
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("Dex:Load")) {
+ String zipFile = message.getString("zipfile");
+ String implClass = message.getString("impl");
+ Log.d(LOGTAG, "Attempting to load classes.dex file from " + zipFile + " and instantiate " + implClass);
+ try {
+ File tmpDir = mApplicationContext.getDir("dex", 0);
+ DexClassLoader loader = new DexClassLoader(zipFile, tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader());
+ Class<?> c = loader.loadClass(implClass);
+ try {
+ Constructor<?> constructor = c.getDeclaredConstructor(Map.class);
+ Map<String, Handler.Callback> callbacks = new HashMap<String, Handler.Callback>();
+ constructor.newInstance(callbacks);
+ registerCallbacks(zipFile, callbacks);
+ } catch (NoSuchMethodException nsme) {
+ Log.d(LOGTAG, "Did not find constructor with parameters Map<String, Handler.Callback>. Falling back to default constructor...");
+ // fallback for instances with no constructor that takes a Map<String, Handler.Callback>
+ c.newInstance();
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Unable to load dex successfully", e);
+ }
+ } else if (event.equals("Dex:Unload")) {
+ String zipFile = message.getString("zipfile");
+ unregisterCallbacks(zipFile);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception handling message [" + event + "]:", e);
+ }
+ }
+
+ private void registerCallbacks(String zipFile, Map<String, Handler.Callback> callbacks) {
+ Map<String, GeckoEventListener> addonCallbacks = mAddonCallbacks.get(zipFile);
+ if (addonCallbacks != null) {
+ Log.w(LOGTAG, "Found pre-existing callbacks for zipfile [" + zipFile + "]; aborting re-registration!");
+ return;
+ }
+ addonCallbacks = new HashMap<String, GeckoEventListener>();
+ for (String event : callbacks.keySet()) {
+ CallbackWrapper wrapper = new CallbackWrapper(callbacks.get(event));
+ mDispatcher.registerGeckoThreadListener(wrapper, event);
+ addonCallbacks.put(event, wrapper);
+ }
+ mAddonCallbacks.put(zipFile, addonCallbacks);
+ }
+
+ private void unregisterCallbacks(String zipFile) {
+ Map<String, GeckoEventListener> callbacks = mAddonCallbacks.remove(zipFile);
+ if (callbacks == null) {
+ Log.w(LOGTAG, "Attempting to unregister callbacks from zipfile [" + zipFile + "] which has no callbacks registered.");
+ return;
+ }
+ for (String event : callbacks.keySet()) {
+ mDispatcher.unregisterGeckoThreadListener(callbacks.get(event), event);
+ }
+ }
+
+ private static class CallbackWrapper implements GeckoEventListener {
+ private final Handler.Callback mDelegate;
+ private Bundle mBundle;
+
+ CallbackWrapper(Handler.Callback delegate) {
+ mDelegate = delegate;
+ }
+
+ private Bundle jsonToBundle(JSONObject json) {
+ // XXX right now we only support primitive types;
+ // we don't recurse down into JSONArray or JSONObject instances
+ Bundle b = new Bundle();
+ for (Iterator<?> keys = json.keys(); keys.hasNext(); ) {
+ try {
+ String key = (String)keys.next();
+ Object value = json.get(key);
+ if (value instanceof Integer) {
+ b.putInt(key, (Integer)value);
+ } else if (value instanceof String) {
+ b.putString(key, (String)value);
+ } else if (value instanceof Boolean) {
+ b.putBoolean(key, (Boolean)value);
+ } else if (value instanceof Long) {
+ b.putLong(key, (Long)value);
+ } else if (value instanceof Double) {
+ b.putDouble(key, (Double)value);
+ }
+ } catch (JSONException e) {
+ Log.d(LOGTAG, "Error during JSON->bundle conversion", e);
+ }
+ }
+ return b;
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject json) {
+ try {
+ if (mBundle != null) {
+ Log.w(LOGTAG, "Event [" + event + "] handler is re-entrant; response messages may be lost");
+ }
+ mBundle = jsonToBundle(json);
+ Message msg = new Message();
+ msg.setData(mBundle);
+ mDelegate.handleMessage(msg);
+
+ JSONObject obj = new JSONObject();
+ obj.put("response", mBundle.getString("response"));
+ EventDispatcher.sendResponse(json, obj);
+ mBundle = null;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Caught exception thrown from wrapped addon message handler", e);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java
new file mode 100644
index 000000000..f361773ca
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/javaaddons/JavaAddonManagerV1.java
@@ -0,0 +1,260 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.javaaddons;
+
+import android.content.Context;
+import android.util.Log;
+import android.util.Pair;
+import dalvik.system.DexClassLoader;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+public class JavaAddonManagerV1 implements NativeEventListener {
+ private static final String LOGTAG = "GeckoJavaAddonMgrV1";
+ public static final String MESSAGE_LOAD = "JavaAddonManagerV1:Load";
+ public static final String MESSAGE_UNLOAD = "JavaAddonManagerV1:Unload";
+
+ private static JavaAddonManagerV1 sInstance;
+
+ // Protected by static synchronized.
+ private Context mApplicationContext;
+
+ private final org.mozilla.gecko.EventDispatcher mDispatcher;
+
+ // Protected by synchronized (this).
+ private final Map<String, EventDispatcherImpl> mGUIDToDispatcherMap = new HashMap<>();
+
+ public static synchronized JavaAddonManagerV1 getInstance() {
+ if (sInstance == null) {
+ sInstance = new JavaAddonManagerV1();
+ }
+ return sInstance;
+ }
+
+ private JavaAddonManagerV1() {
+ mDispatcher = org.mozilla.gecko.EventDispatcher.getInstance();
+ }
+
+ public synchronized void init(Context applicationContext) {
+ if (mApplicationContext != null) {
+ // We've already registered; don't register again.
+ return;
+ }
+ mApplicationContext = applicationContext;
+ mDispatcher.registerGeckoThreadListener(this,
+ MESSAGE_LOAD,
+ MESSAGE_UNLOAD);
+ }
+
+ protected String getExtension(String filename) {
+ if (filename == null) {
+ return "";
+ }
+ final int last = filename.lastIndexOf(".");
+ if (last < 0) {
+ return "";
+ }
+ return filename.substring(last);
+ }
+
+ protected synchronized EventDispatcherImpl registerNewInstance(String classname, String filename)
+ throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, IOException {
+ Log.d(LOGTAG, "Attempting to instantiate " + classname + "from filename " + filename);
+
+ // It's important to maintain the extension, either .dex, .apk, .jar.
+ final String extension = getExtension(filename);
+ final File dexFile = GeckoJarReader.extractStream(mApplicationContext, filename, mApplicationContext.getCacheDir(), "." + extension);
+ try {
+ if (dexFile == null) {
+ throw new IOException("Could not find file " + filename);
+ }
+ final File tmpDir = mApplicationContext.getDir("dex", 0); // We'd prefer getCodeCacheDir but it's API 21+.
+ final DexClassLoader loader = new DexClassLoader(dexFile.getAbsolutePath(), tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader());
+ final Class<?> c = loader.loadClass(classname);
+ final Constructor<?> constructor = c.getDeclaredConstructor(Context.class, JavaAddonInterfaceV1.EventDispatcher.class);
+ final String guid = Utils.generateGuid();
+ final EventDispatcherImpl dispatcher = new EventDispatcherImpl(guid, filename);
+ final Object instance = constructor.newInstance(mApplicationContext, dispatcher);
+ mGUIDToDispatcherMap.put(guid, dispatcher);
+ return dispatcher;
+ } finally {
+ // DexClassLoader writes an optimized version, so we can get rid of our temporary extracted version.
+ if (dexFile != null) {
+ dexFile.delete();
+ }
+ }
+ }
+
+ @Override
+ public synchronized void handleMessage(String event, NativeJSObject message, org.mozilla.gecko.util.EventCallback callback) {
+ try {
+ switch (event) {
+ case MESSAGE_LOAD: {
+ if (callback == null) {
+ throw new IllegalArgumentException("callback must not be null");
+ }
+ final String classname = message.getString("classname");
+ final String filename = message.getString("filename");
+ final EventDispatcherImpl dispatcher = registerNewInstance(classname, filename);
+ callback.sendSuccess(dispatcher.guid);
+ }
+ break;
+ case MESSAGE_UNLOAD: {
+ if (callback == null) {
+ throw new IllegalArgumentException("callback must not be null");
+ }
+ final String guid = message.getString("guid");
+ final EventDispatcherImpl dispatcher = mGUIDToDispatcherMap.remove(guid);
+ if (dispatcher == null) {
+ Log.w(LOGTAG, "Attempting to unload addon with unknown associated dispatcher; ignoring.");
+ callback.sendSuccess(false);
+ }
+ dispatcher.unregisterAllEventListeners();
+ callback.sendSuccess(true);
+ }
+ break;
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message [" + event + "]", e);
+ if (callback != null) {
+ callback.sendError("Exception handling message [" + event + "]: " + e.toString());
+ }
+ }
+ }
+
+ /**
+ * An event dispatcher is tied to a single Java Addon instance. It serves to prefix all
+ * messages with its unique GUID.
+ * <p/>
+ * Curiously, the dispatcher does not hold a direct reference to its add-on instance. It will
+ * likely hold indirect instances through its wrapping map, since the instance will probably
+ * register event listeners that hold a reference to itself. When these listeners are
+ * unregistered, any link will be broken, allowing the instances to be garbage collected.
+ */
+ private class EventDispatcherImpl implements JavaAddonInterfaceV1.EventDispatcher {
+ private final String guid;
+ private final String dexFileName;
+
+ // Protected by synchronized (this).
+ private final Map<JavaAddonInterfaceV1.EventListener, Pair<NativeEventListener, String[]>> mListenerToWrapperMap = new IdentityHashMap<>();
+
+ public EventDispatcherImpl(String guid, String dexFileName) {
+ this.guid = guid;
+ this.dexFileName = dexFileName;
+ }
+
+ protected class ListenerWrapper implements NativeEventListener {
+ private final JavaAddonInterfaceV1.EventListener listener;
+
+ public ListenerWrapper(JavaAddonInterfaceV1.EventListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void handleMessage(String prefixedEvent, NativeJSObject message, final org.mozilla.gecko.util.EventCallback callback) {
+ if (!prefixedEvent.startsWith(guid + ":")) {
+ return;
+ }
+ final String event = prefixedEvent.substring(guid.length() + 1); // Skip "guid:".
+ try {
+ JavaAddonInterfaceV1.EventCallback callbackAdapter = null;
+ if (callback != null) {
+ callbackAdapter = new JavaAddonInterfaceV1.EventCallback() {
+ @Override
+ public void sendSuccess(Object response) {
+ callback.sendSuccess(response);
+ }
+
+ @Override
+ public void sendError(Object response) {
+ callback.sendError(response);
+ }
+ };
+ }
+ final JSONObject json = new JSONObject(message.toString());
+ listener.handleMessage(mApplicationContext, event, json, callbackAdapter);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message [" + prefixedEvent + "]", e);
+ if (callback != null) {
+ callback.sendError("Got exception handling message [" + prefixedEvent + "]: " + e.toString());
+ }
+ }
+ }
+ }
+
+ @Override
+ public synchronized void registerEventListener(final JavaAddonInterfaceV1.EventListener listener, String... events) {
+ if (mListenerToWrapperMap.containsKey(listener)) {
+ Log.e(LOGTAG, "Attempting to register listener which is already registered; ignoring.");
+ return;
+ }
+
+ final NativeEventListener listenerWrapper = new ListenerWrapper(listener);
+
+ final String[] prefixedEvents = new String[events.length];
+ for (int i = 0; i < events.length; i++) {
+ prefixedEvents[i] = this.guid + ":" + events[i];
+ }
+ mDispatcher.registerGeckoThreadListener(listenerWrapper, prefixedEvents);
+ mListenerToWrapperMap.put(listener, new Pair<>(listenerWrapper, prefixedEvents));
+ }
+
+ @Override
+ public synchronized void unregisterEventListener(final JavaAddonInterfaceV1.EventListener listener) {
+ final Pair<NativeEventListener, String[]> pair = mListenerToWrapperMap.remove(listener);
+ if (pair == null) {
+ Log.e(LOGTAG, "Attempting to unregister listener which is not registered; ignoring.");
+ return;
+ }
+ mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
+ }
+
+
+ protected synchronized void unregisterAllEventListeners() {
+ // Unregister everything, then forget everything.
+ for (Pair<NativeEventListener, String[]> pair : mListenerToWrapperMap.values()) {
+ mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
+ }
+ mListenerToWrapperMap.clear();
+ }
+
+ @Override
+ public void sendRequestToGecko(final String event, final JSONObject message, final JavaAddonInterfaceV1.RequestCallback callback) {
+ final String prefixedEvent = guid + ":" + event;
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest(prefixedEvent, message) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ if (callback == null) {
+ // Nothing to do.
+ return;
+ }
+ try {
+ final JSONObject json = new JSONObject(nativeJSObject.toString());
+ callback.onResponse(GeckoAppShell.getContext(), json);
+ } catch (JSONException e) {
+ // No way to report failure.
+ Log.e(LOGTAG, "Exception handling response to request [" + event + "]:", e);
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java
new file mode 100644
index 000000000..0f27c1feb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java
@@ -0,0 +1,455 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.lwt;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.WindowUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+
+import android.app.Application;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.Shader;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewParent;
+
+public class LightweightTheme implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoLightweightTheme";
+
+ private static final String PREFS_URL = "lightweightTheme.headerURL";
+ private static final String PREFS_COLOR = "lightweightTheme.color";
+
+ private static final String ASSETS_PREFIX = "resource://android/assets/";
+
+ private final Application mApplication;
+
+ private Bitmap mBitmap;
+ private int mColor;
+ private boolean mIsLight;
+
+ public static interface OnChangeListener {
+ // The View should change its background/text color.
+ public void onLightweightThemeChanged();
+
+ // The View should reset to its default background/text color.
+ public void onLightweightThemeReset();
+ }
+
+ private final List<OnChangeListener> mListeners;
+
+ class LightweightThemeRunnable implements Runnable {
+ private String mHeaderURL;
+ private String mColor;
+
+ private String mSavedURL;
+ private String mSavedColor;
+
+ LightweightThemeRunnable() {
+ }
+
+ LightweightThemeRunnable(final String headerURL, final String color) {
+ mHeaderURL = headerURL;
+ mColor = color;
+ }
+
+ private void loadFromPrefs() {
+ SharedPreferences prefs = GeckoSharedPrefs.forProfile(mApplication);
+ mSavedURL = prefs.getString(PREFS_URL, null);
+ mSavedColor = prefs.getString(PREFS_COLOR, null);
+ }
+
+ private void saveToPrefs() {
+ GeckoSharedPrefs.forProfile(mApplication)
+ .edit()
+ .putString(PREFS_URL, mHeaderURL)
+ .putString(PREFS_COLOR, mColor)
+ .apply();
+
+ // Let's keep the saved data in sync.
+ mSavedURL = mHeaderURL;
+ mSavedColor = mColor;
+ }
+
+ @Override
+ public void run() {
+ // Load the data from preferences, if it exists.
+ loadFromPrefs();
+
+ if (TextUtils.isEmpty(mHeaderURL)) {
+ // mHeaderURL is null is this is the early startup path. Use
+ // the saved values, if we have any.
+ mHeaderURL = mSavedURL;
+ mColor = mSavedColor;
+ if (TextUtils.isEmpty(mHeaderURL)) {
+ // We don't have any saved values, so we probably don't have
+ // any lightweight theme set yet.
+ return;
+ }
+ } else if (TextUtils.equals(mHeaderURL, mSavedURL)) {
+ // If we are already using the given header, just return
+ // without doing any work.
+ return;
+ } else {
+ // mHeaderURL and mColor probably need to be saved if we get here.
+ saveToPrefs();
+ }
+
+ String croppedURL = mHeaderURL;
+ int mark = croppedURL.indexOf('?');
+ if (mark != -1) {
+ croppedURL = croppedURL.substring(0, mark);
+ }
+
+ if (croppedURL.startsWith(ASSETS_PREFIX)) {
+ onBitmapLoaded(loadFromAssets(croppedURL));
+ } else {
+ onBitmapLoaded(BitmapUtils.decodeUrl(croppedURL));
+ }
+ }
+
+ private Bitmap loadFromAssets(String url) {
+ InputStream stream = null;
+
+ try {
+ stream = mApplication.getAssets().open(url.substring(ASSETS_PREFIX.length()));
+ return BitmapFactory.decodeStream(stream);
+ } catch (IOException e) {
+ return null;
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException e) { }
+ }
+ }
+ }
+
+ private void onBitmapLoaded(final Bitmap bitmap) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setLightweightTheme(bitmap, mColor);
+ }
+ });
+ }
+ }
+
+ public LightweightTheme(Application application) {
+ mApplication = application;
+ mListeners = new ArrayList<OnChangeListener>();
+
+ // unregister isn't needed as the lifetime is same as the application.
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ "LightweightTheme:Update",
+ "LightweightTheme:Disable");
+
+ ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable());
+ }
+
+ public void addListener(final OnChangeListener listener) {
+ // Don't inform the listeners that attached late.
+ // Their onLayout() will take care of them before their onDraw();
+ mListeners.add(listener);
+ }
+
+ public void removeListener(OnChangeListener listener) {
+ mListeners.remove(listener);
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("LightweightTheme:Update")) {
+ JSONObject lightweightTheme = message.getJSONObject("data");
+ final String headerURL = lightweightTheme.getString("headerURL");
+ final String color = lightweightTheme.optString("accentcolor");
+
+ ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable(headerURL, color));
+ } else if (event.equals("LightweightTheme:Disable")) {
+ // Clear the saved data when a theme is disabled.
+ // Called on the Gecko thread, but should be very lightweight.
+ clearPrefs();
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ resetLightweightTheme();
+ }
+ });
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ /**
+ * Clear the data stored in preferences for fast path loading during startup
+ */
+ private void clearPrefs() {
+ GeckoSharedPrefs.forProfile(mApplication)
+ .edit()
+ .remove(PREFS_URL)
+ .remove(PREFS_COLOR)
+ .apply();
+ }
+
+ /**
+ * Set a new lightweight theme with the given bitmap.
+ * Note: This should be called on the UI thread to restrict accessing the
+ * bitmap to a single thread.
+ *
+ * @param bitmap The bitmap used for the lightweight theme.
+ * @param color The background/accent color used for the lightweight theme.
+ */
+ private void setLightweightTheme(Bitmap bitmap, String color) {
+ if (bitmap == null || bitmap.getWidth() == 0 || bitmap.getHeight() == 0) {
+ mBitmap = null;
+ return;
+ }
+
+ // Get the max display dimension so we can crop or expand the theme.
+ final int maxWidth = WindowUtils.getLargestDimension(mApplication);
+
+ // The lightweight theme image's width and height.
+ final int bitmapWidth = bitmap.getWidth();
+ final int bitmapHeight = bitmap.getHeight();
+
+ try {
+ mColor = Color.parseColor(color);
+ } catch (Exception e) {
+ // Malformed or missing color.
+ // Default to TRANSPARENT.
+ mColor = Color.TRANSPARENT;
+ }
+
+ // Calculate the luminance to determine if it's a light or a dark theme.
+ double luminance = (0.2125 * ((mColor & 0x00FF0000) >> 16)) +
+ (0.7154 * ((mColor & 0x0000FF00) >> 8)) +
+ (0.0721 * (mColor & 0x000000FF));
+ mIsLight = luminance > 110;
+
+ // The bitmap image might be smaller than the device's width.
+ // If it's smaller, fill the extra space on the left with the dominant color.
+ if (bitmapWidth >= maxWidth) {
+ mBitmap = Bitmap.createBitmap(bitmap, bitmapWidth - maxWidth, 0, maxWidth, bitmapHeight);
+ } else {
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+
+ // Create a bigger image that can fill the device width.
+ // By creating a canvas for the bitmap, anything drawn on the canvas
+ // will be drawn on the bitmap.
+ mBitmap = Bitmap.createBitmap(maxWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(mBitmap);
+
+ // Fill the canvas with dominant color.
+ canvas.drawColor(mColor);
+
+ // The image should be top-right aligned.
+ Rect rect = new Rect();
+ Gravity.apply(Gravity.TOP | Gravity.RIGHT,
+ bitmapWidth,
+ bitmapHeight,
+ new Rect(0, 0, maxWidth, bitmapHeight),
+ rect);
+
+ // Draw the bitmap.
+ canvas.drawBitmap(bitmap, null, rect, paint);
+ }
+
+ for (OnChangeListener listener : mListeners) {
+ listener.onLightweightThemeChanged();
+ }
+ }
+
+ /**
+ * Reset the lightweight theme.
+ * Note: This should be called on the UI thread to restrict accessing the
+ * bitmap to a single thread.
+ */
+ private void resetLightweightTheme() {
+ ThreadUtils.assertOnUiThread(AssertBehavior.NONE);
+ if (mBitmap == null) {
+ return;
+ }
+
+ // Reset the bitmap.
+ mBitmap = null;
+
+ for (OnChangeListener listener : mListeners) {
+ listener.onLightweightThemeReset();
+ }
+ }
+
+ /**
+ * A lightweight theme is enabled only if there is an active bitmap.
+ *
+ * @return True if the theme is enabled.
+ */
+ public boolean isEnabled() {
+ return (mBitmap != null);
+ }
+
+ /**
+ * Based on the luminance of the domanint color, a theme is classified as light or dark.
+ *
+ * @return True if the theme is light.
+ */
+ public boolean isLightTheme() {
+ return mIsLight;
+ }
+
+ /**
+ * Crop the image based on the position of the view on the window.
+ * Either the View or one of its ancestors might have scrolled or translated.
+ * This value should be taken into account while mapping the View to the Bitmap.
+ *
+ * @param view The view requesting a cropped bitmap.
+ */
+ private Bitmap getCroppedBitmap(View view) {
+ if (mBitmap == null || view == null) {
+ return null;
+ }
+
+ // Get the global position of the view on the entire screen.
+ Rect rect = new Rect();
+ view.getGlobalVisibleRect(rect);
+
+ // Get the activity's window position. This does an IPC call, may be expensive.
+ Rect window = new Rect();
+ view.getWindowVisibleDisplayFrame(window);
+
+ // Calculate the coordinates for the cropped bitmap.
+ int screenWidth = view.getContext().getResources().getDisplayMetrics().widthPixels;
+ int left = mBitmap.getWidth() - screenWidth + rect.left;
+ int right = mBitmap.getWidth() - screenWidth + rect.right;
+ int top = rect.top - window.top;
+ int bottom = rect.bottom - window.top;
+
+ int offsetX = 0;
+ int offsetY = 0;
+
+ // Find if this view or any of its ancestors has been translated or scrolled.
+ ViewParent parent;
+ View curView = view;
+ do {
+ offsetX += (int) curView.getTranslationX() - curView.getScrollX();
+ offsetY += (int) curView.getTranslationY() - curView.getScrollY();
+
+ parent = curView.getParent();
+
+ if (parent instanceof View) {
+ curView = (View) parent;
+ }
+
+ } while (parent instanceof View);
+
+ // Adjust the coordinates for the offset.
+ left -= offsetX;
+ right -= offsetX;
+ top -= offsetY;
+ bottom -= offsetY;
+
+ // The either the required height may be less than the available image height or more than it.
+ // If the height required is more, crop only the available portion on the image.
+ int width = right - left;
+ int height = (bottom > mBitmap.getHeight() ? mBitmap.getHeight() - top : bottom - top);
+
+ // There is a chance that the view is not visible or doesn't fall within the phone's size.
+ // In this case, 'rect' will have all values as '0'. Hence 'top' and 'bottom' may be negative,
+ // and createBitmap() will fail.
+ // The view will get a background in its next layout pass.
+ try {
+ return Bitmap.createBitmap(mBitmap, left, top, width, height);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Converts the cropped bitmap to a BitmapDrawable and returns the same.
+ *
+ * @param view The view for which a background drawable is required.
+ * @return Either the cropped bitmap as a Drawable or null.
+ */
+ public Drawable getDrawable(View view) {
+ Bitmap bitmap = getCroppedBitmap(view);
+ if (bitmap == null) {
+ return null;
+ }
+
+ BitmapDrawable drawable = new BitmapDrawable(view.getContext().getResources(), bitmap);
+ drawable.setGravity(Gravity.TOP | Gravity.RIGHT);
+ drawable.setTileModeXY(Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
+ return drawable;
+ }
+
+ /**
+ * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the dominant color.
+ *
+ * @param view The view for which a background drawable is required.
+ * @return Either the cropped bitmap as a Drawable or null.
+ */
+ public LightweightThemeDrawable getColorDrawable(View view) {
+ return getColorDrawable(view, mColor, false);
+ }
+
+ /**
+ * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color.
+ *
+ * @param view The view for which a background drawable is required.
+ * @param color The color over which the drawable should be drawn.
+ * @return Either the cropped bitmap as a Drawable or null.
+ */
+ public LightweightThemeDrawable getColorDrawable(View view, int color) {
+ return getColorDrawable(view, color, false);
+ }
+
+ /**
+ * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color.
+ *
+ * @param view The view for which a background drawable is required.
+ * @param color The color over which the drawable should be drawn.
+ * @param needsDominantColor A layer of dominant color is needed or not.
+ * @return Either the cropped bitmap as a Drawable or null.
+ */
+ public LightweightThemeDrawable getColorDrawable(View view, int color, boolean needsDominantColor) {
+ Bitmap bitmap = getCroppedBitmap(view);
+ if (bitmap == null) {
+ return null;
+ }
+
+ LightweightThemeDrawable drawable = new LightweightThemeDrawable(view.getContext().getResources(), bitmap);
+ if (needsDominantColor) {
+ drawable.setColorWithFilter(color, (mColor & 0x22FFFFFF));
+ } else {
+ drawable.setColor(color);
+ }
+
+ return drawable;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java
new file mode 100644
index 000000000..c0ae6eaed
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightThemeDrawable.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.lwt;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.ComposeShader;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.Rect;
+import android.graphics.Shader;
+import android.graphics.drawable.Drawable;
+
+/**
+ * A special drawable used with lightweight themes. This draws a color
+ * (with an optional color-filter) and a bitmap (with a linear gradient
+ * to specify the alpha) in order.
+ */
+public class LightweightThemeDrawable extends Drawable {
+ private final Paint mPaint;
+ private Paint mColorPaint;
+
+ private final Bitmap mBitmap;
+ private final Resources mResources;
+
+ private int mStartColor;
+ private int mEndColor;
+
+ public LightweightThemeDrawable(Resources resources, Bitmap bitmap) {
+ mBitmap = bitmap;
+ mResources = resources;
+
+ mPaint = new Paint();
+ mPaint.setAntiAlias(true);
+ mPaint.setStrokeWidth(0.0f);
+ }
+
+ @Override
+ protected void onBoundsChange(Rect bounds) {
+ super.onBoundsChange(bounds);
+ initializeBitmapShader();
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ // Draw the colors, if available.
+ if (mColorPaint != null) {
+ canvas.drawPaint(mColorPaint);
+ }
+
+ // Draw the bitmap.
+ canvas.drawPaint(mPaint);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ // A StateListDrawable will reset the alpha value with 255.
+ // We cannot use to be the bitmap alpha.
+ mPaint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter filter) {
+ mPaint.setColorFilter(filter);
+ }
+
+ /**
+ * Creates a paint that paint a particular color.
+ *
+ * Note that the given color should include an alpha value.
+ *
+ * @param color The color to be painted.
+ */
+ public void setColor(int color) {
+ mColorPaint = new Paint(mPaint);
+ mColorPaint.setColor(color);
+ }
+
+ /**
+ * Creates a paint that paint a particular color, and a filter for the color.
+ *
+ * Note that the given color should include an alpha value.
+ *
+ * @param color The color to be painted.
+ * @param filter The filter color to be applied using SRC_OVER mode.
+ */
+ public void setColorWithFilter(int color, int filter) {
+ mColorPaint = new Paint(mPaint);
+ mColorPaint.setColor(color);
+ mColorPaint.setColorFilter(new PorterDuffColorFilter(filter, PorterDuff.Mode.SRC_OVER));
+ }
+
+ /**
+ * Set the alpha for the linear gradient used with the bitmap's shader.
+ *
+ * @param startAlpha The starting alpha (0..255) value to be applied to the LinearGradient.
+ * @param startAlpha The ending alpha (0..255) value to be applied to the LinearGradient.
+ */
+ public void setAlpha(int startAlpha, int endAlpha) {
+ mStartColor = startAlpha << 24;
+ mEndColor = endAlpha << 24;
+ initializeBitmapShader();
+ }
+
+ private void initializeBitmapShader() {
+ // A bitmap-shader to draw the bitmap.
+ // Clamp mode will repeat the last row of pixels.
+ // Hence its better to have an endAlpha of 0 for the linear-gradient.
+ BitmapShader bitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
+
+ // A linear-gradient to specify the opacity of the bitmap.
+ LinearGradient gradient = new LinearGradient(0, 0, 0, mBitmap.getHeight(), mStartColor, mEndColor, Shader.TileMode.CLAMP);
+
+ // Make a combined shader -- a performance win.
+ // The linear-gradient is the 'SRC' and the bitmap-shader is the 'DST'.
+ // Drawing the DST in the SRC will provide the opacity.
+ mPaint.setShader(new ComposeShader(bitmapShader, gradient, PorterDuff.Mode.DST_IN));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java b/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java
new file mode 100644
index 000000000..6f23790b9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/mdns/MulticastDNSManager.java
@@ -0,0 +1,535 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.mdns;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
+import android.support.annotation.UiThread;
+import android.util.Log;
+
+import java.net.InetAddress;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * This class is the bridge between XPCOM mDNS module and NsdManager.
+ *
+ * @See nsIDNSServiceDiscovery.idl
+ */
+public abstract class MulticastDNSManager {
+ protected static final String LOGTAG = "GeckoMDNSManager";
+ private static MulticastDNSManager instance = null;
+
+ public static MulticastDNSManager getInstance(final Context context) {
+ if (instance == null) {
+ instance = new DummyMulticastDNSManager();
+ }
+ return instance;
+ }
+
+ public abstract void init();
+ public abstract void tearDown();
+}
+
+/**
+ * Mix-in class for MulticastDNSManagers to call EventDispatcher.
+ */
+class MulticastDNSEventManager {
+ private NativeEventListener mListener = null;
+ private boolean mEventsRegistered = false;
+
+ MulticastDNSEventManager(NativeEventListener listener) {
+ mListener = listener;
+ }
+
+ @UiThread
+ public void init() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mEventsRegistered || mListener == null) {
+ return;
+ }
+
+ registerEvents();
+ mEventsRegistered = true;
+ }
+
+ @UiThread
+ public void tearDown() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!mEventsRegistered || mListener == null) {
+ return;
+ }
+
+ unregisterEvents();
+ mEventsRegistered = false;
+ }
+
+ private void registerEvents() {
+ EventDispatcher.getInstance().registerGeckoThreadListener(mListener,
+ "NsdManager:DiscoverServices",
+ "NsdManager:StopServiceDiscovery",
+ "NsdManager:RegisterService",
+ "NsdManager:UnregisterService",
+ "NsdManager:ResolveService");
+ }
+
+ private void unregisterEvents() {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(mListener,
+ "NsdManager:DiscoverServices",
+ "NsdManager:StopServiceDiscovery",
+ "NsdManager:RegisterService",
+ "NsdManager:UnregisterService",
+ "NsdManager:ResolveService");
+ }
+}
+
+class NsdMulticastDNSManager extends MulticastDNSManager implements NativeEventListener {
+ private final NsdManager nsdManager;
+ private final MulticastDNSEventManager mEventManager;
+ private Map<String, DiscoveryListener> mDiscoveryListeners = null;
+ private Map<String, RegistrationListener> mRegistrationListeners = null;
+
+ @TargetApi(16)
+ public NsdMulticastDNSManager(final Context context) {
+ nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
+ mEventManager = new MulticastDNSEventManager(this);
+ mDiscoveryListeners = new ConcurrentHashMap<String, DiscoveryListener>();
+ mRegistrationListeners = new ConcurrentHashMap<String, RegistrationListener>();
+ }
+
+ @Override
+ public void init() {
+ mEventManager.init();
+ }
+
+ @Override
+ public void tearDown() {
+ mDiscoveryListeners.clear();
+ mRegistrationListeners.clear();
+
+ mEventManager.tearDown();
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ Log.v(LOGTAG, "handleMessage: " + event);
+
+ switch (event) {
+ case "NsdManager:DiscoverServices": {
+ DiscoveryListener listener = new DiscoveryListener(nsdManager);
+ listener.discoverServices(message.getString("serviceType"), callback);
+ mDiscoveryListeners.put(message.getString("uniqueId"), listener);
+ break;
+ }
+ case "NsdManager:StopServiceDiscovery": {
+ String uuid = message.getString("uniqueId");
+ DiscoveryListener listener = mDiscoveryListeners.remove(uuid);
+ if (listener == null) {
+ Log.e(LOGTAG, "DiscoveryListener " + uuid + " was not found.");
+ return;
+ }
+ listener.stopServiceDiscovery(callback);
+ break;
+ }
+ case "NsdManager:RegisterService": {
+ RegistrationListener listener = new RegistrationListener(nsdManager);
+ listener.registerService(message.getInt("port"),
+ message.optString("serviceName", android.os.Build.MODEL),
+ message.getString("serviceType"),
+ parseAttributes(message.optObjectArray("attributes", null)),
+ callback);
+ mRegistrationListeners.put(message.getString("uniqueId"), listener);
+ break;
+ }
+ case "NsdManager:UnregisterService": {
+ String uuid = message.getString("uniqueId");
+ RegistrationListener listener = mRegistrationListeners.remove(uuid);
+ if (listener == null) {
+ Log.e(LOGTAG, "RegistrationListener " + uuid + " was not found.");
+ return;
+ }
+ listener.unregisterService(callback);
+ break;
+ }
+ case "NsdManager:ResolveService": {
+ (new ResolveListener(nsdManager)).resolveService(message.getString("serviceName"),
+ message.getString("serviceType"),
+ callback);
+ break;
+ }
+ }
+ }
+
+ private Map<String, String> parseAttributes(final NativeJSObject[] jsobjs) {
+ if (jsobjs == null || jsobjs.length == 0 || !Versions.feature21Plus) {
+ return null;
+ }
+
+ Map<String, String> attributes = new HashMap<String, String>(jsobjs.length);
+ for (NativeJSObject obj : jsobjs) {
+ attributes.put(obj.getString("name"), obj.getString("value"));
+ }
+
+ return attributes;
+ }
+
+ @TargetApi(16)
+ public static JSONObject toJSON(final NsdServiceInfo serviceInfo) throws JSONException {
+ JSONObject obj = new JSONObject();
+
+ InetAddress host = serviceInfo.getHost();
+ if (host != null) {
+ obj.put("host", host.getCanonicalHostName());
+ obj.put("address", host.getHostAddress());
+ }
+
+ int port = serviceInfo.getPort();
+ if (port != 0) {
+ obj.put("port", port);
+ }
+
+ String serviceName = serviceInfo.getServiceName();
+ if (serviceName != null) {
+ obj.put("serviceName", serviceName);
+ }
+
+ String serviceType = serviceInfo.getServiceType();
+ if (serviceType != null) {
+ obj.put("serviceType", serviceType);
+ }
+
+ return obj;
+ }
+}
+
+class DummyMulticastDNSManager extends MulticastDNSManager implements NativeEventListener {
+ static final int FAILURE_UNSUPPORTED = -65544;
+ private final MulticastDNSEventManager mEventManager;
+
+ public DummyMulticastDNSManager() {
+ mEventManager = new MulticastDNSEventManager(this);
+ }
+
+ @Override
+ public void init() {
+ mEventManager.init();
+ }
+
+ @Override
+ public void tearDown() {
+ mEventManager.tearDown();
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ Log.v(LOGTAG, "handleMessage: " + event);
+ callback.sendError(FAILURE_UNSUPPORTED);
+ }
+}
+
+@TargetApi(16)
+class DiscoveryListener implements NsdManager.DiscoveryListener {
+ private static final String LOGTAG = "GeckoMDNSManager";
+ private final NsdManager nsdManager;
+
+ // Callbacks are called from different thread, and every callback can be called only once.
+ private EventCallback mStartCallback = null;
+ private EventCallback mStopCallback = null;
+
+ DiscoveryListener(final NsdManager nsdManager) {
+ this.nsdManager = nsdManager;
+ }
+
+ public void discoverServices(final String serviceType, final EventCallback callback) {
+ synchronized (this) {
+ mStartCallback = callback;
+ }
+ nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, this);
+ }
+
+ public void stopServiceDiscovery(final EventCallback callback) {
+ synchronized (this) {
+ mStopCallback = callback;
+ }
+ nsdManager.stopServiceDiscovery(this);
+ }
+
+ @Override
+ public synchronized void onDiscoveryStarted(final String serviceType) {
+ Log.d(LOGTAG, "onDiscoveryStarted: " + serviceType);
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStartCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ callback.sendSuccess(serviceType);
+ }
+
+ @Override
+ public synchronized void onStartDiscoveryFailed(final String serviceType, final int errorCode) {
+ Log.e(LOGTAG, "onStartDiscoveryFailed: " + serviceType + "(" + errorCode + ")");
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStartCallback;
+ }
+
+ callback.sendError(errorCode);
+ }
+
+ @Override
+ public synchronized void onDiscoveryStopped(final String serviceType) {
+ Log.d(LOGTAG, "onDiscoveryStopped: " + serviceType);
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStopCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ callback.sendSuccess(serviceType);
+ }
+
+ @Override
+ public synchronized void onStopDiscoveryFailed(final String serviceType, final int errorCode) {
+ Log.e(LOGTAG, "onStopDiscoveryFailed: " + serviceType + "(" + errorCode + ")");
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStopCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ callback.sendError(errorCode);
+ }
+
+ @Override
+ public void onServiceFound(final NsdServiceInfo serviceInfo) {
+ Log.d(LOGTAG, "onServiceFound: " + serviceInfo.getServiceName());
+ JSONObject json;
+ try {
+ json = NsdMulticastDNSManager.toJSON(serviceInfo);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest("NsdManager:ServiceFound", json) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ // don't care return value.
+ }
+ });
+ }
+
+ @Override
+ public void onServiceLost(final NsdServiceInfo serviceInfo) {
+ Log.d(LOGTAG, "onServiceLost: " + serviceInfo.getServiceName());
+ JSONObject json;
+ try {
+ json = NsdMulticastDNSManager.toJSON(serviceInfo);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest("NsdManager:ServiceLost", json) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ // don't care return value.
+ }
+ });
+ }
+}
+
+@TargetApi(16)
+class RegistrationListener implements NsdManager.RegistrationListener {
+ private static final String LOGTAG = "GeckoMDNSManager";
+ private final NsdManager nsdManager;
+
+ // Callbacks are called from different thread, and every callback can be called only once.
+ private EventCallback mStartCallback = null;
+ private EventCallback mStopCallback = null;
+
+ RegistrationListener(final NsdManager nsdManager) {
+ this.nsdManager = nsdManager;
+ }
+
+ public void registerService(final int port, final String serviceName, final String serviceType, final Map<String, String> attributes, final EventCallback callback) {
+ Log.d(LOGTAG, "registerService: " + serviceName + "." + serviceType + ":" + port);
+
+ NsdServiceInfo serviceInfo = new NsdServiceInfo();
+ serviceInfo.setPort(port);
+ serviceInfo.setServiceName(serviceName);
+ serviceInfo.setServiceType(serviceType);
+ setAttributes(serviceInfo, attributes);
+
+ synchronized (this) {
+ mStartCallback = callback;
+ }
+ nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, this);
+ }
+
+ @TargetApi(21)
+ private void setAttributes(final NsdServiceInfo serviceInfo, final Map<String, String> attributes) {
+ if (attributes == null || !Versions.feature21Plus) {
+ return;
+ }
+
+ for (Map.Entry<String, String> entry : attributes.entrySet()) {
+ serviceInfo.setAttribute(entry.getKey(), entry.getValue());
+ }
+ }
+
+ public void unregisterService(final EventCallback callback) {
+ Log.d(LOGTAG, "unregisterService");
+ synchronized (this) {
+ mStopCallback = callback;
+ }
+
+ nsdManager.unregisterService(this);
+ }
+
+ @Override
+ public synchronized void onServiceRegistered(final NsdServiceInfo serviceInfo) {
+ Log.d(LOGTAG, "onServiceRegistered: " + serviceInfo.getServiceName());
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStartCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ try {
+ callback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo));
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public synchronized void onRegistrationFailed(final NsdServiceInfo serviceInfo, final int errorCode) {
+ Log.e(LOGTAG, "onRegistrationFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")");
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStartCallback;
+ }
+
+ callback.sendError(errorCode);
+ }
+
+ @Override
+ public synchronized void onServiceUnregistered(final NsdServiceInfo serviceInfo) {
+ Log.d(LOGTAG, "onServiceUnregistered: " + serviceInfo.getServiceName());
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStopCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ try {
+ callback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo));
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public synchronized void onUnregistrationFailed(final NsdServiceInfo serviceInfo, final int errorCode) {
+ Log.e(LOGTAG, "onUnregistrationFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")");
+
+ EventCallback callback;
+ synchronized (this) {
+ callback = mStopCallback;
+ }
+
+ if (callback == null) {
+ return;
+ }
+
+ callback.sendError(errorCode);
+ }
+}
+
+@TargetApi(16)
+class ResolveListener implements NsdManager.ResolveListener {
+ private static final String LOGTAG = "GeckoMDNSManager";
+ private final NsdManager nsdManager;
+
+ // Callback is called from different thread, and the callback can be called only once.
+ private EventCallback mCallback = null;
+
+ public ResolveListener(final NsdManager nsdManager) {
+ this.nsdManager = nsdManager;
+ }
+
+ public void resolveService(final String serviceName, final String serviceType, final EventCallback callback) {
+ NsdServiceInfo serviceInfo = new NsdServiceInfo();
+ serviceInfo.setServiceName(serviceName);
+ serviceInfo.setServiceType(serviceType);
+
+ mCallback = callback;
+ nsdManager.resolveService(serviceInfo, this);
+ }
+
+
+ @Override
+ public synchronized void onResolveFailed(final NsdServiceInfo serviceInfo, final int errorCode) {
+ Log.e(LOGTAG, "onResolveFailed: " + serviceInfo.getServiceName() + "(" + errorCode + ")");
+
+ if (mCallback == null) {
+ return;
+ }
+ mCallback.sendError(errorCode);
+ }
+
+ @Override
+ public synchronized void onServiceResolved(final NsdServiceInfo serviceInfo) {
+ Log.d(LOGTAG, "onServiceResolved: " + serviceInfo.getServiceName());
+
+ if (mCallback == null) {
+ return;
+ }
+
+ try {
+ mCallback.sendSuccess(NsdMulticastDNSManager.toJSON(serviceInfo));
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java
new file mode 100644
index 000000000..c9c620606
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodec.java
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.view.Surface;
+
+import java.nio.ByteBuffer;
+
+// A wrapper interface that mimics the new {@link android.media.MediaCodec}
+// asynchronous mode API in Lollipop.
+public interface AsyncCodec {
+ public interface Callbacks {
+ void onInputBufferAvailable(AsyncCodec codec, int index);
+ void onOutputBufferAvailable(AsyncCodec codec, int index, BufferInfo info);
+ void onError(AsyncCodec codec, int error);
+ void onOutputFormatChanged(AsyncCodec codec, MediaFormat format);
+ }
+
+ public abstract void setCallbacks(Callbacks callbacks, Handler handler);
+ public abstract void configure(MediaFormat format, Surface surface, int flags);
+ public abstract void start();
+ public abstract void stop();
+ public abstract void flush();
+ public abstract void release();
+ public abstract ByteBuffer getInputBuffer(int index);
+ public abstract ByteBuffer getOutputBuffer(int index);
+ public abstract void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags);
+ public abstract void releaseOutputBuffer(int index, boolean render);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java
new file mode 100644
index 000000000..fd670e21b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/AsyncCodecFactory.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import java.io.IOException;
+
+public final class AsyncCodecFactory {
+ public static AsyncCodec create(String name) throws IOException {
+ // TODO: create (to be implemented) LollipopAsyncCodec when running on Lollipop or later devices.
+ return new JellyBeanAsyncCodec(name);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java b/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java
new file mode 100644
index 000000000..93a63bcb5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/AudioFocusAgent.java
@@ -0,0 +1,135 @@
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioManager;
+import android.media.AudioManager.OnAudioFocusChangeListener;
+import android.util.Log;
+
+public class AudioFocusAgent {
+ private static final String LOGTAG = "AudioFocusAgent";
+
+ private static Context mContext;
+ private AudioManager mAudioManager;
+ private OnAudioFocusChangeListener mAfChangeListener;
+
+ public static final String OWN_FOCUS = "own_focus";
+ public static final String LOST_FOCUS = "lost_focus";
+ public static final String LOST_FOCUS_TRANSIENT = "lost_focus_transient";
+
+ private String mAudioFocusState = LOST_FOCUS;
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void notifyStartedPlaying() {
+ if (!isAttachedToContext()) {
+ return;
+ }
+ Log.d(LOGTAG, "NotifyStartedPlaying");
+ AudioFocusAgent.getInstance().requestAudioFocusIfNeeded();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void notifyStoppedPlaying() {
+ if (!isAttachedToContext()) {
+ return;
+ }
+ Log.d(LOGTAG, "NotifyStoppedPlaying");
+ AudioFocusAgent.getInstance().abandonAudioFocusIfNeeded();
+ }
+
+ public synchronized void attachToContext(Context context) {
+ if (isAttachedToContext()) {
+ return;
+ }
+
+ mContext = context;
+ mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+
+ mAfChangeListener = new OnAudioFocusChangeListener() {
+ public void onAudioFocusChange(int focusChange) {
+ switch (focusChange) {
+ case AudioManager.AUDIOFOCUS_LOSS:
+ Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS");
+ notifyObservers("AudioFocusChanged", "lostAudioFocus");
+ notifyMediaControlService(MediaControlService.ACTION_PAUSE_BY_AUDIO_FOCUS);
+ mAudioFocusState = LOST_FOCUS;
+ break;
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
+ Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_LOSS_TRANSIENT");
+ notifyObservers("AudioFocusChanged", "lostAudioFocusTransiently");
+ notifyMediaControlService(MediaControlService.ACTION_PAUSE_BY_AUDIO_FOCUS);
+ mAudioFocusState = LOST_FOCUS_TRANSIENT;
+ break;
+ case AudioManager.AUDIOFOCUS_GAIN:
+ if (!mAudioFocusState.equals(LOST_FOCUS_TRANSIENT)) {
+ return;
+ }
+ Log.d(LOGTAG, "onAudioFocusChange, AUDIOFOCUS_GAIN");
+ notifyObservers("AudioFocusChanged", "gainAudioFocus");
+ notifyMediaControlService(MediaControlService.ACTION_RESUME_BY_AUDIO_FOCUS);
+ mAudioFocusState = OWN_FOCUS;
+ break;
+ default:
+ }
+ }
+ };
+ notifyMediaControlService(MediaControlService.ACTION_INIT);
+ }
+
+ @RobocopTarget
+ public static AudioFocusAgent getInstance() {
+ return AudioFocusAgent.SingletonHolder.INSTANCE;
+ }
+
+ private static class SingletonHolder {
+ private static final AudioFocusAgent INSTANCE = new AudioFocusAgent();
+ }
+
+ private static boolean isAttachedToContext() {
+ return (mContext != null);
+ }
+
+ private void notifyObservers(String topic, String data) {
+ GeckoAppShell.notifyObservers(topic, data);
+ }
+
+ private AudioFocusAgent() {}
+
+ private void requestAudioFocusIfNeeded() {
+ if (mAudioFocusState.equals(OWN_FOCUS)) {
+ return;
+ }
+
+ int result = mAudioManager.requestAudioFocus(mAfChangeListener,
+ AudioManager.STREAM_MUSIC,
+ AudioManager.AUDIOFOCUS_GAIN);
+
+ String focusMsg = (result == AudioManager.AUDIOFOCUS_GAIN) ?
+ "AudioFocus request granted" : "AudioFoucs request failed";
+ Log.d(LOGTAG, focusMsg);
+ if (result == AudioManager.AUDIOFOCUS_GAIN) {
+ mAudioFocusState = OWN_FOCUS;
+ }
+ }
+
+ private void abandonAudioFocusIfNeeded() {
+ if (!mAudioFocusState.equals(OWN_FOCUS)) {
+ return;
+ }
+
+ Log.d(LOGTAG, "Abandon AudioFocus");
+ mAudioManager.abandonAudioFocus(mAfChangeListener);
+ mAudioFocusState = LOST_FOCUS;
+ }
+
+ private void notifyMediaControlService(String action) {
+ Intent intent = new Intent(mContext, MediaControlService.class);
+ intent.setAction(action);
+ mContext.startService(intent);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/Codec.java b/mobile/android/base/java/org/mozilla/gecko/media/Codec.java
new file mode 100644
index 000000000..b0a26dfb3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/Codec.java
@@ -0,0 +1,366 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.TransactionTooLargeException;
+import android.util.Log;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+import java.util.NoSuchElementException;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/* package */ final class Codec extends ICodec.Stub implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteCodec";
+ private static final boolean DEBUG = false;
+
+ public enum Error {
+ DECODE, FATAL
+ };
+
+ private final class Callbacks implements AsyncCodec.Callbacks {
+ private ICodecCallbacks mRemote;
+ private boolean mHasInputCapacitySet;
+ private boolean mHasOutputCapacitySet;
+
+ public Callbacks(ICodecCallbacks remote) {
+ mRemote = remote;
+ }
+
+ @Override
+ public void onInputBufferAvailable(AsyncCodec codec, int index) {
+ if (mFlushing) {
+ // Flush invalidates all buffers.
+ return;
+ }
+ if (!mHasInputCapacitySet) {
+ int capacity = codec.getInputBuffer(index).capacity();
+ if (capacity > 0) {
+ mSamplePool.setInputBufferSize(capacity);
+ mHasInputCapacitySet = true;
+ }
+ }
+ if (!mInputProcessor.onBuffer(index)) {
+ reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full"));
+ }
+ }
+
+ @Override
+ public void onOutputBufferAvailable(AsyncCodec codec, int index, MediaCodec.BufferInfo info) {
+ if (mFlushing) {
+ // Flush invalidates all buffers.
+ return;
+ }
+ ByteBuffer output = codec.getOutputBuffer(index);
+ if (!mHasOutputCapacitySet) {
+ int capacity = output.capacity();
+ if (capacity > 0) {
+ mSamplePool.setOutputBufferSize(capacity);
+ mHasOutputCapacitySet = true;
+ }
+ }
+ Sample copy = mSamplePool.obtainOutput(info);
+ try {
+ if (info.size > 0) {
+ copy.buffer.readFromByteBuffer(output, info.offset, info.size);
+ }
+ mSentOutputs.add(copy);
+ mRemote.onOutput(copy);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage());
+ outputDummy(info);
+ } catch (TransactionTooLargeException ttle) {
+ Log.e(LOGTAG, "Output is too large:" + ttle.getMessage());
+ outputDummy(info);
+ } catch (RemoteException e) {
+ // Dead recipient.
+ e.printStackTrace();
+ }
+
+ mCodec.releaseOutputBuffer(index, true);
+ boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ if (DEBUG && eos) {
+ Log.d(LOGTAG, "output EOS");
+ }
+ }
+
+ private void outputDummy(MediaCodec.BufferInfo info) {
+ try {
+ if (DEBUG) Log.d(LOGTAG, "return dummy sample");
+ mRemote.onOutput(Sample.create(null, info, null));
+ } catch (RemoteException e) {
+ // Dead recipient.
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void onError(AsyncCodec codec, int error) {
+ reportError(Error.FATAL, new Exception("codec error:" + error));
+ }
+
+ @Override
+ public void onOutputFormatChanged(AsyncCodec codec, MediaFormat format) {
+ try {
+ mRemote.onOutputFormatChanged(new FormatParam(format));
+ } catch (RemoteException re) {
+ // Dead recipient.
+ re.printStackTrace();
+ }
+ }
+ }
+
+ private final class InputProcessor {
+ private Queue<Sample> mInputSamples = new LinkedList<>();
+ private Queue<Integer> mAvailableInputBuffers = new LinkedList<>();
+ private Queue<Sample> mDequeuedSamples = new LinkedList<>();
+
+ private synchronized Sample onAllocate(int size) {
+ Sample sample = mSamplePool.obtainInput(size);
+ mDequeuedSamples.add(sample);
+ return sample;
+ }
+
+ private synchronized boolean onSample(Sample sample) {
+ if (sample == null) {
+ return false;
+ }
+
+ if (!sample.isEOS()) {
+ Sample temp = sample;
+ sample = mDequeuedSamples.remove();
+ sample.info = temp.info;
+ sample.cryptoInfo = temp.cryptoInfo;
+ temp.dispose();
+ }
+
+ if (!mInputSamples.offer(sample)) {
+ return false;
+ }
+ feedSampleToBuffer();
+ return true;
+ }
+
+ private synchronized boolean onBuffer(int index) {
+ if (!mAvailableInputBuffers.offer(index)) {
+ return false;
+ }
+ feedSampleToBuffer();
+ return true;
+ }
+
+ private void feedSampleToBuffer() {
+ while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) {
+ int index = mAvailableInputBuffers.poll();
+ int len = 0;
+ Sample sample = mInputSamples.poll();
+ long pts = sample.info.presentationTimeUs;
+ int flags = sample.info.flags;
+ if (!sample.isEOS() && sample.buffer != null) {
+ len = sample.info.size;
+ ByteBuffer buf = mCodec.getInputBuffer(index);
+ try {
+ sample.writeToByteBuffer(buf);
+ mCallbacks.onInputExhausted();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ }
+ mSamplePool.recycleInput(sample);
+ }
+ mCodec.queueInputBuffer(index, 0, len, pts, flags);
+ }
+ }
+
+ private synchronized void reset() {
+ mInputSamples.clear();
+ mAvailableInputBuffers.clear();
+ }
+ }
+
+ private volatile ICodecCallbacks mCallbacks;
+ private AsyncCodec mCodec;
+ private InputProcessor mInputProcessor;
+ private volatile boolean mFlushing = false;
+ private SamplePool mSamplePool;
+ private Queue<Sample> mSentOutputs = new ConcurrentLinkedQueue<>();
+
+ public synchronized void setCallbacks(ICodecCallbacks callbacks) throws RemoteException {
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Callbacks is dead");
+ try {
+ release();
+ } catch (RemoteException e) {
+ // Nowhere to report the error.
+ }
+ }
+
+ @Override
+ public synchronized boolean configure(FormatParam format, Surface surface, int flags) throws RemoteException {
+ if (mCallbacks == null) {
+ Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()");
+ return false;
+ }
+
+ if (mCodec != null) {
+ if (DEBUG) Log.d(LOGTAG, "release existing codec: " + mCodec);
+ releaseCodec();
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "configure " + this);
+
+ MediaFormat fmt = format.asFormat();
+ String codecName = getDecoderForFormat(fmt);
+ if (codecName == null) {
+ Log.e(LOGTAG, "FAIL: cannot find codec");
+ return false;
+ }
+
+ try {
+ AsyncCodec codec = AsyncCodecFactory.create(codecName);
+ codec.setCallbacks(new Callbacks(mCallbacks), null);
+ codec.configure(fmt, surface, flags);
+ mCodec = codec;
+ mInputProcessor = new InputProcessor();
+ mSamplePool = new SamplePool(codecName);
+ if (DEBUG) Log.d(LOGTAG, codec.toString() + " created");
+ return true;
+ } catch (Exception e) {
+ if (DEBUG) Log.d(LOGTAG, "FAIL: cannot create codec -- " + codecName);
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private void releaseCodec() {
+ mInputProcessor.reset();
+ try {
+ mCodec.release();
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ mCodec = null;
+ }
+
+ private String getDecoderForFormat(MediaFormat format) {
+ String mime = format.getString(MediaFormat.KEY_MIME);
+ if (mime == null) {
+ return null;
+ }
+ int numCodecs = MediaCodecList.getCodecCount();
+ for (int i = 0; i < numCodecs; i++) {
+ MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder()) {
+ continue;
+ }
+ String[] types = info.getSupportedTypes();
+ for (String t : types) {
+ if (t.equalsIgnoreCase(mime)) {
+ return info.getName();
+ }
+ }
+ }
+ return null;
+ // TODO: API 21+ is simpler.
+ //static MediaCodecList sCodecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+ //return sCodecList.findDecoderForFormat(format);
+ }
+
+ @Override
+ public synchronized void start() throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "start " + this);
+ mFlushing = false;
+ try {
+ mCodec.start();
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private void reportError(Error error, Exception e) {
+ if (e != null) {
+ e.printStackTrace();
+ }
+ try {
+ mCallbacks.onError(error == Error.FATAL);
+ } catch (RemoteException re) {
+ re.printStackTrace();
+ }
+ }
+
+ @Override
+ public synchronized void stop() throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "stop " + this);
+ try {
+ mCodec.stop();
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void flush() throws RemoteException {
+ mFlushing = true;
+ if (DEBUG) Log.d(LOGTAG, "flush " + this);
+ mInputProcessor.reset();
+ try {
+ mCodec.flush();
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+
+ mFlushing = false;
+ if (DEBUG) Log.d(LOGTAG, "flushed " + this);
+ }
+
+ @Override
+ public synchronized Sample dequeueInput(int size) {
+ return mInputProcessor.onAllocate(size);
+ }
+
+ @Override
+ public synchronized void queueInput(Sample sample) throws RemoteException {
+ if (!mInputProcessor.onSample(sample)) {
+ reportError(Error.FATAL, new Exception("FAIL: input sample queue is full"));
+ }
+ }
+
+ @Override
+ public synchronized void releaseOutput(Sample sample) {
+ try {
+ mSamplePool.recycleOutput(mSentOutputs.remove());
+ } catch (Exception e) {
+ Log.e(LOGTAG, "failed to release output:" + sample);
+ e.printStackTrace();
+ }
+ sample.dispose();
+ }
+
+ @Override
+ public synchronized void release() throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "release " + this);
+ releaseCodec();
+ mSamplePool.reset();
+ mSamplePool = null;
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java
new file mode 100644
index 000000000..3025c14d0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/CodecProxy.java
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.media.MediaFormat;
+import android.os.DeadObjectException;
+import android.os.RemoteException;
+import android.util.Log;
+import android.view.Surface;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+// Proxy class of ICodec binder.
+public final class CodecProxy {
+ private static final String LOGTAG = "GeckoRemoteCodecProxy";
+ private static final boolean DEBUG = false;
+
+ private ICodec mRemote;
+ private FormatParam mFormat;
+ private Surface mOutputSurface;
+ private CallbacksForwarder mCallbacks;
+
+ public interface Callbacks {
+ void onInputExhausted();
+ void onOutputFormatChanged(MediaFormat format);
+ void onOutput(Sample output);
+ void onError(boolean fatal);
+ }
+
+ @WrapForJNI
+ public static class NativeCallbacks extends JNIObject implements Callbacks {
+ public native void onInputExhausted();
+ public native void onOutputFormatChanged(MediaFormat format);
+ public native void onOutput(Sample output);
+ public native void onError(boolean fatal);
+
+ @Override // JNIObject
+ protected native void disposeNative();
+ }
+
+ private class CallbacksForwarder extends ICodecCallbacks.Stub {
+ private final Callbacks mCallbacks;
+
+ CallbacksForwarder(Callbacks callbacks) {
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public void onInputExhausted() throws RemoteException {
+ mCallbacks.onInputExhausted();
+ }
+
+ @Override
+ public void onOutputFormatChanged(FormatParam format) throws RemoteException {
+ mCallbacks.onOutputFormatChanged(format.asFormat());
+ }
+
+ @Override
+ public void onOutput(Sample sample) throws RemoteException {
+ mCallbacks.onOutput(sample);
+ mRemote.releaseOutput(sample);
+ sample.dispose();
+ }
+
+ @Override
+ public void onError(boolean fatal) throws RemoteException {
+ reportError(fatal);
+ }
+
+ public void reportError(boolean fatal) {
+ mCallbacks.onError(fatal);
+ }
+ }
+
+ @WrapForJNI
+ public static CodecProxy create(MediaFormat format, Surface surface, Callbacks callbacks) {
+ return RemoteManager.getInstance().createCodec(format, surface, callbacks);
+ }
+
+ public static CodecProxy createCodecProxy(MediaFormat format, Surface surface, Callbacks callbacks) {
+ return new CodecProxy(format, surface, callbacks);
+ }
+
+ private CodecProxy(MediaFormat format, Surface surface, Callbacks callbacks) {
+ mFormat = new FormatParam(format);
+ mOutputSurface = surface;
+ mCallbacks = new CallbacksForwarder(callbacks);
+ }
+
+ boolean init(ICodec remote) {
+ try {
+ remote.setCallbacks(mCallbacks);
+ remote.configure(mFormat, mOutputSurface, 0);
+ remote.start();
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ mRemote = remote;
+ return true;
+ }
+
+ boolean deinit() {
+ try {
+ mRemote.stop();
+ mRemote.release();
+ mRemote = null;
+ return true;
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean input(ByteBuffer bytes, BufferInfo info, CryptoInfo cryptoInfo) {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot send input to an ended codec");
+ return false;
+ }
+
+ try {
+ Sample sample = (info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) ?
+ Sample.EOS : mRemote.dequeueInput(info.size).set(bytes, info, cryptoInfo);
+ mRemote.queueInput(sample);
+ sample.dispose();
+ } catch (IOException e) {
+ e.printStackTrace();
+ return false;
+ } catch (DeadObjectException e) {
+ return false;
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ Log.e(LOGTAG, "fail to input sample: size=" + info.size +
+ ", pts=" + info.presentationTimeUs +
+ ", flags=" + Integer.toHexString(info.flags));
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI
+ public synchronized boolean flush() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot flush an ended codec");
+ return false;
+ }
+ try {
+ if (DEBUG) Log.d(LOGTAG, "flush " + this);
+ mRemote.flush();
+ } catch (DeadObjectException e) {
+ return false;
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI
+ public synchronized boolean release() {
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ return true;
+ }
+ if (DEBUG) Log.d(LOGTAG, "release " + this);
+ try {
+ RemoteManager.getInstance().releaseCodec(this);
+ } catch (DeadObjectException e) {
+ return false;
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+
+ public synchronized void reportError(boolean fatal) {
+ mCallbacks.reportError(fatal);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java
new file mode 100644
index 000000000..c6762672d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/FormatParam.java
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaFormat;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.nio.ByteBuffer;
+
+/** A wrapper to make {@link MediaFormat} parcelable.
+ * Supports following keys:
+ * <ul>
+ * <li>{@link MediaFormat#KEY_MIME}</li>
+ * <li>{@link MediaFormat#KEY_WIDTH}</li>
+ * <li>{@link MediaFormat#KEY_HEIGHT}</li>
+ * <li>{@link MediaFormat#KEY_CHANNEL_COUNT}</li>
+ * <li>{@link MediaFormat#KEY_SAMPLE_RATE}</li>
+ * <li>"csd-0"</li>
+ * <li>"csd-1"</li>
+ * </ul>
+ */
+public final class FormatParam implements Parcelable {
+ // Keys for codec specific config bits not exposed in {@link MediaFormat}.
+ private static final String KEY_CONFIG_0 = "csd-0";
+ private static final String KEY_CONFIG_1 = "csd-1";
+
+ private MediaFormat mFormat;
+
+ public MediaFormat asFormat() {
+ return mFormat;
+ }
+
+ public FormatParam(MediaFormat format) {
+ mFormat = format;
+ }
+
+ protected FormatParam(Parcel in) {
+ mFormat = new MediaFormat();
+ readFromParcel(in);
+ }
+
+ public static final Creator<FormatParam> CREATOR = new Creator<FormatParam>() {
+ @Override
+ public FormatParam createFromParcel(Parcel in) {
+ return new FormatParam(in);
+ }
+
+ @Override
+ public FormatParam[] newArray(int size) {
+ return new FormatParam[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void readFromParcel(Parcel in) {
+ Bundle bundle = in.readBundle();
+ fromBundle(bundle);
+ }
+
+ private void fromBundle(Bundle bundle) {
+ if (bundle.containsKey(MediaFormat.KEY_MIME)) {
+ mFormat.setString(MediaFormat.KEY_MIME,
+ bundle.getString(MediaFormat.KEY_MIME));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_WIDTH)) {
+ mFormat.setInteger(MediaFormat.KEY_WIDTH,
+ bundle.getInt(MediaFormat.KEY_WIDTH));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) {
+ mFormat.setInteger(MediaFormat.KEY_HEIGHT,
+ bundle.getInt(MediaFormat.KEY_HEIGHT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ mFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT,
+ bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE,
+ bundle.getInt(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (bundle.containsKey(KEY_CONFIG_0)) {
+ mFormat.setByteBuffer(KEY_CONFIG_0,
+ ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0)));
+ }
+ if (bundle.containsKey(KEY_CONFIG_1)) {
+ mFormat.setByteBuffer(KEY_CONFIG_1,
+ ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1))));
+ }
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeBundle(toBundle());
+ }
+
+ private Bundle toBundle() {
+ Bundle bundle = new Bundle();
+ if (mFormat.containsKey(MediaFormat.KEY_MIME)) {
+ bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) {
+ bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) {
+ bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ bundle.putInt(MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_0)) {
+ ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0);
+ bundle.putByteArray(KEY_CONFIG_0,
+ Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_1)) {
+ ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1);
+ bundle.putByteArray(KEY_CONFIG_1,
+ Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ return bundle;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java
new file mode 100644
index 000000000..7b3bda3fd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrm.java
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+
+public interface GeckoMediaDrm {
+ public interface Callbacks {
+ void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request);
+ void onSessionUpdated(int promiseId, byte[] sessionId);
+ void onSessionClosed(int promiseId, byte[] sessionId);
+ void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request);
+ void onSessionError(byte[] sessionId, String message);
+ void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos);
+ // All failure cases should go through this function.
+ void onRejectPromise(int promiseId, String message);
+ }
+ void setCallbacks(Callbacks callbacks);
+ void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData);
+ void updateSession(int promiseId, String sessionId, byte[] response);
+ void closeSession(int promiseId, String sessionId);
+ void release();
+ MediaCrypto getMediaCrypto();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java
new file mode 100644
index 000000000..6ccaf80df
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java
@@ -0,0 +1,627 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import java.lang.*;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.UUID;
+import java.util.ArrayDeque;
+
+import android.annotation.SuppressLint;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.media.MediaCrypto;
+import android.media.MediaCryptoException;
+import android.media.MediaDrm;
+import android.media.MediaDrmException;
+import android.util.Log;
+
+public class GeckoMediaDrmBridgeV21 implements GeckoMediaDrm {
+ private static final String LOGTAG = "GeckoMediaDrmBridgeV21";
+ private static final String INVALID_SESSION_ID = "Invalid";
+ private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha";
+ private static final boolean DEBUG = false;
+ private static final UUID WIDEVINE_SCHEME_UUID =
+ new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL);
+ // MediaDrm.KeyStatus information listener is supported on M+, adding a
+ // dummy key id to report key status.
+ private static final byte[] DUMMY_KEY_ID = new byte[] {0};
+
+ private UUID mSchemeUUID;
+ private Handler mHandler;
+ private HandlerThread mHandlerThread;
+ private ByteBuffer mCryptoSessionId;
+
+ // mProvisioningPromiseId is great than 0 only during provisioning.
+ private int mProvisioningPromiseId;
+ private HashSet<ByteBuffer> mSessionIds;
+ private HashMap<ByteBuffer, String> mSessionMIMETypes;
+ private ArrayDeque<PendingCreateSessionData> mPendingCreateSessionDataQueue;
+ private GeckoMediaDrm.Callbacks mCallbacks;
+
+ private MediaCrypto mCrypto;
+ protected MediaDrm mDrm;
+
+ public static int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/
+ public static int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/
+ public static int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/
+
+ // Store session data while provisioning
+ private static class PendingCreateSessionData {
+ public final int mToken;
+ public final int mPromiseId;
+ public final byte[] mInitData;
+ public final String mMimeType;
+
+ private PendingCreateSessionData(int token, int promiseId,
+ byte[] initData, String mimeType) {
+ mToken = token;
+ mPromiseId = promiseId;
+ mInitData = initData;
+ mMimeType = mimeType;
+ }
+ }
+
+ public boolean isSecureDecoderComonentRequired(String mimeType) {
+ if (mCrypto != null) {
+ return mCrypto.requiresSecureDecoderComponent(mimeType);
+ }
+ return false;
+ }
+
+ private static void assertTrue(boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ @SuppressLint("WrongConstant")
+ private void configureVendorSpecificProperty() {
+ assertTrue(mDrm != null);
+ // Support L3 for now
+ mDrm.setPropertyString("securityLevel", "L3");
+ // Refer to chromium, set multi-session mode for Widevine.
+ if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) {
+ mDrm.setPropertyString("sessionSharing", "enable");
+ }
+ }
+
+ GeckoMediaDrmBridgeV21(String keySystem) throws Exception {
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21()");
+
+ mProvisioningPromiseId = 0;
+ mSessionIds = new HashSet<ByteBuffer>();
+ mSessionMIMETypes = new HashMap<ByteBuffer, String>();
+ mPendingCreateSessionDataQueue = new ArrayDeque<PendingCreateSessionData>();
+
+ mSchemeUUID = convertKeySystemToSchemeUUID(keySystem);
+ mCryptoSessionId = null;
+
+ if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString());
+
+ // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions
+ // threw by the following steps.
+ mDrm = new MediaDrm(mSchemeUUID);
+ configureVendorSpecificProperty();
+ mDrm.setOnEventListener(new MediaDrmListener());
+ }
+
+ @Override
+ public void setCallbacks(GeckoMediaDrm.Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ if (mProvisioningPromiseId > 0 && mCrypto == null) {
+ if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !");
+ savePendingCreateSessionData(createSessionToken, promiseId,
+ initData, initDataType);
+ return;
+ }
+
+ ByteBuffer sessionId = null;
+ String strSessionId = null;
+ try {
+ boolean hasMediaCrypto = ensureMediaCryptoCreated();
+ if (!hasMediaCrypto) {
+ onRejectPromise(promiseId, "MediaCrypto intance is not created !");
+ return;
+ }
+
+ sessionId = openSession();
+ if (sessionId == null) {
+ onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !");
+ return;
+ }
+
+ MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType);
+ if (request == null) {
+ mDrm.closeSession(sessionId.array());
+ onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !");
+ return;
+ }
+ onSessionCreated(createSessionToken,
+ promiseId,
+ sessionId.array(),
+ request.getData());
+ onSessionMessage(sessionId.array(),
+ LICENSE_REQUEST_INITIAL,
+ request.getData());
+ mSessionMIMETypes.put(sessionId, initDataType);
+ strSessionId = new String(sessionId.array());
+ mSessionIds.add(sessionId);
+ if (DEBUG) Log.d(LOGTAG, " StringID : " + strSessionId + " is put into mSessionIds ");
+ } catch (android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage());
+ if (sessionId != null) {
+ // The promise of this createSession will be either resolved
+ // or rejected after provisioning.
+ mDrm.closeSession(sessionId.array());
+ }
+ savePendingCreateSessionData(createSessionToken, promiseId,
+ initData, initDataType);
+ startProvisioning(promiseId);
+ }
+ }
+
+ @Override
+ public void updateSession(int promiseId,
+ String sessionId,
+ byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId);
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes());
+ if (!sessionExists(session)) {
+ onRejectPromise(promiseId, "Invalid session during updateSession.");
+ return;
+ }
+
+ try {
+ final byte [] keySetId = mDrm.provideKeyResponse(session.array(), response);
+ if (DEBUG) {
+ HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array());
+ for (String strKey : infoMap.keySet()) {
+ String strValue = infoMap.get(strKey);
+ Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")");
+ }
+ }
+ SessionKeyInfo[] keyInfos = new SessionKeyInfo[1];
+ keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID,
+ MediaDrm.KeyStatus.STATUS_USABLE);
+ onSessionBatchedKeyChanged(session.array(), keyInfos);
+ if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId);
+ onSessionUpdated(promiseId, session.array());
+ return;
+ } catch (android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:" + e.getMessage());
+ onSessionError(session.array(), "Got NotProvisionedException.");
+ onRejectPromise(promiseId, "Not provisioned during updateSession.");
+ } catch (android.media.DeniedByServerException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:" + e.getMessage());
+ onSessionError(session.array(), "Got DeniedByServerException.");
+ onRejectPromise(promiseId, "Denied by server during updateSession.");
+ } catch (java.lang.IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Exception when calling provideKeyResponse():" + e.getMessage());
+ onSessionError(session.array(), "Got IllegalStateException.");
+ onRejectPromise(promiseId, "Rejected during updateSession.");
+ }
+ release();
+ return;
+ }
+
+ @Override
+ public void closeSession(int promiseId, String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes());
+ mSessionIds.remove(session);
+ mDrm.closeSession(session.array());
+ onSessionClosed(promiseId, session.array());
+ }
+
+ @Override
+ public void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ if (mProvisioningPromiseId > 0) {
+ onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session.");
+ mProvisioningPromiseId = 0;
+ }
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions.");
+ }
+ mPendingCreateSessionDataQueue = null;
+
+ if (mDrm != null) {
+ for (ByteBuffer session : mSessionIds) {
+ mDrm.closeSession(session.array());
+ }
+ mDrm.release();
+ mDrm = null;
+ }
+ mSessionIds.clear();
+ mSessionIds = null;
+ mSessionMIMETypes.clear();
+ mSessionMIMETypes = null;
+
+ mCryptoSessionId = null;
+ if (mCrypto != null) {
+ mCrypto.release();
+ mCrypto = null;
+ }
+ if (mHandlerThread != null) {
+ mHandlerThread.quitSafely();
+ mHandlerThread = null;
+ }
+ mHandler = null;
+ }
+
+ @Override
+ public MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+ return mCrypto;
+ }
+
+ protected void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+
+ protected void onSessionUpdated(int promiseId, byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+
+ protected void onSessionClosed(int promiseId, byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+
+ protected void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+
+ protected void onSessionError(byte[] sessionId, String message) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionError(sessionId, message);
+ }
+
+ protected void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+
+ protected void onRejectPromise(int promiseId, String message) {
+ assertTrue(mCallbacks != null);
+ mCallbacks.onRejectPromise(promiseId, message);
+ }
+
+ private MediaDrm.KeyRequest getKeyRequest(ByteBuffer aSession,
+ byte[] data,
+ String mimeType)
+ throws android.media.NotProvisionedException {
+ if (mProvisioningPromiseId > 0) {
+ // Now provisioning.
+ return null;
+ }
+
+ try {
+ HashMap<String, String> optionalParameters = new HashMap<String, String>();
+ return mDrm.getKeyRequest(aSession.array(),
+ data,
+ mimeType,
+ MediaDrm.KEY_TYPE_STREAMING,
+ optionalParameters);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e);
+ }
+ return null;
+ }
+
+ private class MediaDrmListener implements MediaDrm.OnEventListener {
+ @Override
+ public void onEvent(MediaDrm mediaDrm, byte[] sessionArray, int event,
+ int extra, byte[] data) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()");
+ if (sessionArray == null) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session.");
+ return;
+ }
+ ByteBuffer session = ByteBuffer.wrap(sessionArray);
+ if (!sessionExists(session)) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session.");
+ return;
+ }
+ // On L, these events are treated as exceptions and handled correspondingly.
+ // Leaving this code block for logging message.
+ String sessionId = new String(session.array());
+ switch (event) {
+ case MediaDrm.EVENT_PROVISION_REQUIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED");
+ break;
+ case MediaDrm.EVENT_KEY_REQUIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED");
+ // No need to handle here if we're not in privacy mode.
+ break;
+ case MediaDrm.EVENT_KEY_EXPIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + sessionId);
+ break;
+ case MediaDrm.EVENT_VENDOR_DEFINED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + sessionId);
+ break;
+ default:
+ if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event);
+ return;
+ }
+ }
+ }
+
+ private ByteBuffer openSession() throws android.media.NotProvisionedException {
+ try {
+ byte[] sessionId = mDrm.openSession();
+ // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in
+ // case the underlying byte[] is modified.
+ return ByteBuffer.wrap(sessionId.clone());
+ } catch (android.media.NotProvisionedException e) {
+ // Throw NotProvisionedException so that we can startProvisioning().
+ throw e;
+ } catch (java.lang.RuntimeException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage());
+ release();
+ return null;
+ } catch (android.media.MediaDrmException e) {
+ // Other MediaDrmExceptions (e.g. ResourceBusyException) are not
+ // recoverable.
+ release();
+ return null;
+ }
+ }
+
+ private boolean sessionExists(ByteBuffer session) {
+ if (mCryptoSessionId == null) {
+ if (DEBUG) Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created.");
+ return false;
+ }
+ if (session == null) {
+ if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !");
+ return false;
+ }
+ return !session.equals(mCryptoSessionId) && mSessionIds.contains(session);
+ }
+
+ private class PostRequestTask extends AsyncTask<Void, Void, Void> {
+ private static final String LOGTAG = "PostRequestTask";
+
+ private int mPromiseId;
+ private String mURL;
+ private byte[] mDrmRequest;
+ private byte[] mResponseBody;
+
+ PostRequestTask(int promiseId, String url, byte[] drmRequest) {
+ this.mPromiseId = promiseId;
+ this.mURL = url;
+ this.mDrmRequest = drmRequest;
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ URL finalURL = new URL(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8"));
+ HttpURLConnection urlConnection = (HttpURLConnection) finalURL.openConnection();
+ urlConnection.setRequestMethod("POST");
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURL.toString());
+
+ // Add data
+ urlConnection.setRequestProperty("Accept", "*/*");
+ urlConnection.setRequestProperty("User-Agent", getCDMUserAgent());
+ urlConnection.setRequestProperty("Content-Type", "application/json");
+
+ // Execute HTTP Post Request
+ urlConnection.connect();
+
+ int responseCode = urlConnection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ BufferedReader in =
+ new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
+ String inputLine;
+ StringBuffer response = new StringBuffer();
+
+ while ((inputLine = in.readLine()) != null) {
+ response.append(inputLine);
+ }
+ in.close();
+ mResponseBody = String.valueOf(response).getBytes();
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, response received.");
+ if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length);
+ } else {
+ Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode);
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Got exception during posting provisioning request ...", e);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void v) {
+ onProvisionResponse(mPromiseId, mResponseBody);
+ }
+ }
+
+ private boolean provideProvisionResponse(byte[] response) {
+ if (response == null || response.length == 0) {
+ if (DEBUG) Log.d(LOGTAG, "Invalid provision response.");
+ return false;
+ }
+
+ try {
+ mDrm.provideProvisionResponse(response);
+ return true;
+ } catch (android.media.DeniedByServerException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ } catch (java.lang.IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ }
+ return false;
+ }
+
+ private void savePendingCreateSessionData(int token,
+ int promiseId,
+ byte[] initData,
+ String mime) {
+ if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId);
+ mPendingCreateSessionDataQueue.offer(new PendingCreateSessionData(token, promiseId, initData, mime));
+ }
+
+ private void processPendingCreateSessionData() {
+ if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... ");
+
+ assertTrue(mProvisioningPromiseId == 0);
+ try {
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId);
+
+ createSession(pendingData.mToken,
+ pendingData.mPromiseId,
+ pendingData.mMimeType,
+ pendingData.mInitData);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e);
+ }
+ }
+
+ private void resumePendingOperations() {
+ if (mHandlerThread == null) {
+ mHandlerThread = new HandlerThread("PendingSessionOpsThread");
+ mHandlerThread.start();
+ }
+ if (mHandler == null) {
+ mHandler = new Handler(mHandlerThread.getLooper());
+ }
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ processPendingCreateSessionData();
+ }
+ });
+ }
+
+ // Only triggered when failed on {openSession, getKeyRequest}
+ private void startProvisioning(int promiseId) {
+ if (DEBUG) Log.d(LOGTAG, "startProvisioning()");
+ if (mProvisioningPromiseId > 0) {
+ // Already in provisioning.
+ return;
+ }
+ try {
+ mProvisioningPromiseId = promiseId;
+ MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest();
+ PostRequestTask postTask =
+ new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData());
+ postTask.execute();
+ } catch (Exception e) {
+ onRejectPromise(promiseId, "Exception happened in startProvisioning !");
+ mProvisioningPromiseId = 0;
+ }
+ }
+
+ private void onProvisionResponse(int promiseId, byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()");
+
+ mProvisioningPromiseId = 0;
+ boolean success = provideProvisionResponse(response);
+ if (success) {
+ // Promise will either be resovled / rejected in createSession during
+ // resuming operations.
+ resumePendingOperations();
+ } else {
+ onRejectPromise(promiseId, "Failed to provide provision response.");
+ }
+ }
+
+ private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException {
+ if (mCrypto != null) {
+ return true;
+ }
+ try {
+ mCryptoSessionId = openSession();
+ if (mCryptoSessionId == null) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto");
+ return false;
+ }
+
+ if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) {
+ final byte [] cryptoSessionId = mCryptoSessionId.array();
+ mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId);
+ String strCryptoSessionId = new String(cryptoSessionId);
+ mSessionIds.add(mCryptoSessionId);
+ if (DEBUG) Log.d(LOGTAG, "MediaCrypto successfully created! - SId " + INVALID_SESSION_ID + ", " + strCryptoSessionId);
+ return true;
+ } else {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme.");
+ return false;
+ }
+ } catch (android.media.MediaCryptoException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage());
+ release();
+ return false;
+ } catch (android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage());
+ throw e;
+ }
+ }
+
+ private UUID convertKeySystemToSchemeUUID(String keySystem) {
+ if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) {
+ return WIDEVINE_SCHEME_UUID;
+ }
+ if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem);
+ return null;
+ }
+
+ private String getCDMUserAgent() {
+ // This user agent is found and hard-coded in Android(L) source code and
+ // Chromium project. Not sure if it's gonna change in the future.
+ String ua = "Widevine CDM v1.0";
+ return ua;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
new file mode 100644
index 000000000..74144f28e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.TargetApi;
+import static android.os.Build.VERSION_CODES.M;
+import android.media.MediaDrm;
+import android.util.Log;
+import java.util.List;
+
+public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 {
+
+ private static final String LOGTAG = "GeckoMediaDrmBridgeV23";
+ private static final boolean DEBUG = false;
+
+ GeckoMediaDrmBridgeV23(String keySystem) throws Exception {
+ super(keySystem);
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor");
+ mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null);
+ }
+
+ @TargetApi(M)
+ private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener {
+ @Override
+ public void onKeyStatusChange(MediaDrm mediaDrm,
+ byte[] sessionId,
+ List<MediaDrm.KeyStatus> keyInformation,
+ boolean hasNewUsableKey) {
+ if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey);
+ if (keyInformation.size() == 0) {
+ return;
+ }
+ SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()];
+ for (int i = 0; i < keyInformation.size(); i++) {
+ MediaDrm.KeyStatus keyStatus = keyInformation.get(i);
+ keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(),
+ keyStatus.getStatusCode());
+ }
+ onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
new file mode 100644
index 000000000..3df01f1fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
@@ -0,0 +1,405 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+// Implement async API using MediaCodec sync mode (API v16).
+// This class uses internal worker thread/handler (mBufferPoller) to poll
+// input and output buffer and notifies the client through callbacks.
+final class JellyBeanAsyncCodec implements AsyncCodec {
+ private static final String LOGTAG = "GeckoAsyncCodecAPIv16";
+ private static final boolean DEBUG = false;
+
+ private static final int ERROR_CODEC = -10000;
+
+ private abstract class CancelableHandler extends Handler {
+ private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL'
+
+ protected CancelableHandler(Looper looper) {
+ super(looper);
+ }
+
+ protected void cancel() {
+ removeCallbacksAndMessages(null);
+ sendEmptyMessage(MSG_CANCELLATION);
+ // Wait until handleMessageLocked() is done.
+ synchronized (this) { }
+ }
+
+ protected boolean isCanceled() {
+ return hasMessages(MSG_CANCELLATION);
+ }
+
+ // Subclass should implement this and return true if it handles msg.
+ // Warning: Never, ever call super.handleMessage() in this method!
+ protected abstract boolean handleMessageLocked(Message msg);
+
+ public final void handleMessage(Message msg) {
+ // Block cancel() during handleMessageLocked().
+ synchronized (this) {
+ if (isCanceled() || handleMessageLocked(msg)) {
+ return;
+ }
+ }
+
+ switch (msg.what) {
+ case MSG_CANCELLATION:
+ // Just a marker. Nothing to do here.
+ if (DEBUG) Log.d(LOGTAG, "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this);
+ break;
+ default:
+ super.handleMessage(msg);
+ break;
+ }
+ }
+ }
+
+ // A handler to invoke AsyncCodec.Callbacks methods.
+ private final class CallbackSender extends CancelableHandler {
+ private static final int MSG_INPUT_BUFFER_AVAILABLE = 1;
+ private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2;
+ private static final int MSG_OUTPUT_FORMAT_CHANGE = 3;
+ private static final int MSG_ERROR = 4;
+ private Callbacks mCallbacks;
+
+ private CallbackSender(Looper looper, Callbacks callbacks) {
+ super(looper);
+ mCallbacks = callbacks;
+ }
+
+ public void notifyInputBuffer(int index) {
+ if (isCanceled()) {
+ return;
+ }
+
+ Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ private void processMessage(Message msg) {
+ if (Looper.myLooper() == getLooper()) {
+ handleMessage(msg);
+ } else {
+ sendMessage(msg);
+ }
+ }
+
+ public void notifyOutputBuffer(int index, MediaCodec.BufferInfo info) {
+ if (isCanceled()) {
+ return;
+ }
+
+ Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ public void notifyOutputFormat(MediaFormat format) {
+ if (isCanceled()) {
+ return;
+ }
+ processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format));
+ }
+
+ public void notifyError(int result) {
+ Log.e(LOGTAG, "codec error:" + result);
+ processMessage(obtainMessage(MSG_ERROR, result, 0));
+ }
+
+ protected boolean handleMessageLocked(Message msg) {
+ switch (msg.what) {
+ case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index.
+ mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this,
+ msg.arg1);
+ break;
+ case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info.
+ mCallbacks.onOutputBufferAvailable(JellyBeanAsyncCodec.this,
+ msg.arg1,
+ (MediaCodec.BufferInfo)msg.obj);
+ break;
+ case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format.
+ mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this,
+ (MediaFormat)msg.obj);
+ break;
+ case MSG_ERROR: // arg1: error code.
+ mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1);
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(),
+ // with 10ms time-out. Once triggered and successfully gets a buffer, it
+ // will schedule next polling until EOS or failure. To prevent it from
+ // automatically polling more buffer, use cancel() it inherits from
+ // CancelableHandler.
+ private final class BufferPoller extends CancelableHandler {
+ private static final int MSG_POLL_INPUT_BUFFERS = 1;
+ private static final int MSG_POLL_OUTPUT_BUFFERS = 2;
+
+ private static final long DEQUEUE_TIMEOUT_US = 10000;
+
+ public BufferPoller(Looper looper) {
+ super(looper);
+ }
+
+ private void schedulePollingIfNotCanceled(int what) {
+ if (isCanceled()) {
+ return;
+ }
+
+ schedulePolling(what);
+ }
+
+ private void schedulePolling(int what) {
+ if (needsBuffer(what)) {
+ sendEmptyMessage(what);
+ }
+ }
+
+ private boolean needsBuffer(int what) {
+ if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) {
+ return false;
+ }
+
+ if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected boolean handleMessageLocked(Message msg) {
+ try {
+ switch (msg.what) {
+ case MSG_POLL_INPUT_BUFFERS:
+ pollInputBuffer();
+ break;
+ case MSG_POLL_OUTPUT_BUFFERS:
+ pollOutputBuffer();
+ break;
+ default:
+ return false;
+ }
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ }
+
+ return true;
+ }
+
+ private void pollInputBuffer() {
+ int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ mCallbackSender.notifyInputBuffer(result);
+ schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ } else if (result != MediaCodec.INFO_TRY_AGAIN_LATER) {
+ mCallbackSender.notifyError(result);
+ }
+ }
+
+ private void pollOutputBuffer() {
+ boolean dequeueMoreBuffer = true;
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ mOutputEnded = true;
+ }
+ mCallbackSender.notifyOutputBuffer(result, info);
+ if (!hasMessages(MSG_POLL_INPUT_BUFFERS)) {
+ schedulePollingIfNotCanceled(MSG_POLL_INPUT_BUFFERS);
+ }
+ } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ mOutputBuffers = mCodec.getOutputBuffers();
+ } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat());
+ } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ // When input ended, keep polling remaining output buffer until EOS.
+ dequeueMoreBuffer = mInputEnded;
+ } else {
+ mCallbackSender.notifyError(result);
+ dequeueMoreBuffer = false;
+ }
+
+ if (dequeueMoreBuffer) {
+ schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS);
+ }
+ }
+ }
+
+ private MediaCodec mCodec;
+ private ByteBuffer[] mInputBuffers;
+ private ByteBuffer[] mOutputBuffers;
+ private AsyncCodec.Callbacks mCallbacks;
+ private CallbackSender mCallbackSender;
+
+ private BufferPoller mBufferPoller;
+ private volatile boolean mInputEnded;
+ private volatile boolean mOutputEnded;
+
+ // Must be called on a thread with looper.
+ /* package */ JellyBeanAsyncCodec(String name) throws IOException {
+ mCodec = MediaCodec.createByCodecName(name);
+ initBufferPoller(name + " buffer poller");
+ }
+
+ private void initBufferPoller(String name) {
+ if (mBufferPoller != null) {
+ Log.e(LOGTAG, "poller already initialized");
+ return;
+ }
+ HandlerThread thread = new HandlerThread(name);
+ thread.start();
+ mBufferPoller = new BufferPoller(thread.getLooper());
+ if (DEBUG) Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId());
+ }
+
+ @Override
+ public void setCallbacks(AsyncCodec.Callbacks callbacks, Handler handler) {
+ if (callbacks == null) {
+ return;
+ }
+
+ Looper looper = (handler == null) ? null : handler.getLooper();
+ if (looper == null) {
+ // Use this thread if no handler supplied.
+ looper = Looper.myLooper();
+ }
+ if (looper == null) {
+ // This thread has no looper. Use poller thread.
+ looper = mBufferPoller.getLooper();
+ }
+ mCallbackSender = new CallbackSender(looper, callbacks);
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender);
+ }
+
+ @Override
+ public void configure(MediaFormat format, Surface surface, int flags) {
+ assertCallbacks();
+
+ mCodec.configure(format, surface, null, flags);
+ }
+
+ private void assertCallbacks() {
+ if (mCallbackSender == null) {
+ throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks().");
+ }
+ }
+
+ @Override
+ public void start() {
+ assertCallbacks();
+
+ mCodec.start();
+ mInputEnded = false;
+ mOutputEnded = false;
+ mInputBuffers = mCodec.getInputBuffers();
+ mOutputBuffers = mCodec.getOutputBuffers();
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+
+ @Override
+ public final void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags) {
+ assertCallbacks();
+
+ mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+
+ try {
+ mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ return;
+ }
+
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS);
+ }
+
+ @Override
+ public final void releaseOutputBuffer(int index, boolean render) {
+ assertCallbacks();
+
+ mCodec.releaseOutputBuffer(index, render);
+ }
+
+ @Override
+ public final ByteBuffer getInputBuffer(int index) {
+ assertCallbacks();
+
+ return mInputBuffers[index];
+ }
+
+ @Override
+ public final ByteBuffer getOutputBuffer(int index) {
+ assertCallbacks();
+
+ return mOutputBuffers[index];
+ }
+
+ @Override
+ public void flush() {
+ assertCallbacks();
+
+ mInputEnded = false;
+ mOutputEnded = false;
+ cancelPendingTasks();
+ mCodec.flush();
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+
+ private void cancelPendingTasks() {
+ mBufferPoller.cancel();
+ mCallbackSender.cancel();
+ }
+
+ @Override
+ public void stop() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCodec.stop();
+ }
+
+ @Override
+ public void release() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCallbackSender = null;
+ mCodec.release();
+ stopBufferPoller();
+ }
+
+ private void stopBufferPoller() {
+ if (mBufferPoller == null) {
+ Log.e(LOGTAG, "no initialized poller.");
+ return;
+ }
+
+ mBufferPoller.getLooper().quit();
+ mBufferPoller = null;
+
+ if (DEBUG) Log.d(LOGTAG, "stop poller " + this);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java b/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java
new file mode 100644
index 000000000..2aad674b6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/LocalMediaDrmBridge.java
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+import org.mozilla.gecko.AppConstants;
+
+import android.media.MediaCrypto;
+import android.util.Log;
+
+final class LocalMediaDrmBridge implements GeckoMediaDrm {
+ private static final String LOGTAG = "GeckoLocalMediaDrmBridge";
+ private static final boolean DEBUG = false;
+ private GeckoMediaDrm mBridge = null;
+ private CallbacksForwarder mCallbacksFwd;
+
+ // Forward the callback calls from GeckoMediaDrmBridgeV{21,23}
+ // to the callback MediaDrmProxy.Callbacks.
+ private class CallbacksForwarder implements GeckoMediaDrm.Callbacks {
+ private final GeckoMediaDrm.Callbacks mProxyCallbacks;
+
+ CallbacksForwarder(GeckoMediaDrm.Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mProxyCallbacks = callbacks;
+ }
+
+ @Override
+ public void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionCreated(createSessionToken,
+ promiseId,
+ sessionId,
+ request);
+ }
+
+ @Override
+ public void onSessionUpdated(int promiseId, byte[] sessionId) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionClosed(int promiseId, byte[] sessionId) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+
+ @Override
+ public void onSessionError(byte[] sessionId,
+ String message) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionError(sessionId, message);
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos) {
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+
+ @Override
+ public void onRejectPromise(int promiseId, String message) {
+ if (DEBUG) Log.d(LOGTAG, message);
+ assertTrue(mProxyCallbacks != null);
+ mProxyCallbacks.onRejectPromise(promiseId, message);
+ }
+ } // CallbacksForwarder
+
+ private static void assertTrue(boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ LocalMediaDrmBridge(String keySystem) throws Exception {
+ if (AppConstants.Versions.preLollipop) {
+ mBridge = null;
+ } else if (AppConstants.Versions.feature21Plus &&
+ AppConstants.Versions.preMarshmallow) {
+ mBridge = new GeckoMediaDrmBridgeV21(keySystem);
+ } else {
+ mBridge = new GeckoMediaDrmBridgeV23(keySystem);
+ }
+ }
+
+ @Override
+ public synchronized void setCallbacks(Callbacks callbacks) {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ mCallbacksFwd = new CallbacksForwarder(callbacks);
+ assertTrue(mBridge != null);
+ mBridge.setCallbacks(mCallbacksFwd);
+ }
+
+ @Override
+ public synchronized void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ assertTrue(mCallbacksFwd != null);
+ try {
+ mBridge.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to createSession.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to createSession.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(int promiseId, String sessionId, byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+ assertTrue(mCallbacksFwd != null);
+ try {
+ mBridge.updateSession(promiseId, sessionId, response);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to updateSession.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to updateSession.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(int promiseId, String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ assertTrue(mCallbacksFwd != null);
+ try {
+ mBridge.closeSession(promiseId, sessionId);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to closeSession.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to closeSession.");
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ try {
+ mBridge.release();
+ mBridge = null;
+ mCallbacksFwd = null;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to release", e);
+ }
+ }
+
+ @Override
+ public synchronized MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+ return mBridge != null ? mBridge.getMediaCrypto() : null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
new file mode 100644
index 000000000..2aa783050
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaControlService.java
@@ -0,0 +1,431 @@
+package org.mozilla.gecko.media;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.support.v4.app.NotificationManagerCompat;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.ref.WeakReference;
+
+public class MediaControlService extends Service implements Tabs.OnTabsChangedListener {
+ private static final String LOGTAG = "MediaControlService";
+
+ public static final String ACTION_INIT = "action_init";
+ public static final String ACTION_RESUME = "action_resume";
+ public static final String ACTION_PAUSE = "action_pause";
+ public static final String ACTION_STOP = "action_stop";
+ public static final String ACTION_RESUME_BY_AUDIO_FOCUS = "action_resume_audio_focus";
+ public static final String ACTION_PAUSE_BY_AUDIO_FOCUS = "action_pause_audio_focus";
+
+ private static final int MEDIA_CONTROL_ID = 1;
+ private static final String MEDIA_CONTROL_PREF = "dom.audiochannel.mediaControl";
+
+ private String mActionState = ACTION_STOP;
+
+ private MediaSession mSession;
+ private MediaController mController;
+
+ private PrefsHelper.PrefHandler mPrefsObserver;
+ private final String[] mPrefs = { MEDIA_CONTROL_PREF };
+
+ private boolean mInitialize = false;
+ private boolean mIsMediaControlPrefOn = true;
+
+ private static WeakReference<Tab> mTabReference = new WeakReference<>(null);
+
+ private int minCoverSize;
+ private int coverSize;
+
+ @Override
+ public void onCreate() {
+ initialize();
+ }
+
+ @Override
+ public void onDestroy() {
+ shutdown();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ handleIntent(intent);
+ return START_NOT_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ mSession.release();
+ return super.onUnbind(intent);
+ }
+
+ @Override
+ public void onTaskRemoved(Intent rootIntent) {
+ shutdown();
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ if (!mInitialize) {
+ return;
+ }
+
+ final Tab playingTab = mTabReference.get();
+ switch (msg) {
+ case MEDIA_PLAYING_CHANGE:
+ // The 'MEDIA_PLAYING_CHANGE' would only be received when the
+ // media starts or ends.
+ if (playingTab != tab && tab.isMediaPlaying()) {
+ mTabReference = new WeakReference<>(tab);
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ } else if (playingTab == tab && !tab.isMediaPlaying()) {
+ notifyControlInterfaceChanged(ACTION_STOP);
+ mTabReference = new WeakReference<>(null);
+ }
+ break;
+ case MEDIA_PLAYING_RESUME:
+ // user resume the paused-by-control media from page so that we
+ // should make the control interface consistent.
+ if (playingTab == tab && !isMediaPlaying()) {
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ }
+ break;
+ case CLOSED:
+ if (playingTab == null || playingTab == tab) {
+ // Remove the controls when the playing tab disappeared or was closed.
+ notifyControlInterfaceChanged(ACTION_STOP);
+ }
+ break;
+ case FAVICON:
+ if (playingTab == tab) {
+ final String actionForPendingIntent = isMediaPlaying() ?
+ ACTION_PAUSE : ACTION_RESUME;
+ notifyControlInterfaceChanged(actionForPendingIntent);
+ }
+ break;
+ }
+ }
+
+ private boolean isMediaPlaying() {
+ return mActionState.equals(ACTION_RESUME);
+ }
+
+ private void initialize() {
+ if (mInitialize ||
+ !isAndroidVersionLollopopOrHigher()) {
+ return;
+ }
+
+ Log.d(LOGTAG, "initialize");
+ getGeckoPreference();
+ initMediaSession();
+
+ coverSize = (int) getResources().getDimension(R.dimen.notification_media_cover);
+ minCoverSize = getResources().getDimensionPixelSize(R.dimen.favicon_bg);
+
+ Tabs.registerOnTabsChangedListener(this);
+ mInitialize = true;
+ }
+
+ private void shutdown() {
+ if (!mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "shutdown");
+ notifyControlInterfaceChanged(ACTION_STOP);
+ PrefsHelper.removeObserver(mPrefsObserver);
+
+ Tabs.unregisterOnTabsChangedListener(this);
+ mInitialize = false;
+ stopSelf();
+ }
+
+ private boolean isAndroidVersionLollopopOrHigher() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
+ }
+
+ private void handleIntent(Intent intent) {
+ if (intent == null || intent.getAction() == null || !mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "HandleIntent, action = " + intent.getAction() + ", actionState = " + mActionState);
+ switch (intent.getAction()) {
+ case ACTION_INIT :
+ // This action is used to create a service and do the initialization,
+ // the actual operation would be executed via control interface's
+ // pending intent.
+ break;
+ case ACTION_RESUME :
+ mController.getTransportControls().play();
+ break;
+ case ACTION_PAUSE :
+ mController.getTransportControls().pause();
+ break;
+ case ACTION_STOP :
+ mController.getTransportControls().stop();
+ break;
+ case ACTION_PAUSE_BY_AUDIO_FOCUS :
+ mController.getTransportControls().sendCustomAction(ACTION_PAUSE_BY_AUDIO_FOCUS, null);
+ break;
+ case ACTION_RESUME_BY_AUDIO_FOCUS :
+ mController.getTransportControls().sendCustomAction(ACTION_RESUME_BY_AUDIO_FOCUS, null);
+ break;
+ }
+ }
+
+ private void getGeckoPreference() {
+ mPrefsObserver = new PrefsHelper.PrefHandlerBase() {
+ @Override
+ public void prefValue(String pref, boolean value) {
+ if (pref.equals(MEDIA_CONTROL_PREF)) {
+ mIsMediaControlPrefOn = value;
+
+ // If media is playing, we just need to create or remove
+ // the media control interface.
+ if (mActionState.equals(ACTION_RESUME)) {
+ notifyControlInterfaceChanged(mIsMediaControlPrefOn ?
+ ACTION_PAUSE : ACTION_STOP);
+ }
+
+ // If turn off pref during pausing, except removing media
+ // interface, we also need to stop the service and notify
+ // gecko about that.
+ if (mActionState.equals(ACTION_PAUSE) &&
+ !mIsMediaControlPrefOn) {
+ Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+ intent.setAction(ACTION_STOP);
+ handleIntent(intent);
+ }
+ }
+ }
+ };
+ PrefsHelper.addObserver(mPrefs, mPrefsObserver);
+ }
+
+ private void initMediaSession() {
+ // Android MediaSession is introduced since version L.
+ mSession = new MediaSession(getApplicationContext(),
+ "fennec media session");
+ mController = new MediaController(getApplicationContext(),
+ mSession.getSessionToken());
+
+ mSession.setCallback(new MediaSession.Callback() {
+ @Override
+ public void onCustomAction(String action, Bundle extras) {
+ if (action.equals(ACTION_PAUSE_BY_AUDIO_FOCUS)) {
+ Log.d(LOGTAG, "Controller, pause by audio focus changed");
+ notifyControlInterfaceChanged(ACTION_RESUME);
+ } else if (action.equals(ACTION_RESUME_BY_AUDIO_FOCUS)) {
+ Log.d(LOGTAG, "Controller, resume by audio focus changed");
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ }
+ }
+
+ @Override
+ public void onPlay() {
+ Log.d(LOGTAG, "Controller, onPlay");
+ super.onPlay();
+ notifyControlInterfaceChanged(ACTION_PAUSE);
+ notifyObservers("MediaControl", "resumeMedia");
+ // To make sure we always own audio focus during playing.
+ AudioFocusAgent.notifyStartedPlaying();
+ }
+
+ @Override
+ public void onPause() {
+ Log.d(LOGTAG, "Controller, onPause");
+ super.onPause();
+ notifyControlInterfaceChanged(ACTION_RESUME);
+ notifyObservers("MediaControl", "mediaControlPaused");
+ AudioFocusAgent.notifyStoppedPlaying();
+ }
+
+ @Override
+ public void onStop() {
+ Log.d(LOGTAG, "Controller, onStop");
+ super.onStop();
+ notifyControlInterfaceChanged(ACTION_STOP);
+ notifyObservers("MediaControl", "mediaControlStopped");
+ mTabReference = new WeakReference<>(null);
+ }
+ });
+ }
+
+ private void notifyObservers(String topic, String data) {
+ GeckoAppShell.notifyObservers(topic, data);
+ }
+
+ private boolean isNeedToRemoveControlInterface(String action) {
+ return action.equals(ACTION_STOP);
+ }
+
+ private void notifyControlInterfaceChanged(final String uiAction) {
+ if (!mInitialize) {
+ return;
+ }
+
+ Log.d(LOGTAG, "notifyControlInterfaceChanged, action = " + uiAction);
+
+ if (isNeedToRemoveControlInterface(uiAction)) {
+ stopForeground(false);
+ NotificationManagerCompat.from(this).cancel(MEDIA_CONTROL_ID);
+ setActionState(uiAction);
+ return;
+ }
+
+ if (!mIsMediaControlPrefOn) {
+ return;
+ }
+
+ final Tab tab = mTabReference.get();
+
+ if (tab == null) {
+ return;
+ }
+
+ setActionState(uiAction);
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ updateNotification(tab, uiAction);
+ }
+ });
+ }
+
+ private void setActionState(final String uiAction) {
+ switch (uiAction) {
+ case ACTION_PAUSE:
+ mActionState = ACTION_RESUME;
+ break;
+ case ACTION_RESUME:
+ mActionState = ACTION_PAUSE;
+ break;
+ case ACTION_STOP:
+ mActionState = ACTION_STOP;
+ break;
+ }
+ }
+
+ private void updateNotification(Tab tab, String action) {
+ ThreadUtils.assertNotOnUiThread();
+
+ final Notification.MediaStyle style = new Notification.MediaStyle();
+ style.setShowActionsInCompactView(0);
+
+ final boolean isPlaying = isMediaPlaying();
+ final int visibility = tab.isPrivate() ?
+ Notification.VISIBILITY_PRIVATE : Notification.VISIBILITY_PUBLIC;
+
+ final Notification notification = new Notification.Builder(this)
+ .setSmallIcon(R.drawable.flat_icon)
+ .setLargeIcon(generateCoverArt(tab))
+ .setContentTitle(tab.getTitle())
+ .setContentText(tab.getURL())
+ .setContentIntent(createContentIntent(tab.getId()))
+ .setDeleteIntent(createDeleteIntent())
+ .setStyle(style)
+ .addAction(createNotificationAction(action))
+ .setOngoing(isPlaying)
+ .setShowWhen(false)
+ .setWhen(0)
+ .setVisibility(visibility)
+ .build();
+
+ if (isPlaying) {
+ startForeground(MEDIA_CONTROL_ID, notification);
+ } else {
+ stopForeground(false);
+ NotificationManagerCompat.from(this)
+ .notify(MEDIA_CONTROL_ID, notification);
+ }
+ }
+
+ private Notification.Action createNotificationAction(String action) {
+ boolean isPlayAction = action.equals(ACTION_RESUME);
+
+ int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause;
+ String title = getString(isPlayAction ? R.string.media_play : R.string.media_pause);
+
+ final Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+ intent.setAction(action);
+ final PendingIntent pendingIntent = PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+
+ //noinspection deprecation - The new constructor is only for API > 23
+ return new Notification.Action.Builder(icon, title, pendingIntent).build();
+ }
+
+ private PendingIntent createContentIntent(int tabId) {
+ Intent intent = new Intent(getApplicationContext(), BrowserApp.class);
+ intent.setAction(GeckoApp.ACTION_SWITCH_TAB);
+ intent.putExtra("TabId", tabId);
+ return PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ private PendingIntent createDeleteIntent() {
+ Intent intent = new Intent(getApplicationContext(), MediaControlService.class);
+ intent.setAction(ACTION_STOP);
+ return PendingIntent.getService(getApplicationContext(), 1, intent, 0);
+ }
+
+ private Bitmap generateCoverArt(Tab tab) {
+ final Bitmap favicon = tab.getFavicon();
+
+ // If we do not have a favicon or if it's smaller than 72 pixels then just use the default icon.
+ if (favicon == null || favicon.getWidth() < minCoverSize || favicon.getHeight() < minCoverSize) {
+ // Use the launcher icon as fallback
+ return BitmapFactory.decodeResource(getResources(), R.drawable.notification_media);
+ }
+
+ // Favicon should at least have half of the size of the cover
+ int width = Math.max(favicon.getWidth(), coverSize / 2);
+ int height = Math.max(favicon.getHeight(), coverSize / 2);
+
+ final Bitmap coverArt = Bitmap.createBitmap(coverSize, coverSize, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(coverArt);
+ canvas.drawColor(0xFF777777);
+
+ int left = Math.max(0, (coverArt.getWidth() / 2) - (width / 2));
+ int right = Math.min(coverSize, left + width);
+ int top = Math.max(0, (coverArt.getHeight() / 2) - (height / 2));
+ int bottom = Math.min(coverSize, top + height);
+
+ final Paint paint = new Paint();
+ paint.setAntiAlias(true);
+
+ canvas.drawBitmap(favicon,
+ new Rect(0, 0, favicon.getWidth(), favicon.getHeight()),
+ new Rect(left, top, right, bottom),
+ paint);
+
+ return coverArt;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java
new file mode 100644
index 000000000..faca2389e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaDrmProxy.java
@@ -0,0 +1,307 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+package org.mozilla.gecko.media;
+
+import java.util.ArrayList;
+import java.util.UUID;
+
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.AppConstants;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.util.Log;
+import android.os.Build;
+
+public final class MediaDrmProxy {
+ private static final String LOGTAG = "GeckoMediaDrmProxy";
+ private static final boolean DEBUG = false;
+ private static final UUID WIDEVINE_SCHEME_UUID =
+ new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL);
+
+ private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha";
+ @WrapForJNI
+ private static final String AAC = "audio/mp4a-latm";
+ @WrapForJNI
+ private static final String AVC = "video/avc";
+ @WrapForJNI
+ private static final String VORBIS = "audio/vorbis";
+ @WrapForJNI
+ private static final String VP8 = "video/x-vnd.on2.vp8";
+ @WrapForJNI
+ private static final String VP9 = "video/x-vnd.on2.vp9";
+ @WrapForJNI
+ private static final String OPUS = "audio/opus";
+
+ // A flag to avoid using the native object that has been destroyed.
+ private boolean mDestroyed;
+ private GeckoMediaDrm mImpl;
+ public static ArrayList<MediaDrmProxy> mProxyList = new ArrayList<MediaDrmProxy>();
+
+ private static boolean isSystemSupported() {
+ // Support versions >= LOLLIPOP
+ if (AppConstants.Versions.preLollipop) {
+ if (DEBUG) Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT);
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI
+ public static boolean isSchemeSupported(String keySystem) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID)
+ && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID);
+ }
+ if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem);
+ return false;
+ }
+
+ @WrapForJNI
+ public static boolean IsCryptoSchemeSupported(String keySystem,
+ String container) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container);
+ }
+ if (DEBUG) Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container);
+ return false;
+ }
+
+ @WrapForJNI
+ public static boolean CanDecode(String mimeType) {
+ for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) {
+ MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder()) {
+ continue;
+ }
+ for (String m : info.getSupportedTypes()) {
+ if (m.equals(mimeType)) {
+ return true;
+ }
+ }
+ }
+ if (DEBUG) Log.d(LOGTAG, "cannot decode mimetype = " + mimeType);
+ return false;
+ }
+
+ // Interface for callback to native.
+ public interface Callbacks {
+ void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request);
+
+ void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ void onSessionClosed(int promiseId, byte[] sessionId);
+
+ void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request);
+
+ void onSessionError(byte[] sessionId,
+ String message);
+
+ // MediaDrm.KeyStatus is available in API level 23(M)
+ // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html
+ // For compatibility between L and M above, we'll unwrap the KeyStatus structure
+ // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy).
+ void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos);
+
+ void onRejectPromise(int promiseId,
+ String message);
+ } // Callbacks
+
+ public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ NativeMediaDrmProxyCallbacks() {}
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionClosed(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionError(byte[] sessionId,
+ String message);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onRejectPromise(int promiseId,
+ String message);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // NativeMediaDrmProxyCallbacks
+
+ // A proxy to callback from LocalMediaDrmBridge to native instance.
+ public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks {
+ private final Callbacks mNativeCallbacks;
+ private final MediaDrmProxy mProxy;
+
+ public MediaDrmProxyCallbacks(MediaDrmProxy proxy, Callbacks callbacks) {
+ mNativeCallbacks = callbacks;
+ mProxy = proxy;
+ }
+
+ @Override
+ public void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionCreated(createSessionToken,
+ promiseId,
+ sessionId,
+ request);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(int promiseId, byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(int promiseId, byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+ }
+
+ @Override
+ public void onSessionError(byte[] sessionId,
+ String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionError(sessionId, message);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(int promiseId,
+ String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onRejectPromise(promiseId, message);
+ }
+ }
+ } // MediaDrmProxyCallbacks
+
+ public boolean isDestroyed() {
+ return mDestroyed;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static MediaDrmProxy create(String keySystem,
+ Callbacks nativeCallbacks,
+ boolean isRemote) {
+ // TODO: Will implement {Local,Remote}MediaDrmBridge instantiation by
+ // '''isRemote''' flag in Bug 1307818.
+ MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks);
+ return proxy;
+ }
+
+ MediaDrmProxy(String keySystem, Callbacks nativeCallbacks) {
+ if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy");
+ // TODO: Bug 1306185 will implement the LocalMediaDrmBridge as an impl
+ // of GeckoMediaDrm for in-process decoding mode.
+ //mImpl = new LocalMediaDrmBridge(keySystem);
+ mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks));
+ mProxyList.add(this);
+ }
+
+ @WrapForJNI
+ private void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId);
+ mImpl.createSession(createSessionToken,
+ promiseId,
+ initDataType,
+ initData);
+ }
+
+ @WrapForJNI
+ private void updateSession(int promiseId, String sessionId, byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.updateSession(promiseId, sessionId, response);
+ }
+
+ @WrapForJNI
+ private void closeSession(int promiseId, String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.closeSession(promiseId, sessionId);
+ }
+
+ @WrapForJNI // Called when natvie object is destroyed.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mDestroyed) {
+ return;
+ }
+ mDestroyed = true;
+ release();
+ }
+
+ private void release() {
+ if (DEBUG) Log.d(LOGTAG, "release");
+ mProxyList.remove(this);
+ mImpl.release();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java
new file mode 100644
index 000000000..fcb0fc659
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/MediaManager.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+import org.mozilla.gecko.mozglue.GeckoLoader;
+
+public final class MediaManager extends Service {
+ private static boolean sNativeLibLoaded;
+
+ private Binder mBinder = new IMediaManager.Stub() {
+ @Override
+ public ICodec createCodec() throws RemoteException {
+ return new Codec();
+ }
+
+ @Override
+ public IMediaDrmBridge createRemoteMediaDrmBridge(String keySystem,
+ String stubId)
+ throws RemoteException {
+ return new RemoteMediaDrmBridgeStub(keySystem, stubId);
+ }
+ };
+
+ @Override
+ public synchronized void onCreate() {
+ if (!sNativeLibLoaded) {
+ GeckoLoader.doLoadLibrary(this, "mozglue");
+ sNativeLibLoaded = true;
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java
new file mode 100644
index 000000000..260ca73c1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteManager.java
@@ -0,0 +1,224 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.Telemetry;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.MediaFormat;
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.view.Surface;
+import android.util.Log;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.LinkedList;
+import java.util.List;
+
+public final class RemoteManager implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteManager";
+ private static final boolean DEBUG = false;
+ private static RemoteManager sRemoteManager = null;
+
+ public synchronized static RemoteManager getInstance() {
+ if (sRemoteManager == null) {
+ sRemoteManager = new RemoteManager();
+ }
+
+ sRemoteManager.init();
+ return sRemoteManager;
+ }
+
+ private List<CodecProxy> mProxies = new LinkedList<CodecProxy>();
+ private volatile IMediaManager mRemote;
+ private volatile CountDownLatch mConnectionLatch;
+ private final ServiceConnection mConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ if (DEBUG) Log.d(LOGTAG, "service connected");
+ try {
+ service.linkToDeath(RemoteManager.this, 0);
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ }
+ mRemote = IMediaManager.Stub.asInterface(service);
+ if (mConnectionLatch != null) {
+ mConnectionLatch.countDown();
+ }
+ }
+
+ /**
+ * Called when a connection to the Service has been lost. This typically
+ * happens when the process hosting the service has crashed or been killed.
+ * This does <em>not</em> remove the ServiceConnection itself -- this
+ * binding to the service will remain active, and you will receive a call
+ * to {@link #onServiceConnected} when the Service is next running.
+ *
+ * @param name The concrete component name of the service whose
+ * connection has been lost.
+ */
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ if (DEBUG) Log.d(LOGTAG, "service disconnected");
+ mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0);
+ mRemote = null;
+ if (mConnectionLatch != null) {
+ mConnectionLatch.countDown();
+ }
+ }
+ };
+
+ private synchronized boolean init() {
+ if (mRemote != null) {
+ return true;
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "init remote manager " + this);
+ Context appCtxt = GeckoAppShell.getApplicationContext();
+ if (DEBUG) Log.d(LOGTAG, "ctxt=" + appCtxt);
+ appCtxt.bindService(new Intent(appCtxt, MediaManager.class),
+ mConnection, Context.BIND_AUTO_CREATE);
+ if (!waitConnection()) {
+ appCtxt.unbindService(mConnection);
+ return false;
+ }
+ return true;
+ }
+
+ private boolean waitConnection() {
+ boolean ok = false;
+
+ mConnectionLatch = new CountDownLatch(1);
+ try {
+ int retryCount = 0;
+ while (retryCount < 5) {
+ if (DEBUG) Log.d(LOGTAG, "waiting for connection latch:" + mConnectionLatch);
+ mConnectionLatch.await(1, TimeUnit.SECONDS);
+ if (mConnectionLatch.getCount() == 0) {
+ break;
+ }
+ Log.w(LOGTAG, "Creator not connected in 1s. Try again.");
+ retryCount++;
+ }
+ ok = true;
+ } catch (InterruptedException e) {
+ Log.e(LOGTAG, "service not connected in 5 seconds. Stop waiting.");
+ e.printStackTrace();
+ }
+ mConnectionLatch = null;
+
+ return ok;
+ }
+
+ public synchronized CodecProxy createCodec(MediaFormat format,
+ Surface surface,
+ CodecProxy.Callbacks callbacks) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize");
+ return null;
+ }
+ try {
+ ICodec remote = mRemote.createCodec();
+ CodecProxy proxy = CodecProxy.createCodecProxy(format, surface, callbacks);
+ if (proxy.init(remote)) {
+ mProxies.add(proxy);
+ return proxy;
+ } else {
+ return null;
+ }
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ private static final String MEDIA_DECODING_PROCESS_CRASH = "MEDIA_DECODING_PROCESS_CRASH";
+ private void reportDecodingProcessCrash() {
+ Telemetry.addToHistogram(MEDIA_DECODING_PROCESS_CRASH, 1);
+ }
+
+ public synchronized IMediaDrmBridge createRemoteMediaDrmBridge(String keySystem,
+ String stubId) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize");
+ return null;
+ }
+ try {
+ IMediaDrmBridge remoteBridge =
+ mRemote.createRemoteMediaDrmBridge(keySystem, stubId);
+ return remoteBridge;
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void binderDied() {
+ Log.e(LOGTAG, "remote codec is dead");
+ reportDecodingProcessCrash();
+ handleRemoteDeath();
+ }
+
+ private synchronized void handleRemoteDeath() {
+ // Wait for onServiceDisconnected()
+ if (!waitConnection()) {
+ notifyError(true);
+ return;
+ }
+ // Restart
+ if (init() && recoverRemoteCodec()) {
+ notifyError(false);
+ } else {
+ notifyError(true);
+ }
+ }
+
+ private synchronized void notifyError(boolean fatal) {
+ for (CodecProxy proxy : mProxies) {
+ proxy.reportError(fatal);
+ }
+ }
+
+ private synchronized boolean recoverRemoteCodec() {
+ if (DEBUG) Log.d(LOGTAG, "recover codec");
+ boolean ok = true;
+ try {
+ for (CodecProxy proxy : mProxies) {
+ ok &= proxy.init(mRemote.createCodec());
+ }
+ return ok;
+ } catch (RemoteException e) {
+ return false;
+ }
+ }
+
+ public void releaseCodec(CodecProxy proxy) throws DeadObjectException, RemoteException {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet");
+ return;
+ }
+ proxy.deinit();
+ synchronized (this) {
+ if (mProxies.remove(proxy) && mProxies.isEmpty()) {
+ release();
+ }
+ }
+ }
+
+ private void release() {
+ if (DEBUG) Log.d(LOGTAG, "release remote manager " + this);
+ Context appCtxt = GeckoAppShell.getApplicationContext();
+ mRemote.asBinder().unlinkToDeath(this, 0);
+ mRemote = null;
+ appCtxt.unbindService(mConnection);
+ }
+} // RemoteManager \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
new file mode 100644
index 000000000..d65bb7872
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+import android.util.Log;
+
+final class RemoteMediaDrmBridge implements GeckoMediaDrm {
+ private static final String LOGTAG = "GeckoRemoteMediaDrmBridge";
+ private static final boolean DEBUG = false;
+ private CallbacksForwarder mCallbacksFwd;
+ private IMediaDrmBridge mRemote;
+
+ // Forward callbacks from remote bridge stub to MediaDrmProxy.
+ private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub {
+ private final GeckoMediaDrm.Callbacks mProxyCallbacks;
+ CallbacksForwarder(Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mProxyCallbacks = callbacks;
+ }
+
+ @Override
+ public void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request) {
+ mProxyCallbacks.onSessionCreated(createSessionToken,
+ promiseId,
+ sessionId,
+ request);
+ }
+
+ @Override
+ public void onSessionUpdated(int promiseId, byte[] sessionId) {
+ mProxyCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionClosed(int promiseId, byte[] sessionId) {
+ mProxyCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request) {
+ mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+
+ @Override
+ public void onSessionError(byte[] sessionId, String message) {
+ mProxyCallbacks.onSessionError(sessionId, message);
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos) {
+ mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+
+ @Override
+ public void onRejectPromise(int promiseId, String message) {
+ mProxyCallbacks.onRejectPromise(promiseId, message);
+ }
+ } // CallbacksForwarder
+
+ /* package-private */ static void assertTrue(boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ public RemoteMediaDrmBridge(IMediaDrmBridge remoteBridge) {
+ assertTrue(remoteBridge != null);
+ mRemote = remoteBridge;
+ }
+
+ @Override
+ public synchronized void setCallbacks(Callbacks callbacks) {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(callbacks != null);
+ assertTrue(mRemote != null);
+
+ mCallbacksFwd = new CallbacksForwarder(callbacks);
+ try {
+ mRemote.setCallbacks(mCallbacksFwd);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception during setCallbacks", e);
+ }
+ }
+
+ @Override
+ public synchronized void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+
+ try {
+ mRemote.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception while creating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(int promiseId, String sessionId, byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+
+ try {
+ mRemote.updateSession(promiseId, sessionId, response);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception while updating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(int promiseId, String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+
+ try {
+ mRemote.closeSession(promiseId, sessionId);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception while closing remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session.");
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+
+ try {
+ mRemote.release();
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e);
+ }
+ mRemote = null;
+ mCallbacksFwd = null;
+ }
+
+ @Override
+ public synchronized MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!");
+ assertTrue(false);
+ return null;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
new file mode 100644
index 000000000..8aed0f851
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
@@ -0,0 +1,247 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+import org.mozilla.gecko.AppConstants;
+
+import java.util.ArrayList;
+
+import android.media.MediaCrypto;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteMediaDrmBridgeStub";
+ private static final boolean DEBUG = false;
+ private volatile IMediaDrmBridgeCallbacks mCallbacks = null;
+
+ // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21.
+ private GeckoMediaDrm mBridge = null;
+
+ // mStubId is initialized during stub construction. It should be a unique
+ // string which is generated in MediaDrmProxy in Fennec App process and is
+ // used for Codec to obtain corresponding MediaCrypto as input to achieve
+ // decryption.
+ // The generated stubId will be delivered to Codec via a code path starting
+ // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec.
+ private String mStubId = "";
+
+ public static ArrayList<RemoteMediaDrmBridgeStub> mBridgeStubs =
+ new ArrayList<RemoteMediaDrmBridgeStub>();
+
+ private String getId() {
+ return mStubId;
+ }
+
+ private MediaCrypto getMediaCryptoFromBridge() {
+ return mBridge != null ? mBridge.getMediaCrypto() : null;
+ }
+
+ public static synchronized MediaCrypto getMediaCrypto(String stubId) {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+
+ for (int i = 0; i < mBridgeStubs.size(); i++) {
+ if (mBridgeStubs.get(i) != null &&
+ mBridgeStubs.get(i).getId().equals(stubId)) {
+ return mBridgeStubs.get(i).getMediaCryptoFromBridge();
+ }
+ }
+ return null;
+ }
+
+ // Callback to RemoteMediaDrmBridge.
+ private final class Callbacks implements GeckoMediaDrm.Callbacks {
+ private IMediaDrmBridgeCallbacks mRemoteCallbacks;
+
+ public Callbacks(IMediaDrmBridgeCallbacks remote) {
+ mRemoteCallbacks = remote;
+ }
+
+ @Override
+ public void onSessionCreated(int createSessionToken,
+ int promiseId,
+ byte[] sessionId,
+ byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionCreated()");
+ try {
+ mRemoteCallbacks.onSessionCreated(createSessionToken,
+ promiseId,
+ sessionId,
+ request);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(int promiseId, byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()");
+ try {
+ mRemoteCallbacks.onSessionUpdated(promiseId, sessionId);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(int promiseId, byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionClosed()");
+ try {
+ mRemoteCallbacks.onSessionClosed(promiseId, sessionId);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(byte[] sessionId,
+ int sessionMessageType,
+ byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionMessage()");
+ try {
+ mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionError(byte[] sessionId, String message) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionError()");
+ try {
+ mRemoteCallbacks.onSessionError(sessionId, message);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(byte[] sessionId,
+ SessionKeyInfo[] keyInfos) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()");
+ try {
+ mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(int promiseId, String message) {
+ if (DEBUG) Log.d(LOGTAG, "onRejectPromise()");
+ try {
+ mRemoteCallbacks.onRejectPromise(promiseId, message);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+ }
+
+ /* package-private */ void assertTrue(boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ RemoteMediaDrmBridgeStub(String keySystem, String stubId) throws RemoteException {
+ if (AppConstants.Versions.preLollipop) {
+ Log.e(LOGTAG, "Pre-Lollipop should never enter here!!");
+ throw new RemoteException("Error, unsupported version!");
+ }
+ try {
+ if (AppConstants.Versions.feature21Plus &&
+ AppConstants.Versions.preMarshmallow) {
+ mBridge = new GeckoMediaDrmBridgeV21(keySystem);
+ } else {
+ mBridge = new GeckoMediaDrmBridgeV23(keySystem);
+ }
+ mStubId = stubId;
+ mBridgeStubs.add(this);
+ } catch (Exception e) {
+ throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation.");
+ }
+ }
+
+ @Override
+ public synchronized void setCallbacks(IMediaDrmBridgeCallbacks callbacks) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(mBridge != null);
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ mBridge.setCallbacks(new Callbacks(mCallbacks));
+ }
+
+ @Override
+ public synchronized void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ byte[] initData) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.createSession(createSessionToken,
+ promiseId,
+ initDataType,
+ initData);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to createSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to createSession.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(int promiseId,
+ String sessionId,
+ byte[] response) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.updateSession(promiseId, sessionId, response);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to updateSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to updateSession.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(int promiseId, String sessionId) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.closeSession(promiseId, sessionId);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to closeSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to closeSession.");
+ }
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Binder died !!");
+ try {
+ release();
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ mBridgeStubs.remove(this);
+ if (mBridge != null) {
+ mBridge.release();
+ mBridge = null;
+ }
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ mStubId = "";
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/Sample.java b/mobile/android/base/java/org/mozilla/gecko/media/Sample.java
new file mode 100644
index 000000000..b7a98da8a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/Sample.java
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.SharedMemBuffer;
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+// Parcelable carrying input/output sample data and info cross process.
+public final class Sample implements Parcelable {
+ public static final Sample EOS;
+ static {
+ BufferInfo eosInfo = new BufferInfo();
+ eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ EOS = new Sample(null, eosInfo, null);
+ }
+
+ public interface Buffer extends Parcelable {
+ int capacity();
+ void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException;
+ void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException;
+ void dispose();
+ }
+
+ private static final class ArrayBuffer implements Buffer {
+ private byte[] mArray;
+
+ public static final Creator<ArrayBuffer> CREATOR = new Creator<ArrayBuffer>() {
+ @Override
+ public ArrayBuffer createFromParcel(Parcel in) {
+ return new ArrayBuffer(in);
+ }
+
+ @Override
+ public ArrayBuffer[] newArray(int size) {
+ return new ArrayBuffer[size];
+ }
+ };
+
+ private ArrayBuffer(Parcel in) {
+ mArray = in.createByteArray();
+ }
+
+ private ArrayBuffer(byte[] bytes) { mArray = bytes; }
+
+ @Override
+ public int describeContents() { return 0; }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeByteArray(mArray);
+ }
+
+ @Override
+ public int capacity() {
+ return mArray != null ? mArray.length : 0;
+ }
+
+ @Override
+ public void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException {
+ src.position(offset);
+ if (mArray == null || mArray.length != size) {
+ mArray = new byte[size];
+ }
+ src.get(mArray, 0, size);
+ }
+
+ @Override
+ public void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException {
+ dest.put(mArray, offset, size);
+ }
+
+ @Override
+ public void dispose() {
+ mArray = null;
+ }
+ }
+
+ public Buffer buffer;
+ @WrapForJNI
+ public BufferInfo info;
+ public CryptoInfo cryptoInfo;
+
+ public static Sample create() { return create(null, new BufferInfo(), null); }
+
+ public static Sample create(ByteBuffer src, BufferInfo info, CryptoInfo cryptoInfo) {
+ ArrayBuffer buffer = new ArrayBuffer(byteArrayFromBuffer(src, info.offset, info.size));
+
+ BufferInfo bufferInfo = new BufferInfo();
+ bufferInfo.set(0, info.size, info.presentationTimeUs, info.flags);
+
+ return new Sample(buffer, bufferInfo, cryptoInfo);
+ }
+
+ public static Sample create(SharedMemory sharedMem) {
+ return new Sample(new SharedMemBuffer(sharedMem), new BufferInfo(), null);
+ }
+
+ private Sample(Buffer bytes, BufferInfo info, CryptoInfo cryptoInfo) {
+ buffer = bytes;
+ this.info = info;
+ this.cryptoInfo = cryptoInfo;
+ }
+
+ private Sample(Parcel in) {
+ readInfo(in);
+ readCrypto(in);
+ buffer = in.readParcelable(Sample.class.getClassLoader());
+ }
+
+ private void readInfo(Parcel in) {
+ int offset = in.readInt();
+ int size = in.readInt();
+ long pts = in.readLong();
+ int flags = in.readInt();
+
+ info = new BufferInfo();
+ info.set(offset, size, pts, flags);
+ }
+
+ private void readCrypto(Parcel in) {
+ int hasCryptoInfo = in.readInt();
+ if (hasCryptoInfo == 0) {
+ return;
+ }
+
+ byte[] iv = in.createByteArray();
+ byte[] key = in.createByteArray();
+ int mode = in.readInt();
+ int[] numBytesOfClearData = in.createIntArray();
+ int[] numBytesOfEncryptedData = in.createIntArray();
+ int numSubSamples = in.readInt();
+
+ cryptoInfo = new CryptoInfo();
+ cryptoInfo.set(numSubSamples,
+ numBytesOfClearData,
+ numBytesOfEncryptedData,
+ key,
+ iv,
+ mode);
+ }
+
+ public Sample set(ByteBuffer bytes, BufferInfo info, CryptoInfo cryptoInfo) throws IOException {
+ if (bytes != null && info.size > 0) {
+ buffer.readFromByteBuffer(bytes, info.offset, info.size);
+ }
+ this.info.set(0, info.size, info.presentationTimeUs, info.flags);
+ this.cryptoInfo = cryptoInfo;
+
+ return this;
+ }
+
+ public void dispose() {
+ if (isEOS()) {
+ return;
+ }
+
+ if (buffer != null) {
+ buffer.dispose();
+ buffer = null;
+ }
+ info = null;
+ cryptoInfo = null;
+ }
+
+ public boolean isEOS() {
+ return (this == EOS) ||
+ ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0);
+ }
+
+ public static final Creator<Sample> CREATOR = new Creator<Sample>() {
+ @Override
+ public Sample createFromParcel(Parcel in) {
+ return new Sample(in);
+ }
+
+ @Override
+ public Sample[] newArray(int size) {
+ return new Sample[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int parcelableFlags) {
+ writeInfo(dest);
+ writeCrypto(dest);
+ dest.writeParcelable(buffer, parcelableFlags);
+ }
+
+ private void writeInfo(Parcel dest) {
+ dest.writeInt(info.offset);
+ dest.writeInt(info.size);
+ dest.writeLong(info.presentationTimeUs);
+ dest.writeInt(info.flags);
+ }
+
+ private void writeCrypto(Parcel dest) {
+ if (cryptoInfo != null) {
+ dest.writeInt(1);
+ dest.writeByteArray(cryptoInfo.iv);
+ dest.writeByteArray(cryptoInfo.key);
+ dest.writeInt(cryptoInfo.mode);
+ dest.writeIntArray(cryptoInfo.numBytesOfClearData);
+ dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData);
+ dest.writeInt(cryptoInfo.numSubSamples);
+ } else {
+ dest.writeInt(0);
+ }
+ }
+
+ public static byte[] byteArrayFromBuffer(ByteBuffer buffer, int offset, int size) {
+ if (buffer == null || buffer.capacity() == 0 || size == 0) {
+ return null;
+ }
+ if (buffer.hasArray() && offset == 0 && buffer.array().length == size) {
+ return buffer.array();
+ }
+ int length = Math.min(offset + size, buffer.capacity()) - offset;
+ byte[] bytes = new byte[length];
+ buffer.position(offset);
+ buffer.get(bytes);
+ return bytes;
+ }
+
+ @WrapForJNI
+ public void writeToByteBuffer(ByteBuffer dest) throws IOException {
+ if (buffer != null && dest != null && info.size > 0) {
+ buffer.writeToByteBuffer(dest, info.offset, info.size);
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (isEOS()) {
+ return "EOS sample";
+ }
+
+ StringBuilder str = new StringBuilder();
+ str.append("{ buffer=").append(buffer).
+ append(", info=").
+ append("{ offset=").append(info.offset).
+ append(", size=").append(info.size).
+ append(", pts=").append(info.presentationTimeUs).
+ append(", flags=").append(Integer.toHexString(info.flags)).append(" }").
+ append(" }");
+ return str.toString();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java
new file mode 100644
index 000000000..9041e3756
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/SamplePool.java
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+final class SamplePool {
+ private final class Impl {
+ private final String mName;
+ private int mNextId = 0;
+ private int mDefaultBufferSize = 4096;
+ private final List<Sample> mRecycledSamples = new ArrayList<>();
+
+ private Impl(String name) {
+ mName = name;
+ }
+
+ private void setDefaultBufferSize(int size) {
+ mDefaultBufferSize = size;
+ }
+
+ private synchronized Sample allocate(int size) {
+ Sample sample;
+ if (!mRecycledSamples.isEmpty()) {
+ sample = mRecycledSamples.remove(0);
+ sample.info.set(0, 0, 0, 0);
+ } else {
+ SharedMemory shm = null;
+ try {
+ shm = new SharedMemory(mNextId++, Math.max(size, mDefaultBufferSize));
+ } catch (NoSuchMethodException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ if (shm != null) {
+ sample = Sample.create(shm);
+ } else {
+ sample = Sample.create();
+ }
+ }
+
+ return sample;
+ }
+
+ private synchronized void recycle(Sample recycled) {
+ if (recycled.buffer.capacity() >= mDefaultBufferSize) {
+ mRecycledSamples.add(recycled);
+ } else {
+ recycled.dispose();
+ }
+ }
+
+ private synchronized void clear() {
+ for (Sample s : mRecycledSamples) {
+ s.dispose();
+ }
+
+ mRecycledSamples.clear();
+ }
+
+ @Override
+ protected void finalize() {
+ clear();
+ }
+ }
+
+ private final Impl mInputs;
+ private final Impl mOutputs;
+
+ /* package */ SamplePool(String name) {
+ mInputs = new Impl(name + " input buffer pool");
+ mOutputs = new Impl(name + " output buffer pool");
+ }
+
+ /* package */ void setInputBufferSize(int size) {
+ mInputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ void setOutputBufferSize(int size) {
+ mOutputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ Sample obtainInput(int size) {
+ return mInputs.allocate(size);
+ }
+
+ /* package */ Sample obtainOutput(MediaCodec.BufferInfo info) {
+ Sample output = mOutputs.allocate(info.size);
+ output.info.set(0, info.size, info.presentationTimeUs, info.flags);
+ return output;
+ }
+
+ /* package */ void recycleInput(Sample sample) {
+ sample.cryptoInfo = null;
+ mInputs.recycle(sample);
+ }
+
+ /* package */ void recycleOutput(Sample sample) {
+ mOutputs.recycle(sample);
+ }
+
+ /* package */ void reset() {
+ mInputs.clear();
+ mOutputs.clear();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java
new file mode 100644
index 000000000..b41ef3625
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/SessionKeyInfo.java
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class SessionKeyInfo implements Parcelable {
+ @WrapForJNI
+ public byte[] keyId;
+
+ @WrapForJNI
+ public int status;
+
+ @WrapForJNI
+ public SessionKeyInfo(byte[] keyId, int status) {
+ this.keyId = keyId;
+ this.status = status;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int parcelableFlags) {
+ dest.writeByteArray(keyId);
+ dest.writeInt(status);
+ }
+
+ public static final Creator<SessionKeyInfo> CREATOR = new Creator<SessionKeyInfo>() {
+ @Override
+ public SessionKeyInfo createFromParcel(Parcel in) {
+ return new SessionKeyInfo(in);
+ }
+
+ @Override
+ public SessionKeyInfo[] newArray(int size) {
+ return new SessionKeyInfo[size];
+ }
+ };
+
+ private SessionKeyInfo(Parcel src) {
+ keyId = src.createByteArray();
+ status = src.readInt();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java b/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java
new file mode 100644
index 000000000..508b9d015
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/VideoPlayer.java
@@ -0,0 +1,204 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.content.Context;
+
+import android.graphics.Color;
+
+import android.net.Uri;
+
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+
+import android.widget.ImageButton;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.MediaController;
+import android.widget.VideoView;
+
+import org.mozilla.gecko.R;
+
+public class VideoPlayer extends FrameLayout {
+ private VideoView video;
+ private FullScreenMediaController controller;
+ private FullScreenListener fullScreenListener;
+
+ private boolean isFullScreen;
+
+ public VideoPlayer(Context ctx) {
+ this(ctx, null);
+ }
+
+ public VideoPlayer(Context ctx, AttributeSet attrs) {
+ this(ctx, attrs, 0);
+ }
+
+ public VideoPlayer(Context ctx, AttributeSet attrs, int defStyle) {
+ super(ctx, attrs, defStyle);
+ setFullScreen(false);
+ setVisibility(View.GONE);
+ }
+
+ public void start(Uri uri) {
+ stop();
+
+ video = new VideoView(getContext());
+ controller = new FullScreenMediaController(getContext());
+ video.setMediaController(controller);
+ controller.setAnchorView(video);
+
+ video.setVideoURI(uri);
+
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER);
+
+ addView(video, layoutParams);
+ setVisibility(View.VISIBLE);
+
+ video.setZOrderOnTop(true);
+ video.start();
+ }
+
+ public boolean isPlaying() {
+ return video != null;
+ }
+
+ public void stop() {
+ if (video == null) {
+ return;
+ }
+
+ removeAllViews();
+ setVisibility(View.GONE);
+ video.stopPlayback();
+
+ video = null;
+ controller = null;
+ }
+
+ public void setFullScreenListener(FullScreenListener listener) {
+ fullScreenListener = listener;
+ }
+
+ public boolean isFullScreen() {
+ return isFullScreen;
+ }
+
+ public void setFullScreen(boolean fullScreen) {
+ isFullScreen = fullScreen;
+ if (fullScreen) {
+ setBackgroundColor(Color.BLACK);
+ } else {
+ setBackgroundResource(R.color.dark_transparent_overlay);
+ }
+
+ if (controller != null) {
+ controller.setFullScreen(fullScreen);
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (event.isSystem()) {
+ return super.onKeyDown(keyCode, event);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (event.isSystem()) {
+ return super.onKeyUp(keyCode, event);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ super.onTouchEvent(event);
+ return true;
+ }
+
+ @Override
+ public boolean onTrackballEvent(MotionEvent event) {
+ super.onTrackballEvent(event);
+ return true;
+ }
+
+ public interface FullScreenListener {
+ void onFullScreenChanged(boolean fullScreen);
+ }
+
+ private class FullScreenMediaController extends MediaController {
+ private ImageButton mButton;
+
+ public FullScreenMediaController(Context ctx) {
+ super(ctx);
+
+ mButton = new ImageButton(getContext());
+ mButton.setScaleType(ImageView.ScaleType.FIT_CENTER);
+ mButton.setBackgroundColor(Color.TRANSPARENT);
+ mButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FullScreenMediaController.this.onFullScreenClicked();
+ }
+ });
+
+ updateFullScreenButton(false);
+ }
+
+ public void setFullScreen(boolean fullScreen) {
+ updateFullScreenButton(fullScreen);
+ }
+
+ private void updateFullScreenButton(boolean fullScreen) {
+ mButton.setImageResource(fullScreen ? R.drawable.exit_fullscreen : R.drawable.fullscreen);
+ }
+
+ private void onFullScreenClicked() {
+ if (VideoPlayer.this.fullScreenListener != null) {
+ boolean fullScreen = !VideoPlayer.this.isFullScreen();
+ VideoPlayer.this.fullScreenListener.onFullScreenChanged(fullScreen);
+ }
+ }
+
+ @Override
+ public void setAnchorView(final View view) {
+ super.setAnchorView(view);
+
+ // Add the fullscreen button here because this is where the parent class actually creates
+ // the media buttons and their layout.
+ //
+ // http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/widget/MediaController.java#239
+ //
+ // The media buttons are in a horizontal linear layout which is itself packed into
+ // a vertical layout. The vertical layout is the only child of the FrameLayout which
+ // MediaController inherits from.
+ LinearLayout child = (LinearLayout) getChildAt(0);
+ LinearLayout buttons = (LinearLayout) child.getChildAt(0);
+
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
+ LayoutParams.FILL_PARENT);
+ params.gravity = Gravity.CENTER_VERTICAL;
+
+ if (mButton.getParent() != null) {
+ ((ViewGroup)mButton.getParent()).removeView(mButton);
+ }
+
+ buttons.addView(mButton, params);
+ }
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java
new file mode 100644
index 000000000..512f32002
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenu.java
@@ -0,0 +1,928 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.BaseAdapter;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class GeckoMenu extends ListView
+ implements Menu,
+ AdapterView.OnItemClickListener,
+ GeckoMenuItem.OnShowAsActionChangedListener {
+ private static final String LOGTAG = "GeckoMenu";
+
+ /**
+ * Controls whether off-UI-thread method calls in this class cause an
+ * exception or just logging.
+ */
+ private static final AssertBehavior THREAD_ASSERT_BEHAVIOR = AppConstants.RELEASE_OR_BETA ? AssertBehavior.NONE : AssertBehavior.THROW;
+
+ /*
+ * A callback for a menu item click/long click event.
+ */
+ public static interface Callback {
+ // Called when a menu item is clicked, with the actual menu item as the argument.
+ public boolean onMenuItemClick(MenuItem item);
+
+ // Called when a menu item is long-clicked, with the actual menu item as the argument.
+ public boolean onMenuItemLongClick(MenuItem item);
+ }
+
+ /*
+ * An interface for a presenter to show the menu.
+ * Either an Activity or a View can be a presenter, that can watch for events
+ * and show/hide menu.
+ */
+ public static interface MenuPresenter {
+ // Open the menu.
+ public void openMenu();
+
+ // Show the actual view containing the menu items. This can either be a parent or sub-menu.
+ public void showMenu(View menu);
+
+ // Close the menu.
+ public void closeMenu();
+ }
+
+ /*
+ * An interface for a presenter of action-items.
+ * Either an Activity or a View can be a presenter, that can watch for events
+ * and add/remove action-items. If not ActionItemBarPresenter, the menu uses a
+ * DefaultActionItemBar, that shows the action-items as a header over list-view.
+ */
+ public static interface ActionItemBarPresenter {
+ // Add an action-item.
+ public boolean addActionItem(View actionItem);
+
+ // Remove an action-item.
+ public void removeActionItem(View actionItem);
+ }
+
+ protected static final int NO_ID = 0;
+
+ // List of all menu items.
+ private final List<GeckoMenuItem> mItems;
+
+ // Quick lookup array used to make a fast path in findItem.
+ private final SparseArray<MenuItem> mItemsById;
+
+ // Map of "always" action-items in action-bar and their views.
+ private final Map<GeckoMenuItem, View> mPrimaryActionItems;
+
+ // Map of "ifRoom" action-items in action-bar and their views.
+ private final Map<GeckoMenuItem, View> mSecondaryActionItems;
+
+ // Map of "collapseActionView" action-items in action-bar and their views.
+ private final Map<GeckoMenuItem, View> mQuickShareActionItems;
+
+ // Reference to a callback for menu events.
+ private Callback mCallback;
+
+ // Reference to menu presenter.
+ private MenuPresenter mMenuPresenter;
+
+ // Reference to "always" action-items bar in action-bar.
+ private ActionItemBarPresenter mPrimaryActionItemBar;
+
+ // Reference to "ifRoom" action-items bar in action-bar.
+ private final ActionItemBarPresenter mSecondaryActionItemBar;
+
+ // Reference to "collapseActionView" action-items bar in action-bar.
+ private final ActionItemBarPresenter mQuickShareActionItemBar;
+
+ // Adapter to hold the list of menu items.
+ private final MenuItemsAdapter mAdapter;
+
+ // Show/hide icons in the list.
+ boolean mShowIcons;
+
+ public GeckoMenu(Context context) {
+ this(context, null);
+ }
+
+ public GeckoMenu(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.geckoMenuListViewStyle);
+ }
+
+ public GeckoMenu(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT));
+
+ // Attach an adapter.
+ mAdapter = new MenuItemsAdapter();
+ setAdapter(mAdapter);
+ setOnItemClickListener(this);
+
+ mItems = new ArrayList<GeckoMenuItem>();
+ mItemsById = new SparseArray<MenuItem>();
+ mPrimaryActionItems = new HashMap<GeckoMenuItem, View>();
+ mSecondaryActionItems = new HashMap<GeckoMenuItem, View>();
+ mQuickShareActionItems = new HashMap<GeckoMenuItem, View>();
+
+ mPrimaryActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_action_bar, null);
+ mSecondaryActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_secondary_action_bar, null);
+ mQuickShareActionItemBar = (DefaultActionItemBar) LayoutInflater.from(context).inflate(R.layout.menu_secondary_action_bar, null);
+ }
+
+ private static void assertOnUiThread() {
+ ThreadUtils.assertOnUiThread(THREAD_ASSERT_BEHAVIOR);
+ }
+
+ @Override
+ public MenuItem add(CharSequence title) {
+ GeckoMenuItem menuItem = new GeckoMenuItem(this, NO_ID, 0, title);
+ addItem(menuItem);
+ return menuItem;
+ }
+
+ @Override
+ public MenuItem add(int groupId, int itemId, int order, int titleRes) {
+ GeckoMenuItem menuItem = new GeckoMenuItem(this, itemId, order, titleRes);
+ addItem(menuItem);
+ return menuItem;
+ }
+
+ @Override
+ public MenuItem add(int titleRes) {
+ GeckoMenuItem menuItem = new GeckoMenuItem(this, NO_ID, 0, titleRes);
+ addItem(menuItem);
+ return menuItem;
+ }
+
+ @Override
+ public MenuItem add(int groupId, int itemId, int order, CharSequence title) {
+ GeckoMenuItem menuItem = new GeckoMenuItem(this, itemId, order, title);
+ addItem(menuItem);
+ return menuItem;
+ }
+
+ private void addItem(GeckoMenuItem menuItem) {
+ assertOnUiThread();
+ menuItem.setOnShowAsActionChangedListener(this);
+ mAdapter.addMenuItem(menuItem);
+ mItems.add(menuItem);
+ }
+
+ private boolean addActionItem(final GeckoMenuItem menuItem) {
+ assertOnUiThread();
+ menuItem.setOnShowAsActionChangedListener(this);
+
+ final View actionView = menuItem.getActionView();
+ final int actionEnum = menuItem.getActionEnum();
+ boolean added = false;
+
+ if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_ALWAYS) {
+ if (mPrimaryActionItems.size() == 0 &&
+ mPrimaryActionItemBar instanceof DefaultActionItemBar) {
+ // Reset the adapter before adding the header view to a list.
+ setAdapter(null);
+ addHeaderView((DefaultActionItemBar) mPrimaryActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ if (added = mPrimaryActionItemBar.addActionItem(actionView)) {
+ mPrimaryActionItems.put(menuItem, actionView);
+ mItems.add(menuItem);
+ }
+ } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_IF_ROOM) {
+ if (mSecondaryActionItems.size() == 0) {
+ // Reset the adapter before adding the header view to a list.
+ setAdapter(null);
+ addHeaderView((DefaultActionItemBar) mSecondaryActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ if (added = mSecondaryActionItemBar.addActionItem(actionView)) {
+ mSecondaryActionItems.put(menuItem, actionView);
+ mItems.add(menuItem);
+ }
+ } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW) {
+ if (actionView instanceof MenuItemSwitcherLayout) {
+ final MenuItemSwitcherLayout quickShareView = (MenuItemSwitcherLayout) actionView;
+
+ // We don't want to add the quick share bar if we don't have any quick share items.
+ if (quickShareView.getActionButtonCount() > 0 &&
+ (added = mQuickShareActionItemBar.addActionItem(quickShareView))) {
+ if (mQuickShareActionItems.size() == 0) {
+ // Reset the adapter before adding the header view to a list.
+ setAdapter(null);
+ addHeaderView((DefaultActionItemBar) mQuickShareActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ mQuickShareActionItems.put(menuItem, quickShareView);
+ mItems.add(menuItem);
+ }
+ }
+ }
+
+ // Set the listeners.
+ if (actionView instanceof MenuItemActionBar) {
+ ((MenuItemActionBar) actionView).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ handleMenuItemClick(menuItem);
+ }
+ });
+ ((MenuItemActionBar) actionView).setOnLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ if (handleMenuItemLongClick(menuItem)) {
+ GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec));
+ return true;
+ }
+ return false;
+ }
+ });
+ } else if (actionView instanceof MenuItemSwitcherLayout) {
+ ((MenuItemSwitcherLayout) actionView).setMenuItemClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ handleMenuItemClick(menuItem);
+ }
+ });
+ ((MenuItemSwitcherLayout) actionView).setMenuItemLongClickListener(new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ if (handleMenuItemLongClick(menuItem)) {
+ GeckoAppShell.vibrateOnHapticFeedbackEnabled(getResources().getIntArray(R.array.long_press_vibrate_msec));
+ return true;
+ }
+ return false;
+ }
+ });
+ }
+
+ return added;
+ }
+
+ @Override
+ public int addIntentOptions(int groupId, int itemId, int order, ComponentName caller, Intent[] specifics, Intent intent, int flags, MenuItem[] outSpecificItems) {
+ return 0;
+ }
+
+ @Override
+ public SubMenu addSubMenu(int groupId, int itemId, int order, CharSequence title) {
+ MenuItem menuItem = add(groupId, itemId, order, title);
+ return addSubMenu(menuItem);
+ }
+
+ @Override
+ public SubMenu addSubMenu(int groupId, int itemId, int order, int titleRes) {
+ MenuItem menuItem = add(groupId, itemId, order, titleRes);
+ return addSubMenu(menuItem);
+ }
+
+ @Override
+ public SubMenu addSubMenu(CharSequence title) {
+ MenuItem menuItem = add(title);
+ return addSubMenu(menuItem);
+ }
+
+ @Override
+ public SubMenu addSubMenu(int titleRes) {
+ MenuItem menuItem = add(titleRes);
+ return addSubMenu(menuItem);
+ }
+
+ private SubMenu addSubMenu(MenuItem menuItem) {
+ GeckoSubMenu subMenu = new GeckoSubMenu(getContext());
+ subMenu.setMenuItem(menuItem);
+ subMenu.setCallback(mCallback);
+ subMenu.setMenuPresenter(mMenuPresenter);
+ ((GeckoMenuItem) menuItem).setSubMenu(subMenu);
+ return subMenu;
+ }
+
+ private void removePrimaryActionBarView() {
+ // Reset the adapter before removing the header view from a list.
+ setAdapter(null);
+ removeHeaderView((DefaultActionItemBar) mPrimaryActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ private void removeSecondaryActionBarView() {
+ // Reset the adapter before removing the header view from a list.
+ setAdapter(null);
+ removeHeaderView((DefaultActionItemBar) mSecondaryActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ private void removeQuickShareActionBarView() {
+ // Reset the adapter before removing the header view from a list.
+ setAdapter(null);
+ removeHeaderView((DefaultActionItemBar) mQuickShareActionItemBar);
+ setAdapter(mAdapter);
+ }
+
+ @Override
+ public void clear() {
+ assertOnUiThread();
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.hasSubMenu()) {
+ SubMenu sub = menuItem.getSubMenu();
+ if (sub == null) {
+ continue;
+ }
+ try {
+ sub.clear();
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Couldn't clear submenu.", ex);
+ }
+ }
+ }
+
+ mAdapter.clear();
+ mItems.clear();
+
+ /*
+ * Reinflating the menu will re-add any action items to the toolbar, so
+ * remove the old ones. This also ensures that any text associated with
+ * these is switched to the correct locale.
+ */
+ if (mPrimaryActionItemBar != null) {
+ for (View item : mPrimaryActionItems.values()) {
+ mPrimaryActionItemBar.removeActionItem(item);
+ }
+ }
+ mPrimaryActionItems.clear();
+
+ if (mSecondaryActionItemBar != null) {
+ for (View item : mSecondaryActionItems.values()) {
+ mSecondaryActionItemBar.removeActionItem(item);
+ }
+ }
+ mSecondaryActionItems.clear();
+
+ if (mQuickShareActionItemBar != null) {
+ for (View item : mQuickShareActionItems.values()) {
+ mQuickShareActionItemBar.removeActionItem(item);
+ }
+ }
+ mQuickShareActionItems.clear();
+
+ // Remove the view, too -- the first addActionItem will re-add it,
+ // and this is simpler than changing that logic.
+ if (mPrimaryActionItemBar instanceof DefaultActionItemBar) {
+ removePrimaryActionBarView();
+ }
+
+ removeSecondaryActionBarView();
+ removeQuickShareActionBarView();
+ }
+
+ @Override
+ public void close() {
+ if (mMenuPresenter != null)
+ mMenuPresenter.closeMenu();
+ }
+
+ private void showMenu(View viewForMenu) {
+ if (mMenuPresenter != null)
+ mMenuPresenter.showMenu(viewForMenu);
+ }
+
+ @Override
+ public MenuItem findItem(int id) {
+ assertOnUiThread();
+ MenuItem quickItem = mItemsById.get(id);
+ if (quickItem != null) {
+ return quickItem;
+ }
+
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.getItemId() == id) {
+ mItemsById.put(id, menuItem);
+ return menuItem;
+ } else if (menuItem.hasSubMenu()) {
+ if (!menuItem.hasActionProvider()) {
+ SubMenu subMenu = menuItem.getSubMenu();
+ MenuItem item = subMenu.findItem(id);
+ if (item != null) {
+ mItemsById.put(id, item);
+ return item;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public MenuItem getItem(int index) {
+ if (index < mItems.size())
+ return mItems.get(index);
+
+ return null;
+ }
+
+ @Override
+ public boolean hasVisibleItems() {
+ assertOnUiThread();
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.isVisible() &&
+ !mPrimaryActionItems.containsKey(menuItem) &&
+ !mSecondaryActionItems.containsKey(menuItem) &&
+ !mQuickShareActionItems.containsKey(menuItem))
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ // Close the menu if it is open and the hardware menu key is pressed.
+ if (keyCode == KeyEvent.KEYCODE_MENU && isShown()) {
+ close();
+ return true;
+ }
+
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean isShortcutKey(int keyCode, KeyEvent event) {
+ return true;
+ }
+
+ @Override
+ public boolean performIdentifierAction(int id, int flags) {
+ return false;
+ }
+
+ @Override
+ public boolean performShortcut(int keyCode, KeyEvent event, int flags) {
+ return false;
+ }
+
+ @Override
+ public void removeGroup(int groupId) {
+ }
+
+ @Override
+ public void removeItem(int id) {
+ assertOnUiThread();
+ GeckoMenuItem item = (GeckoMenuItem) findItem(id);
+ if (item == null)
+ return;
+
+ // Remove it from the cache.
+ mItemsById.remove(id);
+
+ // Remove it from any sub-menu.
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.hasSubMenu()) {
+ SubMenu subMenu = menuItem.getSubMenu();
+ if (subMenu != null && subMenu.findItem(id) != null) {
+ subMenu.removeItem(id);
+ return;
+ }
+ }
+ }
+
+ // Remove it from own menu.
+ if (mPrimaryActionItems.containsKey(item)) {
+ if (mPrimaryActionItemBar != null)
+ mPrimaryActionItemBar.removeActionItem(mPrimaryActionItems.get(item));
+
+ mPrimaryActionItems.remove(item);
+ mItems.remove(item);
+
+ if (mPrimaryActionItems.size() == 0 &&
+ mPrimaryActionItemBar instanceof DefaultActionItemBar) {
+ removePrimaryActionBarView();
+ }
+
+ return;
+ }
+
+ if (mSecondaryActionItems.containsKey(item)) {
+ if (mSecondaryActionItemBar != null)
+ mSecondaryActionItemBar.removeActionItem(mSecondaryActionItems.get(item));
+
+ mSecondaryActionItems.remove(item);
+ mItems.remove(item);
+
+ if (mSecondaryActionItems.size() == 0) {
+ removeSecondaryActionBarView();
+ }
+
+ return;
+ }
+
+ if (mQuickShareActionItems.containsKey(item)) {
+ if (mQuickShareActionItemBar != null)
+ mQuickShareActionItemBar.removeActionItem(mQuickShareActionItems.get(item));
+
+ mQuickShareActionItems.remove(item);
+ mItems.remove(item);
+
+ if (mQuickShareActionItems.size() == 0) {
+ removeQuickShareActionBarView();
+ }
+
+ return;
+ }
+
+ mAdapter.removeMenuItem(item);
+ mItems.remove(item);
+ }
+
+ @Override
+ public void setGroupCheckable(int group, boolean checkable, boolean exclusive) {
+ }
+
+ @Override
+ public void setGroupEnabled(int group, boolean enabled) {
+ }
+
+ @Override
+ public void setGroupVisible(int group, boolean visible) {
+ }
+
+ @Override
+ public void setQwertyMode(boolean isQwerty) {
+ }
+
+ @Override
+ public int size() {
+ return mItems.size();
+ }
+
+ @Override
+ public boolean hasActionItemBar() {
+ return (mPrimaryActionItemBar != null) &&
+ (mSecondaryActionItemBar != null) &&
+ (mQuickShareActionItemBar != null);
+ }
+
+ @Override
+ public void onShowAsActionChanged(GeckoMenuItem item) {
+ removeItem(item.getItemId());
+
+ if (item.isActionItem() && addActionItem(item)) {
+ return;
+ }
+
+ addItem(item);
+ }
+
+ public void onItemChanged(GeckoMenuItem item) {
+ assertOnUiThread();
+ if (item.isActionItem()) {
+ final View actionView;
+ final int actionEnum = item.getActionEnum();
+ if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_ALWAYS) {
+ actionView = mPrimaryActionItems.get(item);
+ } else if (actionEnum == GeckoMenuItem.SHOW_AS_ACTION_IF_ROOM) {
+ actionView = mSecondaryActionItems.get(item);
+ } else {
+ actionView = mQuickShareActionItems.get(item);
+ }
+
+ if (actionView != null) {
+ if (item.isVisible()) {
+ actionView.setVisibility(View.VISIBLE);
+ if (actionView instanceof MenuItemActionBar) {
+ ((MenuItemActionBar) actionView).initialize(item);
+ } else {
+ ((MenuItemSwitcherLayout) actionView).initialize(item);
+ }
+ } else {
+ actionView.setVisibility(View.GONE);
+ }
+ }
+ } else {
+ mAdapter.notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ // We might be showing headers. Account them while using the position.
+ position -= getHeaderViewsCount();
+
+ GeckoMenuItem item = mAdapter.getItem(position);
+ handleMenuItemClick(item);
+ }
+
+ void handleMenuItemClick(GeckoMenuItem item) {
+ if (!item.isEnabled())
+ return;
+
+ if (item.invoke()) {
+ close();
+ } else if (item.hasSubMenu()) {
+ // Refresh the submenu for the provider.
+ GeckoActionProvider provider = item.getGeckoActionProvider();
+ if (provider != null) {
+ GeckoSubMenu subMenu = new GeckoSubMenu(getContext());
+ subMenu.setShowIcons(true);
+ provider.onPrepareSubMenu(subMenu);
+ item.setSubMenu(subMenu);
+ }
+
+ // Show the submenu.
+ GeckoSubMenu subMenu = (GeckoSubMenu) item.getSubMenu();
+ showMenu(subMenu);
+ } else {
+ close();
+ mCallback.onMenuItemClick(item);
+ }
+ }
+
+ boolean handleMenuItemLongClick(GeckoMenuItem item) {
+ if (!item.isEnabled()) {
+ return false;
+ }
+
+ if (mCallback != null) {
+ if (mCallback.onMenuItemLongClick(item)) {
+ close();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public Callback getCallback() {
+ return mCallback;
+ }
+
+ public MenuPresenter getMenuPresenter() {
+ return mMenuPresenter;
+ }
+
+ public void setCallback(Callback callback) {
+ mCallback = callback;
+
+ // Update the submenus just in case this changes on the fly.
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.hasSubMenu()) {
+ GeckoSubMenu subMenu = (GeckoSubMenu) menuItem.getSubMenu();
+ subMenu.setCallback(mCallback);
+ }
+ }
+ }
+
+ public void setMenuPresenter(MenuPresenter presenter) {
+ mMenuPresenter = presenter;
+
+ // Update the submenus just in case this changes on the fly.
+ for (GeckoMenuItem menuItem : mItems) {
+ if (menuItem.hasSubMenu()) {
+ GeckoSubMenu subMenu = (GeckoSubMenu) menuItem.getSubMenu();
+ subMenu.setMenuPresenter(mMenuPresenter);
+ }
+ }
+ }
+
+ public void setActionItemBarPresenter(ActionItemBarPresenter presenter) {
+ mPrimaryActionItemBar = presenter;
+ }
+
+ public void setShowIcons(boolean show) {
+ if (mShowIcons != show) {
+ mShowIcons = show;
+ mAdapter.notifyDataSetChanged();
+ }
+ }
+
+ // Action Items are added to the header view by default.
+ // URL bar can register itself as a presenter, in case it has a different place to show them.
+ public static class DefaultActionItemBar extends LinearLayout
+ implements ActionItemBarPresenter {
+ private final int mRowHeight;
+ private float mWeightSum;
+
+ public DefaultActionItemBar(Context context) {
+ this(context, null);
+ }
+
+ public DefaultActionItemBar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ mRowHeight = getResources().getDimensionPixelSize(R.dimen.menu_item_row_height);
+ }
+
+ @Override
+ public boolean addActionItem(View actionItem) {
+ ViewGroup.LayoutParams actualParams = actionItem.getLayoutParams();
+ LinearLayout.LayoutParams params;
+
+ if (actualParams != null) {
+ params = new LinearLayout.LayoutParams(actionItem.getLayoutParams());
+ params.width = 0;
+ } else {
+ params = new LinearLayout.LayoutParams(0, mRowHeight);
+ }
+
+ if (actionItem instanceof MenuItemSwitcherLayout) {
+ params.weight = ((MenuItemSwitcherLayout) actionItem).getChildCount();
+ } else {
+ params.weight = 1.0f;
+ }
+
+ mWeightSum += params.weight;
+
+ actionItem.setLayoutParams(params);
+ addView(actionItem);
+ setWeightSum(mWeightSum);
+ return true;
+ }
+
+ @Override
+ public void removeActionItem(View actionItem) {
+ if (indexOfChild(actionItem) != -1) {
+ LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) actionItem.getLayoutParams();
+ mWeightSum -= params.weight;
+ removeView(actionItem);
+ }
+ }
+ }
+
+ // Adapter to bind menu items to the list.
+ private class MenuItemsAdapter extends BaseAdapter {
+ private static final int VIEW_TYPE_DEFAULT = 0;
+ private static final int VIEW_TYPE_ACTION_MODE = 1;
+
+ private final List<GeckoMenuItem> mItems;
+
+ public MenuItemsAdapter() {
+ mItems = new ArrayList<GeckoMenuItem>();
+ }
+
+ @Override
+ public int getCount() {
+ if (mItems == null)
+ return 0;
+
+ int visibleCount = 0;
+ for (GeckoMenuItem item : mItems) {
+ if (item.isVisible())
+ visibleCount++;
+ }
+
+ return visibleCount;
+ }
+
+ @Override
+ public GeckoMenuItem getItem(int position) {
+ for (GeckoMenuItem item : mItems) {
+ if (item.isVisible()) {
+ position--;
+
+ if (position < 0)
+ return item;
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ GeckoMenuItem item = getItem(position);
+ GeckoMenuItem.Layout view = null;
+
+ // Try to re-use the view.
+ if (convertView == null && getItemViewType(position) == VIEW_TYPE_DEFAULT) {
+ view = new MenuItemDefault(parent.getContext(), null);
+ } else {
+ view = (GeckoMenuItem.Layout) convertView;
+ }
+
+ if (view == null || view instanceof MenuItemSwitcherLayout) {
+ // Always get from the menu item.
+ // This will ensure that the default activity is refreshed.
+ view = (MenuItemSwitcherLayout) item.getActionView();
+
+ // ListView will not perform an item click if the row has a focusable view in it.
+ // Hence, forward the click event on the menu item in the action-view to the ListView.
+ final View actionView = (View) view;
+ final int pos = position;
+ final long id = getItemId(position);
+ ((MenuItemSwitcherLayout) view).setMenuItemClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GeckoMenu listView = GeckoMenu.this;
+ listView.performItemClick(actionView, pos + listView.getHeaderViewsCount(), id);
+ }
+ });
+ }
+
+ // Initialize the view.
+ view.setShowIcon(mShowIcons);
+ view.initialize(item);
+ return (View) view;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return getItem(position).getGeckoActionProvider() == null ? VIEW_TYPE_DEFAULT : VIEW_TYPE_ACTION_MODE;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 2;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ // Setting this to true is a workaround to fix disappearing
+ // dividers in the menu (bug 963249).
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ // Setting this to true is a workaround to fix disappearing
+ // dividers in the menu in L (bug 1050780).
+ return true;
+ }
+
+ public void addMenuItem(GeckoMenuItem menuItem) {
+ if (mItems.contains(menuItem))
+ return;
+
+ // Insert it in proper order.
+ int index = 0;
+ for (GeckoMenuItem item : mItems) {
+ if (item.getOrder() > menuItem.getOrder()) {
+ mItems.add(index, menuItem);
+ notifyDataSetChanged();
+ return;
+ } else {
+ index++;
+ }
+ }
+
+ // Add the menuItem at the end.
+ mItems.add(menuItem);
+ notifyDataSetChanged();
+ }
+
+ public void removeMenuItem(GeckoMenuItem menuItem) {
+ // Remove it from the list.
+ mItems.remove(menuItem);
+ notifyDataSetChanged();
+ }
+
+ public void clear() {
+ mItemsById.clear();
+ mItems.clear();
+ notifyDataSetChanged();
+ }
+
+ public GeckoMenuItem getMenuItem(int id) {
+ for (GeckoMenuItem item : mItems) {
+ if (item.getItemId() == id)
+ return item;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java
new file mode 100644
index 000000000..dfcb31c5f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuInflater.java
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import java.io.IOException;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.util.AttributeSet;
+import android.util.Xml;
+import android.view.InflateException;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.SubMenu;
+
+public class GeckoMenuInflater extends MenuInflater {
+ private static final String TAG_MENU = "menu";
+ private static final String TAG_ITEM = "item";
+ private static final int NO_ID = 0;
+
+ private final Context mContext;
+
+ // Private class to hold the parsed menu item.
+ private class ParsedItem {
+ public int id;
+ public int order;
+ public CharSequence title;
+ public int iconRes;
+ public boolean checkable;
+ public boolean checked;
+ public boolean visible;
+ public boolean enabled;
+ public int showAsAction;
+ public boolean hasSubMenu;
+ }
+
+ public GeckoMenuInflater(Context context) {
+ super(context);
+ mContext = context;
+ }
+
+ @Override
+ public void inflate(int menuRes, Menu menu) {
+
+ // This does not check for a well-formed XML.
+
+ XmlResourceParser parser = null;
+ try {
+ parser = mContext.getResources().getXml(menuRes);
+ AttributeSet attrs = Xml.asAttributeSet(parser);
+
+ parseMenu(parser, attrs, menu);
+
+ } catch (XmlPullParserException | IOException e) {
+ throw new InflateException("Error inflating menu XML", e);
+ } finally {
+ if (parser != null)
+ parser.close();
+ }
+ }
+
+ private void parseMenu(XmlResourceParser parser, AttributeSet attrs, Menu menu)
+ throws XmlPullParserException, IOException {
+ ParsedItem item = null;
+
+ String tag;
+ int eventType = parser.getEventType();
+
+ do {
+ tag = parser.getName();
+
+ switch (eventType) {
+ case XmlPullParser.START_TAG:
+ if (tag.equals(TAG_ITEM)) {
+ // Parse the menu item.
+ item = new ParsedItem();
+ parseItem(item, attrs);
+ } else if (tag.equals(TAG_MENU)) {
+ if (item != null) {
+ // Add the submenu item.
+ SubMenu subMenu = menu.addSubMenu(NO_ID, item.id, item.order, item.title);
+ item.hasSubMenu = true;
+
+ // Set the menu item in main menu.
+ MenuItem menuItem = subMenu.getItem();
+ setValues(item, menuItem);
+
+ // Start parsing the sub menu.
+ parseMenu(parser, attrs, subMenu);
+ }
+ }
+ break;
+
+ case XmlPullParser.END_TAG:
+ if (parser.getName().equals(TAG_ITEM)) {
+ if (!item.hasSubMenu) {
+ // Add the item.
+ MenuItem menuItem = menu.add(NO_ID, item.id, item.order, item.title);
+ setValues(item, menuItem);
+ }
+ } else if (tag.equals(TAG_MENU)) {
+ return;
+ }
+ break;
+ }
+
+ eventType = parser.next();
+
+ } while (eventType != XmlPullParser.END_DOCUMENT);
+ }
+
+ public void parseItem(ParsedItem item, AttributeSet attrs) {
+ TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.MenuItem);
+
+ item.id = a.getResourceId(R.styleable.MenuItem_android_id, NO_ID);
+ item.order = a.getInt(R.styleable.MenuItem_android_orderInCategory, 0);
+ item.title = a.getText(R.styleable.MenuItem_android_title);
+ item.checkable = a.getBoolean(R.styleable.MenuItem_android_checkable, false);
+ item.checked = a.getBoolean(R.styleable.MenuItem_android_checked, false);
+ item.visible = a.getBoolean(R.styleable.MenuItem_android_visible, true);
+ item.enabled = a.getBoolean(R.styleable.MenuItem_android_enabled, true);
+ item.hasSubMenu = false;
+ item.iconRes = a.getResourceId(R.styleable.MenuItem_android_icon, 0);
+ item.showAsAction = a.getInt(R.styleable.MenuItem_android_showAsAction, 0);
+
+ a.recycle();
+ }
+
+ public void setValues(ParsedItem item, MenuItem menuItem) {
+ // We are blocking any presenter updates during inflation.
+ GeckoMenuItem geckoItem = null;
+ if (menuItem instanceof GeckoMenuItem) {
+ geckoItem = (GeckoMenuItem) menuItem;
+ }
+
+ if (geckoItem != null) {
+ geckoItem.stopDispatchingChanges();
+ }
+
+ menuItem.setChecked(item.checked)
+ .setVisible(item.visible)
+ .setEnabled(item.enabled)
+ .setCheckable(item.checkable)
+ .setIcon(item.iconRes);
+
+ menuItem.setShowAsAction(item.showAsAction);
+
+ if (geckoItem != null) {
+ // We don't need to allow presenter updates during inflation,
+ // so we use the weak form of re-enabling changes.
+ geckoItem.resumeDispatchingChanges();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java
new file mode 100644
index 000000000..21df4208d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoMenuItem.java
@@ -0,0 +1,472 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.view.ActionProvider;
+import android.view.ContextMenu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+
+public class GeckoMenuItem implements MenuItem {
+ private static final int SHARE_BAR_HISTORY_SIZE = 2;
+
+ // These values mirror MenuItem values that are only available on API >= 11.
+ public static final int SHOW_AS_ACTION_NEVER = 0;
+ public static final int SHOW_AS_ACTION_IF_ROOM = 1;
+ public static final int SHOW_AS_ACTION_ALWAYS = 2;
+ public static final int SHOW_AS_ACTION_WITH_TEXT = 4;
+ public static final int SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW = 8;
+
+ // A View that can show a MenuItem should be able to initialize from
+ // the properties of the MenuItem.
+ public static interface Layout {
+ public void initialize(GeckoMenuItem item);
+ public void setShowIcon(boolean show);
+ }
+
+ public static interface OnShowAsActionChangedListener {
+ public boolean hasActionItemBar();
+ public void onShowAsActionChanged(GeckoMenuItem item);
+ }
+
+ private final int mId;
+ private final int mOrder;
+ private View mActionView;
+ private int mActionEnum;
+ private CharSequence mTitle;
+ private CharSequence mTitleCondensed;
+ private boolean mCheckable;
+ private boolean mChecked;
+ private boolean mVisible = true;
+ private boolean mEnabled = true;
+ private Drawable mIcon;
+ private int mIconRes;
+ private GeckoActionProvider mActionProvider;
+ private GeckoSubMenu mSubMenu;
+ private MenuItem.OnMenuItemClickListener mMenuItemClickListener;
+ final GeckoMenu mMenu;
+ OnShowAsActionChangedListener mShowAsActionChangedListener;
+
+ private volatile boolean mShouldDispatchChanges = true;
+ private volatile boolean mDidChange;
+
+ public GeckoMenuItem(GeckoMenu menu, int id, int order, int titleRes) {
+ mMenu = menu;
+ mId = id;
+ mOrder = order;
+ mTitle = mMenu.getResources().getString(titleRes);
+ }
+
+ public GeckoMenuItem(GeckoMenu menu, int id, int order, CharSequence title) {
+ mMenu = menu;
+ mId = id;
+ mOrder = order;
+ mTitle = title;
+ }
+
+ /**
+ * Stop dispatching item changed events to presenters until
+ * [start|resume]DispatchingItemsChanged() is called. Useful when
+ * many menu operations are going to be performed as a batch.
+ */
+ public void stopDispatchingChanges() {
+ mDidChange = false;
+ mShouldDispatchChanges = false;
+ }
+
+ /**
+ * Resume dispatching item changed events to presenters. This method
+ * will NOT call onItemChanged if any menu operations were queued.
+ * Only future menu operations will call onItemChanged. Useful for
+ * sequelching presenter updates.
+ */
+ public void resumeDispatchingChanges() {
+ mShouldDispatchChanges = true;
+ }
+
+ /**
+ * Start dispatching item changed events to presenters. This method
+ * will call onItemChanged if any menu operations were queued.
+ */
+ public void startDispatchingChanges() {
+ if (mDidChange) {
+ mMenu.onItemChanged(this);
+ }
+ mShouldDispatchChanges = true;
+ }
+
+ @Override
+ public boolean collapseActionView() {
+ return false;
+ }
+
+ @Override
+ public boolean expandActionView() {
+ return false;
+ }
+
+ public boolean hasActionProvider() {
+ return (mActionProvider != null);
+ }
+
+ public int getActionEnum() {
+ return mActionEnum;
+ }
+
+ public GeckoActionProvider getGeckoActionProvider() {
+ return mActionProvider;
+ }
+
+ @Override
+ public ActionProvider getActionProvider() {
+ return null;
+ }
+
+ @Override
+ public View getActionView() {
+ if (mActionProvider != null) {
+ return mActionProvider.onCreateActionView(SHARE_BAR_HISTORY_SIZE,
+ GeckoActionProvider.ActionViewType.DEFAULT);
+ }
+
+ return mActionView;
+ }
+
+ @Override
+ public char getAlphabeticShortcut() {
+ return 0;
+ }
+
+ @Override
+ public int getGroupId() {
+ return 0;
+ }
+
+ @Override
+ public Drawable getIcon() {
+ if (mIcon == null) {
+ if (mIconRes != 0)
+ return ResourceDrawableUtils.getDrawable(mMenu.getContext(), mIconRes);
+ else
+ return null;
+ } else {
+ return mIcon;
+ }
+ }
+
+ @Override
+ public Intent getIntent() {
+ return null;
+ }
+
+ @Override
+ public int getItemId() {
+ return mId;
+ }
+
+ @Override
+ public ContextMenu.ContextMenuInfo getMenuInfo() {
+ return null;
+ }
+
+ @Override
+ public char getNumericShortcut() {
+ return 0;
+ }
+
+ @Override
+ public int getOrder() {
+ return mOrder;
+ }
+
+ @Override
+ public SubMenu getSubMenu() {
+ return mSubMenu;
+ }
+
+ @Override
+ public CharSequence getTitle() {
+ return mTitle;
+ }
+
+ @Override
+ public CharSequence getTitleCondensed() {
+ return mTitleCondensed;
+ }
+
+ @Override
+ public boolean hasSubMenu() {
+ if (mActionProvider != null)
+ return mActionProvider.hasSubMenu();
+
+ return (mSubMenu != null);
+ }
+
+ public boolean isActionItem() {
+ return (mActionEnum > 0);
+ }
+
+ @Override
+ public boolean isActionViewExpanded() {
+ return false;
+ }
+
+ @Override
+ public boolean isCheckable() {
+ return mCheckable;
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mChecked;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+
+ @Override
+ public boolean isVisible() {
+ return mVisible;
+ }
+
+ @Override
+ public MenuItem setActionProvider(ActionProvider actionProvider) {
+ return this;
+ }
+
+ public MenuItem setActionProvider(GeckoActionProvider actionProvider) {
+ mActionProvider = actionProvider;
+ if (mActionProvider != null) {
+ actionProvider.setOnTargetSelectedListener(new GeckoActionProvider.OnTargetSelectedListener() {
+ @Override
+ public void onTargetSelected() {
+ mMenu.close();
+
+ // Refresh the menu item to show the high frequency apps.
+ mShowAsActionChangedListener.onShowAsActionChanged(GeckoMenuItem.this);
+ }
+ });
+ }
+
+ mShowAsActionChangedListener.onShowAsActionChanged(this);
+ return this;
+ }
+
+ @Override
+ public MenuItem setActionView(int resId) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setActionView(View view) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setAlphabeticShortcut(char alphaChar) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setCheckable(boolean checkable) {
+ if (mCheckable != checkable) {
+ mCheckable = checkable;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setChecked(boolean checked) {
+ if (mChecked != checked) {
+ mChecked = checked;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setEnabled(boolean enabled) {
+ if (mEnabled != enabled) {
+ mEnabled = enabled;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setIcon(Drawable icon) {
+ if (mIcon != icon) {
+ mIcon = icon;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setIcon(int iconRes) {
+ if (mIconRes != iconRes) {
+ mIconRes = iconRes;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setIntent(Intent intent) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setNumericShortcut(char numericChar) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setOnActionExpandListener(MenuItem.OnActionExpandListener listener) {
+ return this;
+ }
+
+ @Override
+ public MenuItem setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener menuItemClickListener) {
+ mMenuItemClickListener = menuItemClickListener;
+ return this;
+ }
+
+ @Override
+ public MenuItem setShortcut(char numericChar, char alphaChar) {
+ return this;
+ }
+
+ @Override
+ public void setShowAsAction(int actionEnum) {
+ setShowAsAction(actionEnum, 0);
+ }
+
+ public void setShowAsAction(int actionEnum, int style) {
+ if (mShowAsActionChangedListener == null)
+ return;
+
+ if (mActionEnum == actionEnum)
+ return;
+
+ if (actionEnum > 0) {
+ if (!mShowAsActionChangedListener.hasActionItemBar())
+ return;
+
+ if (!hasActionProvider()) {
+ // Change the type to just an icon
+ MenuItemActionBar actionView;
+ if (style != 0) {
+ actionView = new MenuItemActionBar(mMenu.getContext(), null, style);
+ } else {
+ if (actionEnum == SHOW_AS_ACTION_ALWAYS) {
+ actionView = new MenuItemActionBar(mMenu.getContext());
+ } else {
+ actionView = new MenuItemActionBar(mMenu.getContext(), null, R.attr.menuItemSecondaryActionBarStyle);
+ }
+ }
+
+ actionView.initialize(this);
+ mActionView = actionView;
+ }
+
+ mActionEnum = actionEnum;
+ }
+
+ mShowAsActionChangedListener.onShowAsActionChanged(this);
+ }
+
+ @Override
+ public MenuItem setShowAsActionFlags(int actionEnum) {
+ return this;
+ }
+
+ public MenuItem setSubMenu(GeckoSubMenu subMenu) {
+ mSubMenu = subMenu;
+ return this;
+ }
+
+ @Override
+ public MenuItem setTitle(CharSequence title) {
+ if (!TextUtils.equals(mTitle, title)) {
+ mTitle = title;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ @Override
+ public MenuItem setTitle(int title) {
+ CharSequence newTitle = mMenu.getResources().getString(title);
+ return setTitle(newTitle);
+ }
+
+ @Override
+ public MenuItem setTitleCondensed(CharSequence title) {
+ mTitleCondensed = title;
+ return this;
+ }
+
+ @Override
+ public MenuItem setVisible(boolean visible) {
+ // Action views are not normal menu items and visibility can get out
+ // of sync unless we dispatch whenever required.
+ if (isActionItem() || mVisible != visible) {
+ mVisible = visible;
+ if (mShouldDispatchChanges) {
+ mMenu.onItemChanged(this);
+ } else {
+ mDidChange = true;
+ }
+ }
+ return this;
+ }
+
+ public boolean invoke() {
+ if (mMenuItemClickListener != null)
+ return mMenuItemClickListener.onMenuItemClick(this);
+ else
+ return false;
+ }
+
+ public void setOnShowAsActionChangedListener(OnShowAsActionChangedListener listener) {
+ mShowAsActionChangedListener = listener;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java
new file mode 100644
index 000000000..d774bdd37
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/GeckoSubMenu.java
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+
+public class GeckoSubMenu extends GeckoMenu
+ implements SubMenu {
+ private static final String LOGTAG = "GeckoSubMenu";
+
+ // MenuItem associated with this submenu.
+ private MenuItem mMenuItem;
+
+ public GeckoSubMenu(Context context) {
+ super(context);
+ }
+
+ public GeckoSubMenu(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public GeckoSubMenu(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void clearHeader() {
+ }
+
+ public SubMenu setMenuItem(MenuItem item) {
+ mMenuItem = item;
+ return this;
+ }
+
+ @Override
+ public MenuItem getItem() {
+ return mMenuItem;
+ }
+
+ @Override
+ public SubMenu setHeaderIcon(Drawable icon) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setHeaderIcon(int iconRes) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setHeaderTitle(CharSequence title) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setHeaderTitle(int titleRes) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setHeaderView(View view) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setIcon(Drawable icon) {
+ return this;
+ }
+
+ @Override
+ public SubMenu setIcon(int iconRes) {
+ return this;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java
new file mode 100644
index 000000000..882187dd6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemActionBar.java
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class MenuItemActionBar extends ThemedImageButton
+ implements GeckoMenuItem.Layout {
+ private static final String LOGTAG = "GeckoMenuItemActionBar";
+
+ public MenuItemActionBar(Context context) {
+ this(context, null);
+ }
+
+ public MenuItemActionBar(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.menuItemActionBarStyle);
+ }
+
+ public MenuItemActionBar(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ public void initialize(GeckoMenuItem item) {
+ if (item == null)
+ return;
+
+ setIcon(item.getIcon());
+ setTitle(item.getTitle());
+ setEnabled(item.isEnabled());
+ setId(item.getItemId());
+ }
+
+ void setIcon(Drawable icon) {
+ if (icon == null) {
+ setVisibility(GONE);
+ } else {
+ setVisibility(VISIBLE);
+ setImageDrawable(icon);
+ }
+ }
+
+ void setIcon(int icon) {
+ setIcon((icon == 0) ? null : ResourceDrawableUtils.getDrawable(getContext(), icon));
+ }
+
+ void setTitle(CharSequence title) {
+ // set accessibility contentDescription here
+ setContentDescription(title);
+ }
+
+ @Override
+ public void setShowIcon(boolean show) {
+ // Do nothing.
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java
new file mode 100644
index 000000000..5b5069334
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemDefault.java
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+public class MenuItemDefault extends TextView
+ implements GeckoMenuItem.Layout {
+ private static final int[] STATE_MORE = new int[] { R.attr.state_more };
+ private static final int[] STATE_CHECKED = new int[] { android.R.attr.state_checkable, android.R.attr.state_checked };
+ private static final int[] STATE_UNCHECKED = new int[] { android.R.attr.state_checkable };
+
+ private Drawable mIcon;
+ private final Drawable mState;
+ private static Rect sIconBounds;
+
+ private boolean mCheckable;
+ private boolean mChecked;
+ private boolean mHasSubMenu;
+ private boolean mShowIcon;
+
+ public MenuItemDefault(Context context) {
+ this(context, null);
+ }
+
+ public MenuItemDefault(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.menuItemDefaultStyle);
+ }
+
+ public MenuItemDefault(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ Resources res = getResources();
+ int width = res.getDimensionPixelSize(R.dimen.menu_item_row_width);
+ int height = res.getDimensionPixelSize(R.dimen.menu_item_row_height);
+ setMinimumWidth(width);
+ setMinimumHeight(height);
+
+ int stateIconSize = res.getDimensionPixelSize(R.dimen.menu_item_state_icon);
+ Rect stateIconBounds = new Rect(0, 0, stateIconSize, stateIconSize);
+
+ mState = res.getDrawable(R.drawable.menu_item_state).mutate();
+ mState.setBounds(stateIconBounds);
+
+ if (sIconBounds == null) {
+ int iconSize = res.getDimensionPixelSize(R.dimen.menu_item_icon);
+ sIconBounds = new Rect(0, 0, iconSize, iconSize);
+ }
+
+ setCompoundDrawables(mIcon, null, mState, null);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 2);
+
+ if (mHasSubMenu)
+ mergeDrawableStates(drawableState, STATE_MORE);
+ else if (mCheckable && mChecked)
+ mergeDrawableStates(drawableState, STATE_CHECKED);
+ else if (mCheckable && !mChecked)
+ mergeDrawableStates(drawableState, STATE_UNCHECKED);
+
+ return drawableState;
+ }
+
+ @Override
+ public void initialize(GeckoMenuItem item) {
+ if (item == null)
+ return;
+
+ setTitle(item.getTitle());
+ setIcon(item.getIcon());
+ setEnabled(item.isEnabled());
+ setCheckable(item.isCheckable());
+ setChecked(item.isChecked());
+ setSubMenuIndicator(item.hasSubMenu());
+ }
+
+ private void refreshIcon() {
+ setCompoundDrawables(mShowIcon ? mIcon : null, null, mState, null);
+ }
+
+ void setIcon(Drawable icon) {
+ mIcon = icon;
+
+ if (mIcon != null) {
+ mIcon.setBounds(sIconBounds);
+ mIcon.setAlpha(isEnabled() ? 255 : 99);
+ }
+
+ refreshIcon();
+ }
+
+ void setIcon(int icon) {
+ setIcon((icon == 0) ? null : ResourceDrawableUtils.getDrawable(getContext(), icon));
+ }
+
+ void setTitle(CharSequence title) {
+ setText(title);
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+
+ if (mIcon != null)
+ mIcon.setAlpha(enabled ? 255 : 99);
+
+ if (mState != null)
+ mState.setAlpha(enabled ? 255 : 99);
+ }
+
+ private void setCheckable(boolean checkable) {
+ if (mCheckable != checkable) {
+ mCheckable = checkable;
+ refreshDrawableState();
+ }
+ }
+
+ private void setChecked(boolean checked) {
+ if (mChecked != checked) {
+ mChecked = checked;
+ refreshDrawableState();
+ }
+ }
+
+ @Override
+ public void setShowIcon(boolean show) {
+ if (mShowIcon != show) {
+ mShowIcon = show;
+ refreshIcon();
+ }
+ }
+
+ void setSubMenuIndicator(boolean hasSubMenu) {
+ if (mHasSubMenu != hasSubMenu) {
+ mHasSubMenu = hasSubMenu;
+ refreshDrawableState();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java
new file mode 100644
index 000000000..d01f52687
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuItemSwitcherLayout.java
@@ -0,0 +1,188 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.R;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+
+/**
+ * This class is a container view for menu items that:
+ * * Shows text if there is enough space and there are
+ * no action buttons ({@link #mActionButtons}).
+ * * Shows an icon if there is not enough space for text,
+ * or there are action buttons.
+ */
+public class MenuItemSwitcherLayout extends LinearLayout
+ implements GeckoMenuItem.Layout,
+ View.OnClickListener {
+ private final MenuItemDefault mMenuItem;
+ private final MenuItemActionBar mMenuButton;
+ private final List<ImageButton> mActionButtons;
+ private final List<View.OnClickListener> mActionButtonListeners = new ArrayList<View.OnClickListener>();
+
+ public MenuItemSwitcherLayout(Context context) {
+ this(context, null);
+ }
+
+ public MenuItemSwitcherLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, R.attr.menuItemSwitcherLayoutStyle);
+ }
+
+ @TargetApi(14)
+ public MenuItemSwitcherLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.menu_item_switcher_layout, this);
+ mMenuItem = (MenuItemDefault) findViewById(R.id.menu_item);
+ mMenuButton = (MenuItemActionBar) findViewById(R.id.menu_item_button);
+ mActionButtons = new ArrayList<ImageButton>();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ final int width = right - left;
+
+ final View parent = (View) getParent();
+ final int parentPadding = parent.getPaddingLeft() + parent.getPaddingRight();
+ final int horizontalSpaceAvailableInParent = parent.getMeasuredWidth() - parentPadding;
+
+ // Check if there is another View sharing horizontal
+ // space with this View in the parent.
+ if (width < horizontalSpaceAvailableInParent || mActionButtons.size() != 0) {
+ // Use the icon.
+ mMenuItem.setVisibility(View.GONE);
+ mMenuButton.setVisibility(View.VISIBLE);
+ } else {
+ // Use the button.
+ mMenuItem.setVisibility(View.VISIBLE);
+ mMenuButton.setVisibility(View.GONE);
+ }
+
+ super.onLayout(changed, left, top, right, bottom);
+ }
+
+ @Override
+ public void initialize(GeckoMenuItem item) {
+ if (item == null) {
+ return;
+ }
+
+ mMenuItem.initialize(item);
+ mMenuButton.initialize(item);
+ setEnabled(item.isEnabled());
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ mMenuItem.setEnabled(enabled);
+ mMenuButton.setEnabled(enabled);
+
+ for (ImageButton button : mActionButtons) {
+ button.setEnabled(enabled);
+ button.setAlpha(enabled ? 255 : 99);
+ }
+ }
+
+ public void setMenuItemClickListener(View.OnClickListener listener) {
+ mMenuItem.setOnClickListener(listener);
+ mMenuButton.setOnClickListener(listener);
+ }
+
+ public void setMenuItemLongClickListener(View.OnLongClickListener listener) {
+ mMenuItem.setOnLongClickListener(listener);
+ mMenuButton.setOnLongClickListener(listener);
+ }
+
+ public void addActionButtonClickListener(View.OnClickListener listener) {
+ mActionButtonListeners.add(listener);
+ }
+
+ @Override
+ public void setShowIcon(boolean show) {
+ mMenuItem.setShowIcon(show);
+ }
+
+ public void setIcon(Drawable icon) {
+ mMenuItem.setIcon(icon);
+ mMenuButton.setIcon(icon);
+ }
+
+ public void setIcon(int icon) {
+ mMenuItem.setIcon(icon);
+ mMenuButton.setIcon(icon);
+ }
+
+ public void setTitle(CharSequence title) {
+ mMenuItem.setTitle(title);
+ mMenuButton.setContentDescription(title);
+ }
+
+ public void setSubMenuIndicator(boolean hasSubMenu) {
+ mMenuItem.setSubMenuIndicator(hasSubMenu);
+ }
+
+ public void addActionButton(Drawable drawable, CharSequence label) {
+ // If this is the first icon, retain the text.
+ // If not, make the menu item an icon.
+ final int count = mActionButtons.size();
+ mMenuItem.setVisibility(View.GONE);
+ mMenuButton.setVisibility(View.VISIBLE);
+
+ if (drawable != null) {
+ ImageButton button = new ImageButton(getContext(), null, R.attr.menuItemSecondaryActionBarStyle);
+ button.setImageDrawable(drawable);
+ button.setContentDescription(label);
+ button.setOnClickListener(this);
+ button.setTag(count);
+
+ final int height = (int) (getResources().getDimension(R.dimen.menu_item_row_height));
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, height);
+ params.weight = 1.0f;
+ button.setLayoutParams(params);
+
+ // Place action buttons to the right of the actual menu item
+ mActionButtons.add(button);
+ addView(button, count + 1);
+ }
+ }
+
+ protected int getActionButtonCount() {
+ return mActionButtons.size();
+ }
+
+ @Override
+ public void onClick(View view) {
+ for (View.OnClickListener listener : mActionButtonListeners) {
+ listener.onClick(view);
+ }
+ }
+
+ /**
+ * Update the styles if this view is being used in the context menus.
+ *
+ * Ideally, we just use different layout files and styles to set this, but
+ * MenuItemSwitcherLayout is too integrated into GeckoActionProvider to provide
+ * an easy separation so instead I provide this hack. I'm sorry.
+ */
+ public void initContextMenuStyles() {
+ final int defaultContextMenuPadding = getContext().getResources().getDimensionPixelOffset(
+ R.dimen.context_menu_item_horizontal_padding);
+ mMenuItem.setPadding(defaultContextMenuPadding, getPaddingTop(),
+ defaultContextMenuPadding, getPaddingBottom());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java
new file mode 100644
index 000000000..ce4da8b7f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPanel.java
@@ -0,0 +1,36 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.LinearLayout;
+
+/**
+ * The outer container for the custom menu. On phones with h/w menu button,
+ * this is given to Android which inflates it to the right panel. On phones
+ * with s/w menu button, this is added to a MenuPopup.
+ */
+public class MenuPanel extends LinearLayout {
+ public MenuPanel(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ int width = (int) context.getResources().getDimension(R.dimen.menu_item_row_width);
+ setLayoutParams(new ViewGroup.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT));
+ }
+
+ @Override
+ public boolean dispatchPopulateAccessibilityEvent (AccessibilityEvent event) {
+ onPopulateAccessibilityEvent(event);
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java
new file mode 100644
index 000000000..227cc7630
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/menu/MenuPopup.java
@@ -0,0 +1,76 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.menu;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.support.v7.widget.CardView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.PopupWindow;
+
+/**
+ * A popup to show the inflated MenuPanel.
+ */
+public class MenuPopup extends PopupWindow {
+ private final CardView mPanel;
+
+ private final int mPopupWidth;
+
+ public MenuPopup(Context context) {
+ super(context);
+
+ setFocusable(true);
+
+ mPopupWidth = context.getResources().getDimensionPixelSize(R.dimen.menu_popup_width);
+
+ // Setting a null background makes the popup to not close on touching outside.
+ setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+ setWindowLayoutMode(ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ LayoutInflater inflater = LayoutInflater.from(context);
+ mPanel = (CardView) inflater.inflate(R.layout.menu_popup, null);
+ setContentView(mPanel);
+
+ setAnimationStyle(R.style.PopupAnimation);
+ }
+
+ /**
+ * Adds the panel with the menu to its content.
+ *
+ * @param view The panel view with the menu to be shown.
+ */
+ public void setPanelView(View view) {
+ view.setLayoutParams(new FrameLayout.LayoutParams(mPopupWidth,
+ FrameLayout.LayoutParams.WRAP_CONTENT));
+
+ mPanel.removeAllViews();
+ mPanel.addView(view);
+ }
+
+ /**
+ * A small little offset.
+ */
+ @Override
+ public void showAsDropDown(View anchor) {
+ // Set a height, so that the popup will not be displayed below the bottom of the screen.
+ // We use the exact height of the internal content, which is the technique described in
+ // http://stackoverflow.com/a/7698709
+ setHeight(mPanel.getHeight());
+
+ // Attempt to align the center of the popup with the center of the anchor. If the anchor is
+ // near the edge of the screen, the popup will just align with the edge of the screen.
+ final int xOffset = anchor.getWidth() / 2 - mPopupWidth / 2;
+ showAsDropDown(anchor, xOffset, -anchor.getHeight());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java
new file mode 100644
index 000000000..cf22685c2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemBuffer.java
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.mozglue;
+
+import android.os.Parcel;
+
+import org.mozilla.gecko.media.Sample;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public final class SharedMemBuffer implements Sample.Buffer {
+ private SharedMemory mSharedMem;
+
+ /* package */
+ public SharedMemBuffer(SharedMemory sharedMem) {
+ mSharedMem = sharedMem;
+ }
+
+ protected SharedMemBuffer(Parcel in) {
+ mSharedMem = in.readParcelable(Sample.class.getClassLoader());
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(mSharedMem, flags);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator<SharedMemBuffer> CREATOR = new Creator<SharedMemBuffer>() {
+ @Override
+ public SharedMemBuffer createFromParcel(Parcel in) {
+ return new SharedMemBuffer(in);
+ }
+
+ @Override
+ public SharedMemBuffer[] newArray(int size) {
+ return new SharedMemBuffer[size];
+ }
+ };
+
+ @Override
+ public int capacity() {
+ return mSharedMem != null ? mSharedMem.getSize() : 0;
+ }
+
+ @Override
+ public void readFromByteBuffer(ByteBuffer src, int offset, int size) throws IOException {
+ if (!src.isDirect()) {
+ throw new IOException("SharedMemBuffer only support reading from direct byte buffer.");
+ }
+ nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size);
+ }
+
+ private native static void nativeReadFromDirectBuffer(ByteBuffer src, long dest, int offset, int size);
+
+ @Override
+ public void writeToByteBuffer(ByteBuffer dest, int offset, int size) throws IOException {
+ if (!dest.isDirect()) {
+ throw new IOException("SharedMemBuffer only support writing to direct byte buffer.");
+ }
+ nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size);
+ }
+
+ private native static void nativeWriteToDirectBuffer(long src, ByteBuffer dest, int offset, int size);
+
+ @Override
+ public void dispose() {
+ if (mSharedMem != null) {
+ mSharedMem.dispose();
+ mSharedMem = null;
+ }
+ }
+
+ @Override public String toString() { return "Buffer: " + mSharedMem; }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java
new file mode 100644
index 000000000..bc43a2755
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/mozglue/SharedMemory.java
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.mozglue;
+
+import android.os.MemoryFile;
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+import android.util.Log;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.lang.reflect.Method;
+
+public class SharedMemory implements Parcelable {
+ private static final String LOGTAG = "GeckoShmem";
+ private static Method sGetFDMethod = null; // MemoryFile.getFileDescriptor() is hidden. :(
+ private ParcelFileDescriptor mDescriptor;
+ private int mSize;
+ private int mId;
+ private long mHandle; // The native pointer.
+ private boolean mIsMapped;
+ private MemoryFile mBackedFile;
+
+ static {
+ try {
+ sGetFDMethod = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
+ } catch (NoSuchMethodException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private SharedMemory(Parcel in) {
+ mDescriptor = in.readFileDescriptor();
+ mSize = in.readInt();
+ mId = in.readInt();
+ }
+
+ public static final Creator<SharedMemory> CREATOR = new Creator<SharedMemory>() {
+ @Override
+ public SharedMemory createFromParcel(Parcel in) {
+ return new SharedMemory(in);
+ }
+
+ @Override
+ public SharedMemory[] newArray(int size) {
+ return new SharedMemory[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ // We don't want ParcelFileDescriptor.writeToParcel() to close the fd.
+ dest.writeFileDescriptor(mDescriptor.getFileDescriptor());
+ dest.writeInt(mSize);
+ dest.writeInt(mId);
+ }
+
+ public SharedMemory(int id, int size) throws NoSuchMethodException, IOException {
+ if (sGetFDMethod == null) {
+ throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist.");
+ }
+ mBackedFile = new MemoryFile(null, size);
+ try {
+ FileDescriptor fd = (FileDescriptor)sGetFDMethod.invoke(mBackedFile);
+ mDescriptor = ParcelFileDescriptor.dup(fd);
+ mSize = size;
+ mId = id;
+ mBackedFile.allowPurging(false);
+ } catch (Exception e) {
+ e.printStackTrace();
+ close();
+ throw new IOException(e.getMessage());
+ }
+ }
+
+ public void flush() {
+ if (mBackedFile == null) {
+ close();
+ }
+ }
+
+ public void close() {
+ if (mIsMapped) {
+ unmap(mHandle, mSize);
+ mHandle = 0;
+ }
+
+ if (mDescriptor != null) {
+ try {
+ mDescriptor.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ mDescriptor = null;
+ }
+ }
+
+ // Should only be called by process that allocates shared memory.
+ public void dispose() {
+ if (!isValid()) {
+ return;
+ }
+
+ close();
+
+ if (mBackedFile != null) {
+ mBackedFile.close();
+ mBackedFile = null;
+ }
+ }
+
+ private native void unmap(long address, int size);
+
+ public boolean isValid() { return mDescriptor != null; }
+
+ public int getSize() { return mSize; }
+
+ private int getFD() {
+ return isValid() ? mDescriptor.getFd() : -1;
+ }
+
+ public long getPointer() {
+ if (!isValid()) {
+ return 0;
+ }
+
+ if (!mIsMapped) {
+ mHandle = map(getFD(), mSize);
+ if (mHandle != 0) {
+ mIsMapped = true;
+ }
+ }
+ return mHandle;
+ }
+
+ private native long map(int fd, int size);
+
+ @Override
+ protected void finalize() throws Throwable {
+ if (mBackedFile != null) {
+ Log.w(LOGTAG, "dispose() not called before finalizing");
+ }
+ dispose();
+
+ super.finalize();
+ }
+
+ @Override
+ public String toString() {
+ return "SHM(" + getSize() + " bytes): id=" + mId + ", backing=" + mBackedFile + ",fd=" + mDescriptor;
+ }
+
+ @Override
+ public boolean equals(Object that) {
+ return (this == that) ||
+ ((that instanceof SharedMemory) && (hashCode() == that.hashCode()));
+ }
+
+ @Override
+ public int hashCode() {
+ return mId;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java
new file mode 100644
index 000000000..c46c01050
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationClient.java
@@ -0,0 +1,324 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.notifications;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.util.Log;
+
+import java.util.HashMap;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoService;
+import org.mozilla.gecko.NotificationListener;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.gfx.BitmapUtils;
+
+/**
+ * Client for posting notifications.
+ */
+public final class NotificationClient implements NotificationListener {
+ private static final String LOGTAG = "GeckoNotificationClient";
+ /* package */ static final String CLICK_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".NOTIFICATION_CLICK";
+ /* package */ static final String CLOSE_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".NOTIFICATION_CLOSE";
+ /* package */ static final String PERSISTENT_INTENT_EXTRA = "persistentIntent";
+
+ private final Context mContext;
+ private final NotificationManagerCompat mNotificationManager;
+
+ private final HashMap<String, Notification> mNotifications = new HashMap<>();
+
+ /**
+ * Notification associated with this service's foreground state.
+ *
+ * {@link android.app.Service#startForeground(int, android.app.Notification)}
+ * associates the foreground with exactly one notification from the service.
+ * To keep Fennec alive during downloads (and to make sure it can be killed
+ * once downloads are complete), we make sure that the foreground is always
+ * associated with an active progress notification if and only if at least
+ * one download is in progress.
+ */
+ private String mForegroundNotification;
+
+ public NotificationClient(Context context) {
+ mContext = context.getApplicationContext();
+ mNotificationManager = NotificationManagerCompat.from(mContext);
+ }
+
+ @Override // NotificationListener
+ public void showNotification(String name, String cookie, String title,
+ String text, String host, String imageUrl) {
+ showNotification(name, cookie, title, text, host, imageUrl, /* data */ null);
+ }
+
+ @Override // NotificationListener
+ public void showPersistentNotification(String name, String cookie, String title,
+ String text, String host, String imageUrl,
+ String data) {
+ showNotification(name, cookie, title, text, host, imageUrl, data != null ? data : "");
+ }
+
+ private void showNotification(String name, String cookie, String title,
+ String text, String host, String imageUrl,
+ String persistentData) {
+ // Put the strings into the intent as an URI
+ // "alert:?name=<name>&cookie=<cookie>"
+ String packageName = AppConstants.ANDROID_PACKAGE_NAME;
+ String className = AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS;
+ if (GeckoAppShell.getGeckoInterface() != null) {
+ final ComponentName comp = GeckoAppShell.getGeckoInterface()
+ .getActivity().getComponentName();
+ packageName = comp.getPackageName();
+ className = comp.getClassName();
+ }
+ final Uri dataUri = (new Uri.Builder())
+ .scheme("moz-notification")
+ .authority(packageName)
+ .path(className)
+ .appendQueryParameter("name", name)
+ .appendQueryParameter("cookie", cookie)
+ .build();
+
+ final Intent clickIntent = new Intent(CLICK_ACTION);
+ clickIntent.setClass(mContext, NotificationReceiver.class);
+ clickIntent.setData(dataUri);
+
+ if (persistentData != null) {
+ final Intent persistentIntent = GeckoService.getIntentToCreateServices(
+ mContext, "persistent-notification-click", persistentData);
+ clickIntent.putExtra(PERSISTENT_INTENT_EXTRA, persistentIntent);
+ }
+
+ final PendingIntent clickPendingIntent = PendingIntent.getBroadcast(
+ mContext, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ final Intent closeIntent = new Intent(CLOSE_ACTION);
+ closeIntent.setClass(mContext, NotificationReceiver.class);
+ closeIntent.setData(dataUri);
+
+ if (persistentData != null) {
+ final Intent persistentIntent = GeckoService.getIntentToCreateServices(
+ mContext, "persistent-notification-close", persistentData);
+ closeIntent.putExtra(PERSISTENT_INTENT_EXTRA, persistentIntent);
+ }
+
+ final PendingIntent closePendingIntent = PendingIntent.getBroadcast(
+ mContext, 0, closeIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ add(name, imageUrl, host, title, text, clickPendingIntent, closePendingIntent);
+ GeckoAppShell.onNotificationShow(name, cookie);
+ }
+
+ @Override // NotificationListener
+ public void closeNotification(String name)
+ {
+ remove(name);
+ }
+
+ /**
+ * Adds a notification; used for web notifications.
+ *
+ * @param name the unique name of the notification
+ * @param imageUrl URL of the image to use
+ * @param alertTitle title of the notification
+ * @param alertText text of the notification
+ * @param contentIntent Intent used when the notification is clicked
+ * @param deleteIntent Intent used when the notification is closed
+ */
+ private void add(final String name, final String imageUrl, final String host,
+ final String alertTitle, final String alertText,
+ final PendingIntent contentIntent, final PendingIntent deleteIntent) {
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext)
+ .setContentTitle(alertTitle)
+ .setContentText(alertText)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentIntent(contentIntent)
+ .setDeleteIntent(deleteIntent)
+ .setAutoCancel(true)
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(alertText)
+ .setSummaryText(host));
+
+ // Fetch icon.
+ if (!imageUrl.isEmpty()) {
+ final Bitmap image = BitmapUtils.decodeUrl(imageUrl);
+ builder.setLargeIcon(image);
+ }
+
+ builder.setWhen(System.currentTimeMillis());
+ final Notification notification = builder.build();
+
+ synchronized (this) {
+ mNotifications.put(name, notification);
+ }
+
+ mNotificationManager.notify(name, 0, notification);
+ }
+
+ /**
+ * Adds a notification; used for Fennec app notifications.
+ *
+ * @param name the unique name of the notification
+ * @param notification the Notification to add
+ */
+ public synchronized void add(final String name, final Notification notification) {
+ final boolean ongoing = isOngoing(notification);
+
+ if (ongoing != isOngoing(mNotifications.get(name))) {
+ // In order to change notification from ongoing to non-ongoing, or vice versa,
+ // we have to remove the previous notification, because ongoing notifications
+ // use a different id value than non-ongoing notifications.
+ onNotificationClose(name);
+ }
+
+ mNotifications.put(name, notification);
+
+ if (!ongoing) {
+ mNotificationManager.notify(name, 0, notification);
+ return;
+ }
+
+ // Ongoing
+ if (mForegroundNotification == null) {
+ setForegroundNotificationLocked(name, notification);
+ } else if (mForegroundNotification.equals(name)) {
+ // Shortcut to update the current foreground notification, instead of
+ // going through the service.
+ mNotificationManager.notify(R.id.foregroundNotification, notification);
+ }
+ }
+
+ /**
+ * Updates a notification.
+ *
+ * @param name Name of existing notification
+ * @param progress progress of item being updated
+ * @param progressMax max progress of item being updated
+ * @param alertText text of the notification
+ */
+ public void update(final String name, final long progress,
+ final long progressMax, final String alertText) {
+ Notification notification;
+ synchronized (this) {
+ notification = mNotifications.get(name);
+ }
+ if (notification == null) {
+ return;
+ }
+
+ notification = new NotificationCompat.Builder(mContext)
+ .setContentText(alertText)
+ .setSmallIcon(notification.icon)
+ .setWhen(notification.when)
+ .setContentIntent(notification.contentIntent)
+ .setProgress((int) progressMax, (int) progress, false)
+ .build();
+
+ add(name, notification);
+ }
+
+ /* package */ synchronized Notification onNotificationClose(final String name) {
+ mNotificationManager.cancel(name, 0);
+
+ final Notification notification = mNotifications.remove(name);
+ if (notification != null) {
+ updateForegroundNotificationLocked(name);
+ }
+ return notification;
+ }
+
+ /**
+ * Removes a notification.
+ *
+ * @param name Name of existing notification
+ */
+ public synchronized void remove(final String name) {
+ final Notification notification = onNotificationClose(name);
+ if (notification == null || notification.deleteIntent == null) {
+ return;
+ }
+
+ // Canceling the notification doesn't trigger the delete intent, so we
+ // have to trigger it manually.
+ try {
+ notification.deleteIntent.send();
+ } catch (final PendingIntent.CanceledException e) {
+ // Ignore.
+ }
+ }
+
+ /**
+ * Determines whether the service is done.
+ *
+ * The service is considered finished when all notifications have been
+ * removed.
+ *
+ * @return whether all notifications have been removed
+ */
+ public synchronized boolean isDone() {
+ return mNotifications.isEmpty();
+ }
+
+ /**
+ * Determines whether a notification should hold a foreground service to keep Gecko alive
+ *
+ * @param name the name of the notification to check
+ * @return whether the notification is ongoing
+ */
+ public synchronized boolean isOngoing(final String name) {
+ return isOngoing(mNotifications.get(name));
+ }
+
+ /**
+ * Determines whether a notification should hold a foreground service to keep Gecko alive
+ *
+ * @param notification the notification to check
+ * @return whether the notification is ongoing
+ */
+ public boolean isOngoing(final Notification notification) {
+ if (notification != null && (notification.flags & Notification.FLAG_ONGOING_EVENT) != 0) {
+ return true;
+ }
+ return false;
+ }
+
+ private void setForegroundNotificationLocked(final String name,
+ final Notification notification) {
+ mForegroundNotification = name;
+
+ final Intent intent = new Intent(mContext, NotificationService.class);
+ intent.putExtra(NotificationService.EXTRA_NOTIFICATION, notification);
+ mContext.startService(intent);
+ }
+
+ private void updateForegroundNotificationLocked(final String oldName) {
+ if (mForegroundNotification == null || !mForegroundNotification.equals(oldName)) {
+ return;
+ }
+
+ // If we're removing the notification associated with the
+ // foreground, we need to pick another active notification to act
+ // as the foreground notification.
+ for (final String name : mNotifications.keySet()) {
+ final Notification notification = mNotifications.get(name);
+ if (isOngoing(notification)) {
+ setForegroundNotificationLocked(name, notification);
+ return;
+ }
+ }
+
+ setForegroundNotificationLocked(null, null);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java
new file mode 100644
index 000000000..1e33031b5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationHelper.java
@@ -0,0 +1,366 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.notifications;
+
+import java.util.HashMap;
+import java.util.Iterator;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.mozglue.SafeIntent;
+import org.mozilla.gecko.util.GeckoEventListener;
+
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+
+public final class NotificationHelper implements GeckoEventListener {
+ public static final String HELPER_BROADCAST_ACTION = AppConstants.ANDROID_PACKAGE_NAME + ".helperBroadcastAction";
+
+ public static final String NOTIFICATION_ID = "NotificationHelper_ID";
+ private static final String LOGTAG = "GeckoNotificationHelper";
+ private static final String HELPER_NOTIFICATION = "helperNotif";
+
+ // Attributes mandatory to be used while sending a notification from js.
+ private static final String TITLE_ATTR = "title";
+ private static final String TEXT_ATTR = "text";
+ /* package */ static final String ID_ATTR = "id";
+ private static final String SMALLICON_ATTR = "smallIcon";
+
+ // Attributes that can be used while sending a notification from js.
+ private static final String PROGRESS_VALUE_ATTR = "progress_value";
+ private static final String PROGRESS_MAX_ATTR = "progress_max";
+ private static final String PROGRESS_INDETERMINATE_ATTR = "progress_indeterminate";
+ private static final String LIGHT_ATTR = "light";
+ private static final String ONGOING_ATTR = "ongoing";
+ private static final String WHEN_ATTR = "when";
+ private static final String PRIORITY_ATTR = "priority";
+ private static final String LARGE_ICON_ATTR = "largeIcon";
+ private static final String ACTIONS_ATTR = "actions";
+ private static final String ACTION_ID_ATTR = "buttonId";
+ private static final String ACTION_TITLE_ATTR = "title";
+ private static final String ACTION_ICON_ATTR = "icon";
+ private static final String PERSISTENT_ATTR = "persistent";
+ private static final String HANDLER_ATTR = "handlerKey";
+ private static final String COOKIE_ATTR = "cookie";
+ static final String EVENT_TYPE_ATTR = "eventType";
+
+ private static final String NOTIFICATION_SCHEME = "moz-notification";
+
+ private static final String BUTTON_EVENT = "notification-button-clicked";
+ private static final String CLICK_EVENT = "notification-clicked";
+ static final String CLEARED_EVENT = "notification-cleared";
+
+ static final String ORIGINAL_EXTRA_COMPONENT = "originalComponent";
+
+ private final Context mContext;
+
+ // Holds a list of notifications that should be cleared if the Fennec Activity is shut down.
+ // Will not include ongoing or persistent notifications that are tied to Gecko's lifecycle.
+ private HashMap<String, String> mClearableNotifications;
+
+ private boolean mInitialized;
+ private static NotificationHelper sInstance;
+
+ private NotificationHelper(Context context) {
+ mContext = context;
+ }
+
+ public void init() {
+ if (mInitialized) {
+ return;
+ }
+
+ mClearableNotifications = new HashMap<String, String>();
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ "Notification:Show",
+ "Notification:Hide");
+ mInitialized = true;
+ }
+
+ public static NotificationHelper getInstance(Context context) {
+ // If someone else created this singleton, but didn't initialize it, something has gone wrong.
+ if (sInstance != null && !sInstance.mInitialized) {
+ throw new IllegalStateException("NotificationHelper was created by someone else but not initialized");
+ }
+
+ if (sInstance == null) {
+ sInstance = new NotificationHelper(context.getApplicationContext());
+ }
+ return sInstance;
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ if (event.equals("Notification:Show")) {
+ showNotification(message);
+ } else if (event.equals("Notification:Hide")) {
+ hideNotification(message);
+ }
+ }
+
+ public boolean isHelperIntent(Intent i) {
+ return i.getBooleanExtra(HELPER_NOTIFICATION, false);
+ }
+
+ public static void getArgsAndSendNotificationIntent(SafeIntent intent) {
+ final JSONObject args = new JSONObject();
+ final Uri data = intent.getData();
+
+ final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR);
+
+ try {
+ args.put(ID_ATTR, data.getQueryParameter(ID_ATTR));
+ args.put(EVENT_TYPE_ATTR, notificationType);
+ args.put(HANDLER_ATTR, data.getQueryParameter(HANDLER_ATTR));
+ args.put(COOKIE_ATTR, intent.getStringExtra(COOKIE_ATTR));
+
+ if (BUTTON_EVENT.equals(notificationType)) {
+ final String actionName = data.getQueryParameter(ACTION_ID_ATTR);
+ args.put(ACTION_ID_ATTR, actionName);
+ }
+
+ Log.i(LOGTAG, "Send " + args.toString());
+ GeckoAppShell.notifyObservers("Notification:Event", args.toString());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error building JSON notification arguments.", e);
+ }
+ }
+
+ public void handleNotificationIntent(SafeIntent i) {
+ final Uri data = i.getData();
+ final String notificationType = data.getQueryParameter(EVENT_TYPE_ATTR);
+ final String id = data.getQueryParameter(ID_ATTR);
+ if (id == null || notificationType == null) {
+ Log.e(LOGTAG, "handleNotificationEvent: invalid intent parameters");
+ return;
+ }
+
+ getArgsAndSendNotificationIntent(i);
+
+ // If the notification was clicked, we are closing it. This must be executed after
+ // sending the event to js side because when the notification is canceled no event can be
+ // handled.
+ if (CLICK_EVENT.equals(notificationType) && !i.getBooleanExtra(ONGOING_ATTR, false)) {
+ // The handler and cookie parameters are optional.
+ final String handler = data.getQueryParameter(HANDLER_ATTR);
+ final String cookie = i.getStringExtra(COOKIE_ATTR);
+ hideNotification(id, handler, cookie);
+ }
+ }
+
+ private Uri.Builder getNotificationBuilder(JSONObject message, String type) {
+ Uri.Builder b = new Uri.Builder();
+ b.scheme(NOTIFICATION_SCHEME).appendQueryParameter(EVENT_TYPE_ATTR, type);
+
+ try {
+ final String id = message.getString(ID_ATTR);
+ b.appendQueryParameter(ID_ATTR, id);
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex);
+ }
+
+ try {
+ final String id = message.getString(HANDLER_ATTR);
+ b.appendQueryParameter(HANDLER_ATTR, id);
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Notification doesn't have a handler");
+ }
+
+ return b;
+ }
+
+ private Intent buildNotificationIntent(JSONObject message, Uri.Builder builder) {
+ Intent notificationIntent = new Intent(HELPER_BROADCAST_ACTION);
+ final boolean ongoing = message.optBoolean(ONGOING_ATTR);
+ notificationIntent.putExtra(ONGOING_ATTR, ongoing);
+
+ final Uri dataUri = builder.build();
+ notificationIntent.setData(dataUri);
+ notificationIntent.putExtra(HELPER_NOTIFICATION, true);
+ notificationIntent.putExtra(COOKIE_ATTR, message.optString(COOKIE_ATTR));
+
+ // All intents get routed through the notificationReceiver. That lets us bail if we don't want to start Gecko
+ final ComponentName name = new ComponentName(mContext, GeckoAppShell.getGeckoInterface().getActivity().getClass());
+ notificationIntent.putExtra(ORIGINAL_EXTRA_COMPONENT, name);
+
+ return notificationIntent;
+ }
+
+ private PendingIntent buildNotificationPendingIntent(JSONObject message, String type) {
+ Uri.Builder builder = getNotificationBuilder(message, type);
+ final Intent notificationIntent = buildNotificationIntent(message, builder);
+ return PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ private PendingIntent buildButtonClickPendingIntent(JSONObject message, JSONObject action) {
+ Uri.Builder builder = getNotificationBuilder(message, BUTTON_EVENT);
+ try {
+ // Action name must be in query uri, otherwise buttons pending intents
+ // would be collapsed.
+ if (action.has(ACTION_ID_ATTR)) {
+ builder.appendQueryParameter(ACTION_ID_ATTR, action.getString(ACTION_ID_ATTR));
+ } else {
+ Log.i(LOGTAG, "button event with no name");
+ }
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "buildNotificationPendingIntent, error parsing", ex);
+ }
+ final Intent notificationIntent = buildNotificationIntent(message, builder);
+ PendingIntent res = PendingIntent.getBroadcast(mContext, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ return res;
+ }
+
+ private void showNotification(JSONObject message) {
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext);
+
+ // These attributes are required
+ final String id;
+ try {
+ builder.setContentTitle(message.getString(TITLE_ATTR));
+ builder.setContentText(message.getString(TEXT_ATTR));
+ id = message.getString(ID_ATTR);
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error parsing", ex);
+ return;
+ }
+
+ Uri imageUri = Uri.parse(message.optString(SMALLICON_ATTR));
+ builder.setSmallIcon(BitmapUtils.getResource(mContext, imageUri));
+
+ JSONArray light = message.optJSONArray(LIGHT_ATTR);
+ if (light != null && light.length() == 3) {
+ try {
+ builder.setLights(light.getInt(0),
+ light.getInt(1),
+ light.getInt(2));
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error parsing", ex);
+ }
+ }
+
+ boolean ongoing = message.optBoolean(ONGOING_ATTR);
+ builder.setOngoing(ongoing);
+
+ if (message.has(WHEN_ATTR)) {
+ long when = message.optLong(WHEN_ATTR);
+ builder.setWhen(when);
+ }
+
+ if (message.has(PRIORITY_ATTR)) {
+ int priority = message.optInt(PRIORITY_ATTR);
+ builder.setPriority(priority);
+ }
+
+ if (message.has(LARGE_ICON_ATTR)) {
+ Bitmap b = BitmapUtils.getBitmapFromDataURI(message.optString(LARGE_ICON_ATTR));
+ builder.setLargeIcon(b);
+ }
+
+ if (message.has(PROGRESS_VALUE_ATTR) &&
+ message.has(PROGRESS_MAX_ATTR) &&
+ message.has(PROGRESS_INDETERMINATE_ATTR)) {
+ try {
+ final int progress = message.getInt(PROGRESS_VALUE_ATTR);
+ final int progressMax = message.getInt(PROGRESS_MAX_ATTR);
+ final boolean progressIndeterminate = message.getBoolean(PROGRESS_INDETERMINATE_ATTR);
+ builder.setProgress(progressMax, progress, progressIndeterminate);
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error parsing", ex);
+ }
+ }
+
+ JSONArray actions = message.optJSONArray(ACTIONS_ATTR);
+ if (actions != null) {
+ try {
+ for (int i = 0; i < actions.length(); i++) {
+ JSONObject action = actions.getJSONObject(i);
+ final PendingIntent pending = buildButtonClickPendingIntent(message, action);
+ final String actionTitle = action.getString(ACTION_TITLE_ATTR);
+ final Uri actionImage = Uri.parse(action.optString(ACTION_ICON_ATTR));
+ builder.addAction(BitmapUtils.getResource(mContext, actionImage),
+ actionTitle,
+ pending);
+ }
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error parsing", ex);
+ }
+ }
+
+ PendingIntent pi = buildNotificationPendingIntent(message, CLICK_EVENT);
+ builder.setContentIntent(pi);
+ PendingIntent deletePendingIntent = buildNotificationPendingIntent(message, CLEARED_EVENT);
+ builder.setDeleteIntent(deletePendingIntent);
+
+ ((NotificationClient) GeckoAppShell.getNotificationListener()).add(id, builder.build());
+
+ boolean persistent = message.optBoolean(PERSISTENT_ATTR);
+ // We add only not persistent notifications to the list since we want to purge only
+ // them when geckoapp is destroyed.
+ if (!persistent && !mClearableNotifications.containsKey(id)) {
+ mClearableNotifications.put(id, message.toString());
+ }
+ }
+
+ private void hideNotification(JSONObject message) {
+ final String id;
+ final String handler;
+ final String cookie;
+ try {
+ id = message.getString("id");
+ handler = message.optString("handlerKey");
+ cookie = message.optString("cookie");
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error parsing", ex);
+ return;
+ }
+
+ hideNotification(id, handler, cookie);
+ }
+
+ private void closeNotification(String id, String handlerKey, String cookie) {
+ ((NotificationClient) GeckoAppShell.getNotificationListener()).remove(id);
+ }
+
+ public void hideNotification(String id, String handlerKey, String cookie) {
+ mClearableNotifications.remove(id);
+ closeNotification(id, handlerKey, cookie);
+ }
+
+ private void clearAll() {
+ for (Iterator<String> i = mClearableNotifications.keySet().iterator(); i.hasNext();) {
+ final String id = i.next();
+ final String json = mClearableNotifications.get(id);
+ i.remove();
+
+ JSONObject obj;
+ try {
+ obj = new JSONObject(json);
+ } catch (JSONException ex) {
+ obj = new JSONObject();
+ }
+
+ closeNotification(id, obj.optString(HANDLER_ATTR), obj.optString(COOKIE_ATTR));
+ }
+ }
+
+ public static void destroy() {
+ if (sInstance != null) {
+ sInstance.clearAll();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java
new file mode 100644
index 000000000..c3dd43297
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationReceiver.java
@@ -0,0 +1,106 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.notifications;
+
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Log;
+
+/**
+ * Broadcast receiver for Notifications. Will forward them to GeckoApp (and start Gecko) if they're clicked.
+ * If they're being dismissed, it will not start Gecko, but may forward them to JS if Gecko is running.
+ * This is also the only entry point for notification intents.
+ */
+public class NotificationReceiver extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoNotificationReceiver";
+
+ public void onReceive(Context context, Intent intent) {
+ final Uri data = intent.getData();
+ if (data == null) {
+ Log.e(LOGTAG, "handleNotificationEvent: empty data");
+ return;
+ }
+
+ final String action = intent.getAction();
+ if (NotificationClient.CLICK_ACTION.equals(action) ||
+ NotificationClient.CLOSE_ACTION.equals(action)) {
+ onNotificationClientAction(context, action, data, intent);
+ return;
+ }
+
+ final String notificationType = data.getQueryParameter(NotificationHelper.EVENT_TYPE_ATTR);
+ if (notificationType == null) {
+ return;
+ }
+
+ // In case the user swiped out the notification, we empty the id set.
+ if (NotificationHelper.CLEARED_EVENT.equals(notificationType)) {
+ // If Gecko isn't running, we throw away events where the notification was cancelled.
+ // i.e. Don't bug the user if they're just closing a bunch of notifications.
+ if (GeckoThread.isRunning()) {
+ NotificationHelper.getArgsAndSendNotificationIntent(new SafeIntent(intent));
+ }
+
+ final NotificationClient client = (NotificationClient)
+ GeckoAppShell.getNotificationListener();
+ client.onNotificationClose(data.getQueryParameter(NotificationHelper.ID_ATTR));
+ return;
+ }
+
+ forwardMessageToActivity(intent, context);
+ }
+
+ private void forwardMessageToActivity(final Intent intent, final Context context) {
+ final ComponentName name = intent.getExtras().getParcelable(NotificationHelper.ORIGINAL_EXTRA_COMPONENT);
+ intent.setComponent(name);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ private void onNotificationClientAction(final Context context, final String action,
+ final Uri data, final Intent intent) {
+ final String name = data.getQueryParameter("name");
+ final String cookie = data.getQueryParameter("cookie");
+ final Intent persistentIntent = (Intent)
+ intent.getParcelableExtra(NotificationClient.PERSISTENT_INTENT_EXTRA);
+
+ if (persistentIntent != null) {
+ // Go through GeckoService for persistent notifications.
+ context.startService(persistentIntent);
+ }
+
+ if (NotificationClient.CLICK_ACTION.equals(action)) {
+ GeckoAppShell.onNotificationClick(name, cookie);
+
+ if (persistentIntent != null) {
+ // Don't launch GeckoApp if it's a background persistent notification.
+ return;
+ }
+
+ final Intent appIntent = new Intent(GeckoApp.ACTION_ALERT_CALLBACK);
+ appIntent.setComponent(new ComponentName(
+ data.getAuthority(), data.getPath().substring(1))); // exclude leading slash.
+ appIntent.setData(data);
+ appIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(appIntent);
+
+ } else if (NotificationClient.CLOSE_ACTION.equals(action)) {
+ GeckoAppShell.onNotificationClose(name, cookie);
+
+ final NotificationClient client = (NotificationClient)
+ GeckoAppShell.getNotificationListener();
+ client.onNotificationClose(name);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java
new file mode 100644
index 000000000..04b94cd1a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/NotificationService.java
@@ -0,0 +1,37 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.notifications;
+
+import android.app.Notification;
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+import org.mozilla.gecko.R;
+
+public final class NotificationService extends Service {
+ public static final String EXTRA_NOTIFICATION = "notification";
+
+ @Override // Service
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ final Notification notification = intent.getParcelableExtra(EXTRA_NOTIFICATION);
+ if (notification != null) {
+ // Start foreground notification.
+ startForeground(R.id.foregroundNotification, notification);
+ return START_NOT_STICKY;
+ }
+
+ // Stop foreground notification
+ stopForeground(true);
+ stopSelfResult(startId);
+ return START_NOT_STICKY;
+ }
+
+ @Override // Service
+ public IBinder onBind(final Intent intent) {
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java b/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java
new file mode 100644
index 000000000..6e799bf74
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/notifications/WhatsNewReceiver.java
@@ -0,0 +1,99 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.notifications;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.support.v4.app.NotificationCompat;
+import android.text.TextUtils;
+
+import com.keepsafe.switchboard.SwitchBoard;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.Experiments;
+
+import java.util.Locale;
+
+public class WhatsNewReceiver extends BroadcastReceiver {
+
+ public static final String EXTRA_WHATSNEW_NOTIFICATION = "whatsnew_notification";
+ private static final String ACTION_NOTIFICATION_CANCELLED = "notification_cancelled";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (ACTION_NOTIFICATION_CANCELLED.equals(intent.getAction())) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.NOTIFICATION, EXTRA_WHATSNEW_NOTIFICATION);
+ return;
+ }
+
+ final String dataString = intent.getDataString();
+ if (TextUtils.isEmpty(dataString) || !dataString.contains(AppConstants.ANDROID_PACKAGE_NAME)) {
+ return;
+ }
+
+ if (!SwitchBoard.isInExperiment(context, Experiments.WHATSNEW_NOTIFICATION)) {
+ return;
+ }
+
+ if (!isPreferenceEnabled(context)) {
+ return;
+ }
+
+ showWhatsNewNotification(context);
+ }
+
+ private boolean isPreferenceEnabled(Context context) {
+ return GeckoSharedPrefs.forApp(context).getBoolean(GeckoPreferences.PREFS_NOTIFICATIONS_WHATS_NEW, true);
+ }
+
+ private void showWhatsNewNotification(Context context) {
+ final Notification notification = new NotificationCompat.Builder(context)
+ .setContentTitle(context.getString(R.string.whatsnew_notification_title))
+ .setContentText(context.getString(R.string.whatsnew_notification_summary))
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setAutoCancel(true)
+ .setContentIntent(getContentIntent(context))
+ .setDeleteIntent(getDeleteIntent(context))
+ .build();
+
+ final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ final int notificationID = EXTRA_WHATSNEW_NOTIFICATION.hashCode();
+ notificationManager.notify(notificationID, notification);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.NOTIFICATION, EXTRA_WHATSNEW_NOTIFICATION);
+ }
+
+ private PendingIntent getContentIntent(Context context) {
+ final String link = context.getString(R.string.whatsnew_notification_url,
+ AppConstants.MOZ_APP_VERSION,
+ AppConstants.OS_TARGET,
+ Locales.getLanguageTag(Locale.getDefault()));
+
+ final Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ i.setData(Uri.parse(link));
+ i.putExtra(EXTRA_WHATSNEW_NOTIFICATION, true);
+
+ return PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ private PendingIntent getDeleteIntent(Context context) {
+ final Intent i = new Intent(context, WhatsNewReceiver.class);
+ i.setAction(ACTION_NOTIFICATION_CANCELLED);
+
+ return PendingIntent.getBroadcast(context, 0, i, PendingIntent.FLAG_CANCEL_CURRENT);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java b/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java
new file mode 100644
index 000000000..16f5560d3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/OverlayConstants.java
@@ -0,0 +1,68 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays;
+
+/**
+ * Constants used by the share handler service (and clients).
+ * The intent API used by the service is defined herein.
+ */
+public class OverlayConstants {
+ /*
+ * OverlayIntentHandler service intent actions.
+ */
+
+ /*
+ * Causes the service to broadcast an intent containing state necessary for proper display of
+ * a UI to select a target share method.
+ *
+ * Intent parameters:
+ *
+ * None.
+ */
+ public static final String ACTION_PREPARE_SHARE = "org.mozilla.gecko.overlays.ACTION_PREPARE_SHARE";
+
+ /*
+ * Action for sharing a page.
+ *
+ * Intent parameters:
+ *
+ * $EXTRA_URL: URL of page to share. (required)
+ * $EXTRA_SHARE_METHOD: Method(s) via which to share this url/title combination. Can be either a
+ * ShareType or a ShareType[]
+ * $EXTRA_TITLE: Title of page to share (optional)
+ * $EXTRA_PARAMETERS: Parcelable of extra data to pass to the ShareMethod (optional)
+ */
+ public static final String ACTION_SHARE = "org.mozilla.gecko.overlays.ACTION_SHARE";
+
+ /*
+ * OverlayIntentHandler service intent extra field keys.
+ */
+
+ // The URL/title of the page being shared
+ public static final String EXTRA_URL = "URL";
+ public static final String EXTRA_TITLE = "TITLE";
+
+ // The optional extra Parcelable parameters for a ShareMethod.
+ public static final String EXTRA_PARAMETERS = "EXTRA";
+
+ // The extra field key used for holding the ShareMethod.Type we wish to use for an operation.
+ public static final String EXTRA_SHARE_METHOD = "SHARE_METHOD";
+
+ /*
+ * ShareMethod UI event intent constants. Broadcast by ShareMethods using LocalBroadcastManager
+ * when state has changed that requires an update of any currently-displayed share UI.
+ */
+
+ /*
+ * Action for a ShareMethod UI event.
+ *
+ * Intent parameters:
+ *
+ * $EXTRA_SHARE_METHOD: The ShareType to which this event relates.
+ * ... ShareType-specific parameters as desired... (optional)
+ */
+ public static final String SHARE_METHOD_UI_EVENT = "org.mozilla.gecko.overlays.ACTION_SHARE_METHOD_UI_EVENT";
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java
new file mode 100644
index 000000000..7182fcce7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/OverlayActionService.java
@@ -0,0 +1,126 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.service;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.IBinder;
+import android.util.Log;
+
+import org.mozilla.gecko.overlays.service.sharemethods.AddBookmark;
+import org.mozilla.gecko.overlays.service.sharemethods.SendTab;
+import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_PREPARE_SHARE;
+import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_SHARE;
+
+/**
+ * A service to receive requests from overlays to perform actions.
+ * See OverlayConstants for details of the intent API supported by this service.
+ *
+ * Currently supported operations are:
+ *
+ * Add bookmark*
+ * Send tab (delegates to Sync's existing handler)
+ * Future: Load page in background.
+ *
+ * * Neither of these incur a page fetch on the service... yet. That will require headless Gecko,
+ * something we're yet to have. Refactoring Gecko as a service itself and restructing the rest of
+ * the app to talk to it seems like the way to go there.
+ */
+public class OverlayActionService extends Service {
+ private static final String LOGTAG = "GeckoOverlayService";
+
+ // Map used for selecting the appropriate helper object when handling a share.
+ final Map<ShareMethod.Type, ShareMethod> shareTypes = new EnumMap<>(ShareMethod.Type.class);
+
+ // Map relating Strings representing share types to the corresponding ShareMethods.
+ // Share methods are initialised (and shown in the UI) in the order they are given here.
+ // This map is used to look up the appropriate ShareMethod when handling a request, as well as
+ // for identifying which ShareMethod needs re-initialising in response to such an intent (which
+ // will be necessary in situations such as the deletion of Sync accounts).
+
+ // Not a bindable service.
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent == null) {
+ return START_NOT_STICKY;
+ }
+
+ // Dispatch intent to appropriate method according to its action.
+ String action = intent.getAction();
+
+ switch (action) {
+ case ACTION_SHARE:
+ handleShare(intent);
+ break;
+ case ACTION_PREPARE_SHARE:
+ initShareMethods(getApplicationContext());
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported intent action: " + action);
+ }
+
+ return START_NOT_STICKY;
+ }
+
+ /**
+ * Reinitialise all ShareMethods, causing them to broadcast any UI update events necessary.
+ */
+ private void initShareMethods(final Context context) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ shareTypes.clear();
+
+ shareTypes.put(ShareMethod.Type.ADD_BOOKMARK, new AddBookmark(context));
+ shareTypes.put(ShareMethod.Type.SEND_TAB, new SendTab(context));
+ }
+ });
+ }
+
+ public void handleShare(final Intent intent) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ ShareData shareData;
+ try {
+ shareData = ShareData.fromIntent(intent);
+ } catch (IllegalArgumentException e) {
+ Log.e(LOGTAG, "Error parsing share intent: ", e);
+ return;
+ }
+
+ ShareMethod shareMethod = shareTypes.get(shareData.shareMethodType);
+
+ final ShareMethod.Result result = shareMethod.handle(shareData);
+ // Dispatch the share to the targeted ShareMethod.
+ switch (result) {
+ case SUCCESS:
+ Log.d(LOGTAG, "Share was successful");
+ break;
+ case TRANSIENT_FAILURE:
+ // Fall-through
+ case PERMANENT_FAILURE:
+ Log.e(LOGTAG, "Share failed: " + result);
+ break;
+ default:
+ throw new IllegalStateException("Unknown share method result code: " + result);
+ }
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java
new file mode 100644
index 000000000..df233d74a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/ShareData.java
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.service;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Parcelable;
+import org.mozilla.gecko.overlays.OverlayConstants;
+import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod;
+
+import static org.mozilla.gecko.overlays.OverlayConstants.EXTRA_SHARE_METHOD;
+
+/**
+ * Class to hold information related to a particular request to perform a share.
+ */
+public class ShareData {
+ private static final String LOGTAG = "GeckoShareRequest";
+
+ public final String url;
+ public final String title;
+ public final Parcelable extra;
+ public final ShareMethod.Type shareMethodType;
+
+ public ShareData(String url, String title, Parcelable extra, ShareMethod.Type shareMethodType) {
+ if (url == null) {
+ throw new IllegalArgumentException("Null url passed to ShareData!");
+ }
+
+ this.url = url;
+ this.title = title;
+ this.extra = extra;
+ this.shareMethodType = shareMethodType;
+ }
+
+ public static ShareData fromIntent(Intent intent) {
+ Bundle extras = intent.getExtras();
+
+ // Fish the parameters out of the Intent.
+ final String url = extras.getString(OverlayConstants.EXTRA_URL);
+ final String title = extras.getString(OverlayConstants.EXTRA_TITLE);
+ final Parcelable extra = extras.getParcelable(OverlayConstants.EXTRA_PARAMETERS);
+ ShareMethod.Type shareMethodType = (ShareMethod.Type) extras.get(EXTRA_SHARE_METHOD);
+
+ return new ShareData(url, title, extra, shareMethodType);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java
new file mode 100644
index 000000000..71931e683
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/AddBookmark.java
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.service.sharemethods;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.overlays.service.ShareData;
+
+public class AddBookmark extends ShareMethod {
+ private static final String LOGTAG = "GeckoAddBookmark";
+
+ @Override
+ public Result handle(ShareData shareData) {
+ ContentResolver resolver = context.getContentResolver();
+
+ LocalBrowserDB browserDB = new LocalBrowserDB(GeckoProfile.DEFAULT_PROFILE);
+ browserDB.addBookmark(resolver, shareData.title, shareData.url);
+
+ return Result.SUCCESS;
+ }
+
+ public AddBookmark(Context context) {
+ super(context);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java
new file mode 100644
index 000000000..5abcbd99f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/SendTab.java
@@ -0,0 +1,296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.service.sharemethods;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.db.TabsAccessor;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.overlays.OverlayConstants;
+import org.mozilla.gecko.overlays.service.ShareData;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.CommandRunner;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.SyncConfiguration;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * ShareMethod implementation to handle Sync's "Send tab to device" mechanism.
+ * See OverlayConstants for documentation of OverlayIntentHandler service intent API (which is how
+ * this class is chiefly interacted with).
+ */
+public class SendTab extends ShareMethod {
+ private static final String LOGTAG = "GeckoSendTab";
+
+ // Key used in the extras Bundle in the share intent used for a send tab ShareMethod.
+ public static final String SEND_TAB_TARGET_DEVICES = "SEND_TAB_TARGET_DEVICES";
+
+ // Key used in broadcast intent from SendTab ShareMethod specifying available RemoteClients.
+ public static final String EXTRA_REMOTE_CLIENT_RECORDS = "RECORDS";
+
+ // The intent we should dispatch when the button for this ShareMethod is tapped, instead of
+ // taking the normal action (e.g., "Set up Sync!")
+ public static final String OVERRIDE_INTENT = "OVERRIDE_INTENT";
+
+ private Set<String> validGUIDs;
+
+ // A TabSender appropriate to the account type we're connected to.
+ private TabSender tabSender;
+
+ @Override
+ public Result handle(ShareData shareData) {
+ if (shareData.extra == null) {
+ Log.e(LOGTAG, "No target devices specified!");
+
+ // Retrying with an identical lack of devices ain't gonna fix it...
+ return Result.PERMANENT_FAILURE;
+ }
+
+ String[] targetGUIDs = ((Bundle) shareData.extra).getStringArray(SEND_TAB_TARGET_DEVICES);
+
+ // Ensure all target GUIDs are devices we actually know about.
+ if (!validGUIDs.containsAll(Arrays.asList(targetGUIDs))) {
+ // Find the set of invalid GUIDs to provide a nice error message.
+ Log.e(LOGTAG, "Not all provided GUIDs are real devices:");
+ for (String targetGUID : targetGUIDs) {
+ if (!validGUIDs.contains(targetGUID)) {
+ Log.e(LOGTAG, "Invalid GUID: " + targetGUID);
+ }
+ }
+
+ return Result.PERMANENT_FAILURE;
+ }
+
+ Log.i(LOGTAG, "Send tab handler invoked.");
+
+ final CommandProcessor processor = CommandProcessor.getProcessor();
+
+ final String accountGUID = tabSender.getAccountGUID();
+ Log.d(LOGTAG, "Retrieved local account GUID '" + accountGUID + "'.");
+
+ if (accountGUID == null) {
+ Log.e(LOGTAG, "Cannot determine account GUID");
+
+ // It's not completely out of the question that a background sync might come along and
+ // fix everything for us...
+ return Result.TRANSIENT_FAILURE;
+ }
+
+ // Queue up the share commands for each destination device.
+ // Remember that ShareMethod.handle is always run on the background thread, so the database
+ // access here is of no concern.
+ for (int i = 0; i < targetGUIDs.length; i++) {
+ processor.sendURIToClientForDisplay(shareData.url, targetGUIDs[i], shareData.title, accountGUID, context);
+ }
+
+ // Request an immediate sync to push these new commands to the network ASAP.
+ Log.i(LOGTAG, "Requesting immediate clients stage sync.");
+ tabSender.sync();
+
+ return Result.SUCCESS;
+ // ... Probably.
+ }
+
+ /**
+ * Get an Intent suitable for broadcasting the UI state of this ShareMethod.
+ * The caller shall populate the intent with the actual state.
+ */
+ private Intent getUIStateIntent() {
+ Intent uiStateIntent = new Intent(OverlayConstants.SHARE_METHOD_UI_EVENT);
+ uiStateIntent.putExtra(OverlayConstants.EXTRA_SHARE_METHOD, (Parcelable) Type.SEND_TAB);
+ return uiStateIntent;
+ }
+
+ /**
+ * Broadcast the given intent to any UIs that may be listening.
+ */
+ private void broadcastUIState(Intent uiStateIntent) {
+ LocalBroadcastManager.getInstance(context).sendBroadcast(uiStateIntent);
+ }
+
+ /**
+ * Load the state of the user's Firefox Sync accounts and broadcast it to any registered
+ * listeners. This will cause any UIs that may exist that depend on this information to update.
+ */
+ public SendTab(Context aContext) {
+ super(aContext);
+ // Initialise the UI state intent...
+
+ // Determine if the user has a new or old style sync account and load the available sync
+ // clients for it.
+ final AccountManager accountManager = AccountManager.get(context);
+ final Account[] fxAccounts = accountManager.getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+
+ if (fxAccounts.length > 0) {
+ final AndroidFxAccount fxAccount = new AndroidFxAccount(context, fxAccounts[0]);
+ if (fxAccount.getState().getNeededAction() != State.Action.None) {
+ // We have a Firefox Account, but it's definitely not able to send a tab
+ // right now. Redirect to the status activity.
+ Log.w(LOGTAG, "Firefox Account named like " + fxAccount.getObfuscatedEmail() +
+ " needs action before it can send a tab; redirecting to status activity.");
+
+ setOverrideIntentAction(FxAccountConstants.ACTION_FXA_STATUS);
+ return;
+ }
+
+ tabSender = new FxAccountTabSender(fxAccount);
+
+ updateClientList(tabSender);
+
+ Log.i(LOGTAG, "Allowing tab send for Firefox Account.");
+ registerDisplayURICommand();
+ return;
+ }
+
+ // Have registered UIs offer to set up a Firefox Account.
+ setOverrideIntentAction(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ }
+
+ /**
+ * Load the list of Sync clients that are not this device using the given TabSender.
+ */
+ private void updateClientList(TabSender tabSender) {
+ Collection<RemoteClient> otherClients = getOtherClients(tabSender);
+
+ // Put the list of RemoteClients into the uiStateIntent and broadcast it.
+ RemoteClient[] records = new RemoteClient[otherClients.size()];
+ records = otherClients.toArray(records);
+
+ validGUIDs = new HashSet<>();
+
+ for (RemoteClient client : otherClients) {
+ validGUIDs.add(client.guid);
+ }
+
+ if (validGUIDs.isEmpty()) {
+ // Guess we'd better override. We have no clients.
+ // This does the broadcast for us.
+ setOverrideIntentAction(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ return;
+ }
+
+ Intent uiStateIntent = getUIStateIntent();
+ uiStateIntent.putExtra(EXTRA_REMOTE_CLIENT_RECORDS, records);
+ broadcastUIState(uiStateIntent);
+ }
+
+ /**
+ * Record our intention to redirect the user to a different activity when they attempt to share
+ * with us, usually because we found something wrong with their Sync account (a need to login,
+ * register, etc.)
+ * This will be recorded in the OVERRIDE_INTENT field of the UI broadcast. Consumers should
+ * dispatch this intent instead of attempting to share with this ShareMethod whenever it is
+ * non-null.
+ *
+ * @param action to launch instead of invoking a share.
+ */
+ protected void setOverrideIntentAction(final String action) {
+ Intent intent = new Intent(action);
+ // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+ // the soft keyboard not being shown for the started activity. Why, Android, why?
+ intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ Intent uiStateIntent = getUIStateIntent();
+ uiStateIntent.putExtra(OVERRIDE_INTENT, intent);
+
+ broadcastUIState(uiStateIntent);
+ }
+
+ private static void registerDisplayURICommand() {
+ final CommandProcessor processor = CommandProcessor.getProcessor();
+ processor.registerCommand("displayURI", new CommandRunner(3) {
+ @Override
+ public void executeCommand(final GlobalSession session, List<String> args) {
+ CommandProcessor.displayURI(args, session.getContext());
+ }
+ });
+ }
+
+ /**
+ * @return A collection of unique remote clients sorted by most recently used.
+ */
+ protected Collection<RemoteClient> getOtherClients(final TabSender sender) {
+ if (sender == null) {
+ Log.w(LOGTAG, "No tab sender when fetching other client IDs.");
+ return Collections.emptyList();
+ }
+
+ final BrowserDB browserDB = BrowserDB.from(context);
+ final TabsAccessor tabsAccessor = browserDB.getTabsAccessor();
+ final Cursor remoteTabsCursor = tabsAccessor.getRemoteClientsByRecencyCursor(context);
+ try {
+ if (remoteTabsCursor.getCount() == 0) {
+ return Collections.emptyList();
+ }
+ return tabsAccessor.getClientsWithoutTabsByRecencyFromCursor(remoteTabsCursor);
+ } finally {
+ remoteTabsCursor.close();
+ }
+ }
+
+ /**
+ * Inteface for interacting with Sync accounts. Used to hide the difference in implementation
+ * between FXA and "old sync" accounts when sending tabs.
+ */
+ private interface TabSender {
+ public static final String[] STAGES_TO_SYNC = new String[] { "clients", "tabs" };
+
+ /**
+ * @return Return null if the account isn't correctly initialized. Return
+ * the account GUID otherwise.
+ */
+ String getAccountGUID();
+
+ /**
+ * Sync this account, specifying only clients and tabs as the engines to sync.
+ */
+ void sync();
+ }
+
+ private static class FxAccountTabSender implements TabSender {
+ private final AndroidFxAccount fxAccount;
+
+ public FxAccountTabSender(AndroidFxAccount fxa) {
+ fxAccount = fxa;
+ }
+
+ @Override
+ public String getAccountGUID() {
+ try {
+ final SharedPreferences prefs = fxAccount.getSyncPrefs();
+ return prefs.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null);
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Could not get Firefox Account parameters or preferences; aborting.");
+ return null;
+ }
+ }
+
+ @Override
+ public void sync() {
+ fxAccount.requestImmediateSync(STAGES_TO_SYNC, null);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java
new file mode 100644
index 000000000..768176d63
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/service/sharemethods/ShareMethod.java
@@ -0,0 +1,82 @@
+/*This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.service.sharemethods;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.mozilla.gecko.overlays.service.ShareData;
+
+/**
+ * Represents a method of sharing a URL/title. Add a bookmark? Send to a device? Add to reading list?
+ */
+public abstract class ShareMethod {
+ protected final Context context;
+
+ public ShareMethod(Context aContext) {
+ context = aContext;
+ }
+
+ /**
+ * Perform a share for the given title/URL combination. Called on the background thread by the
+ * handler service when a request is made. The "extra" parameter is provided should a ShareMethod
+ * desire to handle the share differently based on some additional parameters.
+ *
+ * @param title The page title for the page being shared. May be null if none can be found.
+ * @param url The URL of the page to be shared. Never null.
+ * @param extra A Parcelable of ShareMethod-specific parameters that may be provided by the
+ * caller. Generally null, but this field may be used to provide extra input to
+ * the ShareMethod (such as the device to share to in the case of SendTab).
+ * @return true if the attempt to share was a success. False in the event of an error.
+ */
+ public abstract Result handle(ShareData shareData);
+
+ /**
+ * Enum representing the possible results of performing a share.
+ */
+ public static enum Result {
+ // Victory!
+ SUCCESS,
+
+ // Failure, but retrying the same action again might lead to success.
+ TRANSIENT_FAILURE,
+
+ // Failure, and you're not going to succeed until you reinitialise the ShareMethod (ie.
+ // until you repeat the entire share action). Examples include broken Sync accounts, or
+ // Sync accounts with no valid target devices (so the only way to fix this is to add some
+ // and try again: pushing a retry button isn't sane).
+ PERMANENT_FAILURE
+ }
+
+ /**
+ * Enum representing types of ShareMethod. Parcelable so it may be efficiently used in Intents.
+ */
+ public static enum Type implements Parcelable {
+ ADD_BOOKMARK,
+ SEND_TAB;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(ordinal());
+ }
+
+ public static final Creator<Type> CREATOR = new Creator<Type>() {
+ @Override
+ public Type createFromParcel(final Parcel source) {
+ return Type.values()[source.readInt()];
+ }
+
+ @Override
+ public Type[] newArray(final int size) {
+ return new Type[size];
+ }
+ };
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java
new file mode 100644
index 000000000..8b7bc872b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/OverlayDialogButton.java
@@ -0,0 +1,128 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.ui;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/**
+ * A button in the share overlay, such as the "Add to Reading List" button.
+ * Has an associated icon and label, and two states: enabled and disabled.
+ *
+ * When disabled, tapping results in a "pop" animation causing the icon to pulse. When enabled,
+ * tapping calls the OnClickListener set by the consumer in the usual way.
+ */
+public class OverlayDialogButton extends LinearLayout {
+ private static final String LOGTAG = "GeckoOverlayDialogButton";
+
+ // We can't use super.isEnabled(), since we want to stay clickable in disabled state.
+ private boolean isEnabled = true;
+
+ private final ImageView iconView;
+ private final TextView labelView;
+
+ private String enabledText = "";
+ private String disabledText = "";
+
+ private OnClickListener enabledOnClickListener;
+
+ public OverlayDialogButton(Context context) {
+ this(context, null);
+ }
+
+ public OverlayDialogButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setOrientation(LinearLayout.HORIZONTAL);
+
+ LayoutInflater.from(context).inflate(R.layout.overlay_share_button, this);
+
+ iconView = (ImageView) findViewById(R.id.overlaybtn_icon);
+ labelView = (TextView) findViewById(R.id.overlaybtn_label);
+
+ super.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+
+ if (isEnabled) {
+ if (enabledOnClickListener != null) {
+ enabledOnClickListener.onClick(v);
+ } else {
+ Log.e(LOGTAG, "enabledOnClickListener is null.");
+ }
+ } else {
+ Animation anim = AnimationUtils.loadAnimation(getContext(), R.anim.overlay_pop);
+ iconView.startAnimation(anim);
+ }
+ }
+ });
+
+ final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.OverlayDialogButton);
+
+ Drawable drawable = typedArray.getDrawable(R.styleable.OverlayDialogButton_drawable);
+ if (drawable != null) {
+ setDrawable(drawable);
+ }
+
+ String disabledText = typedArray.getString(R.styleable.OverlayDialogButton_disabledText);
+ if (disabledText != null) {
+ this.disabledText = disabledText;
+ }
+
+ String enabledText = typedArray.getString(R.styleable.OverlayDialogButton_enabledText);
+ if (enabledText != null) {
+ this.enabledText = enabledText;
+ }
+
+ typedArray.recycle();
+
+ setEnabled(true);
+ }
+
+ public void setDrawable(Drawable drawable) {
+ iconView.setImageDrawable(drawable);
+ }
+
+ public void setText(String text) {
+ labelView.setText(text);
+ }
+
+ @Override
+ public void setOnClickListener(OnClickListener listener) {
+ enabledOnClickListener = listener;
+ }
+
+ /**
+ * Set the enabledness state of this view. We don't call super.setEnabled, as we want to remain
+ * clickable even in the disabled state (but with a different click listener).
+ */
+ @Override
+ public void setEnabled(boolean enabled) {
+ isEnabled = enabled;
+ iconView.setEnabled(enabled);
+ labelView.setEnabled(enabled);
+
+ if (enabled) {
+ setText(enabledText);
+ } else {
+ setText(disabledText);
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java
new file mode 100644
index 000000000..08e9c59f5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabDeviceListArrayAdapter.java
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.ui;
+
+import java.util.Collection;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.overlays.ui.SendTabList.State;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+
+public class SendTabDeviceListArrayAdapter extends ArrayAdapter<RemoteClient> {
+ @SuppressWarnings("unused")
+ private static final String LOGTAG = "GeckoSendTabAdapter";
+
+ private State currentState;
+
+ // String to display when in a "button-like" special state. Instead of using a
+ // RemoteClient we override the rendering using this string.
+ private String dummyRecordName;
+
+ private final SendTabTargetSelectedListener listener;
+
+ private Collection<RemoteClient> records;
+
+ // The AlertDialog to show in the event the record is pressed while in the SHOW_DEVICES state.
+ // This will show the user a prompt to select a device from a longer list of devices.
+ private AlertDialog dialog;
+
+ public SendTabDeviceListArrayAdapter(Context context, SendTabTargetSelectedListener aListener) {
+ super(context, R.layout.overlay_share_send_tab_item, R.id.overlaybtn_label);
+
+ listener = aListener;
+
+ // We do this manually and avoid multiple notifications when doing compound operations.
+ setNotifyOnChange(false);
+ }
+
+ /**
+ * Get an array of the contents of this adapter were it in the LIST state.
+ * Useful for determining the "real" contents of the adapter.
+ */
+ public RemoteClient[] toArray() {
+ return records.toArray(new RemoteClient[records.size()]);
+ }
+
+ public void setRemoteClientsList(Collection<RemoteClient> remoteClientsList) {
+ records = remoteClientsList;
+ updateRecordList();
+ }
+
+ /**
+ * Ensure the contents of the Adapter are synchronised with the `records` field. This may not
+ * be the case if records has recently changed, or if we have experienced a state change.
+ */
+ public void updateRecordList() {
+ if (currentState != State.LIST) {
+ return;
+ }
+
+ clear();
+
+ setNotifyOnChange(false); // So we don't notify for each add.
+ addAll(records);
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, View convertView, ViewGroup parent) {
+ final Context context = getContext();
+
+ // Reuse View objects if they exist.
+ OverlayDialogButton row = (OverlayDialogButton) convertView;
+ if (row == null) {
+ row = (OverlayDialogButton) View.inflate(context, R.layout.overlay_share_send_tab_item, null);
+ }
+
+ // The first view in the list has a unique style.
+ if (position == 0) {
+ row.setBackgroundResource(R.drawable.overlay_share_button_background_first);
+ } else {
+ row.setBackgroundResource(R.drawable.overlay_share_button_background);
+ }
+
+ if (currentState != State.LIST) {
+ // If we're in a special "Button-like" state, use the override string and a generic icon.
+ final Drawable sendTabIcon = context.getResources().getDrawable(R.drawable.shareplane);
+ row.setText(dummyRecordName);
+ row.setDrawable(sendTabIcon);
+ }
+
+ // If we're just a button to launch the dialog, set the listener and abort.
+ if (currentState == State.SHOW_DEVICES) {
+ row.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ dialog.show();
+ }
+ });
+
+ return row;
+ }
+
+ // The remaining states delegate to the SentTabTargetSelectedListener.
+ final RemoteClient remoteClient = getItem(position);
+ if (currentState == State.LIST) {
+ final Drawable clientIcon = context.getResources().getDrawable(getImage(remoteClient));
+ row.setText(remoteClient.name);
+ row.setDrawable(clientIcon);
+
+ final String listenerGUID = remoteClient.guid;
+
+ row.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ listener.onSendTabTargetSelected(listenerGUID);
+ }
+ });
+ } else {
+ row.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ listener.onSendTabActionSelected();
+ }
+ });
+ }
+
+ return row;
+ }
+
+ private static int getImage(RemoteClient record) {
+ if ("mobile".equals(record.deviceType)) {
+ return R.drawable.device_mobile;
+ }
+
+ return R.drawable.device_desktop;
+ }
+
+ public void switchState(State newState) {
+ if (currentState == newState) {
+ return;
+ }
+
+ currentState = newState;
+
+ switch (newState) {
+ case LIST:
+ updateRecordList();
+ break;
+ case NONE:
+ showDummyRecord(getContext().getResources().getString(R.string.overlay_share_send_tab_btn_label));
+ break;
+ case SHOW_DEVICES:
+ showDummyRecord(getContext().getResources().getString(R.string.overlay_share_send_other));
+ break;
+ default:
+ throw new IllegalStateException("Unexpected state transition: " + newState);
+ }
+ }
+
+ /**
+ * Set the dummy override string to the given value and clear the list.
+ */
+ private void showDummyRecord(String name) {
+ dummyRecordName = name;
+ clear();
+ add(null);
+ notifyDataSetChanged();
+ }
+
+ public void setDialog(AlertDialog aDialog) {
+ dialog = aDialog;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java
new file mode 100644
index 000000000..4fc6caaa9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabList.java
@@ -0,0 +1,150 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.ui;
+
+import static org.mozilla.gecko.overlays.ui.SendTabList.State.LOADING;
+import static org.mozilla.gecko.overlays.ui.SendTabList.State.SHOW_DEVICES;
+
+import java.util.Arrays;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.RemoteClient;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.util.AttributeSet;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+
+/**
+ * The SendTab button has a few different states depending on the available devices (and whether
+ * we've loaded them yet...)
+ *
+ * Initially, the view resembles a disabled button. (the LOADING state)
+ * Once state is loaded from Sync's database, we know how many devices the user may send their tab
+ * to.
+ *
+ * If there are no targets, the user was found to not have a Sync account, or their Sync account is
+ * in a state that prevents it from being able to send a tab, we enter the NONE state and display
+ * a generic button which launches an appropriate activity to fix the situation when tapped (such
+ * as the set up Sync wizard).
+ *
+ * If the number of targets does not MAX_INLINE_SYNC_TARGETS, we present a button for each of them.
+ * (the LIST state)
+ *
+ * Otherwise, we enter the SHOW_DEVICES state, in which we display a "Send to other devices" button
+ * that takes the user to a menu for selecting a target device from their complete list of many
+ * devices.
+ */
+public class SendTabList extends ListView {
+ @SuppressWarnings("unused")
+ private static final String LOGTAG = "GeckoSendTabList";
+
+ // The maximum number of target devices to show in the main list. Further devices are available
+ // from a secondary menu.
+ public static final int MAXIMUM_INLINE_ELEMENTS = R.integer.number_of_inline_share_devices;
+
+ private SendTabDeviceListArrayAdapter clientListAdapter;
+
+ // Listener to fire when a share target is selected (either directly or via the prompt)
+ private SendTabTargetSelectedListener listener;
+
+ private final State currentState = LOADING;
+
+ /**
+ * Enum defining the states this view may occupy.
+ */
+ public enum State {
+ // State when no sync targets exist (a generic "Send to Firefox Sync" button which launches
+ // an activity to set it up)
+ NONE,
+
+ // As NONE, but disabled. Initial state. Used until we get information from Sync about what
+ // we really want.
+ LOADING,
+
+ // A list of devices to share to.
+ LIST,
+
+ // A single button prompting the user to select a device to share to.
+ SHOW_DEVICES
+ }
+
+ public SendTabList(Context context) {
+ super(context);
+ }
+
+ public SendTabList(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void setAdapter(ListAdapter adapter) {
+ if (!(adapter instanceof SendTabDeviceListArrayAdapter)) {
+ throw new IllegalArgumentException("adapter must be a SendTabDeviceListArrayAdapter instance");
+ }
+
+ clientListAdapter = (SendTabDeviceListArrayAdapter) adapter;
+ super.setAdapter(adapter);
+ }
+
+ public void setSendTabTargetSelectedListener(SendTabTargetSelectedListener aListener) {
+ listener = aListener;
+ }
+
+ public void switchState(State state) {
+ if (state == currentState) {
+ return;
+ }
+
+ clientListAdapter.switchState(state);
+ if (state == SHOW_DEVICES) {
+ clientListAdapter.setDialog(getDialog());
+ }
+ }
+
+ public void setSyncClients(final RemoteClient[] c) {
+ final RemoteClient[] clients = c == null ? new RemoteClient[0] : c;
+
+ clientListAdapter.setRemoteClientsList(Arrays.asList(clients));
+ }
+
+ /**
+ * Get an AlertDialog listing all devices, allowing the user to select the one they want.
+ * Used when more than MAXIMUM_INLINE_ELEMENTS devices are found (to avoid displaying them all
+ * inline and looking crazy).
+ */
+ public AlertDialog getDialog() {
+ final Context context = getContext();
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+
+ final RemoteClient[] records = clientListAdapter.toArray();
+ final String[] dialogElements = new String[records.length];
+
+ for (int i = 0; i < records.length; i++) {
+ dialogElements[i] = records[i].name;
+ }
+
+ builder.setTitle(R.string.overlay_share_select_device)
+ .setItems(dialogElements, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int index) {
+ listener.onSendTabTargetSelected(records[index].guid);
+ }
+ })
+ .setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialogInterface) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY, "device_selection_cancel");
+ }
+ });
+
+ return builder.create();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java
new file mode 100644
index 000000000..79da526da
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/SendTabTargetSelectedListener.java
@@ -0,0 +1,25 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.mozilla.gecko.overlays.ui;
+
+/**
+ * Interface for classes that wish to listen for the selection of an element from a SendTabList.
+ */
+public interface SendTabTargetSelectedListener {
+ /**
+ * Called when a row in the SendTabList is clicked.
+ *
+ * @param targetGUID The GUID of the ClientRecord the element represents (if any, otherwise null)
+ */
+ public void onSendTabTargetSelected(String targetGUID);
+
+ /**
+ * Called when the overall Send Tab item is clicked.
+ *
+ * This implies that the clients list was unavailable.
+ */
+ public void onSendTabActionSelected();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java
new file mode 100644
index 000000000..156fdda2a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/overlays/ui/ShareDialog.java
@@ -0,0 +1,493 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.ui;
+
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.db.RemoteClient;
+import org.mozilla.gecko.overlays.OverlayConstants;
+import org.mozilla.gecko.overlays.service.OverlayActionService;
+import org.mozilla.gecko.overlays.service.sharemethods.SendTab;
+import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod;
+import org.mozilla.gecko.sync.setup.activities.WebURLFinder;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.animation.AnimationSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.TextView;
+import android.widget.Toast;
+
+/**
+ * A transparent activity that displays the share overlay.
+ */
+public class ShareDialog extends Locales.LocaleAwareActivity implements SendTabTargetSelectedListener {
+
+ private enum State {
+ DEFAULT,
+ DEVICES_ONLY // Only display the device list.
+ }
+
+ private static final String LOGTAG = "GeckoShareDialog";
+
+ /** Flag to indicate that we should always show the device list; specific to this release channel. **/
+ public static final String INTENT_EXTRA_DEVICES_ONLY =
+ AppConstants.ANDROID_PACKAGE_NAME + ".intent.extra.DEVICES_ONLY";
+
+ /** The maximum number of devices we'll show in the dialog when in State.DEFAULT. **/
+ private static final int MAXIMUM_INLINE_DEVICES = 2;
+
+ private State state;
+
+ private SendTabList sendTabList;
+ private OverlayDialogButton bookmarkButton;
+
+ // The bookmark button drawable set from XML - we need this to reset state.
+ private Drawable bookmarkButtonDrawable;
+
+ private String url;
+ private String title;
+
+ // The override intent specified by SendTab (if any). See SendTab.java.
+ private Intent sendTabOverrideIntent;
+
+ // Flag set during animation to prevent animation multiple-start.
+ private boolean isAnimating;
+
+ // BroadcastReceiver to receive callbacks from ShareMethods which are changing state.
+ private final BroadcastReceiver uiEventListener = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ ShareMethod.Type originShareMethod = intent.getParcelableExtra(OverlayConstants.EXTRA_SHARE_METHOD);
+ switch (originShareMethod) {
+ case SEND_TAB:
+ handleSendTabUIEvent(intent);
+ break;
+ default:
+ throw new IllegalArgumentException("UIEvent broadcast from ShareMethod that isn't thought to support such broadcasts.");
+ }
+ }
+ };
+
+ /**
+ * Called when a UI event broadcast is received from the SendTab ShareMethod.
+ */
+ protected void handleSendTabUIEvent(Intent intent) {
+ sendTabOverrideIntent = intent.getParcelableExtra(SendTab.OVERRIDE_INTENT);
+
+ RemoteClient[] remoteClientRecords = (RemoteClient[]) intent.getParcelableArrayExtra(SendTab.EXTRA_REMOTE_CLIENT_RECORDS);
+
+ // Escape hatch: we don't show the option to open this dialog in this state so this should
+ // never be run. However, due to potential inconsistencies in synced client state
+ // (e.g. bug 1122302 comment 47), we might fail.
+ if (state == State.DEVICES_ONLY &&
+ (remoteClientRecords == null || remoteClientRecords.length == 0)) {
+ Log.e(LOGTAG, "In state: " + State.DEVICES_ONLY + " and received 0 synced clients. Finishing...");
+ Toast.makeText(this, getResources().getText(R.string.overlay_no_synced_devices), Toast.LENGTH_SHORT)
+ .show();
+ finish();
+ return;
+ }
+
+ sendTabList.setSyncClients(remoteClientRecords);
+
+ if (state == State.DEVICES_ONLY ||
+ remoteClientRecords == null ||
+ remoteClientRecords.length <= MAXIMUM_INLINE_DEVICES) {
+ // Show the list of devices in-line.
+ sendTabList.switchState(SendTabList.State.LIST);
+
+ // The first item in the list has a unique style. If there are no items
+ // in the list, the next button appears to be the first item in the list.
+ //
+ // Note: a more thorough implementation would add this
+ // (and other non-ListView buttons) into a custom ListView.
+ if (remoteClientRecords == null || remoteClientRecords.length == 0) {
+ bookmarkButton.setBackgroundResource(
+ R.drawable.overlay_share_button_background_first);
+ }
+ return;
+ }
+
+ // Just show a button to launch the list of devices to choose from.
+ sendTabList.switchState(SendTabList.State.SHOW_DEVICES);
+ }
+
+ @Override
+ protected void onDestroy() {
+ // Remove the listener when the activity is destroyed: we no longer care.
+ // Note: The activity can be destroyed without onDestroy being called. However, this occurs
+ // only when the application is killed, something which also kills the registered receiver
+ // list, and the service, and everything else: so we don't care.
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(uiEventListener);
+
+ super.onDestroy();
+ }
+
+ /**
+ * Show a toast indicating we were started with no URL, and then stop.
+ */
+ private void abortDueToNoURL() {
+ Log.e(LOGTAG, "Unable to process shared intent. No URL found!");
+
+ // Display toast notifying the user of failure (most likely a developer who screwed up
+ // trying to send a share intent).
+ Toast toast = Toast.makeText(this, getResources().getText(R.string.overlay_share_no_url), Toast.LENGTH_SHORT);
+ toast.show();
+ finish();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.overlay_share_dialog);
+
+ LocalBroadcastManager.getInstance(this).registerReceiver(uiEventListener,
+ new IntentFilter(OverlayConstants.SHARE_METHOD_UI_EVENT));
+
+ // Send tab.
+ sendTabList = (SendTabList) findViewById(R.id.overlay_send_tab_btn);
+
+ // Register ourselves as both the listener and the context for the Adapter.
+ final SendTabDeviceListArrayAdapter adapter = new SendTabDeviceListArrayAdapter(this, this);
+ sendTabList.setAdapter(adapter);
+ sendTabList.setSendTabTargetSelectedListener(this);
+
+ bookmarkButton = (OverlayDialogButton) findViewById(R.id.overlay_share_bookmark_btn);
+
+ bookmarkButtonDrawable = bookmarkButton.getBackground();
+
+ // Bookmark button
+ bookmarkButton = (OverlayDialogButton) findViewById(R.id.overlay_share_bookmark_btn);
+ bookmarkButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ addBookmark();
+ }
+ });
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ final Intent intent = getIntent();
+
+ state = intent.getBooleanExtra(INTENT_EXTRA_DEVICES_ONLY, false) ?
+ State.DEVICES_ONLY : State.DEFAULT;
+
+ // If the Activity is being reused, we need to reset the state. Ideally, we create a
+ // new instance for each call, but Android L breaks this (bug 1137928).
+ sendTabList.switchState(SendTabList.State.LOADING);
+ bookmarkButton.setBackgroundDrawable(bookmarkButtonDrawable);
+
+ // The URL is usually hiding somewhere in the extra text. Extract it.
+ final String extraText = IntentUtils.getStringExtraSafe(intent, Intent.EXTRA_TEXT);
+ if (TextUtils.isEmpty(extraText)) {
+ abortDueToNoURL();
+ return;
+ }
+
+ final String pageUrl = new WebURLFinder(extraText).bestWebURL();
+ if (TextUtils.isEmpty(pageUrl)) {
+ abortDueToNoURL();
+ return;
+ }
+
+ // Have the service start any initialisation work that's necessary for us to show the correct
+ // UI. The results of such work will come in via the BroadcastListener.
+ Intent serviceStartupIntent = new Intent(this, OverlayActionService.class);
+ serviceStartupIntent.setAction(OverlayConstants.ACTION_PREPARE_SHARE);
+ startService(serviceStartupIntent);
+
+ // Start the slide-up animation.
+ getWindow().setWindowAnimations(0);
+ final Animation anim = AnimationUtils.loadAnimation(this, R.anim.overlay_slide_up);
+ findViewById(R.id.sharedialog).startAnimation(anim);
+
+ // If provided, we use the subject text to give us something nice to display.
+ // If not, we wing it with the URL.
+
+ // TODO: Consider polling Fennec databases to find better information to display.
+ final String subjectText = intent.getStringExtra(Intent.EXTRA_SUBJECT);
+
+ final String telemetryExtras = "title=" + (subjectText != null);
+ if (subjectText != null) {
+ ((TextView) findViewById(R.id.title)).setText(subjectText);
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.SHARE_OVERLAY, telemetryExtras);
+
+ title = subjectText;
+ url = pageUrl;
+
+ // Set the subtitle text on the view and cause it to marquee if it's too long (which it will
+ // be, since it's a URL).
+ final TextView subtitleView = (TextView) findViewById(R.id.subtitle);
+ subtitleView.setText(pageUrl);
+ subtitleView.setEllipsize(TextUtils.TruncateAt.MARQUEE);
+ subtitleView.setSingleLine(true);
+ subtitleView.setMarqueeRepeatLimit(5);
+ subtitleView.setSelected(true);
+
+ final View titleView = findViewById(R.id.title);
+
+ if (state == State.DEVICES_ONLY) {
+ bookmarkButton.setVisibility(View.GONE);
+
+ titleView.setOnClickListener(null);
+ subtitleView.setOnClickListener(null);
+ return;
+ }
+
+ bookmarkButton.setVisibility(View.VISIBLE);
+
+ // Configure buttons.
+ final View.OnClickListener launchBrowser = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ ShareDialog.this.launchBrowser();
+ }
+ };
+
+ titleView.setOnClickListener(launchBrowser);
+ subtitleView.setOnClickListener(launchBrowser);
+
+ final LocalBrowserDB browserDB = new LocalBrowserDB(getCurrentProfile());
+ setButtonState(url, browserDB);
+ }
+
+ @Override
+ protected void onNewIntent(final Intent intent) {
+ super.onNewIntent(intent);
+
+ // The intent returned by getIntent is not updated automatically.
+ setIntent(intent);
+ }
+
+ /**
+ * Sets the state of the bookmark/reading list buttons: they are disabled if the given URL is
+ * already in the corresponding list.
+ */
+ private void setButtonState(final String pageURL, final LocalBrowserDB browserDB) {
+ new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+ // Flags to hold the result
+ boolean isBookmark;
+
+ @Override
+ protected Void doInBackground() {
+ final ContentResolver contentResolver = getApplicationContext().getContentResolver();
+
+ isBookmark = browserDB.isBookmark(contentResolver, pageURL);
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ findViewById(R.id.overlay_share_bookmark_btn).setEnabled(!isBookmark);
+ }
+ }.execute();
+ }
+
+ /**
+ * Helper method to get an overlay service intent populated with the data held in this dialog.
+ */
+ private Intent getServiceIntent(ShareMethod.Type method) {
+ final Intent serviceIntent = new Intent(this, OverlayActionService.class);
+ serviceIntent.setAction(OverlayConstants.ACTION_SHARE);
+
+ serviceIntent.putExtra(OverlayConstants.EXTRA_SHARE_METHOD, (Parcelable) method);
+ serviceIntent.putExtra(OverlayConstants.EXTRA_URL, url);
+ serviceIntent.putExtra(OverlayConstants.EXTRA_TITLE, title);
+
+ return serviceIntent;
+ }
+
+ @Override
+ public void finish() {
+ finish(true);
+ }
+
+ private void finish(final boolean shouldOverrideAnimations) {
+ super.finish();
+ if (shouldOverrideAnimations) {
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+ }
+
+ /*
+ * Button handlers. Send intents to the background service responsible for processing requests
+ * on Fennec in the background. (a nice extensible mechanism for "doing stuff without properly
+ * launching Fennec").
+ */
+
+ @Override
+ public void onSendTabActionSelected() {
+ // This requires an override intent.
+ if (sendTabOverrideIntent == null) {
+ throw new IllegalStateException("sendTabOverrideIntent must not be null");
+ }
+
+ startActivity(sendTabOverrideIntent);
+ finish();
+ }
+
+ @Override
+ public void onSendTabTargetSelected(String targetGUID) {
+ // targetGUID being null with no override intent should be an impossible state.
+ if (targetGUID == null) {
+ throw new IllegalStateException("targetGUID must not be null");
+ }
+
+ Intent serviceIntent = getServiceIntent(ShareMethod.Type.SEND_TAB);
+
+ // Currently, only one extra parameter is necessary (the GUID of the target device).
+ Bundle extraParameters = new Bundle();
+
+ // Future: Handle multiple-selection. Bug 1061297.
+ extraParameters.putStringArray(SendTab.SEND_TAB_TARGET_DEVICES, new String[] { targetGUID });
+
+ serviceIntent.putExtra(OverlayConstants.EXTRA_PARAMETERS, extraParameters);
+
+ startService(serviceIntent);
+ animateOut(true);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.SHARE_OVERLAY, "sendtab");
+ }
+
+ public void addBookmark() {
+ startService(getServiceIntent(ShareMethod.Type.ADD_BOOKMARK));
+ animateOut(true);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SHARE_OVERLAY, "bookmark");
+ }
+
+ public void launchBrowser() {
+ try {
+ // This can launch in the guest profile. Sorry.
+ final Intent i = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
+ i.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ startActivity(i);
+ } catch (URISyntaxException e) {
+ // Nothing much we can do.
+ } finally {
+ // Since we're changing apps, users expect the default app switch animations.
+ finish(false);
+ }
+ }
+
+ private String getCurrentProfile() {
+ return GeckoProfile.DEFAULT_PROFILE;
+ }
+
+ /**
+ * Slide the overlay down off the screen, display
+ * a check (if given), and finish the activity.
+ */
+ private void animateOut(final boolean shouldDisplayConfirmation) {
+ if (isAnimating) {
+ return;
+ }
+
+ isAnimating = true;
+ final Animation slideOutAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_slide_down);
+
+ final Animation animationToFinishActivity;
+ if (!shouldDisplayConfirmation) {
+ animationToFinishActivity = slideOutAnim;
+ } else {
+ final View check = findViewById(R.id.check);
+ check.setVisibility(View.VISIBLE);
+ final Animation checkEntryAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_check_entry);
+ final Animation checkExitAnim = AnimationUtils.loadAnimation(this, R.anim.overlay_check_exit);
+ checkExitAnim.setStartOffset(checkEntryAnim.getDuration() + 500);
+
+ final AnimationSet checkAnimationSet = new AnimationSet(this, null);
+ checkAnimationSet.addAnimation(checkEntryAnim);
+ checkAnimationSet.addAnimation(checkExitAnim);
+
+ check.startAnimation(checkAnimationSet);
+ animationToFinishActivity = checkExitAnim;
+ }
+
+ findViewById(R.id.sharedialog).startAnimation(slideOutAnim);
+ animationToFinishActivity.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) { /* Unused. */ }
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ finish();
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) { /* Unused. */ }
+ });
+
+ // Allows the user to dismiss the animation early.
+ setFullscreenFinishOnClickListener();
+ }
+
+ /**
+ * Sets a fullscreen {@link #finish()} click listener. We do this rather than attaching an
+ * onClickListener to the root View because in that case, we need to remove all of the
+ * existing listeners, which is less robust.
+ */
+ private void setFullscreenFinishOnClickListener() {
+ final View clickTarget = findViewById(R.id.fullscreen_click_target);
+ clickTarget.setVisibility(View.VISIBLE);
+ clickTarget.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ finish();
+ }
+ });
+ }
+
+ /**
+ * Close the dialog if back is pressed.
+ */
+ @Override
+ public void onBackPressed() {
+ animateOut(false);
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY);
+ }
+
+ /**
+ * Close the dialog if the anything that isn't a button is tapped.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ animateOut(false);
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.SHARE_OVERLAY);
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java
new file mode 100644
index 000000000..b68a018f2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AlignRightLinkPreference.java
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+class AlignRightLinkPreference extends LinkPreference {
+
+ public AlignRightLinkPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setLayoutResource(R.layout.preference_rightalign_icon);
+ }
+
+ public AlignRightLinkPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ setLayoutResource(R.layout.preference_rightalign_icon);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java
new file mode 100644
index 000000000..bb71ce78b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImport.java
@@ -0,0 +1,230 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.ContentValues;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Build;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.Bookmarks;
+import org.mozilla.gecko.db.LocalBrowserDB;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.IconsHelper;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.util.ArrayList;
+
+public class AndroidImport implements Runnable {
+ /**
+ * The Android M SDK removed several fields and methods from android.provider.Browser. This class is used as a
+ * replacement to support building with the new SDK but at the same time still use these fields on lower Android
+ * versions.
+ */
+ private static class LegacyBrowserProvider {
+ public static final Uri BOOKMARKS_URI = Uri.parse("content://browser/bookmarks");
+
+ // Incomplete: This are just the fields we currently use in our code base
+ public static class BookmarkColumns implements BaseColumns {
+ public static final String URL = "url";
+ public static final String VISITS = "visits";
+ public static final String DATE = "date";
+ public static final String BOOKMARK = "bookmark";
+ public static final String TITLE = "title";
+ public static final String CREATED = "created";
+ public static final String FAVICON = "favicon";
+ }
+ }
+
+ public static final Uri SAMSUNG_BOOKMARKS_URI = Uri.parse("content://com.sec.android.app.sbrowser.browser/bookmarks");
+ public static final Uri SAMSUNG_HISTORY_URI = Uri.parse("content://com.sec.android.app.sbrowser.browser/history");
+ public static final String SAMSUNG_MANUFACTURER = "samsung";
+
+ private static final String LOGTAG = "AndroidImport";
+ private final Context mContext;
+ private final Runnable mOnDoneRunnable;
+ private final ArrayList<ContentProviderOperation> mOperations;
+ private final ContentResolver mCr;
+ private final LocalBrowserDB mDB;
+ private final boolean mImportBookmarks;
+ private final boolean mImportHistory;
+
+ public AndroidImport(Context context, Runnable onDoneRunnable,
+ boolean doBookmarks, boolean doHistory) {
+ mContext = context;
+ mOnDoneRunnable = onDoneRunnable;
+ mOperations = new ArrayList<ContentProviderOperation>();
+ mCr = mContext.getContentResolver();
+ mDB = new LocalBrowserDB(GeckoProfile.get(context).getName());
+ mImportBookmarks = doBookmarks;
+ mImportHistory = doHistory;
+ }
+
+ public void mergeBookmarks() {
+ Cursor cursor = null;
+ try {
+ cursor = query(LegacyBrowserProvider.BOOKMARKS_URI,
+ SAMSUNG_BOOKMARKS_URI,
+ LegacyBrowserProvider.BookmarkColumns.BOOKMARK + " = 1");
+
+ if (cursor != null) {
+ final int faviconCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.FAVICON);
+ final int titleCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.TITLE);
+ final int urlCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.URL);
+ // http://code.google.com/p/android/issues/detail?id=17969
+ final int createCol = cursor.getColumnIndex(LegacyBrowserProvider.BookmarkColumns.CREATED);
+
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ String url = cursor.getString(urlCol);
+ String title = cursor.getString(titleCol);
+ long created;
+ if (createCol >= 0) {
+ created = cursor.getLong(createCol);
+ } else {
+ created = System.currentTimeMillis();
+ }
+ // Need to set it to the current time so Sync picks it up.
+ long modified = System.currentTimeMillis();
+ byte[] data = cursor.getBlob(faviconCol);
+ mDB.updateBookmarkInBatch(mCr, mOperations,
+ url, title, null, -1,
+ created, modified,
+ BrowserContract.Bookmarks.DEFAULT_POSITION,
+ null, Bookmarks.TYPE_BOOKMARK);
+ if (data != null) {
+ storeBitmap(data, url);
+ }
+ cursor.moveToNext();
+ }
+ }
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ flushBatchOperations();
+ }
+
+ public void mergeHistory() {
+ ArrayList<ContentValues> visitsToSynthesize = new ArrayList<>();
+ Cursor cursor = null;
+ try {
+ cursor = query (LegacyBrowserProvider.BOOKMARKS_URI,
+ SAMSUNG_HISTORY_URI,
+ LegacyBrowserProvider.BookmarkColumns.BOOKMARK + " = 0 AND " +
+ LegacyBrowserProvider.BookmarkColumns.VISITS + " > 0");
+
+ if (cursor != null) {
+ final int dateCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.DATE);
+ final int faviconCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.FAVICON);
+ final int titleCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.TITLE);
+ final int urlCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.URL);
+ final int visitsCol = cursor.getColumnIndexOrThrow(LegacyBrowserProvider.BookmarkColumns.VISITS);
+
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ String url = cursor.getString(urlCol);
+ String title = cursor.getString(titleCol);
+ long date = cursor.getLong(dateCol);
+ int visits = cursor.getInt(visitsCol);
+ byte[] data = cursor.getBlob(faviconCol);
+ mDB.updateHistoryInBatch(mCr, mOperations, url, title, date, visits);
+ if (data != null) {
+ storeBitmap(data, url);
+ }
+ ContentValues visitData = new ContentValues();
+ visitData.put(LocalBrowserDB.HISTORY_VISITS_DATE, date);
+ visitData.put(LocalBrowserDB.HISTORY_VISITS_URL, url);
+ visitData.put(LocalBrowserDB.HISTORY_VISITS_COUNT, visits);
+ visitsToSynthesize.add(visitData);
+ cursor.moveToNext();
+ }
+ }
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+
+ flushBatchOperations();
+
+ // Now that we have flushed history records, we need to synthesize individual visits. We have
+ // gathered information about all of the visits we need to synthesize into visitsForSynthesis.
+ mDB.insertVisitsFromImportHistoryInBatch(mCr, mOperations, visitsToSynthesize);
+
+ flushBatchOperations();
+ }
+
+ private void storeBitmap(byte[] data, String url) {
+ if (TextUtils.isEmpty(url) || data == null) {
+ return;
+ }
+
+ final Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
+ if (bitmap == null) {
+ return;
+ }
+
+ final String iconUrl = IconsHelper.guessDefaultFaviconURL(url);
+ if (iconUrl == null) {
+ return;
+ }
+
+ final DiskStorage storage = DiskStorage.get(mContext);
+
+ storage.putIcon(url, bitmap);
+ storage.putMapping(url, iconUrl);
+ }
+
+ protected Cursor query(Uri mainUri, Uri fallbackUri, String condition) {
+ final Cursor cursor = mCr.query(mainUri, null, condition, null, null);
+ if (Build.MANUFACTURER.equals(SAMSUNG_MANUFACTURER) && (cursor == null || cursor.getCount() == 0)) {
+ if (cursor != null) {
+ cursor.close();
+ }
+ return mCr.query(fallbackUri, null, null, null, null);
+ }
+ return cursor;
+ }
+
+ protected void flushBatchOperations() {
+ Log.d(LOGTAG, "Flushing " + mOperations.size() + " DB operations");
+ try {
+ // We don't really care for the results, this is best-effort.
+ mCr.applyBatch(BrowserContract.AUTHORITY, mOperations);
+ } catch (RemoteException e) {
+ Log.e(LOGTAG, "Remote exception while updating db: ", e);
+ } catch (OperationApplicationException e) {
+ // Bug 716729 means this happens even in normal circumstances
+ Log.d(LOGTAG, "Error while applying database updates: ", e);
+ }
+ mOperations.clear();
+ }
+
+ @Override
+ public void run() {
+ if (mImportBookmarks) {
+ mergeBookmarks();
+ }
+ if (mImportHistory) {
+ mergeHistory();
+ }
+
+ mOnDoneRunnable.run();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java
new file mode 100644
index 000000000..0f1d3ec3f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AndroidImportPreference.java
@@ -0,0 +1,112 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.Set;
+
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.util.Log;
+
+class AndroidImportPreference extends MultiPrefMultiChoicePreference {
+ private static final String LOGTAG = "AndroidImport";
+ public static final String PREF_KEY = "android.not_a_preference.import_android";
+ private static final String PREF_KEY_PREFIX = "import_android.data.";
+ private final Context mContext;
+
+ public static class Handler implements GeckoPreferences.PrefHandler {
+ public boolean setupPref(Context context, Preference pref) {
+ // Feature disabled on devices running Android M+ (Bug 1183559)
+ return Versions.preMarshmallow && Restrictions.isAllowed(context, Restrictable.IMPORT_SETTINGS);
+ }
+
+ public void onChange(Context context, Preference pref, Object newValue) { }
+ }
+
+ public AndroidImportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+
+ if (!positiveResult)
+ return;
+
+ boolean bookmarksChecked = false;
+ boolean historyChecked = false;
+
+ Set<String> values = getValues();
+
+ for (String value : values) {
+ // Import checkbox values are stored in Android prefs to
+ // remember their check states. The key names are import_android.data.X
+ String key = value.substring(PREF_KEY_PREFIX.length());
+ if ("bookmarks".equals(key)) {
+ bookmarksChecked = true;
+ } else if ("history".equals(key)) {
+ historyChecked = true;
+ }
+ }
+
+ runImport(bookmarksChecked, historyChecked);
+ }
+
+ protected void runImport(final boolean doBookmarks, final boolean doHistory) {
+ Log.i(LOGTAG, "Importing Android history/bookmarks");
+ if (!doBookmarks && !doHistory) {
+ return;
+ }
+
+ final String dialogTitle;
+ if (doBookmarks && doHistory) {
+ dialogTitle = mContext.getString(R.string.bookmarkhistory_import_both);
+ } else if (doBookmarks) {
+ dialogTitle = mContext.getString(R.string.bookmarkhistory_import_bookmarks);
+ } else {
+ dialogTitle = mContext.getString(R.string.bookmarkhistory_import_history);
+ }
+
+ final ProgressDialog dialog =
+ ProgressDialog.show(mContext,
+ dialogTitle,
+ mContext.getString(R.string.bookmarkhistory_import_wait),
+ true);
+
+ final Runnable stopCallback = new Runnable() {
+ @Override
+ public void run() {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ dialog.dismiss();
+ }
+ });
+ }
+ };
+
+ ThreadUtils.postToBackgroundThread(
+ // Constructing AndroidImport may need finding the profile,
+ // which hits disk, so it needs to go into a Runnable too.
+ new Runnable() {
+ @Override
+ public void run() {
+ new AndroidImport(mContext, stopCallback, doBookmarks, doHistory).run();
+ }
+ }
+ );
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java b/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java
new file mode 100644
index 000000000..fb4a8f751
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/AppCompatPreferenceActivity.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.preferences;
+
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import android.support.annotation.LayoutRes;
+import android.support.annotation.Nullable;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.AppCompatDelegate;
+import android.support.v7.widget.Toolbar;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * A {@link android.preference.PreferenceActivity} which implements and proxies the necessary calls
+ * to be used with AppCompat.
+ *
+ * This technique can be used with an {@link android.app.Activity} class, not just
+ * {@link android.preference.PreferenceActivity}.
+ *
+ * This class was directly imported (without any modifications) from Android SDK examples, at:
+ * https://android.googlesource.com/platform/development/+/master/samples/Support7Demos/src/com/example/android/supportv7/app/AppCompatPreferenceActivity.java
+ */
+public abstract class AppCompatPreferenceActivity extends PreferenceActivity {
+ private AppCompatDelegate mDelegate;
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ getDelegate().installViewFactory();
+ getDelegate().onCreate(savedInstanceState);
+ super.onCreate(savedInstanceState);
+ }
+ @Override
+ protected void onPostCreate(Bundle savedInstanceState) {
+ super.onPostCreate(savedInstanceState);
+ getDelegate().onPostCreate(savedInstanceState);
+ }
+ public ActionBar getSupportActionBar() {
+ return getDelegate().getSupportActionBar();
+ }
+ public void setSupportActionBar(@Nullable Toolbar toolbar) {
+ getDelegate().setSupportActionBar(toolbar);
+ }
+ @Override
+ public MenuInflater getMenuInflater() {
+ return getDelegate().getMenuInflater();
+ }
+ @Override
+ public void setContentView(@LayoutRes int layoutResID) {
+ getDelegate().setContentView(layoutResID);
+ }
+ @Override
+ public void setContentView(View view) {
+ getDelegate().setContentView(view);
+ }
+ @Override
+ public void setContentView(View view, ViewGroup.LayoutParams params) {
+ getDelegate().setContentView(view, params);
+ }
+ @Override
+ public void addContentView(View view, ViewGroup.LayoutParams params) {
+ getDelegate().addContentView(view, params);
+ }
+ @Override
+ protected void onPostResume() {
+ super.onPostResume();
+ getDelegate().onPostResume();
+ }
+ @Override
+ protected void onTitleChanged(CharSequence title, int color) {
+ super.onTitleChanged(title, color);
+ getDelegate().setTitle(title);
+ }
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ getDelegate().onConfigurationChanged(newConfig);
+ }
+ @Override
+ protected void onStop() {
+ super.onStop();
+ getDelegate().onStop();
+ }
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ getDelegate().onDestroy();
+ }
+ public void invalidateOptionsMenu() {
+ getDelegate().invalidateOptionsMenu();
+ }
+ private AppCompatDelegate getDelegate() {
+ if (mDelegate == null) {
+ mDelegate = AppCompatDelegate.create(this, null);
+ }
+ return mDelegate;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java
new file mode 100644
index 000000000..5218cd06d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ClearOnShutdownPref.java
@@ -0,0 +1,37 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.util.PrefUtils;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.Preference;
+
+public class ClearOnShutdownPref implements GeckoPreferences.PrefHandler {
+ public static final String PREF = GeckoPreferences.NON_PREF_PREFIX + "history.clear_on_exit";
+
+ @Override
+ public boolean setupPref(Context context, Preference pref) {
+ // The pref is initialized asynchronously. Read the pref explicitly
+ // here to make sure we have the data.
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(context);
+ final Set<String> clearItems = PrefUtils.getStringSet(prefs, PREF, new HashSet<String>());
+ ((ListCheckboxPreference) pref).setChecked(clearItems.size() > 0);
+ return true;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void onChange(Context context, Preference pref, Object newValue) {
+ final Set<String> vals = (Set<String>) newValue;
+ ((ListCheckboxPreference) pref).setChecked(vals.size() > 0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java
new file mode 100644
index 000000000..2934ca88e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomCheckBoxPreference.java
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.Context;
+import android.preference.CheckBoxPreference;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * Represents a Checkbox element in a preference menu.
+ * The title of the Checkbox can be larger than the view.
+ * In this case, it will be displayed in 2 or more lines.
+ * The default behavior of the class CheckBoxPreference
+ * doesn't wrap the title.
+ */
+
+public class CustomCheckBoxPreference extends CheckBoxPreference {
+
+ public CustomCheckBoxPreference(Context context) {
+ super(context);
+ }
+
+ public CustomCheckBoxPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+ public CustomCheckBoxPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+ final TextView title = (TextView) view.findViewById(android.R.id.title);
+ if (title != null) {
+ title.setSingleLine(false);
+ title.setEllipsize(null);
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java
new file mode 100644
index 000000000..ee5a46bef
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListCategory.java
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.Context;
+import android.preference.PreferenceCategory;
+import android.util.AttributeSet;
+
+public abstract class CustomListCategory extends PreferenceCategory {
+ protected CustomListPreference mDefaultReference;
+
+ public CustomListCategory(Context context) {
+ super(context);
+ }
+
+ public CustomListCategory(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public CustomListCategory(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onAttachedToActivity() {
+ super.onAttachedToActivity();
+
+ setOrderingAsAdded(true);
+ }
+
+ /**
+ * Set the default to some available list item. Used if the current default is removed or
+ * disabled.
+ */
+ protected void setFallbackDefault() {
+ if (getPreferenceCount() > 0) {
+ CustomListPreference aItem = (CustomListPreference) getPreference(0);
+ setDefault(aItem);
+ }
+ }
+
+ /**
+ * Removes the given item from the set of available list items.
+ * This only updates the UI, so callers are responsible for persisting any state.
+ *
+ * @param item The given item to remove.
+ */
+ public void uninstall(CustomListPreference item) {
+ removePreference(item);
+ if (item == mDefaultReference) {
+ // If the default is being deleted, set a new default.
+ setFallbackDefault();
+ }
+ }
+
+ /**
+ * Sets the given item as the current default.
+ * This only updates the UI, so callers are responsible for persisting any state.
+ *
+ * @param item The intended new default.
+ */
+ public void setDefault(CustomListPreference item) {
+ if (mDefaultReference != null) {
+ mDefaultReference.setIsDefault(false);
+ }
+
+ item.setIsDefault(true);
+ mDefaultReference = item;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java
new file mode 100644
index 000000000..8b7e0e7b3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/CustomListPreference.java
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.Resources;
+import android.preference.Preference;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * Represents an element in a <code>CustomListCategory</code> preference menu.
+ * This preference con display a dialog when clicked, and also supports
+ * being set as a default item within the preference list category.
+ */
+
+public abstract class CustomListPreference extends Preference implements View.OnLongClickListener {
+ protected String LOGTAG = "CustomListPreference";
+
+ // Indices of the buttons of the Dialog.
+ public static final int INDEX_SET_DEFAULT_BUTTON = 0;
+
+ // Dialog item labels.
+ private String[] mDialogItems;
+
+ // Dialog displayed when this element is tapped.
+ protected AlertDialog mDialog;
+
+ // Cache label to avoid repeated use of the resource system.
+ protected final String LABEL_IS_DEFAULT;
+ protected final String LABEL_SET_AS_DEFAULT;
+ protected final String LABEL_REMOVE;
+
+ protected boolean mIsDefault;
+
+ // Enclosing parent category that contains this preference.
+ protected final CustomListCategory mParentCategory;
+
+ /**
+ * Create a preference object to represent a list preference that is attached to
+ * a category.
+ *
+ * @param context The activity context we operate under.
+ * @param parentCategory The PreferenceCategory this object exists within.
+ */
+ public CustomListPreference(Context context, CustomListCategory parentCategory) {
+ super(context);
+
+ mParentCategory = parentCategory;
+ setLayoutResource(getPreferenceLayoutResource());
+
+ setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ CustomListPreference sPref = (CustomListPreference) preference;
+ sPref.showDialog();
+ return true;
+ }
+ });
+
+ Resources res = getContext().getResources();
+
+ // Fetch these strings now, instead of every time we ever want to relabel a button.
+ LABEL_IS_DEFAULT = res.getString(R.string.pref_default);
+ LABEL_SET_AS_DEFAULT = res.getString(R.string.pref_dialog_set_default);
+ LABEL_REMOVE = res.getString(R.string.pref_dialog_remove);
+ }
+
+ /**
+ * Returns the Android resource id for the layout.
+ */
+ protected abstract int getPreferenceLayoutResource();
+
+ /**
+ * Set whether this object's UI should display this as the default item.
+ * Note: This must be called from the UI thread because it touches the view hierarchy.
+ *
+ * To ensure proper ordering, this method should only be called after this Preference
+ * is added to the PreferenceCategory.
+ *
+ * @param isDefault Flag indicating if this represents the default list item.
+ */
+ public void setIsDefault(boolean isDefault) {
+ mIsDefault = isDefault;
+ if (isDefault) {
+ setOrder(0);
+ setSummary(LABEL_IS_DEFAULT);
+ } else {
+ setOrder(1);
+ setSummary("");
+ }
+ }
+
+ private String[] getCachedDialogItems() {
+ if (mDialogItems == null) {
+ mDialogItems = createDialogItems();
+ }
+ return mDialogItems;
+ }
+
+ /**
+ * Returns the strings to be displayed in the dialog.
+ */
+ abstract protected String[] createDialogItems();
+
+ /**
+ * Display a dialog for this preference, when the preference is clicked.
+ */
+ public void showDialog() {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+ builder.setTitle(getTitle().toString());
+ builder.setItems(getCachedDialogItems(), new DialogInterface.OnClickListener() {
+ // Forward relevant events to the container class for handling.
+ @Override
+ public void onClick(DialogInterface dialog, int indexClicked) {
+ hideDialog();
+ onDialogIndexClicked(indexClicked);
+ }
+ });
+
+ configureDialogBuilder(builder);
+
+ // We have to construct the dialog itself on the UI thread.
+ mDialog = builder.create();
+ mDialog.setOnShowListener(new DialogInterface.OnShowListener() {
+ // Called when the dialog is shown (so we're finally able to manipulate button enabledness).
+ @Override
+ public void onShow(DialogInterface dialog) {
+ configureShownDialog();
+ }
+ });
+ mDialog.show();
+ }
+
+ /**
+ * (Optional) Configure the AlertDialog builder.
+ */
+ protected void configureDialogBuilder(AlertDialog.Builder builder) {
+ return;
+ }
+
+ abstract protected void onDialogIndexClicked(int index);
+
+ /**
+ * Disables buttons in the shown AlertDialog as required. The button elements are not created
+ * until after show is called, so this method has to be called from the onShowListener above.
+ * @see this.showDialog
+ */
+ protected void configureShownDialog() {
+ // If this is already the default list item, disable the button for setting this as the default.
+ final TextView defaultButton = (TextView) mDialog.getListView().getChildAt(INDEX_SET_DEFAULT_BUTTON);
+ if (mIsDefault) {
+ defaultButton.setEnabled(false);
+
+ // Failure to unregister this listener leads to tapping the button dismissing the dialog
+ // without doing anything.
+ defaultButton.setOnClickListener(null);
+ }
+ }
+
+ /**
+ * Hide the dialog we previously created, if any.
+ */
+ public void hideDialog() {
+ if (mDialog != null && mDialog.isShowing()) {
+ mDialog.dismiss();
+ }
+ }
+
+ @Override
+ public boolean onLongClick(View view) {
+ // Show the preference dialog on long-press.
+ showDialog();
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java b/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java
new file mode 100644
index 000000000..1e235640e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/DistroSharedPrefsImport.java
@@ -0,0 +1,61 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.distribution.Distribution;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Iterator;
+
+public class DistroSharedPrefsImport {
+
+ public static final String LOGTAG = DistroSharedPrefsImport.class.getSimpleName();
+
+ public static void importPreferences(final Context context, final Distribution distribution) {
+ if (distribution == null) {
+ return;
+ }
+
+ final JSONObject preferences = distribution.getAndroidPreferences();
+ if (preferences.length() == 0) {
+ return;
+ }
+
+ final Iterator<?> keys = preferences.keys();
+ final SharedPreferences.Editor sharedPreferences = GeckoSharedPrefs.forProfile(context).edit();
+
+ while (keys.hasNext()) {
+ final String key = (String) keys.next();
+ final Object value;
+ try {
+ value = preferences.get(key);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Unable to completely process Android Preferences JSON.", e);
+ continue;
+ }
+
+ // We currently don't support Float preferences.
+ if (value instanceof String) {
+ sharedPreferences.putString(GeckoPreferences.NON_PREF_PREFIX + key, (String) value);
+ } else if (value instanceof Boolean) {
+ sharedPreferences.putBoolean(GeckoPreferences.NON_PREF_PREFIX + key, (boolean) value);
+ } else if (value instanceof Integer) {
+ sharedPreferences.putInt(GeckoPreferences.NON_PREF_PREFIX + key, (int) value);
+ } else if (value instanceof Long) {
+ sharedPreferences.putLong(GeckoPreferences.NON_PREF_PREFIX + key, (long) value);
+ } else {
+ Log.d(LOGTAG, "Unknown preference value type whilst importing android preferences from distro file.");
+ }
+ }
+ sharedPreferences.apply();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java
new file mode 100644
index 000000000..c77c2cc23
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/FontSizePreference.java
@@ -0,0 +1,192 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.preference.DialogPreference;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import java.util.HashMap;
+
+class FontSizePreference extends DialogPreference {
+ private static final String LOGTAG = "FontSizePreference";
+ private static final int TWIP_TO_PT_RATIO = 20; // 20 twip = 1 point.
+ private static final int PREVIEW_FONT_SIZE_UNIT = TypedValue.COMPLEX_UNIT_PT;
+ private static final int DEFAULT_FONT_INDEX = 2;
+
+ private final Context mContext;
+ /** Container for mPreviewFontView to allow for scrollable padding at the top of the view. */
+ private ScrollView mScrollingContainer;
+ private TextView mPreviewFontView;
+ private Button mIncreaseFontButton;
+ private Button mDecreaseFontButton;
+
+ private final String[] mFontTwipValues;
+ private final String[] mFontSizeNames; // Ex: "Small".
+ /** Index into the above arrays for the saved preference value (from Gecko). */
+ private int mSavedFontIndex = DEFAULT_FONT_INDEX;
+ /** Index into the above arrays for the currently displayed font size (the preview). */
+ private int mPreviewFontIndex = mSavedFontIndex;
+ private final HashMap<String, Integer> mFontTwipToIndexMap;
+
+ public FontSizePreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+
+ final Resources res = mContext.getResources();
+ mFontTwipValues = res.getStringArray(R.array.pref_font_size_values);
+ mFontSizeNames = res.getStringArray(R.array.pref_font_size_entries);
+ mFontTwipToIndexMap = new HashMap<String, Integer>();
+ for (int i = 0; i < mFontTwipValues.length; ++i) {
+ mFontTwipToIndexMap.put(mFontTwipValues[i], i);
+ }
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ final LayoutInflater inflater =
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View dialogView = inflater.inflate(R.layout.font_size_preference, null);
+ initInternalViews(dialogView);
+ updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]);
+
+ builder.setTitle(null);
+ builder.setView(dialogView);
+ }
+
+ /** Saves relevant views to instance variables and initializes their settings. */
+ private void initInternalViews(View dialogView) {
+ mScrollingContainer = (ScrollView) dialogView.findViewById(R.id.scrolling_container);
+ // Background cannot be set in XML (see bug 783597 - TODO: Change this to XML when bug is fixed).
+ mScrollingContainer.setBackgroundColor(Color.WHITE);
+ mPreviewFontView = (TextView) dialogView.findViewById(R.id.preview);
+
+ mDecreaseFontButton = (Button) dialogView.findViewById(R.id.decrease_preview_font_button);
+ mIncreaseFontButton = (Button) dialogView.findViewById(R.id.increase_preview_font_button);
+ setButtonState(mPreviewFontIndex);
+ mDecreaseFontButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPreviewFontIndex = Math.max(mPreviewFontIndex - 1, 0);
+ updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]);
+ mIncreaseFontButton.setEnabled(true);
+ // If we reached the minimum index, disable the button.
+ if (mPreviewFontIndex == 0) {
+ mDecreaseFontButton.setEnabled(false);
+ }
+ }
+ });
+ mIncreaseFontButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPreviewFontIndex = Math.min(mPreviewFontIndex + 1, mFontTwipValues.length - 1);
+ updatePreviewFontSize(mFontTwipValues[mPreviewFontIndex]);
+
+ mDecreaseFontButton.setEnabled(true);
+ // If we reached the maximum index, disable the button.
+ if (mPreviewFontIndex == mFontTwipValues.length - 1) {
+ mIncreaseFontButton.setEnabled(false);
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+ if (!positiveResult) {
+ mPreviewFontIndex = mSavedFontIndex;
+ return;
+ }
+ mSavedFontIndex = mPreviewFontIndex;
+ final String twipVal = mFontTwipValues[mSavedFontIndex];
+ final OnPreferenceChangeListener prefChangeListener = getOnPreferenceChangeListener();
+ if (prefChangeListener == null) {
+ Log.e(LOGTAG, "PreferenceChangeListener is null. FontSizePreference will not be saved to Gecko.");
+ return;
+ }
+ prefChangeListener.onPreferenceChange(this, twipVal);
+ }
+
+ /**
+ * Finds the index of the given twip value and sets it as the saved preference value. Also the
+ * current preview text size to the given value. Does not update the mPreviewFontView text size.
+ */
+ protected void setSavedFontSize(String twip) {
+ final Integer index = mFontTwipToIndexMap.get(twip);
+ if (index != null) {
+ mSavedFontIndex = index;
+ mPreviewFontIndex = mSavedFontIndex;
+ return;
+ }
+ resetSavedFontSizeToDefault();
+ Log.e(LOGTAG, "setSavedFontSize: Given font size does not exist in twip values map. Reverted to default font size.");
+ }
+
+ /**
+ * Updates the mPreviewFontView to the given text size, resets the container's scroll to the top
+ * left, and invalidates the view. Does not update the font indices.
+ */
+ private void updatePreviewFontSize(String twip) {
+ float pt = convertTwipStrToPT(twip);
+ // Android will not render a font size of 0 pt but for Gecko, 0 twip turns off font
+ // inflation. Thus we special case 0 twip to display a renderable font size.
+ if (pt == 0) {
+ // Android adds an inexplicable extra margin on the smallest font size so to get around
+ // this, we reinflate the view.
+ ViewGroup parentView = (ViewGroup) mScrollingContainer.getParent();
+ parentView.removeAllViews();
+ final LayoutInflater inflater =
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View dialogView = inflater.inflate(R.layout.font_size_preference, parentView);
+ initInternalViews(dialogView);
+ mPreviewFontView.setTextSize(PREVIEW_FONT_SIZE_UNIT, 1);
+ } else {
+ mPreviewFontView.setTextSize(PREVIEW_FONT_SIZE_UNIT, pt);
+ }
+ mScrollingContainer.scrollTo(0, 0);
+ }
+
+ /**
+ * Resets the font indices to the default value. Does not update the mPreviewFontView text size.
+ */
+ private void resetSavedFontSizeToDefault() {
+ mSavedFontIndex = DEFAULT_FONT_INDEX;
+ mPreviewFontIndex = mSavedFontIndex;
+ }
+
+ private void setButtonState(int index) {
+ if (index == 0) {
+ mDecreaseFontButton.setEnabled(false);
+ } else if (index == mFontTwipValues.length - 1) {
+ mIncreaseFontButton.setEnabled(false);
+ }
+ }
+
+ /**
+ * Returns the name of the font size (ex: "Small") at the currently saved preference value.
+ */
+ protected String getSavedFontSizeName() {
+ return mFontSizeNames[mSavedFontIndex];
+ }
+
+ private float convertTwipStrToPT(String twip) {
+ return Float.parseFloat(twip) / TWIP_TO_PT_RATIO;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java
new file mode 100644
index 000000000..6be9e6ea5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferenceFragment.java
@@ -0,0 +1,296 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import java.util.Locale;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.LocaleManager;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.fxa.AccountLoader;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+
+import android.accounts.Account;
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.LoaderManager;
+import android.content.Context;
+import android.content.Loader;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceScreen;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+
+import com.squareup.leakcanary.RefWatcher;
+
+/* A simple implementation of PreferenceFragment for large screen devices
+ * This will strip category headers (so that they aren't shown to the user twice)
+ * as well as initializing Gecko prefs when a fragment is shown.
+*/
+public class GeckoPreferenceFragment extends PreferenceFragment {
+
+ public static final int ACCOUNT_LOADER_ID = 1;
+ private AccountLoaderCallbacks accountLoaderCallbacks;
+ private SyncPreference syncPreference;
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale);
+
+ final Activity context = getActivity();
+
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+ final Locale changed = localeManager.onSystemConfigurationChanged(context, getResources(), newConfig, lastLocale);
+ if (changed != null) {
+ applyLocale(changed);
+ }
+ }
+
+ private static final String LOGTAG = "GeckoPreferenceFragment";
+ private PrefsHelper.PrefHandler mPrefsRequest;
+ private Locale lastLocale = Locale.getDefault();
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Write prefs to our custom GeckoSharedPrefs file.
+ getPreferenceManager().setSharedPreferencesName(GeckoSharedPrefs.APP_PREFS_NAME);
+
+ int res = getResource();
+ if (res == R.xml.preferences) {
+ Telemetry.startUISession(TelemetryContract.Session.SETTINGS);
+ } else {
+ final String resourceName = getArguments().getString("resource");
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.SETTINGS, resourceName);
+ }
+
+ // Display a menu for Search preferences.
+ if (res == R.xml.preferences_search) {
+ setHasOptionsMenu(true);
+ }
+
+ addPreferencesFromResource(res);
+
+ PreferenceScreen screen = getPreferenceScreen();
+ setPreferenceScreen(screen);
+ mPrefsRequest = ((GeckoPreferences)getActivity()).setupPreferences(screen);
+ syncPreference = (SyncPreference) findPreference(GeckoPreferences.PREFS_SYNC);
+ }
+
+ /**
+ * Return the title to use for this preference fragment.
+ *
+ * We only return titles for the preference screens that are
+ * launched directly, and thus might need to be redisplayed.
+ *
+ * This method sets the title that you see on non-multi-pane devices.
+ */
+ private String getTitle() {
+ final int res = getResource();
+ if (res == R.xml.preferences) {
+ return getString(R.string.settings_title);
+ }
+
+ // We can launch this category from the Data Reporting notification.
+ if (res == R.xml.preferences_privacy) {
+ return getString(R.string.pref_category_privacy_short);
+ }
+
+ // We can launch this category from the the magnifying glass in the quick search bar.
+ if (res == R.xml.preferences_search) {
+ return getString(R.string.pref_category_search);
+ }
+
+ // Launched as action from content notifications.
+ if (res == R.xml.preferences_notifications) {
+ return getString(R.string.pref_category_notifications);
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the header id for this preference fragment. This allows
+ * us to select the correct header when launching a preference
+ * screen directly.
+ *
+ * We only return titles for the preference screens that are
+ * launched directly.
+ */
+ private int getHeader() {
+ final int res = getResource();
+ if (res == R.xml.preferences) {
+ return R.id.pref_header_general;
+ }
+
+ // We can launch this category from the Data Reporting notification.
+ if (res == R.xml.preferences_privacy) {
+ return R.id.pref_header_privacy;
+ }
+
+ // We can launch this category from the the magnifying glass in the quick search bar.
+ if (res == R.xml.preferences_search) {
+ return R.id.pref_header_search;
+ }
+
+ // Launched as action from content notifications.
+ if (res == R.xml.preferences_notifications) {
+ return R.id.pref_header_notifications;
+ }
+
+ return -1;
+ }
+
+ private void updateTitle() {
+ final String newTitle = getTitle();
+ if (newTitle == null) {
+ Log.d(LOGTAG, "No new title to show.");
+ return;
+ }
+
+ final GeckoPreferences activity = (GeckoPreferences) getActivity();
+ if (activity.isMultiPane()) {
+ // In a multi-pane activity, the title is "Settings", and the action
+ // bar is along the top of the screen. We don't want to change those.
+ activity.showBreadCrumbs(newTitle, newTitle);
+ activity.switchToHeader(getHeader());
+ return;
+ }
+
+ Log.v(LOGTAG, "Setting activity title to " + newTitle);
+ activity.setTitle(newTitle);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ accountLoaderCallbacks = new AccountLoaderCallbacks();
+ getLoaderManager().initLoader(ACCOUNT_LOADER_ID, null, accountLoaderCallbacks);
+ }
+
+ @Override
+ public void onResume() {
+ // This is a little delicate. Ensure that you do nothing prior to
+ // super.onResume that you wouldn't do in onCreate.
+ applyLocale(Locale.getDefault());
+ super.onResume();
+
+ // Force reload as the account may have been deleted while the app was in background.
+ getLoaderManager().restartLoader(ACCOUNT_LOADER_ID, null, accountLoaderCallbacks);
+ }
+
+ private void applyLocale(final Locale currentLocale) {
+ final Context context = getActivity().getApplicationContext();
+
+ BrowserLocaleManager.getInstance().updateConfiguration(context, currentLocale);
+
+ if (!currentLocale.equals(lastLocale)) {
+ // Locales differ. Let's redisplay.
+ Log.d(LOGTAG, "Locale changed: " + currentLocale);
+ this.lastLocale = currentLocale;
+
+ // Rebuild the list to reflect the current locale.
+ getPreferenceScreen().removeAll();
+ addPreferencesFromResource(getResource());
+ }
+
+ // Fix the parent title regardless.
+ updateTitle();
+ }
+
+ /*
+ * Get the resource from Fragment arguments and return it.
+ *
+ * If no resource can be found, return the resource id of the default preference screen.
+ */
+ private int getResource() {
+ int resid = 0;
+
+ final String resourceName = getArguments().getString("resource");
+ final Activity activity = getActivity();
+
+ if (resourceName != null) {
+ // Fetch resource id by resource name.
+ final Resources resources = activity.getResources();
+ final String packageName = activity.getPackageName();
+ resid = resources.getIdentifier(resourceName, "xml", packageName);
+ }
+
+ if (resid == 0) {
+ // The resource was invalid. Use the default resource.
+ Log.e(LOGTAG, "Failed to find resource: " + resourceName + ". Displaying default settings.");
+
+ boolean isMultiPane = ((GeckoPreferences) activity).isMultiPane();
+ resid = isMultiPane ? R.xml.preferences_general_tablet : R.xml.preferences;
+ }
+
+ return resid;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ inflater.inflate(R.menu.preferences_search_menu, menu);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (mPrefsRequest != null) {
+ PrefsHelper.removeObserver(mPrefsRequest);
+ mPrefsRequest = null;
+ }
+
+ final int res = getResource();
+ if (res == R.xml.preferences) {
+ Telemetry.stopUISession(TelemetryContract.Session.SETTINGS);
+ }
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ private class AccountLoaderCallbacks implements LoaderManager.LoaderCallbacks<Account> {
+ @Override
+ public Loader<Account> onCreateLoader(int id, Bundle args) {
+ return new AccountLoader(getActivity());
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Account> loader, Account account) {
+ if (syncPreference == null) {
+ return;
+ }
+
+ if (account == null) {
+ syncPreference.update(null);
+ return;
+ }
+
+ syncPreference.update(new AndroidFxAccount(getActivity(), account));
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Account> loader) {
+ if (syncPreference != null) {
+ syncPreference.update(null);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
new file mode 100644
index 000000000..5ab1bc3fd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
@@ -0,0 +1,1520 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.json.JSONArray;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.AdjustConstants;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.DataReportingNotification;
+import org.mozilla.gecko.DynamicToolbar;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoActivityStatus;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.LocaleManager;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.activitystream.ActivityStream;
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.db.BrowserContract.SuggestedSites;
+import org.mozilla.gecko.feeds.FeedService;
+import org.mozilla.gecko.feeds.action.CheckForUpdatesAction;
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.tabqueue.TabQueueHelper;
+import org.mozilla.gecko.tabqueue.TabQueuePrompt;
+import org.mozilla.gecko.updater.UpdateService;
+import org.mozilla.gecko.updater.UpdateServiceHelper;
+import org.mozilla.gecko.util.ContextUtils;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.InputOptionsUtils;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.annotation.TargetApi;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.NotificationManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.content.res.Configuration;
+import android.Manifest;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.CheckBoxPreference;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.Preference.OnPreferenceChangeListener;
+import android.preference.Preference.OnPreferenceClickListener;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceGroup;
+import android.preference.SwitchPreference;
+import android.preference.TwoStatePreference;
+import android.support.design.widget.Snackbar;
+import android.support.design.widget.TextInputLayout;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v7.app.ActionBar;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.ListAdapter;
+import android.widget.ListView;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class GeckoPreferences
+extends AppCompatPreferenceActivity
+implements
+GeckoActivityStatus,
+NativeEventListener,
+OnPreferenceChangeListener,
+OnSharedPreferenceChangeListener
+{
+ private static final String LOGTAG = "GeckoPreferences";
+
+ // We have a white background, which makes transitions on
+ // some devices look bad. Don't use transitions on those
+ // devices.
+ private static final boolean NO_TRANSITIONS = HardwareUtils.IS_KINDLE_DEVICE;
+ private static final int NO_SUCH_ID = 0;
+
+ public static final String NON_PREF_PREFIX = "android.not_a_preference.";
+ public static final String INTENT_EXTRA_RESOURCES = "resource";
+ public static final String PREFS_TRACKING_PROTECTION_PROMPT_SHOWN = NON_PREF_PREFIX + "trackingProtectionPromptShown";
+ public static String PREFS_HEALTHREPORT_UPLOAD_ENABLED = NON_PREF_PREFIX + "healthreport.uploadEnabled";
+ public static final String PREFS_SYNC = NON_PREF_PREFIX + "sync";
+
+ private static boolean sIsCharEncodingEnabled;
+ private boolean mInitialized;
+ private PrefsHelper.PrefHandler mPrefsRequest;
+ private List<Header> mHeaders;
+
+ // These match keys in resources/xml*/preferences*.xml
+ private static final String PREFS_SEARCH_RESTORE_DEFAULTS = NON_PREF_PREFIX + "search.restore_defaults";
+ private static final String PREFS_DATA_REPORTING_PREFERENCES = NON_PREF_PREFIX + "datareporting.preferences";
+ private static final String PREFS_TELEMETRY_ENABLED = "toolkit.telemetry.enabled";
+ private static final String PREFS_CRASHREPORTER_ENABLED = "datareporting.crashreporter.submitEnabled";
+ private static final String PREFS_MENU_CHAR_ENCODING = "browser.menu.showCharacterEncoding";
+ private static final String PREFS_MP_ENABLED = "privacy.masterpassword.enabled";
+ private static final String PREFS_UPDATER_AUTODOWNLOAD = "app.update.autodownload";
+ private static final String PREFS_UPDATER_URL = "app.update.url.android";
+ private static final String PREFS_GEO_REPORTING = NON_PREF_PREFIX + "app.geo.reportdata";
+ private static final String PREFS_GEO_LEARN_MORE = NON_PREF_PREFIX + "geo.learn_more";
+ private static final String PREFS_HEALTHREPORT_LINK = NON_PREF_PREFIX + "healthreport.link";
+ private static final String PREFS_DEVTOOLS_REMOTE_USB_ENABLED = "devtools.remote.usb.enabled";
+ private static final String PREFS_DEVTOOLS_REMOTE_WIFI_ENABLED = "devtools.remote.wifi.enabled";
+ private static final String PREFS_DEVTOOLS_REMOTE_LINK = NON_PREF_PREFIX + "remote_debugging.link";
+ private static final String PREFS_TRACKING_PROTECTION = "privacy.trackingprotection.state";
+ private static final String PREFS_TRACKING_PROTECTION_PB = "privacy.trackingprotection.pbmode.enabled";
+ private static final String PREFS_ZOOMED_VIEW_ENABLED = "ui.zoomedview.enabled";
+ public static final String PREFS_VOICE_INPUT_ENABLED = NON_PREF_PREFIX + "voice_input_enabled";
+ public static final String PREFS_QRCODE_ENABLED = NON_PREF_PREFIX + "qrcode_enabled";
+ private static final String PREFS_TRACKING_PROTECTION_PRIVATE_BROWSING = "privacy.trackingprotection.pbmode.enabled";
+ private static final String PREFS_TRACKING_PROTECTION_LEARN_MORE = NON_PREF_PREFIX + "trackingprotection.learn_more";
+ private static final String PREFS_CLEAR_PRIVATE_DATA = NON_PREF_PREFIX + "privacy.clear";
+ private static final String PREFS_CLEAR_PRIVATE_DATA_EXIT = NON_PREF_PREFIX + "history.clear_on_exit";
+ private static final String PREFS_SCREEN_ADVANCED = NON_PREF_PREFIX + "advanced_screen";
+ public static final String PREFS_HOMEPAGE = NON_PREF_PREFIX + "homepage";
+ public static final String PREFS_HOMEPAGE_PARTNER_COPY = GeckoPreferences.PREFS_HOMEPAGE + ".partner";
+ public static final String PREFS_HISTORY_SAVED_SEARCH = NON_PREF_PREFIX + "search.search_history.enabled";
+ private static final String PREFS_FAQ_LINK = NON_PREF_PREFIX + "faq.link";
+ private static final String PREFS_FEEDBACK_LINK = NON_PREF_PREFIX + "feedback.link";
+ public static final String PREFS_NOTIFICATIONS_CONTENT = NON_PREF_PREFIX + "notifications.content";
+ public static final String PREFS_NOTIFICATIONS_CONTENT_LEARN_MORE = NON_PREF_PREFIX + "notifications.content.learn_more";
+ public static final String PREFS_NOTIFICATIONS_WHATS_NEW = NON_PREF_PREFIX + "notifications.whats_new";
+ public static final String PREFS_APP_UPDATE_LAST_BUILD_ID = "app.update.last_build_id";
+ public static final String PREFS_READ_PARTNER_CUSTOMIZATIONS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_customizations_provider";
+ public static final String PREFS_READ_PARTNER_BOOKMARKS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_bookmarks_provider";
+ public static final String PREFS_CUSTOM_TABS = NON_PREF_PREFIX + "customtabs";
+ public static final String PREFS_ACTIVITY_STREAM = NON_PREF_PREFIX + "activitystream";
+ public static final String PREFS_CATEGORY_EXPERIMENTAL_FEATURES = NON_PREF_PREFIX + "category_experimental";
+
+ private static final String ACTION_STUMBLER_UPLOAD_PREF = "STUMBLER_PREF";
+
+
+ // This isn't a Gecko pref, even if it looks like one.
+ private static final String PREFS_BROWSER_LOCALE = "locale";
+
+ public static final String PREFS_RESTORE_SESSION = NON_PREF_PREFIX + "restoreSession3";
+ public static final String PREFS_RESTORE_SESSION_FROM_CRASH = "browser.sessionstore.resume_from_crash";
+ public static final String PREFS_RESTORE_SESSION_MAX_CRASH_RESUMES = "browser.sessionstore.max_resumed_crashes";
+ public static final String PREFS_TAB_QUEUE = NON_PREF_PREFIX + "tab_queue";
+ public static final String PREFS_TAB_QUEUE_LAST_SITE = NON_PREF_PREFIX + "last_site";
+ public static final String PREFS_TAB_QUEUE_LAST_TIME = NON_PREF_PREFIX + "last_time";
+
+ private static final String PREFS_DYNAMIC_TOOLBAR = "browser.chrome.dynamictoolbar";
+
+ // These values are chosen to be distinct from other Activity constants.
+ private static final int REQUEST_CODE_PREF_SCREEN = 5;
+ private static final int RESULT_CODE_EXIT_SETTINGS = 6;
+
+ // Result code used when a locale preference changes.
+ // Callers can recognize this code to refresh themselves to
+ // accommodate a locale change.
+ public static final int RESULT_CODE_LOCALE_DID_CHANGE = 7;
+
+ private static final int REQUEST_CODE_TAB_QUEUE = 8;
+
+ private final Map<String, PrefHandler> HANDLERS;
+ {
+ final HashMap<String, PrefHandler> tempHandlers = new HashMap<>(2);
+ tempHandlers.put(ClearOnShutdownPref.PREF, new ClearOnShutdownPref());
+ tempHandlers.put(AndroidImportPreference.PREF_KEY, new AndroidImportPreference.Handler());
+ HANDLERS = Collections.unmodifiableMap(tempHandlers);
+ }
+
+ private SwitchPreference tabQueuePreference;
+
+ /**
+ * Track the last locale so we know whether to redisplay.
+ */
+ private Locale lastLocale = Locale.getDefault();
+ private boolean localeSwitchingIsEnabled;
+
+ private void startActivityForResultChoosingTransition(final Intent intent, final int requestCode) {
+ startActivityForResult(intent, requestCode);
+ if (NO_TRANSITIONS) {
+ overridePendingTransition(0, 0);
+ }
+ }
+
+ private void finishChoosingTransition() {
+ finish();
+ if (NO_TRANSITIONS) {
+ overridePendingTransition(0, 0);
+ }
+ }
+ private void updateActionBarTitle(int title) {
+ final String newTitle = getString(title);
+ if (newTitle != null) {
+ Log.v(LOGTAG, "Setting action bar title to " + newTitle);
+
+ setTitle(newTitle);
+ }
+ }
+
+ /**
+ * We only call this method for pre-HC versions of Android.
+ */
+ private void updateTitleForPrefsResource(int res) {
+ // At present we only need to do this for non-leaf prefs views
+ // and the locale switcher itself.
+ int title = -1;
+ if (res == R.xml.preferences) {
+ title = R.string.settings_title;
+ } else if (res == R.xml.preferences_locale) {
+ title = R.string.pref_category_language;
+ } else if (res == R.xml.preferences_vendor) {
+ title = R.string.pref_category_vendor;
+ } else if (res == R.xml.preferences_general) {
+ title = R.string.pref_category_general;
+ } else if (res == R.xml.preferences_search) {
+ title = R.string.pref_category_search;
+ }
+ if (title != -1) {
+ setTitle(title);
+ }
+ }
+
+ private void onLocaleChanged(Locale newLocale) {
+ Log.d(LOGTAG, "onLocaleChanged: " + newLocale);
+
+ BrowserLocaleManager.getInstance().updateConfiguration(getApplicationContext(), newLocale);
+ this.lastLocale = newLocale;
+
+ if (isMultiPane()) {
+ // This takes care of the left pane.
+ invalidateHeaders();
+
+ // Detach and reattach the current prefs pane so that it
+ // reflects the new locale.
+ final FragmentManager fragmentManager = getFragmentManager();
+ int id = getResources().getIdentifier("android:id/prefs", null, null);
+ final Fragment current = fragmentManager.findFragmentById(id);
+ if (current != null) {
+ fragmentManager.beginTransaction()
+ .disallowAddToBackStack()
+ .detach(current)
+ .attach(current)
+ .commitAllowingStateLoss();
+ } else {
+ Log.e(LOGTAG, "No prefs fragment to reattach!");
+ }
+
+ // Because Android just rebuilt the activity itself with the
+ // old language, we need to update the top title and other
+ // wording again.
+ if (onIsMultiPane()) {
+ updateActionBarTitle(R.string.settings_title);
+ }
+
+ // Update the title to for the preference pane that we're currently showing.
+ final int titleId = getIntent().getExtras().getInt(PreferenceActivity.EXTRA_SHOW_FRAGMENT_TITLE);
+ if (titleId != NO_SUCH_ID) {
+ setTitle(titleId);
+ } else {
+ throw new IllegalStateException("Title id not found in intent bundle extras");
+ }
+
+ // Don't finish the activity -- we just reloaded all of the
+ // individual parts! -- but when it returns, make sure that the
+ // caller knows the locale changed.
+ setResult(RESULT_CODE_LOCALE_DID_CHANGE);
+ return;
+ }
+
+ refreshSuggestedSites();
+
+ // Cause the current fragment to redisplay, the hard way.
+ // This avoids nonsense with trying to reach inside fragments and force them
+ // to redisplay themselves.
+ // We also don't need to update the title.
+ final Intent intent = (Intent) getIntent().clone();
+ intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN);
+
+ setResult(RESULT_CODE_LOCALE_DID_CHANGE);
+ finishChoosingTransition();
+ }
+
+ private void checkLocale() {
+ final Locale currentLocale = Locale.getDefault();
+ Log.v(LOGTAG, "Checking locale: " + currentLocale + " vs " + lastLocale);
+ if (currentLocale.equals(lastLocale)) {
+ return;
+ }
+
+ onLocaleChanged(currentLocale);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ // Apply the current user-selected locale, if necessary.
+ checkLocale();
+
+ // Track this so we can decide whether to show locale options.
+ // See also the workaround below for Bug 1015209.
+ localeSwitchingIsEnabled = BrowserLocaleManager.getInstance().isEnabled();
+
+ // For Android v11+ where we use Fragments (v11+ only due to bug 866352),
+ // check that PreferenceActivity.EXTRA_SHOW_FRAGMENT has been set
+ // (or set it) before super.onCreate() is called so Android can display
+ // the correct Fragment resource.
+ // Note: this seems to only be required for non-multipane devices, multipane
+ // manages to automatically select the correct fragments.
+ if (!getIntent().hasExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT)) {
+ // Set up the default fragment if there is no explicit fragment to show.
+ setupTopLevelFragmentIntent();
+ }
+
+ // We must call this before setTitle to avoid crashes. Most devices don't seem to care
+ // (we used to call onCreate later), however the ASUS TF300T (running 4.2) crashes
+ // with an NPE in android.support.v7.app.AppCompatDelegateImplV7.ensureSubDecor(), and it's
+ // likely other strange devices (other Asus devices, some Samsungs) could do the same.
+ super.onCreate(savedInstanceState);
+
+ if (onIsMultiPane()) {
+ // So that Android doesn't put the fragment title (or nothing at
+ // all) in the action bar.
+ updateActionBarTitle(R.string.settings_title);
+
+ if (Build.VERSION.SDK_INT < 13) {
+ // Affected by Bug 1015209 -- no detach/attach.
+ // If we try rejigging fragments, we'll crash, so don't
+ // enable locale switching at all.
+ localeSwitchingIsEnabled = false;
+ throw new IllegalStateException("foobar");
+ }
+ }
+
+ // Use setResourceToOpen to specify these extras.
+ Bundle intentExtras = getIntent().getExtras();
+
+ EventDispatcher.getInstance().registerGeckoThreadListener(this,
+ "Sanitize:Finished",
+ "Snackbar:Show");
+
+ // Add handling for long-press click.
+ // This is only for Android 3.0 and below (which use the long-press-context-menu paradigm).
+ final ListView mListView = getListView();
+ mListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ // Call long-click handler if it the item implements it.
+ final ListAdapter listAdapter = ((ListView) parent).getAdapter();
+ final Object listItem = listAdapter.getItem(position);
+
+ // Only CustomListPreference handles long clicks.
+ if (listItem instanceof CustomListPreference && listItem instanceof View.OnLongClickListener) {
+ final View.OnLongClickListener longClickListener = (View.OnLongClickListener) listItem;
+ return longClickListener.onLongClick(view);
+ }
+ return false;
+ }
+ });
+
+ // N.B., if we ever need to redisplay the locale selection UI without
+ // just finishing and recreating the activity, right here we'll need to
+ // capture EXTRA_SHOW_FRAGMENT_TITLE from the intent and store the title ID.
+
+ // If launched from notification, explicitly cancel the notification.
+ if (intentExtras != null && intentExtras.containsKey(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.LAUNCH, Method.NOTIFICATION, "settings-data-choices");
+ NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode());
+ }
+
+ // Launched from "Notifications settings" action button in a notification.
+ if (intentExtras != null && intentExtras.containsKey(CheckForUpdatesAction.EXTRA_CONTENT_NOTIFICATION)) {
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(this));
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.BUTTON, "notification-settings");
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, FeedService.getEnabledExperiment(this));
+ }
+ }
+
+ /**
+ * Set intent to display top-level settings fragment,
+ * and show the correct title.
+ */
+ private void setupTopLevelFragmentIntent() {
+ Intent intent = getIntent();
+ // Check intent to determine settings screen to display.
+ Bundle intentExtras = intent.getExtras();
+ Bundle fragmentArgs = new Bundle();
+ // Add resource argument to fragment if it exists.
+ if (intentExtras != null && intentExtras.containsKey(INTENT_EXTRA_RESOURCES)) {
+ String resourceName = intentExtras.getString(INTENT_EXTRA_RESOURCES);
+ fragmentArgs.putString(INTENT_EXTRA_RESOURCES, resourceName);
+ } else {
+ // Use top-level settings screen.
+ if (!onIsMultiPane()) {
+ fragmentArgs.putString(INTENT_EXTRA_RESOURCES, "preferences");
+ } else {
+ fragmentArgs.putString(INTENT_EXTRA_RESOURCES, "preferences_general_tablet");
+ }
+ }
+
+ // Build fragment intent.
+ intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, GeckoPreferenceFragment.class.getName());
+ intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs);
+ // Used to get fragment title when locale changes (see onLocaleChanged method above)
+ intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_TITLE, R.string.settings_title);
+ }
+
+ @Override
+ public boolean isValidFragment(String fragmentName) {
+ return GeckoPreferenceFragment.class.getName().equals(fragmentName);
+ }
+
+ @TargetApi(11)
+ @Override
+ public void onBuildHeaders(List<Header> target) {
+ if (onIsMultiPane()) {
+ loadHeadersFromResource(R.xml.preference_headers, target);
+
+ Iterator<Header> iterator = target.iterator();
+
+ while (iterator.hasNext()) {
+ Header header = iterator.next();
+
+ if (header.id == R.id.pref_header_advanced && !Restrictions.isAllowed(this, Restrictable.ADVANCED_SETTINGS)) {
+ iterator.remove();
+ } else if (header.id == R.id.pref_header_clear_private_data
+ && !Restrictions.isAllowed(this, Restrictable.CLEAR_HISTORY)) {
+ iterator.remove();
+ }
+ }
+
+ mHeaders = target;
+ }
+ }
+
+ @TargetApi(11)
+ public void switchToHeader(int id) {
+ if (mHeaders == null) {
+ // Can't switch to a header if there are no headers!
+ return;
+ }
+
+ for (Header header : mHeaders) {
+ if (header.id == id) {
+ switchToHeader(header);
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (!hasFocus || mInitialized)
+ return;
+
+ mInitialized = true;
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+
+ if (NO_TRANSITIONS) {
+ overridePendingTransition(0, 0);
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, Method.BACK, "settings");
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
+ "Sanitize:Finished",
+ "Snackbar:Show");
+
+ if (mPrefsRequest != null) {
+ PrefsHelper.removeObserver(mPrefsRequest);
+ mPrefsRequest = null;
+ }
+ }
+
+ @Override
+ public void onPause() {
+ // Symmetric with onResume.
+ if (isMultiPane()) {
+ SharedPreferences prefs = GeckoSharedPrefs.forApp(this);
+ prefs.unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ super.onPause();
+
+ if (getApplication() instanceof GeckoApplication) {
+ ((GeckoApplication) getApplication()).onActivityPause(this);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (getApplication() instanceof GeckoApplication) {
+ ((GeckoApplication) getApplication()).onActivityResume(this);
+ }
+
+ // Watch prefs, otherwise we don't reliably get told when they change.
+ // See documentation for onSharedPreferenceChange for more.
+ // Inexplicably only needed on tablet.
+ if (isMultiPane()) {
+ SharedPreferences prefs = GeckoSharedPrefs.forApp(this);
+ prefs.registerOnSharedPreferenceChangeListener(this);
+ }
+ }
+
+ @Override
+ public void startActivity(Intent intent) {
+ // For settings, we want to be able to pass results up the chain
+ // of preference screens so Settings can behave as a single unit.
+ // Specifically, when we open a link, we want to back out of all
+ // the settings screens.
+ // We need to start nested PreferenceScreens withStartActivityForResult().
+ // Android doesn't let us do that (see Preference.onClick), so we're overriding here.
+ startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN);
+ }
+
+ @Override
+ public void startWithFragment(String fragmentName, Bundle args,
+ Fragment resultTo, int resultRequestCode, int titleRes, int shortTitleRes) {
+ Log.v(LOGTAG, "Starting with fragment: " + fragmentName + ", title " + titleRes);
+
+ // Overriding because we want to use startActivityForResult for Fragment intents.
+ Intent intent = onBuildStartFragmentIntent(fragmentName, args, titleRes, shortTitleRes);
+ if (resultTo == null) {
+ startActivityForResultChoosingTransition(intent, REQUEST_CODE_PREF_SCREEN);
+ } else {
+ resultTo.startActivityForResult(intent, resultRequestCode);
+ if (NO_TRANSITIONS) {
+ overridePendingTransition(0, 0);
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ // We might have just returned from a settings activity that allows us
+ // to switch locales, so reflect any change that occurred.
+ checkLocale();
+
+ switch (requestCode) {
+ case REQUEST_CODE_PREF_SCREEN:
+ switch (resultCode) {
+ case RESULT_CODE_EXIT_SETTINGS:
+ updateActionBarTitle(R.string.settings_title);
+
+ // Pass this result up to the parent activity.
+ setResult(RESULT_CODE_EXIT_SETTINGS);
+ finishChoosingTransition();
+ break;
+ }
+ break;
+ case REQUEST_CODE_TAB_QUEUE:
+ if (TabQueueHelper.processTabQueuePromptResponse(resultCode, this)) {
+ tabQueuePreference.setChecked(true);
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ Permissions.onRequestPermissionsResult(this, permissions, grantResults);
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ try {
+ switch (event) {
+ case "Sanitize:Finished":
+ boolean success = message.getBoolean("success");
+ final int stringRes = success ? R.string.private_data_success : R.string.private_data_fail;
+
+ SnackbarBuilder.builder(GeckoPreferences.this)
+ .message(stringRes)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ break;
+ case "Snackbar:Show":
+ SnackbarBuilder.builder(this)
+ .fromEvent(message)
+ .callback(callback)
+ .buildAndShow();
+ break;
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ /**
+ * Initialize all of the preferences (native of Gecko ones) for this screen.
+ *
+ * @param prefs The android.preference.PreferenceGroup to initialize
+ * @return The integer id for the PrefsHelper.PrefHandlerBase listener added
+ * to monitor changes to Gecko prefs.
+ */
+ public PrefsHelper.PrefHandler setupPreferences(PreferenceGroup prefs) {
+ ArrayList<String> list = new ArrayList<String>();
+ setupPreferences(prefs, list);
+ return getGeckoPreferences(prefs, list);
+ }
+
+ /**
+ * Recursively loop through a PreferenceGroup. Initialize native Android prefs,
+ * and build a list of Gecko preferences in the passed in prefs array
+ *
+ * @param preferences The android.preference.PreferenceGroup to initialize
+ * @param prefs An ArrayList to fill with Gecko preferences that need to be
+ * initialized
+ * @return The integer id for the PrefsHelper.PrefHandlerBase listener added
+ * to monitor changes to Gecko prefs.
+ */
+ private void setupPreferences(PreferenceGroup preferences, ArrayList<String> prefs) {
+ for (int i = 0; i < preferences.getPreferenceCount(); i++) {
+ final Preference pref = preferences.getPreference(i);
+
+ // Eliminate locale switching if necessary.
+ // This logic will need to be extended when
+ // content language selection (Bug 881510) is implemented.
+ if (!localeSwitchingIsEnabled &&
+ "preferences_locale".equals(pref.getExtras().getString("resource"))) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+
+ String key = pref.getKey();
+ if (pref instanceof PreferenceGroup) {
+ // If datareporting is disabled, remove UI.
+ if (PREFS_DATA_REPORTING_PREFERENCES.equals(key)) {
+ if (!AppConstants.MOZ_DATA_REPORTING || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_SCREEN_ADVANCED.equals(key) &&
+ !Restrictions.isAllowed(this, Restrictable.ADVANCED_SETTINGS)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ } else if (PREFS_CATEGORY_EXPERIMENTAL_FEATURES.equals(key)
+ && !AppConstants.MOZ_ANDROID_ACTIVITY_STREAM
+ && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ setupPreferences((PreferenceGroup) pref, prefs);
+ } else {
+ if (HANDLERS.containsKey(key)) {
+ PrefHandler handler = HANDLERS.get(key);
+ if (!handler.setupPref(this, pref)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ }
+
+ pref.setOnPreferenceChangeListener(this);
+ if (PREFS_UPDATER_AUTODOWNLOAD.equals(key)) {
+ if (!AppConstants.MOZ_UPDATER || ContextUtils.isInstalledFromGooglePlay(this)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_TRACKING_PROTECTION.equals(key)) {
+ // Remove UI for global TP pref in non-Nightly builds.
+ if (!AppConstants.NIGHTLY_BUILD) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_TRACKING_PROTECTION_PB.equals(key)) {
+ // Remove UI for private-browsing-only TP pref in Nightly builds.
+ if (AppConstants.NIGHTLY_BUILD) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_TELEMETRY_ENABLED.equals(key)) {
+ if (!AppConstants.MOZ_TELEMETRY_REPORTING || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(key) ||
+ PREFS_HEALTHREPORT_LINK.equals(key)) {
+ if (!AppConstants.MOZ_SERVICES_HEALTHREPORT || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_CRASHREPORTER_ENABLED.equals(key)) {
+ if (!AppConstants.MOZ_CRASHREPORTER || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_GEO_REPORTING.equals(key) ||
+ PREFS_GEO_LEARN_MORE.equals(key)) {
+ if (!AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED || !Restrictions.isAllowed(this, Restrictable.DATA_CHOICES)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_DEVTOOLS_REMOTE_USB_ENABLED.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_DEVTOOLS_REMOTE_WIFI_ENABLED.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ if (!InputOptionsUtils.supportsQrCodeReader(getApplicationContext())) {
+ // WiFi debugging requires a QR code reader
+ pref.setEnabled(false);
+ pref.setSummary(getString(R.string.pref_developer_remotedebugging_wifi_disabled_summary));
+ continue;
+ }
+ } else if (PREFS_DEVTOOLS_REMOTE_LINK.equals(key)) {
+ // Remove the "Learn more" link if remote debugging is disabled
+ if (!Restrictions.isAllowed(this, Restrictable.REMOTE_DEBUGGING)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_RESTORE_SESSION.equals(key) ||
+ PREFS_BROWSER_LOCALE.equals(key)) {
+ // Set the summary string to the current entry. The summary
+ // for other list prefs will be set in the PrefsHelper
+ // callback, but since this pref doesn't live in Gecko, we
+ // need to handle it separately.
+ ListPreference listPref = (ListPreference) pref;
+ CharSequence selectedEntry = listPref.getEntry();
+ listPref.setSummary(selectedEntry);
+ continue;
+ } else if (PREFS_SYNC.equals(key)) {
+ // Don't show sync prefs while in guest mode.
+ if (!Restrictions.isAllowed(this, Restrictable.MODIFY_ACCOUNTS)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_SEARCH_RESTORE_DEFAULTS.equals(key)) {
+ pref.setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ GeckoPreferences.this.restoreDefaultSearchEngines();
+ Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_RESTORE_DEFAULTS, Method.LIST_ITEM);
+ return true;
+ }
+ });
+ } else if (PREFS_TAB_QUEUE.equals(key)) {
+ tabQueuePreference = (SwitchPreference) pref;
+ // Only show tab queue pref on nightly builds with the tab queue build flag.
+ if (!TabQueueHelper.TAB_QUEUE_ENABLED) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_ZOOMED_VIEW_ENABLED.equals(key)) {
+ if (!AppConstants.NIGHTLY_BUILD) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_VOICE_INPUT_ENABLED.equals(key)) {
+ if (!InputOptionsUtils.supportsVoiceRecognizer(getApplicationContext(), getResources().getString(R.string.voicesearch_prompt))) {
+ // Remove UI for voice input on non nightly builds.
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_QRCODE_ENABLED.equals(key)) {
+ if (!InputOptionsUtils.supportsQrCodeReader(getApplicationContext())) {
+ // Remove UI for qr code input on non nightly builds
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_TRACKING_PROTECTION_PRIVATE_BROWSING.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_TRACKING_PROTECTION_LEARN_MORE.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.PRIVATE_BROWSING)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_MP_ENABLED.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.MASTER_PASSWORD)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_CLEAR_PRIVATE_DATA.equals(key) || PREFS_CLEAR_PRIVATE_DATA_EXIT.equals(key)) {
+ if (!Restrictions.isAllowed(this, Restrictable.CLEAR_HISTORY)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_HOMEPAGE.equals(key)) {
+ String setUrl = GeckoSharedPrefs.forProfile(getBaseContext()).getString(PREFS_HOMEPAGE, AboutPages.HOME);
+ setHomePageSummary(pref, setUrl);
+ pref.setOnPreferenceChangeListener(this);
+ } else if (PREFS_FAQ_LINK.equals(key)) {
+ // Format the FAQ link
+ final String VERSION = AppConstants.MOZ_APP_VERSION;
+ final String OS = AppConstants.OS_TARGET;
+ final String LOCALE = Locales.getLanguageTag(Locale.getDefault());
+
+ final String url = getResources().getString(R.string.faq_link, VERSION, OS, LOCALE);
+ ((LinkPreference) pref).setUrl(url);
+ } else if (PREFS_FEEDBACK_LINK.equals(key)) {
+ // Format the feedback link. We can't easily use this "app.feedbackURL"
+ // Gecko preference because the URL must be formatted.
+ final String url = getResources().getString(R.string.feedback_link, AppConstants.MOZ_APP_VERSION, AppConstants.MOZ_UPDATE_CHANNEL);
+ ((LinkPreference) pref).setUrl(url);
+ } else if (PREFS_DYNAMIC_TOOLBAR.equals(key)) {
+ if (DynamicToolbar.isForceDisabled()) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_NOTIFICATIONS_CONTENT.equals(key) ||
+ PREFS_NOTIFICATIONS_CONTENT_LEARN_MORE.equals(key)) {
+ if (!FeedService.isInExperiment(this)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+ } else if (PREFS_CUSTOM_TABS.equals(key) && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ } else if (PREFS_ACTIVITY_STREAM.equals(key) && !ActivityStream.isUserEligible(this)) {
+ preferences.removePreference(pref);
+ i--;
+ continue;
+ }
+
+ // Some Preference UI elements are not actually preferences,
+ // but they require a key to work correctly. For example,
+ // "Clear private data" requires a key for its state to be
+ // saved when the orientation changes. It uses the
+ // "android.not_a_preference.privacy.clear" key - which doesn't
+ // exist in Gecko - to satisfy this requirement.
+ if (isGeckoPref(key)) {
+ prefs.add(key);
+ }
+ }
+ }
+ }
+
+ private void setHomePageSummary(Preference pref, String value) {
+ if (!TextUtils.isEmpty(value)) {
+ pref.setSummary(value);
+ } else {
+ pref.setSummary(AboutPages.HOME);
+ }
+ }
+
+ private boolean isGeckoPref(String key) {
+ if (TextUtils.isEmpty(key)) {
+ return false;
+ }
+
+ if (key.startsWith(NON_PREF_PREFIX)) {
+ return false;
+ }
+
+ if (key.equals(PREFS_BROWSER_LOCALE)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Restore default search engines in Gecko and retrigger a search engine refresh.
+ */
+ protected void restoreDefaultSearchEngines() {
+ GeckoAppShell.notifyObservers("SearchEngines:RestoreDefaults", null);
+
+ // Send message to Gecko to get engines. SearchPreferenceCategory listens for the response.
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int itemId = item.getItemId();
+ switch (itemId) {
+ case android.R.id.home:
+ finishChoosingTransition();
+ return true;
+ }
+
+ // Generated R.id.* apparently aren't constant expressions, so they can't be switched.
+ if (itemId == R.id.restore_defaults) {
+ restoreDefaultSearchEngines();
+ Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_RESTORE_DEFAULTS, Method.MENU);
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ final private int DIALOG_CREATE_MASTER_PASSWORD = 0;
+ final private int DIALOG_REMOVE_MASTER_PASSWORD = 1;
+
+ public static void setCharEncodingState(boolean enabled) {
+ sIsCharEncodingEnabled = enabled;
+ }
+
+ public static boolean getCharEncodingState() {
+ return sIsCharEncodingEnabled;
+ }
+
+ public static void broadcastAction(final Context context, final Intent intent) {
+ fillIntentWithProfileInfo(context, intent);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+
+ private static void fillIntentWithProfileInfo(final Context context, final Intent intent) {
+ // There is a race here, but GeckoProfile returns the default profile
+ // when Gecko is not explicitly running for a different profile. In a
+ // multi-profile world, this will need to be updated (possibly to
+ // broadcast settings for all profiles). See Bug 882182.
+ GeckoProfile profile = GeckoProfile.get(context);
+ if (profile != null) {
+ intent.putExtra("profileName", profile.getName())
+ .putExtra("profilePath", profile.getDir().getAbsolutePath());
+ }
+ }
+
+ /**
+ * Broadcast the provided value as the value of the
+ * <code>PREFS_GEO_REPORTING</code> pref.
+ */
+ public static void broadcastStumblerPref(final Context context, final boolean value) {
+ Intent intent = new Intent(ACTION_STUMBLER_UPLOAD_PREF)
+ .putExtra("pref", PREFS_GEO_REPORTING)
+ .putExtra("branch", GeckoSharedPrefs.APP_PREFS_NAME)
+ .putExtra("enabled", value)
+ .putExtra("moz_mozilla_api_key", AppConstants.MOZ_MOZILLA_API_KEY);
+ if (GeckoAppShell.getGeckoInterface() != null) {
+ intent.putExtra("user_agent", GeckoAppShell.getGeckoInterface().getDefaultUAString());
+ }
+ broadcastAction(context, intent);
+ }
+
+ /**
+ * Broadcast the current value of the
+ * <code>PREFS_GEO_REPORTING</code> pref.
+ */
+ public static void broadcastStumblerPref(final Context context) {
+ final boolean value = getBooleanPref(context, PREFS_GEO_REPORTING, false);
+ broadcastStumblerPref(context, value);
+ }
+
+ /**
+ * Return the value of the named preference in the default preferences file.
+ *
+ * This corresponds to the storage that backs preferences.xml.
+ * @param context a <code>Context</code>; the
+ * <code>PreferenceActivity</code> will suffice, but this
+ * method is intended to be called from other contexts
+ * within the application, not just this <code>Activity</code>.
+ * @param name the name of the preference to retrieve.
+ * @param def the default value to return if the preference is not present.
+ * @return the value of the preference, or the default.
+ */
+ public static boolean getBooleanPref(final Context context, final String name, boolean def) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ return prefs.getBoolean(name, def);
+ }
+
+ /**
+ * Immediately handle the user's selection of a browser locale.
+ *
+ * Earlier locale-handling code did this with centralized logic in
+ * GeckoApp, delegating to LocaleManager for persistence and refreshing
+ * the activity as necessary.
+ *
+ * We no longer handle this by sending a message to GeckoApp, for
+ * several reasons:
+ *
+ * * GeckoApp might not be running. Activities don't always stick around.
+ * A Java bridge message might not be handled.
+ * * We need to adapt the preferences UI to the locale ourselves.
+ * * The user might not hit Back (or Up) -- they might hit Home and never
+ * come back.
+ *
+ * We handle the case of the user returning to the browser via the
+ * onActivityResult mechanism: see {@link BrowserApp#onActivityResult(int, int, Intent)}.
+ */
+ private boolean onLocaleSelected(final String currentLocale, final String newValue) {
+ final Context context = getApplicationContext();
+
+ // LocaleManager operations need to occur on the background thread.
+ // ... but activity operations need to occur on the UI thread. So we
+ // have nested runnables.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+
+ if (TextUtils.isEmpty(newValue)) {
+ BrowserLocaleManager.getInstance().resetToSystemLocale(context);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_RESET);
+ } else {
+ if (null == localeManager.setSelectedLocale(context, newValue)) {
+ localeManager.updateConfiguration(context, Locale.getDefault());
+ }
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_UNSELECTED, Method.NONE,
+ currentLocale == null ? "unknown" : currentLocale);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOCALE_BROWSER_SELECTED, Method.NONE, newValue);
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ onLocaleChanged(Locale.getDefault());
+ }
+ });
+ }
+ });
+
+ return true;
+ }
+
+ private void refreshSuggestedSites() {
+ final ContentResolver cr = getApplicationContext().getContentResolver();
+
+ // This will force all active suggested sites cursors
+ // to request a refresh (e.g. cursor loaders).
+ cr.notifyChange(SuggestedSites.CONTENT_URI, null);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale);
+
+ if (lastLocale.equals(newConfig.locale)) {
+ Log.d(LOGTAG, "Old locale same as new locale. Short-circuiting.");
+ return;
+ }
+
+ final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+ final Locale changed = localeManager.onSystemConfigurationChanged(this, getResources(), newConfig, lastLocale);
+ if (changed != null) {
+ onLocaleChanged(changed);
+ }
+ }
+
+ /**
+ * Implementation for the {@link OnSharedPreferenceChangeListener} interface,
+ * which we use to watch changes in our prefs file.
+ *
+ * This is reliably called whenever the pref changes, which is not the case
+ * for multiple consecutive changes in the case of onPreferenceChange.
+ *
+ * Note that this listener is not always registered: we use it only on
+ * tablets, Honeycomb and up, where we'll have a multi-pane view and prefs
+ * changing multiple times.
+ */
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (PREFS_BROWSER_LOCALE.equals(key)) {
+ onLocaleSelected(Locales.getLanguageTag(lastLocale),
+ sharedPreferences.getString(key, null));
+ }
+ }
+
+ public interface PrefHandler {
+ // Allows the pref to do any initialization it needs. Return false to have the pref removed
+ // from the prefs screen entirely.
+ public boolean setupPref(Context context, Preference pref);
+ public void onChange(Context context, Preference pref, Object newValue);
+ }
+
+ private void recordSettingChangeTelemetry(String prefName, Object newValue) {
+ final String value;
+ if (newValue instanceof Boolean) {
+ value = (Boolean) newValue ? "1" : "0";
+ } else if (prefName.equals(PREFS_HOMEPAGE)) {
+ // Don't record the user's homepage preference.
+ value = "*";
+ } else {
+ value = newValue.toString();
+ }
+
+ final JSONArray extras = new JSONArray();
+ extras.put(prefName);
+ extras.put(value);
+ Telemetry.sendUIEvent(TelemetryContract.Event.EDIT, Method.SETTINGS, extras.toString());
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ final String prefName = preference.getKey();
+ Log.i(LOGTAG, "Changed " + prefName + " = " + newValue);
+ recordSettingChangeTelemetry(prefName, newValue);
+
+ if (PREFS_MP_ENABLED.equals(prefName)) {
+ showDialog((Boolean) newValue ? DIALOG_CREATE_MASTER_PASSWORD : DIALOG_REMOVE_MASTER_PASSWORD);
+
+ // We don't want the "use master password" pref to change until the
+ // user has gone through the dialog.
+ return false;
+ }
+
+ if (PREFS_HOMEPAGE.equals(prefName)) {
+ setHomePageSummary(preference, String.valueOf(newValue));
+ }
+
+ if (PREFS_BROWSER_LOCALE.equals(prefName)) {
+ // Even though this is a list preference, we don't want to handle it
+ // below, so we return here.
+ return onLocaleSelected(Locales.getLanguageTag(lastLocale), (String) newValue);
+ }
+
+ if (PREFS_MENU_CHAR_ENCODING.equals(prefName)) {
+ setCharEncodingState(((String) newValue).equals("true"));
+ } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) {
+ UpdateServiceHelper.setAutoDownloadPolicy(this, UpdateService.AutoDownloadPolicy.get((String) newValue));
+ } else if (PREFS_UPDATER_URL.equals(prefName)) {
+ UpdateServiceHelper.setUpdateUrl(this, (String) newValue);
+ } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(prefName)) {
+ final Boolean newBooleanValue = (Boolean) newValue;
+ AdjustConstants.getAdjustHelper().setEnabled(newBooleanValue);
+ } else if (PREFS_GEO_REPORTING.equals(prefName)) {
+ if ((Boolean) newValue) {
+ enableStumbler((CheckBoxPreference) preference);
+ return false;
+ } else {
+ broadcastStumblerPref(GeckoPreferences.this, false);
+ return true;
+ }
+ } else if (PREFS_TAB_QUEUE.equals(prefName)) {
+ if ((Boolean) newValue && !TabQueueHelper.canDrawOverlays(this)) {
+ Intent promptIntent = new Intent(this, TabQueuePrompt.class);
+ startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE);
+ return false;
+ }
+ } else if (PREFS_NOTIFICATIONS_CONTENT.equals(prefName)) {
+ FeedService.setup(this);
+ } else if (PREFS_ACTIVITY_STREAM.equals(prefName)) {
+ ThreadUtils.postDelayedToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.scheduleRestart();
+ }
+ }, 1000);
+ } else if (HANDLERS.containsKey(prefName)) {
+ PrefHandler handler = HANDLERS.get(prefName);
+ handler.onChange(this, preference, newValue);
+ }
+
+ // Send Gecko-side pref changes to Gecko
+ if (isGeckoPref(prefName)) {
+ PrefsHelper.setPref(prefName, newValue, true /* flush */);
+ }
+
+ if (preference instanceof ListPreference) {
+ // We need to find the entry for the new value
+ int newIndex = ((ListPreference) preference).findIndexOfValue((String) newValue);
+ CharSequence newEntry = ((ListPreference) preference).getEntries()[newIndex];
+ ((ListPreference) preference).setSummary(newEntry);
+ } else if (preference instanceof LinkPreference) {
+ setResult(RESULT_CODE_EXIT_SETTINGS);
+ finishChoosingTransition();
+ } else if (preference instanceof FontSizePreference) {
+ final FontSizePreference fontSizePref = (FontSizePreference) preference;
+ fontSizePref.setSummary(fontSizePref.getSavedFontSizeName());
+ }
+
+ return true;
+ }
+
+ private void enableStumbler(final CheckBoxPreference preference) {
+ Permissions
+ .from(this)
+ .withPermissions(Manifest.permission.ACCESS_FINE_LOCATION)
+ .onUIThread()
+ .andFallback(new Runnable() {
+ @Override
+ public void run() {
+ preference.setChecked(false);
+ }
+ })
+ .run(new Runnable() {
+ @Override
+ public void run() {
+ preference.setChecked(true);
+ broadcastStumblerPref(GeckoPreferences.this, true);
+ }
+ });
+ }
+
+ private TextInputLayout getTextBox(int aHintText) {
+ final EditText input = new EditText(this);
+ int inputtype = InputType.TYPE_CLASS_TEXT;
+ inputtype |= InputType.TYPE_TEXT_VARIATION_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
+ input.setInputType(inputtype);
+
+ input.setHint(aHintText);
+
+ final TextInputLayout layout = new TextInputLayout(this);
+ layout.addView(input);
+
+ return layout;
+ }
+
+ private class PasswordTextWatcher implements TextWatcher {
+ EditText input1;
+ EditText input2;
+ AlertDialog dialog;
+
+ PasswordTextWatcher(EditText aInput1, EditText aInput2, AlertDialog aDialog) {
+ input1 = aInput1;
+ input2 = aInput2;
+ dialog = aDialog;
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (dialog == null)
+ return;
+
+ String text1 = input1.getText().toString();
+ String text2 = input2.getText().toString();
+ boolean disabled = TextUtils.isEmpty(text1) || TextUtils.isEmpty(text2) || !text1.equals(text2);
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!disabled);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) { }
+ }
+
+ private class EmptyTextWatcher implements TextWatcher {
+ EditText input;
+ AlertDialog dialog;
+
+ EmptyTextWatcher(EditText aInput, AlertDialog aDialog) {
+ input = aInput;
+ dialog = aDialog;
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (dialog == null)
+ return;
+
+ String text = input.getText().toString();
+ boolean disabled = TextUtils.isEmpty(text);
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!disabled);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) { }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ LinearLayout linearLayout = new LinearLayout(this);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ AlertDialog dialog;
+ switch (id) {
+ case DIALOG_CREATE_MASTER_PASSWORD:
+ final TextInputLayout inputLayout1 = getTextBox(R.string.masterpassword_password);
+ final TextInputLayout inputLayout2 = getTextBox(R.string.masterpassword_confirm);
+ linearLayout.addView(inputLayout1);
+ linearLayout.addView(inputLayout2);
+
+ final EditText input1 = inputLayout1.getEditText();
+ final EditText input2 = inputLayout2.getEditText();
+
+ builder.setTitle(R.string.masterpassword_create_title)
+ .setView((View) linearLayout)
+ .setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ PrefsHelper.setPref(PREFS_MP_ENABLED,
+ input1.getText().toString(),
+ /* flush */ true);
+ }
+ })
+ .setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ return;
+ }
+ });
+ dialog = builder.create();
+ dialog.setOnShowListener(new DialogInterface.OnShowListener() {
+ @Override
+ public void onShow(DialogInterface dialog) {
+ input1.setText("");
+ input2.setText("");
+ input1.requestFocus();
+ }
+ });
+
+ PasswordTextWatcher watcher = new PasswordTextWatcher(input1, input2, dialog);
+ input1.addTextChangedListener((TextWatcher) watcher);
+ input2.addTextChangedListener((TextWatcher) watcher);
+
+ break;
+ case DIALOG_REMOVE_MASTER_PASSWORD:
+ final TextInputLayout inputLayout = getTextBox(R.string.masterpassword_password);
+ linearLayout.addView(inputLayout);
+ final EditText input = inputLayout.getEditText();
+
+ builder.setTitle(R.string.masterpassword_remove_title)
+ .setView((View) linearLayout)
+ .setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ PrefsHelper.setPref(PREFS_MP_ENABLED, input.getText().toString());
+ }
+ })
+ .setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ return;
+ }
+ });
+ dialog = builder.create();
+ dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ input.setText("");
+ }
+ });
+ dialog.setOnShowListener(new DialogInterface.OnShowListener() {
+ @Override
+ public void onShow(DialogInterface dialog) {
+ input.setText("");
+ }
+ });
+ input.addTextChangedListener(new EmptyTextWatcher(input, dialog));
+ break;
+ default:
+ return null;
+ }
+
+ return dialog;
+ }
+
+ // Initialize preferences by requesting the preference values from Gecko
+ private static class PrefCallbacks extends PrefsHelper.PrefHandlerBase {
+ private final PreferenceGroup screen;
+
+ public PrefCallbacks(final PreferenceGroup screen) {
+ this.screen = screen;
+ }
+
+ private Preference getField(String prefName) {
+ return screen.findPreference(prefName);
+ }
+
+ @Override
+ public void prefValue(String prefName, final boolean value) {
+ final TwoStatePreference pref = (TwoStatePreference) getField(prefName);
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (pref.isChecked() != value) {
+ pref.setChecked(value);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void prefValue(String prefName, final String value) {
+ final Preference pref = getField(prefName);
+ if (pref instanceof EditTextPreference) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ((EditTextPreference) pref).setText(value);
+ }
+ });
+ } else if (pref instanceof ListPreference) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ((ListPreference) pref).setValue(value);
+ // Set the summary string to the current entry
+ CharSequence selectedEntry = ((ListPreference) pref).getEntry();
+ ((ListPreference) pref).setSummary(selectedEntry);
+ }
+ });
+ } else if (pref instanceof FontSizePreference) {
+ final FontSizePreference fontSizePref = (FontSizePreference) pref;
+ fontSizePref.setSavedFontSize(value);
+ final String fontSizeName = fontSizePref.getSavedFontSizeName();
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ fontSizePref.setSummary(fontSizeName); // Ex: "Small".
+ }
+ });
+ }
+ }
+
+ @Override
+ public void prefValue(String prefName, final int value) {
+ final Preference pref = getField(prefName);
+ Log.w(LOGTAG, "Unhandled int value for pref [" + pref + "]");
+ }
+
+ @Override
+ public void finish() {
+ // enable all preferences once we have them from gecko
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ screen.setEnabled(true);
+ }
+ });
+ }
+ }
+
+ private PrefsHelper.PrefHandler getGeckoPreferences(final PreferenceGroup screen,
+ ArrayList<String> prefs) {
+ final PrefsHelper.PrefHandler prefHandler = new PrefCallbacks(screen);
+ final String[] prefNames = prefs.toArray(new String[prefs.size()]);
+ PrefsHelper.addObserver(prefNames, prefHandler);
+ return prefHandler;
+ }
+
+ @Override
+ public boolean isGeckoActivityOpened() {
+ return false;
+ }
+
+ /**
+ * Given an Intent instance, add extras to specify which settings section to
+ * open.
+ *
+ * resource should be a valid Android XML resource identifier.
+ *
+ * The mechanism to open a section differs based on Android version.
+ */
+ public static void setResourceToOpen(final Intent intent, final String resource) {
+ if (intent == null) {
+ throw new IllegalArgumentException("intent must not be null");
+ }
+ if (resource == null) {
+ return;
+ }
+
+ intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, GeckoPreferenceFragment.class.getName());
+
+ Bundle fragmentArgs = new Bundle();
+ fragmentArgs.putString("resource", resource);
+ intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java
new file mode 100644
index 000000000..774f78c53
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/LinkPreference.java
@@ -0,0 +1,35 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.Tabs;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+class LinkPreference extends Preference {
+ private String mUrl;
+
+ public LinkPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mUrl = attrs.getAttributeValue(null, "url");
+ }
+ public LinkPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ mUrl = attrs.getAttributeValue(null, "url");
+ }
+
+ public void setUrl(String url) {
+ mUrl = url;
+ }
+
+ @Override
+ protected void onClick() {
+ Tabs.getInstance().loadUrlInTab(mUrl);
+ callChangeListener(mUrl);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java
new file mode 100644
index 000000000..f56ea58b9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ListCheckboxPreference.java
@@ -0,0 +1,58 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.Checkable;
+
+import org.mozilla.gecko.R;
+
+/**
+ * This preference shows a checkbox on its left hand side, but will show a menu when clicked.
+ * Its used for preferences like "Clear on Exit" that have a boolean on-off state, but that represent
+ * multiple boolean options inside.
+ **/
+class ListCheckboxPreference extends MultiChoicePreference implements Checkable {
+ private static final String LOGTAG = "GeckoListCheckboxPreference";
+ private boolean checked;
+
+ public ListCheckboxPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setWidgetLayoutResource(R.layout.preference_checkbox);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return checked;
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ View checkboxView = view.findViewById(R.id.checkbox);
+ if (checkboxView instanceof Checkable) {
+ ((Checkable) checkboxView).setChecked(checked);
+ }
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ boolean changed = checked != this.checked;
+ this.checked = checked;
+ if (changed) {
+ notifyDependencyChange(shouldDisableDependents());
+ notifyChanged();
+ }
+ }
+
+ @Override
+ public void toggle() {
+ checked = !checked;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java
new file mode 100644
index 000000000..c962a3d19
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/LocaleListPreference.java
@@ -0,0 +1,316 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import java.nio.ByteBuffer;
+import java.text.Collator;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.preference.ListPreference;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+
+public class LocaleListPreference extends ListPreference {
+ private static final String LOG_TAG = "GeckoLocaleList";
+
+ /**
+ * With thanks to <http://stackoverflow.com/a/22679283/22003> for the
+ * initial solution.
+ *
+ * This class encapsulates an approach to checking whether a script
+ * is usable on a device. We attempt to draw a character from the
+ * script (e.g., ব). If the fonts on the device don't have the correct
+ * glyph, Android typically renders whitespace (rather than .notdef).
+ *
+ * Pass in part of the name of the locale in its local representation,
+ * and a whitespace character; this class performs the graphical comparison.
+ *
+ * See Bug 1023451 Comment 24 for extensive explanation.
+ */
+ private static class CharacterValidator {
+ private static final int BITMAP_WIDTH = 32;
+ private static final int BITMAP_HEIGHT = 48;
+
+ private final Paint paint = new Paint();
+ private final byte[] missingCharacter;
+
+ public CharacterValidator(String missing) {
+ this.missingCharacter = getPixels(drawBitmap(missing));
+ }
+
+ private Bitmap drawBitmap(String text) {
+ Bitmap b = Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ALPHA_8);
+ Canvas c = new Canvas(b);
+ c.drawText(text, 0, BITMAP_HEIGHT / 2, this.paint);
+ return b;
+ }
+
+ private static byte[] getPixels(final Bitmap b) {
+ final int byteCount;
+ if (Versions.feature19Plus) {
+ byteCount = b.getAllocationByteCount();
+ } else {
+ // Close enough for government work.
+ // Equivalent to getByteCount, but works on <12.
+ byteCount = b.getRowBytes() * b.getHeight();
+ }
+
+ final ByteBuffer buffer = ByteBuffer.allocate(byteCount);
+ try {
+ b.copyPixelsToBuffer(buffer);
+ } catch (RuntimeException e) {
+ // Android throws this if there's not enough space in the buffer.
+ // This should never occur, but if it does, we don't
+ // really care -- we probably don't need the entire image.
+ // This is awful. I apologize.
+ if ("Buffer not large enough for pixels".equals(e.getMessage())) {
+ return buffer.array();
+ }
+ throw e;
+ }
+
+ return buffer.array();
+ }
+
+ public boolean characterIsMissingInFont(String ch) {
+ byte[] rendered = getPixels(drawBitmap(ch));
+ return Arrays.equals(rendered, missingCharacter);
+ }
+ }
+
+ private volatile Locale entriesLocale;
+ private final CharacterValidator characterValidator;
+
+ public LocaleListPreference(Context context) {
+ this(context, null);
+ }
+
+ public LocaleListPreference(Context context, AttributeSet attributes) {
+ super(context, attributes);
+
+ // Thus far, missing glyphs are replaced by whitespace, not a box
+ // or other Unicode codepoint.
+ this.characterValidator = new CharacterValidator(" ");
+ buildList();
+ }
+
+ private static final class LocaleDescriptor implements Comparable<LocaleDescriptor> {
+ // We use Locale.US here to ensure a stable ordering of entries.
+ private static final Collator COLLATOR = Collator.getInstance(Locale.US);
+
+ public final String tag;
+ private final String nativeName;
+
+ public LocaleDescriptor(String tag) {
+ this(Locales.parseLocaleCode(tag), tag);
+ }
+
+ public LocaleDescriptor(Locale locale, String tag) {
+ this.tag = tag;
+
+ final String displayName = locale.getDisplayName(locale);
+ if (TextUtils.isEmpty(displayName)) {
+ // There's nothing sane we can do.
+ Log.w(LOG_TAG, "Display name is empty. Using " + locale.toString());
+ this.nativeName = locale.toString();
+ return;
+ }
+
+ // For now, uppercase the first character of LTR locale names.
+ // This is pretty much what Android does. This is a reasonable hack
+ // for Bug 1014602, but it won't generalize to all locales.
+ final byte directionality = Character.getDirectionality(displayName.charAt(0));
+ if (directionality == Character.DIRECTIONALITY_LEFT_TO_RIGHT) {
+ this.nativeName = displayName.substring(0, 1).toUpperCase(locale) +
+ displayName.substring(1);
+ return;
+ }
+
+ this.nativeName = displayName;
+ }
+
+ public String getTag() {
+ return this.tag;
+ }
+
+ public String getDisplayName() {
+ return this.nativeName;
+ }
+
+ @Override
+ public String toString() {
+ return this.nativeName;
+ }
+
+
+ @Override
+ public int compareTo(LocaleDescriptor another) {
+ // We sort by name, so we use Collator.
+ return COLLATOR.compare(this.nativeName, another.nativeName);
+ }
+
+ /**
+ * See Bug 1023451 Comment 10 for the research that led to
+ * this method.
+ *
+ * @return true if this locale can be used for displaying UI
+ * on this device without known issues.
+ */
+ public boolean isUsable(CharacterValidator validator) {
+ if (Versions.preLollipop && this.tag.matches("[a-zA-Z]{3}.*")) {
+ // Earlier versions of Android can't load three-char locale code
+ // resources.
+ return false;
+ }
+
+ // Oh, for Java 7 switch statements.
+ if (this.tag.equals("bn-IN")) {
+ // Bengali sometimes has an English label if the Bengali script
+ // is missing. This prevents us from simply checking character
+ // rendering for bn-IN; we'll get a false positive for "B", not "ব".
+ //
+ // This doesn't seem to affect other Bengali-script locales
+ // (below), which always have a label in native script.
+ if (!this.nativeName.startsWith("বাংলা")) {
+ // We're on an Android version that doesn't even have
+ // characters to say বাংলা. Definite failure.
+ return false;
+ }
+ }
+
+ // These locales use a script that is often unavailable
+ // on common Android devices. Make sure we can show them.
+ // See documentation for CharacterValidator.
+ // Note that bn-IN is checked here even if it passed above.
+ if (this.tag.equals("or") ||
+ this.tag.equals("my") ||
+ this.tag.equals("pa-IN") ||
+ this.tag.equals("gu-IN") ||
+ this.tag.equals("bn-IN")) {
+ if (validator.characterIsMissingInFont(this.nativeName.substring(0, 1))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ /**
+ * Not every locale we ship can be used on every device, due to
+ * font or rendering constraints.
+ *
+ * This method filters down the list before generating the descriptor array.
+ */
+ private LocaleDescriptor[] getUsableLocales() {
+ Collection<String> shippingLocales = BrowserLocaleManager.getPackagedLocaleTags(getContext());
+
+ // Future: single-locale builds should be specified, too.
+ if (shippingLocales == null) {
+ final String fallbackTag = BrowserLocaleManager.getInstance().getFallbackLocaleTag();
+ return new LocaleDescriptor[] { new LocaleDescriptor(fallbackTag) };
+ }
+
+ final int initialCount = shippingLocales.size();
+ final Set<LocaleDescriptor> locales = new HashSet<LocaleDescriptor>(initialCount);
+ for (String tag : shippingLocales) {
+ final LocaleDescriptor descriptor = new LocaleDescriptor(tag);
+
+ if (!descriptor.isUsable(this.characterValidator)) {
+ Log.w(LOG_TAG, "Skipping locale " + tag + " on this device.");
+ continue;
+ }
+
+ locales.add(descriptor);
+ }
+
+ final int usableCount = locales.size();
+ final LocaleDescriptor[] descriptors = locales.toArray(new LocaleDescriptor[usableCount]);
+ Arrays.sort(descriptors, 0, usableCount);
+ return descriptors;
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ // The superclass will take care of persistence.
+ super.onDialogClosed(positiveResult);
+
+ // Use this hook to try to fix up the environment ASAP.
+ // Do this so that the redisplayed fragment is inflated
+ // with the right locale.
+ final Locale selectedLocale = getSelectedLocale();
+ final Context context = getContext();
+ BrowserLocaleManager.getInstance().updateConfiguration(context, selectedLocale);
+ }
+
+ private Locale getSelectedLocale() {
+ final String tag = getValue();
+ if (tag == null || tag.equals("")) {
+ return Locale.getDefault();
+ }
+ return Locales.parseLocaleCode(tag);
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ final String value = getValue();
+
+ if (TextUtils.isEmpty(value)) {
+ return getContext().getString(R.string.locale_system_default);
+ }
+
+ // We can't trust super.getSummary() across locale changes,
+ // apparently, so let's do the same work.
+ return new LocaleDescriptor(value).getDisplayName();
+ }
+
+ private void buildList() {
+ final Locale currentLocale = Locale.getDefault();
+ Log.d(LOG_TAG, "Building locales list. Current locale: " + currentLocale);
+
+ if (currentLocale.equals(this.entriesLocale) &&
+ getEntries() != null) {
+ Log.v(LOG_TAG, "No need to build list.");
+ return;
+ }
+
+ final LocaleDescriptor[] descriptors = getUsableLocales();
+ final int count = descriptors.length;
+
+ this.entriesLocale = currentLocale;
+
+ // We leave room for "System default".
+ final String[] entries = new String[count + 1];
+ final String[] values = new String[count + 1];
+
+ entries[0] = getContext().getString(R.string.locale_system_default);
+ values[0] = "";
+
+ for (int i = 0; i < count; ++i) {
+ final String displayName = descriptors[i].getDisplayName();
+ final String tag = descriptors[i].getTag();
+ Log.v(LOG_TAG, displayName + " => " + tag);
+ entries[i + 1] = displayName;
+ values[i + 1] = tag;
+ }
+
+ setEntries(entries);
+ setEntryValues(values);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java
new file mode 100644
index 000000000..c545472e2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/ModifiableHintPreference.java
@@ -0,0 +1,67 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.preference.Preference;
+import android.text.Spanned;
+import android.text.SpannableStringBuilder;
+import android.text.style.ImageSpan;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+class ModifiableHintPreference extends Preference {
+ private static final String LOGTAG = "ModifiableHintPref";
+ private final Context mContext;
+
+ private final String MATCH_STRING = "%I";
+ private final int RESID_TEXT_VIEW = R.id.label_search_hint;
+ private final int RESID_DRAWABLE = R.drawable.ab_add_search_engine;
+ private final double SCALE_FACTOR = 0.5;
+
+ public ModifiableHintPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ }
+
+ public ModifiableHintPreference(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ mContext = context;
+ }
+
+ @Override
+ protected View onCreateView(ViewGroup parent) {
+ View thisView = super.onCreateView(parent);
+ configurePreferenceView(thisView);
+ return thisView;
+ }
+
+ private void configurePreferenceView(View view) {
+ TextView textView = (TextView) view.findViewById(RESID_TEXT_VIEW);
+ String searchHint = textView.getText().toString();
+
+ // Use an ImageSpan to include the "add search" icon in the Tip.
+ int imageSpanIndex = searchHint.indexOf(MATCH_STRING);
+ if (imageSpanIndex != -1) {
+ // Scale the resource.
+ Drawable drawable = mContext.getResources().getDrawable(RESID_DRAWABLE);
+ drawable.setBounds(0, 0, (int) (drawable.getIntrinsicWidth() * SCALE_FACTOR),
+ (int) (drawable.getIntrinsicHeight() * SCALE_FACTOR));
+
+ ImageSpan searchIcon = new ImageSpan(drawable);
+ final SpannableStringBuilder hintBuilder = new SpannableStringBuilder(searchHint);
+
+ // Insert the image.
+ hintBuilder.setSpan(searchIcon, imageSpanIndex, imageSpanIndex + MATCH_STRING.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ textView.setText(hintBuilder, TextView.BufferType.SPANNABLE);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java
new file mode 100644
index 000000000..5749bf29d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiChoicePreference.java
@@ -0,0 +1,271 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.util.PrefUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.AlertDialog.Builder;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.TypedArray;
+import android.content.SharedPreferences;
+import android.preference.DialogPreference;
+import android.util.AttributeSet;
+
+import java.util.HashSet;
+import java.util.Set;
+
+class MultiChoicePreference extends DialogPreference implements DialogInterface.OnMultiChoiceClickListener {
+ private static final String LOGTAG = "GeckoMultiChoicePreference";
+
+ private boolean mValues[];
+ private boolean mPrevValues[];
+ private CharSequence mEntryValues[];
+ private CharSequence mEntries[];
+ private CharSequence mInitialValues[];
+
+ public MultiChoicePreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiChoicePreference);
+ mEntries = a.getTextArray(R.styleable.MultiChoicePreference_entries);
+ mEntryValues = a.getTextArray(R.styleable.MultiChoicePreference_entryValues);
+ mInitialValues = a.getTextArray(R.styleable.MultiChoicePreference_initialValues);
+ a.recycle();
+
+ loadPersistedValues();
+ }
+
+ public MultiChoicePreference(Context context) {
+ this(context, null);
+ }
+
+ /**
+ * Sets the human-readable entries to be shown in the list. This will be
+ * shown in subsequent dialogs.
+ * <p>
+ * Each entry must have a corresponding index in
+ * {@link #setEntryValues(CharSequence[])} and
+ * {@link #setInitialValues(CharSequence[])}.
+ *
+ * @param entries The entries.
+ */
+ public void setEntries(CharSequence[] entries) {
+ mEntries = entries.clone();
+ }
+
+ /**
+ * @param entriesResId The entries array as a resource.
+ */
+ public void setEntries(int entriesResId) {
+ setEntries(getContext().getResources().getTextArray(entriesResId));
+ }
+
+ /**
+ * Sets the preference values for preferences shown in the list.
+ *
+ * @param entryValues The entry values.
+ */
+ public void setEntryValues(CharSequence[] entryValues) {
+ mEntryValues = entryValues.clone();
+ loadPersistedValues();
+ }
+
+ /**
+ * Entry values define a separate pref for each row in the dialog.
+ *
+ * @param entryValuesResId The entryValues array as a resource.
+ */
+ public void setEntryValues(int entryValuesResId) {
+ setEntryValues(getContext().getResources().getTextArray(entryValuesResId));
+ }
+
+ /**
+ * The array of initial entry values in this list. Each entryValue
+ * corresponds to an entryKey. These values are used if a) the preference
+ * isn't persisted, or b) the preference is persisted but hasn't yet been
+ * set.
+ *
+ * @param initialValues The entry values
+ */
+ public void setInitialValues(CharSequence[] initialValues) {
+ mInitialValues = initialValues.clone();
+ loadPersistedValues();
+ }
+
+ /**
+ * @param initialValuesResId The initialValues array as a resource.
+ */
+ public void setInitialValues(int initialValuesResId) {
+ setInitialValues(getContext().getResources().getTextArray(initialValuesResId));
+ }
+
+ /**
+ * The list of translated strings corresponding to each preference.
+ *
+ * @return The array of entries.
+ */
+ public CharSequence[] getEntries() {
+ return mEntries.clone();
+ }
+
+ /**
+ * The list of values corresponding to each preference.
+ *
+ * @return The array of values.
+ */
+ public CharSequence[] getEntryValues() {
+ return mEntryValues.clone();
+ }
+
+ /**
+ * The list of initial values for each preference. Each string in this list
+ * should be either "true" or "false".
+ *
+ * @return The array of initial values.
+ */
+ public CharSequence[] getInitialValues() {
+ return mInitialValues.clone();
+ }
+
+ public void setValue(final int i, final boolean value) {
+ mValues[i] = value;
+ mPrevValues = mValues.clone();
+ }
+
+ /**
+ * The list of values for each preference. These values are updated after
+ * the dialog has been displayed.
+ *
+ * @return The array of values.
+ */
+ public Set<String> getValues() {
+ final Set<String> values = new HashSet<String>();
+
+ if (mValues == null) {
+ return values;
+ }
+
+ for (int i = 0; i < mValues.length; i++) {
+ if (mValues[i]) {
+ values.add(mEntryValues[i].toString());
+ }
+ }
+
+ return values;
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which, boolean val) {
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(Builder builder) {
+ if (mEntries == null || mInitialValues == null || mEntryValues == null) {
+ throw new IllegalStateException(
+ "MultiChoicePreference requires entries, entryValues, and initialValues arrays.");
+ }
+
+ if (mEntries.length != mEntryValues.length || mEntries.length != mInitialValues.length) {
+ throw new IllegalStateException(
+ "MultiChoicePreference entries, entryValues, and initialValues arrays must be the same length");
+ }
+
+ builder.setMultiChoiceItems(mEntries, mValues, this);
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ if (mPrevValues == null || mInitialValues == null) {
+ // Initialization is done asynchronously, so these values may not
+ // have been set before the dialog was closed.
+ return;
+ }
+
+ if (!positiveResult) {
+ // user cancelled; reset checkbox values to their previous state
+ mValues = mPrevValues.clone();
+ return;
+ }
+
+ mPrevValues = mValues.clone();
+
+ if (!callChangeListener(getValues())) {
+ return;
+ }
+
+ persist();
+ }
+
+ /* Persists the current data stored by this pref to SharedPreferences. */
+ public boolean persist() {
+ if (isPersistent()) {
+ final SharedPreferences.Editor edit = GeckoSharedPrefs.forProfile(getContext()).edit();
+ final boolean res = persist(edit);
+ edit.apply();
+ return res;
+ }
+
+ return false;
+ }
+
+ /* Internal persist method. Take an edit so that multiple prefs can be persisted in a single commit. */
+ protected boolean persist(SharedPreferences.Editor edit) {
+ if (isPersistent()) {
+ Set<String> vals = getValues();
+ PrefUtils.putStringSet(edit, getKey(), vals).apply();;
+ return true;
+ }
+
+ return false;
+ }
+
+ /* Returns a list of EntryValues that are currently enabled. */
+ public Set<String> getPersistedStrings(Set<String> defaultVal) {
+ if (!isPersistent()) {
+ return defaultVal;
+ }
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(getContext());
+ return PrefUtils.getStringSet(prefs, getKey(), defaultVal);
+ }
+
+ /**
+ * Loads persistent prefs from shared preferences. If the preferences
+ * aren't persistent or haven't yet been stored, they will be set to their
+ * initial values.
+ */
+ protected void loadPersistedValues() {
+ final int entryCount = mInitialValues.length;
+ mValues = new boolean[entryCount];
+
+ if (entryCount != mEntries.length || entryCount != mEntryValues.length) {
+ throw new IllegalStateException(
+ "MultiChoicePreference entryValues and initialValues arrays must be the same length");
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final Set<String> stringVals = getPersistedStrings(null);
+
+ for (int i = 0; i < entryCount; i++) {
+ if (stringVals != null) {
+ mValues[i] = stringVals.contains(mEntryValues[i]);
+ } else {
+ final boolean defaultVal = mInitialValues[i].equals("true");
+ mValues[i] = defaultVal;
+ }
+ }
+
+ mPrevValues = mValues.clone();
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java
new file mode 100644
index 000000000..580d613ca
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/MultiPrefMultiChoicePreference.java
@@ -0,0 +1,116 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.res.TypedArray;
+import android.content.SharedPreferences;
+import android.widget.Button;
+import android.util.AttributeSet;
+import android.util.Log;
+
+import java.util.Set;
+
+/* Provides backwards compatibility for some old multi-choice pref types used by Gecko.
+ * This will import the old data from the old prefs the first time it is run.
+ */
+class MultiPrefMultiChoicePreference extends MultiChoicePreference {
+ private static final String LOGTAG = "GeckoMultiPrefPreference";
+ private static final String IMPORT_SUFFIX = "_imported_";
+ private final CharSequence[] keys;
+
+ public MultiPrefMultiChoicePreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiPrefMultiChoicePreference);
+ keys = a.getTextArray(R.styleable.MultiPrefMultiChoicePreference_entryKeys);
+ a.recycle();
+
+ loadPersistedValues();
+ }
+
+ // Helper method for reading a boolean pref.
+ private boolean getPersistedBoolean(SharedPreferences prefs, String key, boolean defaultReturnValue) {
+ if (!isPersistent()) {
+ return defaultReturnValue;
+ }
+
+ return prefs.getBoolean(key, defaultReturnValue);
+ }
+
+ // Overridden to do a one time import for the old preference type to the new one.
+ @Override
+ protected synchronized void loadPersistedValues() {
+ // This will load the new pref if it exists.
+ super.loadPersistedValues();
+
+ // First check if we've already done the import the old data. If so, nothing to load.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(getContext());
+ final boolean imported = getPersistedBoolean(prefs, getKey() + IMPORT_SUFFIX, false);
+ if (imported) {
+ return;
+ }
+
+ // Load the data we'll need to find the old style prefs
+ final CharSequence[] init = getInitialValues();
+ final CharSequence[] entries = getEntries();
+ if (keys == null || init == null) {
+ return;
+ }
+
+ final int entryCount = keys.length;
+ if (entryCount != entries.length || entryCount != init.length) {
+ throw new IllegalStateException("MultiChoicePreference entryKeys and initialValues arrays must be the same length");
+ }
+
+ // Now iterate through the entries on a background thread.
+ final SharedPreferences.Editor edit = prefs.edit();
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ // Use one editor to batch as many changes as we can.
+ for (int i = 0; i < entryCount; i++) {
+ String key = keys[i].toString();
+ boolean initialValue = "true".equals(init[i]);
+ boolean val = getPersistedBoolean(prefs, key, initialValue);
+
+ // Save the pref and remove the old preference.
+ setValue(i, val);
+ edit.remove(key);
+ }
+
+ persist(edit);
+ edit.putBoolean(getKey() + IMPORT_SUFFIX, true);
+ edit.apply();
+ } catch (Exception ex) {
+ Log.i(LOGTAG, "Err", ex);
+ }
+ }
+ });
+ }
+
+
+ @Override
+ public void onClick(DialogInterface dialog, int which, boolean val) {
+ // enable positive button only if at least one item is checked
+ boolean enabled = false;
+ final Set<String> values = getValues();
+
+ enabled = (values.size() > 0);
+ final Button button = ((AlertDialog) dialog).getButton(DialogInterface.BUTTON_POSITIVE);
+ if (button.isEnabled() != enabled) {
+ button.setEnabled(enabled);
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java
new file mode 100644
index 000000000..337d9dd2f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreference.java
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.Property;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.R;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.DialogInterface.OnShowListener;
+import android.content.res.Resources;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+public class PanelsPreference extends CustomListPreference {
+ protected String LOGTAG = "PanelsPreference";
+
+ // Position state of this Preference in enclosing category.
+ private static final int STATE_IS_FIRST = 0;
+ private static final int STATE_IS_LAST = 1;
+
+ /**
+ * Index of the context menu button for controlling display options.
+ * For (removable) Dynamic panels, this button removes the panel.
+ * For built-in panels, this button toggles showing or hiding the panel.
+ */
+ private static final int INDEX_DISPLAY_BUTTON = 1;
+ private static final int INDEX_REORDER_BUTTON = 2;
+
+ // Indices of buttons in context menu for reordering.
+ private static final int INDEX_MOVE_UP_BUTTON = 0;
+ private static final int INDEX_MOVE_DOWN_BUTTON = 1;
+
+ private String LABEL_HIDE;
+ private String LABEL_SHOW;
+
+ private View preferenceView;
+ protected boolean mIsHidden;
+ private final boolean mIsRemovable;
+
+ private boolean mAnimate;
+ private static final int ANIMATION_DURATION_MS = 400;
+
+ // State for reordering.
+ private int mPositionState = -1;
+ private final int mIndex;
+
+ public PanelsPreference(Context context, CustomListCategory parentCategory, boolean isRemovable, int index, boolean animate) {
+ super(context, parentCategory);
+ mIsRemovable = isRemovable;
+ mIndex = index;
+ mAnimate = animate;
+ }
+
+ @Override
+ protected int getPreferenceLayoutResource() {
+ return R.layout.preference_panels;
+ }
+
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ // Override view handling so we can grey out "hidden" PanelPreferences.
+ view.setEnabled(!mIsHidden);
+
+ if (view instanceof ViewGroup) {
+ final ViewGroup group = (ViewGroup) view;
+ for (int i = 0; i < group.getChildCount(); i++) {
+ group.getChildAt(i).setEnabled(!mIsHidden);
+ }
+ preferenceView = group;
+ }
+
+ if (mAnimate) {
+ ViewHelper.setAlpha(preferenceView, 0);
+
+ final PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION_MS);
+ animator.attach(preferenceView, Property.ALPHA, 1);
+ animator.start();
+
+ // Clear animate flag.
+ mAnimate = false;
+ }
+ }
+
+ @Override
+ protected String[] createDialogItems() {
+ final Resources res = getContext().getResources();
+ final String labelReorder = res.getString(R.string.pref_panels_reorder);
+
+ if (mIsRemovable) {
+ return new String[] { LABEL_SET_AS_DEFAULT, LABEL_REMOVE, labelReorder };
+ }
+
+ // Built-in panels can't be removed, so use show/hide options.
+ LABEL_HIDE = res.getString(R.string.pref_panels_hide);
+ LABEL_SHOW = res.getString(R.string.pref_panels_show);
+
+ return new String[] { LABEL_SET_AS_DEFAULT, LABEL_HIDE, labelReorder };
+ }
+
+ @Override
+ public void setIsDefault(boolean isDefault) {
+ mIsDefault = isDefault;
+ if (isDefault) {
+ setSummary(LABEL_IS_DEFAULT);
+ if (mIsHidden) {
+ // Unhide the panel if it's being set as the default.
+ setHidden(false);
+ }
+ } else {
+ setSummary("");
+ }
+ }
+
+ @Override
+ protected void onDialogIndexClicked(int index) {
+ switch (index) {
+ case INDEX_SET_DEFAULT_BUTTON:
+ mParentCategory.setDefault(this);
+ break;
+
+ case INDEX_DISPLAY_BUTTON:
+ // Handle display options for the panel.
+ if (mIsRemovable) {
+ // For removable panels, the button displays text for removing the panel.
+ mParentCategory.uninstall(this);
+ } else {
+ // Otherwise, the button toggles between text for showing or hiding the panel.
+ ((PanelsPreferenceCategory) mParentCategory).setHidden(this, !mIsHidden);
+ }
+ break;
+
+ case INDEX_REORDER_BUTTON:
+ // Display dialog for changing preference order.
+ final Dialog orderDialog = makeReorderDialog();
+ orderDialog.show();
+ break;
+
+ default:
+ Log.w(LOGTAG, "Selected index out of range: " + index);
+ }
+ }
+
+ @Override
+ protected void configureShownDialog() {
+ super.configureShownDialog();
+
+ // Handle Show/Hide buttons.
+ if (!mIsRemovable) {
+ final TextView hideButton = (TextView) mDialog.getListView().getChildAt(INDEX_DISPLAY_BUTTON);
+ hideButton.setText(mIsHidden ? LABEL_SHOW : LABEL_HIDE);
+ }
+ }
+
+
+ private Dialog makeReorderDialog() {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+
+ final Resources res = getContext().getResources();
+ final String labelUp = res.getString(R.string.pref_panels_move_up);
+ final String labelDown = res.getString(R.string.pref_panels_move_down);
+
+ builder.setTitle(getTitle());
+ builder.setItems(new String[] { labelUp, labelDown }, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int index) {
+ dialog.dismiss();
+ switch (index) {
+ case INDEX_MOVE_UP_BUTTON:
+ ((PanelsPreferenceCategory) mParentCategory).moveUp(PanelsPreference.this);
+ break;
+
+ case INDEX_MOVE_DOWN_BUTTON:
+ ((PanelsPreferenceCategory) mParentCategory).moveDown(PanelsPreference.this);
+ break;
+ }
+ }
+ });
+
+ final Dialog dialog = builder.create();
+ dialog.setOnShowListener(new OnShowListener() {
+ @Override
+ public void onShow(DialogInterface dialog) {
+ setReorderItemsEnabled(dialog);
+ }
+ });
+
+ return dialog;
+ }
+
+ public void setIsFirst() {
+ mPositionState = STATE_IS_FIRST;
+ }
+
+ public void setIsLast() {
+ mPositionState = STATE_IS_LAST;
+ }
+
+ /**
+ * Configure enabled state of the reorder dialog, which must be done after the dialog is shown.
+ * @param dialog Dialog to configure
+ */
+ private void setReorderItemsEnabled(DialogInterface dialog) {
+ // Update button enabled-ness for reordering.
+ switch (mPositionState) {
+ case STATE_IS_FIRST:
+ final TextView itemUp = (TextView) ((AlertDialog) dialog).getListView().getChildAt(INDEX_MOVE_UP_BUTTON);
+ itemUp.setEnabled(false);
+ // Disable clicks to this view.
+ itemUp.setOnClickListener(null);
+ break;
+
+ case STATE_IS_LAST:
+ final TextView itemDown = (TextView) ((AlertDialog) dialog).getListView().getChildAt(INDEX_MOVE_DOWN_BUTTON);
+ itemDown.setEnabled(false);
+ // Disable clicks to this view.
+ itemDown.setOnClickListener(null);
+ break;
+
+ default:
+ // Do nothing.
+ break;
+ }
+ }
+
+ public void setHidden(boolean toHide) {
+ if (toHide) {
+ setIsDefault(false);
+ }
+
+ if (mIsHidden != toHide) {
+ mIsHidden = toHide;
+ notifyChanged();
+ }
+ }
+
+ public boolean isHidden() {
+ return mIsHidden;
+ }
+
+ public int getIndex() {
+ return mIndex;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java
new file mode 100644
index 000000000..d44b6eaa9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PanelsPreferenceCategory.java
@@ -0,0 +1,261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.home.HomeConfig;
+import org.mozilla.gecko.home.HomeConfig.PanelConfig;
+import org.mozilla.gecko.home.HomeConfig.State;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+
+public class PanelsPreferenceCategory extends CustomListCategory {
+ public static final String LOGTAG = "PanelsPrefCategory";
+
+ protected HomeConfig mHomeConfig;
+ protected HomeConfig.Editor mConfigEditor;
+
+ protected UIAsyncTask.WithoutParams<State> mLoadTask;
+
+ public PanelsPreferenceCategory(Context context) {
+ super(context);
+ initConfig(context);
+ }
+
+ public PanelsPreferenceCategory(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initConfig(context);
+ }
+
+ public PanelsPreferenceCategory(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initConfig(context);
+ }
+
+ protected void initConfig(Context context) {
+ mHomeConfig = HomeConfig.getDefault(context);
+ }
+
+ @Override
+ public void onAttachedToActivity() {
+ super.onAttachedToActivity();
+
+ loadHomeConfig(null);
+ }
+
+ /**
+ * Load the Home Panels config and populate the preferences screen and maintain local state.
+ */
+ private void loadHomeConfig(final String animatePanelId) {
+ mLoadTask = new UIAsyncTask.WithoutParams<State>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public HomeConfig.State doInBackground() {
+ return mHomeConfig.load();
+ }
+
+ @Override
+ public void onPostExecute(HomeConfig.State configState) {
+ mConfigEditor = configState.edit();
+ displayHomeConfig(configState, animatePanelId);
+ }
+ };
+ mLoadTask.execute();
+ }
+
+ /**
+ * Simplified refresh of Home Panels when there is no state to be persisted.
+ */
+ public void refresh() {
+ refresh(null, null);
+ }
+
+ /**
+ * Refresh the Home Panels list and animate a panel, if specified.
+ * If null, load from HomeConfig.
+ *
+ * @param State HomeConfig.State to rebuild Home Panels list from.
+ * @param String panelId of panel to be animated.
+ */
+ public void refresh(State state, String animatePanelId) {
+ // Clear all the existing home panels.
+ removeAll();
+
+ if (state == null) {
+ loadHomeConfig(animatePanelId);
+ } else {
+ displayHomeConfig(state, animatePanelId);
+ }
+ }
+
+ private void displayHomeConfig(HomeConfig.State configState, String animatePanelId) {
+ int index = 0;
+ for (PanelConfig panelConfig : configState) {
+ final boolean isRemovable = panelConfig.isDynamic();
+
+ // Create and add the pref.
+ final String panelId = panelConfig.getId();
+ final boolean animate = TextUtils.equals(animatePanelId, panelId);
+
+ final PanelsPreference pref = new PanelsPreference(getContext(), PanelsPreferenceCategory.this, isRemovable, index, animate);
+ pref.setTitle(panelConfig.getTitle());
+ pref.setKey(panelConfig.getId());
+ // XXX: Pull icon from PanelInfo.
+ addPreference(pref);
+
+ if (panelConfig.isDisabled()) {
+ pref.setHidden(true);
+ }
+
+ index++;
+ }
+
+ setPositionState();
+ setDefaultFromConfig();
+ }
+
+ private void setPositionState() {
+ final int prefCount = getPreferenceCount();
+
+ // Pass in position state to first and last preference.
+ final PanelsPreference firstPref = (PanelsPreference) getPreference(0);
+ firstPref.setIsFirst();
+
+ final PanelsPreference lastPref = (PanelsPreference) getPreference(prefCount - 1);
+ lastPref.setIsLast();
+ }
+
+ private void setDefaultFromConfig() {
+ final String defaultPanelId = mConfigEditor.getDefaultPanelId();
+ if (defaultPanelId == null) {
+ mDefaultReference = null;
+ return;
+ }
+
+ final int prefCount = getPreferenceCount();
+
+ for (int i = 0; i < prefCount; i++) {
+ final PanelsPreference pref = (PanelsPreference) getPreference(i);
+
+ if (defaultPanelId.equals(pref.getKey())) {
+ super.setDefault(pref);
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void setDefault(CustomListPreference pref) {
+ super.setDefault(pref);
+
+ final String id = pref.getKey();
+
+ final String defaultPanelId = mConfigEditor.getDefaultPanelId();
+ if (defaultPanelId != null && defaultPanelId.equals(id)) {
+ return;
+ }
+
+ updateVisibilityPrefsForPanel(id, true);
+
+ mConfigEditor.setDefault(id);
+ mConfigEditor.apply();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_SET_DEFAULT, Method.DIALOG, id);
+ }
+
+ @Override
+ protected void onPrepareForRemoval() {
+ super.onPrepareForRemoval();
+ if (mLoadTask != null) {
+ mLoadTask.cancel();
+ }
+ }
+
+ @Override
+ public void uninstall(CustomListPreference pref) {
+ final String id = pref.getKey();
+ mConfigEditor.uninstall(id);
+ mConfigEditor.apply();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_REMOVE, Method.DIALOG, id);
+
+ super.uninstall(pref);
+ }
+
+ public void moveUp(PanelsPreference pref) {
+ final int panelIndex = pref.getIndex();
+ if (panelIndex > 0) {
+ final String panelKey = pref.getKey();
+ mConfigEditor.moveTo(panelKey, panelIndex - 1);
+ final State state = mConfigEditor.apply();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_MOVE, Method.DIALOG, panelKey);
+
+ refresh(state, panelKey);
+ }
+ }
+
+ public void moveDown(PanelsPreference pref) {
+ final int panelIndex = pref.getIndex();
+ if (panelIndex < getPreferenceCount() - 1) {
+ final String panelKey = pref.getKey();
+ mConfigEditor.moveTo(panelKey, panelIndex + 1);
+ final State state = mConfigEditor.apply();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_MOVE, Method.DIALOG, panelKey);
+
+ refresh(state, panelKey);
+ }
+ }
+
+ /**
+ * Update the hide/show state of the preference and save the HomeConfig
+ * changes.
+ *
+ * @param pref Preference to update
+ * @param toHide New hidden state of the preference
+ */
+ protected void setHidden(PanelsPreference pref, boolean toHide) {
+ final String id = pref.getKey();
+ mConfigEditor.setDisabled(id, toHide);
+ mConfigEditor.apply();
+
+ if (toHide) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_HIDE, Method.DIALOG, id);
+ } else {
+ Telemetry.sendUIEvent(TelemetryContract.Event.PANEL_SHOW, Method.DIALOG, id);
+ }
+
+ updateVisibilityPrefsForPanel(id, !toHide);
+
+ pref.setHidden(toHide);
+ setDefaultFromConfig();
+ }
+
+ /**
+ * When the default panel is removed or disabled, find an enabled panel
+ * if possible and set it as mDefaultReference.
+ */
+ @Override
+ protected void setFallbackDefault() {
+ setDefaultFromConfig();
+ }
+
+ private void updateVisibilityPrefsForPanel(String panelId, boolean toShow) {
+ if (HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.BOOKMARKS).equals(panelId)) {
+ GeckoSharedPrefs.forProfile(getContext()).edit().putBoolean(HomeConfig.PREF_KEY_BOOKMARKS_PANEL_ENABLED, toShow).apply();
+ }
+
+ if (HomeConfig.getIdForBuiltinPanelType(HomeConfig.PanelType.COMBINED_HISTORY).equals(panelId)) {
+ GeckoSharedPrefs.forProfile(getContext()).edit().putBoolean(HomeConfig.PREF_KEY_HISTORY_PANEL_ENABLED, toShow).apply();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java
new file mode 100644
index 000000000..61eff98e7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/PrivateDataPreference.java
@@ -0,0 +1,67 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.icons.storage.DiskStorage;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.Log;
+
+class PrivateDataPreference extends MultiPrefMultiChoicePreference {
+ private static final String LOGTAG = "GeckoPrivateDataPreference";
+ private static final String PREF_KEY_PREFIX = "private.data.";
+
+ public PrivateDataPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+
+ if (!positiveResult) {
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SANITIZE, TelemetryContract.Method.DIALOG, "settings");
+
+ final Set<String> values = getValues();
+ final JSONObject json = new JSONObject();
+
+ for (String value : values) {
+ // Privacy pref checkbox values are stored in Android prefs to
+ // remember their check states. The key names are private.data.X,
+ // where X is a string from Gecko sanitization. This prefix is
+ // removed here so we can send the values to Gecko, which then does
+ // the sanitization for each key.
+ final String key = value.substring(PREF_KEY_PREFIX.length());
+ try {
+ json.put(key, true);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+ }
+
+ if (values.contains("private.data.offlineApps")) {
+ // Remove all icons from storage if removing "Offline website data" was selected.
+ DiskStorage.get(getContext()).evictAll();
+ }
+
+ // clear private data in gecko
+ GeckoAppShell.notifyObservers("Sanitize:ClearData", json.toString());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java
new file mode 100644
index 000000000..3ba80b562
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchEnginePreference.java
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconDescriptor;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.widget.FaviconView;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.support.design.widget.Snackbar;
+import android.text.SpannableString;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * Represents an element in the list of search engines on the preferences menu.
+ */
+public class SearchEnginePreference extends CustomListPreference {
+ protected String LOGTAG = "SearchEnginePreference";
+
+ protected static final int INDEX_REMOVE_BUTTON = 1;
+
+ // The icon to display in the prompt when clicked.
+ private BitmapDrawable mPromptIcon;
+
+ // The bitmap backing the drawable above - needed separately for the FaviconView.
+ private Bitmap mIconBitmap;
+ private final Object bitmapLock = new Object();
+
+ private FaviconView mFaviconView;
+
+ // Search engine identifier specified by the gecko search service. This will be "other"
+ // for engines that are not shipped with the app.
+ private String mIdentifier;
+
+ public SearchEnginePreference(Context context, SearchPreferenceCategory parentCategory) {
+ super(context, parentCategory);
+ }
+
+ /**
+ * Called by Android when we're bound to the custom view. Allows us to set the custom properties
+ * of our custom view elements as we desire (We can now use findViewById on them).
+ *
+ * @param view The view instance for this Preference object.
+ */
+ @Override
+ protected void onBindView(View view) {
+ super.onBindView(view);
+
+ // We synchronise to avoid a race condition between this and the favicon loading callback in
+ // setSearchEngineFromJSON.
+ synchronized (bitmapLock) {
+ // Set the icon in the FaviconView.
+ mFaviconView = ((FaviconView) view.findViewById(R.id.search_engine_icon));
+
+ if (mIconBitmap != null) {
+ mFaviconView.updateAndScaleImage(IconResponse.create(mIconBitmap));
+ }
+ }
+ }
+
+ @Override
+ protected int getPreferenceLayoutResource() {
+ return R.layout.preference_search_engine;
+ }
+
+ /**
+ * Returns the strings to be displayed in the dialog.
+ */
+ @Override
+ protected String[] createDialogItems() {
+ return new String[] { LABEL_SET_AS_DEFAULT,
+ LABEL_REMOVE };
+ }
+
+ @Override
+ public void showDialog() {
+ // If this is the last engine, then we are the default, and none of the options
+ // on this menu can do anything.
+ if (mParentCategory.getPreferenceCount() == 1) {
+ Activity activity = (Activity) getContext();
+
+ SnackbarBuilder.builder(activity)
+ .message(R.string.pref_search_last_toast)
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+
+ return;
+ }
+
+ super.showDialog();
+ }
+
+ @Override
+ protected void configureDialogBuilder(AlertDialog.Builder builder) {
+ // Copy the icon from this object to the prompt we produce. We lazily create the drawable,
+ // as the user may not ever actually tap this object.
+ if (mPromptIcon == null && mIconBitmap != null) {
+ mPromptIcon = new BitmapDrawable(getContext().getResources(), mFaviconView.getBitmap());
+ }
+
+ builder.setIcon(mPromptIcon);
+ }
+
+ @Override
+ protected void onDialogIndexClicked(int index) {
+ switch (index) {
+ case INDEX_SET_DEFAULT_BUTTON:
+ mParentCategory.setDefault(this);
+ break;
+
+ case INDEX_REMOVE_BUTTON:
+ mParentCategory.uninstall(this);
+ break;
+
+ default:
+ Log.w(LOGTAG, "Selected index out of range.");
+ break;
+ }
+ }
+
+ /**
+ * @return Identifier of built-in search engine, or "other" if engine is not built-in.
+ */
+ public String getIdentifier() {
+ return mIdentifier;
+ }
+
+ /**
+ * Configure this Preference object from the Gecko search engine JSON object.
+ * @param geckoEngineJSON The Gecko-formatted JSON object representing the search engine.
+ * @throws JSONException If the JSONObject is invalid.
+ */
+ public void setSearchEngineFromJSON(JSONObject geckoEngineJSON) throws JSONException {
+ mIdentifier = geckoEngineJSON.getString("identifier");
+
+ // A null JS value gets converted into a string.
+ if (mIdentifier.equals("null")) {
+ mIdentifier = "other";
+ }
+
+ final String engineName = geckoEngineJSON.getString("name");
+ final SpannableString titleSpannable = new SpannableString(engineName);
+
+ setTitle(titleSpannable);
+
+ final String iconURI = geckoEngineJSON.getString("iconURI");
+ // Keep a reference to the bitmap - we'll need it later in onBindView.
+ try {
+ Icons.with(getContext())
+ .pageUrl(mIdentifier)
+ .icon(IconDescriptor.createGenericIcon(iconURI))
+ .privileged(true)
+ .build()
+ .execute(new IconCallback() {
+ @Override
+ public void onIconResponse(IconResponse response) {
+ mIconBitmap = response.getBitmap();
+
+ if (mFaviconView != null) {
+ mFaviconView.updateAndScaleImage(response);
+ }
+ }
+ });
+ } catch (IllegalArgumentException e) {
+ Log.e(LOGTAG, "IllegalArgumentException creating Bitmap. Most likely a zero-length bitmap.", e);
+ } catch (NullPointerException e) {
+ Log.e(LOGTAG, "NullPointerException creating Bitmap. Most likely a zero-length bitmap.", e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java
new file mode 100644
index 000000000..47db8b9b0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SearchPreferenceCategory.java
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class SearchPreferenceCategory extends CustomListCategory implements GeckoEventListener {
+ public static final String LOGTAG = "SearchPrefCategory";
+
+ public SearchPreferenceCategory(Context context) {
+ super(context);
+ }
+
+ public SearchPreferenceCategory(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SearchPreferenceCategory(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onAttachedToActivity() {
+ super.onAttachedToActivity();
+
+ // Register for SearchEngines messages and request list of search engines from Gecko.
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this, "SearchEngines:Data");
+ GeckoAppShell.notifyObservers("SearchEngines:GetVisible", null);
+ }
+
+ @Override
+ protected void onPrepareForRemoval() {
+ super.onPrepareForRemoval();
+
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this, "SearchEngines:Data");
+ }
+
+ @Override
+ public void setDefault(CustomListPreference item) {
+ super.setDefault(item);
+
+ sendGeckoEngineEvent("SearchEngines:SetDefault", item.getTitle().toString());
+
+ final String identifier = ((SearchEnginePreference) item).getIdentifier();
+ Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_SET_DEFAULT, Method.DIALOG, identifier);
+ }
+
+ @Override
+ public void uninstall(CustomListPreference item) {
+ super.uninstall(item);
+
+ sendGeckoEngineEvent("SearchEngines:Remove", item.getTitle().toString());
+
+ final String identifier = ((SearchEnginePreference) item).getIdentifier();
+ Telemetry.sendUIEvent(TelemetryContract.Event.SEARCH_REMOVE, Method.DIALOG, identifier);
+ }
+
+ @Override
+ public void handleMessage(String event, final JSONObject data) {
+ if (event.equals("SearchEngines:Data")) {
+ // Parse engines array from JSON.
+ JSONArray engines;
+ try {
+ engines = data.getJSONArray("searchEngines");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Unable to decode search engine data from Gecko.", e);
+ return;
+ }
+
+ // Clear the preferences category from this thread.
+ this.removeAll();
+
+ // Create an element in this PreferenceCategory for each engine.
+ for (int i = 0; i < engines.length(); i++) {
+ try {
+ final JSONObject engineJSON = engines.getJSONObject(i);
+
+ final SearchEnginePreference enginePreference = new SearchEnginePreference(getContext(), this);
+ enginePreference.setSearchEngineFromJSON(engineJSON);
+ enginePreference.setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ SearchEnginePreference sPref = (SearchEnginePreference) preference;
+ // Display the configuration dialog associated with the tapped engine.
+ sPref.showDialog();
+ return true;
+ }
+ });
+
+ addPreference(enginePreference);
+
+ // The first element in the array is the default engine.
+ if (i == 0) {
+ // We set this here, not in setSearchEngineFromJSON, because it allows us to
+ // keep a reference to the default engine to use when the AlertDialog
+ // callbacks are used.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ enginePreference.setIsDefault(true);
+ }
+ });
+ mDefaultReference = enginePreference;
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSONException parsing engine at index " + i, e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper method to send a particular event string to Gecko with an associated engine name.
+ * @param event The type of event to send.
+ * @param engine The engine to which the event relates.
+ */
+ private void sendGeckoEngineEvent(String event, String engineName) {
+ JSONObject json = new JSONObject();
+ try {
+ json.put("engine", engineName);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSONException creating search engine configuration change message for Gecko.", e);
+ return;
+ }
+ GeckoAppShell.notifyObservers(event, json.toString());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java
new file mode 100644
index 000000000..55be702c4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SetHomepagePreference.java
@@ -0,0 +1,124 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.preferences;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.DialogPreference;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.EditText;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+
+public class SetHomepagePreference extends DialogPreference {
+ private static final String DEFAULT_HOMEPAGE = AboutPages.HOME;
+
+ private final SharedPreferences prefs;
+
+ private RadioGroup homepageLayout;
+ private RadioButton defaultRadio;
+ private RadioButton userAddressRadio;
+ private EditText homepageEditText;
+
+ // This is the url that 1) was loaded from prefs or, 2) stored
+ // when the user pressed the "default homepage" checkbox.
+ private String storedUrl;
+
+ public SetHomepagePreference(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ prefs = GeckoSharedPrefs.forProfile(context);
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ // Without this GB devices have a black background to the dialog.
+ builder.setInverseBackgroundForced(true);
+ }
+
+ @Override
+ protected void onBindDialogView(final View view) {
+ super.onBindDialogView(view);
+
+ homepageLayout = (RadioGroup) view.findViewById(R.id.homepage_layout);
+ defaultRadio = (RadioButton) view.findViewById(R.id.radio_default);
+ userAddressRadio = (RadioButton) view.findViewById(R.id.radio_user_address);
+ homepageEditText = (EditText) view.findViewById(R.id.edittext_user_address);
+
+ storedUrl = prefs.getString(GeckoPreferences.PREFS_HOMEPAGE, DEFAULT_HOMEPAGE);
+
+ homepageLayout.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(final RadioGroup radioGroup, final int checkedId) {
+ if (checkedId == R.id.radio_user_address) {
+ homepageEditText.setVisibility(View.VISIBLE);
+ openKeyboardAndSelectAll(getContext(), homepageEditText);
+ } else {
+ homepageEditText.setVisibility(View.GONE);
+ }
+ }
+ });
+ setUIState(storedUrl);
+ }
+
+ private void setUIState(final String url) {
+ if (isUrlDefaultHomepage(url)) {
+ defaultRadio.setChecked(true);
+ } else {
+ userAddressRadio.setChecked(true);
+ homepageEditText.setText(url);
+ }
+ }
+
+ private boolean isUrlDefaultHomepage(final String url) {
+ return TextUtils.isEmpty(url) || DEFAULT_HOMEPAGE.equals(url);
+ }
+
+ private static void openKeyboardAndSelectAll(final Context context, final View viewToFocus) {
+ viewToFocus.requestFocus();
+ viewToFocus.post(new Runnable() {
+ @Override
+ public void run() {
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(viewToFocus, InputMethodManager.SHOW_IMPLICIT);
+ // android:selectAllOnFocus doesn't work for the initial focus:
+ // I'm not sure why. We manually selectAll instead.
+ if (viewToFocus instanceof EditText) {
+ ((EditText) viewToFocus).selectAll();
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onDialogClosed(final boolean positiveResult) {
+ super.onDialogClosed(positiveResult);
+ if (positiveResult) {
+ final SharedPreferences.Editor editor = prefs.edit();
+ final String homePageEditTextValue = homepageEditText.getText().toString();
+ final String newPrefValue;
+ if (homepageLayout.getCheckedRadioButtonId() == R.id.radio_default ||
+ isUrlDefaultHomepage(homePageEditTextValue)) {
+ newPrefValue = "";
+ editor.remove(GeckoPreferences.PREFS_HOMEPAGE);
+ } else {
+ newPrefValue = homePageEditTextValue;
+ editor.putString(GeckoPreferences.PREFS_HOMEPAGE, newPrefValue);
+ }
+ editor.apply();
+
+ if (getOnPreferenceChangeListener() != null) {
+ getOnPreferenceChangeListener().onPreferenceChange(this, newPrefValue);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java b/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java
new file mode 100644
index 000000000..350ac8fc0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/SyncPreference.java
@@ -0,0 +1,103 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.preferences;
+
+import android.content.Context;
+import android.content.Intent;
+import android.preference.Preference;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Target;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity;
+import org.mozilla.gecko.fxa.activities.PicassoPreferenceIconTarget;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+class SyncPreference extends Preference {
+ private final Context mContext;
+ private final Target profileAvatarTarget;
+
+ public SyncPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ final float cornerRadius = mContext.getResources().getDimension(R.dimen.fxaccount_profile_image_width) / 2;
+ profileAvatarTarget = new PicassoPreferenceIconTarget(mContext.getResources(), this, cornerRadius);
+ }
+
+ private void launchFxASetup() {
+ final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED);
+ intent.putExtra(FxAccountWebFlowActivity.EXTRA_ENDPOINT, FxAccountConstants.ENDPOINT_PREFERENCES);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ mContext.startActivity(intent);
+ }
+
+ public void update(final AndroidFxAccount fxAccount) {
+ if (fxAccount == null) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setTitle(R.string.pref_sync);
+ setSummary(R.string.pref_sync_summary);
+ // Cancel any pending task.
+ Picasso.with(mContext).cancelRequest(profileAvatarTarget);
+ // Clear previously set icon.
+ // Bug 1312719 - IconDrawable is prior to IconResId, drawable must be set null before setIcon(resId)
+ // http://androidxref.com/5.1.1_r6/xref/frameworks/base/core/java/android/preference/Preference.java#562
+ setIcon(null);
+ setIcon(R.drawable.sync_avatar_default);
+ }
+ });
+ return;
+ }
+
+ // Update title from account email.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setTitle(fxAccount.getEmail());
+ setSummary("");
+ }
+ });
+
+ final ExtendedJSONObject profileJSON = fxAccount.getProfileJSON();
+ if (profileJSON == null) {
+ return;
+ }
+
+ // Avatar URI empty, return early.
+ final String avatarURI = profileJSON.getString(FxAccountConstants.KEY_PROFILE_JSON_AVATAR);
+ if (TextUtils.isEmpty(avatarURI)) {
+ return;
+ }
+
+ Picasso.with(mContext)
+ .load(avatarURI)
+ .centerInside()
+ .resizeDimen(R.dimen.fxaccount_profile_image_width, R.dimen.fxaccount_profile_image_height)
+ .placeholder(R.drawable.sync_avatar_default)
+ .error(R.drawable.sync_avatar_default)
+ .into(profileAvatarTarget);
+ }
+
+ @Override
+ protected void onClick() {
+ // Launch the FxA "Get started" activity, which will dispatch to the
+ // right location.
+ launchFxASetup();
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, Method.SETTINGS, "sync_setup");
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java b/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java
new file mode 100644
index 000000000..c1eeb6bd5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/AddToHomeScreenPromotion.java
@@ -0,0 +1,237 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.promotion;
+
+import android.app.Activity;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.CallSuper;
+import android.util.Log;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.delegates.TabsTrayVisibilityAwareDelegate;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.ref.WeakReference;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Promote "Add to home screen" if user visits website often.
+ */
+public class AddToHomeScreenPromotion extends TabsTrayVisibilityAwareDelegate implements Tabs.OnTabsChangedListener {
+ private static class URLHistory {
+ public final long visits;
+ public final long lastVisit;
+
+ private URLHistory(long visits, long lastVisit) {
+ this.visits = visits;
+ this.lastVisit = lastVisit;
+ }
+ }
+
+ private static final String LOGTAG = "GeckoPromoteShortcut";
+
+ private static final String EXPERIMENT_MINIMUM_TOTAL_VISITS = "minimumTotalVisits";
+ private static final String EXPERIMENT_LAST_VISIT_MINIMUM_AGE = "lastVisitMinimumAgeMs";
+ private static final String EXPERIMENT_LAST_VISIT_MAXIMUM_AGE = "lastVisitMaximumAgeMs";
+
+ private WeakReference<Activity> activityReference;
+ private boolean isEnabled;
+ private int minimumVisits;
+ private int lastVisitMinimumAgeMs;
+ private int lastVisitMaximumAgeMs;
+
+ @CallSuper
+ @Override
+ public void onCreate(BrowserApp browserApp, Bundle savedInstanceState) {
+ super.onCreate(browserApp, savedInstanceState);
+ activityReference = new WeakReference<Activity>(browserApp);
+
+ initializeExperiment(browserApp);
+ }
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ private void initializeExperiment(Context context) {
+ if (!SwitchBoard.isInExperiment(context, Experiments.PROMOTE_ADD_TO_HOMESCREEN)) {
+ Log.v(LOGTAG, "Experiment not enabled");
+ // Experiment is not enabled. No need to try to read values.
+ return;
+ }
+
+ JSONObject values = SwitchBoard.getExperimentValuesFromJson(context, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+ if (values == null) {
+ // We didn't get any values for this experiment. Let's disable it instead of picking default
+ // values that might be bad.
+ return;
+ }
+
+ try {
+ initializeWithValues(
+ values.getInt(EXPERIMENT_MINIMUM_TOTAL_VISITS),
+ values.getInt(EXPERIMENT_LAST_VISIT_MINIMUM_AGE),
+ values.getInt(EXPERIMENT_LAST_VISIT_MAXIMUM_AGE));
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not read experiment values", e);
+ }
+ }
+
+ private void initializeWithValues(int minimumVisits, int lastVisitMinimumAgeMs, int lastVisitMaximumAgeMs) {
+ this.isEnabled = true;
+
+ this.minimumVisits = minimumVisits;
+ this.lastVisitMinimumAgeMs = lastVisitMinimumAgeMs;
+ this.lastVisitMaximumAgeMs = lastVisitMaximumAgeMs;
+ }
+
+ @Override
+ public void onTabChanged(final Tab tab, Tabs.TabEvents msg, String data) {
+ if (tab == null) {
+ return;
+ }
+
+ if (!Tabs.getInstance().isSelectedTab(tab)) {
+ // We only ever want to show this promotion for the current tab.
+ return;
+ }
+
+ if (Tabs.TabEvents.LOADED != msg) {
+ return;
+ }
+
+ if (tab.isPrivate()) {
+ // Never show the prompt for private browsing tabs.
+ return;
+ }
+
+ if (isTabsTrayVisible()) {
+ // We only want to show this prompt if this tab is in the foreground and not on top
+ // of the tabs tray.
+ return;
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ maybeShowPromotionForUrl(tab.getURL(), tab.getTitle());
+ }
+ });
+ }
+
+ private void maybeShowPromotionForUrl(String url, String title) {
+ if (!isEnabled) {
+ return;
+ }
+
+ final Context context = activityReference.get();
+ if (context == null) {
+ return;
+ }
+
+ if (!shouldShowPromotion(context, url, title)) {
+ return;
+ }
+
+ HomeScreenPrompt.show(context, url, title);
+ }
+
+ private boolean shouldShowPromotion(Context context, String url, String title) {
+ if (TextUtils.isEmpty(url) || TextUtils.isEmpty(title)) {
+ // We require an URL and a title for the shortcut.
+ return false;
+ }
+
+ if (AboutPages.isAboutPage(url)) {
+ // No promotion for our internal sites.
+ return false;
+ }
+
+ if (!url.startsWith("https://")) {
+ // Only promote websites that are served over HTTPS.
+ return false;
+ }
+
+ URLHistory history = getHistoryForURL(context, url);
+ if (history == null) {
+ // There's no history for this URL yet or we can't read it right now. Just ignore.
+ return false;
+ }
+
+ if (history.visits < minimumVisits) {
+ // This URL has not been visited often enough.
+ return false;
+ }
+
+ if (history.lastVisit > System.currentTimeMillis() - lastVisitMinimumAgeMs) {
+ // The last visit is too new. Do not show promotion. This is mostly to avoid that the
+ // promotion shows up for a quick refreshs and in the worst case the last visit could
+ // be the current visit (race).
+ return false;
+ }
+
+ if (history.lastVisit < System.currentTimeMillis() - lastVisitMaximumAgeMs) {
+ // The last visit is to old. Do not show promotion.
+ return false;
+ }
+
+ if (hasAcceptedOrDeclinedHomeScreenShortcut(context, url)) {
+ // The user has already created a shortcut in the past or actively declined to create one.
+ // Let's not ask again for this url - We do not want to be annoying.
+ return false;
+ }
+
+ return true;
+ }
+
+ protected boolean hasAcceptedOrDeclinedHomeScreenShortcut(Context context, String url) {
+ final UrlAnnotations urlAnnotations = BrowserDB.from(context).getUrlAnnotations();
+ return urlAnnotations.hasAcceptedOrDeclinedHomeScreenShortcut(context.getContentResolver(), url);
+ }
+
+ protected URLHistory getHistoryForURL(Context context, String url) {
+ final GeckoProfile profile = GeckoProfile.get(context);
+ final BrowserDB browserDB = BrowserDB.from(profile);
+
+ Cursor cursor = null;
+ try {
+ cursor = browserDB.getHistoryForURL(context.getContentResolver(), url);
+
+ if (cursor.moveToFirst()) {
+ return new URLHistory(
+ cursor.getInt(cursor.getColumnIndex(BrowserContract.History.VISITS)),
+ cursor.getLong(cursor.getColumnIndex(BrowserContract.History.DATE_LAST_VISITED)));
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java b/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java
new file mode 100644
index 000000000..0f2df8a2c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/HomeScreenPrompt.java
@@ -0,0 +1,237 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.promotion;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Prompt to promote adding the current website to the home screen.
+ */
+public class HomeScreenPrompt extends Locales.LocaleAwareActivity implements IconCallback {
+ private static final String EXTRA_TITLE = "title";
+ private static final String EXTRA_URL = "url";
+
+ private static final String TELEMETRY_EXTRA = "home_screen_promotion";
+
+ private View containerView;
+ private ImageView iconView;
+ private String title;
+ private String url;
+ private boolean isAnimating;
+ private boolean hasAccepted;
+ private boolean hasDeclined;
+
+ public static void show(Context context, String url, String title) {
+ Intent intent = new Intent(context, HomeScreenPrompt.class);
+ intent.putExtra(EXTRA_TITLE, title);
+ intent.putExtra(EXTRA_URL, url);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ fetchDataFromIntent();
+ setupViews();
+ loadShortcutIcon();
+
+ slideIn();
+
+ Telemetry.startUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+
+ // Technically this isn't triggered by a "service". But it's also triggered by a background task and without
+ // actual user interaction.
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.SERVICE, TELEMETRY_EXTRA);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ Telemetry.stopUISession(TelemetryContract.Session.EXPERIMENT, Experiments.PROMOTE_ADD_TO_HOMESCREEN);
+ }
+
+ private void fetchDataFromIntent() {
+ final Bundle extras = getIntent().getExtras();
+
+ title = extras.getString(EXTRA_TITLE);
+ url = extras.getString(EXTRA_URL);
+ }
+
+ private void setupViews() {
+ setContentView(R.layout.homescreen_prompt);
+
+ ((TextView) findViewById(R.id.title)).setText(title);
+
+ Uri uri = Uri.parse(url);
+ ((TextView) findViewById(R.id.host)).setText(uri.getHost());
+
+ containerView = findViewById(R.id.container);
+ iconView = (ImageView) findViewById(R.id.icon);
+
+ findViewById(R.id.add).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ hasAccepted = true;
+
+ addToHomeScreen();
+ }
+ });
+
+ findViewById(R.id.close).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onDecline();
+ }
+ });
+ }
+
+ private void addToHomeScreen() {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ GeckoAppShell.createShortcut(title, url);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, TELEMETRY_EXTRA);
+
+ ActivityUtils.goToHomeScreen(HomeScreenPrompt.this);
+
+ finish();
+ }
+ });
+ }
+
+
+
+ private void loadShortcutIcon() {
+ Icons.with(this)
+ .pageUrl(url)
+ .skipNetwork()
+ .skipMemory()
+ .forLauncherIcon()
+ .build()
+ .execute(this);
+ }
+
+ private void slideIn() {
+ containerView.setTranslationY(500);
+ containerView.setAlpha(0);
+
+ final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0);
+ translateAnimator.setDuration(400);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1);
+ alphaAnimator.setStartDelay(200);
+ alphaAnimator.setDuration(600);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(alphaAnimator, translateAnimator);
+ set.setStartDelay(400);
+
+ set.start();
+ }
+
+ /**
+ * Remember that the user rejected creating a home screen shortcut for this URL.
+ */
+ private void rememberRejection() {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final UrlAnnotations urlAnnotations = BrowserDB.from(HomeScreenPrompt.this).getUrlAnnotations();
+ urlAnnotations.insertHomeScreenShortcut(getContentResolver(), url, false);
+ }
+ });
+ }
+
+ private void slideOut() {
+ if (isAnimating) {
+ return;
+ }
+
+ isAnimating = true;
+
+ ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight());
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ finish();
+ }
+
+ });
+ animator.start();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+
+ @Override
+ public void onBackPressed() {
+ onDecline();
+ }
+
+ private void onDecline() {
+ if (hasDeclined || hasAccepted) {
+ return;
+ }
+
+ rememberRejection();
+ slideOut();
+
+ // Technically not always an action triggered by the "back" button but with the same effect: Finishing this
+ // activity and going back to the previous one.
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, TELEMETRY_EXTRA);
+
+ hasDeclined = true;
+ }
+
+ /**
+ * User clicked outside of the prompt.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ onDecline();
+
+ return true;
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ iconView.setImageBitmap(response.getBitmap());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java b/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java
new file mode 100644
index 000000000..db5a531c6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/ReaderViewBookmarkPromotion.java
@@ -0,0 +1,103 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.promotion;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.Experiments;
+
+public class ReaderViewBookmarkPromotion extends BrowserAppDelegateWithReference implements Tabs.OnTabsChangedListener {
+ private static final String PREF_FIRST_RV_HINT_SHOWN = "first_reader_view_hint_shown";
+ private static final String FIRST_READERVIEW_OPEN_TELEMETRYEXTRA = "first_readerview_open_prompt";
+
+ private boolean hasEnteredReaderMode = false;
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ Tabs.registerOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ switch (msg) {
+ case LOCATION_CHANGE:
+ // old url: data
+ // new url: tab.getURL()
+ final boolean enteringReaderMode = ReaderModeUtils.isEnteringReaderMode(data, tab.getURL());
+
+ if (!hasEnteredReaderMode && enteringReaderMode) {
+ hasEnteredReaderMode = true;
+ promoteBookmarking();
+ }
+
+ break;
+ }
+ }
+
+ @Override
+ public void onActivityResult(BrowserApp browserApp, int requestCode, int resultCode, Intent data) {
+ switch (requestCode) {
+ case BrowserApp.ACTIVITY_REQUEST_TRIPLE_READERVIEW:
+ if (resultCode == BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK) {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ tab.addBookmark();
+ }
+ } else if (resultCode == BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE) {
+ // Nothing to do: we won't show this promotion again either way.
+ }
+ break;
+ }
+ }
+
+ private void promoteBookmarking() {
+ final BrowserApp browserApp = getBrowserApp();
+ if (browserApp == null) {
+ return;
+ }
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forProfile(browserApp);
+ final boolean isEnabled = SwitchBoard.isInExperiment(browserApp, Experiments.TRIPLE_READERVIEW_BOOKMARK_PROMPT);
+
+ // We reuse the same preference as for the first offline reader view bookmark
+ // as we only want to show one of the two UIs (they both explain the same
+ // functionality).
+ if (!isEnabled || prefs.getBoolean(PREF_FIRST_RV_HINT_SHOWN, false)) {
+ return;
+ }
+
+ SimpleHelperUI.show(browserApp,
+ FIRST_READERVIEW_OPEN_TELEMETRYEXTRA,
+ BrowserApp.ACTIVITY_REQUEST_TRIPLE_READERVIEW,
+ R.string.helper_triple_readerview_open_title,
+ R.string.helper_triple_readerview_open_message,
+ R.drawable.helper_readerview_bookmark, // We share the icon with the usual helper UI
+ R.string.helper_triple_readerview_open_button,
+ BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_ADD_BOOKMARK,
+ BrowserApp.ACTIVITY_RESULT_TRIPLE_READERVIEW_IGNORE);
+
+ prefs
+ .edit()
+ .putBoolean(PREF_FIRST_RV_HINT_SHOWN, true)
+ .apply();
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java b/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java
new file mode 100644
index 000000000..b6b857fb9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/promotion/SimpleHelperUI.java
@@ -0,0 +1,194 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.promotion;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.StringRes;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+/**
+ * Generic HelperUI (prompt) that can be populated with an image, title, message and action button.
+ * See show() for usage. This is run as an Activity, results must be handled in the parent Activities
+ * onActivityResult().
+ */
+public class SimpleHelperUI extends Locales.LocaleAwareActivity {
+ public static final String PREF_FIRST_RVBP_SHOWN = "first_reader_view_bookmark_prompt_shown";
+ public static final String FIRST_RVBP_SHOWN_TELEMETRYEXTRA = "first_readerview_bookmark_prompt";
+
+ private View containerView;
+
+ private boolean isAnimating;
+
+ private String mTelemetryExtra;
+
+ private static final String EXTRA_TELEMETRYEXTRA = "telemetryextra";
+ private static final String EXTRA_TITLE = "title";
+ private static final String EXTRA_MESSAGE = "message";
+ private static final String EXTRA_IMAGE = "image";
+ private static final String EXTRA_BUTTON = "button";
+ private static final String EXTRA_RESULTCODE_POSITIVE = "positive";
+ private static final String EXTRA_RESULTCODE_NEGATIVE = "negative";
+
+
+ /**
+ * Show a generic helper UI/prompt.
+ *
+ * @param owner The owning Activity, the result of this prompt will be delivered to its
+ * onActivityResult().
+ * @param requestCode The request code for the Activity that will be created, this is passed to
+ * onActivityResult() to identify the prompt.
+ *
+ * @param positiveResultCode The result code passed to onActivityResult() when the button has
+ * been pressed.
+ * @param negativeResultCode The result code passed to onActivityResult() when the prompt was
+ * dismissed, either by pressing outside the prompt or by pressing the
+ * device back button.
+ */
+ public static void show(Activity owner, String telemetryExtra,
+ int requestCode,
+ @StringRes int title, @StringRes int message,
+ @DrawableRes int image, @StringRes int buttonText,
+ int positiveResultCode, int negativeResultCode) {
+ Intent intent = new Intent(owner, SimpleHelperUI.class);
+
+ intent.putExtra(EXTRA_TELEMETRYEXTRA, telemetryExtra);
+
+ intent.putExtra(EXTRA_TITLE, title);
+ intent.putExtra(EXTRA_MESSAGE, message);
+
+ intent.putExtra(EXTRA_IMAGE, image);
+ intent.putExtra(EXTRA_BUTTON, buttonText);
+
+ intent.putExtra(EXTRA_RESULTCODE_POSITIVE, positiveResultCode);
+ intent.putExtra(EXTRA_RESULTCODE_NEGATIVE, negativeResultCode);
+
+ owner.startActivityForResult(intent, requestCode);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mTelemetryExtra = getIntent().getStringExtra(EXTRA_TELEMETRYEXTRA);
+
+ setupViews();
+
+ slideIn();
+ }
+
+ private void setupViews() {
+ final Intent i = getIntent();
+
+ setContentView(R.layout.simple_helper_ui);
+
+ containerView = findViewById(R.id.container);
+
+ ((ImageView) findViewById(R.id.image)).setImageResource(i.getIntExtra(EXTRA_IMAGE, 0));
+
+ ((TextView) findViewById(R.id.title)).setText(i.getIntExtra(EXTRA_TITLE, 0));
+
+ ((TextView) findViewById(R.id.message)).setText(i.getIntExtra(EXTRA_MESSAGE, 0));
+
+ final Button button = ((Button) findViewById(R.id.button));
+ button.setText(i.getIntExtra(EXTRA_BUTTON, 0));
+ button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ slideOut();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, mTelemetryExtra);
+
+ setResult(i.getIntExtra(EXTRA_RESULTCODE_POSITIVE, -1));
+ }
+ });
+ }
+
+ private void slideIn() {
+ containerView.setTranslationY(500);
+ containerView.setAlpha(0);
+
+ final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0);
+ translateAnimator.setDuration(400);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1);
+ alphaAnimator.setStartDelay(200);
+ alphaAnimator.setDuration(600);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(alphaAnimator, translateAnimator);
+ set.setStartDelay(400);
+
+ set.start();
+ }
+
+ private void slideOut() {
+ if (isAnimating) {
+ return;
+ }
+
+ isAnimating = true;
+
+ ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight());
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ finish();
+ }
+
+ });
+ animator.start();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+
+ @Override
+ public void onBackPressed() {
+ slideOut();
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, mTelemetryExtra);
+
+ setResult(getIntent().getIntExtra(EXTRA_RESULTCODE_NEGATIVE, -1));
+
+ }
+
+ /**
+ * User clicked outside of the prompt.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ slideOut();
+
+ // Not really an action triggered by the "back" button but with the same effect: Finishing this
+ // activity and going back to the previous one.
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL, TelemetryContract.Method.BACK, mTelemetryExtra);
+
+ setResult(getIntent().getIntExtra(EXTRA_RESULTCODE_NEGATIVE, -1));
+
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java
new file mode 100644
index 000000000..3d66eeea8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/ColorPickerInput.java
@@ -0,0 +1,59 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.widget.BasicColorPicker;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.View;
+
+public class ColorPickerInput extends PromptInput {
+ public static final String INPUT_TYPE = "color";
+ public static final String LOGTAG = "GeckoColorPickerInput";
+
+ private final boolean mShowAdvancedButton = true;
+ private final int mInitialColor;
+
+ public ColorPickerInput(JSONObject obj) {
+ super(obj);
+ String init = obj.optString("value");
+ mInitialColor = Color.rgb(Integer.parseInt(init.substring(1, 3), 16),
+ Integer.parseInt(init.substring(3, 5), 16),
+ Integer.parseInt(init.substring(5, 7), 16));
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ mView = inflater.inflate(R.layout.basic_color_picker_dialog, null);
+
+ BasicColorPicker cp = (BasicColorPicker) mView.findViewById(R.id.colorpicker);
+ cp.setColor(mInitialColor);
+
+ return mView;
+ }
+
+ @Override
+ public Object getValue() {
+ BasicColorPicker cp = (BasicColorPicker) mView.findViewById(R.id.colorpicker);
+ int color = cp.getColor();
+ return "#" + Integer.toHexString(color).substring(2);
+ }
+
+ @Override
+ public boolean getScrollable() {
+ return true;
+ }
+
+ @Override
+ public boolean canApplyInputStyle() {
+ return false;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java
new file mode 100644
index 000000000..bc7d7ac20
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IconGridInput.java
@@ -0,0 +1,171 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.GridView;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+public class IconGridInput extends PromptInput implements OnItemClickListener {
+ public static final String INPUT_TYPE = "icongrid";
+ public static final String LOGTAG = "GeckoIconGridInput";
+
+ private ArrayAdapter<IconGridItem> mAdapter; // An adapter holding a list of items to show in the grid
+
+ private static int mColumnWidth = -1; // The maximum width of columns
+ private static int mMaxColumns = -1; // The maximum number of columns to show
+ private static int mIconSize = -1; // Size of icons in the grid
+ private int mSelected; // Current selection
+ private final JSONArray mArray;
+
+ public IconGridInput(JSONObject obj) {
+ super(obj);
+ mArray = obj.optJSONArray("items");
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ if (mColumnWidth < 0) {
+ // getColumnWidth isn't available on pre-ICS, so we pull it out and assign it here
+ mColumnWidth = context.getResources().getDimensionPixelSize(R.dimen.icongrid_columnwidth);
+ }
+
+ if (mIconSize < 0) {
+ mIconSize = GeckoAppShell.getPreferredIconSize();
+ }
+
+ if (mMaxColumns < 0) {
+ mMaxColumns = context.getResources().getInteger(R.integer.max_icon_grid_columns);
+ }
+
+ // TODO: Dynamically handle size changes
+ final WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ final Display display = wm.getDefaultDisplay();
+ final int screenWidth = display.getWidth();
+ int maxColumns = Math.min(mMaxColumns, screenWidth / mColumnWidth);
+
+ final GridView view = (GridView) LayoutInflater.from(context).inflate(R.layout.icon_grid, null, false);
+ view.setColumnWidth(mColumnWidth);
+
+ final ArrayList<IconGridItem> items = new ArrayList<IconGridItem>(mArray.length());
+ for (int i = 0; i < mArray.length(); i++) {
+ IconGridItem item = new IconGridItem(context, mArray.optJSONObject(i));
+ items.add(item);
+ if (item.selected) {
+ mSelected = i;
+ }
+ }
+
+ view.setNumColumns(Math.min(items.size(), maxColumns));
+ view.setOnItemClickListener(this);
+ // Despite what the docs say, setItemChecked was not moved into the AbsListView class until sometime between
+ // Android 2.3.7 and Android 4.0.3. For other versions the item won't be visually highlighted, BUT we really only
+ // mSelected will still be set so that we default to its behavior.
+ if (mSelected > -1) {
+ view.setItemChecked(mSelected, true);
+ }
+
+ mAdapter = new IconGridAdapter(context, -1, items);
+ view.setAdapter(mAdapter);
+ mView = view;
+ return mView;
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ mSelected = position;
+ notifyListeners(Integer.toString(position));
+ }
+
+ @Override
+ public Object getValue() {
+ return mSelected;
+ }
+
+ @Override
+ public boolean getScrollable() {
+ return true;
+ }
+
+ private class IconGridAdapter extends ArrayAdapter<IconGridItem> {
+ public IconGridAdapter(Context context, int resource, List<IconGridItem> items) {
+ super(context, resource, items);
+ }
+
+ @Override
+ public View getView(int position, View convert, ViewGroup parent) {
+ final Context context = parent.getContext();
+ if (convert == null) {
+ convert = LayoutInflater.from(context).inflate(R.layout.icon_grid_item, parent, false);
+ }
+ bindView(convert, context, position);
+ return convert;
+ }
+
+ private void bindView(View v, Context c, int position) {
+ final IconGridItem item = getItem(position);
+ final TextView text1 = (TextView) v.findViewById(android.R.id.text1);
+ text1.setText(item.label);
+
+ final TextView text2 = (TextView) v.findViewById(android.R.id.text2);
+ if (TextUtils.isEmpty(item.description)) {
+ text2.setVisibility(View.GONE);
+ } else {
+ text2.setVisibility(View.VISIBLE);
+ text2.setText(item.description);
+ }
+
+ final ImageView icon = (ImageView) v.findViewById(R.id.icon);
+ icon.setImageDrawable(item.icon);
+ ViewGroup.LayoutParams lp = icon.getLayoutParams();
+ lp.width = lp.height = mIconSize;
+ }
+ }
+
+ private class IconGridItem {
+ final String label;
+ final String description;
+ final boolean selected;
+ Drawable icon;
+
+ public IconGridItem(final Context context, final JSONObject obj) {
+ label = obj.optString("name");
+ final String iconUrl = obj.optString("iconUri");
+ description = obj.optString("description");
+ selected = obj.optBoolean("selected");
+
+ ResourceDrawableUtils.getDrawable(context, iconUrl, new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(Drawable d) {
+ icon = d;
+ if (mAdapter != null) {
+ mAdapter.notifyDataSetChanged();
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java
new file mode 100644
index 000000000..502f1156d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentChooserPrompt.java
@@ -0,0 +1,158 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.widget.ListView;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a prompt letting the user pick from a list of intent handlers for a set of Intents or
+ * for a GeckoActionProvider. Basic usage:
+ * IntentChooserPrompt prompt = new IntentChooserPrompt(context, new Intent[] {
+ * ... // some intents
+ * });
+ * prompt.show("Title", context, new IntentHandler() {
+ * public void onIntentSelected(Intent intent, int position) { }
+ * public void onCancelled() { }
+ * });
+ **/
+public class IntentChooserPrompt {
+ private static final String LOGTAG = "GeckoIntentChooser";
+
+ private final ArrayList<PromptListItem> mItems;
+
+ public IntentChooserPrompt(Context context, Intent[] intents) {
+ mItems = getItems(context, intents);
+ }
+
+ public IntentChooserPrompt(Context context, GeckoActionProvider provider) {
+ mItems = getItems(context, provider);
+ }
+
+ /* If an IntentHandler is passed in, will asynchronously call the handler when the dialog is closed
+ * Otherwise, will return the Intent that was chosen by the user. Must be called on the UI thread.
+ */
+ public void show(final String title, final Context context, final IntentHandler handler) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mItems.isEmpty()) {
+ Log.i(LOGTAG, "No activities for the intent chooser!");
+ handler.onCancelled();
+ return;
+ }
+
+ // If there's only one item in the intent list, just return it
+ if (mItems.size() == 1) {
+ handler.onIntentSelected(mItems.get(0).getIntent(), 0);
+ return;
+ }
+
+ final Prompt prompt = new Prompt(context, new Prompt.PromptCallback() {
+ @Override
+ public void onPromptFinished(String promptServiceResult) {
+ if (handler == null) {
+ return;
+ }
+
+ int itemId = -1;
+ try {
+ itemId = new JSONObject(promptServiceResult).getInt("button");
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "result from promptservice was invalid: ", e);
+ }
+
+ if (itemId == -1) {
+ handler.onCancelled();
+ } else {
+ handler.onIntentSelected(mItems.get(itemId).getIntent(), itemId);
+ }
+ }
+ });
+
+ PromptListItem[] arrays = new PromptListItem[mItems.size()];
+ mItems.toArray(arrays);
+ prompt.show(title, "", arrays, ListView.CHOICE_MODE_NONE);
+
+ return;
+ }
+
+ // Whether or not any activities were found. Useful for checking if you should try a different Intent set
+ public boolean hasActivities(Context context) {
+ return mItems.isEmpty();
+ }
+
+ // Gets a list of PromptListItems for an Intent array
+ private ArrayList<PromptListItem> getItems(final Context context, Intent[] intents) {
+ final ArrayList<PromptListItem> items = new ArrayList<PromptListItem>();
+
+ // If we have intents, use them to build the initial list
+ for (final Intent intent : intents) {
+ items.addAll(getItemsForIntent(context, intent));
+ }
+
+ return items;
+ }
+
+ // Gets a list of PromptListItems for a GeckoActionProvider
+ private ArrayList<PromptListItem> getItems(final Context context, final GeckoActionProvider provider) {
+ final ArrayList<PromptListItem> items = new ArrayList<PromptListItem>();
+
+ // Add any intents from the provider.
+ final PackageManager packageManager = context.getPackageManager();
+ final ArrayList<ResolveInfo> infos = provider.getSortedActivities();
+
+ for (final ResolveInfo info : infos) {
+ items.add(getItemForResolveInfo(info, packageManager, provider.getIntent()));
+ }
+
+ return items;
+ }
+
+ private PromptListItem getItemForResolveInfo(ResolveInfo info, PackageManager pm, Intent intent) {
+ PromptListItem item = new PromptListItem(info.loadLabel(pm).toString());
+ item.setIcon(info.loadIcon(pm));
+
+ Intent i = new Intent(intent);
+ // These intents should be implicit.
+ i.setComponent(new ComponentName(info.activityInfo.applicationInfo.packageName,
+ info.activityInfo.name));
+ item.setIntent(new Intent(i));
+
+ return item;
+ }
+
+ private ArrayList<PromptListItem> getItemsForIntent(Context context, Intent intent) {
+ ArrayList<PromptListItem> items = new ArrayList<PromptListItem>();
+ PackageManager pm = context.getPackageManager();
+ List<ResolveInfo> lri = pm.queryIntentActivityOptions(GeckoAppShell.getGeckoInterface().getActivity().getComponentName(), null, intent, 0);
+
+ // If we didn't find any activities, just return the empty list
+ if (lri == null) {
+ return items;
+ }
+
+ // Otherwise, convert the ResolveInfo. Note we don't currently check for duplicates here.
+ for (ResolveInfo ri : lri) {
+ items.add(getItemForResolveInfo(ri, pm, intent));
+ }
+
+ return items;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java
new file mode 100644
index 000000000..1509ab626
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/IntentHandler.java
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import android.content.Intent;
+
+public interface IntentHandler {
+ public void onIntentSelected(Intent intent, int position);
+ public void onCancelled();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java b/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java
new file mode 100644
index 000000000..11121b2cc
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/Prompt.java
@@ -0,0 +1,586 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.DialogInterface.OnClickListener;
+import android.content.res.Resources;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.ScrollView;
+
+import java.util.ArrayList;
+
+public class Prompt implements OnClickListener, OnCancelListener, OnItemClickListener,
+ PromptInput.OnChangeListener, Tabs.OnTabsChangedListener {
+ private static final String LOGTAG = "GeckoPromptService";
+
+ private String[] mButtons;
+ private PromptInput[] mInputs;
+ private AlertDialog mDialog;
+ private int mDoubleTapButtonType;
+
+ private final LayoutInflater mInflater;
+ private final Context mContext;
+ private PromptCallback mCallback;
+ private String mGuid;
+ private PromptListAdapter mAdapter;
+
+ private static boolean mInitialized;
+ private static int mInputPaddingSize;
+
+ private int mTabId = Tabs.INVALID_TAB_ID;
+ private Object mPreviousInputValue = null;
+
+ public Prompt(Context context, PromptCallback callback) {
+ this(context);
+ mCallback = callback;
+ }
+
+ private Prompt(Context context) {
+ mContext = context;
+ mInflater = LayoutInflater.from(mContext);
+
+ if (!mInitialized) {
+ Resources res = mContext.getResources();
+ mInputPaddingSize = (int) (res.getDimension(R.dimen.prompt_service_inputs_padding));
+ mInitialized = true;
+ }
+ }
+
+ private View applyInputStyle(View view, PromptInput input) {
+ // Don't add padding to color picker views
+ if (input.canApplyInputStyle()) {
+ view.setPadding(mInputPaddingSize, 0, mInputPaddingSize, 0);
+ }
+ return view;
+ }
+
+ public void show(JSONObject message) {
+ String title = message.optString("title");
+ String text = message.optString("text");
+ mGuid = message.optString("guid");
+
+ mButtons = getStringArray(message, "buttons");
+ final int buttonCount = mButtons == null ? 0 : mButtons.length;
+ mDoubleTapButtonType = convertIndexToButtonType(message.optInt("doubleTapButton", -1), buttonCount);
+ mPreviousInputValue = null;
+
+ JSONArray inputs = getSafeArray(message, "inputs");
+ mInputs = new PromptInput[inputs.length()];
+ for (int i = 0; i < mInputs.length; i++) {
+ try {
+ mInputs[i] = PromptInput.getInput(inputs.getJSONObject(i));
+ mInputs[i].setListener(this);
+ } catch (Exception ex) { }
+ }
+
+ PromptListItem[] menuitems = PromptListItem.getArray(message.optJSONArray("listitems"));
+ String selected = message.optString("choiceMode");
+
+ int choiceMode = ListView.CHOICE_MODE_NONE;
+ if ("single".equals(selected)) {
+ choiceMode = ListView.CHOICE_MODE_SINGLE;
+ } else if ("multiple".equals(selected)) {
+ choiceMode = ListView.CHOICE_MODE_MULTIPLE;
+ }
+
+ if (message.has("tabId")) {
+ mTabId = message.optInt("tabId", Tabs.INVALID_TAB_ID);
+ }
+
+ show(title, text, menuitems, choiceMode);
+ }
+
+ private int convertIndexToButtonType(final int buttonIndex, final int buttonCount) {
+ if (buttonIndex < 0 || buttonIndex >= buttonCount) {
+ // All valid DialogInterface button values are < 0,
+ // so we return 0 as an invalid value.
+ return 0;
+ }
+
+ switch (buttonIndex) {
+ case 0:
+ return DialogInterface.BUTTON_POSITIVE;
+ case 1:
+ return DialogInterface.BUTTON_NEUTRAL;
+ case 2:
+ return DialogInterface.BUTTON_NEGATIVE;
+ default:
+ return 0;
+ }
+ }
+
+ public void show(String title, String text, PromptListItem[] listItems, int choiceMode) {
+ ThreadUtils.assertOnUiThread();
+
+ try {
+ create(title, text, listItems, choiceMode);
+ } catch (IllegalStateException ex) {
+ Log.i(LOGTAG, "Error building dialog", ex);
+ return;
+ }
+
+ if (mTabId != Tabs.INVALID_TAB_ID) {
+ Tabs.registerOnTabsChangedListener(this);
+
+ final Tab tab = Tabs.getInstance().getTab(mTabId);
+ if (Tabs.getInstance().getSelectedTab() == tab) {
+ mDialog.show();
+ }
+ } else {
+ mDialog.show();
+ }
+ }
+
+ @Override
+ public void onTabChanged(final Tab tab, final Tabs.TabEvents msg, final String data) {
+ if (tab != Tabs.getInstance().getTab(mTabId)) {
+ return;
+ }
+
+ switch (msg) {
+ case SELECTED:
+ Log.i(LOGTAG, "Selected");
+ mDialog.show();
+ break;
+ case UNSELECTED:
+ Log.i(LOGTAG, "Unselected");
+ mDialog.hide();
+ break;
+ case LOCATION_CHANGE:
+ Log.i(LOGTAG, "Location change");
+ mDialog.cancel();
+ break;
+ }
+ }
+
+ private void create(String title, String text, PromptListItem[] listItems, int choiceMode)
+ throws IllegalStateException {
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
+ if (!TextUtils.isEmpty(title)) {
+ // Long strings can delay showing the dialog, so we cap the number of characters shown to 256.
+ builder.setTitle(title.substring(0, Math.min(title.length(), 256)));
+ }
+
+ if (!TextUtils.isEmpty(text)) {
+ builder.setMessage(text);
+ }
+
+ // Because lists are currently added through the normal Android AlertBuilder interface, they're
+ // incompatible with also adding additional input elements to a dialog.
+ if (listItems != null && listItems.length > 0) {
+ addListItems(builder, listItems, choiceMode);
+ } else if (!addInputs(builder)) {
+ throw new IllegalStateException("Could not add inputs to dialog");
+ }
+
+ int length = mButtons == null ? 0 : mButtons.length;
+ if (length > 0) {
+ builder.setPositiveButton(mButtons[0], this);
+ if (length > 1) {
+ builder.setNeutralButton(mButtons[1], this);
+ if (length > 2) {
+ builder.setNegativeButton(mButtons[2], this);
+ }
+ }
+ }
+
+ mDialog = builder.create();
+ mDialog.setOnCancelListener(Prompt.this);
+ }
+
+ public void setButtons(String[] buttons) {
+ mButtons = buttons;
+ }
+
+ public void setInputs(PromptInput[] inputs) {
+ mInputs = inputs;
+ }
+
+ /* Adds to a result value from the lists that can be shown in dialogs.
+ * Will set the selected value(s) to the button attribute of the
+ * object that's passed in. If this is a multi-select dialog, sets a
+ * selected attribute to an array of booleans.
+ */
+ private void addListResult(final JSONObject result, int which) {
+ if (mAdapter == null) {
+ return;
+ }
+
+ try {
+ JSONArray selected = new JSONArray();
+
+ // If the button has already been filled in
+ ArrayList<Integer> selectedItems = mAdapter.getSelected();
+ for (Integer item : selectedItems) {
+ selected.put(item);
+ }
+
+ // If we haven't assigned a button yet, or we assigned it to -1, assign the which
+ // parameter to both selected and the button.
+ if (!result.has("button") || result.optInt("button") == -1) {
+ if (!selectedItems.contains(which)) {
+ selected.put(which);
+ }
+
+ result.put("button", which);
+ }
+
+ result.put("list", selected);
+ } catch (JSONException ex) { }
+ }
+
+ /* Adds to a result value from the inputs that can be shown in dialogs.
+ * Each input will set its own value in the result.
+ */
+ private void addInputValues(final JSONObject result) {
+ try {
+ if (mInputs != null) {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i] != null) {
+ result.put(mInputs[i].getId(), mInputs[i].getValue());
+ }
+ }
+ }
+ } catch (JSONException ex) { }
+ }
+
+ /* Adds the selected button to a result. This should only be called if there
+ * are no lists shown on the dialog, since they also write their results to the button
+ * attribute.
+ */
+ private void addButtonResult(final JSONObject result, int which) {
+ int button = -1;
+ switch (which) {
+ case DialogInterface.BUTTON_POSITIVE : button = 0; break;
+ case DialogInterface.BUTTON_NEUTRAL : button = 1; break;
+ case DialogInterface.BUTTON_NEGATIVE : button = 2; break;
+ }
+ try {
+ result.put("button", button);
+ } catch (JSONException ex) { }
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ThreadUtils.assertOnUiThread();
+ closeDialog(which);
+ }
+
+ /* Adds a set of list items to the prompt. This can be used for either context menu type dialogs, checked lists,
+ * or multiple selection lists.
+ *
+ * @param builder
+ * The alert builder currently building this dialog.
+ * @param listItems
+ * The items to add.
+ * @param choiceMode
+ * One of the ListView.CHOICE_MODE constants to designate whether this list shows checkmarks, radios buttons, or nothing.
+ */
+ private void addListItems(AlertDialog.Builder builder, PromptListItem[] listItems, int choiceMode) {
+ switch (choiceMode) {
+ case ListView.CHOICE_MODE_MULTIPLE_MODAL:
+ case ListView.CHOICE_MODE_MULTIPLE:
+ addMultiSelectList(builder, listItems);
+ break;
+ case ListView.CHOICE_MODE_SINGLE:
+ addSingleSelectList(builder, listItems);
+ break;
+ case ListView.CHOICE_MODE_NONE:
+ default:
+ addMenuList(builder, listItems);
+ }
+ }
+
+ /* Shows a multi-select list with checkmarks on the side. Android doesn't support using an adapter for
+ * multi-choice lists by default so instead we insert our own custom list so that we can do fancy things
+ * to the rows like disabling/indenting them.
+ *
+ * @param builder
+ * The alert builder currently building this dialog.
+ * @param listItems
+ * The items to add.
+ */
+ private void addMultiSelectList(AlertDialog.Builder builder, PromptListItem[] listItems) {
+ ListView listView = (ListView) mInflater.inflate(R.layout.select_dialog_list, null);
+ listView.setOnItemClickListener(this);
+ listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+
+ mAdapter = new PromptListAdapter(mContext, R.layout.select_dialog_multichoice, listItems);
+ listView.setAdapter(mAdapter);
+ builder.setView(listView);
+ }
+
+ /* Shows a single-select list with radio boxes on the side.
+ *
+ * @param builder
+ * the alert builder currently building this dialog.
+ * @param listItems
+ * The items to add.
+ */
+ private void addSingleSelectList(AlertDialog.Builder builder, PromptListItem[] listItems) {
+ mAdapter = new PromptListAdapter(mContext, R.layout.select_dialog_singlechoice, listItems);
+ builder.setSingleChoiceItems(mAdapter, mAdapter.getSelectedIndex(), new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // The adapter isn't aware of single vs. multi choice lists, so manually
+ // clear any other selected items first.
+ ArrayList<Integer> selected = mAdapter.getSelected();
+ for (Integer sel : selected) {
+ mAdapter.toggleSelected(sel);
+ }
+
+ // Now select this item.
+ mAdapter.toggleSelected(which);
+ closeIfNoButtons(which);
+ }
+ });
+ }
+
+ /* Shows a single-select list.
+ *
+ * @param builder
+ * the alert builder currently building this dialog.
+ * @param listItems
+ * The items to add.
+ */
+ private void addMenuList(AlertDialog.Builder builder, PromptListItem[] listItems) {
+ mAdapter = new PromptListAdapter(mContext, android.R.layout.simple_list_item_1, listItems);
+ builder.setAdapter(mAdapter, this);
+ }
+
+ /* Wraps an input in a linearlayout. We do this so that we can set padding that appears outside the background
+ * drawable for the view.
+ */
+ private View wrapInput(final PromptInput input) {
+ final LinearLayout linearLayout = new LinearLayout(mContext);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ applyInputStyle(linearLayout, input);
+
+ linearLayout.addView(input.getView(mContext));
+
+ return linearLayout;
+ }
+
+ /* Add the requested input elements to the dialog.
+ *
+ * @param builder
+ * the alert builder currently building this dialog.
+ * @return
+ * return true if the inputs were added successfully. This may fail
+ * if the requested input is compatible with this Android version.
+ */
+ private boolean addInputs(AlertDialog.Builder builder) {
+ int length = mInputs == null ? 0 : mInputs.length;
+ if (length == 0) {
+ return true;
+ }
+
+ try {
+ View root = null;
+ boolean scrollable = false; // If any of the inputs are scrollable, we won't wrap this in a ScrollView
+
+ if (length == 1) {
+ root = wrapInput(mInputs[0]);
+ scrollable |= mInputs[0].getScrollable();
+ } else if (length > 1) {
+ LinearLayout linearLayout = new LinearLayout(mContext);
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ for (int i = 0; i < length; i++) {
+ View content = wrapInput(mInputs[i]);
+ linearLayout.addView(content);
+ scrollable |= mInputs[i].getScrollable();
+ }
+ root = linearLayout;
+ }
+
+ if (scrollable) {
+ // If we're showing some sort of scrollable list, force an inverse background.
+ builder.setInverseBackgroundForced(true);
+ builder.setView(root);
+ } else {
+ ScrollView view = new ScrollView(mContext);
+ view.addView(root);
+ builder.setView(view);
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Error showing prompt inputs", ex);
+ // We cannot display these input widgets with this sdk version,
+ // do not display any dialog and finish the prompt now.
+ cancelDialog();
+ return false;
+ }
+
+ return true;
+ }
+
+ /* AdapterView.OnItemClickListener
+ * Called when a list item is clicked
+ */
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ ThreadUtils.assertOnUiThread();
+ mAdapter.toggleSelected(position);
+
+ // If there are no buttons on this dialog, then we take selecting an item as a sign to close
+ // the dialog. Note that means it will be hard to select multiple things in this list, but
+ // given there is no way to confirm+close the dialog, it seems reasonable.
+ closeIfNoButtons(position);
+ }
+
+ private boolean closeIfNoButtons(int selected) {
+ ThreadUtils.assertOnUiThread();
+ if (mButtons == null || mButtons.length == 0) {
+ closeDialog(selected);
+ return true;
+ }
+ return false;
+ }
+
+ /* @DialogInterface.OnCancelListener
+ * Called when the user hits back to cancel a dialog. The dialog will close itself when this
+ * ends. Setup the correct return values here.
+ *
+ * @param aDialog
+ * A dialog interface for the dialog that's being closed.
+ */
+ @Override
+ public void onCancel(DialogInterface aDialog) {
+ ThreadUtils.assertOnUiThread();
+ cancelDialog();
+ }
+
+ /* Called in situations where we want to cancel the dialog . This can happen if the user hits back,
+ * or if the dialog can't be created because of invalid JSON.
+ */
+ private void cancelDialog() {
+ JSONObject ret = new JSONObject();
+ try {
+ ret.put("button", -1);
+ } catch (Exception ex) { }
+ addInputValues(ret);
+ notifyClosing(ret);
+ }
+
+ /* Called any time we're closing the dialog to cleanup and notify listeners that the dialog
+ * is closing.
+ */
+ private void closeDialog(int which) {
+ JSONObject ret = new JSONObject();
+ mDialog.dismiss();
+
+ addButtonResult(ret, which);
+ addListResult(ret, which);
+ addInputValues(ret);
+
+ notifyClosing(ret);
+ }
+
+ /* Called any time we're closing the dialog to cleanup and notify listeners that the dialog
+ * is closing.
+ */
+ private void notifyClosing(JSONObject aReturn) {
+ try {
+ aReturn.put("guid", mGuid);
+ } catch (JSONException ex) { }
+
+ if (mTabId != Tabs.INVALID_TAB_ID) {
+ Tabs.unregisterOnTabsChangedListener(this);
+ }
+
+ if (mCallback != null) {
+ mCallback.onPromptFinished(aReturn.toString());
+ }
+ }
+
+ // Called when the prompt inputs on the dialog change
+ @Override
+ public void onChange(PromptInput input) {
+ // If there are no buttons on this dialog, assuming that "changing" an input
+ // means something was selected and we can close. This provides a way to tap
+ // on a list item and close the dialog automatically.
+ if (!closeIfNoButtons(-1)) {
+ // Alternatively, if a default button has been specified for double tapping,
+ // we want to close the dialog if the same input value has been transmitted
+ // twice in a row.
+ closeIfDoubleTapEnabled(input.getValue());
+ }
+ }
+
+ private boolean closeIfDoubleTapEnabled(Object inputValue) {
+ if (mDoubleTapButtonType != 0 && inputValue == mPreviousInputValue) {
+ closeDialog(mDoubleTapButtonType);
+ return true;
+ }
+ mPreviousInputValue = inputValue;
+ return false;
+ }
+
+ private static JSONArray getSafeArray(JSONObject json, String key) {
+ try {
+ return json.getJSONArray(key);
+ } catch (Exception e) {
+ return new JSONArray();
+ }
+ }
+
+ public static String[] getStringArray(JSONObject aObject, String aName) {
+ JSONArray items = getSafeArray(aObject, aName);
+ int length = items.length();
+ String[] list = new String[length];
+ for (int i = 0; i < length; i++) {
+ try {
+ list[i] = items.getString(i);
+ } catch (Exception ex) { }
+ }
+ return list;
+ }
+
+ private static boolean[] getBooleanArray(JSONObject aObject, String aName) {
+ JSONArray items = new JSONArray();
+ try {
+ items = aObject.getJSONArray(aName);
+ } catch (Exception ex) { return null; }
+ int length = items.length();
+ boolean[] list = new boolean[length];
+ for (int i = 0; i < length; i++) {
+ try {
+ list[i] = items.getBoolean(i);
+ } catch (Exception ex) { }
+ }
+ return list;
+ }
+
+ public interface PromptCallback {
+
+ /**
+ * Called when the Prompt has been completed (i.e. when the user has selected an item or action in the Prompt).
+ * This callback is run on the UI thread.
+ */
+ public void onPromptFinished(String jsonResult);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java
new file mode 100644
index 000000000..752f5c24c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java
@@ -0,0 +1,398 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.widget.AllCapsTextView;
+import org.mozilla.gecko.widget.DateTimePicker;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.support.design.widget.TextInputLayout;
+import android.support.v7.widget.AppCompatCheckBox;
+import android.text.Html;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.text.format.DateFormat;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.DatePicker;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.TimePicker;
+
+public abstract class PromptInput {
+ protected final String mLabel;
+ protected final String mType;
+ protected final String mId;
+ protected final String mValue;
+ protected final String mMinValue;
+ protected final String mMaxValue;
+ protected OnChangeListener mListener;
+ protected View mView;
+ public static final String LOGTAG = "GeckoPromptInput";
+
+ public interface OnChangeListener {
+ void onChange(PromptInput input);
+ }
+
+ public void setListener(OnChangeListener listener) {
+ mListener = listener;
+ }
+
+ public static class EditInput extends PromptInput {
+ protected final String mHint;
+ protected final boolean mAutofocus;
+ public static final String INPUT_TYPE = "textbox";
+
+ public EditInput(JSONObject object) {
+ super(object);
+ mHint = object.optString("hint");
+ mAutofocus = object.optBoolean("autofocus");
+ }
+
+ @Override
+ public View getView(final Context context) throws UnsupportedOperationException {
+ EditText input = new EditText(context);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ input.setText(mValue);
+
+ if (!TextUtils.isEmpty(mHint)) {
+ input.setHint(mHint);
+ }
+
+ if (mAutofocus) {
+ input.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ ((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE)).showSoftInput(v, 0);
+ }
+ }
+ });
+ input.requestFocus();
+ }
+
+ TextInputLayout inputLayout = new TextInputLayout(context);
+ inputLayout.addView(input);
+
+ mView = (View) inputLayout;
+ return mView;
+ }
+
+ @Override
+ public Object getValue() {
+ final TextInputLayout inputLayout = (TextInputLayout) mView;
+ return inputLayout.getEditText().getText();
+ }
+ }
+
+ public static class NumberInput extends EditInput {
+ public static final String INPUT_TYPE = "number";
+ public NumberInput(JSONObject obj) {
+ super(obj);
+ }
+
+ @Override
+ public View getView(final Context context) throws UnsupportedOperationException {
+ final TextInputLayout inputLayout = (TextInputLayout) super.getView(context);
+ final EditText input = inputLayout.getEditText();
+ input.setRawInputType(Configuration.KEYBOARD_12KEY);
+ input.setInputType(InputType.TYPE_CLASS_NUMBER |
+ InputType.TYPE_NUMBER_FLAG_SIGNED);
+ return input;
+ }
+ }
+
+ public static class PasswordInput extends EditInput {
+ public static final String INPUT_TYPE = "password";
+ public PasswordInput(JSONObject obj) {
+ super(obj);
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ final TextInputLayout inputLayout = (TextInputLayout) super.getView(context);
+ inputLayout.getEditText().setInputType(InputType.TYPE_CLASS_TEXT |
+ InputType.TYPE_TEXT_VARIATION_PASSWORD |
+ InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ return inputLayout;
+ }
+ }
+
+ public static class CheckboxInput extends PromptInput {
+ public static final String INPUT_TYPE = "checkbox";
+ private final boolean mChecked;
+
+ public CheckboxInput(JSONObject obj) {
+ super(obj);
+ mChecked = obj.optBoolean("checked");
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ final CheckBox checkbox = new AppCompatCheckBox(context);
+ checkbox.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
+ checkbox.setText(mLabel);
+ checkbox.setChecked(mChecked);
+ mView = (View)checkbox;
+ return mView;
+ }
+
+ @Override
+ public Object getValue() {
+ CheckBox checkbox = (CheckBox)mView;
+ return checkbox.isChecked() ? Boolean.TRUE : Boolean.FALSE;
+ }
+ }
+
+ public static class DateTimeInput extends PromptInput {
+ public static final String[] INPUT_TYPES = new String[] {
+ "date",
+ "week",
+ "time",
+ "datetime-local",
+ "datetime",
+ "month"
+ };
+
+ public DateTimeInput(JSONObject obj) {
+ super(obj);
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ if (mType.equals("date")) {
+ try {
+ DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd", mValue,
+ DateTimePicker.PickersState.DATE, mMinValue, mMaxValue);
+ input.toggleCalendar(true);
+ mView = (View)input;
+ } catch (UnsupportedOperationException ex) {
+ // We can't use our custom version of the DatePicker widget because the sdk is too old.
+ // But we can fallback on the native one.
+ DatePicker input = new DatePicker(context);
+ try {
+ if (!TextUtils.isEmpty(mValue)) {
+ GregorianCalendar calendar = new GregorianCalendar();
+ calendar.setTime(new SimpleDateFormat("yyyy-MM-dd").parse(mValue));
+ input.updateDate(calendar.get(Calendar.YEAR),
+ calendar.get(Calendar.MONTH),
+ calendar.get(Calendar.DAY_OF_MONTH));
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "error parsing format string: " + e);
+ }
+ mView = (View)input;
+ }
+ } else if (mType.equals("week")) {
+ DateTimePicker input = new DateTimePicker(context, "yyyy-'W'ww", mValue,
+ DateTimePicker.PickersState.WEEK, mMinValue, mMaxValue);
+ mView = (View)input;
+ } else if (mType.equals("time")) {
+ TimePicker input = new TimePicker(context);
+ input.setIs24HourView(DateFormat.is24HourFormat(context));
+
+ GregorianCalendar calendar = new GregorianCalendar();
+ if (!TextUtils.isEmpty(mValue)) {
+ try {
+ calendar.setTime(new SimpleDateFormat("HH:mm").parse(mValue));
+ } catch (Exception e) { }
+ }
+ input.setCurrentHour(calendar.get(GregorianCalendar.HOUR_OF_DAY));
+ input.setCurrentMinute(calendar.get(GregorianCalendar.MINUTE));
+ mView = (View)input;
+ } else if (mType.equals("datetime-local") || mType.equals("datetime")) {
+ DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd HH:mm", mValue.replace("T", " ").replace("Z", ""),
+ DateTimePicker.PickersState.DATETIME,
+ mMinValue.replace("T", " ").replace("Z", ""), mMaxValue.replace("T", " ").replace("Z", ""));
+ input.toggleCalendar(true);
+ mView = (View)input;
+ } else if (mType.equals("month")) {
+ DateTimePicker input = new DateTimePicker(context, "yyyy-MM", mValue,
+ DateTimePicker.PickersState.MONTH, mMinValue, mMaxValue);
+ mView = (View)input;
+ }
+ return mView;
+ }
+
+ private static String formatDateString(String dateFormat, Calendar calendar) {
+ return new SimpleDateFormat(dateFormat).format(calendar.getTime());
+ }
+
+ @Override
+ public Object getValue() {
+ if (mType.equals("time")) {
+ TimePicker tp = (TimePicker)mView;
+ GregorianCalendar calendar =
+ new GregorianCalendar(0, 0, 0, tp.getCurrentHour(), tp.getCurrentMinute());
+ return formatDateString("HH:mm", calendar);
+ } else {
+ DateTimePicker dp = (DateTimePicker)mView;
+ GregorianCalendar calendar = new GregorianCalendar();
+ calendar.setTimeInMillis(dp.getTimeInMillis());
+ if (mType.equals("date")) {
+ return formatDateString("yyyy-MM-dd", calendar);
+ } else if (mType.equals("week")) {
+ return formatDateString("yyyy-'W'ww", calendar);
+ } else if (mType.equals("datetime-local")) {
+ return formatDateString("yyyy-MM-dd'T'HH:mm", calendar);
+ } else if (mType.equals("datetime")) {
+ calendar.set(GregorianCalendar.ZONE_OFFSET, 0);
+ calendar.setTimeInMillis(dp.getTimeInMillis());
+ return formatDateString("yyyy-MM-dd'T'HH:mm'Z'", calendar);
+ } else if (mType.equals("month")) {
+ return formatDateString("yyyy-MM", calendar);
+ }
+ }
+ return super.getValue();
+ }
+ }
+
+ public static class MenulistInput extends PromptInput {
+ public static final String INPUT_TYPE = "menulist";
+ private static String[] mListitems;
+ private static int mSelected;
+
+ public Spinner spinner;
+ public AllCapsTextView textView;
+
+ public MenulistInput(JSONObject obj) {
+ super(obj);
+ mListitems = Prompt.getStringArray(obj, "values");
+ mSelected = obj.optInt("selected");
+ }
+
+ @Override
+ public View getView(final Context context) throws UnsupportedOperationException {
+ spinner = new Spinner(context, Spinner.MODE_DIALOG);
+ try {
+ if (mListitems.length > 0) {
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(context, android.R.layout.simple_spinner_item, mListitems);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+
+ spinner.setAdapter(adapter);
+ spinner.setSelection(mSelected);
+ }
+ } catch (Exception ex) {
+ }
+
+ if (!TextUtils.isEmpty(mLabel)) {
+ LinearLayout container = new LinearLayout(context);
+ container.setOrientation(LinearLayout.VERTICAL);
+
+ textView = new AllCapsTextView(context, null);
+ textView.setText(mLabel);
+ container.addView(textView);
+
+ container.addView(spinner);
+ return container;
+ }
+
+ return spinner;
+ }
+
+ @Override
+ public Object getValue() {
+ return spinner.getSelectedItemPosition();
+ }
+ }
+
+ public static class LabelInput extends PromptInput {
+ public static final String INPUT_TYPE = "label";
+ public LabelInput(JSONObject obj) {
+ super(obj);
+ }
+
+ @Override
+ public View getView(Context context) throws UnsupportedOperationException {
+ // not really an input, but a way to add labels and such to the dialog
+ TextView view = new TextView(context);
+ view.setText(Html.fromHtml(mLabel));
+ mView = view;
+ return mView;
+ }
+ }
+
+ public PromptInput(JSONObject obj) {
+ mLabel = obj.optString("label");
+ mType = obj.optString("type");
+ String id = obj.optString("id");
+ mId = TextUtils.isEmpty(id) ? mType : id;
+ mValue = obj.optString("value");
+ mMaxValue = obj.optString("max");
+ mMinValue = obj.optString("min");
+ }
+
+ public static PromptInput getInput(JSONObject obj) {
+ String type = obj.optString("type");
+ switch (type) {
+ case EditInput.INPUT_TYPE:
+ return new EditInput(obj);
+ case NumberInput.INPUT_TYPE:
+ return new NumberInput(obj);
+ case PasswordInput.INPUT_TYPE:
+ return new PasswordInput(obj);
+ case CheckboxInput.INPUT_TYPE:
+ return new CheckboxInput(obj);
+ case MenulistInput.INPUT_TYPE:
+ return new MenulistInput(obj);
+ case LabelInput.INPUT_TYPE:
+ return new LabelInput(obj);
+ case IconGridInput.INPUT_TYPE:
+ return new IconGridInput(obj);
+ case ColorPickerInput.INPUT_TYPE:
+ return new ColorPickerInput(obj);
+ case TabInput.INPUT_TYPE:
+ return new TabInput(obj);
+ default:
+ for (String dtType : DateTimeInput.INPUT_TYPES) {
+ if (dtType.equals(type)) {
+ return new DateTimeInput(obj);
+ }
+ }
+
+ break;
+ }
+
+ return null;
+ }
+
+ public abstract View getView(Context context) throws UnsupportedOperationException;
+
+ public String getId() {
+ return mId;
+ }
+
+ public Object getValue() {
+ return null;
+ }
+
+ public boolean getScrollable() {
+ return false;
+ }
+
+ public boolean canApplyInputStyle() {
+ return true;
+ }
+
+ protected void notifyListeners(String val) {
+ if (mListener != null) {
+ mListener.onChange(this);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java
new file mode 100644
index 000000000..720086c92
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListAdapter.java
@@ -0,0 +1,281 @@
+package org.mozilla.gecko.prompts;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.menu.MenuItemSwitcherLayout;
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckedTextView;
+import android.widget.TextView;
+import android.widget.ListView;
+import android.widget.ArrayAdapter;
+import android.util.TypedValue;
+
+import java.util.ArrayList;
+
+public class PromptListAdapter extends ArrayAdapter<PromptListItem> {
+ private static final int VIEW_TYPE_ITEM = 0;
+ private static final int VIEW_TYPE_GROUP = 1;
+ private static final int VIEW_TYPE_ACTIONS = 2;
+ private static final int VIEW_TYPE_COUNT = 3;
+
+ private static final String LOGTAG = "GeckoPromptListAdapter";
+
+ private final int mResourceId;
+ private Drawable mBlankDrawable;
+ private Drawable mMoreDrawable;
+ private static int mGroupPaddingSize;
+ private static int mLeftRightTextWithIconPadding;
+ private static int mTopBottomTextWithIconPadding;
+ private static int mIconSize;
+ private static int mMinRowSize;
+ private static int mIconTextPadding;
+ private static float mTextSize;
+ private static boolean mInitialized;
+
+ PromptListAdapter(Context context, int textViewResourceId, PromptListItem[] objects) {
+ super(context, textViewResourceId, objects);
+ mResourceId = textViewResourceId;
+ init();
+ }
+
+ private void init() {
+ if (!mInitialized) {
+ Resources res = getContext().getResources();
+ mGroupPaddingSize = (int) (res.getDimension(R.dimen.prompt_service_group_padding_size));
+ mLeftRightTextWithIconPadding = (int) (res.getDimension(R.dimen.prompt_service_left_right_text_with_icon_padding));
+ mTopBottomTextWithIconPadding = (int) (res.getDimension(R.dimen.prompt_service_top_bottom_text_with_icon_padding));
+ mIconTextPadding = (int) (res.getDimension(R.dimen.prompt_service_icon_text_padding));
+ mIconSize = (int) (res.getDimension(R.dimen.prompt_service_icon_size));
+ mMinRowSize = (int) (res.getDimension(R.dimen.menu_item_row_height));
+ mTextSize = res.getDimension(R.dimen.menu_item_textsize);
+
+ mInitialized = true;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ PromptListItem item = getItem(position);
+ if (item.isGroup) {
+ return VIEW_TYPE_GROUP;
+ } else if (item.showAsActions) {
+ return VIEW_TYPE_ACTIONS;
+ } else {
+ return VIEW_TYPE_ITEM;
+ }
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return VIEW_TYPE_COUNT;
+ }
+
+ private Drawable getMoreDrawable(Resources res) {
+ if (mMoreDrawable == null) {
+ mMoreDrawable = res.getDrawable(R.drawable.menu_item_more);
+ }
+ return mMoreDrawable;
+ }
+
+ private Drawable getBlankDrawable(Resources res) {
+ if (mBlankDrawable == null) {
+ mBlankDrawable = res.getDrawable(R.drawable.blank);
+ }
+ return mBlankDrawable;
+ }
+
+ public void toggleSelected(int position) {
+ PromptListItem item = getItem(position);
+ item.setSelected(!item.getSelected());
+ }
+
+ private void maybeUpdateIcon(PromptListItem item, TextView t) {
+ if (item.getIcon() == null && !item.inGroup && !item.isParent) {
+ t.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
+ return;
+ }
+
+ Drawable d = null;
+ Resources res = getContext().getResources();
+ // Set the padding between the icon and the text.
+ t.setCompoundDrawablePadding(mIconTextPadding);
+ if (item.getIcon() != null) {
+ // We want the icon to be of a specific size. Some do not
+ // follow this rule so we have to resize them.
+ Bitmap bitmap = ((BitmapDrawable) item.getIcon()).getBitmap();
+ d = new BitmapDrawable(res, Bitmap.createScaledBitmap(bitmap, mIconSize, mIconSize, true));
+ } else if (item.inGroup) {
+ // We don't currently support "indenting" items with icons
+ d = getBlankDrawable(res);
+ }
+
+ Drawable moreDrawable = null;
+ if (item.isParent) {
+ moreDrawable = getMoreDrawable(res);
+ }
+
+ if (d != null || moreDrawable != null) {
+ t.setCompoundDrawablesWithIntrinsicBounds(d, null, moreDrawable, null);
+ }
+ }
+
+ private void maybeUpdateCheckedState(ListView list, int position, PromptListItem item, ViewHolder viewHolder) {
+ viewHolder.textView.setEnabled(!item.disabled && !item.isGroup);
+ viewHolder.textView.setClickable(item.isGroup || item.disabled);
+ if (viewHolder.textView instanceof CheckedTextView) {
+ // Apparently just using ct.setChecked(true) doesn't work, so this
+ // is stolen from the android source code as a way to set the checked
+ // state of these items
+ list.setItemChecked(position, item.getSelected());
+ }
+ }
+
+ boolean isSelected(int position) {
+ return getItem(position).getSelected();
+ }
+
+ ArrayList<Integer> getSelected() {
+ int length = getCount();
+
+ ArrayList<Integer> selected = new ArrayList<Integer>();
+ for (int i = 0; i < length; i++) {
+ if (isSelected(i)) {
+ selected.add(i);
+ }
+ }
+
+ return selected;
+ }
+
+ int getSelectedIndex() {
+ int length = getCount();
+ for (int i = 0; i < length; i++) {
+ if (isSelected(i)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private View getActionView(PromptListItem item, final ListView list, final int position) {
+ final GeckoActionProvider provider = GeckoActionProvider.getForType(item.getIntent().getType(), getContext());
+ provider.setIntent(item.getIntent());
+
+ final MenuItemSwitcherLayout view = (MenuItemSwitcherLayout) provider.onCreateActionView(
+ GeckoActionProvider.ActionViewType.CONTEXT_MENU);
+ // If a quickshare button is clicked, we need to close the dialog.
+ view.addActionButtonClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ListView.OnItemClickListener listener = list.getOnItemClickListener();
+ if (listener != null) {
+ listener.onItemClick(list, view, position, position);
+ }
+ }
+ });
+
+ return view;
+ }
+
+ private void updateActionView(final PromptListItem item, final MenuItemSwitcherLayout view, final ListView list, final int position) {
+ view.setTitle(item.label);
+ view.setIcon(item.getIcon());
+ view.setSubMenuIndicator(item.isParent);
+
+ // If the share button is clicked, we need to close the dialog and then show an intent chooser
+ view.setMenuItemClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ ListView.OnItemClickListener listener = list.getOnItemClickListener();
+ if (listener != null) {
+ listener.onItemClick(list, view, position, position);
+ }
+
+ final GeckoActionProvider provider = GeckoActionProvider.getForType(item.getIntent().getType(), getContext());
+ IntentChooserPrompt prompt = new IntentChooserPrompt(getContext(), provider);
+ prompt.show(item.label, getContext(), new IntentHandler() {
+ @Override
+ public void onIntentSelected(final Intent intent, final int p) {
+ provider.chooseActivity(p);
+
+ // Context: Sharing via content contextmenu list (no explicit session is active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "promptlist");
+ }
+
+ @Override
+ public void onCancelled() {
+ // do nothing
+ }
+ });
+ }
+ });
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ PromptListItem item = getItem(position);
+ int type = getItemViewType(position);
+ ViewHolder viewHolder = null;
+
+ if (convertView == null) {
+ if (type == VIEW_TYPE_ACTIONS) {
+ convertView = getActionView(item, (ListView) parent, position);
+ } else {
+ int resourceId = mResourceId;
+ if (item.isGroup) {
+ resourceId = R.layout.list_item_header;
+ }
+
+ LayoutInflater mInflater = LayoutInflater.from(getContext());
+ convertView = mInflater.inflate(resourceId, null);
+ convertView.setMinimumHeight(mMinRowSize);
+
+ TextView tv = (TextView) convertView.findViewById(android.R.id.text1);
+ tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
+ viewHolder = new ViewHolder(tv, tv.getPaddingLeft(), tv.getPaddingRight(),
+ tv.getPaddingTop(), tv.getPaddingBottom());
+
+ convertView.setTag(viewHolder);
+ }
+ } else {
+ viewHolder = (ViewHolder) convertView.getTag();
+ }
+
+ if (type == VIEW_TYPE_ACTIONS) {
+ updateActionView(item, (MenuItemSwitcherLayout) convertView, (ListView) parent, position);
+ } else {
+ viewHolder.textView.setText(item.label);
+ maybeUpdateCheckedState((ListView) parent, position, item, viewHolder);
+ maybeUpdateIcon(item, viewHolder.textView);
+ }
+
+ return convertView;
+ }
+
+ private static class ViewHolder {
+ public final TextView textView;
+ public final int paddingLeft;
+ public final int paddingRight;
+ public final int paddingTop;
+ public final int paddingBottom;
+
+ ViewHolder(TextView aTextView, int aLeft, int aRight, int aTop, int aBottom) {
+ textView = aTextView;
+ paddingLeft = aLeft;
+ paddingRight = aRight;
+ paddingTop = aTop;
+ paddingBottom = aBottom;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java
new file mode 100644
index 000000000..48ace735c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptListItem.java
@@ -0,0 +1,128 @@
+package org.mozilla.gecko.prompts;
+
+import org.json.JSONException;
+import org.mozilla.gecko.IntentHelper;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.ThumbnailHelper;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.widget.GeckoActionProvider;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+
+import java.util.List;
+import java.util.ArrayList;
+
+// This class should die and be replaced with normal menu items
+public class PromptListItem {
+ private static final String LOGTAG = "GeckoPromptListItem";
+ public final String label;
+ public final boolean isGroup;
+ public final boolean inGroup;
+ public final boolean disabled;
+ public final int id;
+ public final boolean showAsActions;
+ public final boolean isParent;
+
+ public Intent mIntent;
+ public boolean mSelected;
+ public Drawable mIcon;
+
+ PromptListItem(JSONObject aObject) {
+ Context context = GeckoAppShell.getContext();
+ label = aObject.isNull("label") ? "" : aObject.optString("label");
+ isGroup = aObject.optBoolean("isGroup");
+ inGroup = aObject.optBoolean("inGroup");
+ disabled = aObject.optBoolean("disabled");
+ id = aObject.optInt("id");
+ mSelected = aObject.optBoolean("selected");
+
+ JSONObject obj = aObject.optJSONObject("showAsActions");
+ if (obj != null) {
+ showAsActions = true;
+ String uri = obj.isNull("uri") ? "" : obj.optString("uri");
+ String type = obj.isNull("type") ? GeckoActionProvider.DEFAULT_MIME_TYPE :
+ obj.optString("type", GeckoActionProvider.DEFAULT_MIME_TYPE);
+
+ mIntent = IntentHelper.getShareIntent(context, uri, type, "");
+ isParent = true;
+ } else {
+ mIntent = null;
+ showAsActions = false;
+ // Support both "isParent" (backwards compat for older consumers), and "menu" for the new Tabbed prompt ui.
+ isParent = aObject.optBoolean("isParent") || aObject.optBoolean("menu");
+ }
+
+ final String iconStr = aObject.optString("icon");
+ if (iconStr != null) {
+ final ResourceDrawableUtils.BitmapLoader loader = new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(Drawable d) {
+ mIcon = d;
+ }
+ };
+
+ if (iconStr.startsWith("thumbnail:")) {
+ final int id = Integer.parseInt(iconStr.substring(10), 10);
+ ThumbnailHelper.getInstance().getAndProcessThumbnailFor(id, loader);
+ } else {
+ ResourceDrawableUtils.getDrawable(context, iconStr, loader);
+ }
+ }
+ }
+
+ public void setIntent(Intent i) {
+ mIntent = i;
+ }
+
+ public Intent getIntent() {
+ return mIntent;
+ }
+
+ public void setIcon(Drawable icon) {
+ mIcon = icon;
+ }
+
+ public Drawable getIcon() {
+ return mIcon;
+ }
+
+ public void setSelected(boolean selected) {
+ mSelected = selected;
+ }
+
+ public boolean getSelected() {
+ return mSelected;
+ }
+
+ public PromptListItem(String aLabel) {
+ label = aLabel;
+ isGroup = false;
+ inGroup = false;
+ isParent = false;
+ disabled = false;
+ id = 0;
+ showAsActions = false;
+ }
+
+ static PromptListItem[] getArray(JSONArray items) {
+ if (items == null) {
+ return new PromptListItem[0];
+ }
+
+ int length = items.length();
+ List<PromptListItem> list = new ArrayList<>(length);
+ for (int i = 0; i < length; i++) {
+ try {
+ PromptListItem item = new PromptListItem(items.getJSONObject(i));
+ list.add(item);
+ } catch (JSONException ex) { }
+ }
+
+ return list.toArray(new PromptListItem[length]);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java
new file mode 100644
index 000000000..8155cc1c6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptService.java
@@ -0,0 +1,72 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.util.Log;
+
+public class PromptService implements GeckoEventListener {
+ private static final String LOGTAG = "GeckoPromptService";
+
+ private final Context mContext;
+
+ public PromptService(Context context) {
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "Prompt:Show",
+ "Prompt:ShowTop");
+ mContext = context;
+ }
+
+ public void destroy() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "Prompt:Show",
+ "Prompt:ShowTop");
+ }
+
+ public void show(final String aTitle, final String aText, final PromptListItem[] aMenuList,
+ final int aChoiceMode, final Prompt.PromptCallback callback) {
+ // The dialog must be created on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Prompt p;
+ p = new Prompt(mContext, callback);
+ p.show(aTitle, aText, aMenuList, aChoiceMode);
+ }
+ });
+ }
+
+ // GeckoEventListener implementation
+ @Override
+ public void handleMessage(String event, final JSONObject message) {
+ // The dialog must be created on the UI thread.
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Prompt p;
+ p = new Prompt(mContext, new Prompt.PromptCallback() {
+ @Override
+ public void onPromptFinished(String jsonResult) {
+ try {
+ EventDispatcher.sendResponse(message, new JSONObject(jsonResult));
+ } catch (JSONException ex) {
+ Log.i(LOGTAG, "Error building json response", ex);
+ }
+ }
+ });
+ p.show(message);
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java b/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java
new file mode 100644
index 000000000..ab490e79c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/TabInput.java
@@ -0,0 +1,107 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.prompts;
+
+import java.util.LinkedHashMap;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ListView;
+import android.widget.TabHost;
+import android.widget.TextView;
+
+public class TabInput extends PromptInput implements AdapterView.OnItemClickListener {
+ public static final String INPUT_TYPE = "tabs";
+ public static final String LOGTAG = "GeckoTabInput";
+
+ /* Keeping the order of this in sync with the JSON is important. */
+ final private LinkedHashMap<String, PromptListItem[]> mTabs;
+
+ private TabHost mHost;
+ private int mPosition;
+
+ public TabInput(JSONObject obj) {
+ super(obj);
+ mTabs = new LinkedHashMap<String, PromptListItem[]>();
+ try {
+ JSONArray tabs = obj.getJSONArray("items");
+ for (int i = 0; i < tabs.length(); i++) {
+ JSONObject tab = tabs.getJSONObject(i);
+ String title = tab.getString("label");
+ JSONArray items = tab.getJSONArray("items");
+ mTabs.put(title, PromptListItem.getArray(items));
+ }
+ } catch (JSONException ex) {
+ Log.e(LOGTAG, "Exception", ex);
+ }
+ }
+
+ @Override
+ public View getView(final Context context) throws UnsupportedOperationException {
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ mHost = (TabHost) inflater.inflate(R.layout.tab_prompt_input, null);
+ mHost.setup();
+
+ for (String title : mTabs.keySet()) {
+ final TabHost.TabSpec spec = mHost.newTabSpec(title);
+ spec.setContent(new TabHost.TabContentFactory() {
+ @Override
+ public View createTabContent(final String tag) {
+ PromptListAdapter adapter = new PromptListAdapter(context, android.R.layout.simple_list_item_1, mTabs.get(tag));
+ ListView listView = new ListView(context);
+ listView.setCacheColorHint(0);
+ listView.setOnItemClickListener(TabInput.this);
+ listView.setAdapter(adapter);
+ return listView;
+ }
+ });
+
+ spec.setIndicator(title);
+ mHost.addTab(spec);
+ }
+ mView = mHost;
+ return mHost;
+ }
+
+ @Override
+ public Object getValue() {
+ JSONObject obj = new JSONObject();
+ try {
+ obj.put("tab", mHost.getCurrentTab());
+ obj.put("item", mPosition);
+ } catch (JSONException ex) { }
+
+ return obj;
+ }
+
+ @Override
+ public boolean getScrollable() {
+ return true;
+ }
+
+ @Override
+ public boolean canApplyInputStyle() {
+ return false;
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ ThreadUtils.assertOnUiThread();
+ mPosition = position;
+ notifyListeners(Integer.toString(position));
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java
new file mode 100644
index 000000000..42a7c6a90
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/Fetched.java
@@ -0,0 +1,71 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.support.annotation.NonNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Pair a (String) value with a timestamp. The timestamp is usually when the
+ * value was fetched from a remote service or when the value was locally
+ * generated.
+ *
+ * It's awkward to serialize generic values to JSON -- that requires lots of
+ * factory classes -- so we specialize to String instances.
+ */
+public class Fetched {
+ public final String value;
+ public final long timestamp;
+
+ public Fetched(String value, long timestamp) {
+ this.value = value;
+ this.timestamp = timestamp;
+ }
+
+ public static Fetched now(String value) {
+ return new Fetched(value, System.currentTimeMillis());
+ }
+
+ public static @NonNull Fetched fromJSONObject(@NonNull JSONObject json) {
+ final String value = json.optString("value", null);
+ final String timestampString = json.optString("timestamp", null);
+ final long timestamp = timestampString != null ? Long.valueOf(timestampString) : 0L;
+ return new Fetched(value, timestamp);
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject jsonObject = new JSONObject();
+ if (value != null) {
+ jsonObject.put("value", value);
+ } else {
+ jsonObject.remove("value");
+ }
+ jsonObject.put("timestamp", Long.toString(timestamp));
+ return jsonObject;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ // Auto-generated.
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Fetched fetched = (Fetched) o;
+
+ if (timestamp != fetched.timestamp) return false;
+ return !(value != null ? !value.equals(fetched.value) : fetched.value != null);
+
+ }
+
+ @Override
+ public int hashCode() {
+ // Auto-generated.
+ int result = value != null ? value.hashCode() : 0;
+ result = 31 * result + (int) (timestamp ^ (timestamp >>> 32));
+ return result;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
new file mode 100644
index 000000000..9c1fab5f9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
@@ -0,0 +1,110 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.push.RegisterUserAgentResponse;
+import org.mozilla.gecko.push.SubscribeChannelResponse;
+import org.mozilla.gecko.push.autopush.AutopushClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.sync.Utils;
+
+import java.util.concurrent.Executor;
+
+/**
+ * This class bridges the autopush client, which is written in callback style, with the Fennec
+ * push implementation, which is written in a linear style. It handles returning results and
+ * re-throwing exceptions passed as messages.
+ * <p/>
+ * TODO: fold this into the autopush client directly.
+ */
+public class PushClient {
+ public static class LocalException extends Exception {
+ private static final long serialVersionUID = 2387554736L;
+
+ public LocalException(Throwable throwable) {
+ super(throwable);
+ }
+ }
+
+ private final AutopushClient autopushClient;
+
+ public PushClient(String serverURI) {
+ this.autopushClient = new AutopushClient(serverURI, Utils.newSynchronousExecutor());
+ }
+
+ /**
+ * Each instance is <b>single-use</b>! Exactly one delegate method should be invoked once,
+ * but we take care to handle multiple invocations (favoring the earliest), just to be safe.
+ */
+ protected static class Delegate<T> implements AutopushClient.RequestDelegate<T> {
+ Object result; // Oh, for an algebraic data type when you need one!
+
+ @SuppressWarnings("unchecked")
+ public T responseOrThrow() throws LocalException, AutopushClientException {
+ if (result instanceof LocalException) {
+ throw (LocalException) result;
+ }
+ if (result instanceof AutopushClientException) {
+ throw (AutopushClientException) result;
+ }
+ return (T) result;
+ }
+
+ @Override
+ public void handleError(Exception e) {
+ if (result == null) {
+ result = new LocalException(e);
+ }
+ }
+
+ @Override
+ public void handleFailure(AutopushClientException e) {
+ if (result == null) {
+ result = e;
+ }
+ }
+
+ @Override
+ public void handleSuccess(T response) {
+ if (result == null) {
+ result = response;
+ }
+ }
+ }
+
+ public RegisterUserAgentResponse registerUserAgent(@NonNull String token) throws LocalException, AutopushClientException {
+ final Delegate<RegisterUserAgentResponse> delegate = new Delegate<>();
+ autopushClient.registerUserAgent(token, delegate);
+ return delegate.responseOrThrow();
+ }
+
+ public void reregisterUserAgent(@NonNull String uaid, @NonNull String secret, @NonNull String token) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.reregisterUserAgent(uaid, secret, token, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+
+ public void unregisterUserAgent(@NonNull String uaid, @NonNull String secret) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.unregisterUserAgent(uaid, secret, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+
+ public SubscribeChannelResponse subscribeChannel(@NonNull String uaid, @NonNull String secret, @Nullable String appServerKey) throws LocalException, AutopushClientException {
+ final Delegate<SubscribeChannelResponse> delegate = new Delegate<>();
+ autopushClient.subscribeChannel(uaid, secret, appServerKey, delegate);
+ return delegate.responseOrThrow();
+ }
+
+ public void unsubscribeChannel(@NonNull String uaid, @NonNull String secret, @NonNull String chid) throws LocalException, AutopushClientException {
+ final Delegate<Void> delegate = new Delegate<>();
+ autopushClient.unsubscribeChannel(uaid, secret, chid, delegate);
+ delegate.responseOrThrow(); // For side-effects only.
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
new file mode 100644
index 000000000..42ef60b61
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
@@ -0,0 +1,354 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * The push manager advances push registrations, ensuring that the upstream autopush endpoint has
+ * a fresh GCM token. It brokers channel subscription requests to the upstream and maintains
+ * local state.
+ * <p/>
+ * This class is not thread safe. An individual instance should be accessed on a single
+ * (background) thread.
+ */
+public class PushManager {
+ public static final long TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS = 7 * 24 * 60 * 60 * 1000L; // One week.
+
+ public static class ProfileNeedsConfigurationException extends Exception {
+ private static final long serialVersionUID = 3326738888L;
+
+ public ProfileNeedsConfigurationException() {
+ super();
+ }
+ }
+
+ private static final String LOG_TAG = "GeckoPushManager";
+
+ protected final @NonNull PushState state;
+ protected final @NonNull GcmTokenClient gcmClient;
+ protected final @NonNull PushClientFactory pushClientFactory;
+
+ // For testing only.
+ public interface PushClientFactory {
+ PushClient getPushClient(String autopushEndpoint, boolean debug);
+ }
+
+ public PushManager(@NonNull PushState state, @NonNull GcmTokenClient gcmClient, @NonNull PushClientFactory pushClientFactory) {
+ this.state = state;
+ this.gcmClient = gcmClient;
+ this.pushClientFactory = pushClientFactory;
+ }
+
+ public PushRegistration registrationForSubscription(String chid) {
+ // chids are globally unique, so we're not concerned about finding a chid associated to
+ // any particular profile.
+ for (Map.Entry<String, PushRegistration> entry : state.getRegistrations().entrySet()) {
+ final PushSubscription subscription = entry.getValue().getSubscription(chid);
+ if (subscription != null) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ public Map<String, PushSubscription> allSubscriptionsForProfile(String profileName) {
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null) {
+ return Collections.emptyMap();
+ }
+ return Collections.unmodifiableMap(registration.subscriptions);
+ }
+
+ public PushRegistration registerUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ Log.i(LOG_TAG, "Registering user agent for profile named: " + profileName);
+ return advanceRegistration(profileName, now);
+ }
+
+ public PushRegistration unregisterUserAgent(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException {
+ Log.i(LOG_TAG, "Unregistering user agent for profile named: " + profileName);
+
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote uaid for profileName: " + profileName);
+ return null;
+ }
+
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ Log.e(LOG_TAG, "Cannot unregisterUserAgent with null registration uaid or secret!");
+ return null;
+ }
+
+ unregisterUserAgentOnBackgroundThread(registration);
+ return registration;
+ }
+
+ public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ Log.i(LOG_TAG, "Subscribing to channel for service: " + service + "; for profile named: " + profileName);
+ final PushRegistration registration = advanceRegistration(profileName, now);
+ final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, appServerKey, System.currentTimeMillis());
+ return subscription;
+ }
+
+ protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws AutopushClientException, PushClient.LocalException {
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ throw new IllegalStateException("Cannot subscribeChannel with null uaid or secret!");
+ }
+
+ // Verify endpoint is not null?
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+
+ final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret, appServerKey);
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Got chid: " + result.channelID + " and endpoint: " + result.endpoint);
+ } else {
+ Log.i(LOG_TAG, "Got chid and endpoint.");
+ }
+
+ final PushSubscription subscription = new PushSubscription(result.channelID, profileName, result.endpoint, service, serviceData);
+ registration.putSubscription(result.channelID, subscription);
+ state.checkpoint();
+
+ return subscription;
+ }
+
+ public PushSubscription unsubscribeChannel(final @NonNull String chid) {
+ Log.i(LOG_TAG, "Unsubscribing from channel with chid: " + chid);
+
+ final PushRegistration registration = registrationForSubscription(chid);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription; not unregistering remote subscription: " + chid);
+ return null;
+ }
+
+ // We remove the local subscription before the remote subscription: without the local
+ // subscription we'll ignoring incoming messages, and after some amount of time the
+ // server will expire the channel due to non-activity. This is also Desktop's approach.
+ final PushSubscription subscription = registration.removeSubscription(chid);
+ state.checkpoint();
+
+ if (subscription == null) {
+ // This should never happen.
+ Log.e(LOG_TAG, "Subscription did not exist: " + chid);
+ return null;
+ }
+
+ final String uaid = registration.uaid.value;
+ final String secret = registration.secret;
+ if (uaid == null || secret == null) {
+ Log.e(LOG_TAG, "Cannot unsubscribeChannel with null registration uaid or secret!");
+ return null;
+ }
+
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+ // Fire and forget.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ pushClient.unsubscribeChannel(registration.uaid.value, registration.secret, chid);
+ Log.i(LOG_TAG, "Unsubscribed from channel with chid: " + chid);
+ } catch (PushClient.LocalException | AutopushClientException e) {
+ Log.w(LOG_TAG, "Failed to unsubscribe from channel with chid; ignoring: " + chid, e);
+ }
+ }
+ });
+
+ return subscription;
+ }
+
+ public PushRegistration configure(final @NonNull String profileName, final @NonNull String endpoint, final boolean debug, final long now) {
+ Log.i(LOG_TAG, "Updating configuration.");
+ final PushRegistration registration = state.getRegistration(profileName);
+ final PushRegistration newRegistration;
+ if (registration != null) {
+ if (!endpoint.equals(registration.autopushEndpoint)) {
+ if (debug) {
+ Log.i(LOG_TAG, "Push configuration autopushEndpoint changed! Was: " + registration.autopushEndpoint + "; now: " + endpoint);
+ } else {
+ Log.i(LOG_TAG, "Push configuration autopushEndpoint changed!");
+ }
+
+ newRegistration = new PushRegistration(endpoint, debug, Fetched.now(null), null);
+
+ if (registration.uaid.value != null) {
+ // New endpoint! All registrations and subscriptions have been dropped, and
+ // should be removed remotely.
+ unregisterUserAgentOnBackgroundThread(registration);
+ }
+ } else if (debug != registration.debug) {
+ Log.i(LOG_TAG, "Push configuration debug changed: " + debug);
+ newRegistration = registration.withDebug(debug);
+ } else {
+ newRegistration = registration;
+ }
+ } else {
+ if (debug) {
+ Log.i(LOG_TAG, "Push configuration set: " + endpoint + "; debug: " + debug);
+ } else {
+ Log.i(LOG_TAG, "Push configuration set!");
+ }
+ newRegistration = new PushRegistration(endpoint, debug, new Fetched(null, now), null);
+ }
+
+ if (newRegistration != registration) {
+ state.putRegistration(profileName, newRegistration);
+ state.checkpoint();
+ }
+
+ return newRegistration;
+ }
+
+ private void unregisterUserAgentOnBackgroundThread(final PushRegistration registration) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug).unregisterUserAgent(registration.uaid.value, registration.secret);
+ Log.i(LOG_TAG, "Unregistered user agent with uaid: " + registration.uaid.value);
+ } catch (PushClient.LocalException | AutopushClientException e) {
+ Log.w(LOG_TAG, "Failed to unregister user agent with uaid; ignoring: " + registration.uaid.value, e);
+ }
+ }
+ });
+ }
+
+ protected @NonNull PushRegistration advanceRegistration(final @NonNull String profileName, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ final PushRegistration registration = state.getRegistration(profileName);
+ if (registration == null || registration.autopushEndpoint == null) {
+ Log.i(LOG_TAG, "Cannot advance to registered: registration needs configuration.");
+ throw new ProfileNeedsConfigurationException();
+ }
+ return advanceRegistration(registration, profileName, now);
+ }
+
+ protected @NonNull PushRegistration advanceRegistration(final PushRegistration registration, final @NonNull String profileName, final long now) throws AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+ final Fetched gcmToken = gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, registration.debug);
+
+ final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
+
+ if (registration.uaid.value == null) {
+ if (registration.debug) {
+ Log.i(LOG_TAG, "No uaid; requesting from autopush endpoint: " + registration.autopushEndpoint);
+ } else {
+ Log.i(LOG_TAG, "No uaid: requesting from autopush endpoint.");
+ }
+ final RegisterUserAgentResponse result = pushClient.registerUserAgent(gcmToken.value);
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Got uaid: " + result.uaid + " and secret: " + result.secret);
+ } else {
+ Log.i(LOG_TAG, "Got uaid and secret.");
+ }
+ final long nextNow = System.currentTimeMillis();
+ final PushRegistration nextRegistration = registration.withUserAgentID(result.uaid, result.secret, nextNow);
+ state.putRegistration(profileName, nextRegistration);
+ state.checkpoint();
+ return advanceRegistration(nextRegistration, profileName, nextNow);
+ }
+
+ if (registration.uaid.timestamp + TIME_BETWEEN_AUTOPUSH_UAID_REGISTRATION_IN_MILLIS < now
+ || registration.uaid.timestamp < gcmToken.timestamp) {
+ if (registration.debug) {
+ Log.i(LOG_TAG, "Stale uaid; re-registering with autopush endpoint: " + registration.autopushEndpoint);
+ } else {
+ Log.i(LOG_TAG, "Stale uaid: re-registering with autopush endpoint.");
+ }
+
+ pushClient.reregisterUserAgent(registration.uaid.value, registration.secret, gcmToken.value);
+
+ Log.i(LOG_TAG, "Re-registered uaid and secret.");
+ final long nextNow = System.currentTimeMillis();
+ final PushRegistration nextRegistration = registration.withUserAgentID(registration.uaid.value, registration.secret, nextNow);
+ state.putRegistration(profileName, nextRegistration);
+ state.checkpoint();
+ return advanceRegistration(nextRegistration, profileName, nextNow);
+ }
+
+ Log.d(LOG_TAG, "Existing uaid is fresh; no need to request from autopush endpoint.");
+ return registration;
+ }
+
+ public void invalidateGcmToken() {
+ gcmClient.invalidateToken();
+ }
+
+ public void startup(long now) {
+ try {
+ Log.i(LOG_TAG, "Startup: requesting GCM token.");
+ gcmClient.getToken(AppConstants.MOZ_ANDROID_GCM_SENDERID, false); // For side-effects.
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ // Requires user intervention. At App startup, we don't want to address this. In
+ // response to user activity, we do want to try to have the user address this.
+ Log.w(LOG_TAG, "Startup: needs Google Play Services. Ignoring until GCM is requested in response to user activity.");
+ return;
+ } catch (IOException e) {
+ // We're temporarily unable to get a GCM token. There's nothing to be done; we'll
+ // try to advance the App's state in response to user activity or at next startup.
+ Log.w(LOG_TAG, "Startup: Google Play Services is available, but we can't get a token; ignoring.", e);
+ return;
+ }
+
+ Log.i(LOG_TAG, "Startup: advancing all registrations.");
+ final Map<String, PushRegistration> registrations = state.getRegistrations();
+
+ // Now advance all registrations.
+ try {
+ final Iterator<Map.Entry<String, PushRegistration>> it = registrations.entrySet().iterator();
+ while (it.hasNext()) {
+ final Map.Entry<String, PushRegistration> entry = it.next();
+ final String profileName = entry.getKey();
+ final PushRegistration registration = entry.getValue();
+ if (registration.subscriptions.isEmpty()) {
+ Log.i(LOG_TAG, "Startup: no subscriptions for profileName; not advancing registration: " + profileName);
+ continue;
+ }
+
+ try {
+ advanceRegistration(profileName, now); // For side-effects.
+ Log.i(LOG_TAG, "Startup: advanced registration for profileName: " + profileName);
+ } catch (ProfileNeedsConfigurationException e) {
+ Log.i(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; profile needs configuration from Gecko.");
+ } catch (AutopushClientException e) {
+ if (e.isTransientError()) {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got transient autopush error. Ignoring; will advance on demand.", e);
+ } else {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got permanent autopush error. Removing registration entirely.", e);
+ it.remove();
+ }
+ } catch (PushClient.LocalException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance registration for profileName: " + profileName + "; got local exception. Ignoring; will advance on demand.", e);
+ }
+ }
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance any registrations; need Google Play Services!", e);
+ return;
+ } catch (IOException e) {
+ Log.w(LOG_TAG, "Startup: cannot advance any registrations; intermittent Google Play Services exception; ignoring, will advance on demand.", e);
+ return;
+ }
+
+ // We may have removed registrations above. Checkpoint just to be safe!
+ state.checkpoint();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java
new file mode 100644
index 000000000..a991774ff
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushRegistration.java
@@ -0,0 +1,126 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Represent an autopush User Agent registration.
+ * <p/>
+ * Such a registration associates an endpoint, optional debug flag, some Google
+ * Cloud Messaging data, and the returned uaid and secret.
+ * <p/>
+ * Each registration is associated to a single Gecko profile, although we don't
+ * enforce that here. This class is immutable, so it is by definition
+ * thread-safe.
+ */
+public class PushRegistration {
+ public final String autopushEndpoint;
+ public final boolean debug;
+ // TODO: fold (timestamp, {uaid, secret}) into this class.
+ public final @NonNull Fetched uaid;
+ public final String secret;
+
+ protected final @NonNull Map<String, PushSubscription> subscriptions;
+
+ public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret, @NonNull Map<String, PushSubscription> subscriptions) {
+ this.autopushEndpoint = autopushEndpoint;
+ this.debug = debug;
+ this.uaid = uaid;
+ this.secret = secret;
+ this.subscriptions = subscriptions;
+ }
+
+ public PushRegistration(String autopushEndpoint, boolean debug, @NonNull Fetched uaid, @Nullable String secret) {
+ this(autopushEndpoint, debug, uaid, secret, new HashMap<String, PushSubscription>());
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject subscriptions = new JSONObject();
+ for (Map.Entry<String, PushSubscription> entry : this.subscriptions.entrySet()) {
+ subscriptions.put(entry.getKey(), entry.getValue().toJSONObject());
+ }
+
+ final JSONObject jsonObject = new JSONObject();
+ jsonObject.put("autopushEndpoint", autopushEndpoint);
+ jsonObject.put("debug", debug);
+ jsonObject.put("uaid", uaid.toJSONObject());
+ jsonObject.put("secret", secret);
+ jsonObject.put("subscriptions", subscriptions);
+ return jsonObject;
+ }
+
+ public static PushRegistration fromJSONObject(@NonNull JSONObject registration) throws JSONException {
+ final String endpoint = registration.optString("autopushEndpoint", null);
+ final boolean debug = registration.getBoolean("debug");
+ final Fetched uaid = Fetched.fromJSONObject(registration.getJSONObject("uaid"));
+ final String secret = registration.optString("secret", null);
+
+ final JSONObject subscriptionsObject = registration.getJSONObject("subscriptions");
+ final Map<String, PushSubscription> subscriptions = new HashMap<>();
+ final Iterator<String> it = subscriptionsObject.keys();
+ while (it.hasNext()) {
+ final String chid = it.next();
+ subscriptions.put(chid, PushSubscription.fromJSONObject(subscriptionsObject.getJSONObject(chid)));
+ }
+
+ return new PushRegistration(endpoint, debug, uaid, secret, subscriptions);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ // Auto-generated.
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ PushRegistration that = (PushRegistration) o;
+
+ if (autopushEndpoint != null ? !autopushEndpoint.equals(that.autopushEndpoint) : that.autopushEndpoint != null)
+ return false;
+ if (!uaid.equals(that.uaid)) return false;
+ if (secret != null ? !secret.equals(that.secret) : that.secret != null) return false;
+ if (subscriptions != null ? !subscriptions.equals(that.subscriptions) : that.subscriptions != null) return false;
+ return (debug == that.debug);
+ }
+
+ @Override
+ public int hashCode() {
+ // Auto-generated.
+ int result = autopushEndpoint != null ? autopushEndpoint.hashCode() : 0;
+ result = 31 * result + (debug ? 1 : 0);
+ result = 31 * result + uaid.hashCode();
+ result = 31 * result + (secret != null ? secret.hashCode() : 0);
+ result = 31 * result + (subscriptions != null ? subscriptions.hashCode() : 0);
+ return result;
+ }
+
+ public PushRegistration withDebug(boolean debug) {
+ return new PushRegistration(this.autopushEndpoint, debug, this.uaid, this.secret, this.subscriptions);
+ }
+
+ public PushRegistration withUserAgentID(String uaid, String secret, long nextNow) {
+ return new PushRegistration(this.autopushEndpoint, this.debug, new Fetched(uaid, nextNow), secret, this.subscriptions);
+ }
+
+ public PushSubscription getSubscription(@NonNull String chid) {
+ return subscriptions.get(chid);
+ }
+
+ public PushSubscription putSubscription(@NonNull String chid, @NonNull PushSubscription subscription) {
+ return subscriptions.put(chid, subscription);
+ }
+
+ public PushSubscription removeSubscription(@NonNull String chid) {
+ return subscriptions.remove(chid);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
new file mode 100644
index 000000000..8d3a92e48
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -0,0 +1,460 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoService;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.fxa.FxAccountPushHandler;
+import org.mozilla.gecko.gcm.GcmTokenClient;
+import org.mozilla.gecko.push.autopush.AutopushClientException;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Class that handles messages used in the Google Cloud Messaging and DOM push API integration.
+ * <p/>
+ * This singleton services Gecko messages from dom/push/PushServiceAndroidGCM.jsm and Google Cloud
+ * Messaging requests.
+ * <p/>
+ * It is expected that Gecko is started (if not already running) soon after receiving GCM messages
+ * otherwise there is a greater risk that pending messages that have not been handle by Gecko will
+ * be lost if this service is killed.
+ * <p/>
+ * It's worth noting that we allow the DOM push API in restricted profiles.
+ */
+@ReflectionTarget
+public class PushService implements BundleEventListener {
+ private static final String LOG_TAG = "GeckoPushService";
+
+ public static final String SERVICE_WEBPUSH = "webpush";
+ public static final String SERVICE_FXA = "fxa";
+
+ private static PushService sInstance;
+
+ private static final String[] GECKO_EVENTS = new String[] {
+ "PushServiceAndroidGCM:Configure",
+ "PushServiceAndroidGCM:DumpRegistration",
+ "PushServiceAndroidGCM:DumpSubscriptions",
+ "PushServiceAndroidGCM:Initialized",
+ "PushServiceAndroidGCM:Uninitialized",
+ "PushServiceAndroidGCM:RegisterUserAgent",
+ "PushServiceAndroidGCM:UnregisterUserAgent",
+ "PushServiceAndroidGCM:SubscribeChannel",
+ "PushServiceAndroidGCM:UnsubscribeChannel",
+ "FxAccountsPush:Initialized",
+ "FxAccountsPush:ReceivedPushMessageToDecode:Response",
+ "History:GetPrePathLastVisitedTimeMilliseconds",
+ };
+
+ private enum GeckoComponent {
+ FxAccountsPush,
+ PushServiceAndroidGCM
+ }
+
+ public static synchronized PushService getInstance(Context context) {
+ if (sInstance == null) {
+ onCreate(context);
+ }
+ return sInstance;
+ }
+
+ @ReflectionTarget
+ public static synchronized void onCreate(Context context) {
+ if (sInstance != null) {
+ return;
+ }
+ sInstance = new PushService(context);
+
+ sInstance.registerGeckoEventListener();
+ sInstance.onStartup();
+ }
+
+ protected final PushManager pushManager;
+
+ // NB: These are not thread-safe, we're depending on these being access from the same background thread.
+ private boolean isReadyPushServiceAndroidGCM = false;
+ private boolean isReadyFxAccountsPush = false;
+ private final List<JSONObject> pendingPushMessages;
+
+ public PushService(Context context) {
+ pushManager = new PushManager(new PushState(context, "GeckoPushState.json"), new GcmTokenClient(context), new PushManager.PushClientFactory() {
+ @Override
+ public PushClient getPushClient(String autopushEndpoint, boolean debug) {
+ return new PushClient(autopushEndpoint);
+ }
+ });
+
+ pendingPushMessages = new LinkedList<>();
+ }
+
+ public void onStartup() {
+ Log.i(LOG_TAG, "Starting up.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during startup; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onRefresh() {
+ Log.i(LOG_TAG, "Google Play Services requested GCM token refresh; invalidating GCM token and running startup again.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ pushManager.invalidateGcmToken();
+ try {
+ pushManager.startup(System.currentTimeMillis());
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Got exception during refresh; ignoring.", e);
+ return;
+ }
+ }
+
+ public void onMessageReceived(final @NonNull Context context, final @NonNull Bundle bundle) {
+ Log.i(LOG_TAG, "Google Play Services GCM message received; delivering.");
+ ThreadUtils.assertOnBackgroundThread();
+
+ final String chid = bundle.getString("chid");
+ if (chid == null) {
+ Log.w(LOG_TAG, "No chid found; ignoring message.");
+ return;
+ }
+
+ final PushRegistration registration = pushManager.registrationForSubscription(chid);
+ if (registration == null) {
+ Log.w(LOG_TAG, "Cannot find registration corresponding to subscription for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ final PushSubscription subscription = registration.getSubscription(chid);
+ if (subscription == null) {
+ // This should never happen. There's not much to be done; in the future, perhaps we
+ // could try to drop the remote subscription?
+ Log.e(LOG_TAG, "No subscription found for chid: " + chid + "; ignoring message.");
+ return;
+ }
+
+ boolean isWebPush = SERVICE_WEBPUSH.equals(subscription.service);
+ boolean isFxAPush = SERVICE_FXA.equals(subscription.service);
+ if (!isWebPush && !isFxAPush) {
+ Log.e(LOG_TAG, "Message directed to unknown service; dropping: " + subscription.service);
+ return;
+ }
+
+ Log.i(LOG_TAG, "Message directed to service: " + subscription.service);
+
+ if (subscription.serviceData == null) {
+ Log.e(LOG_TAG, "No serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.SERVICE, "dom-push-api");
+
+ final String profileName = subscription.serviceData.optString("profileName", null);
+ final String profilePath = subscription.serviceData.optString("profilePath", null);
+ if (profileName == null || profilePath == null) {
+ Log.e(LOG_TAG, "Corrupt serviceData found for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+
+ if (canSendPushMessagesToGecko()) {
+ if (!GeckoThread.canUseProfile(profileName, new File(profilePath))) {
+ Log.e(LOG_TAG, "Mismatched profile for chid: " + chid + "; ignoring dom/push message.");
+ return;
+ }
+ } else {
+ final Intent intent = GeckoService.getIntentToCreateServices(context, "android-push-service");
+ GeckoService.setIntentProfile(intent, profileName, profilePath);
+ context.startService(intent);
+ }
+
+ final JSONObject data = new JSONObject();
+ try {
+ data.put("channelID", chid);
+ data.put("con", bundle.getString("con"));
+ data.put("enc", bundle.getString("enc"));
+ // Only one of cryptokey (newer) and enckey (deprecated) should be set, but the
+ // Gecko handler will verify this.
+ data.put("cryptokey", bundle.getString("cryptokey"));
+ data.put("enckey", bundle.getString("enckey"));
+ data.put("message", bundle.getString("body"));
+
+ if (!canSendPushMessagesToGecko()) {
+ data.put("profileName", profileName);
+ data.put("profilePath", profilePath);
+ data.put("service", subscription.service);
+ }
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception delivering dom/push message to Gecko!", e);
+ return;
+ }
+
+ if (!canSendPushMessagesToGecko()) {
+ Log.i(LOG_TAG, "Required service not initialized, adding message to queue.");
+ pendingPushMessages.add(data);
+ return;
+ }
+
+ if (isWebPush) {
+ sendMessageToGeckoService(data);
+ } else {
+ sendMessageToDecodeToGeckoService(data);
+ }
+ }
+
+ protected static void sendMessageToGeckoService(final @NonNull JSONObject message) {
+ Log.i(LOG_TAG, "Delivering dom/push message to Gecko!");
+ GeckoAppShell.notifyObservers("PushServiceAndroidGCM:ReceivedPushMessage",
+ message.toString(),
+ GeckoThread.State.PROFILE_READY);
+ }
+
+ protected static void sendMessageToDecodeToGeckoService(final @NonNull JSONObject message) {
+ Log.i(LOG_TAG, "Delivering dom/push message to decode to Gecko!");
+ GeckoAppShell.notifyObservers("FxAccountsPush:ReceivedPushMessageToDecode",
+ message.toString(),
+ GeckoThread.State.PROFILE_READY);
+ }
+
+ protected void registerGeckoEventListener() {
+ Log.d(LOG_TAG, "Registered Gecko event listener.");
+ EventDispatcher.getInstance().registerBackgroundThreadListener(this, GECKO_EVENTS);
+ }
+
+ protected void unregisterGeckoEventListener() {
+ Log.d(LOG_TAG, "Unregistered Gecko event listener.");
+ EventDispatcher.getInstance().unregisterBackgroundThreadListener(this, GECKO_EVENTS);
+ }
+
+ @Override
+ public void handleMessage(final String event, final Bundle message, final EventCallback callback) {
+ Log.i(LOG_TAG, "Handling event: " + event);
+ ThreadUtils.assertOnBackgroundThread();
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ // We're invoked in response to a Gecko message on a background thread. We should always
+ // be able to safely retrieve the current Gecko profile.
+ final GeckoProfile geckoProfile = GeckoProfile.get(context);
+
+ if (callback == null) {
+ Log.e(LOG_TAG, "callback must not be null in " + event);
+ return;
+ }
+
+ try {
+ if ("PushServiceAndroidGCM:Initialized".equals(event)) {
+ processComponentState(GeckoComponent.PushServiceAndroidGCM, true);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:Uninitialized".equals(event)) {
+ processComponentState(GeckoComponent.PushServiceAndroidGCM, false);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("FxAccountsPush:Initialized".equals(event)) {
+ processComponentState(GeckoComponent.FxAccountsPush, true);
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:Configure".equals(event)) {
+ final String endpoint = message.getString("endpoint");
+ if (endpoint == null) {
+ callback.sendError("endpoint must not be null in " + event);
+ return;
+ }
+ final boolean debug = message.getBoolean("debug", false);
+ pushManager.configure(geckoProfile.getName(), endpoint, debug, System.currentTimeMillis()); // For side effects.
+ callback.sendSuccess(null);
+ return;
+ }
+ if ("PushServiceAndroidGCM:DumpRegistration".equals(event)) {
+ // In the future, this might be used to interrogate the Java Push Manager
+ // registration state from JavaScript.
+ callback.sendError("Not yet implemented!");
+ return;
+ }
+ if ("PushServiceAndroidGCM:DumpSubscriptions".equals(event)) {
+ try {
+ final Map<String, PushSubscription> result = pushManager.allSubscriptionsForProfile(geckoProfile.getName());
+
+ final JSONObject json = new JSONObject();
+ for (Map.Entry<String, PushSubscription> entry : result.entrySet()) {
+ json.put(entry.getKey(), entry.getValue().toJSONObject());
+ }
+ callback.sendSuccess(json);
+ } catch (JSONException e) {
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ }
+ return;
+ }
+ if ("PushServiceAndroidGCM:RegisterUserAgent".equals(event)) {
+ try {
+ pushManager.registerUserAgent(geckoProfile.getName(), System.currentTimeMillis()); // For side-effects.
+ callback.sendSuccess(null);
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ }
+ return;
+ }
+ if ("PushServiceAndroidGCM:UnregisterUserAgent".equals(event)) {
+ // In the future, this might be used to tell the Java Push Manager to unregister
+ // a User Agent entirely from JavaScript. Right now, however, everything is
+ // subscription based; there's no concept of unregistering all subscriptions
+ // simultaneously.
+ callback.sendError("Not yet implemented!");
+ return;
+ }
+ if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
+ final String service = SERVICE_FXA.equals(message.getString("service")) ?
+ SERVICE_FXA :
+ SERVICE_WEBPUSH;
+ final JSONObject serviceData;
+ final String appServerKey = message.getString("appServerKey");
+ try {
+ serviceData = new JSONObject();
+ serviceData.put("profileName", geckoProfile.getName());
+ serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final PushSubscription subscription;
+ try {
+ subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis());
+ } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ final JSONObject json = new JSONObject();
+ try {
+ json.put("channelID", subscription.chid);
+ json.put("endpoint", subscription.webpushEndpoint);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Got exception in " + event, e);
+ callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.SAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
+ callback.sendSuccess(json);
+ return;
+ }
+ if ("PushServiceAndroidGCM:UnsubscribeChannel".equals(event)) {
+ final String channelID = message.getString("channelID");
+ if (channelID == null) {
+ callback.sendError("channelID must not be null in " + event);
+ return;
+ }
+
+ // Fire and forget. See comments in the function itself.
+ final PushSubscription pushSubscription = pushManager.unsubscribeChannel(channelID);
+ if (pushSubscription != null) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.UNSAVE, TelemetryContract.Method.SERVICE, "dom-push-api");
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.sendError("Could not unsubscribe from channel: " + channelID);
+ return;
+ }
+ if ("FxAccountsPush:ReceivedPushMessageToDecode:Response".equals(event)) {
+ FxAccountPushHandler.handleFxAPushMessage(context, message);
+ return;
+ }
+ if ("History:GetPrePathLastVisitedTimeMilliseconds".equals(event)) {
+ if (callback == null) {
+ Log.e(LOG_TAG, "callback must not be null in " + event);
+ return;
+ }
+ final String prePath = message.getString("prePath");
+ if (prePath == null) {
+ callback.sendError("prePath must not be null in " + event);
+ return;
+ }
+ // We're on a background thread, so we can be synchronous.
+ final long millis = BrowserDB.from(geckoProfile).getPrePathLastVisitedTimeMilliseconds(
+ context.getContentResolver(), prePath);
+ callback.sendSuccess(millis);
+ return;
+ }
+ } catch (GcmTokenClient.NeedsGooglePlayServicesException e) {
+ // TODO: improve this. Can we find a point where the user is *definitely* interacting
+ // with the WebPush? Perhaps we can show a dialog when interacting with the Push
+ // permissions, and then be more aggressive showing this notification when we have
+ // registrations and subscriptions that can't be advanced.
+ callback.sendError("To handle event [" + event + "], user interaction is needed to enable Google Play Services.");
+ }
+ }
+
+ private void processComponentState(@NonNull GeckoComponent component, boolean isReady) {
+ if (component == GeckoComponent.FxAccountsPush) {
+ isReadyFxAccountsPush = isReady;
+
+ } else if (component == GeckoComponent.PushServiceAndroidGCM) {
+ isReadyPushServiceAndroidGCM = isReady;
+ }
+
+ // Send all pending messages to Gecko.
+ if (canSendPushMessagesToGecko()) {
+ sendPushMessagesToGecko(pendingPushMessages);
+ pendingPushMessages.clear();
+ }
+ }
+
+ private boolean canSendPushMessagesToGecko() {
+ return isReadyFxAccountsPush && isReadyPushServiceAndroidGCM;
+ }
+
+ private static void sendPushMessagesToGecko(@NonNull List<JSONObject> messages) {
+ for (JSONObject pushMessage : messages) {
+ final String profileName = pushMessage.optString("profileName", null);
+ final String profilePath = pushMessage.optString("profilePath", null);
+ final String service = pushMessage.optString("service", null);
+ if (profileName == null || profilePath == null ||
+ !GeckoThread.canUseProfile(profileName, new File(profilePath))) {
+ Log.e(LOG_TAG, "Mismatched profile for chid: " +
+ pushMessage.optString("channelID") +
+ "; ignoring dom/push message.");
+ continue;
+ }
+ if (SERVICE_WEBPUSH.equals(service)) {
+ sendMessageToGeckoService(pushMessage);
+ } else if (SERVICE_FXA.equals(service)) {
+ sendMessageToDecodeToGeckoService(pushMessage);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushState.java b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
new file mode 100644
index 000000000..686bf5a0d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushState.java
@@ -0,0 +1,137 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.annotation.WorkerThread;
+import android.support.v4.util.AtomicFile;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Firefox for Android maintains an App-wide mapping associating
+ * profile names to push registrations. Each push registration in turn associates channels to
+ * push subscriptions.
+ * <p/>
+ * We use a simple storage model of JSON backed by an atomic file. It is assumed that instances
+ * of this class will reference distinct files on disk; and that all accesses will be happen on a
+ * single (worker thread).
+ */
+public class PushState {
+ private static final String LOG_TAG = "GeckoPushState";
+
+ private static final long VERSION = 1L;
+
+ protected final @NonNull AtomicFile file;
+
+ protected final @NonNull Map<String, PushRegistration> registrations;
+
+ public PushState(Context context, @NonNull String fileName) {
+ this.registrations = new HashMap<>();
+
+ file = new AtomicFile(new File(context.getApplicationInfo().dataDir, fileName));
+ synchronized (file) {
+ try {
+ final String s = new String(file.readFully(), "UTF-8");
+ final JSONObject temp = new JSONObject(s);
+ if (temp.optLong("version", 0L) != VERSION) {
+ throw new JSONException("Unknown version!");
+ }
+
+ final JSONObject registrationsObject = temp.getJSONObject("registrations");
+ final Iterator<String> it = registrationsObject.keys();
+ while (it.hasNext()) {
+ final String profileName = it.next();
+ final PushRegistration registration = PushRegistration.fromJSONObject(registrationsObject.getJSONObject(profileName));
+ this.registrations.put(profileName, registration);
+ }
+ } catch (FileNotFoundException e) {
+ Log.i(LOG_TAG, "No storage found; starting fresh.");
+ this.registrations.clear();
+ } catch (IOException | JSONException e) {
+ Log.w(LOG_TAG, "Got exception reading storage; dropping storage and starting fresh.", e);
+ this.registrations.clear();
+ }
+ }
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject registrations = new JSONObject();
+ for (Map.Entry<String, PushRegistration> entry : this.registrations.entrySet()) {
+ registrations.put(entry.getKey(), entry.getValue().toJSONObject());
+ }
+
+ final JSONObject jsonObject = new JSONObject();
+ jsonObject.put("version", 1L);
+ jsonObject.put("registrations", registrations);
+ return jsonObject;
+ }
+
+ /**
+ * Synchronously persist the cache to disk.
+ * @return whether the cache was persisted successfully.
+ */
+ @WorkerThread
+ public boolean checkpoint() {
+ synchronized (file) {
+ FileOutputStream fileOutputStream = null;
+ try {
+ fileOutputStream = file.startWrite();
+ fileOutputStream.write(toJSONObject().toString().getBytes("UTF-8"));
+ file.finishWrite(fileOutputStream);
+ return true;
+ } catch (JSONException | IOException e) {
+ Log.e(LOG_TAG, "Got exception writing JSON storage; ignoring.", e);
+ if (fileOutputStream != null) {
+ file.failWrite(fileOutputStream);
+ }
+ return false;
+ }
+ }
+ }
+
+ public PushRegistration putRegistration(@NonNull String profileName, @NonNull PushRegistration registration) {
+ return registrations.put(profileName, registration);
+ }
+
+ /**
+ * Return the existing push registration for the given profile name.
+ * @return the push registration, if one is registered; null otherwise.
+ */
+ public PushRegistration getRegistration(@NonNull String profileName) {
+ return registrations.get(profileName);
+ }
+
+ /**
+ * Return all push registrations, keyed by profile names.
+ * @return a map of all push registrations. <b>The map is intentionally mutable - be careful!</b>
+ */
+ public @NonNull Map<String, PushRegistration> getRegistrations() {
+ return registrations;
+ }
+
+ /**
+ * Remove any existing push registration for the given profile name.
+ * </p>
+ * Most registration removals are during iteration, which should use an iterator that is
+ * aware of removals.
+ * @return the removed push registration, if one was removed; null otherwise.
+ */
+ public PushRegistration removeRegistration(@NonNull String profileName) {
+ return registrations.remove(profileName);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java
new file mode 100644
index 000000000..ecf752591
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushSubscription.java
@@ -0,0 +1,81 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.push;
+
+import android.support.annotation.NonNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Represent an autopush Channel subscription.
+ * <p/>
+ * Such a subscription associates a user agent and autopush data with a channel
+ * ID, a WebPush endpoint, and some service-specific data.
+ * <p/>
+ * Cloud Messaging data, and the returned uaid and secret.
+ * <p/>
+ * Each registration is associated to a single Gecko profile, although we don't
+ * enforce that here. This class is immutable, so it is by definition
+ * thread-safe.
+ */
+public class PushSubscription {
+ public final @NonNull String chid;
+ public final @NonNull String profileName;
+ public final @NonNull String webpushEndpoint;
+ public final @NonNull String service;
+ public final JSONObject serviceData;
+
+ public PushSubscription(@NonNull String chid, @NonNull String profileName, @NonNull String webpushEndpoint, @NonNull String service, JSONObject serviceData) {
+ this.chid = chid;
+ this.profileName = profileName;
+ this.webpushEndpoint = webpushEndpoint;
+ this.service = service;
+ this.serviceData = serviceData;
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject jsonObject = new JSONObject();
+ jsonObject.put("chid", chid);
+ jsonObject.put("profileName", profileName);
+ jsonObject.put("webpushEndpoint", webpushEndpoint);
+ jsonObject.put("service", service);
+ jsonObject.put("serviceData", serviceData);
+ return jsonObject;
+ }
+
+ public static PushSubscription fromJSONObject(@NonNull JSONObject subscription) throws JSONException {
+ final String chid = subscription.getString("chid");
+ final String profileName = subscription.getString("profileName");
+ final String webpushEndpoint = subscription.getString("webpushEndpoint");
+ final String service = subscription.getString("service");
+ final JSONObject serviceData = subscription.optJSONObject("serviceData");
+ return new PushSubscription(chid, profileName, webpushEndpoint, service, serviceData);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ // Auto-generated.
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ PushSubscription that = (PushSubscription) o;
+
+ if (!chid.equals(that.chid)) return false;
+ if (!profileName.equals(that.profileName)) return false;
+ if (!webpushEndpoint.equals(that.webpushEndpoint)) return false;
+ return service.equals(that.service);
+ }
+
+ @Override
+ public int hashCode() {
+ // Auto-generated.
+ int result = profileName.hashCode();
+ result = 31 * result + chid.hashCode();
+ result = 31 * result + webpushEndpoint.hashCode();
+ result = 31 * result + service.hashCode();
+ return result;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java b/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java
new file mode 100644
index 000000000..e70aac5b5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/reader/ReaderModeUtils.java
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.reader;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.net.Uri;
+
+public class ReaderModeUtils {
+ private static final String LOGTAG = "ReaderModeUtils";
+
+ /**
+ * Extract the URL from a valid about:reader URL. You may want to use stripAboutReaderUrl
+ * instead to always obtain a valid String.
+ *
+ * @see #stripAboutReaderUrl(String) for a safer version that returns the original URL for malformed/invalid
+ * URLs.
+ * @return <code>null</code> if the URL is malformed or doesn't contain a URL parameter.
+ */
+ private static String getUrlFromAboutReader(String aboutReaderUrl) {
+ return StringUtils.getQueryParameter(aboutReaderUrl, "url");
+ }
+
+ public static boolean isEnteringReaderMode(String oldURL, String newURL) {
+ if (oldURL == null || newURL == null) {
+ return false;
+ }
+
+ if (!AboutPages.isAboutReader(newURL)) {
+ return false;
+ }
+
+ String urlFromAboutReader = getUrlFromAboutReader(newURL);
+ if (urlFromAboutReader == null) {
+ return false;
+ }
+
+ return urlFromAboutReader.equals(oldURL);
+ }
+
+ public static String getAboutReaderForUrl(String url) {
+ return getAboutReaderForUrl(url, -1);
+ }
+
+ /**
+ * Obtain the underlying URL from an about:reader URL.
+ * This will return the input URL if either of the following is true:
+ * 1. the input URL is a non about:reader URL
+ * 2. the input URL is an invalid/unparseable about:reader URL
+ */
+ public static String stripAboutReaderUrl(String url) {
+ if (!AboutPages.isAboutReader(url)) {
+ return url;
+ }
+
+ final String strippedUrl = getUrlFromAboutReader(url);
+ return strippedUrl != null ? strippedUrl : url;
+ }
+
+ public static String getAboutReaderForUrl(String url, int tabId) {
+ String aboutReaderUrl = AboutPages.READER + "?url=" + Uri.encode(url);
+
+ if (tabId >= 0) {
+ aboutReaderUrl += "&tabId=" + tabId;
+ }
+
+ return aboutReaderUrl;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java b/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java
new file mode 100644
index 000000000..e01ff79ac
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/reader/ReadingListHelper.java
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.reader;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.icons.IconRequest;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.util.concurrent.ExecutionException;
+
+public final class ReadingListHelper implements NativeEventListener {
+ private static final String LOGTAG = "GeckoReadingListHelper";
+
+ protected final Context context;
+ private final BrowserDB db;
+
+ public ReadingListHelper(Context context, GeckoProfile profile) {
+ this.context = context;
+ this.db = BrowserDB.from(profile);
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener((NativeEventListener) this,
+ "Reader:FaviconRequest", "Reader:AddedToCache");
+ }
+
+ public void uninit() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener((NativeEventListener) this,
+ "Reader:FaviconRequest", "Reader:AddedToCache");
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message,
+ final EventCallback callback) {
+ switch (event) {
+ case "Reader:FaviconRequest": {
+ handleReaderModeFaviconRequest(callback, message.getString("url"));
+ break;
+ }
+ case "Reader:AddedToCache": {
+ // AddedToCache is a one way message: callback will be null, and we therefore shouldn't
+ // attempt to handle it.
+ handleAddedToCache(message.getString("url"), message.getString("path"), message.getInt("size"));
+ break;
+ }
+ }
+ }
+
+ /**
+ * Gecko (ReaderMode) requests the page favicon to append to the
+ * document head for display.
+ */
+ private void handleReaderModeFaviconRequest(final EventCallback callback, final String url) {
+ (new UIAsyncTask.WithoutParams<String>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public String doInBackground() {
+ // This is a bit ridiculous if you look at the bigger picture: Reader mode extracts
+ // the article content. We insert the content into a new document (about:reader).
+ // Some events are exchanged to lookup the icon URL for the actual website. This
+ // URL is then added to the markup which will then trigger our icon loading code in
+ // the Tab class.
+ //
+ // The Tab class could just lookup and load the icon itself. All it needs to do is
+ // to strip the about:reader URL and perform a normal icon load from cache.
+ //
+ // A more global solution (looking at desktop and iOS) would be to copy the <link>
+ // markup from the original page to the about:reader page and then rely on our normal
+ // icon loading code. This would work even if we do not have anything in the cache
+ // for some kind of reason.
+
+ final IconRequest request = Icons.with(context)
+ .pageUrl(url)
+ .prepareOnly()
+ .build();
+
+ try {
+ request.execute(null).get();
+ if (request.getIconCount() > 0) {
+ return request.getBestIcon().getUrl();
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ // Ignore
+ }
+
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(String faviconUrl) {
+ JSONObject args = new JSONObject();
+ if (faviconUrl != null) {
+ try {
+ args.put("url", url);
+ args.put("faviconUrl", faviconUrl);
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Error building JSON favicon arguments.", e);
+ }
+ }
+ callback.sendSuccess(args.toString());
+ }
+ }).execute();
+ }
+
+ private void handleAddedToCache(final String url, final String path, final int size) {
+ final SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
+
+ rch.put(url, path, size);
+ }
+
+ public static void cacheReaderItem(final String url, final int tabID, Context context) {
+ if (AboutPages.isAboutReader(url)) {
+ throw new IllegalArgumentException("Page url must be original (not about:reader) url");
+ }
+
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
+
+ if (!rch.isURLCached(url)) {
+ GeckoAppShell.notifyObservers("Reader:AddToCache", Integer.toString(tabID));
+ }
+ }
+
+ public static void removeCachedReaderItem(final String url, Context context) {
+ if (AboutPages.isAboutReader(url)) {
+ throw new IllegalArgumentException("Page url must be original (not about:reader) url");
+ }
+
+ SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
+
+ if (rch.isURLCached(url)) {
+ GeckoAppShell.notifyObservers("Reader:RemoveFromCache", url);
+ }
+
+ // When removing items from the cache we can probably spare ourselves the async callback
+ // that we use when adding cached items. We know the cached item will be gone, hence
+ // we no longer need to track it in the SavedReaderViewHelper
+ rch.remove(url);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java b/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java
new file mode 100644
index 000000000..e60abac71
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/reader/SavedReaderViewHelper.java
@@ -0,0 +1,247 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.reader;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.UrlAnnotations;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Iterator;
+
+/**
+ * Helper to keep track of items that are stored in the reader view cache. This is an in-memory list
+ * of the reader view items that are cached on disk. It is intended to allow quickly determining whether
+ * a given URL is in the cache, and also how many cached items there are.
+ *
+ * Currently we have 1:1 correspondence of reader view items (in the URL-annotations table)
+ * to cached items. This is _not_ a true cache, we never purge/cleanup items here - we only remove
+ * items when we un-reader-view/bookmark them. This is an acceptable model while we can guarantee the
+ * 1:1 correspondence.
+ *
+ * It isn't strictly necessary to mirror cached items in SQL at this stage, however it seems sensible
+ * to maintain URL anotations to avoid additional DB migrations in future.
+ * It is also simpler to implement the reading list smart-folder using the annotations (even if we do
+ * all other decoration from our in-memory cache record), as that is what we will need when
+ * we move away from the 1:1 correspondence.
+ *
+ * Bookmarks can be in one of two states - plain bookmark, or reader view bookmark that is also saved
+ * offline. We're hoping to introduce real cache management / cleanup in future, in which case a
+ * third user-visible state (reader view bookmark without a cache entry) will be added. However that logic is
+ * much more complicated and requires substantial changes in how we decorate reader view bookmarks.
+ * With the current 1:1 correspondence we can use this in-memory helper to quickly decorate
+ * bookmarks (in all the various lists and panels that are used), whereas supporting
+ * the third state requires significant changes in order to allow joining with the
+ * URL-annotations table wherever bookmarks might be retrieved (i.e. multiple homepanels, each with
+ * their own loaders and adapter).
+ *
+ * If/when cache cleanup and sync are implemented, URL annotations will be the canonical record of
+ * user intent, and the cache will no longer represent all reader view bookmarks. We will have (A)
+ * cached items that are not a bookmark, or bookmarks without the reader view annotation (both of
+ * these would need purging), and (B) bookmarks with a reader view annotation, but not stored in
+ * the cache (which we might want to download in the background). Supporting (B) is currently difficult,
+ * see previous paragraph.
+ */
+public class SavedReaderViewHelper {
+ private static final String LOG_TAG = "SavedReaderViewHelper";
+
+ private static final String PATH = "path";
+ private static final String SIZE = "size";
+
+ private static final String DIRECTORY = "readercache";
+ private static final String FILE_NAME = "items.json";
+ private static final String FILE_PATH = DIRECTORY + "/" + FILE_NAME;
+
+ // We use null to indicate that the cache hasn't yet been loaded. Loading has to be explicitly
+ // requested by client code, and must happen on the background thread. Attempting to access
+ // items (which happens mainly on the UI thread) before explicitly loading them is not permitted.
+ private JSONObject mItems = null;
+
+ private final Context mContext;
+
+ private static SavedReaderViewHelper instance = null;
+
+ private SavedReaderViewHelper(Context context) {
+ mContext = context;
+ }
+
+ public static synchronized SavedReaderViewHelper getSavedReaderViewHelper(final Context context) {
+ if (instance == null) {
+ instance = new SavedReaderViewHelper(context);
+ }
+
+ return instance;
+ }
+
+ /**
+ * Load the reader view cache list from our JSON file.
+ *
+ * Must not be run on the UI thread due to file access.
+ */
+ public synchronized void loadItems() {
+ // TODO bug 1264489
+ // This is a band aid fix for Bug 1264134. We need to figure out the root cause and reenable this
+ // assertion.
+ // ThreadUtils.assertNotOnUiThread();
+
+ if (mItems != null) {
+ return;
+ }
+
+ try {
+ mItems = GeckoProfile.get(mContext).readJSONObjectFromFile(FILE_PATH);
+ } catch (IOException e) {
+ mItems = new JSONObject();
+ }
+ }
+
+ private synchronized void assertItemsLoaded() {
+ if (mItems == null) {
+ throw new IllegalStateException("SavedReaderView items must be explicitly loaded using loadItems() before access.");
+ }
+ }
+
+ private JSONObject makeItem(@NonNull String path, long size) throws JSONException {
+ final JSONObject item = new JSONObject();
+
+ item.put(PATH, path);
+ item.put(SIZE, size);
+
+ return item;
+ }
+
+ public synchronized boolean isURLCached(@NonNull final String URL) {
+ assertItemsLoaded();
+ return mItems.has(URL);
+ }
+
+ /**
+ * Insert an item into the list of cached items.
+ *
+ * This may be called from any thread.
+ */
+ public synchronized void put(@NonNull final String pageURL, @NonNull final String path, final long size) {
+ assertItemsLoaded();
+
+ try {
+ mItems.put(pageURL, makeItem(path, size));
+ } catch (JSONException e) {
+ Log.w(LOG_TAG, "Item insertion failed:", e);
+ // This should never happen, absent any errors in our own implementation
+ throw new IllegalStateException("Failure inserting into SavedReaderViewHelper json");
+ }
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ UrlAnnotations annotations = BrowserDB.from(mContext).getUrlAnnotations();
+ annotations.insertReaderViewUrl(mContext.getContentResolver(), pageURL);
+
+ commit();
+ }
+ });
+ }
+
+ protected synchronized void remove(@NonNull final String pageURL) {
+ assertItemsLoaded();
+
+ mItems.remove(pageURL);
+
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ UrlAnnotations annotations = BrowserDB.from(mContext).getUrlAnnotations();
+ annotations.deleteReaderViewUrl(mContext.getContentResolver(), pageURL);
+
+ commit();
+ }
+ });
+ }
+
+ @RobocopTarget
+ public synchronized int size() {
+ assertItemsLoaded();
+ return mItems.length();
+ }
+
+ private synchronized void commit() {
+ ThreadUtils.assertOnBackgroundThread();
+
+ GeckoProfile profile = GeckoProfile.get(mContext);
+ File cacheDir = new File(profile.getDir(), DIRECTORY);
+
+ if (!cacheDir.exists()) {
+ Log.i(LOG_TAG, "No preexisting cache directory, creating now");
+
+ boolean cacheDirCreated = cacheDir.mkdir();
+ if (!cacheDirCreated) {
+ throw new IllegalStateException("Couldn't create cache directory, unable to track reader view cache");
+ }
+ }
+
+ profile.writeFile(FILE_PATH, mItems.toString());
+ }
+
+ /**
+ * Return the Reader View URL for a given URL if it is contained in the cache. Returns the
+ * plain URL if the page is not cached.
+ */
+ public static String getReaderURLIfCached(final Context context, @NonNull final String pageURL) {
+ SavedReaderViewHelper rvh = getSavedReaderViewHelper(context);
+
+ if (rvh.isURLCached(pageURL)) {
+ return ReaderModeUtils.getAboutReaderForUrl(pageURL);
+ } else {
+ return pageURL;
+ }
+ }
+
+ /**
+ * Obtain the total disk space used for saved reader view items, in KB.
+ *
+ * @return Total disk space used (KB), or Integer.MAX_VALUE on overflow.
+ */
+ public synchronized int getDiskSpacedUsedKB() {
+ // JSONObject is not thread safe - we need to be synchronized to avoid issues (most likely to
+ // occur if items are removed during iteration).
+ final Iterator<String> keys = mItems.keys();
+ long bytes = 0;
+
+ while (keys.hasNext()) {
+ final String pageURL = keys.next();
+ try {
+ final JSONObject item = mItems.getJSONObject(pageURL);
+ bytes += item.getLong(SIZE);
+
+ // Overflow is highly unlikely (we will hit device storage limits before we hit integer limits),
+ // but we should still handle this for correctness.
+ // We definitely can't store our output in an int if we overflow the long here.
+ if (bytes < 0) {
+ return Integer.MAX_VALUE;
+ }
+ } catch (JSONException e) {
+ // This shouldn't ever happen:
+ throw new IllegalStateException("Must be able to access items in saved reader view list", e);
+ }
+ }
+
+ long kb = bytes / 1024;
+ if (kb > Integer.MAX_VALUE) {
+ return Integer.MAX_VALUE;
+ } else {
+ return (int) kb;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java
new file mode 100644
index 000000000..480078a98
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/DefaultConfiguration.java
@@ -0,0 +1,34 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+/**
+ * Default implementation of RestrictionConfiguration interface. Used whenever no restrictions are enforced for the
+ * current profile.
+ */
+public class DefaultConfiguration implements RestrictionConfiguration {
+ @Override
+ public boolean isAllowed(Restrictable restrictable) {
+ if (restrictable == Restrictable.BLOCK_LIST) {
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean canLoadUrl(String url) {
+ return true;
+ }
+
+ @Override
+ public boolean isRestricted() {
+ return false;
+ }
+
+ @Override
+ public void update() {}
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java
new file mode 100644
index 000000000..f9663ccf7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/GuestProfileConfiguration.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import android.net.Uri;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * RestrictionConfiguration implementation for guest profiles.
+ */
+public class GuestProfileConfiguration implements RestrictionConfiguration {
+ static List<Restrictable> DISABLED_FEATURES = Arrays.asList(
+ Restrictable.DOWNLOAD,
+ Restrictable.INSTALL_EXTENSION,
+ Restrictable.INSTALL_APPS,
+ Restrictable.BROWSE,
+ Restrictable.SHARE,
+ Restrictable.BOOKMARK,
+ Restrictable.ADD_CONTACT,
+ Restrictable.SET_IMAGE,
+ Restrictable.MODIFY_ACCOUNTS,
+ Restrictable.REMOTE_DEBUGGING,
+ Restrictable.IMPORT_SETTINGS,
+ Restrictable.BLOCK_LIST,
+ Restrictable.DATA_CHOICES,
+ Restrictable.DEFAULT_THEME
+ );
+
+ @SuppressWarnings("serial")
+ private static final List<String> BANNED_SCHEMES = Arrays.asList(
+ "file",
+ "chrome",
+ "resource",
+ "jar",
+ "wyciwyg"
+ );
+
+ private static final List<String> BANNED_URLS = Arrays.asList(
+ "about:config",
+ "about:addons"
+ );
+
+ @Override
+ public boolean isAllowed(Restrictable restrictable) {
+ return !DISABLED_FEATURES.contains(restrictable);
+ }
+
+ @Override
+ public boolean canLoadUrl(String url) {
+ // Null URLs are always permitted.
+ if (url == null) {
+ return true;
+ }
+
+ final Uri u = Uri.parse(url);
+ final String scheme = u.getScheme();
+ if (BANNED_SCHEMES.contains(scheme)) {
+ return false;
+ }
+
+ url = url.toLowerCase();
+ for (String banned : BANNED_URLS) {
+ if (url.startsWith(banned)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean isRestricted() {
+ return true;
+ }
+
+ @Override
+ public void update() {}
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java
new file mode 100644
index 000000000..f794c5782
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictable.java
@@ -0,0 +1,112 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.support.annotation.StringRes;
+
+/**
+ * This is a list of things we can restrict you from doing. Some of these are reflected in Android UserManager constants.
+ * Others are specific to us.
+ * These constants should be in sync with the ones from toolkit/components/parentalcontrols/nsIParentalControlsService.idl
+ */
+public enum Restrictable {
+ DOWNLOAD(1, "downloads", 0, 0),
+
+ INSTALL_EXTENSION(
+ 2, "no_install_extensions",
+ R.string.restrictable_feature_addons_installation,
+ R.string.restrictable_feature_addons_installation_description),
+
+ // UserManager.DISALLOW_INSTALL_APPS
+ INSTALL_APPS(3, "no_install_apps", 0 , 0),
+
+ BROWSE(4, "browse", 0, 0),
+
+ SHARE(5, "share", 0, 0),
+
+ BOOKMARK(6, "bookmark", 0, 0),
+
+ ADD_CONTACT(7, "add_contact", 0, 0),
+
+ SET_IMAGE(8, "set_image", 0, 0),
+
+ // UserManager.DISALLOW_MODIFY_ACCOUNTS
+ MODIFY_ACCOUNTS(9, "no_modify_accounts", 0, 0),
+
+ REMOTE_DEBUGGING(10, "remote_debugging", 0, 0),
+
+ IMPORT_SETTINGS(11, "import_settings", 0, 0),
+
+ PRIVATE_BROWSING(
+ 12, "private_browsing",
+ R.string.restrictable_feature_private_browsing,
+ R.string.restrictable_feature_private_browsing_description),
+
+ DATA_CHOICES(13, "data_coices", 0, 0),
+
+ CLEAR_HISTORY(14, "clear_history",
+ R.string.restrictable_feature_clear_history,
+ R.string.restrictable_feature_clear_history_description),
+
+ MASTER_PASSWORD(15, "master_password", 0, 0),
+
+ GUEST_BROWSING(16, "guest_browsing", 0, 0),
+
+ ADVANCED_SETTINGS(17, "advanced_settings",
+ R.string.restrictable_feature_advanced_settings,
+ R.string.restrictable_feature_advanced_settings_description),
+
+ CAMERA_MICROPHONE(18, "camera_microphone",
+ R.string.restrictable_feature_camera_microphone,
+ R.string.restrictable_feature_camera_microphone_description),
+
+ BLOCK_LIST(19, "block_list",
+ R.string.restrictable_feature_block_list,
+ R.string.restrictable_feature_block_list_description),
+
+ TELEMETRY(20, "telemetry",
+ R.string.datareporting_telemetry_title,
+ R.string.datareporting_telemetry_summary),
+
+ HEALTH_REPORT(21, "health_report",
+ R.string.datareporting_fhr_title,
+ R.string.datareporting_fhr_summary2),
+
+ DEFAULT_THEME(22, "default_theme", 0, 0);
+
+ public final int id;
+ public final String name;
+
+ @StringRes
+ public final int title;
+
+ @StringRes
+ public final int description;
+
+ Restrictable(final int id, final String name, @StringRes int title, @StringRes int description) {
+ this.id = id;
+ this.name = name;
+ this.title = title;
+ this.description = description;
+ }
+
+ public String getTitle(Context context) {
+ if (title == 0) {
+ return toString();
+ }
+ return context.getResources().getString(title);
+ }
+
+ public String getDescription(Context context) {
+ if (description == 0) {
+ return null;
+ }
+ return context.getResources().getString(description);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java
new file mode 100644
index 000000000..15a0b97f4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictedProfileConfiguration.java
@@ -0,0 +1,129 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.StrictMode;
+import android.os.UserManager;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+public class RestrictedProfileConfiguration implements RestrictionConfiguration {
+ // Mapping from restrictable feature to default state (on/off)
+ private static Map<Restrictable, Boolean> configuration = new LinkedHashMap<>();
+ static {
+ configuration.put(Restrictable.INSTALL_EXTENSION, false);
+ configuration.put(Restrictable.PRIVATE_BROWSING, false);
+ configuration.put(Restrictable.CLEAR_HISTORY, false);
+ configuration.put(Restrictable.MASTER_PASSWORD, false);
+ configuration.put(Restrictable.GUEST_BROWSING, false);
+ configuration.put(Restrictable.ADVANCED_SETTINGS, false);
+ configuration.put(Restrictable.CAMERA_MICROPHONE, false);
+ configuration.put(Restrictable.DATA_CHOICES, false);
+ configuration.put(Restrictable.BLOCK_LIST, false);
+ configuration.put(Restrictable.TELEMETRY, false);
+ configuration.put(Restrictable.HEALTH_REPORT, true);
+ configuration.put(Restrictable.DEFAULT_THEME, true);
+ }
+
+ /**
+ * These restrictions are hidden from the admin configuration UI.
+ */
+ private static List<Restrictable> hiddenRestrictions = new ArrayList<>();
+ static {
+ hiddenRestrictions.add(Restrictable.MASTER_PASSWORD);
+ hiddenRestrictions.add(Restrictable.GUEST_BROWSING);
+ hiddenRestrictions.add(Restrictable.DATA_CHOICES);
+ hiddenRestrictions.add(Restrictable.DEFAULT_THEME);
+
+ // Hold behind Nightly flag until we have an actual block list deployed.
+ if (!AppConstants.NIGHTLY_BUILD) {
+ hiddenRestrictions.add(Restrictable.BLOCK_LIST);
+ }
+ }
+
+ /* package-private */ static boolean shouldHide(Restrictable restrictable) {
+ return hiddenRestrictions.contains(restrictable);
+ }
+
+ /* package-private */ static Map<Restrictable, Boolean> getConfiguration() {
+ return configuration;
+ }
+
+ private Context context;
+
+ public RestrictedProfileConfiguration(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ @Override
+ public synchronized boolean isAllowed(Restrictable restrictable) {
+ // Special casing system/user restrictions
+ if (restrictable == Restrictable.INSTALL_APPS || restrictable == Restrictable.MODIFY_ACCOUNTS) {
+ return RestrictionCache.getUserRestriction(context, restrictable.name);
+ }
+
+ if (!RestrictionCache.hasApplicationRestriction(context, restrictable.name) && !configuration.containsKey(restrictable)) {
+ // Always allow features that are not in the configuration
+ return true;
+ }
+
+ return RestrictionCache.getApplicationRestriction(context, restrictable.name, configuration.get(restrictable));
+ }
+
+ @Override
+ public boolean canLoadUrl(String url) {
+ if (!isAllowed(Restrictable.INSTALL_EXTENSION) && AboutPages.isAboutAddons(url)) {
+ return false;
+ }
+
+ if (!isAllowed(Restrictable.PRIVATE_BROWSING) && AboutPages.isAboutPrivateBrowsing(url)) {
+ return false;
+ }
+
+ if (AboutPages.isAboutConfig(url)) {
+ // Always block access to about:config to prevent circumventing restrictions (Bug 1189233)
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean isRestricted() {
+ return true;
+ }
+
+ @Override
+ public synchronized void update() {
+ RestrictionCache.invalidate();
+ }
+
+ public static List<Restrictable> getVisibleRestrictions() {
+ final List<Restrictable> visibleList = new ArrayList<>();
+
+ for (Restrictable restrictable : configuration.keySet()) {
+ if (hiddenRestrictions.contains(restrictable)) {
+ continue;
+ }
+ visibleList.add(restrictable);
+ }
+
+ return visibleList;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java
new file mode 100644
index 000000000..523cc113b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionCache.java
@@ -0,0 +1,99 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.StrictMode;
+import android.os.UserManager;
+
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Cache for user and application restrictions.
+ */
+public class RestrictionCache {
+ private static Bundle cachedAppRestrictions;
+ private static Bundle cachedUserRestrictions;
+ private static boolean isCacheInvalid = true;
+
+ private RestrictionCache() {}
+
+ public static synchronized boolean getUserRestriction(Context context, String restriction) {
+ updateCacheIfNeeded(context);
+ return cachedUserRestrictions.getBoolean(restriction);
+ }
+
+ public static synchronized boolean hasApplicationRestriction(Context context, String restriction) {
+ updateCacheIfNeeded(context);
+ return cachedAppRestrictions.containsKey(restriction);
+ }
+
+ public static synchronized boolean getApplicationRestriction(Context context, String restriction, boolean defaultValue) {
+ updateCacheIfNeeded(context);
+ return cachedAppRestrictions.getBoolean(restriction, defaultValue);
+ }
+
+ public static synchronized boolean hasApplicationRestrictions(Context context) {
+ updateCacheIfNeeded(context);
+ return !cachedAppRestrictions.isEmpty();
+ }
+
+ public static synchronized void invalidate() {
+ isCacheInvalid = true;
+ }
+
+ private static void updateCacheIfNeeded(Context context) {
+ // If we are not on the UI thread then we can just go ahead and read the values (Bug 1189347).
+ // Otherwise we read from the cache to avoid blocking the UI thread. If the cache is invalid
+ // then we hazard the consequences and just do the read.
+ if (isCacheInvalid || !ThreadUtils.isOnUiThread()) {
+ readRestrictions(context);
+ isCacheInvalid = false;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+ private static void readRestrictions(Context context) {
+ final UserManager mgr = (UserManager) context.getSystemService(Context.USER_SERVICE);
+
+ // If we do not have anything in the cache yet then this read might happen on the UI thread (Bug 1189347).
+ final StrictMode.ThreadPolicy policy = StrictMode.allowThreadDiskReads();
+
+ try {
+ Bundle appRestrictions = mgr.getApplicationRestrictions(context.getPackageName());
+ migrateRestrictionsIfNeeded(appRestrictions);
+
+ cachedAppRestrictions = appRestrictions;
+ cachedUserRestrictions = mgr.getUserRestrictions(); // Always implies disk read
+ } finally {
+ StrictMode.setThreadPolicy(policy);
+ }
+ }
+
+ /**
+ * This method migrates the old set of DISALLOW_ restrictions to the new restrictable feature ones (Bug 1189336).
+ */
+ /* package-private */ static void migrateRestrictionsIfNeeded(Bundle bundle) {
+ if (!bundle.containsKey(Restrictable.INSTALL_EXTENSION.name) && bundle.containsKey("no_install_extensions")) {
+ bundle.putBoolean(Restrictable.INSTALL_EXTENSION.name, !bundle.getBoolean("no_install_extensions"));
+ }
+
+ if (!bundle.containsKey(Restrictable.PRIVATE_BROWSING.name) && bundle.containsKey("no_private_browsing")) {
+ bundle.putBoolean(Restrictable.PRIVATE_BROWSING.name, !bundle.getBoolean("no_private_browsing"));
+ }
+
+ if (!bundle.containsKey(Restrictable.CLEAR_HISTORY.name) && bundle.containsKey("no_clear_history")) {
+ bundle.putBoolean(Restrictable.CLEAR_HISTORY.name, !bundle.getBoolean("no_clear_history"));
+ }
+
+ if (!bundle.containsKey(Restrictable.ADVANCED_SETTINGS.name) && bundle.containsKey("no_advanced_settings")) {
+ bundle.putBoolean(Restrictable.ADVANCED_SETTINGS.name, !bundle.getBoolean("no_advanced_settings"));
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java
new file mode 100644
index 000000000..7c40da734
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionConfiguration.java
@@ -0,0 +1,31 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+/**
+ * Interface for classes that Restrictions will delegate to for making decisions.
+ */
+public interface RestrictionConfiguration {
+ /**
+ * Is the user allowed to perform this action?
+ */
+ boolean isAllowed(Restrictable restrictable);
+
+ /**
+ * Is the user allowed to load the given URL?
+ */
+ boolean canLoadUrl(String url);
+
+ /**
+ * Is this user restricted in any way?
+ */
+ boolean isRestricted();
+
+ /**
+ * Update restrictions if needed.
+ */
+ void update();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java
new file mode 100644
index 000000000..26b9a446f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/RestrictionProvider.java
@@ -0,0 +1,84 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import org.mozilla.gecko.AppConstants;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.RestrictionEntry;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import java.util.ArrayList;
+import java.util.Map;
+
+/**
+ * Broadcast receiver providing supported restrictions to the system.
+ */
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+public class RestrictionProvider extends BroadcastReceiver {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (AppConstants.Versions.preJBMR2) {
+ // This broadcast does not make any sense prior to Jelly Bean MR2.
+ return;
+ }
+
+ final PendingResult result = goAsync();
+
+ new Thread() {
+ @Override
+ public void run() {
+ final Bundle oldRestrictions = intent.getBundleExtra(Intent.EXTRA_RESTRICTIONS_BUNDLE);
+ RestrictionCache.migrateRestrictionsIfNeeded(oldRestrictions);
+
+ final Bundle extras = new Bundle();
+
+ ArrayList<RestrictionEntry> entries = initRestrictions(context, oldRestrictions);
+ extras.putParcelableArrayList(Intent.EXTRA_RESTRICTIONS_LIST, entries);
+
+ result.setResult(Activity.RESULT_OK, null, extras);
+ result.finish();
+ }
+ }.start();
+ }
+
+ private ArrayList<RestrictionEntry> initRestrictions(Context context, Bundle oldRestrictions) {
+ ArrayList<RestrictionEntry> entries = new ArrayList<RestrictionEntry>();
+
+ final Map<Restrictable, Boolean> configuration = RestrictedProfileConfiguration.getConfiguration();
+
+ for (Restrictable restrictable : configuration.keySet()) {
+ if (RestrictedProfileConfiguration.shouldHide(restrictable)) {
+ continue;
+ }
+
+ RestrictionEntry entry = createRestrictionEntryWithDefaultValue(context, restrictable,
+ oldRestrictions.getBoolean(restrictable.name, configuration.get(restrictable)));
+ entries.add(entry);
+ }
+
+ return entries;
+ }
+
+ private RestrictionEntry createRestrictionEntryWithDefaultValue(Context context, Restrictable restrictable, boolean defaultValue) {
+ RestrictionEntry entry = new RestrictionEntry(restrictable.name, defaultValue);
+
+ entry.setTitle(restrictable.getTitle(context));
+
+ final String description = restrictable.getDescription(context);
+ if (!TextUtils.isEmpty(description)) {
+ entry.setDescription(description);
+ }
+
+ return entry;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java
new file mode 100644
index 000000000..0cf680810
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/restrictions/Restrictions.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.restrictions;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+@RobocopTarget
+public class Restrictions {
+ private static final String LOGTAG = "GeckoRestrictedProfiles";
+
+ private static RestrictionConfiguration configuration;
+
+ private static RestrictionConfiguration getConfiguration(Context context) {
+ if (configuration == null) {
+ configuration = createConfiguration(context);
+ }
+
+ return configuration;
+ }
+
+ public static synchronized RestrictionConfiguration createConfiguration(Context context) {
+ if (configuration != null) {
+ // This method is synchronized and another thread might already have created the configuration.
+ return configuration;
+ }
+
+ if (isGuestProfile(context)) {
+ return new GuestProfileConfiguration();
+ } else if (isRestrictedProfile(context)) {
+ return new RestrictedProfileConfiguration(context);
+ } else {
+ return new DefaultConfiguration();
+ }
+ }
+
+ private static boolean isGuestProfile(Context context) {
+ if (configuration != null) {
+ return configuration instanceof GuestProfileConfiguration;
+ }
+
+ GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
+ if (geckoInterface != null) {
+ return geckoInterface.getProfile().inGuestMode();
+ }
+
+ return GeckoProfile.get(context).inGuestMode();
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+ public static boolean isRestrictedProfile(Context context) {
+ if (configuration != null) {
+ return configuration instanceof RestrictedProfileConfiguration;
+ }
+
+ if (Versions.preJBMR2) {
+ // Early versions don't support restrictions at all
+ return false;
+ }
+
+ // The user is on a restricted profile if, and only if, we injected application restrictions during account setup.
+ return RestrictionCache.hasApplicationRestrictions(context);
+ }
+
+ public static void update(Context context) {
+ getConfiguration(context).update();
+ }
+
+ private static Restrictable geckoActionToRestriction(int action) {
+ for (Restrictable rest : Restrictable.values()) {
+ if (rest.id == action) {
+ return rest;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown action " + action);
+ }
+
+ private static boolean canLoadUrl(final Context context, final String url) {
+ return getConfiguration(context).canLoadUrl(url);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean isUserRestricted() {
+ return isUserRestricted(GeckoAppShell.getApplicationContext());
+ }
+
+ public static boolean isUserRestricted(final Context context) {
+ return getConfiguration(context).isRestricted();
+ }
+
+ public static boolean isAllowed(final Context context, final Restrictable restrictable) {
+ return getConfiguration(context).isAllowed(restrictable);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean isAllowed(int action, String url) {
+ final Restrictable restrictable;
+ try {
+ restrictable = geckoActionToRestriction(action);
+ } catch (IllegalArgumentException ex) {
+ // Unknown actions represent a coding error, so we
+ // refuse the action and log.
+ Log.e(LOGTAG, "Unknown action " + action + "; check calling code.");
+ return false;
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ if (Restrictable.BROWSE == restrictable) {
+ return canLoadUrl(context, url);
+ } else {
+ return isAllowed(context, restrictable);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java
new file mode 100644
index 000000000..d4d9938e2
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngine.java
@@ -0,0 +1,304 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.search;
+
+import android.net.Uri;
+import android.util.Log;
+import android.util.Xml;
+
+import org.mozilla.gecko.util.StringUtils;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Extend this class to add a new search engine to
+ * the search activity.
+ */
+public class SearchEngine {
+ private static final String LOG_TAG = "SearchEngine";
+
+ private static final String URLTYPE_SUGGEST_JSON = "application/x-suggestions+json";
+ private static final String URLTYPE_SEARCH_HTML = "text/html";
+
+ private static final String URL_REL_MOBILE = "mobile";
+
+ // Parameters copied from nsSearchService.js
+ private static final String MOZ_PARAM_LOCALE = "\\{moz:locale\\}";
+ private static final String MOZ_PARAM_DIST_ID = "\\{moz:distributionID\\}";
+ private static final String MOZ_PARAM_OFFICIAL = "\\{moz:official\\}";
+
+ // Supported OpenSearch parameters
+ // See http://opensearch.a9.com/spec/1.1/querysyntax/#core
+ private static final String OS_PARAM_USER_DEFINED = "\\{searchTerms\\??\\}";
+ private static final String OS_PARAM_INPUT_ENCODING = "\\{inputEncoding\\??\\}";
+ private static final String OS_PARAM_LANGUAGE = "\\{language\\??\\}";
+ private static final String OS_PARAM_OUTPUT_ENCODING = "\\{outputEncoding\\??\\}";
+ private static final String OS_PARAM_OPTIONAL = "\\{(?:\\w+:)?\\w+\\?\\}";
+
+ // Boilerplate bookmarklet-style JS for injecting CSS into the
+ // head of a web page. The actual CSS is inserted at `%s`.
+ private static final String STYLE_INJECTION_SCRIPT =
+ "javascript:(function(){" +
+ "var tag=document.createElement('style');" +
+ "tag.type='text/css';" +
+ "document.getElementsByTagName('head')[0].appendChild(tag);" +
+ "tag.innerText='%s'})();";
+
+ // The Gecko search identifier. This will be null for engines that don't ship with the locale.
+ private final String identifier;
+
+ private String shortName;
+ private String iconURL;
+
+ // Ordered list of preferred results URIs.
+ private final List<Uri> resultsUris = new ArrayList<Uri>();
+ private Uri suggestUri;
+
+ /**
+ *
+ * @param in InputStream of open search plugin XML
+ */
+ public SearchEngine(String identifier, InputStream in) throws IOException, XmlPullParserException {
+ this.identifier = identifier;
+
+ final XmlPullParser parser = Xml.newPullParser();
+ parser.setInput(in, null);
+ parser.nextTag();
+ readSearchPlugin(parser);
+ }
+
+ private void readSearchPlugin(XmlPullParser parser) throws XmlPullParserException, IOException {
+ if (XmlPullParser.START_TAG != parser.getEventType()) {
+ throw new XmlPullParserException("Expected start tag: " + parser.getPositionDescription());
+ }
+
+ final String name = parser.getName();
+ if (!"SearchPlugin".equals(name) && !"OpenSearchDescription".equals(name)) {
+ throw new XmlPullParserException("Expected <SearchPlugin> or <OpenSearchDescription> as root tag: "
+ + parser.getPositionDescription());
+ }
+
+ while (parser.next() != XmlPullParser.END_TAG) {
+ if (parser.getEventType() != XmlPullParser.START_TAG) {
+ continue;
+ }
+
+ final String tag = parser.getName();
+ if (tag.equals("ShortName")) {
+ readShortName(parser);
+ } else if (tag.equals("Url")) {
+ readUrl(parser);
+ } else if (tag.equals("Image")) {
+ readImage(parser);
+ } else {
+ skip(parser);
+ }
+ }
+ }
+
+ private void readShortName(XmlPullParser parser) throws IOException, XmlPullParserException {
+ parser.require(XmlPullParser.START_TAG, null, "ShortName");
+ if (parser.next() == XmlPullParser.TEXT) {
+ shortName = parser.getText();
+ parser.nextTag();
+ }
+ }
+
+ private void readUrl(XmlPullParser parser) throws XmlPullParserException, IOException {
+ parser.require(XmlPullParser.START_TAG, null, "Url");
+
+ final String type = parser.getAttributeValue(null, "type");
+ final String template = parser.getAttributeValue(null, "template");
+ final String rel = parser.getAttributeValue(null, "rel");
+
+ Uri uri = Uri.parse(template);
+
+ while (parser.next() != XmlPullParser.END_TAG) {
+ if (parser.getEventType() != XmlPullParser.START_TAG) {
+ continue;
+ }
+
+ final String tag = parser.getName();
+
+ if (tag.equals("Param")) {
+ final String name = parser.getAttributeValue(null, "name");
+ final String value = parser.getAttributeValue(null, "value");
+ uri = uri.buildUpon().appendQueryParameter(name, value).build();
+ parser.nextTag();
+ // TODO: Support for other tags
+ //} else if (tag.equals("MozParam")) {
+ } else {
+ skip(parser);
+ }
+ }
+
+ if (type.equals(URLTYPE_SEARCH_HTML)) {
+ // Prefer mobile URIs.
+ if (rel != null && rel.equals(URL_REL_MOBILE)) {
+ resultsUris.add(0, uri);
+ } else {
+ resultsUris.add(uri);
+ }
+ } else if (type.equals(URLTYPE_SUGGEST_JSON)) {
+ suggestUri = uri;
+ }
+ }
+
+ private void readImage(XmlPullParser parser) throws XmlPullParserException, IOException {
+ parser.require(XmlPullParser.START_TAG, null, "Image");
+
+ // TODO: Use width and height to get a preferred icon URL.
+ //final int width = Integer.parseInt(parser.getAttributeValue(null, "width"));
+ //final int height = Integer.parseInt(parser.getAttributeValue(null, "height"));
+
+ if (parser.next() == XmlPullParser.TEXT) {
+ iconURL = parser.getText();
+ parser.nextTag();
+ }
+ }
+
+ private void skip(XmlPullParser parser) throws XmlPullParserException, IOException {
+ if (parser.getEventType() != XmlPullParser.START_TAG) {
+ throw new IllegalStateException();
+ }
+ int depth = 1;
+ while (depth != 0) {
+ switch (parser.next()) {
+ case XmlPullParser.END_TAG:
+ depth--;
+ break;
+ case XmlPullParser.START_TAG:
+ depth++;
+ break;
+ }
+ }
+ }
+
+ /**
+ * HACKS! We'll need to replace this with endpoints that return the correct content.
+ *
+ * Retrieve a JS snippet, in bookmarklet style, that can be used
+ * to modify the results page.
+ */
+ public String getInjectableJs() {
+ final String css;
+
+ if (identifier == null) {
+ css = "";
+ } else if (identifier.equals("bing")) {
+ css = "#mHeader{display:none}#contentWrapper{margin-top:0}";
+ } else if (identifier.equals("google")) {
+ css = "#sfcnt,#top_nav{display:none}";
+ } else if (identifier.equals("yahoo")) {
+ css = "#nav,#header{display:none}";
+ } else {
+ css = "";
+ }
+
+ return String.format(STYLE_INJECTION_SCRIPT, css);
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ public String getName() {
+ return shortName;
+ }
+
+ public String getIconURL() {
+ return iconURL;
+ }
+
+ /**
+ * Finds the search query encoded in a given results URL.
+ *
+ * @param url Current results URL.
+ * @return The search query, or an empty string if a query couldn't be found.
+ */
+ public String queryForResultsUrl(String url) {
+ final Uri resultsUri = getResultsUri();
+ final Set<String> names = StringUtils.getQueryParameterNames(resultsUri);
+ for (String name : names) {
+ if (resultsUri.getQueryParameter(name).matches(OS_PARAM_USER_DEFINED)) {
+ return Uri.parse(url).getQueryParameter(name);
+ }
+ }
+ return "";
+ }
+
+ /**
+ * Create a uri string that can be used to fetch the results page.
+ *
+ * @param query The user's query. This method will escape and encode the query.
+ */
+ public String resultsUriForQuery(String query) {
+ final Uri resultsUri = getResultsUri();
+ if (resultsUri == null) {
+ Log.e(LOG_TAG, "No results URL for search engine: " + shortName);
+ return "";
+ }
+ final String template = Uri.decode(resultsUri.toString());
+ return paramSubstitution(template, Uri.encode(query));
+ }
+
+ /**
+ * Create a uri string to fetch autocomplete suggestions.
+ *
+ * @param query The user's query. This method will escape and encode the query.
+ */
+ public String getSuggestionTemplate(String query) {
+ if (suggestUri == null) {
+ Log.e(LOG_TAG, "No suggestions template for search engine: " + shortName);
+ return "";
+ }
+ final String template = Uri.decode(suggestUri.toString());
+ return paramSubstitution(template, Uri.encode(query));
+ }
+
+ /**
+ * @return Preferred results URI.
+ */
+ private Uri getResultsUri() {
+ if (resultsUris.isEmpty()) {
+ return null;
+ }
+ return resultsUris.get(0);
+ }
+
+ /**
+ * Formats template string with proper parameters. Modeled after
+ * ParamSubstitution in nsSearchService.js
+ *
+ * @param template
+ * @param query
+ * @return
+ */
+ private String paramSubstitution(String template, String query) {
+ final String locale = Locale.getDefault().toString();
+
+ template = template.replaceAll(MOZ_PARAM_LOCALE, locale);
+ template = template.replaceAll(MOZ_PARAM_DIST_ID, "");
+ template = template.replaceAll(MOZ_PARAM_OFFICIAL, "unofficial");
+
+ template = template.replaceAll(OS_PARAM_USER_DEFINED, query);
+ template = template.replaceAll(OS_PARAM_INPUT_ENCODING, "UTF-8");
+
+ template = template.replaceAll(OS_PARAM_LANGUAGE, locale);
+ template = template.replaceAll(OS_PARAM_OUTPUT_ENCODING, "UTF-8");
+
+ // Replace any optional parameters
+ template = template.replaceAll(OS_PARAM_OPTIONAL, "");
+
+ return template;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java
new file mode 100644
index 000000000..4b33db40a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/search/SearchEngineManager.java
@@ -0,0 +1,764 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.search;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+import android.support.annotation.WorkerThread;
+import android.text.TextUtils;
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.RawResource;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.lang.ref.WeakReference;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Locale;
+
+/**
+ * This class is not thread-safe, except where otherwise noted.
+ *
+ * This class contains a reference to {@link Context} - DO NOT LEAK!
+ */
+public class SearchEngineManager implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String LOG_TAG = "GeckoSearchEngineManager";
+
+ // Gecko pref that defines the name of the default search engine.
+ private static final String PREF_GECKO_DEFAULT_ENGINE = "browser.search.defaultenginename";
+
+ // Gecko pref that defines the name of the default searchplugin locale.
+ private static final String PREF_GECKO_DEFAULT_LOCALE = "distribution.searchplugins.defaultLocale";
+
+ // Key for shared preference that stores default engine name.
+ private static final String PREF_DEFAULT_ENGINE_KEY = "search.engines.defaultname";
+
+ // Key for shared preference that stores search region.
+ private static final String PREF_REGION_KEY = "search.region";
+
+ // URL for the geo-ip location service. Keep in sync with "browser.search.geoip.url" perference in Gecko.
+ private static final String GEOIP_LOCATION_URL = "https://location.services.mozilla.com/v1/country?key=" + AppConstants.MOZ_MOZILLA_API_KEY;
+
+ // This should go through GeckoInterface to get the UA, but the search activity
+ // doesn't use a GeckoView yet. Until it does, get the UA directly.
+ private static final String USER_AGENT = HardwareUtils.isTablet() ?
+ AppConstants.USER_AGENT_FENNEC_TABLET : AppConstants.USER_AGENT_FENNEC_MOBILE;
+
+ private final Context context;
+ private final Distribution distribution;
+ @Nullable private volatile SearchEngineCallback changeCallback;
+ @Nullable private volatile SearchEngine engine;
+
+ // Cached version of default locale included in Gecko chrome manifest.
+ // This should only be accessed from the background thread.
+ private String fallbackLocale;
+
+ // Cached version of default locale included in Distribution preferences.
+ // This should only be accessed from the background thread.
+ private String distributionLocale;
+
+ public static interface SearchEngineCallback {
+ public void execute(@Nullable SearchEngine engine);
+ }
+
+ public SearchEngineManager(Context context, Distribution distribution) {
+ this.context = context;
+ this.distribution = distribution;
+ GeckoSharedPrefs.forApp(context).registerOnSharedPreferenceChangeListener(this);
+ }
+
+ /**
+ * Sets a callback to be called when the default engine changes. This can be called from any thread.
+ *
+ * @param changeCallback SearchEngineCallback to be called after the search engine
+ * changed. This will run on the UI thread.
+ * Note: callback may be called with null engine.
+ */
+ public void setChangeCallback(SearchEngineCallback changeCallback) {
+ this.changeCallback = changeCallback;
+ }
+
+ /**
+ * Perform an action with the user's default search engine. This can be called from any thread.
+ *
+ * @param callback The callback to be used with the user's default search engine. The call
+ * may be sync or async; if the call is async, it will be called on the
+ * ui thread.
+ */
+ public void getEngine(SearchEngineCallback callback) {
+ if (engine != null) {
+ callback.execute(engine);
+ } else {
+ getDefaultEngine(callback);
+ }
+ }
+
+ /**
+ * Should be called when the object goes out of scope.
+ */
+ public void unregisterListeners() {
+ GeckoSharedPrefs.forApp(context).unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ private volatile int ignorePreferenceChange = 0;
+
+ @UiThread // according to the docs.
+ @Override
+ public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
+ if (!TextUtils.equals(PREF_DEFAULT_ENGINE_KEY, key)) {
+ return;
+ }
+
+ if (ignorePreferenceChange > 0) {
+ ignorePreferenceChange--;
+ return;
+ }
+
+ getDefaultEngine(changeCallback);
+ }
+
+ /**
+ * Runs a SearchEngineCallback on the main thread.
+ */
+ private void runCallback(final SearchEngine engine, @Nullable final SearchEngineCallback callback) {
+ ThreadUtils.postToUiThread(new RunCallbackUiThreadRunnable(this, engine, callback));
+ }
+
+ // Static is not strictly necessary but the outer class has a reference to Context so we should GC ASAP.
+ private static class RunCallbackUiThreadRunnable implements Runnable {
+ private final WeakReference<SearchEngineManager> searchEngineManagerWeakReference;
+ private final SearchEngine searchEngine;
+ private final SearchEngineCallback callback;
+
+ public RunCallbackUiThreadRunnable(final SearchEngineManager searchEngineManager, final SearchEngine searchEngine,
+ final SearchEngineCallback callback) {
+ this.searchEngineManagerWeakReference = new WeakReference<>(searchEngineManager);
+ this.searchEngine = searchEngine;
+ this.callback = callback;
+ }
+
+ @UiThread
+ @Override
+ public void run() {
+ final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get();
+ if (searchEngineManager == null) {
+ return;
+ }
+
+ // Cache engine for future calls to getEngine.
+ searchEngineManager.engine = searchEngine;
+ if (callback != null) {
+ callback.execute(searchEngine);
+ }
+
+ }
+ }
+
+ /**
+ * This method finds and creates the default search engine. It will first look for
+ * the default engine name, then create the engine from that name.
+ *
+ * To find the default engine name, we first look in shared preferences, then
+ * the distribution (if one exists), and finally fall back to the localized default.
+ *
+ * @param callback SearchEngineCallback to be called after successfully looking
+ * up the search engine. This will run on the UI thread.
+ * Note: callback may be called with null engine.
+ */
+ private void getDefaultEngine(final SearchEngineCallback callback) {
+ // This runnable is posted to the background thread.
+ distribution.addOnDistributionReadyCallback(new GetDefaultEngineDistributionCallbacks(this, callback));
+ }
+
+ // Static is not strictly necessary but the outer class contains a reference to Context so we should GC ASAP.
+ private static class GetDefaultEngineDistributionCallbacks implements Distribution.ReadyCallback {
+ private final WeakReference<SearchEngineManager> searchEngineManagerWeakReference;
+ private final SearchEngineCallback callback;
+
+ public GetDefaultEngineDistributionCallbacks(final SearchEngineManager searchEngineManager,
+ final SearchEngineCallback callback) {
+ this.searchEngineManagerWeakReference = new WeakReference<>(searchEngineManager);
+ this.callback = callback;
+ }
+
+ @Override
+ public void distributionNotFound() {
+ defaultBehavior();
+ }
+
+ @Override
+ public void distributionFound(Distribution distribution) {
+ defaultBehavior();
+ }
+
+ @Override
+ public void distributionArrivedLate(Distribution distribution) {
+ final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get();
+ if (searchEngineManager == null) {
+ return;
+ }
+
+ // Let's see if there's a name in the distro.
+ // If so, just this once we'll override the saved value.
+ final String name = searchEngineManager.getDefaultEngineNameFromDistribution();
+
+ if (name == null) {
+ return;
+ }
+
+ // Store the default engine name for the future.
+ // Increment an 'ignore' counter so that this preference change
+ // won't cause getDefaultEngine to be called again.
+ searchEngineManager.ignorePreferenceChange++;
+ GeckoSharedPrefs.forApp(searchEngineManager.context)
+ .edit()
+ .putString(PREF_DEFAULT_ENGINE_KEY, name)
+ .apply();
+
+ final SearchEngine engine = searchEngineManager.createEngineFromName(name);
+ searchEngineManager.runCallback(engine, callback);
+ }
+
+ @WorkerThread // calling methods are @WorkerThread
+ private void defaultBehavior() {
+ final SearchEngineManager searchEngineManager = searchEngineManagerWeakReference.get();
+ if (searchEngineManager == null) {
+ return;
+ }
+
+ // First look for a default name stored in shared preferences.
+ String name = GeckoSharedPrefs.forApp(searchEngineManager.context).getString(PREF_DEFAULT_ENGINE_KEY, null);
+
+ // Check for a region stored in shared preferences. If we don't have a region,
+ // we should force a recheck of the default engine.
+ String region = GeckoSharedPrefs.forApp(searchEngineManager.context).getString(PREF_REGION_KEY, null);
+
+ if (name != null && region != null) {
+ Log.d(LOG_TAG, "Found default engine name in SharedPreferences: " + name);
+ } else {
+ // First, look for the default search engine in a distribution.
+ name = searchEngineManager.getDefaultEngineNameFromDistribution();
+ if (name == null) {
+ // Otherwise, get the default engine that we ship.
+ name = searchEngineManager.getDefaultEngineNameFromLocale();
+ }
+
+ // Store the default engine name for the future.
+ // Increment an 'ignore' counter so that this preference change
+ // won't cause getDefaultEngine to be called again.
+ searchEngineManager.ignorePreferenceChange++;
+ GeckoSharedPrefs.forApp(searchEngineManager.context)
+ .edit()
+ .putString(PREF_DEFAULT_ENGINE_KEY, name)
+ .apply();
+ }
+
+ final SearchEngine engine = searchEngineManager.createEngineFromName(name);
+ searchEngineManager.runCallback(engine, callback);
+ }
+ }
+
+ /**
+ * Looks for a default search engine included in a distribution.
+ * This method must be called after the distribution is ready.
+ *
+ * @return search engine name.
+ */
+ private String getDefaultEngineNameFromDistribution() {
+ if (!distribution.exists()) {
+ return null;
+ }
+
+ final File prefFile = distribution.getDistributionFile("preferences.json");
+ if (prefFile == null) {
+ return null;
+ }
+
+ try {
+ final JSONObject all = FileUtils.readJSONObjectFromFile(prefFile);
+
+ // First, look for a default locale specified by the distribution.
+ if (all.has("Preferences")) {
+ final JSONObject prefs = all.getJSONObject("Preferences");
+ if (prefs.has(PREF_GECKO_DEFAULT_LOCALE)) {
+ Log.d(LOG_TAG, "Found default searchplugin locale in distribution Preferences.");
+ distributionLocale = prefs.getString(PREF_GECKO_DEFAULT_LOCALE);
+ }
+ }
+
+ // Then, check to see if there's a locale-specific default engine override.
+ final String languageTag = Locales.getLanguageTag(Locale.getDefault());
+ final String overridesKey = "LocalizablePreferences." + languageTag;
+ if (all.has(overridesKey)) {
+ final JSONObject overridePrefs = all.getJSONObject(overridesKey);
+ if (overridePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) {
+ Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences override.");
+ return overridePrefs.getString(PREF_GECKO_DEFAULT_ENGINE);
+ }
+ }
+
+ // Next, check to see if there's a non-override default engine pref.
+ if (all.has("LocalizablePreferences")) {
+ final JSONObject localizablePrefs = all.getJSONObject("LocalizablePreferences");
+ if (localizablePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) {
+ Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences.");
+ return localizablePrefs.getString(PREF_GECKO_DEFAULT_ENGINE);
+ }
+ }
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Error getting search engine name from preferences.json", e);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Error parsing preferences.json", e);
+ }
+ return null;
+ }
+
+ /**
+ * Helper function for converting an InputStream to a String.
+ * @param is InputStream you want to convert to a String
+ *
+ * @return String containing the data
+ */
+ private String getHttpResponse(HttpURLConnection conn) {
+ InputStream is = null;
+ try {
+ is = new BufferedInputStream(conn.getInputStream());
+ return new java.util.Scanner(is).useDelimiter("\\A").next();
+ } catch (Exception e) {
+ return "";
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Error closing InputStream", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the country code based on the current IP, using the Mozilla Location Service.
+ * We cache the country code in a shared preference, so we only fetch from the network
+ * once.
+ *
+ * @return String containing the country code
+ */
+ private String fetchCountryCode() {
+ // First, we look to see if we have a cached code.
+ final String region = GeckoSharedPrefs.forApp(context).getString(PREF_REGION_KEY, null);
+ if (region != null) {
+ return region;
+ }
+
+ // Since we didn't have a cached code, we need to fetch a code from the service.
+ try {
+ String responseText = null;
+
+ URL url = new URL(GEOIP_LOCATION_URL);
+ HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
+ try {
+ // POST an empty JSON object.
+ final String message = "{}";
+
+ urlConnection.setDoOutput(true);
+ urlConnection.setConnectTimeout(10000);
+ urlConnection.setReadTimeout(10000);
+ urlConnection.setRequestMethod("POST");
+ urlConnection.setRequestProperty("User-Agent", USER_AGENT);
+ urlConnection.setRequestProperty("Content-Type", "application/json");
+ urlConnection.setFixedLengthStreamingMode(message.getBytes().length);
+
+ final OutputStream out = urlConnection.getOutputStream();
+ out.write(message.getBytes());
+ out.close();
+
+ responseText = getHttpResponse(urlConnection);
+ } finally {
+ urlConnection.disconnect();
+ }
+
+ if (responseText == null) {
+ Log.e(LOG_TAG, "Country code fetch failed");
+ return null;
+ }
+
+ // Extract the country code and save it for later in a cache.
+ final JSONObject response = new JSONObject(responseText);
+ return response.optString("country_code", null);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Country code fetch failed", e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Looks for the default search engine shipped in the locale.
+ *
+ * @return search engine name.
+ */
+ private String getDefaultEngineNameFromLocale() {
+ try {
+ final JSONObject browsersearch = new JSONObject(RawResource.getAsString(context, R.raw.browsersearch));
+
+ // Get the region used to fence search engines.
+ String region = fetchCountryCode();
+
+ // Store the result, even if it's empty. If we fail to get a region, we never
+ // try to get it again, and we will always fallback to the non-region engine.
+ GeckoSharedPrefs.forApp(context)
+ .edit()
+ .putString(PREF_REGION_KEY, (region == null ? "" : region))
+ .apply();
+
+ if (region != null) {
+ if (browsersearch.has("regions")) {
+ final JSONObject regions = browsersearch.getJSONObject("regions");
+ if (regions.has(region)) {
+ final JSONObject regionData = regions.getJSONObject(region);
+ Log.d(LOG_TAG, "Found region-specific default engine name in browsersearch.json.");
+ return regionData.getString("default");
+ }
+ }
+ }
+
+ // Either we have no geoip region, or we didn't find the right region and we are falling back to the default.
+ if (browsersearch.has("default")) {
+ Log.d(LOG_TAG, "Found default engine name in browsersearch.json.");
+ return browsersearch.getString("default");
+ }
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Error getting search engine name from browsersearch.json", e);
+ } catch (JSONException e) {
+ Log.e(LOG_TAG, "Error parsing browsersearch.json", e);
+ }
+ return null;
+ }
+
+ /**
+ * Creates a SearchEngine instance from an engine name.
+ *
+ * To create the engine, we first try to find the search plugin in the distribution
+ * (if one exists), followed by the localized plugins we ship with the browser, and
+ * then finally third-party plugins that are installed in the profile directory.
+ *
+ * This method must be called after the distribution is ready.
+ *
+ * @param name The search engine name (e.g. "Google" or "Amazon.com")
+ * @return SearchEngine instance for name.
+ */
+ private SearchEngine createEngineFromName(String name) {
+ // First, look in the distribution.
+ SearchEngine engine = createEngineFromDistribution(name);
+
+ // Second, look in the jar for plugins shipped with the locale.
+ if (engine == null) {
+ engine = createEngineFromLocale(name);
+ }
+
+ // Finally, look in the profile for third-party plugins.
+ if (engine == null) {
+ engine = createEngineFromProfile(name);
+ }
+
+ if (engine == null) {
+ Log.e(LOG_TAG, "Could not create search engine from name: " + name);
+ }
+
+ return engine;
+ }
+
+ /**
+ * Creates a SearchEngine instance for a distribution search plugin.
+ *
+ * This method iterates through the distribution searchplugins directory,
+ * creating SearchEngine instances until it finds one with the right name.
+ *
+ * This method must be called after the distribution is ready.
+ *
+ * @param name Search engine name.
+ * @return SearchEngine instance for name.
+ */
+ private SearchEngine createEngineFromDistribution(String name) {
+ if (!distribution.exists()) {
+ return null;
+ }
+
+ final File pluginsDir = distribution.getDistributionFile("searchplugins");
+ if (pluginsDir == null) {
+ return null;
+ }
+
+ // Collect an array of files to scan using the same approach as
+ // DirectoryService._appendDistroSearchDirs which states:
+ // Common engines are loaded for all locales. If there is no locale directory for
+ // the current locale, there is a pref: "distribution.searchplugins.defaultLocale",
+ // which specifies a default locale to use.
+ ArrayList<File> files = new ArrayList<>();
+
+ // Load files from the common folder first
+ final File[] commonFiles = (new File(pluginsDir, "common")).listFiles();
+ if (commonFiles != null) {
+ Collections.addAll(files, commonFiles);
+ }
+
+ // Next, check to see if there's a locale-specific override.
+ final File localeDir = new File(pluginsDir, "locale");
+ if (localeDir != null) {
+ final String languageTag = Locales.getLanguageTag(Locale.getDefault());
+ final File[] localeFiles = (new File(localeDir, languageTag)).listFiles();
+ if (localeFiles != null) {
+ Collections.addAll(files, localeFiles);
+ } else {
+ // We didn't append the locale dir - try the default one.
+ if (distributionLocale != null) {
+ final File[] defaultLocaleFiles = (new File(localeDir, distributionLocale)).listFiles();
+ if (defaultLocaleFiles != null) {
+ Collections.addAll(files, defaultLocaleFiles);
+ }
+ }
+ }
+ }
+
+ if (files.isEmpty()) {
+ Log.e(LOG_TAG, "Could not find search plugin files in distribution directory");
+ return null;
+ }
+
+ return createEngineFromFileList(files.toArray(new File[files.size()]), name);
+ }
+
+ /**
+ * Creates a SearchEngine instance for a search plugin shipped in the locale.
+ *
+ * This method reads the list of search plugin file names from list.txt, then
+ * iterates through the files, creating SearchEngine instances until it finds one
+ * with the right name. Unfortunately, we need to do this because there is no
+ * other way to map the search engine "name" to the file for the search plugin.
+ *
+ * @param name Search engine name.
+ * @return SearchEngine instance for name.
+ */
+ private SearchEngine createEngineFromLocale(String name) {
+ final InputStream in = getInputStreamFromSearchPluginsJar("list.txt");
+ if (in == null) {
+ return null;
+ }
+ final BufferedReader br = getBufferedReader(in);
+
+ try {
+ String identifier;
+ while ((identifier = br.readLine()) != null) {
+ final InputStream pluginIn = getInputStreamFromSearchPluginsJar(identifier + ".xml");
+ // pluginIn can be null if the xml file doesn't exist which
+ // can happen with :hidden plugins
+ if (pluginIn != null) {
+ final SearchEngine engine = createEngineFromInputStream(identifier, pluginIn);
+ if (engine != null && engine.getName().equals(name)) {
+ return engine;
+ }
+ }
+ }
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Error creating shipped search engine from name: " + name, e);
+ } finally {
+ try {
+ br.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Creates a SearchEngine instance for a search plugin in the profile directory.
+ *
+ * This method iterates through the profile searchplugins directory, creating
+ * SearchEngine instances until it finds one with the right name.
+ *
+ * @param name Search engine name.
+ * @return SearchEngine instance for name.
+ */
+ private SearchEngine createEngineFromProfile(String name) {
+ final File pluginsDir = GeckoProfile.get(context).getFile("searchplugins");
+ if (pluginsDir == null) {
+ return null;
+ }
+
+ final File[] files = pluginsDir.listFiles();
+ if (files == null) {
+ Log.e(LOG_TAG, "Could not find search plugin files in profile directory");
+ return null;
+ }
+ return createEngineFromFileList(files, name);
+ }
+
+ /**
+ * This method iterates through an array of search plugin files, creating
+ * SearchEngine instances until it finds one with the right name.
+ *
+ * @param files Array of search plugin files. Should not be null.
+ * @param name Search engine name.
+ * @return SearchEngine instance for name.
+ */
+ private SearchEngine createEngineFromFileList(File[] files, String name) {
+ for (int i = 0; i < files.length; i++) {
+ try {
+ final FileInputStream fis = new FileInputStream(files[i]);
+ final SearchEngine engine = createEngineFromInputStream(null, fis);
+ if (engine != null && engine.getName().equals(name)) {
+ return engine;
+ }
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Error creating search engine from name: " + name, e);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Creates a SearchEngine instance from an InputStream.
+ *
+ * This method closes the stream after it is done reading it.
+ *
+ * @param identifier Seach engine identifier. This only exists for search engines that
+ * ship with the default set of engines in the locale.
+ * @param in InputStream for search plugin XML file.
+ * @return SearchEngine instance.
+ */
+ private SearchEngine createEngineFromInputStream(String identifier, InputStream in) {
+ try {
+ try {
+ return new SearchEngine(identifier, in);
+ } finally {
+ in.close();
+ }
+ } catch (Exception e) {
+ Log.e(LOG_TAG, "Exception creating search engine", e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Reads a file from the searchplugins directory in the Gecko jar.
+ *
+ * @param fileName name of the file to read.
+ * @return InputStream for file.
+ */
+ private InputStream getInputStreamFromSearchPluginsJar(String fileName) {
+ final Locale locale = Locale.getDefault();
+
+ // First, try a file path for the full locale.
+ final String languageTag = Locales.getLanguageTag(locale);
+ String url = getSearchPluginsJarURL(context, languageTag, fileName);
+
+ InputStream in = GeckoJarReader.getStream(context, url);
+ if (in != null) {
+ return in;
+ }
+
+ // If that doesn't work, try a file path for just the language.
+ final String language = Locales.getLanguage(locale);
+ if (!languageTag.equals(language)) {
+ url = getSearchPluginsJarURL(context, language, fileName);
+ in = GeckoJarReader.getStream(context, url);
+ if (in != null) {
+ return in;
+ }
+ }
+
+ // Finally, fall back to default locale defined in chrome registry.
+ url = getSearchPluginsJarURL(context, getFallbackLocale(), fileName);
+ return GeckoJarReader.getStream(context, url);
+ }
+
+ /**
+ * Finds a fallback locale in the Gecko chrome registry. If a locale is declared
+ * here, we should be guaranteed to find a searchplugins directory for it.
+ *
+ * This method should only be accessed from the background thread.
+ */
+ private String getFallbackLocale() {
+ if (fallbackLocale != null) {
+ return fallbackLocale;
+ }
+
+ final InputStream in = GeckoJarReader.getStream(
+ context, GeckoJarReader.getJarURL(context, "chrome/chrome.manifest"));
+ if (in == null) {
+ return null;
+ }
+ final BufferedReader br = getBufferedReader(in);
+
+ try {
+ String line;
+ while ((line = br.readLine()) != null) {
+ // We're looking for a line like "locale global en-US en-US/locale/en-US/global/"
+ // https://developer.mozilla.org/en/docs/Chrome_Registration#locale
+ if (line.startsWith("locale global ")) {
+ fallbackLocale = line.split(" ", 4)[2];
+ break;
+ }
+ }
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Error reading fallback locale from chrome registry", e);
+ } finally {
+ try {
+ br.close();
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ return fallbackLocale;
+ }
+
+ /**
+ * Gets the jar URL for a file in the searchplugins directory.
+ *
+ * @param locale String representing the Gecko locale (e.g. "en-US").
+ * @param fileName The name of the file to read.
+ * @return URL for jar file.
+ */
+ private static String getSearchPluginsJarURL(Context context, String locale, String fileName) {
+ final String path = "chrome/" + locale + "/locale/" + locale + "/browser/searchplugins/" + fileName;
+ return GeckoJarReader.getJarURL(context, path);
+ }
+
+ private BufferedReader getBufferedReader(InputStream in) {
+ try {
+ return new BufferedReader(new InputStreamReader(in, "UTF-8"));
+ } catch (UnsupportedEncodingException e) {
+ // Cannot happen.
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
new file mode 100644
index 000000000..667eb8f6c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueHelper.java
@@ -0,0 +1,357 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.content.ContextCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.view.WindowManager;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class TabQueueHelper {
+ private static final String LOGTAG = "Gecko" + TabQueueHelper.class.getSimpleName();
+
+ // Disable Tab Queue for API level 10 (GB) - Bug 1206055
+ public static final boolean TAB_QUEUE_ENABLED = true;
+
+ public static final String FILE_NAME = "tab_queue_url_list.json";
+ public static final String LOAD_URLS_ACTION = "TAB_QUEUE_LOAD_URLS_ACTION";
+ public static final int TAB_QUEUE_NOTIFICATION_ID = R.id.tabQueueNotification;
+
+ public static final String PREF_TAB_QUEUE_COUNT = "tab_queue_count";
+ public static final String PREF_TAB_QUEUE_LAUNCHES = "tab_queue_launches";
+ public static final String PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN = "tab_queue_times_prompt_shown";
+
+ public static final int MAX_TIMES_TO_SHOW_PROMPT = 3;
+ public static final int EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT = 3;
+
+ // result codes for returning from the prompt
+ public static final int TAB_QUEUE_YES = 201;
+ public static final int TAB_QUEUE_NO = 202;
+
+ /**
+ * Checks if the specified context can draw on top of other apps. As of API level 23, an app
+ * cannot draw on top of other apps unless it declares the SYSTEM_ALERT_WINDOW permission in
+ * its manifest, AND the user specifically grants the app this capability.
+ *
+ * @return true if the specified context can draw on top of other apps, false otherwise.
+ */
+ public static boolean canDrawOverlays(Context context) {
+ if (AppConstants.Versions.preMarshmallow) {
+ return true; // We got the permission at install time.
+ }
+
+ // It would be nice to just use Settings.canDrawOverlays() - but this helper is buggy for
+ // apps using sharedUserId (See bug 1244722).
+ // Instead we'll add and remove an invisible view. If this is successful then we seem to
+ // have permission to draw overlays.
+
+ View view = new View(context);
+ view.setVisibility(View.INVISIBLE);
+
+ WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
+ 1, 1,
+ WindowManager.LayoutParams.TYPE_PHONE,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ PixelFormat.TRANSLUCENT);
+
+ WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+
+ try {
+ windowManager.addView(view, layoutParams);
+ windowManager.removeView(view);
+ return true;
+ } catch (final SecurityException | WindowManager.BadTokenException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Check if we should show the tab queue prompt
+ *
+ * @param context
+ * @return true if we should display the prompt, false if not.
+ */
+ public static boolean shouldShowTabQueuePrompt(Context context) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+
+ int numberOfTimesTabQueuePromptSeen = prefs.getInt(PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0);
+
+ // Exit early if the feature is already enabled or the user has seen the
+ // prompt more than MAX_TIMES_TO_SHOW_PROMPT times.
+ if (isTabQueueEnabled(prefs) || numberOfTimesTabQueuePromptSeen >= MAX_TIMES_TO_SHOW_PROMPT) {
+ return false;
+ }
+
+ final int viewActionIntentLaunches = prefs.getInt(PREF_TAB_QUEUE_LAUNCHES, 0) + 1;
+ if (viewActionIntentLaunches < EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) {
+ // Allow a few external links to open before we prompt the user.
+ prefs.edit().putInt(PREF_TAB_QUEUE_LAUNCHES, viewActionIntentLaunches).apply();
+ } else if (viewActionIntentLaunches == EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT) {
+ // Reset to avoid repeatedly showing the prompt if the user doesn't interact with it and
+ // we get more external VIEW action intents in.
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.remove(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES);
+
+ int timesPromptShown = prefs.getInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, 0) + 1;
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN, timesPromptShown);
+ editor.apply();
+
+ // Show the prompt
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Reads file and converts any content to JSON, adds passed in URL to the data and writes back to the file,
+ * creating the file if it doesn't already exist. This should not be run on the UI thread.
+ *
+ * @param profile
+ * @param url URL to add
+ * @param filename filename to add URL to
+ * @return the number of tabs currently queued
+ */
+ public static int queueURL(final GeckoProfile profile, final String url, final String filename) {
+ ThreadUtils.assertNotOnUiThread();
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+
+ jsonArray.put(url);
+
+ profile.writeFile(filename, jsonArray.toString());
+
+ return jsonArray.length();
+ }
+
+ /**
+ * Remove a url from the file, if it exists.
+ * If the url exists multiple times, all instances of it will be removed.
+ * This should not be run on the UI thread.
+ *
+ * @param context
+ * @param urlToRemove URL to remove
+ * @param filename filename to remove URL from
+ * @return the number of queued urls
+ */
+ public static int removeURLFromFile(final Context context, final String urlToRemove, final String filename) {
+ ThreadUtils.assertNotOnUiThread();
+
+ final GeckoProfile profile = GeckoProfile.get(context);
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+ JSONArray newArray = new JSONArray();
+ String url;
+
+ // Since JSONArray.remove was only added in API 19, we have to use two arrays in order to remove.
+ for (int i = 0; i < jsonArray.length(); i++) {
+ try {
+ url = jsonArray.getString(i);
+ } catch (JSONException e) {
+ url = "";
+ }
+ if (!TextUtils.isEmpty(url) && !urlToRemove.equals(url)) {
+ newArray.put(url);
+ }
+ }
+
+ profile.writeFile(filename, newArray.toString());
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ prefs.edit().putInt(PREF_TAB_QUEUE_COUNT, newArray.length()).apply();
+
+ return newArray.length();
+ }
+
+ /**
+ * Get up to eight of the last queued URLs for displaying in the notification.
+ */
+ public static List<String> getLastURLs(final Context context, final String filename) {
+ final GeckoProfile profile = GeckoProfile.get(context);
+ final JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+ final List<String> urls = new ArrayList<>(8);
+
+ for (int i = 0; i < 8; i++) {
+ try {
+ urls.add(jsonArray.getString(i));
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Unable to parse URL from tab queue array", e);
+ }
+ }
+
+ return urls;
+ }
+
+ /**
+ * Displays a notification showing the total number of tabs queue. If there is already a notification displayed, it
+ * will be replaced.
+ *
+ * @param context
+ * @param tabsQueued
+ */
+ public static void showNotification(final Context context, final int tabsQueued, final List<String> urls) {
+ ThreadUtils.assertNotOnUiThread();
+
+ Intent resultIntent = new Intent();
+ resultIntent.setClassName(context, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ resultIntent.setAction(TabQueueHelper.LOAD_URLS_ACTION);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, resultIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ final String text;
+ final Resources resources = context.getResources();
+ if (tabsQueued == 1) {
+ text = resources.getString(R.string.tab_queue_notification_text_singular);
+ } else {
+ text = resources.getString(R.string.tab_queue_notification_text_plural, tabsQueued);
+ }
+
+ NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle();
+ inboxStyle.setBigContentTitle(text);
+ for (String url : urls) {
+ inboxStyle.addLine(url);
+ }
+ inboxStyle.setSummaryText(resources.getString(R.string.tab_queue_notification_title));
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentTitle(text)
+ .setContentText(resources.getString(R.string.tab_queue_notification_title))
+ .setStyle(inboxStyle)
+ .setColor(ContextCompat.getColor(context, R.color.fennec_ui_orange))
+ .setNumber(tabsQueued)
+ .setContentIntent(pendingIntent);
+
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(TabQueueHelper.TAB_QUEUE_NOTIFICATION_ID, builder.build());
+ }
+
+ public static boolean shouldOpenTabQueueUrls(final Context context) {
+ ThreadUtils.assertNotOnUiThread();
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+
+ int tabsQueued = prefs.getInt(PREF_TAB_QUEUE_COUNT, 0);
+
+ return isTabQueueEnabled(prefs) && tabsQueued > 0;
+ }
+
+ public static int getTabQueueLength(final Context context) {
+ ThreadUtils.assertNotOnUiThread();
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ return prefs.getInt(PREF_TAB_QUEUE_COUNT, 0);
+ }
+
+ public static void openQueuedUrls(final Context context, final GeckoProfile profile, final String filename, boolean shouldPerformJavaScriptCallback) {
+ ThreadUtils.assertNotOnUiThread();
+
+ removeNotification(context);
+
+ // exit early if we don't have any tabs queued
+ if (getTabQueueLength(context) < 1) {
+ return;
+ }
+
+ JSONArray jsonArray = profile.readJSONArrayFromFile(filename);
+
+ if (jsonArray.length() > 0) {
+ JSONObject data = new JSONObject();
+ try {
+ data.put("urls", jsonArray);
+ data.put("shouldNotifyTabsOpenedToJava", shouldPerformJavaScriptCallback);
+ GeckoAppShell.notifyObservers("Tabs:OpenMultiple", data.toString());
+ } catch (JSONException e) {
+ // Don't exit early as we perform cleanup at the end of this function.
+ Log.e(LOGTAG, "Error sending tab queue data", e);
+ }
+ }
+
+ try {
+ profile.deleteFileFromProfileDir(filename);
+ } catch (IllegalArgumentException e) {
+ Log.e(LOGTAG, "Error deleting Tab Queue data file.", e);
+ }
+
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ prefs.edit().remove(PREF_TAB_QUEUE_COUNT).apply();
+ }
+
+ protected static void removeNotification(Context context) {
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(TAB_QUEUE_NOTIFICATION_ID);
+ }
+
+ public static boolean processTabQueuePromptResponse(int resultCode, Context context) {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ switch (resultCode) {
+ case TAB_QUEUE_YES:
+ editor.putBoolean(GeckoPreferences.PREFS_TAB_QUEUE, true);
+
+ // By making this one more than EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT we ensure the prompt
+ // will never show again without having to keep track of an extra pref.
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES,
+ TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1);
+ break;
+
+ case TAB_QUEUE_NO:
+ // The user clicked the 'no' button, so let's make sure the user never sees the prompt again by
+ // maxing out the pref used to count the VIEW action intents received and times they've seen the prompt.
+
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_LAUNCHES,
+ TabQueueHelper.EXTERNAL_LAUNCHES_BEFORE_SHOWING_PROMPT + 1);
+
+ editor.putInt(TabQueueHelper.PREF_TAB_QUEUE_TIMES_PROMPT_SHOWN,
+ TabQueueHelper.MAX_TIMES_TO_SHOW_PROMPT + 1);
+ break;
+
+ default:
+ // We shouldn't ever get here.
+ Log.w(LOGTAG, "Unrecognized result code received from the tab queue prompt: " + resultCode);
+ }
+
+ editor.apply();
+
+ return resultCode == TAB_QUEUE_YES;
+ }
+
+ public static boolean isTabQueueEnabled(Context context) {
+ return isTabQueueEnabled(GeckoSharedPrefs.forApp(context));
+ }
+
+ public static boolean isTabQueueEnabled(SharedPreferences prefs) {
+ return prefs.getBoolean(GeckoPreferences.PREFS_TAB_QUEUE, false);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java
new file mode 100644
index 000000000..ead16ccba
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueuePrompt.java
@@ -0,0 +1,215 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabqueue;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.Toast;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+
+public class TabQueuePrompt extends Locales.LocaleAwareActivity {
+ public static final String LOGTAG = "Gecko" + TabQueuePrompt.class.getSimpleName();
+
+ private static final int SETTINGS_REQUEST_CODE = 1;
+
+ // Flag set during animation to prevent animation multiple-start.
+ private boolean isAnimating;
+
+ private View containerView;
+ private View buttonContainer;
+ private View enabledConfirmation;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ showTabQueueEnablePrompt();
+ }
+
+ private void showTabQueueEnablePrompt() {
+ setContentView(R.layout.tab_queue_prompt);
+
+ final View okButton = findViewById(R.id.ok_button);
+ okButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onConfirmButtonPressed();
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes");
+ }
+ });
+ findViewById(R.id.cancel_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_no");
+ setResult(TabQueueHelper.TAB_QUEUE_NO);
+ finish();
+ }
+ });
+ final View settingsButton = findViewById(R.id.settings_button);
+ settingsButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onSettingsButtonPressed();
+ }
+ });
+
+ final View tipView = findViewById(R.id.tip_text);
+ final View settingsPermitView = findViewById(R.id.settings_permit_text);
+
+ if (TabQueueHelper.canDrawOverlays(this)) {
+ okButton.setVisibility(View.VISIBLE);
+ settingsButton.setVisibility(View.GONE);
+ tipView.setVisibility(View.VISIBLE);
+ settingsPermitView.setVisibility(View.GONE);
+ } else {
+ okButton.setVisibility(View.GONE);
+ settingsButton.setVisibility(View.VISIBLE);
+ tipView.setVisibility(View.GONE);
+ settingsPermitView.setVisibility(View.VISIBLE);
+ }
+
+ containerView = findViewById(R.id.tab_queue_container);
+ buttonContainer = findViewById(R.id.button_container);
+ enabledConfirmation = findViewById(R.id.enabled_confirmation);
+
+ containerView.setTranslationY(500);
+ containerView.setAlpha(0);
+
+ final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0);
+ translateAnimator.setDuration(400);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1);
+ alphaAnimator.setStartDelay(200);
+ alphaAnimator.setDuration(600);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(alphaAnimator, translateAnimator);
+ set.setStartDelay(400);
+
+ set.start();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+
+ private void onConfirmButtonPressed() {
+ enabledConfirmation.setVisibility(View.VISIBLE);
+ enabledConfirmation.setAlpha(0);
+
+ final Animator buttonsAlphaAnimator = ObjectAnimator.ofFloat(buttonContainer, "alpha", 0);
+ buttonsAlphaAnimator.setDuration(300);
+
+ final Animator messagesAlphaAnimator = ObjectAnimator.ofFloat(enabledConfirmation, "alpha", 1);
+ messagesAlphaAnimator.setDuration(300);
+ messagesAlphaAnimator.setStartDelay(200);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(buttonsAlphaAnimator, messagesAlphaAnimator);
+
+ set.addListener(new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ slideOut();
+ setResult(TabQueueHelper.TAB_QUEUE_YES);
+ }
+ }, 1000);
+ }
+ });
+
+ set.start();
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private void onSettingsButtonPressed() {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivityForResult(intent, SETTINGS_REQUEST_CODE);
+
+ Toast.makeText(this, R.string.tab_queue_prompt_permit_drawing_over_apps, Toast.LENGTH_LONG).show();
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode != SETTINGS_REQUEST_CODE) {
+ return;
+ }
+
+ if (TabQueueHelper.canDrawOverlays(this)) {
+ // User granted the permission in Android's settings.
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.BUTTON, "tabqueue_prompt_yes");
+
+ setResult(TabQueueHelper.TAB_QUEUE_YES);
+ finish();
+ }
+ }
+
+ /**
+ * Slide the overlay down off the screen and destroy it.
+ */
+ private void slideOut() {
+ if (isAnimating) {
+ return;
+ }
+
+ isAnimating = true;
+
+ ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight());
+ animator.addListener(new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ finish();
+ }
+
+ });
+ animator.start();
+ }
+
+ /**
+ * Close the dialog if back is pressed.
+ */
+ @Override
+ public void onBackPressed() {
+ slideOut();
+ }
+
+ /**
+ * Close the dialog if the anything that isn't a button is tapped.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ slideOut();
+ return true;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java
new file mode 100644
index 000000000..ebb1bd761
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabQueueService.java
@@ -0,0 +1,342 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+
+import android.annotation.TargetApi;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.IBinder;
+import android.provider.Settings;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+
+/**
+ * On launch this Service displays a View over the currently running process with an action to open the url in Fennec
+ * immediately. If the user takes no action, allowing the runnable to be processed after the specified
+ * timeout (TOAST_TIMEOUT), the url is added to a file which is then read in Fennec on next launch, this allows the
+ * user to quickly queue urls to open without having to open Fennec each time. If the Service receives an Intent whilst
+ * the created View is still active, the old url is immediately processed and the View is re-purposed with the new
+ * Intent data.
+ * <p/>
+ * The SYSTEM_ALERT_WINDOW permission is used to allow us to insert a View from this Service which responds to user
+ * interaction, whilst still allowing whatever is in the background to be seen and interacted with.
+ * <p/>
+ * Using an Activity to do this doesn't seem to work as there's an issue to do with the native android intent resolver
+ * dialog not being hidden when the toast is shown. Using an IntentService instead of a Service doesn't work as
+ * each new Intent received kicks off the IntentService lifecycle anew which means that a new View is created each time,
+ * meaning that we can't quickly queue the current data and re-purpose the View. The asynchronous nature of the
+ * IntentService is another prohibitive factor.
+ * <p/>
+ * General approach taken is similar to the FB chat heads functionality:
+ * http://stackoverflow.com/questions/15975988/what-apis-in-android-is-facebook-using-to-create-chat-heads
+ */
+public class TabQueueService extends Service {
+ private static final String LOGTAG = "Gecko" + TabQueueService.class.getSimpleName();
+
+ private static final long TOAST_TIMEOUT = 3000;
+ private static final long TOAST_DOUBLE_TAP_TIMEOUT_MILLIS = 6000;
+
+ private WindowManager windowManager;
+ private View toastLayout;
+ private Button openNowButton;
+ private Handler tabQueueHandler;
+ private WindowManager.LayoutParams toastLayoutParams;
+ private volatile StopServiceRunnable stopServiceRunnable;
+ private HandlerThread handlerThread;
+ private ExecutorService executorService;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ // Not used
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ executorService = Executors.newSingleThreadExecutor();
+
+ handlerThread = new HandlerThread("TabQueueHandlerThread");
+ handlerThread.start();
+ tabQueueHandler = new Handler(handlerThread.getLooper());
+
+ windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
+
+ LayoutInflater layoutInflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
+ toastLayout = layoutInflater.inflate(R.layout.tab_queue_toast, null);
+
+ final Resources resources = getResources();
+
+ TextView messageView = (TextView) toastLayout.findViewById(R.id.toast_message);
+ messageView.setText(resources.getText(R.string.tab_queue_toast_message));
+
+ openNowButton = (Button) toastLayout.findViewById(R.id.toast_button);
+ openNowButton.setText(resources.getText(R.string.tab_queue_toast_action));
+
+ toastLayoutParams = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_PHONE,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
+ WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH |
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ PixelFormat.TRANSLUCENT);
+
+ toastLayoutParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
+ }
+
+ @Override
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ // If this is a redelivery then lets bypass the entire double tap to open now code as that's a big can of worms,
+ // we also don't expect redeliveries because of the short time window associated with this feature.
+ if (flags != START_FLAG_REDELIVERY) {
+ final Context applicationContext = getApplicationContext();
+ final SharedPreferences sharedPreferences = GeckoSharedPrefs.forApp(applicationContext);
+
+ final String lastUrl = sharedPreferences.getString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, "");
+
+ final SafeIntent safeIntent = new SafeIntent(intent);
+ final String intentUrl = safeIntent.getDataString();
+
+ final long lastRunTime = sharedPreferences.getLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, 0);
+ final boolean isWithinDoubleTapTimeLimit = System.currentTimeMillis() - lastRunTime < TOAST_DOUBLE_TAP_TIMEOUT_MILLIS;
+
+ if (!TextUtils.isEmpty(lastUrl) && lastUrl.equals(intentUrl) && isWithinDoubleTapTimeLimit) {
+ // Background thread because we could do some file IO if we have to remove a url from the list.
+ tabQueueHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ // If there is a runnable around, that means that the previous process hasn't yet completed, so
+ // we will need to prevent it from running and remove the view from the window manager.
+ // If there is no runnable around then the url has already been added to the list, so we'll
+ // need to remove it before proceeding or that url will open multiple times.
+ if (stopServiceRunnable != null) {
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopSelfResult(stopServiceRunnable.getStartId());
+ stopServiceRunnable = null;
+ removeView();
+ } else {
+ TabQueueHelper.removeURLFromFile(applicationContext, intentUrl, TabQueueHelper.FILE_NAME);
+ }
+ openNow(safeIntent.getUnsafe());
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-doubletap");
+ stopSelfResult(startId);
+ }
+ });
+
+ return START_REDELIVER_INTENT;
+ }
+
+ sharedPreferences.edit().putString(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE, intentUrl)
+ .putLong(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME, System.currentTimeMillis())
+ .apply();
+ }
+
+ if (stopServiceRunnable != null) {
+ // If we're already displaying a toast, keep displaying it but store the previous url.
+ // The open button will refer to the most recently opened link.
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopServiceRunnable.run(false);
+ } else {
+ try {
+ windowManager.addView(toastLayout, toastLayoutParams);
+ } catch (final SecurityException | WindowManager.BadTokenException e) {
+ Toast.makeText(this, getText(R.string.tab_queue_toast_message), Toast.LENGTH_SHORT).show();
+ showSettingsNotification();
+ }
+ }
+
+ stopServiceRunnable = new StopServiceRunnable(startId) {
+ @Override
+ public void onRun() {
+ addURLToTabQueue(intent, TabQueueHelper.FILE_NAME);
+ stopServiceRunnable = null;
+ }
+ };
+
+ openNowButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ tabQueueHandler.removeCallbacks(stopServiceRunnable);
+ stopServiceRunnable = null;
+ removeView();
+ openNow(intent);
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.INTENT, "tabqueue-now");
+ stopSelfResult(startId);
+ }
+ });
+
+ tabQueueHandler.postDelayed(stopServiceRunnable, TOAST_TIMEOUT);
+
+ return START_REDELIVER_INTENT;
+ }
+
+ private void openNow(Intent intent) {
+ Intent forwardIntent = new Intent(intent);
+ forwardIntent.setClassName(getApplicationContext(), AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
+ forwardIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(forwardIntent);
+
+ TabQueueHelper.removeNotification(getApplicationContext());
+
+ GeckoSharedPrefs.forApp(getApplicationContext()).edit().remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_SITE)
+ .remove(GeckoPreferences.PREFS_TAB_QUEUE_LAST_TIME)
+ .apply();
+
+ executorService.submit(new Runnable() {
+ @Override
+ public void run() {
+ int queuedTabCount = TabQueueHelper.getTabQueueLength(TabQueueService.this);
+ Telemetry.addToHistogram("FENNEC_TABQUEUE_QUEUESIZE", queuedTabCount);
+ }
+ });
+
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private void showSettingsNotification() {
+ if (AppConstants.Versions.preMarshmallow) {
+ return;
+ }
+
+ final Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ PendingIntent pendingIntent = PendingIntent.getActivity(this, intent.hashCode(), intent, 0);
+
+ final String text = getString(R.string.tab_queue_notification_settings);
+
+ final NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle()
+ .bigText(text);
+
+ final Notification notification = new NotificationCompat.Builder(this)
+ .setContentTitle(getString(R.string.pref_tab_queue_title))
+ .setContentText(text)
+ .setCategory(NotificationCompat.CATEGORY_ERROR)
+ .setStyle(style)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setAutoCancel(true)
+ .addAction(R.drawable.ic_action_settings, getString(R.string.tab_queue_prompt_settings_button), pendingIntent)
+ .build();
+
+ NotificationManagerCompat.from(this).notify(R.id.tabQueueSettingsNotification, notification);
+ }
+
+ private void removeView() {
+ try {
+ windowManager.removeView(toastLayout);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ // This can happen if the Service is killed by the system. If this happens the View will have already
+ // been removed but the runnable will have been kept alive.
+ Log.e(LOGTAG, "Error removing Tab Queue toast from service", e);
+ }
+ }
+
+ private void addURLToTabQueue(final Intent intent, final String filename) {
+ if (intent == null) {
+ // This should never happen, but let's return silently instead of crashing if it does.
+ Log.w(LOGTAG, "Error adding URL to tab queue - invalid intent passed in.");
+ return;
+ }
+ final SafeIntent safeIntent = new SafeIntent(intent);
+ final String intentData = safeIntent.getDataString();
+
+ // As we're doing disk IO, let's run this stuff in a separate thread.
+ executorService.submit(new Runnable() {
+ @Override
+ public void run() {
+ Context applicationContext = getApplicationContext();
+ final GeckoProfile profile = GeckoProfile.get(applicationContext);
+ int tabsQueued = TabQueueHelper.queueURL(profile, intentData, filename);
+ List<String> urls = TabQueueHelper.getLastURLs(applicationContext, filename);
+
+ TabQueueHelper.showNotification(applicationContext, tabsQueued, urls);
+
+ // Store the number of URLs queued so that we don't have to read and process the file to see if we have
+ // any urls to open.
+ // TODO: Use profile shared prefs when bug 1147925 gets fixed.
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(applicationContext);
+
+ prefs.edit().putInt(TabQueueHelper.PREF_TAB_QUEUE_COUNT, tabsQueued).apply();
+ }
+ });
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ handlerThread.quit();
+ }
+
+ /**
+ * A modified Runnable which additionally removes the view from the window view hierarchy and stops the service
+ * when run, unless explicitly instructed not to.
+ */
+ private abstract class StopServiceRunnable implements Runnable {
+
+ private final int startId;
+
+ public StopServiceRunnable(final int startId) {
+ this.startId = startId;
+ }
+
+ public void run() {
+ run(true);
+ }
+
+ public void run(final boolean shouldRemoveView) {
+ onRun();
+
+ if (shouldRemoveView) {
+ removeView();
+ }
+
+ stopSelfResult(startId);
+ }
+
+ public int getStartId() {
+ return startId;
+ }
+
+ public abstract void onRun();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java
new file mode 100644
index 000000000..4f5baacdb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabqueue/TabReceivedService.java
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabqueue;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.BrowserContract;
+
+import android.app.IntentService;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationManagerCompat;
+import android.util.Log;
+
+/**
+ * An IntentService that displays a notification for a tab sent to this device.
+ *
+ * The expected Intent should contain:
+ * * Data: URI to open in the notification
+ * * EXTRA_TITLE: Page title of the URI to open
+ */
+public class TabReceivedService extends IntentService {
+ private static final String LOGTAG = "Gecko" + TabReceivedService.class.getSimpleName();
+
+ private static final String PREF_NOTIFICATION_ID = "tab_received_notification_id";
+
+ private static final int MAX_NOTIFICATION_COUNT = 1000;
+
+ public TabReceivedService() {
+ super(LOGTAG);
+ setIntentRedelivery(true);
+ }
+
+ @Override
+ protected void onHandleIntent(final Intent intent) {
+ // IntentServices don't keep the process alive so
+ // we need to do this every time. Ideally, we wouldn't.
+ final Resources res = getResources();
+ BrowserLocaleManager.getInstance().correctLocale(this, res, res.getConfiguration());
+
+ final String uri = intent.getDataString();
+ if (uri == null) {
+ Log.d(LOGTAG, "Received null uri – ignoring");
+ return;
+ }
+
+ final Intent notificationIntent = new Intent(Intent.ACTION_VIEW, intent.getData());
+ notificationIntent.putExtra(BrowserContract.SKIP_TAB_QUEUE_FLAG, true);
+ final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
+
+ final String notificationTitle = getNotificationTitle(intent.getStringExtra(BrowserContract.EXTRA_CLIENT_GUID));
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ builder.setSmallIcon(R.drawable.flat_icon);
+ builder.setContentTitle(notificationTitle);
+ builder.setWhen(System.currentTimeMillis());
+ builder.setAutoCancel(true);
+ builder.setContentText(uri);
+ builder.setContentIntent(contentIntent);
+
+ // Trigger "heads-up" notification mode on supported Android versions.
+ builder.setPriority(NotificationCompat.PRIORITY_HIGH);
+ final Uri notificationSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
+ if (notificationSoundUri != null) {
+ builder.setSound(notificationSoundUri);
+ }
+
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(this);
+ final int notificationId = getNextNotificationId(prefs.getInt(PREF_NOTIFICATION_ID, 0));
+ final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
+ notificationManager.notify(notificationId, builder.build());
+
+ // Save the ID last so if the Service is killed and the Intent is redelivered,
+ // the ID is unlikely to have been updated and we would re-use the the old one.
+ // This would prevent two identical notifications from appearing if the
+ // notification was shown during the previous Intent processing attempt.
+ prefs.edit().putInt(PREF_NOTIFICATION_ID, notificationId).apply();
+ }
+
+ /**
+ * @param clientGUID the guid of the client in the clients table
+ * @return the client's name from the clients table, if possible, else the brand name.
+ */
+ @WorkerThread
+ private String getNotificationTitle(@Nullable final String clientGUID) {
+ if (clientGUID == null) {
+ Log.w(LOGTAG, "Received null guid, using brand name.");
+ return AppConstants.MOZ_APP_DISPLAYNAME;
+ }
+
+ final Cursor c = getContentResolver().query(BrowserContract.Clients.CONTENT_URI,
+ new String[] { BrowserContract.Clients.NAME },
+ BrowserContract.Clients.GUID + "=?", new String[] { clientGUID }, null);
+ try {
+ if (c != null && c.moveToFirst()) {
+ return c.getString(c.getColumnIndex(BrowserContract.Clients.NAME));
+ } else {
+ Log.w(LOGTAG, "Device not found, using brand name.");
+ return AppConstants.MOZ_APP_DISPLAYNAME;
+ }
+ } finally {
+ if (c != null) {
+ c.close();
+ }
+ }
+ }
+
+ /**
+ * Notification IDs must be unique else a notification
+ * will be overwritten so we cycle them.
+ */
+ private int getNextNotificationId(final int currentId) {
+ if (currentId > MAX_NOTIFICATION_COUNT) {
+ return 0;
+ } else {
+ return currentId + 1;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java
new file mode 100644
index 000000000..b7bd83376
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.tabs.TabsPanel.CloseAllPanelView;
+import org.mozilla.gecko.tabs.TabsPanel.TabsLayout;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.FrameLayout;
+
+/**
+ * A container that wraps the private tabs {@link android.widget.AdapterView} and empty
+ * {@link android.view.View} to manage both of their visibility states by changing the visibility of
+ * this container as calling {@link android.widget.AdapterView#setVisibility} does not affect the
+ * empty View's visibility.
+ */
+class PrivateTabsPanel extends FrameLayout implements CloseAllPanelView {
+ private final TabsLayout tabsLayout;
+
+ public PrivateTabsPanel(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ LayoutInflater.from(context).inflate(R.layout.private_tabs_panel, this);
+ tabsLayout = (TabsLayout) findViewById(R.id.private_tabs_layout);
+
+ final View emptyTabsFrame = findViewById(R.id.private_tabs_empty);
+ tabsLayout.setEmptyView(emptyTabsFrame);
+ }
+
+ @Override
+ public void setTabsPanel(final TabsPanel panel) {
+ tabsLayout.setTabsPanel(panel);
+ }
+
+ @Override
+ public void show() {
+ tabsLayout.show();
+ setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void hide() {
+ setVisibility(View.GONE);
+ tabsLayout.hide();
+ }
+
+ @Override
+ public boolean shouldExpand() {
+ return tabsLayout.shouldExpand();
+ }
+
+ @Override
+ public void closeAll() {
+ tabsLayout.closeAll();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java
new file mode 100644
index 000000000..0b6a30d7a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java
@@ -0,0 +1,70 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.graphics.Path;
+
+/**
+ * Utility methods to draws Firefox's tab curve shape.
+ */
+public class TabCurve {
+
+ public enum Direction {
+ LEFT(-1),
+ RIGHT(1);
+
+ private final int value;
+
+ private Direction(int value) {
+ this.value = value;
+ }
+ }
+
+ // Curve's aspect ratio
+ private static final float ASPECT_RATIO = 0.729f;
+
+ // Width multipliers
+ private static final float W_M1 = 0.343f;
+ private static final float W_M2 = 0.514f;
+ private static final float W_M3 = 0.49f;
+ private static final float W_M4 = 0.545f;
+ private static final float W_M5 = 0.723f;
+
+ // Height multipliers
+ private static final float H_M1 = 0.25f;
+ private static final float H_M2 = 0.5f;
+ private static final float H_M3 = 0.72f;
+ private static final float H_M4 = 0.961f;
+
+ private TabCurve() {
+ }
+
+ public static float getWidthForHeight(float height) {
+ return (int) (height * ASPECT_RATIO);
+ }
+
+ public static void drawFromTop(Path path, float from, float height, Direction dir) {
+ final float width = getWidthForHeight(height);
+
+ path.cubicTo(from + width * W_M1 * dir.value, 0.0f,
+ from + width * W_M3 * dir.value, height * H_M1,
+ from + width * W_M2 * dir.value, height * H_M2);
+ path.cubicTo(from + width * W_M4 * dir.value, height * H_M3,
+ from + width * W_M5 * dir.value, height * H_M4,
+ from + width * dir.value, height);
+ }
+
+ public static void drawFromBottom(Path path, float from, float height, Direction dir) {
+ final float width = getWidthForHeight(height);
+
+ path.cubicTo(from + width * (1f - W_M5) * dir.value, height * H_M4,
+ from + width * (1f - W_M4) * dir.value, height * H_M3,
+ from + width * (1f - W_M2) * dir.value, height * H_M2);
+ path.cubicTo(from + width * (1f - W_M3) * dir.value, height * H_M1,
+ from + width * (1f - W_M1) * dir.value, 0.0f,
+ from + width * dir.value, 0.0f);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java
new file mode 100644
index 000000000..7b06c994c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeJSObject;
+
+import android.util.Log;
+
+public class TabHistoryController {
+ private static final String LOGTAG = "TabHistoryController";
+ private final OnShowTabHistory showTabHistoryListener;
+
+ public static enum HistoryAction {
+ ALL,
+ BACK,
+ FORWARD
+ };
+
+ public interface OnShowTabHistory {
+ void onShowHistory(List<TabHistoryPage> historyPageList, int toIndex);
+ }
+
+ public TabHistoryController(OnShowTabHistory showTabHistoryListener) {
+ this.showTabHistoryListener = showTabHistoryListener;
+ }
+
+ /**
+ * This method will show the history for the current tab.
+ */
+ public boolean showTabHistory(final Tab tab, final HistoryAction action) {
+ JSONObject json = new JSONObject();
+ try {
+ json.put("action", action.name());
+ json.put("tabId", tab.getId());
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "JSON error", e);
+ }
+
+ GeckoAppShell.sendRequestToGecko(new GeckoRequest("Session:GetHistory", json) {
+ @Override
+ public void onResponse(NativeJSObject nativeJSObject) {
+ /*
+ * The response from gecko request is of the form
+ * {
+ * "historyItems" : [
+ * {
+ * "title": "google",
+ * "url": "google.com",
+ * "selected": false
+ * }
+ * ],
+ * toIndex = 1
+ * }
+ */
+
+ final NativeJSObject[] historyItems = nativeJSObject.getObjectArray("historyItems");
+ if (historyItems.length == 0) {
+ // Empty history, return without showing the popup.
+ return;
+ }
+
+ final List<TabHistoryPage> historyPageList = new ArrayList<>(historyItems.length);
+ final int toIndex = nativeJSObject.getInt("toIndex");
+
+ for (NativeJSObject obj : historyItems) {
+ final String title = obj.getString("title");
+ final String url = obj.getString("url");
+ final boolean selected = obj.getBoolean("selected");
+ historyPageList.add(new TabHistoryPage(title, url, selected));
+ }
+
+ showTabHistoryListener.onShowHistory(historyPageList, toIndex);
+ }
+ });
+ return true;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java
new file mode 100644
index 000000000..e6deabdcf
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentTransaction;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+
+public class TabHistoryFragment extends Fragment implements OnItemClickListener, OnClickListener {
+ private static final String ARG_LIST = "historyPageList";
+ private static final String ARG_INDEX = "index";
+ private static final String BACK_STACK_ID = "backStateId";
+
+ private List<TabHistoryPage> historyPageList;
+ private int toIndex;
+ private ListView dialogList;
+ private int backStackId = -1;
+ private ViewGroup parent;
+ private boolean dismissed;
+
+ public TabHistoryFragment() {
+
+ }
+
+ public static TabHistoryFragment newInstance(List<TabHistoryPage> historyPageList, int toIndex) {
+ final TabHistoryFragment fragment = new TabHistoryFragment();
+ final Bundle args = new Bundle();
+ args.putParcelableArrayList(ARG_LIST, (ArrayList<? extends Parcelable>) historyPageList);
+ args.putInt(ARG_INDEX, toIndex);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (savedInstanceState != null) {
+ backStackId = savedInstanceState.getInt(BACK_STACK_ID, -1);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ this.parent = container;
+ parent.setVisibility(View.VISIBLE);
+ View view = inflater.inflate(R.layout.tab_history_layout, container, false);
+ view.setOnClickListener(this);
+ dialogList = (ListView) view.findViewById(R.id.tab_history_list);
+ dialogList.setDivider(null);
+ return view;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ Bundle bundle = getArguments();
+ historyPageList = bundle.getParcelableArrayList(ARG_LIST);
+ toIndex = bundle.getInt(ARG_INDEX);
+ final ArrayAdapter<TabHistoryPage> urlAdapter = new TabHistoryAdapter(getActivity(), historyPageList);
+ dialogList.setAdapter(urlAdapter);
+ dialogList.setOnItemClickListener(this);
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ String index = String.valueOf(toIndex - position);
+ GeckoAppShell.notifyObservers("Session:Navigate", index);
+ dismiss();
+ }
+
+ @Override
+ public void onClick(View v) {
+ // Since the fragment view fills the entire screen, any clicks outside of the history
+ // ListView will end up here.
+ dismiss();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ dismiss();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ dismiss();
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ if (backStackId >= 0) {
+ outState.putInt(BACK_STACK_ID, backStackId);
+ }
+ }
+
+ // Function to add this fragment to activity state with containerViewId as parent.
+ // This similar in functionality to DialogFragment.show() except that containerId is provided here.
+ public void show(final int containerViewId, final FragmentTransaction transaction, final String tag) {
+ dismissed = false;
+ transaction.add(containerViewId, this, tag);
+ transaction.addToBackStack(tag);
+ // Populating the tab history requires a gecko call (which can be slow) - therefore the app
+ // state by the time we try to show this fragment is unknown, and we could be in the
+ // middle of shutting down:
+ backStackId = transaction.commitAllowingStateLoss();
+ }
+
+ // Pop the fragment from backstack if it exists.
+ public void dismiss() {
+ if (dismissed) {
+ return;
+ }
+
+ dismissed = true;
+
+ if (backStackId >= 0) {
+ getFragmentManager().popBackStackImmediate(backStackId, FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ backStackId = -1;
+ }
+
+ if (parent != null) {
+ parent.setVisibility(View.GONE);
+ }
+ }
+
+ private static class TabHistoryAdapter extends ArrayAdapter<TabHistoryPage> {
+ private final List<TabHistoryPage> pages;
+ private final Context context;
+
+ public TabHistoryAdapter(Context context, List<TabHistoryPage> pages) {
+ super(context, R.layout.tab_history_item_row, pages);
+ this.context = context;
+ this.pages = pages;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ TabHistoryItemRow row = (TabHistoryItemRow) convertView;
+ if (row == null) {
+ row = new TabHistoryItemRow(context, null);
+ }
+
+ row.update(pages.get(position), position == 0, position == pages.size() - 1);
+ return row;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java
new file mode 100644
index 000000000..112dbc07d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.icons.IconResponse;
+import org.mozilla.gecko.icons.Icons;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.FaviconView;
+
+import java.util.concurrent.Future;
+
+public class TabHistoryItemRow extends RelativeLayout {
+ private final FaviconView favicon;
+ private final TextView title;
+ private final ImageView timeLineTop;
+ private final ImageView timeLineBottom;
+ private Future<IconResponse> ongoingIconLoad;
+
+ public TabHistoryItemRow(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ LayoutInflater.from(context).inflate(R.layout.tab_history_item_row, this);
+ favicon = (FaviconView) findViewById(R.id.tab_history_icon);
+ title = (TextView) findViewById(R.id.tab_history_title);
+ timeLineTop = (ImageView) findViewById(R.id.tab_history_timeline_top);
+ timeLineBottom = (ImageView) findViewById(R.id.tab_history_timeline_bottom);
+ }
+
+ // Update the views with historic page detail.
+ public void update(final TabHistoryPage historyPage, boolean isFirstElement, boolean isLastElement) {
+ ThreadUtils.assertOnUiThread();
+
+ timeLineTop.setVisibility(isFirstElement ? View.INVISIBLE : View.VISIBLE);
+ timeLineBottom.setVisibility(isLastElement ? View.INVISIBLE : View.VISIBLE);
+ title.setText(historyPage.getTitle());
+
+ if (historyPage.isSelected()) {
+ // Highlight title with bold font.
+ title.setTypeface(null, Typeface.BOLD);
+ } else {
+ // Clear previously set bold font.
+ title.setTypeface(null, Typeface.NORMAL);
+ }
+
+ favicon.setEnabled(historyPage.isSelected());
+ favicon.clearImage();
+
+ if (ongoingIconLoad != null) {
+ ongoingIconLoad.cancel(true);
+ }
+
+ ongoingIconLoad = Icons.with(getContext())
+ .pageUrl(historyPage.getUrl())
+ .skipNetwork()
+ .build()
+ .execute(favicon.createIconCallback());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java
new file mode 100644
index 000000000..6c608b2ac
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public class TabHistoryPage implements Parcelable {
+ private final String title;
+ private final String url;
+ private final boolean selected;
+
+ public TabHistoryPage(String title, String url, boolean selected) {
+ this.title = title;
+ this.url = url;
+ this.selected = selected;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public boolean isSelected() {
+ return selected;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(title);
+ dest.writeString(url);
+ dest.writeInt(selected ? 1 : 0);
+ }
+
+ public static final Parcelable.Creator<TabHistoryPage> CREATOR = new Parcelable.Creator<TabHistoryPage>() {
+ @Override
+ public TabHistoryPage createFromParcel(final Parcel source) {
+ final String title = source.readString();
+ final String url = source.readString();
+ final boolean selected = source.readByte() != 0;
+
+ final TabHistoryPage page = new TabHistoryPage(title, url, selected);
+ return page;
+ }
+
+ @Override
+ public TabHistoryPage[] newArray(int size) {
+ return new TabHistoryPage[size];
+ }
+ };
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java
new file mode 100644
index 000000000..7ea02407e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+
+public class TabPanelBackButton extends ImageButton {
+
+ private int dividerWidth = 0;
+
+ private final Drawable divider;
+ private final int dividerPadding;
+
+ public TabPanelBackButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabPanelBackButton);
+ divider = a.getDrawable(R.styleable.TabPanelBackButton_rightDivider);
+ dividerPadding = (int) a.getDimension(R.styleable.TabPanelBackButton_dividerVerticalPadding, 0);
+ a.recycle();
+
+ if (divider != null) {
+ dividerWidth = divider.getIntrinsicWidth();
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ setMeasuredDimension(getMeasuredWidth() + dividerWidth, getMeasuredHeight());
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ if (divider != null) {
+ final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) getLayoutParams();
+ final int left = getRight() - lp.rightMargin - dividerWidth;
+
+ divider.setBounds(left, getPaddingTop() + dividerPadding,
+ left + dividerWidth, getHeight() - getPaddingBottom() - dividerPadding);
+ divider.draw(canvas);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java
new file mode 100644
index 000000000..5d3719343
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java
@@ -0,0 +1,170 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.graphics.Rect;
+import android.support.v4.content.ContextCompat;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.view.ViewTreeObserver;
+
+import org.mozilla.gecko.BrowserApp.TabStripInterface;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+import org.mozilla.gecko.widget.themed.ThemedLinearLayout;
+
+public class TabStrip extends ThemedLinearLayout
+ implements TabStripInterface {
+ private static final String LOGTAG = "GeckoTabStrip";
+
+ private final TabStripView tabStripView;
+ private final ThemedImageButton addTabButton;
+
+ private final TabsListener tabsListener;
+ private OnTabAddedOrRemovedListener tabChangedListener;
+
+ public TabStrip(Context context) {
+ this(context, null);
+ }
+
+ public TabStrip(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setOrientation(HORIZONTAL);
+
+ LayoutInflater.from(context).inflate(R.layout.tab_strip_inner, this);
+ tabStripView = (TabStripView) findViewById(R.id.tab_strip);
+
+ addTabButton = (ThemedImageButton) findViewById(R.id.add_tab);
+ addTabButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final Tabs tabs = Tabs.getInstance();
+ if (isPrivateMode()) {
+ tabs.addPrivateTab();
+ } else {
+ tabs.addTab();
+ }
+ }
+ });
+
+ getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+
+ final Rect r = new Rect();
+ r.left = addTabButton.getRight();
+ r.right = getWidth();
+ r.top = 0;
+ r.bottom = getHeight();
+
+ // Redirect touch events between the 'new tab' button and the edge
+ // of the screen to the 'new tab' button.
+ setTouchDelegate(new TouchDelegate(r, addTabButton));
+
+ return true;
+ }
+ });
+
+ tabsListener = new TabsListener();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ Tabs.registerOnTabsChangedListener(tabsListener);
+ tabStripView.refreshTabs();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ Tabs.unregisterOnTabsChangedListener(tabsListener);
+ tabStripView.clearTabs();
+ }
+
+ @Override
+ public void setPrivateMode(boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+ addTabButton.setPrivateMode(isPrivate);
+ }
+
+ public void setOnTabChangedListener(OnTabAddedOrRemovedListener listener) {
+ tabChangedListener = listener;
+ }
+
+ private class TabsListener implements Tabs.OnTabsChangedListener {
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ switch (msg) {
+ case RESTORED:
+ tabStripView.restoreTabs();
+ break;
+
+ case ADDED:
+ tabStripView.addTab(tab);
+ if (tabChangedListener != null) {
+ tabChangedListener.onTabChanged();
+ }
+ break;
+
+ case CLOSED:
+ tabStripView.removeTab(tab);
+ if (tabChangedListener != null) {
+ tabChangedListener.onTabChanged();
+ }
+ break;
+
+ case SELECTED:
+ // Update the selected position, then fall through...
+ tabStripView.selectTab(tab);
+ setPrivateMode(tab.isPrivate());
+ case UNSELECTED:
+ // We just need to update the style for the unselected tab...
+ case TITLE:
+ case FAVICON:
+ case RECORDING_CHANGE:
+ case AUDIO_PLAYING_CHANGE:
+ tabStripView.updateTab(tab);
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void refresh() {
+ tabStripView.refresh();
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ final Drawable drawable = getTheme().getDrawable(this);
+ if (drawable == null) {
+ return;
+ }
+
+ final StateListDrawable stateList = new StateListDrawable();
+ stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey));
+ stateList.addState(EMPTY_STATE_SET, drawable);
+
+ setBackgroundDrawable(stateList);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ final int defaultBackgroundColor = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey);
+ setBackgroundColor(defaultBackgroundColor);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java
new file mode 100644
index 000000000..8778aac31
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java
@@ -0,0 +1,98 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+
+class TabStripAdapter extends BaseAdapter {
+ private static final String LOGTAG = "GeckoTabStripAdapter";
+
+ private final Context context;
+ private List<Tab> tabs;
+
+ public TabStripAdapter(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public Tab getItem(int position) {
+ return (tabs != null &&
+ position >= 0 &&
+ position < tabs.size() ? tabs.get(position) : null);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ final Tab tab = getItem(position);
+ return (tab != null ? tab.getId() : -1);
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final TabStripItemView item;
+ if (convertView == null) {
+ item = (TabStripItemView)
+ LayoutInflater.from(context).inflate(R.layout.tab_strip_item, parent, false);
+ } else {
+ item = (TabStripItemView) convertView;
+ }
+
+ final Tab tab = tabs.get(position);
+ item.updateFromTab(tab);
+
+ return item;
+ }
+
+ @Override
+ public int getCount() {
+ return (tabs != null ? tabs.size() : 0);
+ }
+
+ int getPositionForTab(Tab tab) {
+ if (tabs == null || tab == null) {
+ return -1;
+ }
+
+ return tabs.indexOf(tab);
+ }
+
+ void removeTab(Tab tab) {
+ if (tabs == null) {
+ return;
+ }
+
+ tabs.remove(tab);
+ notifyDataSetChanged();
+ }
+
+ void refresh(List<Tab> tabs) {
+ // The list of tabs is guaranteed to be non-null.
+ // See TabStripView.refreshTabs().
+ this.tabs = tabs;
+ notifyDataSetChanged();
+ }
+
+ void clear() {
+ tabs = null;
+ notifyDataSetInvalidated();
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java
new file mode 100644
index 000000000..27eaed125
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java
@@ -0,0 +1,254 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.widget.ResizablePathDrawable;
+import org.mozilla.gecko.widget.ResizablePathDrawable.NonScaledPathShape;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+import org.mozilla.gecko.widget.themed.ThemedLinearLayout;
+import org.mozilla.gecko.widget.themed.ThemedTextView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Path;
+import android.graphics.Region;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.Checkable;
+import android.widget.ImageView;
+
+public class TabStripItemView extends ThemedLinearLayout
+ implements Checkable {
+ private static final String LOGTAG = "GeckoTabStripItem";
+
+ private static final int[] STATE_CHECKED = {
+ android.R.attr.state_checked
+ };
+
+ private int id = -1;
+ private boolean checked;
+
+ private final ImageView faviconView;
+ private final ThemedTextView titleView;
+ private final ThemedImageButton closeView;
+
+ private final ResizablePathDrawable backgroundDrawable;
+ private final Region tabRegion;
+ private final Region tabClipRegion;
+ private boolean tabRegionNeedsUpdate;
+
+ private final int faviconSize;
+ private Bitmap lastFavicon;
+
+ public TabStripItemView(Context context) {
+ this(context, null);
+ }
+
+ public TabStripItemView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setOrientation(HORIZONTAL);
+
+ tabRegion = new Region();
+ tabClipRegion = new Region();
+
+ final Resources res = context.getResources();
+
+ final ColorStateList tabColors =
+ res.getColorStateList(R.color.tab_strip_item_bg);
+ backgroundDrawable = new ResizablePathDrawable(new TabCurveShape(), tabColors);
+ setBackgroundDrawable(backgroundDrawable);
+
+ faviconSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_favicon_size);
+
+ LayoutInflater.from(context).inflate(R.layout.tab_strip_item_view, this);
+ setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (id < 0) {
+ throw new IllegalStateException("Invalid tab id:" + id);
+ }
+
+ Tabs.getInstance().selectTab(id);
+ }
+ });
+
+ faviconView = (ImageView) findViewById(R.id.favicon);
+ titleView = (ThemedTextView) findViewById(R.id.title);
+
+ closeView = (ThemedImageButton) findViewById(R.id.close);
+ closeView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (id < 0) {
+ throw new IllegalStateException("Invalid tab id:" + id);
+ }
+
+ final Tabs tabs = Tabs.getInstance();
+ tabs.closeTab(tabs.getTab(id), true);
+ }
+ });
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ // Queue a tab region update in the next draw() call. We don't
+ // update it immediately here because we need the new path from
+ // the background drawable to be updated first.
+ tabRegionNeedsUpdate = true;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ final int action = event.getActionMasked();
+ final int x = (int) event.getX();
+ final int y = (int) event.getY();
+
+ // Let motion events through if they're off the tab shape bounds.
+ if (action == MotionEvent.ACTION_DOWN && !tabRegion.contains(x, y)) {
+ return false;
+ }
+
+ return super.onTouchEvent(event);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ if (tabRegionNeedsUpdate) {
+ final Path path = backgroundDrawable.getPath();
+ tabClipRegion.set(0, 0, getWidth(), getHeight());
+ tabRegion.setPath(path, tabClipRegion);
+ tabRegionNeedsUpdate = false;
+ }
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (checked) {
+ mergeDrawableStates(drawableState, STATE_CHECKED);
+ }
+
+ return drawableState;
+ }
+
+ @Override
+ public boolean isChecked() {
+ return checked;
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ if (this.checked == checked) {
+ return;
+ }
+
+ this.checked = checked;
+ refreshDrawableState();
+ }
+
+ @Override
+ public void toggle() {
+ setChecked(!checked);
+ }
+
+ @Override
+ public void setPressed(boolean pressed) {
+ super.setPressed(pressed);
+
+ // The surrounding tab strip dividers need to be hidden
+ // when a tab item enters pressed state.
+ View parent = (View) getParent();
+ if (parent != null) {
+ parent.invalidate();
+ }
+ }
+
+ void updateFromTab(Tab tab) {
+ if (tab == null) {
+ return;
+ }
+
+ id = tab.getId();
+
+ updateTitle(tab);
+ updateFavicon(tab.getFavicon());
+ setPrivateMode(tab.isPrivate());
+ }
+
+ private void updateTitle(Tab tab) {
+ final String title;
+
+ // Avoid flickering the about:home URL on every load given how often
+ // this page is used in the UI.
+ if (AboutPages.isAboutHome(tab.getURL())) {
+ titleView.setText(R.string.home_title);
+ } else {
+ titleView.setText(tab.getDisplayTitle());
+ }
+
+ // TODO: Set content description to indicate audio is playing.
+ if (tab.isAudioPlaying()) {
+ titleView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0);
+ } else {
+ titleView.setCompoundDrawables(null, null, null, null);
+ }
+ }
+
+ private void updateFavicon(final Bitmap favicon) {
+ if (favicon == null) {
+ lastFavicon = null;
+ faviconView.setImageResource(R.drawable.toolbar_favicon_default);
+ return;
+ }
+ if (favicon == lastFavicon) {
+ return;
+ }
+
+ // Cache the original so we can debounce without scaling.
+ lastFavicon = favicon;
+
+ final Bitmap scaledFavicon =
+ Bitmap.createScaledBitmap(favicon, faviconSize, faviconSize, false);
+ faviconView.setImageBitmap(scaledFavicon);
+ }
+
+ private static class TabCurveShape extends NonScaledPathShape {
+ @Override
+ protected void onResize(float width, float height) {
+ final Path path = getPath();
+
+ path.reset();
+
+ final float curveWidth = TabCurve.getWidthForHeight(height);
+
+ path.moveTo(0, height);
+ TabCurve.drawFromBottom(path, 0, height, TabCurve.Direction.RIGHT);
+ path.lineTo(width - curveWidth, 0);
+
+ TabCurve.drawFromTop(path, width - curveWidth, height, TabCurve.Direction.RIGHT);
+ path.lineTo(0, height);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java
new file mode 100644
index 000000000..f3ec19cef
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java
@@ -0,0 +1,449 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.Shader;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.animation.DecelerateInterpolator;
+import android.view.View;
+import android.view.ViewTreeObserver.OnPreDrawListener;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.widget.TwoWayView;
+
+public class TabStripView extends TwoWayView {
+ private static final String LOGTAG = "GeckoTabStrip";
+
+ private static final int ANIM_TIME_MS = 200;
+ private static final DecelerateInterpolator ANIM_INTERPOLATOR =
+ new DecelerateInterpolator();
+
+ private final TabStripAdapter adapter;
+ private final Drawable divider;
+
+ private final TabAnimatorListener animatorListener;
+
+ private boolean isRestoringTabs;
+
+ // Filled by calls to ShapeDrawable.getPadding();
+ // saved to prevent allocation in draw().
+ private final Rect dividerPadding = new Rect();
+
+ private boolean isPrivate;
+
+ private final Paint fadingEdgePaint;
+ private final int fadingEdgeSize;
+
+ public TabStripView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setOrientation(Orientation.HORIZONTAL);
+ setChoiceMode(ChoiceMode.SINGLE);
+ setItemsCanFocus(true);
+ setChildrenDrawingOrderEnabled(true);
+ setWillNotDraw(false);
+
+ final Resources resources = getResources();
+
+ divider = resources.getDrawable(R.drawable.tab_strip_divider);
+ divider.getPadding(dividerPadding);
+
+ final int itemMargin =
+ resources.getDimensionPixelSize(R.dimen.tablet_tab_strip_item_margin);
+ setItemMargin(itemMargin);
+
+ animatorListener = new TabAnimatorListener();
+
+ fadingEdgePaint = new Paint();
+ fadingEdgeSize =
+ resources.getDimensionPixelOffset(R.dimen.tablet_tab_strip_fading_edge_size);
+
+ adapter = new TabStripAdapter(context);
+ setAdapter(adapter);
+ }
+
+ private View getViewForTab(Tab tab) {
+ final int position = adapter.getPositionForTab(tab);
+ return getChildAt(position - getFirstVisiblePosition());
+ }
+
+ private int getPositionForSelectedTab() {
+ return adapter.getPositionForTab(Tabs.getInstance().getSelectedTab());
+ }
+
+ private void updateSelectedStyle(int selected) {
+ setItemChecked(selected, true);
+ }
+
+ private void updateSelectedPosition(boolean ensureVisible) {
+ final int selected = getPositionForSelectedTab();
+ if (selected != -1) {
+ updateSelectedStyle(selected);
+
+ if (ensureVisible) {
+ ensurePositionIsVisible(selected, true);
+ }
+ }
+ }
+
+ private void animateRemoveTab(Tab removedTab) {
+ final int removedPosition = adapter.getPositionForTab(removedTab);
+
+ final View removedView = getViewForTab(removedTab);
+
+ // The removed position might not have a matching child view
+ // when it's not within the visible range of positions in the strip.
+ if (removedView == null) {
+ return;
+ }
+
+ // We don't animate the removed child view (it just disappears)
+ // but we still need its size of animate all affected children
+ // within the visible viewport.
+ final int removedSize = removedView.getWidth() + getItemMargin();
+
+ getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+
+ final int firstPosition = getFirstVisiblePosition();
+ final List<Animator> childAnimators = new ArrayList<Animator>();
+
+ final int childCount = getChildCount();
+ for (int i = removedPosition - firstPosition; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ final ObjectAnimator animator =
+ ObjectAnimator.ofFloat(child, "translationX", removedSize, 0);
+ childAnimators.add(animator);
+ }
+
+ final AnimatorSet animatorSet = new AnimatorSet();
+ animatorSet.playTogether(childAnimators);
+ animatorSet.setDuration(ANIM_TIME_MS);
+ animatorSet.setInterpolator(ANIM_INTERPOLATOR);
+ animatorSet.addListener(animatorListener);
+
+ animatorSet.start();
+
+ return true;
+ }
+ });
+ }
+
+ private void animateNewTab(Tab newTab) {
+ final int newPosition = adapter.getPositionForTab(newTab);
+ if (newPosition < 0) {
+ return;
+ }
+
+ getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+
+ final int firstPosition = getFirstVisiblePosition();
+
+ final View newChild = getChildAt(newPosition - firstPosition);
+ if (newChild == null) {
+ return true;
+ }
+
+ final List<Animator> childAnimators = new ArrayList<Animator>();
+ childAnimators.add(
+ ObjectAnimator.ofFloat(newChild, "translationY", newChild.getHeight(), 0));
+
+ // This will momentaneously add a gap on the right side
+ // because TwoWayView doesn't provide APIs to control
+ // view recycling programatically to handle these transitory
+ // states in the container during animations.
+
+ final int tabSize = newChild.getWidth();
+ final int newIndex = newPosition - firstPosition;
+ final int childCount = getChildCount();
+ for (int i = newIndex + 1; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ childAnimators.add(
+ ObjectAnimator.ofFloat(child, "translationX", -tabSize, 0));
+ }
+
+ final AnimatorSet animatorSet = new AnimatorSet();
+ animatorSet.playTogether(childAnimators);
+ animatorSet.setDuration(ANIM_TIME_MS);
+ animatorSet.setInterpolator(ANIM_INTERPOLATOR);
+ animatorSet.addListener(animatorListener);
+
+ animatorSet.start();
+
+ return true;
+ }
+ });
+ }
+
+ private void animateRestoredTabs() {
+ getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+
+ final List<Animator> childAnimators = new ArrayList<Animator>();
+
+ final int tabHeight = getHeight() - getPaddingTop() - getPaddingBottom();
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ childAnimators.add(
+ ObjectAnimator.ofFloat(child, "translationY", tabHeight, 0));
+ }
+
+ final AnimatorSet animatorSet = new AnimatorSet();
+ animatorSet.playTogether(childAnimators);
+ animatorSet.setDuration(ANIM_TIME_MS);
+ animatorSet.setInterpolator(ANIM_INTERPOLATOR);
+ animatorSet.addListener(animatorListener);
+
+ animatorSet.start();
+
+ return true;
+ }
+ });
+ }
+
+ /**
+ * Ensures the tab at the given position is visible. If we are not restoring tabs and
+ * shouldAnimate == true, the tab will animate to be visible, if it is not already visible.
+ */
+ private void ensurePositionIsVisible(final int position, final boolean shouldAnimate) {
+ // We just want to move the strip to the right position
+ // when restoring tabs on startup.
+ if (isRestoringTabs || !shouldAnimate) {
+ setSelection(position);
+ return;
+ }
+
+ getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+ smoothScrollToPosition(position);
+ return true;
+ }
+ });
+ }
+
+ private int getCheckedIndex(int childCount) {
+ final int checkedIndex = getCheckedItemPosition() - getFirstVisiblePosition();
+ if (checkedIndex < 0 || checkedIndex > childCount - 1) {
+ return INVALID_POSITION;
+ }
+
+ return checkedIndex;
+ }
+
+ void refreshTabs() {
+ // Store a different copy of the tabs, so that we don't have
+ // to worry about accidentally updating it on the wrong thread.
+ final List<Tab> tabs = new ArrayList<Tab>();
+
+ for (Tab tab : Tabs.getInstance().getTabsInOrder()) {
+ if (tab.isPrivate() == isPrivate) {
+ tabs.add(tab);
+ }
+ }
+
+ adapter.refresh(tabs);
+ updateSelectedPosition(true);
+ }
+
+ void clearTabs() {
+ adapter.clear();
+ }
+
+ void restoreTabs() {
+ isRestoringTabs = true;
+ refreshTabs();
+ animateRestoredTabs();
+ isRestoringTabs = false;
+ }
+
+ void addTab(Tab tab) {
+ // Refresh the list to make sure the new tab is
+ // added in the right position.
+ refreshTabs();
+ animateNewTab(tab);
+ }
+
+ void removeTab(Tab tab) {
+ animateRemoveTab(tab);
+ adapter.removeTab(tab);
+ updateSelectedPosition(false);
+ }
+
+ void selectTab(Tab tab) {
+ if (tab.isPrivate() != isPrivate) {
+ isPrivate = tab.isPrivate();
+ refreshTabs();
+ } else {
+ updateSelectedPosition(true);
+ }
+ }
+
+ void updateTab(Tab tab) {
+ final TabStripItemView item = (TabStripItemView) getViewForTab(tab);
+ if (item != null) {
+ item.updateFromTab(tab);
+ }
+ }
+
+ private float getFadingEdgeStrength() {
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return 0.0f;
+ } else {
+ if (getFirstVisiblePosition() + childCount - 1 < adapter.getCount() - 1) {
+ return 1.0f;
+ }
+
+ final int right = getChildAt(childCount - 1).getRight();
+ final int paddingRight = getPaddingRight();
+ final int width = getWidth();
+
+ final float strength = (right > width - paddingRight ?
+ (float) (right - width + paddingRight) / fadingEdgeSize : 0.0f);
+
+ return Math.max(0.0f, Math.min(strength, 1.0f));
+ }
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ fadingEdgePaint.setShader(new LinearGradient(w - fadingEdgeSize, 0, w, 0,
+ new int[] { 0x0, 0x11292C29, 0xDD292C29 },
+ new float[] { 0, 0.4f, 1.0f }, Shader.TileMode.CLAMP));
+ }
+
+ @Override
+ protected int getChildDrawingOrder(int childCount, int i) {
+ final int checkedIndex = getCheckedIndex(childCount);
+ if (checkedIndex == INVALID_POSITION) {
+ return i;
+ }
+
+ // Always draw the currently selected tab on top of all
+ // other child views so that its curve is fully visible.
+ if (i == childCount - 1) {
+ return checkedIndex;
+ } else if (checkedIndex <= i) {
+ return i + 1;
+ } else {
+ return i;
+ }
+ }
+
+ private void drawDividers(Canvas canvas) {
+ final int bottom = getHeight() - getPaddingBottom() - dividerPadding.bottom;
+ final int top = bottom - divider.getIntrinsicHeight();
+
+ final int dividerWidth = divider.getIntrinsicWidth();
+ final int itemMargin = getItemMargin();
+
+ final int childCount = getChildCount();
+ final int checkedIndex = getCheckedIndex(childCount);
+
+ for (int i = 1; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ final boolean pressed = (child.isPressed() || getChildAt(i - 1).isPressed());
+ final boolean checked = (i == checkedIndex || i == checkedIndex + 1);
+
+ // Don't draw dividers for around checked or pressed items
+ // so that they are not drawn on top of the tab curves.
+ if (pressed || checked) {
+ continue;
+ }
+
+ final int left = child.getLeft() - (itemMargin / 2) - dividerWidth;
+ final int right = left + dividerWidth;
+
+ divider.setBounds(left, top, right, bottom);
+ divider.draw(canvas);
+ }
+ }
+
+ private void drawFadingEdge(Canvas canvas) {
+ final float strength = getFadingEdgeStrength();
+ if (strength > 0.0f) {
+ final int r = getRight();
+ canvas.drawRect(r - fadingEdgeSize, getTop(), r, getBottom(), fadingEdgePaint);
+ fadingEdgePaint.setAlpha((int) (strength * 255));
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+ drawDividers(canvas);
+ drawFadingEdge(canvas);
+ }
+
+ public void refresh() {
+ final int selectedPosition = getPositionForSelectedTab();
+ if (selectedPosition != -1) {
+ ensurePositionIsVisible(selectedPosition, false);
+ }
+ }
+
+ private class TabAnimatorListener implements AnimatorListener {
+ private void setLayerType(int layerType) {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ getChildAt(i).setLayerType(layerType, null);
+ }
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) {
+ setLayerType(View.LAYER_TYPE_HARDWARE);
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ // This method is called even if the animator is canceled.
+ setLayerType(View.LAYER_TYPE_NONE);
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animation) {
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animation) {
+ }
+
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java
new file mode 100644
index 000000000..ead7db9fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java
@@ -0,0 +1,712 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.tabs.TabsPanel.TabsLayout;
+import org.mozilla.gecko.widget.themed.ThemedRelativeLayout;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.os.Build;
+import android.util.AttributeSet;
+import android.util.SparseArray;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.AbsListView;
+import android.widget.AdapterView;
+import android.widget.Button;
+import android.widget.GridView;
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.animation.ValueAnimator;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A tabs layout implementation for the tablet redesign (bug 1014156) and later ported to mobile (bug 1193745).
+ */
+
+class TabsGridLayout extends GridView
+ implements TabsLayout,
+ Tabs.OnTabsChangedListener {
+
+ private static final String LOGTAG = "Gecko" + TabsGridLayout.class.getSimpleName();
+
+ public static final int ANIM_DELAY_MULTIPLE_MS = 20;
+ private static final int ANIM_TIME_MS = 200;
+ private static final DecelerateInterpolator ANIM_INTERPOLATOR = new DecelerateInterpolator();
+
+ private final SparseArray<PointF> tabLocations = new SparseArray<PointF>();
+ private final boolean isPrivate;
+ private final TabsLayoutAdapter tabsAdapter;
+ private final int columnWidth;
+ private TabsPanel tabsPanel;
+ private int lastSelectedTabId;
+
+ public TabsGridLayout(final Context context, final AttributeSet attrs) {
+ super(context, attrs, R.attr.tabGridLayoutViewStyle);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout);
+ isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1);
+ a.recycle();
+
+ tabsAdapter = new TabsGridLayoutAdapter(context);
+ setAdapter(tabsAdapter);
+
+ setRecyclerListener(new RecyclerListener() {
+ @Override
+ public void onMovedToScrapHeap(View view) {
+ TabsLayoutItemView item = (TabsLayoutItemView) view;
+ item.setThumbnail(null);
+ }
+ });
+
+ // The clipToPadding setting in the styles.xml doesn't seem to be working (bug 1101784)
+ // so lets set it manually in code for the moment as it's needed for the padding animation
+ setClipToPadding(false);
+
+ setVerticalFadingEdgeEnabled(false);
+
+ final Resources resources = getResources();
+ columnWidth = resources.getDimensionPixelSize(R.dimen.tab_panel_column_width);
+
+ final int padding = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding);
+ final int paddingTop = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding_top);
+
+ // Lets set double the top padding on the bottom so that the last row shows up properly!
+ // Your demise, GridView, cannot come fast enough.
+ final int paddingBottom = paddingTop * 2;
+
+ setPadding(padding, paddingTop, padding, paddingBottom);
+
+ setOnItemClickListener(new OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final TabsLayoutItemView tabView = (TabsLayoutItemView) view;
+ final int tabId = tabView.getTabId();
+ final Tab tab = Tabs.getInstance().selectTab(tabId);
+ if (tab == null) {
+ return;
+ }
+ autoHidePanel();
+ Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY);
+ }
+ });
+
+ TabSwipeGestureListener mSwipeListener = new TabSwipeGestureListener();
+ setOnTouchListener(mSwipeListener);
+ setOnScrollListener(mSwipeListener.makeScrollListener());
+ }
+
+ private void populateTabLocations(final Tab removedTab) {
+ tabLocations.clear();
+
+ final int firstPosition = getFirstVisiblePosition();
+ final int lastPosition = getLastVisiblePosition();
+ final int numberOfColumns = getNumColumns();
+ final int childCount = getChildCount();
+ final int removedPosition = tabsAdapter.getPositionForTab(removedTab);
+
+ for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) {
+ final View child = getChildAt(i);
+ if (child != null) {
+ // Reset the transformations here in case the user is swiping tabs away fast and they swipe a tab
+ // before the last animation has finished (bug 1179195).
+ resetTransforms(child);
+
+ tabLocations.append(x, new PointF(child.getX(), child.getY()));
+ }
+ }
+
+ final boolean firstChildOffScreen = ((firstPosition > 0) || getChildAt(0).getY() < 0);
+ final boolean lastChildVisible = (lastPosition - childCount == firstPosition - 1);
+ final boolean oneItemOnLastRow = (lastPosition % numberOfColumns == 0);
+ if (firstChildOffScreen && lastChildVisible && oneItemOnLastRow) {
+ // We need to set the view's bottom padding to prevent a sudden jump as the
+ // last item in the row is being removed. We then need to remove the padding
+ // via a sweet animation
+
+ final int removedHeight = getChildAt(0).getMeasuredHeight();
+ final int verticalSpacing =
+ getResources().getDimensionPixelOffset(R.dimen.tab_panel_grid_vspacing);
+
+ ValueAnimator paddingAnimator = ValueAnimator.ofInt(getPaddingBottom() + removedHeight + verticalSpacing, getPaddingBottom());
+ paddingAnimator.setDuration(ANIM_TIME_MS * 2);
+
+ paddingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), (Integer) animation.getAnimatedValue());
+ }
+ });
+ paddingAnimator.start();
+ }
+ }
+
+ @Override
+ public void setTabsPanel(TabsPanel panel) {
+ tabsPanel = panel;
+ }
+
+ @Override
+ public void show() {
+ setVisibility(View.VISIBLE);
+ Tabs.getInstance().refreshThumbnails();
+ Tabs.registerOnTabsChangedListener(this);
+ refreshTabsData();
+
+ final Tab currentlySelectedTab = Tabs.getInstance().getSelectedTab();
+ final int position = currentlySelectedTab != null ? tabsAdapter.getPositionForTab(currentlySelectedTab) : -1;
+ if (position != -1) {
+ final boolean selectionChanged = lastSelectedTabId != currentlySelectedTab.getId();
+ final boolean positionIsVisible = position >= getFirstVisiblePosition() && position <= getLastVisiblePosition();
+
+ if (selectionChanged || !positionIsVisible) {
+ smoothScrollToPosition(position);
+ }
+ }
+ }
+
+ @Override
+ public void hide() {
+ lastSelectedTabId = Tabs.getInstance().getSelectedTab().getId();
+ setVisibility(View.GONE);
+ Tabs.unregisterOnTabsChangedListener(this);
+ GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", "");
+ tabsAdapter.clear();
+ }
+
+ @Override
+ public boolean shouldExpand() {
+ return true;
+ }
+
+ private void autoHidePanel() {
+ tabsPanel.autoHidePanel();
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ switch (msg) {
+ case ADDED:
+ // Refresh only if panel is shown. show() will call refreshTabsData() later again.
+ if (tabsPanel.isShown()) {
+ // Refresh the list to make sure the new tab is added in the right position.
+ refreshTabsData();
+ }
+ break;
+
+ case CLOSED:
+
+ // This is limited to >= ICS as animations on GB devices are generally pants
+ if (Build.VERSION.SDK_INT >= 11 && tabsAdapter.getCount() > 0) {
+ animateRemoveTab(tab);
+ }
+
+ final Tabs tabsInstance = Tabs.getInstance();
+
+ if (tabsAdapter.removeTab(tab)) {
+ if (tab.isPrivate() == isPrivate && tabsAdapter.getCount() > 0) {
+ int selected = tabsAdapter.getPositionForTab(tabsInstance.getSelectedTab());
+ updateSelectedStyle(selected);
+ }
+ if (!tab.isPrivate()) {
+ // Make sure we always have at least one normal tab
+ final Iterable<Tab> tabs = tabsInstance.getTabsInOrder();
+ boolean removedTabIsLastNormalTab = true;
+ for (Tab singleTab : tabs) {
+ if (!singleTab.isPrivate()) {
+ removedTabIsLastNormalTab = false;
+ break;
+ }
+ }
+ if (removedTabIsLastNormalTab) {
+ tabsInstance.addTab();
+ }
+ }
+ }
+ break;
+
+ case SELECTED:
+ // Update the selected position, then fall through...
+ updateSelectedPosition();
+ case UNSELECTED:
+ // We just need to update the style for the unselected tab...
+ case THUMBNAIL:
+ case TITLE:
+ case RECORDING_CHANGE:
+ case AUDIO_PLAYING_CHANGE:
+ View view = getChildAt(tabsAdapter.getPositionForTab(tab) - getFirstVisiblePosition());
+ if (view == null)
+ return;
+
+ ((TabsLayoutItemView) view).assignValues(tab);
+ break;
+ }
+ }
+
+ // Updates the selected position in the list so that it will be scrolled to the right place.
+ private void updateSelectedPosition() {
+ int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab());
+ updateSelectedStyle(selected);
+
+ if (selected != -1) {
+ setSelection(selected);
+ }
+ }
+
+ /**
+ * Updates the selected/unselected style for the tabs.
+ *
+ * @param selected position of the selected tab
+ */
+ private void updateSelectedStyle(final int selected) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ final int displayCount = tabsAdapter.getCount();
+
+ for (int i = 0; i < displayCount; i++) {
+ final Tab tab = tabsAdapter.getItem(i);
+ final boolean checked = displayCount == 1 || i == selected;
+ final View tabView = getViewForTab(tab);
+ if (tabView != null) {
+ ((TabsLayoutItemView) tabView).setChecked(checked);
+ }
+ // setItemChecked doesn't exist until API 11, despite what the API docs say!
+ setItemChecked(i, checked);
+ }
+ }
+ });
+ }
+
+ private void refreshTabsData() {
+ // Store a different copy of the tabs, so that we don't have to worry about
+ // accidentally updating it on the wrong thread.
+ ArrayList<Tab> tabData = new ArrayList<>();
+
+ Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder();
+ for (Tab tab : allTabs) {
+ if (tab.isPrivate() == isPrivate)
+ tabData.add(tab);
+ }
+
+ tabsAdapter.setTabs(tabData);
+ updateSelectedPosition();
+ }
+
+ private void resetTransforms(View view) {
+ view.setAlpha(1);
+ view.setTranslationX(0);
+ view.setTranslationY(0);
+
+ ((TabsLayoutItemView) view).setCloseVisible(true);
+ }
+
+ @Override
+ public void closeAll() {
+
+ autoHidePanel();
+
+ if (getChildCount() == 0) {
+ return;
+ }
+
+ final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder();
+ for (Tab tab : tabs) {
+ // In the normal panel we want to close all tabs (both private and normal),
+ // but in the private panel we only want to close private tabs.
+ if (!isPrivate || tab.isPrivate()) {
+ Tabs.getInstance().closeTab(tab, false);
+ }
+ }
+ }
+
+ private View getViewForTab(Tab tab) {
+ final int position = tabsAdapter.getPositionForTab(tab);
+ return getChildAt(position - getFirstVisiblePosition());
+ }
+
+ void closeTab(View v) {
+ if (tabsAdapter.getCount() == 1) {
+ autoHidePanel();
+ }
+
+ TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag();
+ Tab tab = Tabs.getInstance().getTab(itemView.getTabId());
+
+ Tabs.getInstance().closeTab(tab, true);
+ }
+
+ private void animateRemoveTab(final Tab removedTab) {
+ final int removedPosition = tabsAdapter.getPositionForTab(removedTab);
+
+ final View removedView = getViewForTab(removedTab);
+
+ // The removed position might not have a matching child view
+ // when it's not within the visible range of positions in the strip.
+ if (removedView == null) {
+ return;
+ }
+ final int removedHeight = removedView.getMeasuredHeight();
+
+ populateTabLocations(removedTab);
+
+ getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+ // We don't animate the removed child view (it just disappears)
+ // but we still need its size to animate all affected children
+ // within the visible viewport.
+ final int childCount = getChildCount();
+ final int firstPosition = getFirstVisiblePosition();
+ final int numberOfColumns = getNumColumns();
+
+ final List<Animator> childAnimators = new ArrayList<>();
+
+ PropertyValuesHolder translateX, translateY;
+ for (int x = 0, i = removedPosition - firstPosition; i < childCount; i++, x++) {
+ final View child = getChildAt(i);
+ ObjectAnimator animator;
+
+ if (i % numberOfColumns == numberOfColumns - 1) {
+ // Animate X & Y
+ translateX = PropertyValuesHolder.ofFloat("translationX", -(columnWidth * numberOfColumns), 0);
+ translateY = PropertyValuesHolder.ofFloat("translationY", removedHeight, 0);
+ animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX, translateY);
+ } else {
+ // Just animate X
+ translateX = PropertyValuesHolder.ofFloat("translationX", columnWidth, 0);
+ animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX);
+ }
+ animator.setStartDelay(x * ANIM_DELAY_MULTIPLE_MS);
+ childAnimators.add(animator);
+ }
+
+ final AnimatorSet animatorSet = new AnimatorSet();
+ animatorSet.playTogether(childAnimators);
+ animatorSet.setDuration(ANIM_TIME_MS);
+ animatorSet.setInterpolator(ANIM_INTERPOLATOR);
+ animatorSet.start();
+
+ // Set the starting position of the child views - because we are delaying the start
+ // of the animation, we need to prevent the items being drawn in their final position
+ // prior to the animation starting
+ for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) {
+ final View child = getChildAt(i);
+
+ final PointF targetLocation = tabLocations.get(x + 1);
+ if (targetLocation == null) {
+ continue;
+ }
+
+ child.setX(targetLocation.x);
+ child.setY(targetLocation.y);
+ }
+
+ return true;
+ }
+ });
+ }
+
+
+ private void animateCancel(final View view) {
+ PropertyAnimator animator = new PropertyAnimator(ANIM_TIME_MS);
+ animator.attach(view, PropertyAnimator.Property.ALPHA, 1);
+ animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, 0);
+
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ TabsLayoutItemView tab = (TabsLayoutItemView) view;
+ tab.setCloseVisible(true);
+ }
+ });
+
+ animator.start();
+ }
+
+ private class TabsGridLayoutAdapter extends TabsLayoutAdapter {
+
+ final private Button.OnClickListener mCloseClickListener;
+
+ public TabsGridLayoutAdapter(Context context) {
+ super(context, R.layout.tabs_layout_item_view);
+
+ mCloseClickListener = new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ closeTab(v);
+ }
+ };
+ }
+
+ @Override
+ TabsLayoutItemView newView(int position, ViewGroup parent) {
+ final TabsLayoutItemView item = super.newView(position, parent);
+
+ item.setCloseOnClickListener(mCloseClickListener);
+ ((ThemedRelativeLayout) item.findViewById(R.id.wrapper)).setPrivateMode(isPrivate);
+
+ return item;
+ }
+
+ @Override
+ public void bindView(TabsLayoutItemView view, Tab tab) {
+ super.bindView(view, tab);
+
+ // If we're recycling this view, there's a chance it was transformed during
+ // the close animation. Remove any of those properties.
+ resetTransforms(view);
+ }
+ }
+
+ private class TabSwipeGestureListener implements View.OnTouchListener {
+ // same value the stock browser uses for after drag animation velocity in pixels/sec
+ // http://androidxref.com/4.0.4/xref/packages/apps/Browser/src/com/android/browser/NavTabScroller.java#61
+ private static final float MIN_VELOCITY = 750;
+
+ private final int mSwipeThreshold;
+ private final int mMinFlingVelocity;
+
+ private final int mMaxFlingVelocity;
+ private VelocityTracker mVelocityTracker;
+
+ private int mTabWidth = 1;
+
+ private View mSwipeView;
+ private Runnable mPendingCheckForTap;
+
+ private float mSwipeStartX;
+ private boolean mSwiping;
+ private boolean mEnabled;
+
+ public TabSwipeGestureListener() {
+ mEnabled = true;
+
+ ViewConfiguration vc = ViewConfiguration.get(TabsGridLayout.this.getContext());
+ mSwipeThreshold = vc.getScaledTouchSlop();
+ mMinFlingVelocity = (int) (TabsGridLayout.this.getContext().getResources().getDisplayMetrics().density * MIN_VELOCITY);
+ mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
+ }
+
+ public void setEnabled(boolean enabled) {
+ mEnabled = enabled;
+ }
+
+ public OnScrollListener makeScrollListener() {
+ return new OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ setEnabled(scrollState != GridView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+
+ }
+ };
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent e) {
+ if (!mEnabled) {
+ return false;
+ }
+
+ switch (e.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ // Check if we should set pressed state on the
+ // touched view after a standard delay.
+ triggerCheckForTap();
+
+ final float x = e.getRawX();
+ final float y = e.getRawY();
+
+ // Find out which view is being touched
+ mSwipeView = findViewAt(x, y);
+
+ if (mSwipeView != null) {
+ if (mTabWidth < 2) {
+ mTabWidth = mSwipeView.getWidth();
+ }
+
+ mSwipeStartX = e.getRawX();
+
+ mVelocityTracker = VelocityTracker.obtain();
+ mVelocityTracker.addMovement(e);
+ }
+
+ view.onTouchEvent(e);
+ return true;
+ }
+
+ case MotionEvent.ACTION_UP: {
+ if (mSwipeView == null) {
+ break;
+ }
+
+ cancelCheckForTap();
+ mSwipeView.setPressed(false);
+
+ if (!mSwiping) {
+ final TabsLayoutItemView item = (TabsLayoutItemView) mSwipeView;
+ final int tabId = item.getTabId();
+ final Tab tab = Tabs.getInstance().selectTab(tabId);
+ if (tab != null) {
+ autoHidePanel();
+ Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY);
+ }
+
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ break;
+ }
+
+ mVelocityTracker.addMovement(e);
+ mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
+
+ float velocityX = Math.abs(mVelocityTracker.getXVelocity());
+
+ boolean dismiss = false;
+
+ float deltaX = mSwipeView.getTranslationX();
+
+ if (Math.abs(deltaX) > mTabWidth / 2) {
+ dismiss = true;
+ } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity) {
+ dismiss = mSwiping && (deltaX * mVelocityTracker.getYVelocity() > 0);
+ }
+ if (dismiss) {
+ closeTab(mSwipeView.findViewById(R.id.close));
+ } else {
+ animateCancel(mSwipeView);
+ }
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ mSwipeView = null;
+
+ mSwipeStartX = 0;
+ mSwiping = false;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ if (mSwipeView == null || mVelocityTracker == null) {
+ break;
+ }
+
+ mVelocityTracker.addMovement(e);
+
+ float delta = e.getRawX() - mSwipeStartX;
+
+ boolean isScrollingX = Math.abs(delta) > mSwipeThreshold;
+ boolean isSwipingToClose = isScrollingX;
+
+ // If we're actually swiping, make sure we don't
+ // set pressed state on the swiped view.
+ if (isScrollingX) {
+ cancelCheckForTap();
+ }
+
+ if (isSwipingToClose) {
+ mSwiping = true;
+ TabsGridLayout.this.requestDisallowInterceptTouchEvent(true);
+
+ ((TabsLayoutItemView) mSwipeView).setCloseVisible(false);
+
+ // Stops listview from highlighting the touched item
+ // in the list when swiping.
+ MotionEvent cancelEvent = MotionEvent.obtain(e);
+ cancelEvent.setAction(MotionEvent.ACTION_CANCEL |
+ (e.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ TabsGridLayout.this.onTouchEvent(cancelEvent);
+ cancelEvent.recycle();
+ }
+
+ if (mSwiping) {
+ mSwipeView.setTranslationX(delta);
+
+ mSwipeView.setAlpha(Math.min(1f, 1f - 2f * Math.abs(delta) / mTabWidth));
+
+ return true;
+ }
+
+ break;
+ }
+ }
+ return false;
+ }
+
+ private View findViewAt(float rawX, float rawY) {
+ Rect rect = new Rect();
+
+ int[] listViewCoords = new int[2];
+ TabsGridLayout.this.getLocationOnScreen(listViewCoords);
+
+ int x = (int) rawX - listViewCoords[0];
+ int y = (int) rawY - listViewCoords[1];
+
+ for (int i = 0; i < TabsGridLayout.this.getChildCount(); i++) {
+ View child = TabsGridLayout.this.getChildAt(i);
+ child.getHitRect(rect);
+
+ if (rect.contains(x, y)) {
+ return child;
+ }
+ }
+
+ return null;
+ }
+
+ private void triggerCheckForTap() {
+ if (mPendingCheckForTap == null) {
+ mPendingCheckForTap = new CheckForTap();
+ }
+
+ TabsGridLayout.this.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
+ }
+
+ private void cancelCheckForTap() {
+ if (mPendingCheckForTap == null) {
+ return;
+ }
+
+ TabsGridLayout.this.removeCallbacks(mPendingCheckForTap);
+ }
+
+ private class CheckForTap implements Runnable {
+ @Override
+ public void run() {
+ if (!mSwiping && mSwipeView != null && mEnabled) {
+ mSwipeView.setPressed(true);
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java
new file mode 100644
index 000000000..d5362f1f1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java
@@ -0,0 +1,216 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.widget.RecyclerViewClickSupport;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.support.v7.widget.RecyclerView;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.Button;
+
+import java.util.ArrayList;
+
+public abstract class TabsLayout extends RecyclerView
+ implements TabsPanel.TabsLayout,
+ Tabs.OnTabsChangedListener,
+ RecyclerViewClickSupport.OnItemClickListener,
+ TabsTouchHelperCallback.DismissListener {
+
+ private static final String LOGTAG = "Gecko" + TabsLayout.class.getSimpleName();
+
+ private final boolean isPrivate;
+ private TabsPanel tabsPanel;
+ private final TabsLayoutRecyclerAdapter tabsAdapter;
+
+ public TabsLayout(Context context, AttributeSet attrs, int itemViewLayoutResId) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout);
+ isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1);
+ a.recycle();
+
+ tabsAdapter = new TabsLayoutRecyclerAdapter(context, itemViewLayoutResId, isPrivate,
+ /* close on click listener */
+ new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // The view here is the close button, which has a reference
+ // to the parent TabsLayoutItemView in its tag, hence the getTag() call.
+ TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag();
+ closeTab(itemView);
+ }
+ });
+ setAdapter(tabsAdapter);
+
+ RecyclerViewClickSupport.addTo(this).setOnItemClickListener(this);
+
+ setRecyclerListener(new RecyclerListener() {
+ @Override
+ public void onViewRecycled(RecyclerView.ViewHolder holder) {
+ final TabsLayoutItemView itemView = (TabsLayoutItemView) holder.itemView;
+ itemView.setThumbnail(null);
+ itemView.setCloseVisible(true);
+ }
+ });
+ }
+
+ @Override
+ public void setTabsPanel(TabsPanel panel) {
+ tabsPanel = panel;
+ }
+
+ @Override
+ public void show() {
+ setVisibility(View.VISIBLE);
+ Tabs.getInstance().refreshThumbnails();
+ Tabs.registerOnTabsChangedListener(this);
+ refreshTabsData();
+ }
+
+ @Override
+ public void hide() {
+ setVisibility(View.GONE);
+ Tabs.unregisterOnTabsChangedListener(this);
+ GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", "");
+ tabsAdapter.clear();
+ }
+
+ @Override
+ public boolean shouldExpand() {
+ return true;
+ }
+
+ protected void autoHidePanel() {
+ tabsPanel.autoHidePanel();
+ }
+
+ @Override
+ public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
+ switch (msg) {
+ case ADDED:
+ final int tabIndex = Integer.parseInt(data);
+ tabsAdapter.notifyTabInserted(tab, tabIndex);
+ if (addAtIndexRequiresScroll(tabIndex)) {
+ // (The current Tabs implementation updates the SELECTED tab *after* this
+ // call to ADDED, so don't just call updateSelectedPosition().)
+ scrollToPosition(tabIndex);
+ }
+ break;
+
+ case CLOSED:
+ if (tab.isPrivate() == isPrivate && tabsAdapter.getItemCount() > 0) {
+ tabsAdapter.removeTab(tab);
+ }
+ break;
+
+ case SELECTED:
+ case UNSELECTED:
+ case THUMBNAIL:
+ case TITLE:
+ case RECORDING_CHANGE:
+ case AUDIO_PLAYING_CHANGE:
+ tabsAdapter.notifyTabChanged(tab);
+ break;
+ }
+ }
+
+ // Addition of a tab at selected positions (dependent on LayoutManager) will result in a tab
+ // being added out of view - return true if index is such a position.
+ abstract protected boolean addAtIndexRequiresScroll(int index);
+
+ @Override
+ public void onItemClicked(RecyclerView recyclerView, int position, View v) {
+ final TabsLayoutItemView item = (TabsLayoutItemView) v;
+ final int tabId = item.getTabId();
+ final Tab tab = Tabs.getInstance().selectTab(tabId);
+ if (tab == null) {
+ // The tab that was clicked no longer exists in the tabs list (which can happen if you
+ // tap on a tab while its remove animation is running), so ignore the click.
+ return;
+ }
+
+ autoHidePanel();
+ Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY);
+ }
+
+ // Updates the selected position in the list so that it will be scrolled to the right place.
+ private void updateSelectedPosition() {
+ final int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab());
+ if (selected != NO_POSITION) {
+ scrollToPosition(selected);
+ }
+ }
+
+ private void refreshTabsData() {
+ // Store a different copy of the tabs, so that we don't have to worry about
+ // accidentally updating it on the wrong thread.
+ final ArrayList<Tab> tabData = new ArrayList<>();
+ final Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder();
+
+ for (final Tab tab : allTabs) {
+ if (tab.isPrivate() == isPrivate) {
+ tabData.add(tab);
+ }
+ }
+
+ tabsAdapter.setTabs(tabData);
+ updateSelectedPosition();
+ }
+
+ private void closeTab(View view) {
+ final TabsLayoutItemView itemView = (TabsLayoutItemView) view;
+ final Tab tab = getTabForView(itemView);
+ if (tab == null) {
+ // We can be null here if this is the second closeTab call resulting from a sufficiently
+ // fast double tap on the close tab button.
+ return;
+ }
+
+ final boolean closingLastTab = tabsAdapter.getItemCount() == 1;
+ Tabs.getInstance().closeTab(tab, true);
+ if (closingLastTab) {
+ autoHidePanel();
+ }
+ }
+
+ protected void closeAllTabs() {
+ final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder();
+ for (final Tab tab : tabs) {
+ // In the normal panel we want to close all tabs (both private and normal),
+ // but in the private panel we only want to close private tabs.
+ if (!isPrivate || tab.isPrivate()) {
+ Tabs.getInstance().closeTab(tab, false);
+ }
+ }
+ }
+
+ @Override
+ public void onItemDismiss(View view) {
+ closeTab(view);
+ }
+
+ private Tab getTabForView(View view) {
+ if (view == null) {
+ return null;
+ }
+ return Tabs.getInstance().getTab(((TabsLayoutItemView) view).getTabId());
+ }
+
+ @Override
+ public void setEmptyView(View emptyView) {
+ // We never display an empty view.
+ }
+
+ @Override
+ abstract public void closeAll();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java
new file mode 100644
index 000000000..367da640f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java
@@ -0,0 +1,100 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import java.util.ArrayList;
+
+// Adapter to bind tabs into a list
+public class TabsLayoutAdapter extends BaseAdapter {
+ public static final String LOGTAG = "Gecko" + TabsLayoutAdapter.class.getSimpleName();
+
+ private final Context mContext;
+ private final int mTabLayoutId;
+ private ArrayList<Tab> mTabs;
+ private final LayoutInflater mInflater;
+
+ public TabsLayoutAdapter (Context context, int tabLayoutId) {
+ mContext = context;
+ mInflater = LayoutInflater.from(mContext);
+ mTabLayoutId = tabLayoutId;
+ }
+
+ final void setTabs (ArrayList<Tab> tabs) {
+ mTabs = tabs;
+ notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
+ }
+
+ final boolean removeTab (Tab tab) {
+ boolean tabRemoved = mTabs.remove(tab);
+ if (tabRemoved) {
+ notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
+ }
+ return tabRemoved;
+ }
+
+ final void clear() {
+ mTabs = null;
+
+ notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
+ }
+
+ @Override
+ public int getCount() {
+ return (mTabs == null ? 0 : mTabs.size());
+ }
+
+ @Override
+ public Tab getItem(int position) {
+ return mTabs.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ final int getPositionForTab(Tab tab) {
+ if (mTabs == null || tab == null)
+ return -1;
+
+ return mTabs.indexOf(tab);
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return true;
+ }
+
+ @Override
+ final public TabsLayoutItemView getView(int position, View convertView, ViewGroup parent) {
+ final TabsLayoutItemView view;
+ if (convertView == null) {
+ view = newView(position, parent);
+ } else {
+ view = (TabsLayoutItemView) convertView;
+ }
+ final Tab tab = mTabs.get(position);
+ bindView(view, tab);
+ return view;
+ }
+
+ TabsLayoutItemView newView(int position, ViewGroup parent) {
+ return (TabsLayoutItemView) mInflater.inflate(mTabLayoutId, parent, false);
+ }
+
+ void bindView(TabsLayoutItemView view, Tab tab) {
+ view.assignValues(tab);
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java
new file mode 100644
index 000000000..975e779d6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.widget.TabThumbnailWrapper;
+import org.mozilla.gecko.widget.TouchDelegateWithReset;
+import org.mozilla.gecko.widget.themed.ThemedRelativeLayout;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.widget.Checkable;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+public class TabsLayoutItemView extends LinearLayout
+ implements Checkable {
+ private static final String LOGTAG = "Gecko" + TabsLayoutItemView.class.getSimpleName();
+ private static final int[] STATE_CHECKED = { android.R.attr.state_checked };
+ private boolean mChecked;
+
+ private int mTabId;
+ private TextView mTitle;
+ private TabsPanelThumbnailView mThumbnail;
+ private ImageView mCloseButton;
+ private TabThumbnailWrapper mThumbnailWrapper;
+
+ public TabsLayoutItemView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (mChecked) {
+ mergeDrawableStates(drawableState, STATE_CHECKED);
+ }
+
+ return drawableState;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mChecked;
+ }
+
+ @Override
+ public void setChecked(boolean checked) {
+ if (mChecked == checked) {
+ return;
+ }
+
+ mChecked = checked;
+ refreshDrawableState();
+
+ int count = getChildCount();
+ for (int i = 0; i < count; i++) {
+ final View child = getChildAt(i);
+ if (child instanceof Checkable) {
+ ((Checkable) child).setChecked(checked);
+ }
+ }
+ }
+
+ @Override
+ public void toggle() {
+ mChecked = !mChecked;
+ }
+
+ public void setCloseOnClickListener(OnClickListener mOnClickListener) {
+ mCloseButton.setOnClickListener(mOnClickListener);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mTitle = (TextView) findViewById(R.id.title);
+ mThumbnail = (TabsPanelThumbnailView) findViewById(R.id.thumbnail);
+ mCloseButton = (ImageView) findViewById(R.id.close);
+ mThumbnailWrapper = (TabThumbnailWrapper) findViewById(R.id.wrapper);
+
+ growCloseButtonHitArea();
+ }
+
+ private void growCloseButtonHitArea() {
+ getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ getViewTreeObserver().removeOnPreDrawListener(this);
+
+ // Ideally we want the close button hit area to be 40x40dp but we are constrained by the height of the parent, so
+ // we make it as tall as the parent view and 40dp across.
+ final int targetHitArea = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40, getResources().getDisplayMetrics());;
+
+ final Rect hitRect = new Rect();
+ hitRect.top = 0;
+ hitRect.right = getWidth();
+ hitRect.left = getWidth() - targetHitArea;
+ hitRect.bottom = targetHitArea;
+
+ setTouchDelegate(new TouchDelegateWithReset(hitRect, mCloseButton));
+
+ return true;
+ }
+ });
+ }
+
+ protected void assignValues(Tab tab) {
+ if (tab == null) {
+ return;
+ }
+
+ mTabId = tab.getId();
+
+ setChecked(Tabs.getInstance().isSelectedTab(tab));
+
+ Drawable thumbnailImage = tab.getThumbnail();
+ mThumbnail.setImageDrawable(thumbnailImage);
+
+ mThumbnail.setPrivateMode(tab.isPrivate());
+
+ if (mThumbnailWrapper != null) {
+ mThumbnailWrapper.setRecording(tab.isRecording());
+ }
+
+ final String tabTitle = tab.getDisplayTitle();
+ mTitle.setText(tabTitle);
+ mCloseButton.setTag(this);
+
+ if (tab.isAudioPlaying()) {
+ mTitle.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0);
+ final String tabTitleWithAudio =
+ getResources().getString(R.string.tab_title_prefix_is_playing_audio, tabTitle);
+ mTitle.setContentDescription(tabTitleWithAudio);
+ } else {
+ mTitle.setCompoundDrawables(null, null, null, null);
+ mTitle.setContentDescription(tabTitle);
+ }
+ }
+
+ public int getTabId() {
+ return mTabId;
+ }
+
+ public void setThumbnail(Drawable thumbnail) {
+ mThumbnail.setImageDrawable(thumbnail);
+ }
+
+ public void setCloseVisible(boolean visible) {
+ mCloseButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE);
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ ((ThemedRelativeLayout) findViewById(R.id.wrapper)).setPrivateMode(isPrivate);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java
new file mode 100644
index 000000000..090d74f9d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java
@@ -0,0 +1,124 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.Tab;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import java.util.ArrayList;
+
+public class TabsLayoutRecyclerAdapter
+ extends RecyclerView.Adapter<TabsLayoutRecyclerAdapter.TabsListViewHolder> {
+
+ private static final String LOGTAG = "Gecko" + TabsLayoutRecyclerAdapter.class.getSimpleName();
+
+ private final int tabLayoutId;
+ private @NonNull ArrayList<Tab> tabs;
+ private final LayoutInflater inflater;
+ private final boolean isPrivate;
+ // Click listener for the close button on itemViews.
+ private final Button.OnClickListener closeOnClickListener;
+
+ // The TabsLayoutItemView takes care of caching its own Views, so we don't need to do anything
+ // here except not be abstract.
+ public static class TabsListViewHolder extends RecyclerView.ViewHolder {
+ public TabsListViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+
+ public TabsLayoutRecyclerAdapter(Context context, int tabLayoutId, boolean isPrivate,
+ Button.OnClickListener closeOnClickListener) {
+ inflater = LayoutInflater.from(context);
+ this.tabLayoutId = tabLayoutId;
+ this.isPrivate = isPrivate;
+ this.closeOnClickListener = closeOnClickListener;
+ tabs = new ArrayList<>(0);
+ }
+
+ /* package */ final void setTabs(@NonNull ArrayList<Tab> tabs) {
+ this.tabs = tabs;
+ notifyDataSetChanged();
+ }
+
+ /* package */ final void clear() {
+ tabs = new ArrayList<>(0);
+ notifyDataSetChanged();
+ }
+
+ /* package */ final boolean removeTab(Tab tab) {
+ final int position = getPositionForTab(tab);
+ if (position == -1) {
+ return false;
+ }
+ tabs.remove(position);
+ notifyItemRemoved(position);
+ return true;
+ }
+
+ /* package */ final int getPositionForTab(Tab tab) {
+ if (tab == null) {
+ return -1;
+ }
+
+ return tabs.indexOf(tab);
+ }
+
+ /* package */ void notifyTabChanged(Tab tab) {
+ notifyItemChanged(getPositionForTab(tab));
+ }
+
+ /* package */ void notifyTabInserted(Tab tab, int index) {
+ if (index >= 0 && index <= tabs.size()) {
+ tabs.add(index, tab);
+ notifyItemInserted(index);
+ } else {
+ // Add to the end.
+ tabs.add(tab);
+ notifyItemInserted(tabs.size() - 1);
+ // index == -1 is a valid way to add to the end, the other cases are errors.
+ if (index != -1) {
+ Log.e(LOGTAG, "Tab was inserted at an invalid position: " + Integer.toString(index));
+ }
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return tabs.size();
+ }
+
+ private Tab getItem(int position) {
+ return tabs.get(position);
+ }
+
+ @Override
+ public void onBindViewHolder(TabsListViewHolder viewHolder, int position) {
+ final Tab tab = getItem(position);
+ final TabsLayoutItemView itemView = (TabsLayoutItemView) viewHolder.itemView;
+ itemView.assignValues(tab);
+ // Be careful (re)setting position values here: bind is called on each notifyItemChanged,
+ // so you could be stomping on values that have been set in support of other animations
+ // that are already underway.
+ }
+
+ @Override
+ public TabsListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ final TabsLayoutItemView viewItem = (TabsLayoutItemView) inflater.inflate(tabLayoutId, parent, false);
+ viewItem.setPrivateMode(isPrivate);
+ viewItem.setCloseOnClickListener(closeOnClickListener);
+
+ return new TabsListViewHolder(viewItem);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java
new file mode 100644
index 000000000..8cf2f8ede
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java
@@ -0,0 +1,118 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.helper.ItemTouchHelper;
+import android.util.AttributeSet;
+import android.view.View;
+
+public class TabsListLayout extends TabsLayout {
+ // Time to animate non-flinged tabs of screen, in milliseconds
+ private static final int ANIMATION_DURATION = 250;
+
+ // Time between starting successive tab animations in closeAllTabs.
+ private static final int ANIMATION_CASCADE_DELAY = 75;
+
+ private int closeAllAnimationCount;
+
+ public TabsListLayout(Context context, AttributeSet attrs) {
+ super(context, attrs, R.layout.tabs_list_item_view);
+
+ setHasFixedSize(true);
+
+ setLayoutManager(new LinearLayoutManager(context));
+
+ // A TouchHelper handler for swipe to close.
+ final TabsTouchHelperCallback callback = new TabsTouchHelperCallback(this);
+ final ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
+ touchHelper.attachToRecyclerView(this);
+
+ setItemAnimator(new TabsListLayoutAnimator(ANIMATION_DURATION));
+ }
+
+ @Override
+ public void closeAll() {
+ final int childCount = getChildCount();
+
+ // Just close the panel if there are no tabs to close.
+ if (childCount == 0) {
+ autoHidePanel();
+ return;
+ }
+
+ // Disable the view so that gestures won't interfere wth the tab close animation.
+ setEnabled(false);
+
+ // Delay starting each successive animation to create a cascade effect.
+ int cascadeDelay = 0;
+ closeAllAnimationCount = 0;
+ for (int i = childCount - 1; i >= 0; i--) {
+ final View view = getChildAt(i);
+ if (view == null) {
+ continue;
+ }
+
+ final PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
+ animator.attach(view, PropertyAnimator.Property.ALPHA, 0);
+
+ animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, view.getWidth());
+
+ closeAllAnimationCount++;
+
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ closeAllAnimationCount--;
+ if (closeAllAnimationCount > 0) {
+ return;
+ }
+
+ // Hide the panel after the animation is done.
+ autoHidePanel();
+
+ // Re-enable the view after the animation is done.
+ TabsListLayout.this.setEnabled(true);
+
+ // Then actually close all the tabs.
+ closeAllTabs();
+ }
+ });
+
+ ThreadUtils.postDelayedToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ animator.start();
+ }
+ }, cascadeDelay);
+
+ cascadeDelay += ANIMATION_CASCADE_DELAY;
+ }
+ }
+
+ @Override
+ protected boolean addAtIndexRequiresScroll(int index) {
+ return index == 0 || index == getAdapter().getItemCount() - 1;
+ }
+
+ @Override
+ public void onChildAttachedToWindow(View child) {
+ // Make sure we reset any attributes that may have been animated in this child's previous
+ // incarnation.
+ child.setTranslationX(0);
+ child.setTranslationY(0);
+ child.setAlpha(1);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java
new file mode 100644
index 000000000..471abf883
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java
@@ -0,0 +1,65 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.widget.DefaultItemAnimatorBase;
+
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+class TabsListLayoutAnimator extends DefaultItemAnimatorBase {
+ public TabsListLayoutAnimator(int animationDuration) {
+ setRemoveDuration(animationDuration);
+ setAddDuration(animationDuration);
+ // A fade in/out each time the title/thumbnail/etc. gets updated isn't helpful, so disable
+ // the change animation.
+ setSupportsChangeAnimations(false);
+ }
+
+ @Override
+ protected boolean preAnimateRemoveImpl(final RecyclerView.ViewHolder holder) {
+ // If the view isn't at full alpha then we were closed by a swipe which an
+ // ItemTouchHelper is animating for us, so just return without animating the remove and
+ // let runPendingAnimations pick up the rest.
+ if (holder.itemView.getAlpha() < 1) {
+ return false;
+ }
+ resetAnimation(holder);
+ return true;
+ }
+
+ @Override
+ protected void animateRemoveImpl(final RecyclerView.ViewHolder holder) {
+ final View itemView = holder.itemView;
+ ViewCompat.animate(itemView)
+ .setDuration(getRemoveDuration())
+ .translationX(itemView.getWidth())
+ .alpha(0)
+ .setListener(new DefaultRemoveVpaListener(holder))
+ .start();
+ }
+
+ @Override
+ protected boolean preAnimateAddImpl(RecyclerView.ViewHolder holder) {
+ resetAnimation(holder);
+ final View itemView = holder.itemView;
+ itemView.setTranslationX(itemView.getWidth());
+ itemView.setAlpha(0);
+ return true;
+ }
+
+ @Override
+ protected void animateAddImpl(final RecyclerView.ViewHolder holder) {
+ final View itemView = holder.itemView;
+ ViewCompat.animate(itemView)
+ .setDuration(getAddDuration())
+ .translationX(0)
+ .alpha(1)
+ .setListener(new DefaultAddVpaListener(holder))
+ .start();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java
new file mode 100644
index 000000000..2be127010
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java
@@ -0,0 +1,456 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.lwt.LightweightThemeDrawable;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.widget.GeckoPopupMenu;
+import org.mozilla.gecko.widget.IconTabWidget;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+
+public class TabsPanel extends LinearLayout
+ implements GeckoPopupMenu.OnMenuItemClickListener,
+ LightweightTheme.OnChangeListener,
+ IconTabWidget.OnTabChangedListener {
+ private static final String LOGTAG = "Gecko" + TabsPanel.class.getSimpleName();
+
+ public enum Panel {
+ NORMAL_TABS,
+ PRIVATE_TABS,
+ }
+
+ public interface PanelView {
+ void setTabsPanel(TabsPanel panel);
+ void show();
+ void hide();
+ boolean shouldExpand();
+ }
+
+ public interface CloseAllPanelView extends PanelView {
+ void closeAll();
+ }
+
+ public interface TabsLayout extends CloseAllPanelView {
+ void setEmptyView(View view);
+ }
+
+ public interface TabsLayoutChangeListener {
+ void onTabsLayoutChange(int width, int height);
+ }
+
+ public static View createTabsLayout(final Context context, final AttributeSet attrs) {
+ final boolean isLandscape = context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+
+ if (HardwareUtils.isTablet() || isLandscape) {
+ return new TabsGridLayout(context, attrs);
+ } else {
+ return new TabsListLayout(context, attrs);
+ }
+ }
+
+ private final Context mContext;
+ private final GeckoApp mActivity;
+ private final LightweightTheme mTheme;
+ private RelativeLayout mHeader;
+ private FrameLayout mTabsContainer;
+ private PanelView mPanel;
+ private PanelView mPanelNormal;
+ private PanelView mPanelPrivate;
+ private TabsLayoutChangeListener mLayoutChangeListener;
+
+ private IconTabWidget mTabWidget;
+ private View mMenuButton;
+ private ImageButton mAddTab;
+ private ImageButton mNavBackButton;
+
+ private Panel mCurrentPanel;
+ private boolean mVisible;
+ private boolean mHeaderVisible;
+
+ private final GeckoPopupMenu mPopupMenu;
+
+ public TabsPanel(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ mActivity = (GeckoApp) context;
+ mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme();
+
+ mCurrentPanel = Panel.NORMAL_TABS;
+
+ mPopupMenu = new GeckoPopupMenu(context);
+ mPopupMenu.inflate(R.menu.tabs_menu);
+ mPopupMenu.setOnMenuItemClickListener(this);
+
+ inflateLayout(context);
+ initialize();
+ }
+
+ private void inflateLayout(Context context) {
+ LayoutInflater.from(context).inflate(R.layout.tabs_panel_default, this);
+ }
+
+ private void initialize() {
+ mHeader = (RelativeLayout) findViewById(R.id.tabs_panel_header);
+ mTabsContainer = (FrameLayout) findViewById(R.id.tabs_container);
+
+ mPanelNormal = (PanelView) findViewById(R.id.normal_tabs);
+ mPanelNormal.setTabsPanel(this);
+
+ mPanelPrivate = (PanelView) findViewById(R.id.private_tabs_panel);
+ mPanelPrivate.setTabsPanel(this);
+
+ mAddTab = (ImageButton) findViewById(R.id.add_tab);
+ mAddTab.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ TabsPanel.this.addTab();
+ }
+ });
+
+ mTabWidget = (IconTabWidget) findViewById(R.id.tab_widget);
+
+ mTabWidget.addTab(R.drawable.tabs_normal, R.string.tabs_normal);
+ final ThemedImageButton privateTabsPanel =
+ (ThemedImageButton) mTabWidget.addTab(R.drawable.tabs_private, R.string.tabs_private);
+ privateTabsPanel.setPrivateMode(true);
+
+ if (!Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING)) {
+ mTabWidget.setVisibility(View.GONE);
+ }
+
+ mTabWidget.setTabSelectionListener(this);
+
+ mMenuButton = findViewById(R.id.menu);
+ mMenuButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ showMenu();
+ }
+ });
+
+ mNavBackButton = (ImageButton) findViewById(R.id.nav_back);
+ mNavBackButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mActivity.onBackPressed();
+ }
+ });
+ }
+
+ public void showMenu() {
+ final Menu menu = mPopupMenu.getMenu();
+
+ // Each panel has a "+" shortcut button, so don't show it for that panel.
+ menu.findItem(R.id.new_tab).setVisible(mCurrentPanel != Panel.NORMAL_TABS);
+ menu.findItem(R.id.new_private_tab).setVisible(mCurrentPanel != Panel.PRIVATE_TABS
+ && Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING));
+
+ // Only show "Clear * tabs" for current panel.
+ menu.findItem(R.id.close_all_tabs).setVisible(mCurrentPanel == Panel.NORMAL_TABS);
+ menu.findItem(R.id.close_private_tabs).setVisible(mCurrentPanel == Panel.PRIVATE_TABS);
+
+ mPopupMenu.show();
+ }
+
+ private void addTab() {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "new_tab");
+
+ if (mCurrentPanel == Panel.NORMAL_TABS) {
+ mActivity.addTab();
+ } else {
+ mActivity.addPrivateTab();
+ }
+
+ mActivity.autoHideTabs();
+ }
+
+ @Override
+ public void onTabChanged(int index) {
+ if (index == 0) {
+ show(Panel.NORMAL_TABS);
+ } else {
+ show(Panel.PRIVATE_TABS);
+ }
+ }
+
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ final int itemId = item.getItemId();
+
+ if (itemId == R.id.close_all_tabs) {
+ if (mCurrentPanel == Panel.NORMAL_TABS) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs");
+
+ // Disable the menu button so that the menu won't interfere with the tab close animation.
+ mMenuButton.setEnabled(false);
+ ((CloseAllPanelView) mPanelNormal).closeAll();
+ } else {
+ Log.e(LOGTAG, "Close all tabs menu item should only be visible for normal tabs panel");
+ }
+ return true;
+ }
+
+ if (itemId == R.id.close_private_tabs) {
+ if (mCurrentPanel == Panel.PRIVATE_TABS) {
+ // Mask private browsing
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs");
+
+ ((CloseAllPanelView) mPanelPrivate).closeAll();
+ } else {
+ Log.e(LOGTAG, "Close private tabs menu item should only be visible for private tabs panel");
+ }
+ return true;
+ }
+
+ if (itemId == R.id.new_tab || itemId == R.id.new_private_tab) {
+ hide();
+ }
+
+ return mActivity.onOptionsItemSelected(item);
+ }
+
+ private static int getTabContainerHeight(FrameLayout tabsContainer) {
+ final Resources resources = tabsContainer.getContext().getResources();
+
+ final int screenHeight = resources.getDisplayMetrics().heightPixels;
+ final int actionBarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height);
+
+ return screenHeight - actionBarHeight;
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mTheme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mTheme.removeListener(this);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16
+ public void onLightweightThemeChanged() {
+ final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey);
+ final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background, true);
+ if (drawable == null)
+ return;
+
+ drawable.setAlpha(34, 0);
+ setBackgroundDrawable(drawable);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ // Tabs Panel Toolbar contains the Buttons
+ static class TabsPanelToolbar extends LinearLayout
+ implements LightweightTheme.OnChangeListener {
+ private final LightweightTheme mTheme;
+
+ public TabsPanelToolbar(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mTheme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mTheme.removeListener(this);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16
+ public void onLightweightThemeChanged() {
+ final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey);
+ final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background);
+ if (drawable == null)
+ return;
+
+ drawable.setAlpha(34, 34);
+ setBackgroundDrawable(drawable);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+ }
+
+ public void show(Panel panelToShow) {
+ prepareToShow(panelToShow);
+ int height = getVerticalPanelHeight();
+ dispatchLayoutChange(getWidth(), height);
+ mHeaderVisible = true;
+ }
+
+ public void prepareToShow(Panel panelToShow) {
+ if (!isShown()) {
+ setVisibility(View.VISIBLE);
+ }
+
+ if (mPanel != null) {
+ // Hide the old panel.
+ mPanel.hide();
+ }
+
+ mVisible = true;
+ mCurrentPanel = panelToShow;
+
+ int index = panelToShow.ordinal();
+ mTabWidget.setCurrentTab(index);
+
+ switch (panelToShow) {
+ case NORMAL_TABS:
+ mPanel = mPanelNormal;
+ break;
+ case PRIVATE_TABS:
+ mPanel = mPanelPrivate;
+ break;
+
+ default:
+ throw new IllegalArgumentException("Unknown panel type " + panelToShow);
+ }
+ mPanel.show();
+
+ mAddTab.setVisibility(View.VISIBLE);
+
+ mMenuButton.setEnabled(true);
+ mPopupMenu.setAnchor(mMenuButton);
+ }
+
+ public int getVerticalPanelHeight() {
+ final int actionBarHeight = mContext.getResources().getDimensionPixelSize(R.dimen.browser_toolbar_height);
+ final int height = actionBarHeight + getTabContainerHeight(mTabsContainer);
+ return height;
+ }
+
+ public void hide() {
+ mHeaderVisible = false;
+
+ if (mVisible) {
+ mVisible = false;
+ mPopupMenu.dismiss();
+ dispatchLayoutChange(0, 0);
+ }
+ }
+
+ public void refresh() {
+ removeAllViews();
+
+ inflateLayout(mContext);
+ initialize();
+
+ if (mVisible)
+ show(mCurrentPanel);
+ }
+
+ public void autoHidePanel() {
+ mActivity.autoHideTabs();
+ }
+
+ @Override
+ public boolean isShown() {
+ return mVisible;
+ }
+
+ public void setHWLayerEnabled(boolean enabled) {
+ if (enabled) {
+ mHeader.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ mTabsContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null);
+ } else {
+ mHeader.setLayerType(View.LAYER_TYPE_NONE, null);
+ mTabsContainer.setLayerType(View.LAYER_TYPE_NONE, null);
+ }
+ }
+
+ public void prepareTabsAnimation(PropertyAnimator animator) {
+ if (!mHeaderVisible) {
+ final Resources resources = getContext().getResources();
+ final int toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height);
+ final int translationY = (mVisible ? 0 : -toolbarHeight);
+ if (mVisible) {
+ ViewHelper.setTranslationY(mHeader, -toolbarHeight);
+ ViewHelper.setTranslationY(mTabsContainer, -toolbarHeight);
+ ViewHelper.setAlpha(mTabsContainer, 0.0f);
+ }
+ animator.attach(mTabsContainer, PropertyAnimator.Property.ALPHA, mVisible ? 1.0f : 0.0f);
+ animator.attach(mTabsContainer, PropertyAnimator.Property.TRANSLATION_Y, translationY);
+ animator.attach(mHeader, PropertyAnimator.Property.TRANSLATION_Y, translationY);
+ }
+
+ setHWLayerEnabled(true);
+ }
+
+ public void finishTabsAnimation() {
+ setHWLayerEnabled(false);
+
+ // If the tray is now hidden, call hide() on current panel and unset it as the current panel
+ // to avoid hide() being called again when the layout is opened next.
+ if (!mVisible && mPanel != null) {
+ mPanel.hide();
+ mPanel = null;
+ }
+ }
+
+ public void setTabsLayoutChangeListener(TabsLayoutChangeListener listener) {
+ mLayoutChangeListener = listener;
+ }
+
+ private void dispatchLayoutChange(int width, int height) {
+ if (mLayoutChangeListener != null)
+ mLayoutChangeListener.onTabsLayoutChange(width, height);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java
new file mode 100644
index 000000000..09254bf76
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java
@@ -0,0 +1,52 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import org.mozilla.gecko.widget.CropImageView;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+/**
+ * A width constrained ImageView to show thumbnails of open tabs in the tabs panel.
+ */
+public class TabsPanelThumbnailView extends CropImageView {
+ public static final String LOGTAG = "Gecko" + TabsPanelThumbnailView.class.getSimpleName();
+
+
+ public TabsPanelThumbnailView(final Context context) {
+ this(context, null);
+ }
+
+ public TabsPanelThumbnailView(final Context context, final AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public TabsPanelThumbnailView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected float getAspectRatio() {
+ return ThumbnailHelper.TABS_PANEL_THUMBNAIL_ASPECT_RATIO;
+ }
+
+ @Override
+ public void setImageDrawable(Drawable drawable) {
+ boolean resize = true;
+
+ if (drawable == null) {
+ drawable = getResources().getDrawable(R.drawable.tab_panel_tab_background);
+ resize = false;
+ setScaleType(ScaleType.FIT_XY);
+ }
+
+ super.setImageDrawable(drawable, resize);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java
new file mode 100644
index 000000000..36e9e4739
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java
@@ -0,0 +1,69 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tabs;
+
+import android.graphics.Canvas;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.helper.ItemTouchHelper;
+import android.view.View;
+
+class TabsTouchHelperCallback extends ItemTouchHelper.Callback {
+ private final DismissListener dismissListener;
+
+ interface DismissListener {
+ void onItemDismiss(View view);
+ }
+
+ public TabsTouchHelperCallback(DismissListener dismissListener) {
+ this.dismissListener = dismissListener;
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return true;
+ }
+
+ @Override
+ public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT);
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) {
+ dismissListener.onItemDismiss(viewHolder.itemView);
+ }
+
+ @Override
+ public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder,
+ RecyclerView.ViewHolder target) {
+ return false;
+ }
+
+ // Alpha on an itemView being swiped should decrease to a min over a distance equal to the
+ // width of the item being swiped.
+ @Override
+ public void onChildDraw(Canvas c,
+ RecyclerView recyclerView,
+ RecyclerView.ViewHolder viewHolder,
+ float dX,
+ float dY,
+ int actionState,
+ boolean isCurrentlyActive) {
+ if (actionState != ItemTouchHelper.ACTION_STATE_SWIPE) {
+ return;
+ }
+
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+
+ viewHolder.itemView.setAlpha(Math.max(0.1f,
+ Math.min(1f, 1f - 2f * Math.abs(dX) / viewHolder.itemView.getWidth())));
+ }
+
+ public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ super.clearView(recyclerView, viewHolder);
+ viewHolder.itemView.setAlpha(1);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java
new file mode 100644
index 000000000..6ed4bb0d4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryConstants.java
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.telemetry;
+
+import org.mozilla.gecko.AppConstants;
+
+public class TelemetryConstants {
+ // To test, set this to true & change "toolkit.telemetry.server" in about:config.
+ public static final boolean UPLOAD_ENABLED = AppConstants.MOZILLA_OFFICIAL; // Disabled for developer builds.
+
+ public static final String USER_AGENT =
+ "Firefox-Android-Telemetry/" + AppConstants.MOZ_APP_VERSION + " (" + AppConstants.MOZ_APP_UA_NAME + ")";
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java
new file mode 100644
index 000000000..fae674b2d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryCorePingDelegate.java
@@ -0,0 +1,188 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.adjust.AttributionHelperListener;
+import org.mozilla.gecko.telemetry.measurements.CampaignIdMeasurements;
+import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference;
+import org.mozilla.gecko.distribution.DistributionStoreCallback;
+import org.mozilla.gecko.search.SearchEngineManager;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.measurements.SearchCountMeasurements;
+import org.mozilla.gecko.telemetry.measurements.SessionMeasurements;
+import org.mozilla.gecko.telemetry.pingbuilders.TelemetryCorePingBuilder;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+
+/**
+ * An activity-lifecycle delegate for uploading the core ping.
+ */
+public class TelemetryCorePingDelegate extends BrowserAppDelegateWithReference
+ implements SearchEngineManager.SearchEngineCallback, AttributionHelperListener {
+ private static final String LOGTAG = StringUtils.safeSubstring(
+ "Gecko" + TelemetryCorePingDelegate.class.getSimpleName(), 0, 23);
+
+ private static final String PREF_IS_FIRST_RUN = "telemetry-isFirstRun";
+
+ private TelemetryDispatcher telemetryDispatcher; // lazy
+ private final SessionMeasurements sessionMeasurements = new SessionMeasurements();
+
+ @Override
+ public void onStart(final BrowserApp browserApp) {
+ TelemetryPreferences.initPreferenceObserver(browserApp, browserApp.getProfile().getName());
+
+ // We don't upload in onCreate because that's only called when the Activity needs to be instantiated
+ // and it's possible the system will never free the Activity from memory.
+ //
+ // We don't upload in onResume/onPause because that will be called each time the Activity is obscured,
+ // including by our own Activities/dialogs, and there is no reason to upload each time we're unobscured.
+ //
+ // We're left with onStart/onStop and we upload in onStart because onStop is not guaranteed to be called
+ // and we want to upload the first run ASAP (e.g. to get install data before the app may crash).
+ uploadPing(browserApp);
+ }
+
+ @Override
+ public void onStop(final BrowserApp browserApp) {
+ // We've decided to upload primarily in onStart (see note there). However, if it's the first run,
+ // it's possible a user used fennec and decided never to return to it again - it'd be great to get
+ // their session information before they decided to give it up so we upload here on first run.
+ //
+ // Caveats:
+ // * onStop is not guaranteed to be called in low memory conditions so it's possible we won't upload,
+ // but it's better than it was before.
+ // * Besides first run (because of this call), we can never get the user's *last* session data.
+ //
+ // If we are really interested in the user's last session data, we could consider uploading in onStop
+ // but it's less robust (see discussion in bug 1277091).
+ final SharedPreferences sharedPrefs = getSharedPreferences(browserApp);
+ if (sharedPrefs.getBoolean(PREF_IS_FIRST_RUN, true)) {
+ sharedPrefs.edit()
+ .putBoolean(PREF_IS_FIRST_RUN, false)
+ .apply();
+ uploadPing(browserApp);
+ }
+ }
+
+ private void uploadPing(final BrowserApp browserApp) {
+ final SearchEngineManager searchEngineManager = browserApp.getSearchEngineManager();
+ searchEngineManager.getEngine(this);
+ }
+
+ @Override
+ public void onResume(BrowserApp browserApp) {
+ sessionMeasurements.recordSessionStart();
+ }
+
+ @Override
+ public void onPause(BrowserApp browserApp) {
+ // onStart/onStop is ideal over onResume/onPause. However, onStop is not guaranteed to be called and
+ // dealing with that possibility adds a lot of complexity that we don't want to handle at this point.
+ sessionMeasurements.recordSessionEnd(browserApp);
+ }
+
+ @WorkerThread // via constructor
+ private TelemetryDispatcher getTelemetryDispatcher(final BrowserApp browserApp) {
+ if (telemetryDispatcher == null) {
+ final GeckoProfile profile = browserApp.getProfile();
+ final String profilePath = profile.getDir().getAbsolutePath();
+ final String profileName = profile.getName();
+ telemetryDispatcher = new TelemetryDispatcher(profilePath, profileName);
+ }
+ return telemetryDispatcher;
+ }
+
+ private SharedPreferences getSharedPreferences(final BrowserApp activity) {
+ return GeckoSharedPrefs.forProfileName(activity, activity.getProfile().getName());
+ }
+
+ // via SearchEngineCallback - may be called from any thread.
+ @Override
+ public void execute(@Nullable final org.mozilla.gecko.search.SearchEngine engine) {
+ // Don't waste resources queueing to the background thread if we don't have a reference.
+ if (getBrowserApp() == null) {
+ return;
+ }
+
+ // The containing method can be called from onStart: queue this work so that
+ // the first launch of the activity doesn't trigger profile init too early.
+ //
+ // Additionally, getAndIncrementSequenceNumber must be called from a worker thread.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @WorkerThread
+ @Override
+ public void run() {
+ final BrowserApp activity = getBrowserApp();
+ if (activity == null) {
+ return;
+ }
+
+ final GeckoProfile profile = activity.getProfile();
+ if (!TelemetryUploadService.isUploadEnabledByProfileConfig(activity, profile)) {
+ Log.d(LOGTAG, "Core ping upload disabled by profile config. Returning.");
+ return;
+ }
+
+ final String clientID;
+ try {
+ clientID = profile.getClientId();
+ } catch (final IOException e) {
+ Log.w(LOGTAG, "Unable to get client ID to generate core ping: " + e);
+ return;
+ }
+
+ // Each profile can have different telemetry data so we intentionally grab the shared prefs for the profile.
+ final SharedPreferences sharedPrefs = getSharedPreferences(activity);
+ final SessionMeasurements.SessionMeasurementsContainer sessionMeasurementsContainer =
+ sessionMeasurements.getAndResetSessionMeasurements(activity);
+ final TelemetryCorePingBuilder pingBuilder = new TelemetryCorePingBuilder(activity)
+ .setClientID(clientID)
+ .setDefaultSearchEngine(TelemetryCorePingBuilder.getEngineIdentifier(engine))
+ .setProfileCreationDate(TelemetryCorePingBuilder.getProfileCreationDate(activity, profile))
+ .setSequenceNumber(TelemetryCorePingBuilder.getAndIncrementSequenceNumber(sharedPrefs))
+ .setSessionCount(sessionMeasurementsContainer.sessionCount)
+ .setSessionDuration(sessionMeasurementsContainer.elapsedSeconds);
+ maybeSetOptionalMeasurements(activity, sharedPrefs, pingBuilder);
+
+ getTelemetryDispatcher(activity).queuePingForUpload(activity, pingBuilder);
+ }
+ });
+ }
+
+ private void maybeSetOptionalMeasurements(final Context context, final SharedPreferences sharedPrefs,
+ final TelemetryCorePingBuilder pingBuilder) {
+ final String distributionId = sharedPrefs.getString(DistributionStoreCallback.PREF_DISTRIBUTION_ID, null);
+ if (distributionId != null) {
+ pingBuilder.setOptDistributionID(distributionId);
+ }
+
+ final ExtendedJSONObject searchCounts = SearchCountMeasurements.getAndZeroSearch(sharedPrefs);
+ if (searchCounts.size() > 0) {
+ pingBuilder.setOptSearchCounts(searchCounts);
+ }
+
+ final String campaignId = CampaignIdMeasurements.getCampaignIdFromPrefs(context);
+ if (campaignId != null) {
+ pingBuilder.setOptCampaignId(campaignId);
+ }
+ }
+
+ @Override
+ public void onCampaignIdChanged(String campaignId) {
+ CampaignIdMeasurements.updateCampaignIdPref(getBrowserApp(), campaignId);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java
new file mode 100644
index 000000000..c702bb92c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java
@@ -0,0 +1,118 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry;
+
+import android.content.Context;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+import org.mozilla.gecko.telemetry.pingbuilders.TelemetryCorePingBuilder;
+import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadScheduler;
+import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadAllPingsImmediatelyScheduler;
+import org.mozilla.gecko.telemetry.stores.TelemetryJSONFilePingStore;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * The entry-point for Java-based telemetry. This class handles:
+ * * Initializing the Stores & Schedulers.
+ * * Queueing upload requests for a given ping.
+ *
+ * To test Telemetry , see {@link TelemetryConstants} &
+ * https://wiki.mozilla.org/Mobile/Fennec/Android/Java_telemetry.
+ *
+ * The full architecture is:
+ *
+ * Fennec -(PingBuilder)-> Dispatcher -2-> Scheduler -> UploadService
+ * | 1 |
+ * Store <--------------------------
+ *
+ * The store acts as a single store of truth and contains a list of all
+ * pings waiting to be uploaded. The dispatcher will queue a ping to upload
+ * by writing it to the store. Later, the UploadService will try to upload
+ * this queued ping by reading directly from the store.
+ *
+ * To implement a new ping type, you should:
+ * 1) Implement a {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder} for your ping type.
+ * 2) Re-use a ping store in .../stores/ or implement a new one: {@link TelemetryPingStore}. The
+ * type of store may be affected by robustness requirements (e.g. do you have data in addition to
+ * pings that need to be atomically updated when a ping is stored?) and performance requirements.
+ * 3) Re-use an upload scheduler in .../schedulers/ or implement a new one: {@link TelemetryUploadScheduler}.
+ * 4) Initialize your Store & (if new) Scheduler in the constructor of this class
+ * 5) Add a queuePingForUpload method for your PingBuilder class (see
+ * {@link #queuePingForUpload(Context, TelemetryCorePingBuilder)})
+ * 6) In Fennec, where you want to store a ping and attempt upload, create a PingBuilder and
+ * pass it to the new queuePingForUpload method.
+ */
+public class TelemetryDispatcher {
+ private static final String LOGTAG = "Gecko" + TelemetryDispatcher.class.getSimpleName();
+
+ private static final String STORE_CONTAINER_DIR_NAME = "telemetry_java";
+ private static final String CORE_STORE_DIR_NAME = "core";
+
+ private final TelemetryJSONFilePingStore coreStore;
+
+ private final TelemetryUploadAllPingsImmediatelyScheduler uploadAllPingsImmediatelyScheduler;
+
+ @WorkerThread // via TelemetryJSONFilePingStore
+ public TelemetryDispatcher(final String profilePath, final String profileName) {
+ final String storePath = profilePath + File.separator + STORE_CONTAINER_DIR_NAME;
+
+ // There are measurements in the core ping (e.g. seq #) that would ideally be atomically updated
+ // when the ping is stored. However, for simplicity, we use the json store and accept the possible
+ // loss of data (see bug 1243585 comment 16+ for more).
+ coreStore = new TelemetryJSONFilePingStore(new File(storePath, CORE_STORE_DIR_NAME), profileName);
+
+ uploadAllPingsImmediatelyScheduler = new TelemetryUploadAllPingsImmediatelyScheduler();
+ }
+
+ private void queuePingForUpload(final Context context, final TelemetryPing ping, final TelemetryPingStore store,
+ final TelemetryUploadScheduler scheduler) {
+ final QueuePingRunnable runnable = new QueuePingRunnable(context, ping, store, scheduler);
+ ThreadUtils.postToBackgroundThread(runnable); // TODO: Investigate how busy this thread is. See if we want another.
+ }
+
+ /**
+ * Queues the given ping for upload and potentially schedules upload. This method can be called from any thread.
+ */
+ public void queuePingForUpload(final Context context, final TelemetryCorePingBuilder pingBuilder) {
+ final TelemetryPing ping = pingBuilder.build();
+ queuePingForUpload(context, ping, coreStore, uploadAllPingsImmediatelyScheduler);
+ }
+
+ private static class QueuePingRunnable implements Runnable {
+ private final Context applicationContext;
+ private final TelemetryPing ping;
+ private final TelemetryPingStore store;
+ private final TelemetryUploadScheduler scheduler;
+
+ public QueuePingRunnable(final Context context, final TelemetryPing ping, final TelemetryPingStore store,
+ final TelemetryUploadScheduler scheduler) {
+ this.applicationContext = context.getApplicationContext();
+ this.ping = ping;
+ this.store = store;
+ this.scheduler = scheduler;
+ }
+
+ @Override
+ public void run() {
+ // We block while storing the ping so the scheduled upload is guaranteed to have the newly-stored value.
+ try {
+ store.storePing(ping);
+ } catch (final IOException e) {
+ // Don't log exception to avoid leaking profile path.
+ Log.e(LOGTAG, "Unable to write ping to disk. Continuing with upload attempt");
+ }
+
+ if (scheduler.isReadyToUpload(store)) {
+ scheduler.scheduleUpload(applicationContext, store);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
new file mode 100644
index 000000000..b6ee9c2d8
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
@@ -0,0 +1,34 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.telemetry;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+/**
+ * Container for telemetry data and the data necessary to upload it.
+ *
+ * The doc ID is used by a Store to manipulate its internal pings and should
+ * be the same value found in the urlPath.
+ *
+ * If you want to create one of these, consider extending
+ * {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder}
+ * or one of its descendants.
+ */
+public class TelemetryPing {
+ private final String urlPath;
+ private final ExtendedJSONObject payload;
+ private final String docID;
+
+ public TelemetryPing(final String urlPath, final ExtendedJSONObject payload, final String docID) {
+ this.urlPath = urlPath;
+ this.payload = payload;
+ this.docID = docID;
+ }
+
+ public String getURLPath() { return urlPath; }
+ public ExtendedJSONObject getPayload() { return payload; }
+ public String getDocID() { return docID; }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java
new file mode 100644
index 000000000..329f5b803
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPreferences.java
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.telemetry;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.PrefsHelper.PrefHandler;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Manages getting and setting any preferences related to telemetry.
+ *
+ * This class persists any Gecko preferences beyond shutdown so that these values
+ * can be accessed on the next run before Gecko is started as we expect Telemetry
+ * to run before Gecko is available.
+ */
+public class TelemetryPreferences {
+ private TelemetryPreferences() {}
+
+ private static final String GECKO_PREF_SERVER_URL = "toolkit.telemetry.server";
+ private static final String SHARED_PREF_SERVER_URL = "telemetry-serverUrl";
+
+ // Defaults are a mirror of about:config defaults so we can access them before Gecko is available.
+ private static final String DEFAULT_SERVER_URL = "https://incoming.telemetry.mozilla.org";
+
+ private static final String[] OBSERVED_PREFS = {
+ GECKO_PREF_SERVER_URL,
+ };
+
+ public static String getServerSchemeHostPort(final Context context, final String profileName) {
+ return getSharedPrefs(context, profileName).getString(SHARED_PREF_SERVER_URL, DEFAULT_SERVER_URL);
+ }
+
+ public static void initPreferenceObserver(final Context context, final String profileName) {
+ final PrefHandler prefHandler = new TelemetryPrefHandler(context, profileName);
+ PrefsHelper.addObserver(OBSERVED_PREFS, prefHandler); // gets preference value when gecko starts.
+ }
+
+ private static SharedPreferences getSharedPrefs(final Context context, final String profileName) {
+ return GeckoSharedPrefs.forProfileName(context, profileName);
+ }
+
+ private static class TelemetryPrefHandler extends PrefsHelper.PrefHandlerBase {
+ private final WeakReference<Context> contextWeakReference;
+ private final String profileName;
+
+ private TelemetryPrefHandler(final Context context, final String profileName) {
+ contextWeakReference = new WeakReference<>(context);
+ this.profileName = profileName;
+ }
+
+ @Override
+ public void prefValue(final String pref, final String value) {
+ final Context context = contextWeakReference.get();
+ if (context == null) {
+ return;
+ }
+
+ if (!pref.equals(GECKO_PREF_SERVER_URL)) {
+ throw new IllegalStateException("Unknown preference: " + pref);
+ }
+
+ getSharedPrefs(context, profileName).edit()
+ .putString(SHARED_PREF_SERVER_URL, value)
+ .apply();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
new file mode 100644
index 000000000..543281174
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
@@ -0,0 +1,347 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.telemetry;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import ch.boye.httpclientandroidlib.HttpHeaders;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.restrictions.Restrictable;
+import org.mozilla.gecko.restrictions.Restrictions;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+import org.mozilla.gecko.util.DateUtil;
+import org.mozilla.gecko.util.NetworkUtils;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.security.GeneralSecurityException;
+import java.util.Calendar;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * The service that handles retrieving a list of telemetry pings to upload from the given
+ * {@link TelemetryPingStore}, uploading those payloads to the associated server, and reporting
+ * back to the Store which uploads were a success.
+ */
+public class TelemetryUploadService extends IntentService {
+ private static final String LOGTAG = StringUtils.safeSubstring("Gecko" + TelemetryUploadService.class.getSimpleName(), 0, 23);
+ private static final String WORKER_THREAD_NAME = LOGTAG + "Worker";
+
+ public static final String ACTION_UPLOAD = "upload";
+ public static final String EXTRA_STORE = "store";
+
+ // TelemetryUploadService can run in a background thread so for future proofing, we set it volatile.
+ private static volatile boolean isDisabled = false;
+
+ public static void setDisabled(final boolean isDisabled) {
+ TelemetryUploadService.isDisabled = isDisabled;
+ if (isDisabled) {
+ Log.d(LOGTAG, "Telemetry upload disabled (env var?");
+ }
+ }
+
+ public TelemetryUploadService() {
+ super(WORKER_THREAD_NAME);
+
+ // Intent redelivery can fail hard (e.g. we OOM as we try to upload, the Intent gets redelivered, repeat)
+ // so for simplicity, we avoid it. We expect the upload service to eventually get called again by the caller.
+ setIntentRedelivery(false);
+ }
+
+ /**
+ * Handles a ping with the mandatory extras:
+ * * EXTRA_STORE: A {@link TelemetryPingStore} where the pings to upload are located
+ */
+ @Override
+ public void onHandleIntent(final Intent intent) {
+ Log.d(LOGTAG, "Service started");
+
+ if (!isReadyToUpload(this, intent)) {
+ return;
+ }
+
+ final TelemetryPingStore store = intent.getParcelableExtra(EXTRA_STORE);
+ final boolean wereAllUploadsSuccessful = uploadPendingPingsFromStore(this, store);
+ store.maybePrunePings();
+ Log.d(LOGTAG, "Service finished: upload and prune attempts completed");
+
+ if (!wereAllUploadsSuccessful) {
+ // If we had an upload failure, we should stop the IntentService and drop any
+ // pending Intents in the queue so we don't waste resources (e.g. battery)
+ // trying to upload when there's likely to be another connection failure.
+ Log.d(LOGTAG, "Clearing Intent queue due to connection failures");
+ stopSelf();
+ }
+ }
+
+ /**
+ * @return true if all pings were uploaded successfully, false otherwise.
+ */
+ private static boolean uploadPendingPingsFromStore(final Context context, final TelemetryPingStore store) {
+ final List<TelemetryPing> pingsToUpload = store.getAllPings();
+ if (pingsToUpload.isEmpty()) {
+ return true;
+ }
+
+ final String serverSchemeHostPort = TelemetryPreferences.getServerSchemeHostPort(context, store.getProfileName());
+ final HashSet<String> successfulUploadIDs = new HashSet<>(pingsToUpload.size()); // used for side effects.
+ final PingResultDelegate delegate = new PingResultDelegate(successfulUploadIDs);
+ for (final TelemetryPing ping : pingsToUpload) {
+ // TODO: It'd be great to re-use the same HTTP connection for each upload request.
+ delegate.setDocID(ping.getDocID());
+ final String url = serverSchemeHostPort + "/" + ping.getURLPath();
+ uploadPayload(url, ping.getPayload(), delegate);
+
+ // There are minimal gains in trying to upload if we already failed one attempt.
+ if (delegate.hadConnectionError()) {
+ break;
+ }
+ }
+
+ final boolean wereAllUploadsSuccessful = !delegate.hadConnectionError();
+ if (wereAllUploadsSuccessful) {
+ // We don't log individual successful uploads to avoid log spam.
+ Log.d(LOGTAG, "Telemetry upload success!");
+ }
+ store.onUploadAttemptComplete(successfulUploadIDs);
+ return wereAllUploadsSuccessful;
+ }
+
+ private static void uploadPayload(final String url, final ExtendedJSONObject payload, final ResultDelegate delegate) {
+ final BaseResource resource;
+ try {
+ resource = new BaseResource(url);
+ } catch (final URISyntaxException e) {
+ Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning.");
+ return;
+ }
+
+ delegate.setResource(resource);
+ resource.delegate = delegate;
+ resource.setShouldCompressUploadedEntity(true);
+ resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking.
+
+ // We're in a background thread so we don't have any reason to do this asynchronously.
+ // If we tried, onStartCommand would return and IntentService might stop itself before we finish.
+ resource.postBlocking(payload);
+ }
+
+ private static boolean isReadyToUpload(final Context context, final Intent intent) {
+ // Sanity check: is upload enabled? Generally, the caller should check this before starting the service.
+ // Since we don't have the profile here, we rely on the caller to check the enabled state for the profile.
+ if (!isUploadEnabledByAppConfig(context)) {
+ Log.w(LOGTAG, "Upload is not available by configuration; returning");
+ return false;
+ }
+
+ if (!NetworkUtils.isConnected(context)) {
+ Log.w(LOGTAG, "Network is not connected; returning");
+ return false;
+ }
+
+ if (!isIntentValid(intent)) {
+ Log.w(LOGTAG, "Received invalid Intent; returning");
+ return false;
+ }
+
+ if (!ACTION_UPLOAD.equals(intent.getAction())) {
+ Log.w(LOGTAG, "Unknown action: " + intent.getAction() + ". Returning");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Determines if the telemetry upload feature is enabled via the application configuration. Prefer to use
+ * {@link #isUploadEnabledByProfileConfig(Context, GeckoProfile)} if the profile is available as it takes into
+ * account more information.
+ *
+ * You may wish to also check if the network is connected when calling this method.
+ *
+ * Note that this method logs debug statements when upload is disabled.
+ */
+ public static boolean isUploadEnabledByAppConfig(final Context context) {
+ if (!TelemetryConstants.UPLOAD_ENABLED) {
+ Log.d(LOGTAG, "Telemetry upload feature is compile-time disabled");
+ return false;
+ }
+
+ if (isDisabled) {
+ Log.d(LOGTAG, "Telemetry upload feature is disabled by intent (in testing?)");
+ return false;
+ }
+
+ if (!GeckoPreferences.getBooleanPref(context, GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true)) {
+ Log.d(LOGTAG, "Telemetry upload opt-out");
+ return false;
+ }
+
+ if (Restrictions.isRestrictedProfile(context) &&
+ !Restrictions.isAllowed(context, Restrictable.HEALTH_REPORT)) {
+ Log.d(LOGTAG, "Telemetry upload feature disabled by admin profile");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Determines if the telemetry upload feature is enabled via profile & application level configurations. This is the
+ * preferred method.
+ *
+ * You may wish to also check if the network is connected when calling this method.
+ *
+ * Note that this method logs debug statements when upload is disabled.
+ */
+ public static boolean isUploadEnabledByProfileConfig(final Context context, final GeckoProfile profile) {
+ if (profile.inGuestMode()) {
+ Log.d(LOGTAG, "Profile is in guest mode");
+ return false;
+ }
+
+ return isUploadEnabledByAppConfig(context);
+ }
+
+ private static boolean isIntentValid(final Intent intent) {
+ // Intent can be null. Bug 1025937.
+ if (intent == null) {
+ Log.d(LOGTAG, "Received null intent");
+ return false;
+ }
+
+ if (intent.getParcelableExtra(EXTRA_STORE) == null) {
+ Log.d(LOGTAG, "Received invalid store in Intent");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Logs on success & failure and appends the set ID to the given Set on success.
+ *
+ * Note: you *must* set the ping ID before attempting upload or we'll throw!
+ *
+ * We use mutation on the set ID and the successful upload array to avoid object allocation.
+ */
+ private static class PingResultDelegate extends ResultDelegate {
+ // We persist pings and don't need to worry about losing data so we keep these
+ // durations short to save resources (e.g. battery).
+ private static final int SOCKET_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);
+ private static final int CONNECTION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);
+
+ /** The store ID of the ping currently being uploaded. Use {@link #getDocID()} to access it. */
+ private String docID = null;
+ private final Set<String> successfulUploadIDs;
+
+ private boolean hadConnectionError = false;
+
+ public PingResultDelegate(final Set<String> successfulUploadIDs) {
+ super();
+ this.successfulUploadIDs = successfulUploadIDs;
+ }
+
+ @Override
+ public int socketTimeout() {
+ return SOCKET_TIMEOUT_MILLIS;
+ }
+
+ @Override
+ public int connectionTimeout() {
+ return CONNECTION_TIMEOUT_MILLIS;
+ }
+
+ private String getDocID() {
+ if (docID == null) {
+ throw new IllegalStateException("Expected ping ID to have been updated before retrieval");
+ }
+ return docID;
+ }
+
+ public void setDocID(final String id) {
+ docID = id;
+ }
+
+ @Override
+ public String getUserAgent() {
+ return TelemetryConstants.USER_AGENT;
+ }
+
+ @Override
+ public void handleHttpResponse(final HttpResponse response) {
+ final int status = response.getStatusLine().getStatusCode();
+ switch (status) {
+ case 200:
+ case 201:
+ successfulUploadIDs.add(getDocID());
+ break;
+ default:
+ Log.w(LOGTAG, "Telemetry upload failure. HTTP status: " + status);
+ hadConnectionError = true;
+ }
+ }
+
+ @Override
+ public void handleHttpProtocolException(final ClientProtocolException e) {
+ // We don't log the exception to prevent leaking user data.
+ Log.w(LOGTAG, "HttpProtocolException when trying to upload telemetry");
+ hadConnectionError = true;
+ }
+
+ @Override
+ public void handleHttpIOException(final IOException e) {
+ // We don't log the exception to prevent leaking user data.
+ Log.w(LOGTAG, "HttpIOException when trying to upload telemetry");
+ hadConnectionError = true;
+ }
+
+ @Override
+ public void handleTransportException(final GeneralSecurityException e) {
+ // We don't log the exception to prevent leaking user data.
+ Log.w(LOGTAG, "Transport exception when trying to upload telemetry");
+ hadConnectionError = true;
+ }
+
+ private boolean hadConnectionError() {
+ return hadConnectionError;
+ }
+
+ @Override
+ public void addHeaders(final HttpRequestBase request, final DefaultHttpClient client) {
+ super.addHeaders(request, client);
+ request.addHeader(HttpHeaders.DATE, DateUtil.getDateInHTTPFormat(Calendar.getInstance().getTime()));
+ }
+ }
+
+ /**
+ * A hack because I want to set the resource after the Delegate is constructed.
+ * Be sure to call {@link #setResource(Resource)}!
+ */
+ private static abstract class ResultDelegate extends BaseResourceDelegate {
+ public ResultDelegate() {
+ super(null);
+ }
+
+ protected void setResource(final Resource resource) {
+ this.resource = resource;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java
new file mode 100644
index 000000000..61229b21b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/CampaignIdMeasurements.java
@@ -0,0 +1,37 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.adjust.AttributionHelperListener;
+
+/**
+ * A class to retrieve and store the campaign Id pref that is used when the Adjust SDK gives us
+ * new attribution from the {@link AttributionHelperListener}.
+ */
+public class CampaignIdMeasurements {
+ private static final String PREF_CAMPAIGN_ID = "measurements-campaignId";
+
+ public static String getCampaignIdFromPrefs(@NonNull final Context context) {
+ return GeckoSharedPrefs.forProfile(context)
+ .getString(PREF_CAMPAIGN_ID, null);
+ }
+
+ public static void updateCampaignIdPref(@NonNull final Context context, @NonNull final String campaignId) {
+ if (TextUtils.isEmpty(campaignId)) {
+ return;
+ }
+ GeckoSharedPrefs.forProfile(context)
+ .edit()
+ .putString(PREF_CAMPAIGN_ID, campaignId)
+ .apply();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java
new file mode 100644
index 000000000..c08ad6c02
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SearchCountMeasurements.java
@@ -0,0 +1,100 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.SharedPreferences;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A place to store and retrieve the number of times a user has searched with a specific engine from a
+ * specific location. This is designed for use as a telemetry core ping measurement.
+ *
+ * The implementation works by storing a preference for each engine-location pair and incrementing them
+ * each time {@link #incrementSearch(SharedPreferences, String, String)} is called. In order to
+ * retrieve the full set of keys later, we store all the available key names in another preference.
+ *
+ * When we retrieve the keys in {@link #getAndZeroSearch(SharedPreferences)} (using the set of keys
+ * preference), the values saved to the preferences are returned and the preferences are removed
+ * (i.e. zeroed) from Shared Preferences. The reason we remove the preferences (rather than actually
+ * zeroing them) is to avoid bloating shared preferences if 1) the set of engines ever changes or
+ * 2) we remove this feature.
+ *
+ * Since we increment a value on each successive search, which doesn't take up more space, we don't
+ * have to worry about using excess disk space if the measurements are never zeroed (e.g. telemetry
+ * upload is disabled). In the worst case, we overflow the integer and may return negative values.
+ *
+ * This class is thread-safe by locking access to its public methods. When this class was written, incrementing &
+ * retrieval were called from multiple threads so rather than enforcing the callers keep their threads straight, it
+ * was simpler to lock all access.
+ */
+public class SearchCountMeasurements {
+ /** The set of "engine + where" keys we've stored; used for retrieving stored engines. */
+ @VisibleForTesting static final String PREF_SEARCH_KEYSET = "measurements-search-count-keyset";
+ private static final String PREF_SEARCH_PREFIX = "measurements-search-count-engine-"; // + "engine.where"
+
+ private SearchCountMeasurements() {}
+
+ public static synchronized void incrementSearch(@NonNull final SharedPreferences prefs,
+ @NonNull final String engineIdentifier, @NonNull final String where) {
+ final String engineWhereStr = engineIdentifier + "." + where;
+ final String key = getEngineSearchCountKey(engineWhereStr);
+
+ final int count = prefs.getInt(key, 0);
+ prefs.edit().putInt(key, count + 1).apply();
+
+ unionKeyToSearchKeyset(prefs, engineWhereStr);
+ }
+
+ /**
+ * @param key Engine of the form, "engine.where"
+ */
+ private static void unionKeyToSearchKeyset(@NonNull final SharedPreferences prefs, @NonNull final String key) {
+ final Set<String> keysFromPrefs = prefs.getStringSet(PREF_SEARCH_KEYSET, Collections.<String>emptySet());
+ if (keysFromPrefs.contains(key)) {
+ return;
+ }
+
+ // String set returned by shared prefs cannot be modified so we copy.
+ final Set<String> keysToSave = new HashSet<>(keysFromPrefs);
+ keysToSave.add(key);
+ prefs.edit().putStringSet(PREF_SEARCH_KEYSET, keysToSave).apply();
+ }
+
+ /**
+ * Gets and zeroes search counts.
+ *
+ * We return ExtendedJSONObject for now because that's the format needed by the core telemetry ping.
+ */
+ public static synchronized ExtendedJSONObject getAndZeroSearch(@NonNull final SharedPreferences prefs) {
+ final ExtendedJSONObject out = new ExtendedJSONObject();
+ final SharedPreferences.Editor editor = prefs.edit();
+
+ final Set<String> keysFromPrefs = prefs.getStringSet(PREF_SEARCH_KEYSET, Collections.<String>emptySet());
+ for (final String engineWhereStr : keysFromPrefs) {
+ final String key = getEngineSearchCountKey(engineWhereStr);
+ out.put(engineWhereStr, prefs.getInt(key, 0));
+ editor.remove(key);
+ }
+ editor.remove(PREF_SEARCH_KEYSET)
+ .apply();
+ return out;
+ }
+
+ /**
+ * @param engineWhereStr string of the form "engine.where"
+ * @return the key for the engines' search counts in shared preferences
+ */
+ @VisibleForTesting static String getEngineSearchCountKey(final String engineWhereStr) {
+ return PREF_SEARCH_PREFIX + engineWhereStr;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java
new file mode 100644
index 000000000..6f7d2127a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java
@@ -0,0 +1,99 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.measurements;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.UiThread;
+import android.support.annotation.VisibleForTesting;
+import org.mozilla.gecko.GeckoSharedPrefs;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A class to measure the number of user sessions & their durations. It was created for use with the
+ * telemetry core ping. A session is the time between {@link #recordSessionStart()} and
+ * {@link #recordSessionEnd(Context)}.
+ *
+ * This class is thread-safe, provided the thread annotations are followed. Under the hood, this class uses
+ * SharedPreferences & because there is no atomic getAndSet operation, we synchronize access to it.
+ */
+public class SessionMeasurements {
+ @VisibleForTesting static final String PREF_SESSION_COUNT = "measurements-session-count";
+ @VisibleForTesting static final String PREF_SESSION_DURATION = "measurements-session-duration";
+
+ private boolean sessionStarted = false;
+ private long timeAtSessionStartNano = -1;
+
+ @UiThread // we assume this will be called on the same thread as session end so we don't have to synchronize sessionStarted.
+ public void recordSessionStart() {
+ if (sessionStarted) {
+ throw new IllegalStateException("Trying to start session but it is already started");
+ }
+ sessionStarted = true;
+ timeAtSessionStartNano = getSystemTimeNano();
+ }
+
+ @UiThread // we assume this will be called on the same thread as session start so we don't have to synchronize sessionStarted.
+ public void recordSessionEnd(final Context context) {
+ if (!sessionStarted) {
+ throw new IllegalStateException("Expected session to be started before session end is called");
+ }
+ sessionStarted = false;
+
+ final long sessionElapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(getSystemTimeNano() - timeAtSessionStartNano);
+ final SharedPreferences sharedPrefs = getSharedPreferences(context);
+ synchronized (this) {
+ final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0);
+ final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0);
+ sharedPrefs.edit()
+ .putInt(PREF_SESSION_COUNT, sessionCount + 1)
+ .putLong(PREF_SESSION_DURATION, totalElapsedSeconds + sessionElapsedSeconds)
+ .apply();
+ }
+ }
+
+ /**
+ * Gets the session measurements since the last time the measurements were last retrieved.
+ */
+ public synchronized SessionMeasurementsContainer getAndResetSessionMeasurements(final Context context) {
+ final SharedPreferences sharedPrefs = getSharedPreferences(context);
+ final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0);
+ final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0);
+ sharedPrefs.edit()
+ .putInt(PREF_SESSION_COUNT, 0)
+ .putLong(PREF_SESSION_DURATION, 0)
+ .apply();
+ return new SessionMeasurementsContainer(sessionCount, totalElapsedSeconds);
+ }
+
+ @VisibleForTesting SharedPreferences getSharedPreferences(final Context context) {
+ return GeckoSharedPrefs.forProfile(context);
+ }
+
+ /**
+ * Returns (roughly) the system uptime in nanoseconds. A less coupled implementation would
+ * take this value from the caller of recordSession*, however, we do this internally to ensure
+ * the caller uses both a time system consistent between the start & end calls and uses the
+ * appropriate time system (i.e. not wall time, which can change when the clock is changed).
+ */
+ @VisibleForTesting long getSystemTimeNano() { // TODO: necessary?
+ return System.nanoTime();
+ }
+
+ public static final class SessionMeasurementsContainer {
+ /** The number of sessions. */
+ public final int sessionCount;
+ /** The number of seconds elapsed in ALL sessions included in {@link #sessionCount}. */
+ public final long elapsedSeconds;
+
+ private SessionMeasurementsContainer(final int sessionCount, final long elapsedSeconds) {
+ this.sessionCount = sessionCount;
+ this.elapsedSeconds = elapsedSeconds;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java
new file mode 100644
index 000000000..3f5480f37
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java
@@ -0,0 +1,247 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.pingbuilders;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.text.TextUtils;
+
+import android.util.Log;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.search.SearchEngine;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.util.DateUtil;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.StringUtils;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Builds a {@link TelemetryPing} representing a core ping.
+ *
+ * See https://gecko.readthedocs.org/en/latest/toolkit/components/telemetry/telemetry/core-ping.html
+ * for details on the core ping.
+ */
+public class TelemetryCorePingBuilder extends TelemetryPingBuilder {
+ private static final String LOGTAG = StringUtils.safeSubstring(TelemetryCorePingBuilder.class.getSimpleName(), 0, 23);
+
+ // For legacy reasons, this preference key is not namespaced with "core".
+ private static final String PREF_SEQ_COUNT = "telemetry-seqCount";
+
+ private static final String NAME = "core";
+ private static final int VERSION_VALUE = 7; // For version history, see toolkit/components/telemetry/docs/core-ping.rst
+ private static final String OS_VALUE = "Android";
+
+ private static final String ARCHITECTURE = "arch";
+ private static final String CAMPAIGN_ID = "campaignId";
+ private static final String CLIENT_ID = "clientId";
+ private static final String DEFAULT_SEARCH_ENGINE = "defaultSearch";
+ private static final String DEVICE = "device";
+ private static final String DISTRIBUTION_ID = "distributionId";
+ private static final String EXPERIMENTS = "experiments";
+ private static final String LOCALE = "locale";
+ private static final String OS_ATTR = "os";
+ private static final String OS_VERSION = "osversion";
+ private static final String PING_CREATION_DATE = "created";
+ private static final String PROFILE_CREATION_DATE = "profileDate";
+ private static final String SEARCH_COUNTS = "searches";
+ private static final String SEQ = "seq";
+ private static final String SESSION_COUNT = "sessions";
+ private static final String SESSION_DURATION = "durations";
+ private static final String TIMEZONE_OFFSET = "tz";
+ private static final String VERSION_ATTR = "v";
+
+ public TelemetryCorePingBuilder(final Context context) {
+ initPayloadConstants(context);
+ }
+
+ private void initPayloadConstants(final Context context) {
+ payload.put(VERSION_ATTR, VERSION_VALUE);
+ payload.put(OS_ATTR, OS_VALUE);
+
+ // We limit the device descriptor to 32 characters because it can get long. We give fewer characters to the
+ // manufacturer because we're less likely to have manufacturers with similar names than we are for a
+ // manufacturer to have two devices with the similar names (e.g. Galaxy S6 vs. Galaxy Note 6).
+ final String deviceDescriptor =
+ StringUtils.safeSubstring(Build.MANUFACTURER, 0, 12) + '-' + StringUtils.safeSubstring(Build.MODEL, 0, 19);
+
+ final Calendar nowCalendar = Calendar.getInstance();
+ final DateFormat pingCreationDateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
+
+ payload.put(ARCHITECTURE, AppConstants.ANDROID_CPU_ARCH);
+ payload.put(DEVICE, deviceDescriptor);
+ payload.put(LOCALE, Locales.getLanguageTag(Locale.getDefault()));
+ payload.put(OS_VERSION, Integer.toString(Build.VERSION.SDK_INT)); // A String for cross-platform reasons.
+ payload.put(PING_CREATION_DATE, pingCreationDateFormat.format(nowCalendar.getTime()));
+ payload.put(TIMEZONE_OFFSET, DateUtil.getTimezoneOffsetInMinutesForGivenDate(nowCalendar));
+ payload.putArray(EXPERIMENTS, Experiments.getActiveExperiments(context));
+ }
+
+ @Override
+ public String getDocType() {
+ return NAME;
+ }
+
+ @Override
+ public String[] getMandatoryFields() {
+ return new String[] {
+ ARCHITECTURE,
+ CLIENT_ID,
+ DEFAULT_SEARCH_ENGINE,
+ DEVICE,
+ LOCALE,
+ OS_ATTR,
+ OS_VERSION,
+ PING_CREATION_DATE,
+ PROFILE_CREATION_DATE,
+ SEQ,
+ TIMEZONE_OFFSET,
+ VERSION_ATTR,
+ };
+ }
+
+ public TelemetryCorePingBuilder setClientID(@NonNull final String clientID) {
+ if (clientID == null) {
+ throw new IllegalArgumentException("Expected non-null clientID");
+ }
+ payload.put(CLIENT_ID, clientID);
+ return this;
+ }
+
+ /**
+ * @param engine the default search engine identifier, or null if there is an error.
+ */
+ public TelemetryCorePingBuilder setDefaultSearchEngine(@Nullable final String engine) {
+ if (engine != null && engine.isEmpty()) {
+ throw new IllegalArgumentException("Received empty string. Expected identifier or null.");
+ }
+ payload.put(DEFAULT_SEARCH_ENGINE, engine);
+ return this;
+ }
+
+ public TelemetryCorePingBuilder setOptDistributionID(@NonNull final String distributionID) {
+ if (distributionID == null) {
+ throw new IllegalArgumentException("Expected non-null distribution ID");
+ }
+ payload.put(DISTRIBUTION_ID, distributionID);
+ return this;
+ }
+
+ /**
+ * @param searchCounts non-empty JSON with {"engine.where": <int-count>}
+ */
+ public TelemetryCorePingBuilder setOptSearchCounts(@NonNull final ExtendedJSONObject searchCounts) {
+ if (searchCounts == null) {
+ throw new IllegalStateException("Expected non-null search counts");
+ } else if (searchCounts.size() == 0) {
+ throw new IllegalStateException("Expected non-empty search counts");
+ }
+
+ payload.put(SEARCH_COUNTS, searchCounts);
+ return this;
+ }
+
+ public TelemetryCorePingBuilder setOptCampaignId(final String campaignId) {
+ if (campaignId == null) {
+ throw new IllegalStateException("Expected non-null campaign ID.");
+ }
+ payload.put(CAMPAIGN_ID, campaignId);
+ return this;
+ }
+
+ /**
+ * @param date The profile creation date in days to the unix epoch (not millis!), or null if there is an error.
+ */
+ public TelemetryCorePingBuilder setProfileCreationDate(@Nullable final Long date) {
+ if (date != null && date < 0) {
+ throw new IllegalArgumentException("Expect positive date value. Received: " + date);
+ }
+ payload.put(PROFILE_CREATION_DATE, date);
+ return this;
+ }
+
+ /**
+ * @param seq a positive sequence number.
+ */
+ public TelemetryCorePingBuilder setSequenceNumber(final int seq) {
+ if (seq < 0) {
+ // Since this is an increasing value, it's possible we can overflow into negative values and get into a
+ // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server.
+ Log.w(LOGTAG, "Expected positive sequence number. Received: " + seq);
+ }
+ payload.put(SEQ, seq);
+ return this;
+ }
+
+ public TelemetryCorePingBuilder setSessionCount(final int sessionCount) {
+ if (sessionCount < 0) {
+ // Since this is an increasing value, it's possible we can overflow into negative values and get into a
+ // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server.
+ Log.w(LOGTAG, "Expected positive session count. Received: " + sessionCount);
+ }
+ payload.put(SESSION_COUNT, sessionCount);
+ return this;
+ }
+
+ public TelemetryCorePingBuilder setSessionDuration(final long sessionDuration) {
+ if (sessionDuration < 0) {
+ // Since this is an increasing value, it's possible we can overflow into negative values and get into a
+ // crash loop so we don't crash on invalid arg - we can investigate if we see negative values on the server.
+ Log.w(LOGTAG, "Expected positive session duration. Received: " + sessionDuration);
+ }
+ payload.put(SESSION_DURATION, sessionDuration);
+ return this;
+ }
+
+ /**
+ * Gets the sequence number from shared preferences and increments it in the prefs. This method
+ * is not thread safe.
+ */
+ @WorkerThread // synchronous shared prefs write.
+ public static int getAndIncrementSequenceNumber(final SharedPreferences sharedPrefsForProfile) {
+ final int seq = sharedPrefsForProfile.getInt(PREF_SEQ_COUNT, 1);
+
+ sharedPrefsForProfile.edit().putInt(PREF_SEQ_COUNT, seq + 1).apply();
+ return seq;
+ }
+
+ /**
+ * @return the profile creation date in the format expected by
+ * {@link TelemetryCorePingBuilder#setProfileCreationDate(Long)}.
+ */
+ @WorkerThread
+ public static Long getProfileCreationDate(final Context context, final GeckoProfile profile) {
+ final long profileMillis = profile.getAndPersistProfileCreationDate(context);
+ if (profileMillis < 0) {
+ return null;
+ }
+ return (long) Math.floor((double) profileMillis / TimeUnit.DAYS.toMillis(1));
+ }
+
+ /**
+ * @return the search engine identifier in the format expected by the core ping.
+ */
+ @Nullable
+ public static String getEngineIdentifier(@Nullable final SearchEngine searchEngine) {
+ if (searchEngine == null) {
+ return null;
+ }
+ final String identifier = searchEngine.getIdentifier();
+ return TextUtils.isEmpty(identifier) ? null : identifier;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java
new file mode 100644
index 000000000..57fa0fd8b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java
@@ -0,0 +1,87 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.pingbuilders;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * A generic Builder for {@link TelemetryPing} instances. Each overriding class is
+ * expected to create a specific type of ping (e.g. "core").
+ *
+ * This base class handles the common ping operations under the hood:
+ * * Validating mandatory fields
+ * * Forming the server url
+ */
+abstract class TelemetryPingBuilder {
+ // In the server url, the initial path directly after the "scheme://host:port/"
+ private static final String SERVER_INITIAL_PATH = "submit/telemetry";
+
+ private final String serverPath;
+ protected final ExtendedJSONObject payload;
+ private final String docID;
+
+ public TelemetryPingBuilder() {
+ docID = UUID.randomUUID().toString();
+ serverPath = getTelemetryServerPath(getDocType(), docID);
+ payload = new ExtendedJSONObject();
+ }
+
+ /**
+ * @return the name of the ping (e.g. "core")
+ */
+ public abstract String getDocType();
+
+ /**
+ * @return the fields that are mandatory for the resultant ping to be uploaded to
+ * the server. These will be validated before the ping is built.
+ */
+ public abstract String[] getMandatoryFields();
+
+ public TelemetryPing build() {
+ validatePayload();
+ return new TelemetryPing(serverPath, payload, docID);
+ }
+
+ private void validatePayload() {
+ final Set<String> keySet = payload.keySet();
+ for (final String mandatoryField : getMandatoryFields()) {
+ if (!keySet.contains(mandatoryField)) {
+ throw new IllegalArgumentException("Builder does not contain mandatory field: " +
+ mandatoryField);
+ }
+ }
+ }
+
+ /**
+ * Returns a url of the format:
+ * http://hostname/submit/telemetry/docId/docType/appName/appVersion/appUpdateChannel/appBuildID
+ *
+ * @param docType The name of the ping (e.g. "main")
+ * @return a url at which to POST the telemetry data to
+ */
+ private static String getTelemetryServerPath(final String docType, final String docID) {
+ final String appName = AppConstants.MOZ_APP_BASENAME;
+ final String appVersion = AppConstants.MOZ_APP_VERSION;
+ final String appUpdateChannel = AppConstants.MOZ_UPDATE_CHANNEL;
+ final String appBuildId = AppConstants.MOZ_APP_BUILDID;
+
+ // The compiler will optimize a single String concatenation into a StringBuilder statement.
+ // If you change this `return`, be sure to keep it as a single statement to keep it optimized!
+ return SERVER_INITIAL_PATH + '/' +
+ docID + '/' +
+ docType + '/' +
+ appName + '/' +
+ appVersion + '/' +
+ appUpdateChannel + '/' +
+ appBuildId;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java
new file mode 100644
index 000000000..047a646c3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java
@@ -0,0 +1,32 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.schedulers;
+
+import android.content.Context;
+import android.content.Intent;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+import org.mozilla.gecko.telemetry.TelemetryUploadService;
+
+/**
+ * Schedules an upload with all pings to be sent immediately.
+ */
+public class TelemetryUploadAllPingsImmediatelyScheduler implements TelemetryUploadScheduler {
+
+ @Override
+ public boolean isReadyToUpload(final TelemetryPingStore store) {
+ // We're ready since we don't have any conditions to wait on (e.g. on wifi, accumulated X pings).
+ return true;
+ }
+
+ @Override
+ public void scheduleUpload(final Context applicationContext, final TelemetryPingStore store) {
+ final Intent i = new Intent(TelemetryUploadService.ACTION_UPLOAD);
+ i.setClass(applicationContext, TelemetryUploadService.class);
+ i.putExtra(TelemetryUploadService.EXTRA_STORE, store);
+ applicationContext.startService(i);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java
new file mode 100644
index 000000000..63305aad5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java
@@ -0,0 +1,26 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.schedulers;
+
+import android.content.Context;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+
+/**
+ * An implementation of this class can investigate the given {@link TelemetryPingStore} to
+ * decide if it's ready to upload the pings inside that Store (e.g. on wifi? have we
+ * accumulated X pings?) and can schedule that upload. Typically, the upload will be
+ * scheduled by sending an {@link android.content.Intent} to the
+ * {@link org.mozilla.gecko.telemetry.TelemetryUploadService}, either immediately or
+ * via an external scheduler (e.g. {@link android.app.job.JobScheduler}).
+ *
+ * N.B.: If the Store is not ready to upload, an implementation *should not* try to reschedule
+ * the check to see if it's time to upload - this is expected to be handled by the caller.
+ */
+public interface TelemetryUploadScheduler {
+ boolean isReadyToUpload(TelemetryPingStore store);
+ void scheduleUpload(Context applicationContext, TelemetryPingStore store);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java
new file mode 100644
index 000000000..d52382146
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java
@@ -0,0 +1,301 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.stores;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.VisibleForTesting;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.FileUtils.FileLastModifiedComparator;
+import org.mozilla.gecko.util.FileUtils.FilenameRegexFilter;
+import org.mozilla.gecko.util.FileUtils.FilenameWhitelistFilter;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.UUIDUtil;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.nio.channels.FileLock;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/**
+ * An implementation of TelemetryPingStore that is backed by JSON files.
+ *
+ * This implementation seeks simplicity. Each ping to upload is stored in its own file with its doc ID
+ * as the filename. The doc ID is sent with a ping to be uploaded and is expected to be returned with
+ * {@link #onUploadAttemptComplete(Set)} so the associated file can be removed.
+ *
+ * During prune, the pings with the oldest modified time will be removed first. Different filesystems will
+ * handle clock skew (e.g. manual time changes, daylight savings time, changing timezones) in different ways
+ * and we accept that these modified times may not be consistent - newer data is not more important than
+ * older data and the choice to delete the oldest data first is largely arbitrary so we don't care if
+ * the timestamps are occasionally inconsistent.
+ *
+ * Using separate files for this store allows for less restrictive concurrency:
+ * * requires locking: {@link #storePing(TelemetryPing)} writes a new file
+ * * requires locking: {@link #getAllPings()} reads all files, including those potentially being written,
+ * hence locking
+ * * no locking: {@link #maybePrunePings()} deletes the least recently written pings, none of which should
+ * be currently written
+ * * no locking: {@link #onUploadAttemptComplete(Set)} deletes the given pings, none of which should be
+ * currently written
+ */
+public class TelemetryJSONFilePingStore extends TelemetryPingStore {
+ private static final String LOGTAG = StringUtils.safeSubstring(
+ "Gecko" + TelemetryJSONFilePingStore.class.getSimpleName(), 0, 23);
+
+ @VisibleForTesting static final int MAX_PING_COUNT = 40; // TODO: value.
+
+ // We keep the key names short to reduce storage size impact.
+ @VisibleForTesting static final String KEY_PAYLOAD = "p";
+ @VisibleForTesting static final String KEY_URL_PATH = "u";
+
+ private final File storeDir;
+ private final FilenameFilter uuidFilenameFilter;
+ private final FileLastModifiedComparator fileLastModifiedComparator = new FileLastModifiedComparator();
+
+ @WorkerThread // Writes to disk
+ public TelemetryJSONFilePingStore(final File storeDir, final String profileName) {
+ super(profileName);
+ if (storeDir.exists() && !storeDir.isDirectory()) {
+ // An alternative is to create a new directory, but we wouldn't
+ // be able to access it later so it's better to throw.
+ throw new IllegalStateException("Store dir unexpectedly exists & is not a directory - cannot continue");
+ }
+
+ this.storeDir = storeDir;
+ this.storeDir.mkdirs();
+ uuidFilenameFilter = new FilenameRegexFilter(UUIDUtil.UUID_PATTERN);
+
+ if (!this.storeDir.canRead() || !this.storeDir.canWrite() || !this.storeDir.canExecute()) {
+ throw new IllegalStateException("Cannot read, write, or execute store dir: " +
+ this.storeDir.canRead() + " " + this.storeDir.canWrite() + " " + this.storeDir.canExecute());
+ }
+ }
+
+ @VisibleForTesting File getPingFile(final String docID) {
+ return new File(storeDir, docID);
+ }
+
+ @Override
+ public void storePing(final TelemetryPing ping) throws IOException {
+ final String output;
+ try {
+ output = new JSONObject()
+ .put(KEY_PAYLOAD, ping.getPayload())
+ .put(KEY_URL_PATH, ping.getURLPath())
+ .toString();
+ } catch (final JSONException e) {
+ // Do not log the exception to avoid leaking personal data.
+ throw new IOException("Unable to create JSON to store to disk");
+ }
+
+ final FileOutputStream outputStream = new FileOutputStream(getPingFile(ping.getDocID()), false);
+ blockForLockAndWriteFileAndCloseStream(outputStream, output);
+ }
+
+ @Override
+ public void maybePrunePings() {
+ final File[] files = storeDir.listFiles(uuidFilenameFilter);
+ if (files == null) {
+ return;
+ }
+
+ if (files.length < MAX_PING_COUNT) {
+ return;
+ }
+
+ // It's possible that multiple files will have the same timestamp: in this case they are treated
+ // as equal by the fileLastModifiedComparator. We therefore have to use a sorted list (as
+ // opposed to a set, or map).
+ final ArrayList<File> sortedFiles = new ArrayList<>(Arrays.asList(files));
+ Collections.sort(sortedFiles, fileLastModifiedComparator);
+ deleteSmallestFiles(sortedFiles, files.length - MAX_PING_COUNT);
+ }
+
+ private void deleteSmallestFiles(final ArrayList<File> files, final int numFilesToRemove) {
+ final Iterator<File> it = files.iterator();
+ int i = 0;
+
+ while (i < numFilesToRemove) {
+ i += 1;
+
+ // Sorted list so we're iterating over ascending files.
+ final File file = it.next(); // file count > files to remove so this should not throw.
+ file.delete();
+ }
+ }
+
+ @Override
+ public ArrayList<TelemetryPing> getAllPings() {
+ final File[] fileArray = storeDir.listFiles(uuidFilenameFilter);
+ if (fileArray == null) {
+ // Intentionally don't log all info for the store directory to prevent leaking the path.
+ Log.w(LOGTAG, "listFiles unexpectedly returned null - unable to retrieve pings. Debug: exists? " +
+ storeDir.exists() + "; directory? " + storeDir.isDirectory());
+ return new ArrayList<>(1);
+ }
+
+ final List<File> files = Arrays.asList(fileArray);
+ Collections.sort(files, fileLastModifiedComparator); // oldest to newest
+ final ArrayList<TelemetryPing> out = new ArrayList<>(files.size());
+ for (final File file : files) {
+ final JSONObject obj = lockAndReadJSONFromFile(file);
+ if (obj == null) {
+ // We log in the method to get the JSONObject if we return null.
+ continue;
+ }
+
+ try {
+ final String url = obj.getString(KEY_URL_PATH);
+ final ExtendedJSONObject payload = new ExtendedJSONObject(obj.getString(KEY_PAYLOAD));
+ out.add(new TelemetryPing(url, payload, file.getName()));
+ } catch (final IOException | JSONException | NonObjectJSONException e) {
+ Log.w(LOGTAG, "Bad json in ping. Ignoring.");
+ continue;
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Logs if there is an error.
+ *
+ * @return the JSON object from the given file or null if there is an error.
+ */
+ private JSONObject lockAndReadJSONFromFile(final File file) {
+ // lockAndReadFileAndCloseStream doesn't handle file size of 0.
+ if (file.length() == 0) {
+ Log.w(LOGTAG, "Unexpected empty file: " + file.getName() + ". Ignoring");
+ return null;
+ }
+
+ final FileInputStream inputStream;
+ try {
+ inputStream = new FileInputStream(file);
+ } catch (final FileNotFoundException e) {
+ // permission problem might also cause same exception. To get more debug information.
+ String fileInfo = String.format("existence: %b, can write: %b, size: %d.",
+ file.exists(), file.canWrite(), file.length());
+ String msg = String.format(
+ "Expected file to exist but got exception in thread: %s. File info - %s",
+ Thread.currentThread().getName(), fileInfo);
+ throw new IllegalStateException(msg);
+ }
+
+ final JSONObject obj;
+ try {
+ // Potential optimization: re-use the same buffer for reading from files.
+ obj = lockAndReadFileAndCloseStream(inputStream, (int) file.length());
+ } catch (final IOException | JSONException e) {
+ // We couldn't read this file so let's just skip it. These potentially
+ // corrupted files should be removed when the data is pruned.
+ Log.w(LOGTAG, "Error when reading file: " + file.getName() + " Likely corrupted. Ignoring");
+ return null;
+ }
+
+ if (obj == null) {
+ Log.d(LOGTAG, "Could not read given file: " + file.getName() + " File is locked. Ignoring");
+ }
+ return obj;
+ }
+
+ @Override
+ public void onUploadAttemptComplete(final Set<String> successfulRemoveIDs) {
+ if (successfulRemoveIDs.isEmpty()) {
+ return;
+ }
+
+ final File[] files = storeDir.listFiles(new FilenameWhitelistFilter(successfulRemoveIDs));
+ for (final File file : files) {
+ file.delete();
+ }
+ }
+
+ /**
+ * Locks the given {@link FileOutputStream} and writes the given String. This method will close the given stream.
+ *
+ * Note: this method blocks until a file lock can be acquired.
+ */
+ private static void blockForLockAndWriteFileAndCloseStream(final FileOutputStream outputStream, final String str)
+ throws IOException {
+ try {
+ final FileLock lock = outputStream.getChannel().lock(0, Long.MAX_VALUE, false);
+ if (lock != null) {
+ // The file lock is released when the stream is closed. If we try to redundantly close it, we get
+ // a ClosedChannelException. To be safe, we could catch that every time but there is a performance
+ // hit to exception handling so instead we assume the file lock will be closed.
+ FileUtils.writeStringToOutputStreamAndCloseStream(outputStream, str);
+ }
+ } finally {
+ outputStream.close(); // redundant: closed when the stream is closed, but let's be safe.
+ }
+ }
+
+ /**
+ * Locks the given {@link FileInputStream} and reads the data. This method will close the given stream.
+ *
+ * Note: this method returns null when a lock could not be acquired.
+ */
+ private static JSONObject lockAndReadFileAndCloseStream(final FileInputStream inputStream, final int fileSize)
+ throws IOException, JSONException {
+ try {
+ final FileLock lock = inputStream.getChannel().tryLock(0, Long.MAX_VALUE, true); // null when lock not acquired
+ if (lock == null) {
+ return null;
+ }
+ // The file lock is released when the stream is closed. If we try to redundantly close it, we get
+ // a ClosedChannelException. To be safe, we could catch that every time but there is a performance
+ // hit to exception handling so instead we assume the file lock will be closed.
+ return new JSONObject(FileUtils.readStringFromInputStreamAndCloseStream(inputStream, fileSize));
+ } finally {
+ inputStream.close(); // redundant: closed when the stream is closed, but let's be safe.
+ }
+ }
+
+ public static final Parcelable.Creator<TelemetryJSONFilePingStore> CREATOR = new Parcelable.Creator<TelemetryJSONFilePingStore>() {
+ @Override
+ public TelemetryJSONFilePingStore createFromParcel(final Parcel source) {
+ final String storeDirPath = source.readString();
+ final String profileName = source.readString();
+ return new TelemetryJSONFilePingStore(new File(storeDirPath), profileName);
+ }
+
+ @Override
+ public TelemetryJSONFilePingStore[] newArray(final int size) {
+ return new TelemetryJSONFilePingStore[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(storeDir.getAbsolutePath());
+ dest.writeString(getProfileName());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java
new file mode 100644
index 000000000..7d781cf26
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java
@@ -0,0 +1,66 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package org.mozilla.gecko.telemetry.stores;
+
+import android.os.Parcelable;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Persistent storage for TelemetryPings that are queued for upload.
+ *
+ * An implementation of this class is expected to be thread-safe. Additionally,
+ * multiple instances can be created and run simultaneously so they must be able
+ * to synchronize state (or be stateless!).
+ *
+ * The pings in {@link #getAllPings()} and {@link #maybePrunePings()} are returned in the
+ * same order in order to guarantee consistent results.
+ */
+public abstract class TelemetryPingStore implements Parcelable {
+ private final String profileName;
+
+ public TelemetryPingStore(final String profileName) {
+ this.profileName = profileName;
+ }
+
+ /**
+ * @return the profile name associated with this store.
+ */
+ public String getProfileName() {
+ return profileName;
+ }
+
+ /**
+ * @return a list of all the telemetry pings in the store that are ready for upload, ascending oldest to newest.
+ */
+ public abstract List<TelemetryPing> getAllPings();
+
+ /**
+ * Save a ping to the store.
+ *
+ * @param ping the ping to store
+ * @throws IOException for underlying store access errors
+ */
+ public abstract void storePing(TelemetryPing ping) throws IOException;
+
+ /**
+ * Removes telemetry pings from the store if there are too many pings or they take up too much space.
+ * Pings should be removed from oldest to newest.
+ */
+ public abstract void maybePrunePings();
+
+ /**
+ * Removes the successfully uploaded pings from the database and performs another other actions necessary
+ * for when upload is completed.
+ *
+ * @param successfulRemoveIDs doc ids of pings that were successfully uploaded
+ */
+ public abstract void onUploadAttemptComplete(Set<String> successfulRemoveIDs);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java b/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java
new file mode 100644
index 000000000..07f17590d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingActionModeCallback.java
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.os.Build;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.mozilla.gecko.GeckoAppShell;
+
+import java.util.List;
+
+@TargetApi(Build.VERSION_CODES.M)
+public class FloatingActionModeCallback extends ActionMode.Callback2 {
+ private FloatingToolbarTextSelection textSelection;
+ private List<TextAction> actions;
+
+ public FloatingActionModeCallback(FloatingToolbarTextSelection textSelection, List<TextAction> actions) {
+ this.textSelection = textSelection;
+ this.actions = actions;
+ }
+
+ public void updateActions(List<TextAction> actions) {
+ this.actions = actions;
+ }
+
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ menu.clear();
+
+ for (int i = 0; i < actions.size(); i++) {
+ final TextAction action = actions.get(i);
+ menu.add(Menu.NONE, i, action.getFloatingOrder(), action.getLabel());
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ final TextAction action = actions.get(item.getItemId());
+
+ GeckoAppShell.notifyObservers("TextSelection:Action", action.getId());
+
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {}
+
+ @Override
+ public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
+ final Rect contentRect = textSelection.contentRect;
+ if (contentRect != null) {
+ outRect.set(contentRect);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
new file mode 100644
index 000000000..7a09624d4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/FloatingToolbarTextSelection.java
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.graphics.Rect;
+import android.os.Build;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.ActionMode;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.gfx.LayerView;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.List;
+
+import ch.boye.httpclientandroidlib.util.TextUtils;
+
+/**
+ * Floating toolbar for text selection actions. Only on Android 6+.
+ */
+@TargetApi(Build.VERSION_CODES.M)
+public class FloatingToolbarTextSelection implements TextSelection, GeckoEventListener {
+ private static final String LOGTAG = "GeckoFloatTextSelection";
+
+ // This is an additional offset we add to the height of the selection. This will avoid that the
+ // floating toolbar overlays the bottom handle(s).
+ private static final int HANDLES_OFFSET_DP = 20;
+
+ private final Activity activity;
+ private final LayerView layerView;
+ private final int[] locationInWindow;
+ private final float handlesOffset;
+
+ private ActionMode actionMode;
+ private FloatingActionModeCallback actionModeCallback;
+ private String selectionID;
+ /* package-private */ Rect contentRect;
+
+ public FloatingToolbarTextSelection(Activity activity, LayerView layerView) {
+ this.activity = activity;
+ this.layerView = layerView;
+ this.locationInWindow = new int[2];
+
+ this.handlesOffset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
+ HANDLES_OFFSET_DP, activity.getResources().getDisplayMetrics());
+ }
+
+ @Override
+ public boolean dismiss() {
+ if (finishActionMode()) {
+ endTextSelection();
+ return true;
+ }
+
+ return false;
+ }
+
+ private void endTextSelection() {
+ if (TextUtils.isEmpty(selectionID)) {
+ return;
+ }
+
+ final JSONObject args = new JSONObject();
+ try {
+ args.put("selectionID", selectionID);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error building JSON arguments for TextSelection:End", e);
+ return;
+ }
+
+ GeckoAppShell.notifyObservers("TextSelection:End", args.toString());
+ }
+
+ @Override
+ public void create() {
+ registerForEvents();
+ }
+
+ @Override
+ public void destroy() {
+ unregisterFromEvents();
+ }
+
+ private void registerForEvents() {
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "TextSelection:ActionbarInit",
+ "TextSelection:ActionbarStatus",
+ "TextSelection:ActionbarUninit",
+ "TextSelection:Update",
+ "TextSelection:Visibility");
+ }
+
+ private void unregisterFromEvents() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "TextSelection:ActionbarInit",
+ "TextSelection:ActionbarStatus",
+ "TextSelection:ActionbarUninit",
+ "TextSelection:Update",
+ "TextSelection:Visibility");
+ }
+
+ @Override
+ public void handleMessage(final String event, final JSONObject message) {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleOnMainThread(event, message);
+ }
+ });
+ }
+
+ private void handleOnMainThread(final String event, final JSONObject message) {
+ if ("TextSelection:ActionbarInit".equals(event)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW,
+ TelemetryContract.Method.CONTENT, "text_selection");
+
+ selectionID = message.optString("selectionID");
+ } else if ("TextSelection:ActionbarStatus".equals(event)) {
+ // Ensure async updates from SearchService for example are valid.
+ if (selectionID != message.optString("selectionID")) {
+ return;
+ }
+
+ updateRect(message);
+
+ if (!isRectVisible()) {
+ finishActionMode();
+ } else {
+ startActionMode(TextAction.fromEventMessage(message));
+ }
+ } else if ("TextSelection:ActionbarUninit".equals(event)) {
+ finishActionMode();
+ } else if ("TextSelection:Update".equals(event)) {
+ startActionMode(TextAction.fromEventMessage(message));
+ } else if ("TextSelection:Visibility".equals(event)) {
+ finishActionMode();
+ }
+ }
+
+ private void startActionMode(List<TextAction> actions) {
+ if (actionMode != null) {
+ actionModeCallback.updateActions(actions);
+ actionMode.invalidate();
+ return;
+ }
+
+ actionModeCallback = new FloatingActionModeCallback(this, actions);
+ actionMode = activity.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING);
+ }
+
+ private boolean finishActionMode() {
+ if (actionMode != null) {
+ actionMode.finish();
+ actionMode = null;
+ actionModeCallback = null;
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * If the content rect is a point (left == right and top == bottom) then this means that the
+ * content rect is not in the currently visible part.
+ */
+ private boolean isRectVisible() {
+ // There's another case of an empty rect where just left == right but not top == bottom.
+ // That's the rect for a collapsed selection. While technically this rect isn't visible too
+ // we are not interested in this case because we do not want to hide the toolbar.
+ return contentRect.left != contentRect.right || contentRect.top != contentRect.bottom;
+ }
+
+ private void updateRect(JSONObject message) {
+ try {
+ final double x = message.getDouble("x");
+ final double y = (int) message.getDouble("y");
+ final double width = (int) message.getDouble("width");
+ final double height = (int) message.getDouble("height");
+
+ final float zoomFactor = layerView.getZoomFactor();
+ layerView.getLocationInWindow(locationInWindow);
+
+ contentRect = new Rect(
+ (int) (x * zoomFactor + locationInWindow[0]),
+ (int) (y * zoomFactor + locationInWindow[1]),
+ (int) ((x + width) * zoomFactor + locationInWindow[0]),
+ (int) ((y + height) * zoomFactor + locationInWindow[1] +
+ (height > 0 ? handlesOffset : 0)));
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not calculate content rect", e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java b/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java
new file mode 100644
index 000000000..9fcbce4a4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/TextAction.java
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Text selection action like "copy", "paste", ..
+ */
+public class TextAction {
+ private static final String LOGTAG = "GeckoTextAction";
+
+ private String id;
+ private String label;
+ private int order;
+ private int floatingOrder;
+
+ private TextAction() {}
+
+ public static List<TextAction> fromEventMessage(JSONObject message) {
+ final List<TextAction> actions = new ArrayList<>();
+
+ try {
+ final JSONArray array = message.getJSONArray("actions");
+
+ for (int i = 0; i < array.length(); i++) {
+ final JSONObject object = array.getJSONObject(i);
+
+ final TextAction action = new TextAction();
+ action.id = object.getString("id");
+ action.label = object.getString("label");
+ action.order = object.getInt("order");
+ action.floatingOrder = object.optInt("floatingOrder", i);
+
+ actions.add(action);
+ }
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Could not parse text actions", e);
+ }
+
+ return actions;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public int getOrder() {
+ return order;
+ }
+
+ public int getFloatingOrder() {
+ return floatingOrder;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java b/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java
new file mode 100644
index 000000000..29e8e43f5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/text/TextSelection.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.text;
+
+public interface TextSelection {
+ void create();
+
+ boolean dismiss();
+
+ void destroy();
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java
new file mode 100644
index 000000000..4a1559823
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/AutocompleteHandler.java
@@ -0,0 +1,10 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+public interface AutocompleteHandler {
+ void onAutocomplete(String res);
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java
new file mode 100644
index 000000000..267c95e09
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BackButton.java
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.content.Context;
+import android.graphics.Path;
+import android.util.AttributeSet;
+
+public class BackButton extends NavButton {
+ public BackButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ mPath.reset();
+ mPath.addCircle(width / 2, height / 2, width / 2, Path.Direction.CW);
+
+ mBorderPath.reset();
+ mBorderPath.addCircle(width / 2, height / 2, (width / 2) - (mBorderWidth / 2), Path.Direction.CW);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java
new file mode 100644
index 000000000..b24e3b3ea
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbar.java
@@ -0,0 +1,960 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+
+import android.support.annotation.Nullable;
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SiteIdentity;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.TouchEventInterceptor;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.lwt.LightweightThemeDrawable;
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.MenuPopup;
+import org.mozilla.gecko.tabs.TabHistoryController;
+import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.OnStopListener;
+import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.OnTitleChangeListener;
+import org.mozilla.gecko.toolbar.ToolbarDisplayLayout.UpdateFlags;
+import org.mozilla.gecko.util.Clipboard;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.MenuUtils;
+import org.mozilla.gecko.widget.themed.ThemedFrameLayout;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+import org.mozilla.gecko.widget.themed.ThemedImageView;
+import org.mozilla.gecko.widget.themed.ThemedRelativeLayout;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.PopupWindow;
+import android.support.annotation.NonNull;
+
+/**
+* {@code BrowserToolbar} is single entry point for users of the toolbar
+* subsystem i.e. this should be the only import outside the 'toolbar'
+* package.
+*
+* {@code BrowserToolbar} serves at the single event bus for all
+* sub-components in the toolbar. It tracks tab events and gecko messages
+* and update the state of its inner components accordingly.
+*
+* It has two states, display and edit, which are controlled by
+* ToolbarEditLayout and ToolbarDisplayLayout. In display state, the toolbar
+* displays the current state for the selected tab. In edit state, it shows
+* a text entry for searching bookmarks/history. {@code BrowserToolbar}
+* provides public API to enter, cancel, and commit the edit state as well
+* as a set of listeners to allow {@code BrowserToolbar} users to react
+* to state changes accordingly.
+*/
+public abstract class BrowserToolbar extends ThemedRelativeLayout
+ implements Tabs.OnTabsChangedListener,
+ GeckoMenu.ActionItemBarPresenter {
+ private static final String LOGTAG = "GeckoToolbar";
+
+ private static final int LIGHTWEIGHT_THEME_INVERT_ALPHA = 34; // 255 - alpha = invert_alpha
+
+ public interface OnActivateListener {
+ public void onActivate();
+ }
+
+ public interface OnCommitListener {
+ public void onCommit();
+ }
+
+ public interface OnDismissListener {
+ public void onDismiss();
+ }
+
+ public interface OnFilterListener {
+ public void onFilter(String searchText, AutocompleteHandler handler);
+ }
+
+ public interface OnStartEditingListener {
+ public void onStartEditing();
+ }
+
+ public interface OnStopEditingListener {
+ public void onStopEditing();
+ }
+
+ protected enum UIMode {
+ EDIT,
+ DISPLAY
+ }
+
+ protected final ToolbarDisplayLayout urlDisplayLayout;
+ protected final ToolbarEditLayout urlEditLayout;
+ protected final View urlBarEntry;
+ protected boolean isSwitchingTabs;
+ protected final ThemedImageButton tabsButton;
+
+ private ToolbarProgressView progressBar;
+ protected final TabCounter tabsCounter;
+ protected final ThemedFrameLayout menuButton;
+ protected final ThemedImageView menuIcon;
+ private MenuPopup menuPopup;
+ protected final List<View> focusOrder;
+
+ private OnActivateListener activateListener;
+ private OnFocusChangeListener focusChangeListener;
+ private OnStartEditingListener startEditingListener;
+ private OnStopEditingListener stopEditingListener;
+ private TouchEventInterceptor mTouchEventInterceptor;
+
+ protected final BrowserApp activity;
+
+ protected UIMode uiMode;
+ protected TabHistoryController tabHistoryController;
+
+ private final Paint shadowPaint;
+ private final int shadowColor;
+ private final int shadowPrivateColor;
+ private final int shadowSize;
+
+ private final ToolbarPrefs prefs;
+
+ public abstract boolean isAnimating();
+
+ protected abstract boolean isTabsButtonOffscreen();
+
+ protected abstract void updateNavigationButtons(Tab tab);
+
+ protected abstract void triggerStartEditingTransition(PropertyAnimator animator);
+ protected abstract void triggerStopEditingTransition();
+ public abstract void triggerTabsPanelTransition(PropertyAnimator animator, boolean areTabsShown);
+
+ /**
+ * Returns a Drawable overlaid with the theme's bitmap.
+ */
+ protected Drawable getLWTDefaultStateSetDrawable() {
+ return getTheme().getDrawable(this);
+ }
+
+ public static BrowserToolbar create(final Context context, final AttributeSet attrs) {
+ final boolean isLargeResource = context.getResources().getBoolean(R.bool.is_large_resource);
+ final BrowserToolbar toolbar;
+ if (isLargeResource) {
+ toolbar = new BrowserToolbarTablet(context, attrs);
+ } else {
+ toolbar = new BrowserToolbarPhone(context, attrs);
+ }
+ return toolbar;
+ }
+
+ protected BrowserToolbar(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ setWillNotDraw(false);
+
+ // BrowserToolbar is attached to BrowserApp only.
+ activity = (BrowserApp) context;
+
+ LayoutInflater.from(context).inflate(R.layout.browser_toolbar, this);
+
+ Tabs.registerOnTabsChangedListener(this);
+ isSwitchingTabs = true;
+
+ urlDisplayLayout = (ToolbarDisplayLayout) findViewById(R.id.display_layout);
+ urlBarEntry = findViewById(R.id.url_bar_entry);
+ urlEditLayout = (ToolbarEditLayout) findViewById(R.id.edit_layout);
+
+ tabsButton = (ThemedImageButton) findViewById(R.id.tabs);
+ tabsCounter = (TabCounter) findViewById(R.id.tabs_counter);
+ tabsCounter.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+
+ menuButton = (ThemedFrameLayout) findViewById(R.id.menu);
+ menuIcon = (ThemedImageView) findViewById(R.id.menu_icon);
+
+ // The focusOrder List should be filled by sub-classes.
+ focusOrder = new ArrayList<View>();
+
+ final Resources res = getResources();
+ shadowSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_shadow_size);
+
+ shadowPaint = new Paint();
+ shadowColor = ContextCompat.getColor(context, R.color.url_bar_shadow);
+ shadowPrivateColor = ContextCompat.getColor(context, R.color.url_bar_shadow_private);
+ shadowPaint.setColor(shadowColor);
+ shadowPaint.setStrokeWidth(0.0f);
+
+ setUIMode(UIMode.DISPLAY);
+
+ prefs = new ToolbarPrefs();
+ urlDisplayLayout.setToolbarPrefs(prefs);
+ urlEditLayout.setToolbarPrefs(prefs);
+
+ setOnCreateContextMenuListener(new View.OnCreateContextMenuListener() {
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+ // Do not show the context menu while editing
+ if (isEditing()) {
+ return;
+ }
+
+ // NOTE: Use MenuUtils.safeSetVisible because some actions might
+ // be on the Page menu
+ MenuInflater inflater = activity.getMenuInflater();
+ inflater.inflate(R.menu.titlebar_contextmenu, menu);
+
+ String clipboard = Clipboard.getText();
+ if (TextUtils.isEmpty(clipboard)) {
+ menu.findItem(R.id.pasteandgo).setVisible(false);
+ menu.findItem(R.id.paste).setVisible(false);
+ }
+
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ String url = tab.getURL();
+ if (url == null) {
+ menu.findItem(R.id.copyurl).setVisible(false);
+ menu.findItem(R.id.add_to_launcher).setVisible(false);
+ }
+
+ MenuUtils.safeSetVisible(menu, R.id.subscribe, tab.hasFeeds());
+ MenuUtils.safeSetVisible(menu, R.id.add_search_engine, tab.hasOpenSearch());
+ } else {
+ // if there is no tab, remove anything tab dependent
+ menu.findItem(R.id.copyurl).setVisible(false);
+ menu.findItem(R.id.add_to_launcher).setVisible(false);
+ MenuUtils.safeSetVisible(menu, R.id.subscribe, false);
+ MenuUtils.safeSetVisible(menu, R.id.add_search_engine, false);
+ }
+ }
+ });
+
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (activateListener != null) {
+ activateListener.onActivate();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ prefs.open();
+
+ urlDisplayLayout.setOnStopListener(new OnStopListener() {
+ @Override
+ public Tab onStop() {
+ final Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ tab.doStop();
+ return tab;
+ }
+
+ return null;
+ }
+ });
+
+ urlDisplayLayout.setOnTitleChangeListener(new OnTitleChangeListener() {
+ @Override
+ public void onTitleChange(CharSequence title) {
+ final String contentDescription;
+ if (title != null) {
+ contentDescription = title.toString();
+ } else {
+ contentDescription = activity.getString(R.string.url_bar_default_text);
+ }
+
+ // The title and content description should
+ // always be sync.
+ setContentDescription(contentDescription);
+ }
+ });
+
+ urlEditLayout.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ // This will select the url bar when entering editing mode.
+ setSelected(hasFocus);
+ if (focusChangeListener != null) {
+ focusChangeListener.onFocusChange(v, hasFocus);
+ }
+ }
+ });
+
+ tabsButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Clear focus so a back press with the tabs
+ // panel open does not go to the editing field.
+ urlEditLayout.clearFocus();
+
+ toggleTabs();
+ }
+ });
+ tabsButton.setImageLevel(0);
+
+ menuButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ // Drop the soft keyboard.
+ urlEditLayout.clearFocus();
+ activity.openOptionsMenu();
+ }
+ });
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ prefs.close();
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ final int height = getHeight();
+ canvas.drawRect(0, height - shadowSize, getWidth(), height, shadowPaint);
+ }
+
+ public void onParentFocus() {
+ urlEditLayout.onParentFocus();
+ }
+
+ public void setProgressBar(ToolbarProgressView progressBar) {
+ this.progressBar = progressBar;
+ }
+
+ public void setTabHistoryController(TabHistoryController tabHistoryController) {
+ this.tabHistoryController = tabHistoryController;
+ }
+
+ public void refresh() {
+ urlDisplayLayout.dismissSiteIdentityPopup();
+ }
+
+ public boolean onBackPressed() {
+ // If we exit editing mode during the animation,
+ // we're put into an inconsistent state (bug 1017276).
+ if (isEditing() && !isAnimating()) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL,
+ TelemetryContract.Method.BACK);
+ cancelEdit();
+ return true;
+ }
+
+ return urlDisplayLayout.dismissSiteIdentityPopup();
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ if (h != oldh) {
+ // Post this to happen outside of onSizeChanged, as this may cause
+ // a layout change and relayouts within a layout change don't work.
+ post(new Runnable() {
+ @Override
+ public void run() {
+ activity.refreshToolbarHeight();
+ }
+ });
+ }
+ }
+
+ public void saveTabEditingState(final TabEditingState editingState) {
+ urlEditLayout.saveTabEditingState(editingState);
+ }
+
+ public void restoreTabEditingState(final TabEditingState editingState) {
+ if (!isEditing()) {
+ throw new IllegalStateException("Expected to be editing");
+ }
+
+ urlEditLayout.restoreTabEditingState(editingState);
+ }
+
+ @Override
+ public void onTabChanged(@Nullable Tab tab, Tabs.TabEvents msg, String data) {
+ Log.d(LOGTAG, "onTabChanged: " + msg);
+ final Tabs tabs = Tabs.getInstance();
+
+ // These conditions are split into three phases:
+ // * Always do first
+ // * Handling specific to the selected tab
+ // * Always do afterwards.
+
+ switch (msg) {
+ case ADDED:
+ case CLOSED:
+ updateTabCount(tabs.getDisplayCount());
+ break;
+ case RESTORED:
+ // TabCount fixup after OOM
+ case SELECTED:
+ urlDisplayLayout.dismissSiteIdentityPopup();
+ updateTabCount(tabs.getDisplayCount());
+ isSwitchingTabs = true;
+ break;
+ }
+
+ if (tabs.isSelectedTab(tab)) {
+ final EnumSet<UpdateFlags> flags = EnumSet.noneOf(UpdateFlags.class);
+
+ // Progress-related handling
+ switch (msg) {
+ case START:
+ updateProgressVisibility(tab, Tab.LOAD_PROGRESS_INIT);
+ // Fall through.
+ case ADDED:
+ case LOCATION_CHANGE:
+ case LOAD_ERROR:
+ case LOADED:
+ case STOP:
+ flags.add(UpdateFlags.PROGRESS);
+ if (progressBar.getVisibility() == View.VISIBLE) {
+ progressBar.animateProgress(tab.getLoadProgress());
+ }
+ break;
+
+ case SELECTED:
+ flags.add(UpdateFlags.PROGRESS);
+ updateProgressVisibility();
+ break;
+ }
+
+ switch (msg) {
+ case STOP:
+ // Reset the title in case we haven't navigated
+ // to a new page yet.
+ flags.add(UpdateFlags.TITLE);
+ // Fall through.
+ case START:
+ case CLOSED:
+ case ADDED:
+ updateNavigationButtons(tab);
+ break;
+
+ case SELECTED:
+ flags.add(UpdateFlags.PRIVATE_MODE);
+ setPrivateMode(tab.isPrivate());
+ // Fall through.
+ case LOAD_ERROR:
+ case LOCATION_CHANGE:
+ // We're displaying the tab URL in place of the title,
+ // so we always need to update our "title" here as well.
+ flags.add(UpdateFlags.TITLE);
+ flags.add(UpdateFlags.FAVICON);
+ flags.add(UpdateFlags.SITE_IDENTITY);
+
+ updateNavigationButtons(tab);
+ break;
+
+ case TITLE:
+ flags.add(UpdateFlags.TITLE);
+ break;
+
+ case FAVICON:
+ flags.add(UpdateFlags.FAVICON);
+ break;
+
+ case SECURITY_CHANGE:
+ flags.add(UpdateFlags.SITE_IDENTITY);
+ break;
+ }
+
+ if (!flags.isEmpty() && tab != null) {
+ updateDisplayLayout(tab, flags);
+ }
+ }
+
+ switch (msg) {
+ case SELECTED:
+ case LOAD_ERROR:
+ case LOCATION_CHANGE:
+ isSwitchingTabs = false;
+ }
+ }
+
+ private void updateProgressVisibility() {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ // The selected tab may be null if GeckoApp (and thus the
+ // selected tab) are not yet initialized (bug 1090287).
+ if (selectedTab != null) {
+ updateProgressVisibility(selectedTab, selectedTab.getLoadProgress());
+ }
+ }
+
+ private void updateProgressVisibility(Tab selectedTab, int progress) {
+ if (!isEditing() && selectedTab.getState() == Tab.STATE_LOADING) {
+ progressBar.setProgress(progress);
+ progressBar.setPrivateMode(selectedTab.isPrivate());
+ progressBar.setVisibility(View.VISIBLE);
+ } else {
+ progressBar.setVisibility(View.GONE);
+ }
+ }
+
+ protected boolean isVisible() {
+ return ViewHelper.getTranslationY(this) == 0;
+ }
+
+ @Override
+ public void setNextFocusDownId(int nextId) {
+ super.setNextFocusDownId(nextId);
+ tabsButton.setNextFocusDownId(nextId);
+ urlDisplayLayout.setNextFocusDownId(nextId);
+ menuButton.setNextFocusDownId(nextId);
+ }
+
+ public boolean hideVirtualKeyboard() {
+ InputMethodManager imm =
+ (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
+ return imm.hideSoftInputFromWindow(tabsButton.getWindowToken(), 0);
+ }
+
+ private void showSelectedTabs() {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ if (!tab.isPrivate())
+ activity.showNormalTabs();
+ else
+ activity.showPrivateTabs();
+ }
+ }
+
+ private void toggleTabs() {
+ if (activity.areTabsShown()) {
+ return;
+ }
+
+ if (hideVirtualKeyboard()) {
+ getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ getViewTreeObserver().removeGlobalOnLayoutListener(this);
+ showSelectedTabs();
+ }
+ });
+ } else {
+ showSelectedTabs();
+ }
+ }
+
+ protected void updateTabCount(final int count) {
+ // If toolbar is in edit mode on a phone, this means the entry is expanded
+ // and the tabs button is translated offscreen. Don't trigger tabs counter
+ // updates until the tabs button is back on screen.
+ // See stopEditing()
+ if (isTabsButtonOffscreen()) {
+ return;
+ }
+
+ // Set TabCounter based on visibility
+ if (isVisible() && ViewHelper.getAlpha(tabsCounter) != 0 && !isEditing()) {
+ tabsCounter.setCountWithAnimation(count);
+ } else {
+ tabsCounter.setCount(count);
+ }
+
+ // Update A11y information
+ tabsButton.setContentDescription((count > 1) ?
+ activity.getString(R.string.num_tabs, count) :
+ activity.getString(R.string.one_tab));
+ }
+
+ private void updateDisplayLayout(@NonNull Tab tab, EnumSet<UpdateFlags> flags) {
+ if (isSwitchingTabs) {
+ flags.add(UpdateFlags.DISABLE_ANIMATIONS);
+ }
+
+ urlDisplayLayout.updateFromTab(tab, flags);
+
+ if (flags.contains(UpdateFlags.TITLE)) {
+ if (!isEditing()) {
+ urlEditLayout.setText(tab.getURL());
+ }
+ }
+
+ if (flags.contains(UpdateFlags.PROGRESS)) {
+ updateFocusOrder();
+ }
+ }
+
+ private void updateFocusOrder() {
+ if (focusOrder.size() == 0) {
+ throw new IllegalStateException("Expected focusOrder to be initialized in subclass");
+ }
+
+ View prevView = null;
+
+ // If the element that has focus becomes disabled or invisible, focus
+ // is given to the URL bar.
+ boolean needsNewFocus = false;
+
+ for (View view : focusOrder) {
+ if (view.getVisibility() != View.VISIBLE || !view.isEnabled()) {
+ if (view.hasFocus()) {
+ needsNewFocus = true;
+ }
+ continue;
+ }
+
+ if (view.getId() == R.id.menu_items) {
+ final LinearLayout actionItemBar = (LinearLayout) view;
+ final int childCount = actionItemBar.getChildCount();
+ for (int child = 0; child < childCount; child++) {
+ View childView = actionItemBar.getChildAt(child);
+ if (prevView != null) {
+ childView.setNextFocusLeftId(prevView.getId());
+ prevView.setNextFocusRightId(childView.getId());
+ }
+ prevView = childView;
+ }
+ } else {
+ if (prevView != null) {
+ view.setNextFocusLeftId(prevView.getId());
+ prevView.setNextFocusRightId(view.getId());
+ }
+ prevView = view;
+ }
+ }
+
+ if (needsNewFocus) {
+ requestFocus();
+ }
+ }
+
+ public void setToolBarButtonsAlpha(float alpha) {
+ ViewHelper.setAlpha(tabsCounter, alpha);
+ if (!HardwareUtils.isTablet()) {
+ ViewHelper.setAlpha(menuIcon, alpha);
+ }
+ }
+
+ public void onEditSuggestion(String suggestion) {
+ if (!isEditing()) {
+ return;
+ }
+
+ urlEditLayout.onEditSuggestion(suggestion);
+ }
+
+ public void setTitle(CharSequence title) {
+ urlDisplayLayout.setTitle(title);
+ }
+
+ public void setOnActivateListener(final OnActivateListener listener) {
+ activateListener = listener;
+ }
+
+ public void setOnCommitListener(OnCommitListener listener) {
+ urlEditLayout.setOnCommitListener(listener);
+ }
+
+ public void setOnDismissListener(OnDismissListener listener) {
+ urlEditLayout.setOnDismissListener(listener);
+ }
+
+ public void setOnFilterListener(OnFilterListener listener) {
+ urlEditLayout.setOnFilterListener(listener);
+ }
+
+ @Override
+ public void setOnFocusChangeListener(OnFocusChangeListener listener) {
+ focusChangeListener = listener;
+ }
+
+ public void setOnStartEditingListener(OnStartEditingListener listener) {
+ startEditingListener = listener;
+ }
+
+ public void setOnStopEditingListener(OnStopEditingListener listener) {
+ stopEditingListener = listener;
+ }
+
+ protected void showUrlEditLayout() {
+ setUrlEditLayoutVisibility(true, null);
+ }
+
+ protected void showUrlEditLayout(final PropertyAnimator animator) {
+ setUrlEditLayoutVisibility(true, animator);
+ }
+
+ protected void hideUrlEditLayout() {
+ setUrlEditLayoutVisibility(false, null);
+ }
+
+ protected void hideUrlEditLayout(final PropertyAnimator animator) {
+ setUrlEditLayoutVisibility(false, animator);
+ }
+
+ protected void setUrlEditLayoutVisibility(final boolean showEditLayout, PropertyAnimator animator) {
+ if (showEditLayout) {
+ urlEditLayout.prepareShowAnimation(animator);
+ }
+
+ // If this view is GONE, we trigger a measure pass when setting the view to
+ // VISIBLE. Since this will occur during the toolbar open animation, it causes jank.
+ final int hiddenViewVisibility = View.INVISIBLE;
+
+ if (animator == null) {
+ final View viewToShow = (showEditLayout ? urlEditLayout : urlDisplayLayout);
+ final View viewToHide = (showEditLayout ? urlDisplayLayout : urlEditLayout);
+
+ viewToHide.setVisibility(hiddenViewVisibility);
+ viewToShow.setVisibility(View.VISIBLE);
+ return;
+ }
+
+ animator.addPropertyAnimationListener(new PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ if (!showEditLayout) {
+ urlEditLayout.setVisibility(hiddenViewVisibility);
+ urlDisplayLayout.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ if (showEditLayout) {
+ urlDisplayLayout.setVisibility(hiddenViewVisibility);
+ urlEditLayout.setVisibility(View.VISIBLE);
+ }
+ }
+ });
+ }
+
+ private void setUIMode(final UIMode uiMode) {
+ this.uiMode = uiMode;
+ urlEditLayout.setEnabled(uiMode == UIMode.EDIT);
+ }
+
+ /**
+ * Returns whether or not the URL bar is in editing mode (url bar is expanded, hiding the new
+ * tab button). Note that selection state is independent of editing mode.
+ */
+ public boolean isEditing() {
+ return (uiMode == UIMode.EDIT);
+ }
+
+ public void startEditing(String url, PropertyAnimator animator) {
+ if (isEditing()) {
+ return;
+ }
+
+ urlEditLayout.setText(url != null ? url : "");
+
+ setUIMode(UIMode.EDIT);
+
+ updateProgressVisibility();
+
+ if (startEditingListener != null) {
+ startEditingListener.onStartEditing();
+ }
+
+ triggerStartEditingTransition(animator);
+ }
+
+ /**
+ * Exits edit mode without updating the toolbar title.
+ *
+ * @return the url that was entered
+ */
+ public String cancelEdit() {
+ Telemetry.stopUISession(TelemetryContract.Session.AWESOMESCREEN);
+ return stopEditing();
+ }
+
+ /**
+ * Exits edit mode, updating the toolbar title with the url that was just entered.
+ *
+ * @return the url that was entered
+ */
+ public String commitEdit() {
+ Tab tab = Tabs.getInstance().getSelectedTab();
+ if (tab != null) {
+ tab.resetSiteIdentity();
+ }
+
+ final String url = stopEditing();
+ if (!TextUtils.isEmpty(url)) {
+ setTitle(url);
+ }
+ return url;
+ }
+
+ private String stopEditing() {
+ final String url = urlEditLayout.getText();
+ if (!isEditing()) {
+ return url;
+ }
+ setUIMode(UIMode.DISPLAY);
+
+ if (stopEditingListener != null) {
+ stopEditingListener.onStopEditing();
+ }
+
+ updateProgressVisibility();
+ triggerStopEditingTransition();
+
+ return url;
+ }
+
+ @Override
+ public void setPrivateMode(boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+
+ tabsButton.setPrivateMode(isPrivate);
+ menuButton.setPrivateMode(isPrivate);
+ urlEditLayout.setPrivateMode(isPrivate);
+
+ shadowPaint.setColor(isPrivate ? shadowPrivateColor : shadowColor);
+ }
+
+ public void show() {
+ setVisibility(View.VISIBLE);
+ }
+
+ public void hide() {
+ setVisibility(View.GONE);
+ }
+
+ public View getDoorHangerAnchor() {
+ return urlDisplayLayout;
+ }
+
+ public void onDestroy() {
+ Tabs.unregisterOnTabsChangedListener(this);
+ urlDisplayLayout.destroy();
+ }
+
+ public boolean openOptionsMenu() {
+ // Initialize the popup.
+ if (menuPopup == null) {
+ View panel = activity.getMenuPanel();
+ menuPopup = new MenuPopup(activity);
+ menuPopup.setPanelView(panel);
+
+ menuPopup.setOnDismissListener(new PopupWindow.OnDismissListener() {
+ @Override
+ public void onDismiss() {
+ activity.onOptionsMenuClosed(null);
+ }
+ });
+ }
+
+ GeckoAppShell.getGeckoInterface().invalidateOptionsMenu();
+ if (!menuPopup.isShowing()) {
+ menuPopup.showAsDropDown(menuButton);
+ }
+
+ return true;
+ }
+
+ public boolean closeOptionsMenu() {
+ if (menuPopup != null && menuPopup.isShowing()) {
+ menuPopup.dismiss();
+ }
+
+ return true;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ final Drawable drawable = getLWTDefaultStateSetDrawable();
+ if (drawable == null) {
+ return;
+ }
+
+ final StateListDrawable stateList = new StateListDrawable();
+ stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.tabs_tray_grey_pressed));
+ stateList.addState(EMPTY_STATE_SET, drawable);
+
+ setBackgroundDrawable(stateList);
+ }
+
+ public void setTouchEventInterceptor(TouchEventInterceptor interceptor) {
+ mTouchEventInterceptor = interceptor;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent event) {
+ if (mTouchEventInterceptor != null && mTouchEventInterceptor.onInterceptTouchEvent(this, event)) {
+ return true;
+ }
+ return super.onInterceptTouchEvent(event);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundResource(R.drawable.url_bar_bg);
+ }
+
+ public static LightweightThemeDrawable getLightweightThemeDrawable(final View view,
+ final LightweightTheme theme, final int colorResID) {
+ final int color = ContextCompat.getColor(view.getContext(), colorResID);
+
+ final LightweightThemeDrawable drawable = theme.getColorDrawable(view, color);
+ if (drawable != null) {
+ drawable.setAlpha(LIGHTWEIGHT_THEME_INVERT_ALPHA, LIGHTWEIGHT_THEME_INVERT_ALPHA);
+ }
+
+ return drawable;
+ }
+
+ public static class TabEditingState {
+ // The edited text from the most recent time this tab was unselected.
+ protected String lastEditingText;
+ protected int selectionStart;
+ protected int selectionEnd;
+
+ public boolean isBrowserSearchShown;
+
+ public void copyFrom(final TabEditingState s2) {
+ lastEditingText = s2.lastEditingText;
+ selectionStart = s2.selectionStart;
+ selectionEnd = s2.selectionEnd;
+
+ isBrowserSearchShown = s2.isBrowserSearchShown;
+ }
+
+ public boolean isBrowserSearchShown() {
+ return isBrowserSearchShown;
+ }
+
+ public void setIsBrowserSearchShown(final boolean isShown) {
+ isBrowserSearchShown = isShown;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java
new file mode 100644
index 000000000..a5fc57f1a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhone.java
@@ -0,0 +1,128 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+
+/**
+ * A toolbar implementation for phones.
+ */
+class BrowserToolbarPhone extends BrowserToolbarPhoneBase {
+
+ private final PropertyAnimationListener showEditingListener;
+ private final PropertyAnimationListener stopEditingListener;
+
+ protected boolean isAnimatingEntry;
+
+ protected BrowserToolbarPhone(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ // Create these listeners here, once, to avoid constructing new listeners
+ // each time they are set on an animator (i.e. each time the url bar is clicked).
+ showEditingListener = new PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() { /* Do nothing */ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ isAnimatingEntry = false;
+ }
+ };
+
+ stopEditingListener = new PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() { /* Do nothing */ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ urlBarTranslatingEdge.setVisibility(View.INVISIBLE);
+
+ final PropertyAnimator buttonsAnimator = new PropertyAnimator(300);
+ urlDisplayLayout.prepareStopEditingAnimation(buttonsAnimator);
+ buttonsAnimator.start();
+
+ isAnimatingEntry = false;
+
+ // Trigger animation to update the tabs counter once the
+ // tabs button is back on screen.
+ updateTabCountAndAnimate(Tabs.getInstance().getDisplayCount());
+ }
+ };
+ }
+
+ @Override
+ public boolean isAnimating() {
+ return isAnimatingEntry;
+ }
+
+ @Override
+ protected void triggerStartEditingTransition(final PropertyAnimator animator) {
+ if (isAnimatingEntry) {
+ return;
+ }
+
+ // The animation looks cleaner if the text in the URL bar is
+ // not selected so clear the selection by clearing focus.
+ urlEditLayout.clearFocus();
+
+ urlDisplayLayout.prepareStartEditingAnimation();
+ addAnimationsForEditing(animator, true);
+ showUrlEditLayout(animator);
+ urlBarTranslatingEdge.setVisibility(View.VISIBLE);
+ animator.addPropertyAnimationListener(showEditingListener);
+
+ isAnimatingEntry = true; // To be correct, this should be called last.
+ }
+
+ @Override
+ protected void triggerStopEditingTransition() {
+ final PropertyAnimator animator = new PropertyAnimator(250);
+ animator.setUseHardwareLayer(false);
+
+ addAnimationsForEditing(animator, false);
+ hideUrlEditLayout(animator);
+ animator.addPropertyAnimationListener(stopEditingListener);
+
+ isAnimatingEntry = true;
+ animator.start();
+ }
+
+ private void addAnimationsForEditing(final PropertyAnimator animator, final boolean isEditing) {
+ final int curveTranslation;
+ final int entryTranslation;
+ if (isEditing) {
+ curveTranslation = getUrlBarCurveTranslation();
+ entryTranslation = getUrlBarEntryTranslation();
+ } else {
+ curveTranslation = 0;
+ entryTranslation = 0;
+ }
+
+ // Slide toolbar elements.
+ animator.attach(urlBarTranslatingEdge,
+ PropertyAnimator.Property.TRANSLATION_X,
+ entryTranslation);
+ animator.attach(tabsButton,
+ PropertyAnimator.Property.TRANSLATION_X,
+ curveTranslation);
+ animator.attach(tabsCounter,
+ PropertyAnimator.Property.TRANSLATION_X,
+ curveTranslation);
+ animator.attach(menuButton,
+ PropertyAnimator.Property.TRANSLATION_X,
+ curveTranslation);
+ animator.attach(menuIcon,
+ PropertyAnimator.Property.TRANSLATION_X,
+ curveTranslation);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java
new file mode 100644
index 000000000..5588ddcd3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarPhoneBase.java
@@ -0,0 +1,219 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import java.util.Arrays;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.widget.themed.ThemedImageView;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.Interpolator;
+import android.widget.ImageView;
+
+/**
+ * A base implementations of the browser toolbar for phones.
+ * This class manages any Views, variables, etc. that are exclusive to phone.
+ */
+abstract class BrowserToolbarPhoneBase extends BrowserToolbar {
+
+ protected final ImageView urlBarTranslatingEdge;
+ protected final ThemedImageView editCancel;
+
+ private final Path roundCornerShape;
+ private final Paint roundCornerPaint;
+
+ private final Interpolator buttonsInterpolator = new AccelerateInterpolator();
+
+ public BrowserToolbarPhoneBase(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ final Resources res = context.getResources();
+
+ urlBarTranslatingEdge = (ImageView) findViewById(R.id.url_bar_translating_edge);
+
+ // This will clip the translating edge's image at 60% of its width
+ urlBarTranslatingEdge.getDrawable().setLevel(6000);
+
+ editCancel = (ThemedImageView) findViewById(R.id.edit_cancel);
+
+ focusOrder.add(this);
+ focusOrder.addAll(urlDisplayLayout.getFocusOrder());
+ focusOrder.addAll(Arrays.asList(tabsButton, menuButton));
+
+ roundCornerShape = new Path();
+ roundCornerShape.moveTo(0, 0);
+ roundCornerShape.lineTo(30, 0);
+ roundCornerShape.cubicTo(0, 0, 0, 0, 0, 30);
+ roundCornerShape.lineTo(0, 0);
+
+ roundCornerPaint = new Paint();
+ roundCornerPaint.setAntiAlias(true);
+ roundCornerPaint.setColor(ContextCompat.getColor(context, R.color.text_and_tabs_tray_grey));
+ roundCornerPaint.setStrokeWidth(0.0f);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ editCancel.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // If we exit editing mode during the animation,
+ // we're put into an inconsistent state (bug 1017276).
+ if (!isAnimating()) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.CANCEL,
+ TelemetryContract.Method.ACTIONBAR,
+ getResources().getResourceEntryName(editCancel.getId()));
+ cancelEdit();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void setPrivateMode(final boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+ editCancel.setPrivateMode(isPrivate);
+ }
+
+ @Override
+ protected boolean isTabsButtonOffscreen() {
+ return isEditing();
+ }
+
+ @Override
+ public boolean addActionItem(final View actionItem) {
+ // We have no action item bar.
+ return false;
+ }
+
+ @Override
+ public void removeActionItem(final View actionItem) {
+ // We have no action item bar.
+ }
+
+ @Override
+ protected void updateNavigationButtons(final Tab tab) {
+ // We have no navigation buttons so do nothing.
+ }
+
+ @Override
+ public void draw(final Canvas canvas) {
+ super.draw(canvas);
+
+ if (uiMode == UIMode.DISPLAY) {
+ canvas.drawPath(roundCornerShape, roundCornerPaint);
+ }
+ }
+
+ @Override
+ public void triggerTabsPanelTransition(final PropertyAnimator animator, final boolean areTabsShown) {
+ if (areTabsShown) {
+ ViewHelper.setAlpha(tabsCounter, 0.0f);
+ ViewHelper.setAlpha(menuIcon, 0.0f);
+ return;
+ }
+
+ final PropertyAnimator buttonsAnimator =
+ new PropertyAnimator(animator.getDuration(), buttonsInterpolator);
+ buttonsAnimator.attach(tabsCounter,
+ PropertyAnimator.Property.ALPHA,
+ 1.0f);
+ buttonsAnimator.attach(menuIcon,
+ PropertyAnimator.Property.ALPHA,
+ 1.0f);
+ buttonsAnimator.start();
+ }
+
+ /**
+ * Returns the number of pixels the url bar translating edge
+ * needs to translate to the right to enter its editing mode state.
+ * A negative value means the edge must translate to the left.
+ */
+ protected int getUrlBarEntryTranslation() {
+ // Find the distance from the right-edge of the url bar (where we're translating from) to
+ // the left-edge of the cancel button (where we're translating to; note that the cancel
+ // button must be laid out, i.e. not View.GONE).
+ return editCancel.getLeft() - urlBarEntry.getRight();
+ }
+
+ protected int getUrlBarCurveTranslation() {
+ return getWidth() - tabsButton.getLeft();
+ }
+
+ protected void updateTabCountAndAnimate(final int count) {
+ // Don't animate if the toolbar is hidden.
+ if (!isVisible()) {
+ updateTabCount(count);
+ return;
+ }
+
+ // If toolbar is in edit mode on a phone, this means the entry is expanded
+ // and the tabs button is translated offscreen. Don't trigger tabs counter
+ // updates until the tabs button is back on screen.
+ // See stopEditing()
+ if (!isTabsButtonOffscreen()) {
+ tabsCounter.setCount(count);
+
+ tabsButton.setContentDescription((count > 1) ?
+ activity.getString(R.string.num_tabs, count) :
+ activity.getString(R.string.one_tab));
+ }
+ }
+
+ @Override
+ protected void setUrlEditLayoutVisibility(final boolean showEditLayout,
+ final PropertyAnimator animator) {
+ super.setUrlEditLayoutVisibility(showEditLayout, animator);
+
+ if (animator == null) {
+ editCancel.setVisibility(showEditLayout ? View.VISIBLE : View.INVISIBLE);
+ return;
+ }
+
+ animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ if (!showEditLayout) {
+ editCancel.setVisibility(View.INVISIBLE);
+ }
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ if (showEditLayout) {
+ editCancel.setVisibility(View.VISIBLE);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ super.onLightweightThemeChanged();
+ editCancel.onLightweightThemeChanged();
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ super.onLightweightThemeReset();
+ editCancel.onLightweightThemeReset();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java
new file mode 100644
index 000000000..215934161
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTablet.java
@@ -0,0 +1,211 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+/**
+ * The toolbar implementation for tablet.
+ */
+class BrowserToolbarTablet extends BrowserToolbarTabletBase {
+
+ private static final int FORWARD_ANIMATION_DURATION = 450;
+
+ private enum ForwardButtonState {
+ HIDDEN,
+ DISPLAYED,
+ TRANSITIONING,
+ }
+
+ private final int forwardButtonTranslationWidth;
+
+ private ForwardButtonState forwardButtonState;
+
+ private boolean backButtonWasEnabledOnStartEditing;
+
+ public BrowserToolbarTablet(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ forwardButtonTranslationWidth =
+ getResources().getDimensionPixelOffset(R.dimen.tablet_nav_button_width);
+
+ // The forward button is initially expanded (in the layout file)
+ // so translate it for start of the expansion animation; future
+ // iterations translate it to this position when hiding and will already be set up.
+ ViewHelper.setTranslationX(forwardButton, -forwardButtonTranslationWidth);
+
+ // TODO: Move this to *TabletBase when old tablet is removed.
+ // We don't want users clicking the forward button in transitions, but we don't want it to
+ // look disabled to avoid flickering complications (e.g. disabled in editing mode), so undo
+ // the work of the super class' constructor.
+ forwardButton.setEnabled(true);
+
+ updateForwardButtonState(ForwardButtonState.HIDDEN);
+ }
+
+ private void updateForwardButtonState(final ForwardButtonState state) {
+ forwardButtonState = state;
+ forwardButton.setEnabled(forwardButtonState == ForwardButtonState.DISPLAYED);
+ }
+
+ @Override
+ public boolean isAnimating() {
+ return false;
+ }
+
+ @Override
+ protected void triggerStartEditingTransition(final PropertyAnimator animator) {
+ showUrlEditLayout();
+ }
+
+ @Override
+ protected void triggerStopEditingTransition() {
+ hideUrlEditLayout();
+ }
+
+ @Override
+ protected void animateForwardButton(final ForwardButtonAnimation animation) {
+ final boolean willShowForward = (animation == ForwardButtonAnimation.SHOW);
+ if ((forwardButtonState != ForwardButtonState.HIDDEN && willShowForward) ||
+ (forwardButtonState != ForwardButtonState.DISPLAYED && !willShowForward)) {
+ return;
+ }
+ updateForwardButtonState(ForwardButtonState.TRANSITIONING);
+
+ // We want the forward button to show immediately when switching tabs
+ final PropertyAnimator forwardAnim =
+ new PropertyAnimator(isSwitchingTabs ? 10 : FORWARD_ANIMATION_DURATION);
+
+ forwardAnim.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ if (!willShowForward) {
+ // Set the margin before the transition when hiding the forward button. We
+ // have to do this so that the favicon isn't clipped during the transition
+ MarginLayoutParams layoutParams =
+ (MarginLayoutParams) urlDisplayLayout.getLayoutParams();
+ layoutParams.leftMargin = 0;
+
+ // Do the same on the URL edit container
+ layoutParams = (MarginLayoutParams) urlEditLayout.getLayoutParams();
+ layoutParams.leftMargin = 0;
+
+ requestLayout();
+ // Note, we already translated the favicon, site security, and text field
+ // in prepareForwardAnimation, so they should appear to have not moved at
+ // all at this point.
+ }
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ final ForwardButtonState newForwardButtonState;
+ if (willShowForward) {
+ // Increase the margins to ensure the text does not run outside the View.
+ MarginLayoutParams layoutParams =
+ (MarginLayoutParams) urlDisplayLayout.getLayoutParams();
+ layoutParams.leftMargin = forwardButtonTranslationWidth;
+
+ layoutParams = (MarginLayoutParams) urlEditLayout.getLayoutParams();
+ layoutParams.leftMargin = forwardButtonTranslationWidth;
+
+ newForwardButtonState = ForwardButtonState.DISPLAYED;
+ } else {
+ newForwardButtonState = ForwardButtonState.HIDDEN;
+ }
+
+ urlDisplayLayout.finishForwardAnimation();
+ updateForwardButtonState(newForwardButtonState);
+
+ requestLayout();
+ }
+ });
+
+ prepareForwardAnimation(forwardAnim, animation, forwardButtonTranslationWidth);
+ forwardAnim.start();
+ }
+
+ private void prepareForwardAnimation(PropertyAnimator anim, ForwardButtonAnimation animation, int width) {
+ if (animation == ForwardButtonAnimation.HIDE) {
+ anim.attach(forwardButton,
+ PropertyAnimator.Property.TRANSLATION_X,
+ -width);
+ anim.attach(forwardButton,
+ PropertyAnimator.Property.ALPHA,
+ 0);
+
+ } else {
+ anim.attach(forwardButton,
+ PropertyAnimator.Property.TRANSLATION_X,
+ 0);
+ anim.attach(forwardButton,
+ PropertyAnimator.Property.ALPHA,
+ 1);
+ }
+
+ urlDisplayLayout.prepareForwardAnimation(anim, animation, width);
+ }
+
+ @Override
+ public void triggerTabsPanelTransition(final PropertyAnimator animator, final boolean areTabsShown) {
+ // Do nothing.
+ }
+
+ @Override
+ public void setToolBarButtonsAlpha(float alpha) {
+ // Do nothing.
+ }
+
+
+ @Override
+ public void startEditing(final String url, final PropertyAnimator animator) {
+ // We already know the forward button state - no need to store it here.
+ backButtonWasEnabledOnStartEditing = backButton.isEnabled();
+
+ backButton.setEnabled(false);
+ forwardButton.setEnabled(false);
+
+ super.startEditing(url, animator);
+ }
+
+ @Override
+ public String commitEdit() {
+ stopEditingNewTablet();
+ return super.commitEdit();
+ }
+
+ @Override
+ public String cancelEdit() {
+ // This can get called when we're not editing but we only want
+ // to make these changes when leaving editing mode.
+ if (isEditing()) {
+ stopEditingNewTablet();
+
+ backButton.setEnabled(backButtonWasEnabledOnStartEditing);
+ updateForwardButtonState(forwardButtonState);
+ }
+
+ return super.cancelEdit();
+ }
+
+ private void stopEditingNewTablet() {
+ // Undo the changes caused by calling setEnabled for forwardButton in startEditing.
+ // Note that this should be called first so the enabled state of the
+ // forward button is set to the proper value.
+ forwardButton.setEnabled(true);
+ }
+
+ @Override
+ protected Drawable getLWTDefaultStateSetDrawable() {
+ return BrowserToolbar.getLightweightThemeDrawable(this, getTheme(), R.color.toolbar_grey);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java
new file mode 100644
index 000000000..e818bb95c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/BrowserToolbarTabletBase.java
@@ -0,0 +1,182 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import java.util.Arrays;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.tabs.TabHistoryController;
+import org.mozilla.gecko.menu.MenuItemActionBar;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.widget.themed.ThemedTextView;
+
+import android.content.Context;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+
+/**
+ * A base implementations of the browser toolbar for tablets.
+ * This class manages any Views, variables, etc. that are exclusive to tablet.
+ */
+abstract class BrowserToolbarTabletBase extends BrowserToolbar {
+
+ protected enum ForwardButtonAnimation {
+ SHOW,
+ HIDE
+ }
+
+ protected final LinearLayout actionItemBar;
+
+ protected final BackButton backButton;
+ protected final ForwardButton forwardButton;
+
+ protected final View menuButtonMarginView;
+
+ private final PorterDuffColorFilter privateBrowsingTabletMenuItemColorFilter;
+
+ protected abstract void animateForwardButton(ForwardButtonAnimation animation);
+
+ public BrowserToolbarTabletBase(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ actionItemBar = (LinearLayout) findViewById(R.id.menu_items);
+
+ backButton = (BackButton) findViewById(R.id.back);
+ backButton.setEnabled(false);
+ forwardButton = (ForwardButton) findViewById(R.id.forward);
+ forwardButton.setEnabled(false);
+ initButtonListeners();
+
+ focusOrder.addAll(Arrays.asList(tabsButton, (View) backButton, (View) forwardButton, this));
+ focusOrder.addAll(urlDisplayLayout.getFocusOrder());
+ focusOrder.addAll(Arrays.asList(actionItemBar, menuButton));
+
+ urlDisplayLayout.updateSiteIdentityAnchor(backButton);
+
+ privateBrowsingTabletMenuItemColorFilter = new PorterDuffColorFilter(
+ ContextCompat.getColor(context, R.color.tabs_tray_icon_grey), PorterDuff.Mode.SRC_IN);
+
+ menuButtonMarginView = findViewById(R.id.menu_margin);
+ if (menuButtonMarginView != null) {
+ menuButtonMarginView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void initButtonListeners() {
+ backButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Tabs.getInstance().getSelectedTab().doBack();
+ }
+ });
+ backButton.setOnLongClickListener(new Button.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ return tabHistoryController.showTabHistory(Tabs.getInstance().getSelectedTab(),
+ TabHistoryController.HistoryAction.BACK);
+ }
+ });
+
+ forwardButton.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Tabs.getInstance().getSelectedTab().doForward();
+ }
+ });
+ forwardButton.setOnLongClickListener(new Button.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View view) {
+ return tabHistoryController.showTabHistory(Tabs.getInstance().getSelectedTab(),
+ TabHistoryController.HistoryAction.FORWARD);
+ }
+ });
+ }
+
+ @Override
+ protected boolean isTabsButtonOffscreen() {
+ return false;
+ }
+
+ @Override
+ public boolean addActionItem(final View actionItem) {
+ actionItemBar.addView(actionItem, LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
+ return true;
+ }
+
+ @Override
+ public void removeActionItem(final View actionItem) {
+ actionItemBar.removeView(actionItem);
+ }
+
+ @Override
+ protected void updateNavigationButtons(final Tab tab) {
+ backButton.setEnabled(canDoBack(tab));
+ animateForwardButton(
+ canDoForward(tab) ? ForwardButtonAnimation.SHOW : ForwardButtonAnimation.HIDE);
+ }
+
+ @Override
+ public void setNextFocusDownId(int nextId) {
+ super.setNextFocusDownId(nextId);
+ backButton.setNextFocusDownId(nextId);
+ forwardButton.setNextFocusDownId(nextId);
+ }
+
+ @Override
+ public void setPrivateMode(final boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+
+ // If we had backgroundTintList, we could remove the colorFilter
+ // code in favor of setPrivateMode (bug 1197432).
+ final PorterDuffColorFilter colorFilter =
+ isPrivate ? privateBrowsingTabletMenuItemColorFilter : null;
+ setTabsCounterPrivateMode(isPrivate, colorFilter);
+
+ backButton.setPrivateMode(isPrivate);
+ forwardButton.setPrivateMode(isPrivate);
+ menuIcon.setPrivateMode(isPrivate);
+ for (int i = 0; i < actionItemBar.getChildCount(); ++i) {
+ final MenuItemActionBar child = (MenuItemActionBar) actionItemBar.getChildAt(i);
+ child.setPrivateMode(isPrivate);
+ }
+ }
+
+ private void setTabsCounterPrivateMode(final boolean isPrivate, final PorterDuffColorFilter colorFilter) {
+ // The TabsCounter is a TextSwitcher which cycles two views
+ // to provide animations, hence looping over these two children.
+ for (int i = 0; i < 2; ++i) {
+ final ThemedTextView view = (ThemedTextView) tabsCounter.getChildAt(i);
+ view.setPrivateMode(isPrivate);
+ view.getBackground().mutate().setColorFilter(colorFilter);
+ }
+
+ // To prevent animation of the background,
+ // it is set to a different Drawable.
+ tabsCounter.getBackground().mutate().setColorFilter(colorFilter);
+ }
+
+ @Override
+ public View getDoorHangerAnchor() {
+ return backButton;
+ }
+
+ protected boolean canDoBack(final Tab tab) {
+ return (tab.canDoBack() && !isEditing());
+ }
+
+ protected boolean canDoForward(final Tab tab) {
+ return (tab.canDoForward() && !isEditing());
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java
new file mode 100644
index 000000000..55567fba3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/CanvasDelegate.java
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.AppConstants.Versions;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Shader;
+
+class CanvasDelegate {
+ Paint mPaint;
+ PorterDuffXfermode mMode;
+ DrawManager mDrawManager;
+
+ // DrawManager would do a default draw of the background.
+ static interface DrawManager {
+ public void defaultDraw(Canvas canvas);
+ }
+
+ CanvasDelegate(DrawManager drawManager, Mode mode, Paint paint) {
+ mDrawManager = drawManager;
+
+ // DST_IN masks, DST_OUT clips.
+ mMode = new PorterDuffXfermode(mode);
+
+ mPaint = paint;
+ }
+
+ void draw(Canvas canvas, Path path, int width, int height) {
+ // Save the canvas. All PorterDuff operations should be done in a offscreen bitmap.
+ int count = canvas.saveLayer(0, 0, width, height, null,
+ Canvas.MATRIX_SAVE_FLAG |
+ Canvas.CLIP_SAVE_FLAG |
+ Canvas.HAS_ALPHA_LAYER_SAVE_FLAG |
+ Canvas.FULL_COLOR_LAYER_SAVE_FLAG |
+ Canvas.CLIP_TO_LAYER_SAVE_FLAG);
+
+ // Do a default draw.
+ mDrawManager.defaultDraw(canvas);
+
+ if (path != null && !path.isEmpty()) {
+ // ICS added double-buffering, which made it easier for drawing the Path directly over the DST.
+ // In pre-ICS, drawPath() doesn't seem to use ARGB_8888 mode for performance, hence transparency is not preserved.
+ mPaint.setXfermode(mMode);
+ canvas.drawPath(path, mPaint);
+ }
+
+ // Restore the canvas.
+ canvas.restoreToCount(count);
+ }
+
+ void setShader(Shader shader) {
+ mPaint.setShader(shader);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java
new file mode 100644
index 000000000..f95bb5e8a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ForwardButton.java
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+public class ForwardButton extends NavButton {
+ public ForwardButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ mBorderPath.reset();
+ mBorderPath.moveTo(width - mBorderWidth, 0);
+ mBorderPath.lineTo(width - mBorderWidth, height);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java
new file mode 100644
index 000000000..68194e222
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/NavButton.java
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.AttributeSet;
+
+abstract class NavButton extends ShapedButton {
+ protected final Path mBorderPath;
+ protected final Paint mBorderPaint;
+ protected final float mBorderWidth;
+
+ protected final int mBorderColor;
+ protected final int mBorderColorPrivate;
+
+ public NavButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ final Resources res = getResources();
+ mBorderColor = ContextCompat.getColor(context, R.color.disabled_grey);
+ mBorderColorPrivate = ContextCompat.getColor(context, R.color.toolbar_icon_grey);
+ mBorderWidth = res.getDimension(R.dimen.nav_button_border_width);
+
+ // Paint to draw the border.
+ mBorderPaint = new Paint();
+ mBorderPaint.setAntiAlias(true);
+ mBorderPaint.setStrokeWidth(mBorderWidth);
+ mBorderPaint.setStyle(Paint.Style.STROKE);
+
+ // Path is masked.
+ mBorderPath = new Path();
+
+ setPrivateMode(false);
+ }
+
+ @Override
+ public void setPrivateMode(boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+ mBorderPaint.setColor(isPrivate ? mBorderColorPrivate : mBorderColor);
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ // Draw the border on top.
+ canvas.drawPath(mBorderPath, mBorderPaint);
+ }
+
+ // The drawable is constructed as per @drawable/url_bar_nav_button.
+ @Override
+ public void onLightweightThemeChanged() {
+ final Drawable drawable = BrowserToolbar.getLightweightThemeDrawable(this, getTheme(), R.color.toolbar_grey);
+
+ if (drawable == null) {
+ return;
+ }
+
+ final StateListDrawable stateList = new StateListDrawable();
+ stateList.addState(PRIVATE_PRESSED_STATE_SET, getColorDrawable(R.color.placeholder_active_grey));
+ stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.toolbar_grey_pressed));
+ stateList.addState(PRIVATE_FOCUSED_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey));
+ stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.tablet_highlight_focused));
+ stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.tabs_tray_grey_pressed));
+ stateList.addState(EMPTY_STATE_SET, drawable);
+
+ setBackgroundDrawable(stateList);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundResource(R.drawable.url_bar_nav_button);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java
new file mode 100644
index 000000000..9361d5907
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/PageActionLayout.java
@@ -0,0 +1,371 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ResourceDrawableUtils;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.GeckoPopupMenu;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.UUID;
+import java.util.ArrayList;
+
+public class PageActionLayout extends LinearLayout implements NativeEventListener,
+ View.OnClickListener,
+ View.OnLongClickListener {
+ private static final String MENU_BUTTON_KEY = "MENU_BUTTON_KEY";
+ private static final int DEFAULT_PAGE_ACTIONS_SHOWN = 2;
+
+ private final Context mContext;
+ private final LinearLayout mLayout;
+ private final List<PageAction> mPageActionList;
+
+ private GeckoPopupMenu mPageActionsMenu;
+
+ // By default it's two, can be changed by calling setNumberShown(int)
+ private int mMaxVisiblePageActions;
+
+ public PageActionLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ mLayout = this;
+
+ mPageActionList = new ArrayList<PageAction>();
+ setNumberShown(DEFAULT_PAGE_ACTIONS_SHOWN);
+ refreshPageActionIcons();
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "PageActions:Add",
+ "PageActions:Remove");
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "PageActions:Add",
+ "PageActions:Remove");
+
+ super.onDetachedFromWindow();
+ }
+
+ private void setNumberShown(int count) {
+ ThreadUtils.assertOnUiThread();
+
+ mMaxVisiblePageActions = count;
+
+ for (int index = 0; index < count; index++) {
+ if ((getChildCount() - 1) < index) {
+ mLayout.addView(createImageButton());
+ }
+ }
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ // NativeJSObject cannot be used off of the Gecko thread, so convert it to a Bundle.
+ final Bundle bundle = message.toBundle();
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ handleUiMessage(event, bundle);
+ }
+ });
+ }
+
+ private void handleUiMessage(final String event, final Bundle message) {
+ ThreadUtils.assertOnUiThread();
+
+ if (event.equals("PageActions:Add")) {
+ final String id = message.getString("id");
+ final String title = message.getString("title");
+ final String imageURL = message.getString("icon");
+ final boolean important = message.getBoolean("important");
+
+ addPageAction(id, title, imageURL, new OnPageActionClickListeners() {
+ @Override
+ public void onClick(String id) {
+ GeckoAppShell.notifyObservers("PageActions:Clicked", id);
+ }
+
+ @Override
+ public boolean onLongClick(String id) {
+ GeckoAppShell.notifyObservers("PageActions:LongClicked", id);
+ return true;
+ }
+ }, important);
+ } else if (event.equals("PageActions:Remove")) {
+ final String id = message.getString("id");
+
+ removePageAction(id);
+ }
+ }
+
+ private void addPageAction(final String id, final String title, final String imageData,
+ final OnPageActionClickListeners onPageActionClickListeners, boolean important) {
+ ThreadUtils.assertOnUiThread();
+
+ final PageAction pageAction = new PageAction(id, title, null, onPageActionClickListeners, important);
+
+ int insertAt = mPageActionList.size();
+ while (insertAt > 0 && mPageActionList.get(insertAt - 1).isImportant()) {
+ insertAt--;
+ }
+ mPageActionList.add(insertAt, pageAction);
+
+ ResourceDrawableUtils.getDrawable(mContext, imageData, new ResourceDrawableUtils.BitmapLoader() {
+ @Override
+ public void onBitmapFound(final Drawable d) {
+ if (mPageActionList.contains(pageAction)) {
+ pageAction.setDrawable(d);
+ refreshPageActionIcons();
+ }
+ }
+ });
+ }
+
+ private void removePageAction(String id) {
+ ThreadUtils.assertOnUiThread();
+
+ final Iterator<PageAction> iter = mPageActionList.iterator();
+ while (iter.hasNext()) {
+ final PageAction pageAction = iter.next();
+ if (pageAction.getID().equals(id)) {
+ iter.remove();
+ refreshPageActionIcons();
+ return;
+ }
+ }
+ }
+
+ private ImageButton createImageButton() {
+ ThreadUtils.assertOnUiThread();
+
+ final int width = mContext.getResources().getDimensionPixelSize(R.dimen.page_action_button_width);
+ ImageButton imageButton = new ImageButton(mContext, null, R.style.UrlBar_ImageButton);
+ imageButton.setLayoutParams(new LayoutParams(width, LayoutParams.MATCH_PARENT));
+ imageButton.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
+ imageButton.setOnClickListener(this);
+ imageButton.setOnLongClickListener(this);
+ return imageButton;
+ }
+
+ @Override
+ public void onClick(View v) {
+ String buttonClickedId = (String)v.getTag();
+ if (buttonClickedId != null) {
+ if (buttonClickedId.equals(MENU_BUTTON_KEY)) {
+ showMenu(v, mPageActionList.size() - mMaxVisiblePageActions + 1);
+ } else {
+ getPageActionWithId(buttonClickedId).onClick();
+ }
+ }
+ }
+
+ @Override
+ public boolean onLongClick(View v) {
+ String buttonClickedId = (String)v.getTag();
+ if (buttonClickedId.equals(MENU_BUTTON_KEY)) {
+ showMenu(v, mPageActionList.size() - mMaxVisiblePageActions + 1);
+ return true;
+ } else {
+ return getPageActionWithId(buttonClickedId).onLongClick();
+ }
+ }
+
+ private void setActionForView(final ImageButton view, final PageAction pageAction) {
+ ThreadUtils.assertOnUiThread();
+
+ if (pageAction == null) {
+ view.setTag(null);
+ view.setImageDrawable(null);
+ view.setVisibility(View.GONE);
+ view.setContentDescription(null);
+ return;
+ }
+
+ view.setTag(pageAction.getID());
+ view.setImageDrawable(pageAction.getDrawable());
+ view.setVisibility(View.VISIBLE);
+ view.setContentDescription(pageAction.getTitle());
+ }
+
+ private void refreshPageActionIcons() {
+ ThreadUtils.assertOnUiThread();
+
+ final Resources resources = mContext.getResources();
+ for (int i = 0; i < this.getChildCount(); i++) {
+ final ImageButton v = (ImageButton) this.getChildAt(i);
+ final PageAction pageAction = getPageActionForViewAt(i);
+
+ // If there are more page actions than buttons, set the menu icon.
+ // Otherwise, set the page action's icon if there is a page action.
+ if ((i == this.getChildCount() - 1) && (mPageActionList.size() > mMaxVisiblePageActions)) {
+ v.setTag(MENU_BUTTON_KEY);
+ v.setImageDrawable(resources.getDrawable(R.drawable.icon_pageaction));
+ v.setVisibility((pageAction != null) ? View.VISIBLE : View.GONE);
+ v.setContentDescription(resources.getString(R.string.page_action_dropmarker_description));
+ } else {
+ setActionForView(v, pageAction);
+ }
+ }
+ }
+
+ private PageAction getPageActionForViewAt(int index) {
+ ThreadUtils.assertOnUiThread();
+
+ /**
+ * We show the user the most recent pageaction added since this keeps the user aware of any new page actions being added
+ * Also, the order of the pageAction is important i.e. if a page action is added, instead of shifting the pagactions to the
+ * left to make space for the new one, it would be more visually appealing to have the pageaction appear in the blank space.
+ *
+ * buttonIndex is needed for this reason because every new View added to PageActionLayout gets added to the right of its neighbouring View.
+ * Hence the button on the very leftmost has the index 0. We want our pageactions to start from the rightmost
+ * and hence we maintain the insertion order of the child Views which is essentially the reverse of their index
+ */
+
+ final int buttonIndex = (this.getChildCount() - 1) - index;
+
+ if (mPageActionList.size() > buttonIndex) {
+ // Return the pageactions starting from the end of the list for the number of visible pageactions.
+ final int buttonCount = Math.min(mPageActionList.size(), getChildCount());
+ return mPageActionList.get((mPageActionList.size() - buttonCount) + buttonIndex);
+ }
+ return null;
+ }
+
+ private PageAction getPageActionWithId(String id) {
+ ThreadUtils.assertOnUiThread();
+
+ for (PageAction pageAction : mPageActionList) {
+ if (pageAction.getID().equals(id)) {
+ return pageAction;
+ }
+ }
+ return null;
+ }
+
+ private void showMenu(View pageActionButton, int toShow) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mPageActionsMenu == null) {
+ mPageActionsMenu = new GeckoPopupMenu(pageActionButton.getContext(), pageActionButton);
+ mPageActionsMenu.inflate(0);
+ mPageActionsMenu.setOnMenuItemClickListener(new GeckoPopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ int id = item.getItemId();
+ for (int i = 0; i < mPageActionList.size(); i++) {
+ PageAction pageAction = mPageActionList.get(i);
+ if (pageAction.key() == id) {
+ pageAction.onClick();
+ return true;
+ }
+ }
+ return false;
+ }
+ });
+ }
+ Menu menu = mPageActionsMenu.getMenu();
+ menu.clear();
+
+ for (int i = 0; i < mPageActionList.size() && i < toShow; i++) {
+ PageAction pageAction = mPageActionList.get(i);
+ MenuItem item = menu.add(Menu.NONE, pageAction.key(), Menu.NONE, pageAction.getTitle());
+ item.setIcon(pageAction.getDrawable());
+ }
+ mPageActionsMenu.show();
+ }
+
+ private static interface OnPageActionClickListeners {
+ public void onClick(String id);
+ public boolean onLongClick(String id);
+ }
+
+ private static class PageAction {
+ private final OnPageActionClickListeners mOnPageActionClickListeners;
+ private Drawable mDrawable;
+ private final String mTitle;
+ private final String mId;
+ private final int key;
+ private final boolean mImportant;
+
+ public PageAction(String id,
+ String title,
+ Drawable image,
+ OnPageActionClickListeners onPageActionClickListeners,
+ boolean important) {
+ mId = id;
+ mTitle = title;
+ mDrawable = image;
+ mOnPageActionClickListeners = onPageActionClickListeners;
+ mImportant = important;
+
+ key = UUID.fromString(mId.subSequence(1, mId.length() - 2).toString()).hashCode();
+ }
+
+ public Drawable getDrawable() {
+ return mDrawable;
+ }
+
+ public void setDrawable(Drawable d) {
+ mDrawable = d;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getID() {
+ return mId;
+ }
+
+ public int key() {
+ return key;
+ }
+
+ public boolean isImportant() {
+ return mImportant;
+ }
+
+ public void onClick() {
+ if (mOnPageActionClickListeners != null) {
+ mOnPageActionClickListeners.onClick(mId);
+ }
+ }
+
+ public boolean onLongClick() {
+ if (mOnPageActionClickListeners != null) {
+ return mOnPageActionClickListeners.onLongClick(mId);
+ }
+ return false;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java
new file mode 100644
index 000000000..416485494
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/PhoneTabsButton.java
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.tabs.TabCurve;
+
+public class PhoneTabsButton extends ShapedButton {
+ public PhoneTabsButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+
+ mPath.reset();
+
+ mPath.moveTo(0, 0);
+ TabCurve.drawFromTop(mPath, 0, height, TabCurve.Direction.RIGHT);
+ mPath.lineTo(width, height);
+ mPath.lineTo(width, 0);
+ mPath.lineTo(0, 0);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java
new file mode 100644
index 000000000..003dada2d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButton.java
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.lwt.LightweightThemeDrawable;
+import org.mozilla.gecko.widget.themed.ThemedImageButton;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.AttributeSet;
+
+/**
+ * A ImageButton with a custom drawn path and lightweight theme support. Note that {@link ShapedButtonFrameLayout}
+ * copies the lwt support so if you change it here, you should probably change it there.
+ */
+public class ShapedButton extends ThemedImageButton
+ implements CanvasDelegate.DrawManager {
+
+ protected final Path mPath;
+ protected final CanvasDelegate mCanvasDelegate;
+
+ public ShapedButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ // Path is clipped.
+ mPath = new Path();
+
+ final Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setColor(ContextCompat.getColor(context, R.color.canvas_delegate_paint));
+ paint.setStrokeWidth(0.0f);
+ mCanvasDelegate = new CanvasDelegate(this, Mode.DST_IN, paint);
+
+ setWillNotDraw(false);
+ }
+
+ @Override
+ @SuppressLint("MissingSuperCall") // Super gets called from defaultDraw().
+ // It is intentionally not called in the other case.
+ public void draw(Canvas canvas) {
+ if (mCanvasDelegate != null)
+ mCanvasDelegate.draw(canvas, mPath, getWidth(), getHeight());
+ else
+ defaultDraw(canvas);
+ }
+
+ @Override
+ public void defaultDraw(Canvas canvas) {
+ super.draw(canvas);
+ }
+
+ // The drawable is constructed as per @drawable/shaped_button.
+ @Override
+ public void onLightweightThemeChanged() {
+ final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey);
+ final LightweightThemeDrawable lightWeight = getTheme().getColorDrawable(this, background);
+
+ if (lightWeight == null)
+ return;
+
+ lightWeight.setAlpha(34, 34);
+
+ final StateListDrawable stateList = new StateListDrawable();
+ stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.highlight_shaped));
+ stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.highlight_shaped_focused));
+ stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey));
+ stateList.addState(EMPTY_STATE_SET, lightWeight);
+
+ setBackgroundDrawable(stateList);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundResource(R.drawable.shaped_button);
+ }
+
+ @Override
+ public void setBackgroundDrawable(Drawable drawable) {
+ if (getBackground() == null || drawable == null) {
+ super.setBackgroundDrawable(drawable);
+ return;
+ }
+
+ int[] padding = new int[] { getPaddingLeft(),
+ getPaddingTop(),
+ getPaddingRight(),
+ getPaddingBottom()
+ };
+ drawable.setLevel(getBackground().getLevel());
+ super.setBackgroundDrawable(drawable);
+
+ setPadding(padding[0], padding[1], padding[2], padding[3]);
+ }
+
+ @Override
+ public void setBackgroundResource(int resId) {
+ setBackgroundDrawable(getResources().getDrawable(resId));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java
new file mode 100644
index 000000000..c14829aec
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ShapedButtonFrameLayout.java
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.lwt.LightweightThemeDrawable;
+import org.mozilla.gecko.widget.themed.ThemedFrameLayout;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.StateListDrawable;
+import android.util.AttributeSet;
+
+/** A FrameLayout with lightweight theme support. Note that {@link ShapedButton}'s lwt support is basically the same so
+ * if you change it here, you should probably change it there. Note also that this doesn't have ShapedButton's path code
+ * so shouldn't have "ShapedButton" in the name, but I wanted to make the connection apparent so I left it.
+ */
+public class ShapedButtonFrameLayout extends ThemedFrameLayout {
+
+ public ShapedButtonFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ // The drawable is constructed as per @drawable/shaped_button.
+ @Override
+ public void onLightweightThemeChanged() {
+ final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey);
+ final LightweightThemeDrawable lightWeight = getTheme().getColorDrawable(this, background);
+
+ if (lightWeight == null)
+ return;
+
+ lightWeight.setAlpha(34, 34);
+
+ final StateListDrawable stateList = new StateListDrawable();
+ stateList.addState(PRESSED_ENABLED_STATE_SET, getColorDrawable(R.color.highlight_shaped));
+ stateList.addState(FOCUSED_STATE_SET, getColorDrawable(R.color.highlight_shaped_focused));
+ stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey));
+ stateList.addState(EMPTY_STATE_SET, lightWeight);
+
+ setBackgroundDrawable(stateList);
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ setBackgroundResource(R.drawable.shaped_button);
+ }
+
+ @Override
+ public void setBackgroundDrawable(Drawable drawable) {
+ if (getBackground() == null || drawable == null) {
+ super.setBackgroundDrawable(drawable);
+ return;
+ }
+
+ int[] padding = new int[] { getPaddingLeft(),
+ getPaddingTop(),
+ getPaddingRight(),
+ getPaddingBottom()
+ };
+ drawable.setLevel(getBackground().getLevel());
+ super.setBackgroundDrawable(drawable);
+
+ setPadding(padding[0], padding[1], padding[2], padding[3]);
+ }
+
+ @Override
+ public void setBackgroundResource(int resId) {
+ setBackgroundDrawable(getResources().getDrawable(resId));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java
new file mode 100644
index 000000000..14230a2ec
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/SiteIdentityPopup.java
@@ -0,0 +1,571 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.support.design.widget.Snackbar;
+import android.support.v4.content.ContextCompat;
+import android.widget.ImageView;
+import android.widget.Toast;
+import org.json.JSONException;
+import org.json.JSONArray;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.SiteIdentity;
+import org.mozilla.gecko.SiteIdentity.SecurityMode;
+import org.mozilla.gecko.SiteIdentity.MixedMode;
+import org.mozilla.gecko.SiteIdentity.TrackingMode;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.widget.AnchoredPopup;
+import org.mozilla.gecko.widget.DoorHanger;
+import org.mozilla.gecko.widget.DoorHanger.OnButtonClickListener;
+import org.json.JSONObject;
+
+import android.app.Activity;
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import org.mozilla.gecko.widget.DoorhangerConfig;
+import org.mozilla.gecko.widget.SiteLogins;
+
+/**
+ * SiteIdentityPopup is a singleton class that displays site identity data in
+ * an arrow panel popup hanging from the lock icon in the browser toolbar.
+ *
+ * A site identity icon may be displayed in the url, and is set in <code>ToolbarDisplayLayout</code>.
+ */
+public class SiteIdentityPopup extends AnchoredPopup implements GeckoEventListener {
+
+ public static enum ButtonType { DISABLE, ENABLE, KEEP_BLOCKING, CANCEL, COPY }
+
+ private static final String LOGTAG = "GeckoSiteIdentityPopup";
+
+ private static final String MIXED_CONTENT_SUPPORT_URL =
+ "https://support.mozilla.org/kb/how-does-insecure-content-affect-safety-android";
+ private static final String TRACKING_CONTENT_SUPPORT_URL =
+ "https://support.mozilla.org/kb/firefox-android-tracking-protection";
+
+ // Placeholder string.
+ private final static String FORMAT_S = "%s";
+
+ private final Resources mResources;
+ private SiteIdentity mSiteIdentity;
+
+ private LinearLayout mIdentity;
+
+ private LinearLayout mIdentityKnownContainer;
+
+ private ImageView mIcon;
+ private TextView mTitle;
+ private TextView mSecurityState;
+ private TextView mMixedContentActivity;
+ private TextView mOwner;
+ private TextView mOwnerSupplemental;
+ private TextView mVerifier;
+ private TextView mLink;
+ private TextView mSiteSettingsLink;
+
+ private View mDivider;
+
+ private DoorHanger mTrackingContentNotification;
+ private DoorHanger mSelectLoginDoorhanger;
+
+ private final OnButtonClickListener mContentButtonClickListener;
+
+ public SiteIdentityPopup(Context context) {
+ super(context);
+
+ mResources = mContext.getResources();
+
+ mContentButtonClickListener = new ContentNotificationButtonListener();
+
+ GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
+ "Doorhanger:Logins",
+ "Permissions:CheckResult");
+ }
+
+ @Override
+ protected void init() {
+ super.init();
+
+ // Make the popup focusable so it doesn't inadvertently trigger click events elsewhere
+ // which may reshow the popup (see bug 785156)
+ setFocusable(true);
+
+ LayoutInflater inflater = LayoutInflater.from(mContext);
+ mIdentity = (LinearLayout) inflater.inflate(R.layout.site_identity, null);
+ mContent.addView(mIdentity);
+
+ mIdentityKnownContainer =
+ (LinearLayout) mIdentity.findViewById(R.id.site_identity_known_container);
+
+ mIcon = (ImageView) mIdentity.findViewById(R.id.site_identity_icon);
+ mTitle = (TextView) mIdentity.findViewById(R.id.site_identity_title);
+ mSecurityState = (TextView) mIdentity.findViewById(R.id.site_identity_state);
+ mMixedContentActivity = (TextView) mIdentity.findViewById(R.id.mixed_content_activity);
+
+ mOwner = (TextView) mIdentityKnownContainer.findViewById(R.id.owner);
+ mOwnerSupplemental = (TextView) mIdentityKnownContainer.findViewById(R.id.owner_supplemental);
+ mVerifier = (TextView) mIdentityKnownContainer.findViewById(R.id.verifier);
+ mDivider = mIdentity.findViewById(R.id.divider_doorhanger);
+
+ mLink = (TextView) mIdentity.findViewById(R.id.site_identity_link);
+ mLink.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Tabs.getInstance().loadUrlInTab(MIXED_CONTENT_SUPPORT_URL);
+ }
+ });
+
+ mSiteSettingsLink = (TextView) mIdentity.findViewById(R.id.site_settings_link);
+ }
+
+ private void updateIdentity(final SiteIdentity siteIdentity) {
+ if (!mInflated) {
+ init();
+ }
+
+ final boolean isIdentityKnown = (siteIdentity.getSecurityMode() == SecurityMode.IDENTIFIED ||
+ siteIdentity.getSecurityMode() == SecurityMode.VERIFIED);
+ updateConnectionState(siteIdentity);
+ toggleIdentityKnownContainerVisibility(isIdentityKnown);
+
+ if (isIdentityKnown) {
+ updateIdentityInformation(siteIdentity);
+ }
+
+ GeckoAppShell.notifyObservers("Permissions:Check", null);
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject geckoObject) {
+ if ("Doorhanger:Logins".equals(event)) {
+ try {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null) {
+ final JSONObject data = geckoObject.getJSONObject("data");
+ addLoginsToTab(data);
+ }
+ if (isShowing()) {
+ addSelectLoginDoorhanger(selectedTab);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error accessing logins in Doorhanger:Logins message", e);
+ }
+ } else if ("Permissions:CheckResult".equals(event)) {
+ final boolean hasPermissions = geckoObject.optBoolean("hasPermissions", false);
+ if (hasPermissions) {
+ mSiteSettingsLink.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GeckoAppShell.notifyObservers("Permissions:Get", null);
+ dismiss();
+ }
+ });
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mSiteSettingsLink.setVisibility(hasPermissions ? View.VISIBLE : View.GONE);
+ }
+ });
+ }
+ }
+
+ private void addLoginsToTab(JSONObject data) throws JSONException {
+ final JSONArray logins = data.getJSONArray("logins");
+
+ final SiteLogins siteLogins = new SiteLogins(logins);
+ Tabs.getInstance().getSelectedTab().setSiteLogins(siteLogins);
+ }
+
+ private void addSelectLoginDoorhanger(Tab tab) throws JSONException {
+ final SiteLogins siteLogins = tab.getSiteLogins();
+ if (siteLogins == null) {
+ return;
+ }
+
+ final JSONArray logins = siteLogins.getLogins();
+ if (logins.length() == 0) {
+ return;
+ }
+
+ final JSONObject login = (JSONObject) logins.get(0);
+
+ // Create button click listener for copying a password to the clipboard.
+ final OnButtonClickListener buttonClickListener = new OnButtonClickListener() {
+ Activity activity = (Activity) mContext;
+ @Override
+ public void onButtonClick(JSONObject response, DoorHanger doorhanger) {
+ try {
+ final int buttonId = response.getInt("callback");
+ if (buttonId == ButtonType.COPY.ordinal()) {
+ final ClipboardManager manager = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE);
+ String password;
+ if (response.has("password")) {
+ // Click listener being called from List Dialog.
+ password = response.optString("password");
+ } else {
+ password = login.getString("password");
+ }
+
+ manager.setPrimaryClip(ClipData.newPlainText("password", password));
+
+ SnackbarBuilder.builder(activity)
+ .message(R.string.doorhanger_login_select_toast_copy)
+ .duration(Snackbar.LENGTH_SHORT)
+ .buildAndShow();
+ }
+ dismiss();
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error handling Select login button click", e);
+ SnackbarBuilder.builder(activity)
+ .message(R.string.doorhanger_login_select_toast_copy_error)
+ .duration(Snackbar.LENGTH_SHORT)
+ .buildAndShow();
+ }
+ }
+ };
+
+ final DoorhangerConfig config = new DoorhangerConfig(DoorHanger.Type.LOGIN, buttonClickListener);
+
+ // Set buttons.
+ config.setButton(mContext.getString(R.string.button_cancel), ButtonType.CANCEL.ordinal(), false);
+ config.setButton(mContext.getString(R.string.button_copy), ButtonType.COPY.ordinal(), true);
+
+ // Set message.
+ String username = ((JSONObject) logins.get(0)).getString("username");
+ if (TextUtils.isEmpty(username)) {
+ username = mContext.getString(R.string.doorhanger_login_no_username);
+ }
+
+ final String message = mContext.getString(R.string.doorhanger_login_select_message).replace(FORMAT_S, username);
+ config.setMessage(message);
+
+ // Set options.
+ final JSONObject options = new JSONObject();
+
+ // Add action text only if there are other logins to select.
+ if (logins.length() > 1) {
+
+ final JSONObject actionText = new JSONObject();
+ actionText.put("type", "SELECT");
+
+ final JSONObject bundle = new JSONObject();
+ bundle.put("logins", logins);
+
+ actionText.put("bundle", bundle);
+ options.put("actionText", actionText);
+ }
+
+ config.setOptions(options);
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!mInflated) {
+ init();
+ }
+
+ removeSelectLoginDoorhanger();
+
+ mSelectLoginDoorhanger = DoorHanger.Get(mContext, config);
+ mContent.addView(mSelectLoginDoorhanger);
+ mDivider.setVisibility(View.VISIBLE);
+ }
+ });
+ }
+
+ private void removeSelectLoginDoorhanger() {
+ if (mSelectLoginDoorhanger != null) {
+ mContent.removeView(mSelectLoginDoorhanger);
+ mSelectLoginDoorhanger = null;
+ }
+ }
+
+ private void toggleIdentityKnownContainerVisibility(final boolean isIdentityKnown) {
+ final int identityInfoVisibility = isIdentityKnown ? View.VISIBLE : View.GONE;
+ mIdentityKnownContainer.setVisibility(identityInfoVisibility);
+ }
+
+ /**
+ * Update the Site Identity content to reflect connection state.
+ *
+ * The connection state should reflect the combination of:
+ * a) Connection encryption
+ * b) Mixed Content state (Active/Display Mixed content, loaded, blocked, none, etc)
+ * and update the icons and strings to inform the user of that state.
+ *
+ * @param siteIdentity SiteIdentity information about the connection.
+ */
+ private void updateConnectionState(final SiteIdentity siteIdentity) {
+ if (siteIdentity.getSecurityMode() == SecurityMode.CHROMEUI) {
+ mSecurityState.setText(R.string.identity_connection_chromeui);
+ mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.placeholder_active_grey));
+
+ mIcon.setImageResource(R.drawable.icon);
+ clearSecurityStateIcon();
+
+ mMixedContentActivity.setVisibility(View.GONE);
+ mLink.setVisibility(View.GONE);
+ } else if (!siteIdentity.isSecure()) {
+ if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_LOADED) {
+ // Active Mixed Content loaded because user has disabled blocking.
+ mIcon.setImageResource(R.drawable.lock_disabled);
+ clearSecurityStateIcon();
+ mMixedContentActivity.setVisibility(View.VISIBLE);
+ mMixedContentActivity.setText(R.string.mixed_content_protection_disabled);
+
+ mLink.setVisibility(View.VISIBLE);
+ } else if (siteIdentity.getMixedModeDisplay() == MixedMode.MIXED_CONTENT_LOADED) {
+ // Passive Mixed Content loaded.
+ mIcon.setImageResource(R.drawable.lock_inactive);
+ setSecurityStateIcon(R.drawable.warning_major, 1);
+ mMixedContentActivity.setVisibility(View.VISIBLE);
+ if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_BLOCKED) {
+ mMixedContentActivity.setText(R.string.mixed_content_blocked_some);
+ } else {
+ mMixedContentActivity.setText(R.string.mixed_content_display_loaded);
+ }
+ mLink.setVisibility(View.VISIBLE);
+
+ } else {
+ // Unencrypted connection with no mixed content.
+ mIcon.setImageResource(R.drawable.globe_light);
+ clearSecurityStateIcon();
+
+ mMixedContentActivity.setVisibility(View.GONE);
+ mLink.setVisibility(View.GONE);
+ }
+
+ mSecurityState.setText(R.string.identity_connection_insecure);
+ mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.placeholder_active_grey));
+ } else {
+ // Connection is secure.
+ mIcon.setImageResource(R.drawable.lock_secure);
+
+ setSecurityStateIcon(R.drawable.img_check, 2);
+ mSecurityState.setTextColor(ContextCompat.getColor(mContext, R.color.affirmative_green));
+ mSecurityState.setText(R.string.identity_connection_secure);
+
+ // Mixed content has been blocked, if present.
+ if (siteIdentity.getMixedModeActive() == MixedMode.MIXED_CONTENT_BLOCKED ||
+ siteIdentity.getMixedModeDisplay() == MixedMode.MIXED_CONTENT_BLOCKED) {
+ mMixedContentActivity.setVisibility(View.VISIBLE);
+ mMixedContentActivity.setText(R.string.mixed_content_blocked_all);
+ mLink.setVisibility(View.VISIBLE);
+ } else {
+ mMixedContentActivity.setVisibility(View.GONE);
+ mLink.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ private void clearSecurityStateIcon() {
+ mSecurityState.setCompoundDrawablePadding(0);
+ mSecurityState.setCompoundDrawables(null, null, null, null);
+ }
+
+ private void setSecurityStateIcon(int resource, int factor) {
+ final Drawable stateIcon = ContextCompat.getDrawable(mContext, resource);
+ stateIcon.setBounds(0, 0, stateIcon.getIntrinsicWidth() / factor, stateIcon.getIntrinsicHeight() / factor);
+ mSecurityState.setCompoundDrawables(stateIcon, null, null, null);
+ mSecurityState.setCompoundDrawablePadding((int) mResources.getDimension(R.dimen.doorhanger_drawable_padding));
+ }
+ private void updateIdentityInformation(final SiteIdentity siteIdentity) {
+ String owner = siteIdentity.getOwner();
+ if (owner == null) {
+ mOwner.setVisibility(View.GONE);
+ mOwnerSupplemental.setVisibility(View.GONE);
+ } else {
+ mOwner.setVisibility(View.VISIBLE);
+ mOwner.setText(owner);
+
+ // Supplemental data is optional.
+ final String supplemental = siteIdentity.getSupplemental();
+ if (!TextUtils.isEmpty(supplemental)) {
+ mOwnerSupplemental.setText(supplemental);
+ mOwnerSupplemental.setVisibility(View.VISIBLE);
+ } else {
+ mOwnerSupplemental.setVisibility(View.GONE);
+ }
+ }
+
+ final String verifier = siteIdentity.getVerifier();
+ mVerifier.setText(verifier);
+ }
+
+ private void addTrackingContentNotification(boolean blocked) {
+ // Remove any existing tracking content notification.
+ removeTrackingContentNotification();
+
+ final DoorhangerConfig config = new DoorhangerConfig(DoorHanger.Type.TRACKING, mContentButtonClickListener);
+
+ final int icon = blocked ? R.drawable.shield_enabled : R.drawable.shield_disabled;
+
+ final JSONObject options = new JSONObject();
+ final JSONObject tracking = new JSONObject();
+ try {
+ tracking.put("enabled", blocked);
+ options.put("tracking_protection", tracking);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error adding tracking protection options", e);
+ }
+ config.setOptions(options);
+
+ config.setLink(mContext.getString(R.string.learn_more), TRACKING_CONTENT_SUPPORT_URL);
+
+ addNotificationButtons(config, blocked);
+
+ mTrackingContentNotification = DoorHanger.Get(mContext, config);
+
+ mTrackingContentNotification.setIcon(icon);
+
+ mContent.addView(mTrackingContentNotification);
+ mDivider.setVisibility(View.VISIBLE);
+ }
+
+ private void removeTrackingContentNotification() {
+ if (mTrackingContentNotification != null) {
+ mContent.removeView(mTrackingContentNotification);
+ mTrackingContentNotification = null;
+ }
+ }
+
+ private void addNotificationButtons(DoorhangerConfig config, boolean blocked) {
+ if (blocked) {
+ config.setButton(mContext.getString(R.string.disable_protection), ButtonType.DISABLE.ordinal(), false);
+ } else {
+ config.setButton(mContext.getString(R.string.enable_protection), ButtonType.ENABLE.ordinal(), true);
+ }
+ }
+
+ /*
+ * @param identityData A JSONObject that holds the current tab's identity data.
+ */
+ void setSiteIdentity(SiteIdentity siteIdentity) {
+ mSiteIdentity = siteIdentity;
+ }
+
+ @Override
+ public void show() {
+ if (mSiteIdentity == null) {
+ Log.e(LOGTAG, "Can't show site identity popup for undefined state");
+ return;
+ }
+
+ // Verified about: pages have the CHROMEUI SiteIdentity, however there can also
+ // be unverified about: pages for which "This site's identity is unknown" or
+ // "This is a secure Firefox page" are both misleading, so don't show a popup.
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab != null &&
+ AboutPages.isAboutPage(selectedTab.getURL()) &&
+ mSiteIdentity.getSecurityMode() != SecurityMode.CHROMEUI) {
+ Log.d(LOGTAG, "We don't show site identity popups for unverified about: pages");
+ return;
+ }
+
+ updateIdentity(mSiteIdentity);
+
+ final TrackingMode trackingMode = mSiteIdentity.getTrackingMode();
+ if (trackingMode != TrackingMode.UNKNOWN) {
+ addTrackingContentNotification(trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED);
+ }
+
+ try {
+ addSelectLoginDoorhanger(selectedTab);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error adding selectLogin doorhanger", e);
+ }
+
+ if (mSiteIdentity.getSecurityMode() == SecurityMode.CHROMEUI) {
+ // For about: pages we display the product icon in place of the verified/globe
+ // image, hence we don't also set the favicon (for most about pages the
+ // favicon is the product icon, hence we'd be showing the same icon twice).
+ mTitle.setText(R.string.moz_app_displayname);
+ } else {
+ mTitle.setText(selectedTab.getBaseDomain());
+
+ final Bitmap favicon = selectedTab.getFavicon();
+ if (favicon != null) {
+ final Drawable faviconDrawable = new BitmapDrawable(mResources, favicon);
+ final int dimen = (int) mResources.getDimension(R.dimen.browser_toolbar_favicon_size);
+ faviconDrawable.setBounds(0, 0, dimen, dimen);
+
+ mTitle.setCompoundDrawables(faviconDrawable, null, null, null);
+ mTitle.setCompoundDrawablePadding((int) mContext.getResources().getDimension(R.dimen.doorhanger_drawable_padding));
+ }
+ }
+
+ showDividers();
+
+ super.show();
+ }
+
+ // Show the right dividers
+ private void showDividers() {
+ final int count = mContent.getChildCount();
+ DoorHanger lastVisibleDoorHanger = null;
+
+ for (int i = 0; i < count; i++) {
+ final View child = mContent.getChildAt(i);
+
+ if (!(child instanceof DoorHanger)) {
+ continue;
+ }
+
+ DoorHanger dh = (DoorHanger) child;
+ dh.showDivider();
+ if (dh.getVisibility() == View.VISIBLE) {
+ lastVisibleDoorHanger = dh;
+ }
+ }
+
+ if (lastVisibleDoorHanger != null) {
+ lastVisibleDoorHanger.hideDivider();
+ }
+ }
+
+ void destroy() {
+ GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
+ "Doorhanger:Logins",
+ "Permissions:CheckResult");
+ }
+
+ @Override
+ public void dismiss() {
+ super.dismiss();
+ removeTrackingContentNotification();
+ removeSelectLoginDoorhanger();
+ mTitle.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
+ mDivider.setVisibility(View.GONE);
+ }
+
+ private class ContentNotificationButtonListener implements OnButtonClickListener {
+ @Override
+ public void onButtonClick(JSONObject response, DoorHanger doorhanger) {
+ GeckoAppShell.notifyObservers("Session:Reload", response.toString());
+ dismiss();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java
new file mode 100644
index 000000000..1e0ca516b
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/TabCounter.java
@@ -0,0 +1,154 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.animation.Rotate3DAnimation;
+import org.mozilla.gecko.widget.themed.ThemedTextSwitcher;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.AnimationSet;
+import android.widget.ViewSwitcher;
+
+public class TabCounter extends ThemedTextSwitcher
+ implements ViewSwitcher.ViewFactory {
+
+ private static final float CENTER_X = 0.5f;
+ private static final float CENTER_Y = 1.25f;
+ private static final int DURATION = 500;
+ private static final float Z_DISTANCE = 200;
+
+ private final AnimationSet mFlipInForward;
+ private final AnimationSet mFlipInBackward;
+ private final AnimationSet mFlipOutForward;
+ private final AnimationSet mFlipOutBackward;
+ private final LayoutInflater mInflater;
+ private final int mLayoutId;
+
+ private int mCount;
+ public static final int MAX_VISIBLE_TABS = 99;
+ public static final String SO_MANY_TABS_OPEN = "∞";
+
+ private enum FadeMode {
+ FADE_IN,
+ FADE_OUT
+ }
+
+ public TabCounter(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabCounter);
+ mLayoutId = a.getResourceId(R.styleable.TabCounter_android_layout, R.layout.tabs_counter);
+ a.recycle();
+
+ mInflater = LayoutInflater.from(context);
+
+ mFlipInForward = createAnimation(-90, 0, FadeMode.FADE_IN, -1 * Z_DISTANCE, false);
+ mFlipInBackward = createAnimation(90, 0, FadeMode.FADE_IN, Z_DISTANCE, false);
+ mFlipOutForward = createAnimation(0, -90, FadeMode.FADE_OUT, -1 * Z_DISTANCE, true);
+ mFlipOutBackward = createAnimation(0, 90, FadeMode.FADE_OUT, Z_DISTANCE, true);
+
+ removeAllViews();
+ setFactory(this);
+
+ if (Versions.feature16Plus) {
+ // This adds the TextSwitcher to the a11y node tree, where we in turn
+ // could make it return an empty info node. If we don't do this the
+ // TextSwitcher's child TextViews get picked up, and we don't want
+ // that since the tabs ImageButton is already properly labeled for
+ // accessibility.
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ setAccessibilityDelegate(new View.AccessibilityDelegate() {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {}
+ });
+ }
+ }
+
+ void setCountWithAnimation(int count) {
+ // Don't animate from initial state
+ if (mCount == 0) {
+ setCount(count);
+ return;
+ }
+
+ if (mCount == count) {
+ return;
+ }
+
+ // don't animate if there are still over MAX_VISIBLE_TABS tabs open
+ if (mCount > MAX_VISIBLE_TABS && count > MAX_VISIBLE_TABS) {
+ mCount = count;
+ return;
+ }
+
+ if (count < mCount) {
+ setInAnimation(mFlipInBackward);
+ setOutAnimation(mFlipOutForward);
+ } else {
+ setInAnimation(mFlipInForward);
+ setOutAnimation(mFlipOutBackward);
+ }
+
+ // Eliminate screen artifact. Set explicit In/Out animation pair order. This will always
+ // animate pair in In->Out child order, prevent alternating use of the Out->In case.
+ setDisplayedChild(0);
+
+ // Set In value, trigger animation to Out value
+ setCurrentText(formatForDisplay(mCount));
+ setText(formatForDisplay(count));
+
+ mCount = count;
+ }
+
+ private String formatForDisplay(int count) {
+ if (count > MAX_VISIBLE_TABS) {
+ return SO_MANY_TABS_OPEN;
+ }
+ return String.valueOf(count);
+ }
+
+ void setCount(int count) {
+ setCurrentText(formatForDisplay(count));
+ mCount = count;
+ }
+
+ // Alpha animations in editing mode cause action bar corruption on the
+ // Nexus 7 (bug 961749). As a workaround, skip these animations in editing
+ // mode.
+ void onEnterEditingMode() {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ getChildAt(i).clearAnimation();
+ }
+ }
+
+ private AnimationSet createAnimation(float startAngle, float endAngle,
+ FadeMode fadeMode,
+ float zEnd, boolean reverse) {
+ final Context context = getContext();
+ AnimationSet set = new AnimationSet(context, null);
+ set.addAnimation(new Rotate3DAnimation(startAngle, endAngle, CENTER_X, CENTER_Y, zEnd, reverse));
+ set.addAnimation(fadeMode == FadeMode.FADE_IN ? new AlphaAnimation(0.0f, 1.0f) :
+ new AlphaAnimation(1.0f, 0.0f));
+ set.setDuration(DURATION);
+ set.setInterpolator(context, android.R.anim.accelerate_interpolator);
+ return set;
+ }
+
+ @Override
+ public View makeView() {
+ return mInflater.inflate(mLayoutId, null);
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
new file mode 100644
index 000000000..163ed4a51
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
@@ -0,0 +1,530 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.reader.ReaderModeUtils;
+import org.mozilla.gecko.SiteIdentity;
+import org.mozilla.gecko.SiteIdentity.MixedMode;
+import org.mozilla.gecko.SiteIdentity.SecurityMode;
+import org.mozilla.gecko.SiteIdentity.TrackingMode;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
+import org.mozilla.gecko.toolbar.BrowserToolbarTabletBase.ForwardButtonAnimation;
+import org.mozilla.gecko.Experiments;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.widget.themed.ThemedLinearLayout;
+import org.mozilla.gecko.widget.themed.ThemedTextView;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.support.annotation.NonNull;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageButton;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+/**
+* {@code ToolbarDisplayLayout} is the UI for when the toolbar is in
+* display state. It's used to display the state of the currently selected
+* tab. It should always be updated through a single entry point
+* (updateFromTab) and should never track any tab events or gecko messages
+* on its own to keep it as dumb as possible.
+*
+* The UI has two possible modes: progress and display which are triggered
+* when UpdateFlags.PROGRESS is used depending on the current tab state.
+* The progress mode is triggered when the tab is loading a page. Display mode
+* is used otherwise.
+*
+* {@code ToolbarDisplayLayout} is meant to be owned by {@code BrowserToolbar}
+* which is the main event bus for the toolbar subsystem.
+*/
+public class ToolbarDisplayLayout extends ThemedLinearLayout {
+
+ private static final String LOGTAG = "GeckoToolbarDisplayLayout";
+ private boolean mTrackingProtectionEnabled;
+
+ // To be used with updateFromTab() to allow the caller
+ // to give enough context for the requested state change.
+ enum UpdateFlags {
+ TITLE,
+ FAVICON,
+ PROGRESS,
+ SITE_IDENTITY,
+ PRIVATE_MODE,
+
+ // Disable any animation that might be
+ // triggered from this state change. Mostly
+ // used on tab switches, see BrowserToolbar.
+ DISABLE_ANIMATIONS
+ }
+
+ private enum UIMode {
+ PROGRESS,
+ DISPLAY
+ }
+
+ interface OnStopListener {
+ Tab onStop();
+ }
+
+ interface OnTitleChangeListener {
+ void onTitleChange(CharSequence title);
+ }
+
+ private final BrowserApp mActivity;
+
+ private UIMode mUiMode;
+
+ private boolean mIsAttached;
+
+ private final ThemedTextView mTitle;
+ private final int mTitlePadding;
+ private ToolbarPrefs mPrefs;
+ private OnTitleChangeListener mTitleChangeListener;
+
+ private final ImageButton mSiteSecurity;
+
+ private final ImageButton mStop;
+ private OnStopListener mStopListener;
+
+ private final PageActionLayout mPageActionLayout;
+
+ private final SiteIdentityPopup mSiteIdentityPopup;
+ private int mSecurityImageLevel;
+
+ // Security level constants, which map to the icons / levels defined in:
+ // http://dxr.mozilla.org/mozilla-central/source/mobile/android/base/java/org/mozilla/gecko/resources/drawable/site_security_level.xml
+ // Default level (unverified pages) - globe icon:
+ private static final int LEVEL_DEFAULT_GLOBE = 0;
+ // Levels for displaying Mixed Content state icons.
+ private static final int LEVEL_WARNING_MINOR = 3;
+ private static final int LEVEL_LOCK_DISABLED = 4;
+ // Levels for displaying Tracking Protection state icons.
+ private static final int LEVEL_SHIELD_ENABLED = 5;
+ private static final int LEVEL_SHIELD_DISABLED = 6;
+ // Icon used for about:home
+ private static final int LEVEL_SEARCH_ICON = 999;
+
+ private final ForegroundColorSpan mUrlColorSpan;
+ private final ForegroundColorSpan mPrivateUrlColorSpan;
+ private final ForegroundColorSpan mBlockedColorSpan;
+ private final ForegroundColorSpan mDomainColorSpan;
+ private final ForegroundColorSpan mPrivateDomainColorSpan;
+ private final ForegroundColorSpan mCertificateOwnerColorSpan;
+
+ public ToolbarDisplayLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ setOrientation(HORIZONTAL);
+
+ mActivity = (BrowserApp) context;
+
+ LayoutInflater.from(context).inflate(R.layout.toolbar_display_layout, this);
+
+ mTitle = (ThemedTextView) findViewById(R.id.url_bar_title);
+ mTitlePadding = mTitle.getPaddingRight();
+
+ mUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext));
+ mPrivateUrlColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_urltext_private));
+ mBlockedColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_blockedtext));
+ mDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext));
+ mPrivateDomainColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.url_bar_domaintext_private));
+ mCertificateOwnerColorSpan = new ForegroundColorSpan(ContextCompat.getColor(context, R.color.affirmative_green));
+
+ mSiteSecurity = (ImageButton) findViewById(R.id.site_security);
+
+ mSiteIdentityPopup = new SiteIdentityPopup(mActivity);
+ mSiteIdentityPopup.setAnchor(this);
+ mSiteIdentityPopup.setOnVisibilityChangeListener(mActivity);
+
+ mStop = (ImageButton) findViewById(R.id.stop);
+ mPageActionLayout = (PageActionLayout) findViewById(R.id.page_action_layout);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ mIsAttached = true;
+
+ mSiteSecurity.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mSiteIdentityPopup.show();
+ }
+ });
+
+ mStop.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mStopListener != null) {
+ // Force toolbar to switch to Display mode
+ // immediately based on the stopped tab.
+ final Tab tab = mStopListener.onStop();
+ if (tab != null) {
+ updateUiMode(UIMode.DISPLAY);
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mIsAttached = false;
+ }
+
+ @Override
+ public void setNextFocusDownId(int nextId) {
+ mStop.setNextFocusDownId(nextId);
+ mSiteSecurity.setNextFocusDownId(nextId);
+ mPageActionLayout.setNextFocusDownId(nextId);
+ }
+
+ void setToolbarPrefs(final ToolbarPrefs prefs) {
+ mPrefs = prefs;
+ }
+
+ void updateFromTab(@NonNull Tab tab, EnumSet<UpdateFlags> flags) {
+ // Several parts of ToolbarDisplayLayout's state depends
+ // on the views being attached to the view tree.
+ if (!mIsAttached) {
+ return;
+ }
+
+ if (flags.contains(UpdateFlags.TITLE)) {
+ updateTitle(tab);
+ }
+
+ if (flags.contains(UpdateFlags.SITE_IDENTITY)) {
+ updateSiteIdentity(tab);
+ }
+
+ if (flags.contains(UpdateFlags.PROGRESS)) {
+ updateProgress(tab);
+ }
+
+ if (flags.contains(UpdateFlags.PRIVATE_MODE)) {
+ mTitle.setPrivateMode(tab.isPrivate());
+ }
+ }
+
+ void setTitle(CharSequence title) {
+ mTitle.setText(title);
+
+ if (mTitleChangeListener != null) {
+ mTitleChangeListener.onTitleChange(title);
+ }
+ }
+
+ private void updateTitle(@NonNull Tab tab) {
+ // Keep the title unchanged if there's no selected tab,
+ // or if the tab is entering reader mode.
+ if (tab.isEnteringReaderMode()) {
+ return;
+ }
+
+ final String url = tab.getURL();
+
+ // Setting a null title will ensure we just see the
+ // "Enter Search or Address" placeholder text.
+ if (AboutPages.isTitlelessAboutPage(url)) {
+ setTitle(null);
+ setContentDescription(null);
+ return;
+ }
+
+ // Show the about:blocked page title in red, regardless of prefs
+ if (tab.getErrorType() == Tab.ErrorType.BLOCKED) {
+ final String title = tab.getDisplayTitle();
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder(title);
+ builder.setSpan(mBlockedColorSpan, 0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+
+ setTitle(builder);
+ setContentDescription(null);
+ return;
+ }
+
+ final String baseDomain = tab.getBaseDomain();
+
+ String strippedURL = stripAboutReaderURL(url);
+
+ final boolean isHttpOrHttps = StringUtils.isHttpOrHttps(strippedURL);
+
+ if (mPrefs.shouldTrimUrls()) {
+ strippedURL = StringUtils.stripCommonSubdomains(StringUtils.stripScheme(strippedURL));
+ }
+
+ // The URL bar does not support RTL currently (See bug 928688 and meta bug 702845).
+ // Displaying a URL using RTL (or mixed) characters can lead to an undesired reordering
+ // of elements of the URL. That's why we are forcing the URL to use LTR (bug 1284372).
+ strippedURL = StringUtils.forceLTR(strippedURL);
+
+ // This value is not visible to screen readers but we rely on it when running UI tests. Screen
+ // readers will instead focus BrowserToolbar and read the "base domain" from there. UI tests
+ // will read the content description to obtain the full URL for performing assertions.
+ setContentDescription(strippedURL);
+
+ final SiteIdentity siteIdentity = tab.getSiteIdentity();
+ if (siteIdentity.hasOwner() && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_EV_CERT_OWNER)) {
+ // Show Owner of EV certificate as title
+ updateTitleFromSiteIdentity(siteIdentity);
+ } else if (isHttpOrHttps && !HardwareUtils.isTablet() && !TextUtils.isEmpty(baseDomain)
+ && SwitchBoard.isInExperiment(mActivity, Experiments.URLBAR_SHOW_ORIGIN_ONLY)) {
+ // Show just the base domain as title
+ setTitle(baseDomain);
+ } else {
+ // Display full URL with base domain highlighted as title
+ updateAndColorTitleFromFullURL(strippedURL, baseDomain, tab.isPrivate());
+ }
+ }
+
+ private void updateTitleFromSiteIdentity(SiteIdentity siteIdentity) {
+ final String title;
+
+ if (siteIdentity.hasCountry()) {
+ title = String.format("%s (%s)", siteIdentity.getOwner(), siteIdentity.getCountry());
+ } else {
+ title = siteIdentity.getOwner();
+ }
+
+ final SpannableString spannable = new SpannableString(title);
+ spannable.setSpan(mCertificateOwnerColorSpan, 0, title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ setTitle(spannable);
+ }
+
+ private void updateAndColorTitleFromFullURL(String url, String baseDomain, boolean isPrivate) {
+ if (TextUtils.isEmpty(baseDomain)) {
+ setTitle(url);
+ return;
+ }
+
+ int index = url.indexOf(baseDomain);
+ if (index == -1) {
+ setTitle(url);
+ return;
+ }
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder(url);
+
+ builder.setSpan(isPrivate ? mPrivateUrlColorSpan : mUrlColorSpan, 0, url.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ builder.setSpan(isPrivate ? mPrivateDomainColorSpan : mDomainColorSpan,
+ index, index + baseDomain.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+
+ setTitle(builder);
+ }
+
+ private String stripAboutReaderURL(final String url) {
+ if (!AboutPages.isAboutReader(url)) {
+ return url;
+ }
+
+ return ReaderModeUtils.stripAboutReaderUrl(url);
+ }
+
+ private void updateSiteIdentity(@NonNull Tab tab) {
+ final SiteIdentity siteIdentity = tab.getSiteIdentity();
+
+ mSiteIdentityPopup.setSiteIdentity(siteIdentity);
+
+ final SecurityMode securityMode;
+ final MixedMode activeMixedMode;
+ final MixedMode displayMixedMode;
+ final TrackingMode trackingMode;
+ if (siteIdentity == null) {
+ securityMode = SecurityMode.UNKNOWN;
+ activeMixedMode = MixedMode.UNKNOWN;
+ displayMixedMode = MixedMode.UNKNOWN;
+ trackingMode = TrackingMode.UNKNOWN;
+ } else {
+ securityMode = siteIdentity.getSecurityMode();
+ activeMixedMode = siteIdentity.getMixedModeActive();
+ displayMixedMode = siteIdentity.getMixedModeDisplay();
+ trackingMode = siteIdentity.getTrackingMode();
+ }
+
+ // This is a bit tricky, but we have one icon and three potential indicators.
+ // Default to the identity level
+ int imageLevel = securityMode.ordinal();
+
+ // about: pages should default to having no icon too (the same as SecurityMode.UNKNOWN), however
+ // SecurityMode.CHROMEUI has a different ordinal - hence we need to manually reset it here.
+ // (We then continue and process the tracking / mixed content icons as usual, even for about: pages, as they
+ // can still load external sites.)
+ if (securityMode == SecurityMode.CHROMEUI) {
+ imageLevel = LEVEL_DEFAULT_GLOBE; // == SecurityMode.UNKNOWN.ordinal()
+ }
+
+ // Check to see if any protection was overridden first
+ if (AboutPages.isTitlelessAboutPage(tab.getURL())) {
+ // We always want to just show a search icon on about:home
+ imageLevel = LEVEL_SEARCH_ICON;
+ } else if (trackingMode == TrackingMode.TRACKING_CONTENT_LOADED) {
+ imageLevel = LEVEL_SHIELD_DISABLED;
+ } else if (trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED) {
+ imageLevel = LEVEL_SHIELD_ENABLED;
+ } else if (activeMixedMode == MixedMode.MIXED_CONTENT_LOADED) {
+ imageLevel = LEVEL_LOCK_DISABLED;
+ } else if (displayMixedMode == MixedMode.MIXED_CONTENT_LOADED) {
+ imageLevel = LEVEL_WARNING_MINOR;
+ }
+
+ if (mSecurityImageLevel != imageLevel) {
+ mSecurityImageLevel = imageLevel;
+ mSiteSecurity.setImageLevel(mSecurityImageLevel);
+ updatePageActions();
+ }
+
+ mTrackingProtectionEnabled = trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED;
+ }
+
+ private void updateProgress(@NonNull Tab tab) {
+ final boolean shouldShowThrobber = tab.getState() == Tab.STATE_LOADING;
+
+ updateUiMode(shouldShowThrobber ? UIMode.PROGRESS : UIMode.DISPLAY);
+
+ if (Tab.STATE_SUCCESS == tab.getState() && mTrackingProtectionEnabled) {
+ mActivity.showTrackingProtectionPromptIfApplicable();
+ }
+ }
+
+ private void updateUiMode(UIMode uiMode) {
+ if (mUiMode == uiMode) {
+ return;
+ }
+
+ mUiMode = uiMode;
+
+ // The "Throbber start" and "Throbber stop" log messages in this method
+ // are needed by S1/S2 tests (http://mrcote.info/phonedash/#).
+ // See discussion in Bug 804457. Bug 805124 tracks paring these down.
+ if (mUiMode == UIMode.PROGRESS) {
+ Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber start");
+ } else {
+ Log.i(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - Throbber stop");
+ }
+
+ updatePageActions();
+ }
+
+ private void updatePageActions() {
+ final boolean isShowingProgress = (mUiMode == UIMode.PROGRESS);
+
+ mStop.setVisibility(isShowingProgress ? View.VISIBLE : View.GONE);
+ mPageActionLayout.setVisibility(!isShowingProgress ? View.VISIBLE : View.GONE);
+
+ // We want title to fill the whole space available for it when there are icons
+ // being shown on the right side of the toolbar as the icons already have some
+ // padding in them. This is just to avoid wasting space when icons are shown.
+ mTitle.setPadding(0, 0, (!isShowingProgress ? mTitlePadding : 0), 0);
+ }
+
+ List<View> getFocusOrder() {
+ return Arrays.asList(mSiteSecurity, mPageActionLayout, mStop);
+ }
+
+ void setOnStopListener(OnStopListener listener) {
+ mStopListener = listener;
+ }
+
+ void setOnTitleChangeListener(OnTitleChangeListener listener) {
+ mTitleChangeListener = listener;
+ }
+
+ /**
+ * Update the Site Identity popup anchor.
+ *
+ * Tablet UI has a tablet-specific doorhanger anchor, so update it after all the views
+ * are inflated.
+ * @param view View to use as the anchor for the Site Identity popup.
+ */
+ void updateSiteIdentityAnchor(View view) {
+ mSiteIdentityPopup.setAnchor(view);
+ }
+
+ void prepareForwardAnimation(PropertyAnimator anim, ForwardButtonAnimation animation, int width) {
+ if (animation == ForwardButtonAnimation.HIDE) {
+ // We animate these items individually, rather than this entire view,
+ // so that we don't animate certain views, e.g. the stop button.
+ anim.attach(mTitle,
+ PropertyAnimator.Property.TRANSLATION_X,
+ 0);
+ anim.attach(mSiteSecurity,
+ PropertyAnimator.Property.TRANSLATION_X,
+ 0);
+
+ // We're hiding the forward button. We're going to reset the margin before
+ // the animation starts, so we shift these items to the right so that they don't
+ // appear to move initially.
+ ViewHelper.setTranslationX(mTitle, width);
+ ViewHelper.setTranslationX(mSiteSecurity, width);
+ } else {
+ anim.attach(mTitle,
+ PropertyAnimator.Property.TRANSLATION_X,
+ width);
+ anim.attach(mSiteSecurity,
+ PropertyAnimator.Property.TRANSLATION_X,
+ width);
+ }
+ }
+
+ void finishForwardAnimation() {
+ ViewHelper.setTranslationX(mTitle, 0);
+ ViewHelper.setTranslationX(mSiteSecurity, 0);
+ }
+
+ void prepareStartEditingAnimation() {
+ // Hide page actions/stop buttons immediately
+ ViewHelper.setAlpha(mPageActionLayout, 0);
+ ViewHelper.setAlpha(mStop, 0);
+ }
+
+ void prepareStopEditingAnimation(PropertyAnimator anim) {
+ // Fade toolbar buttons (page actions, stop) after the entry
+ // is shrunk back to its original size.
+ anim.attach(mPageActionLayout,
+ PropertyAnimator.Property.ALPHA,
+ 1);
+
+ anim.attach(mStop,
+ PropertyAnimator.Property.ALPHA,
+ 1);
+ }
+
+ boolean dismissSiteIdentityPopup() {
+ if (mSiteIdentityPopup != null && mSiteIdentityPopup.isShowing()) {
+ mSiteIdentityPopup.dismiss();
+ return true;
+ }
+
+ return false;
+ }
+
+ void destroy() {
+ mSiteIdentityPopup.destroy();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java
new file mode 100644
index 000000000..c9731a401
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditLayout.java
@@ -0,0 +1,348 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.speech.RecognizerIntent;
+import android.widget.Button;
+import android.widget.ImageButton;
+import org.mozilla.gecko.ActivityHandlerHelper;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnCommitListener;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener;
+import org.mozilla.gecko.toolbar.BrowserToolbar.TabEditingState;
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.DrawableUtil;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.InputOptionsUtils;
+import org.mozilla.gecko.widget.themed.ThemedLinearLayout;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.ImageView;
+
+import java.util.List;
+
+/**
+* {@code ToolbarEditLayout} is the UI for when the toolbar is in
+* edit state. It controls a text entry ({@code ToolbarEditText})
+* and its matching 'go' button which changes depending on the
+* current type of text in the entry.
+*/
+public class ToolbarEditLayout extends ThemedLinearLayout {
+
+ public interface OnSearchStateChangeListener {
+ public void onSearchStateChange(boolean isActive);
+ }
+
+ private final ImageView mSearchIcon;
+
+ private final ToolbarEditText mEditText;
+
+ private final ImageButton mVoiceInput;
+ private final ImageButton mQrCode;
+
+ private OnFocusChangeListener mFocusChangeListener;
+
+ private boolean showKeyboardOnFocus = false; // Indicates if we need to show the keyboard after the app resumes
+
+ public ToolbarEditLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ setOrientation(HORIZONTAL);
+
+ LayoutInflater.from(context).inflate(R.layout.toolbar_edit_layout, this);
+ mSearchIcon = (ImageView) findViewById(R.id.search_icon);
+
+ mEditText = (ToolbarEditText) findViewById(R.id.url_edit_text);
+
+ mVoiceInput = (ImageButton) findViewById(R.id.mic);
+ mQrCode = (ImageButton) findViewById(R.id.qrcode);
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (HardwareUtils.isTablet()) {
+ mSearchIcon.setVisibility(View.VISIBLE);
+ }
+
+ mEditText.setOnFocusChangeListener(new OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (mFocusChangeListener != null) {
+ mFocusChangeListener.onFocusChange(ToolbarEditLayout.this, hasFocus);
+
+ // Checking if voice and QR code input are enabled each time the user taps on the URL bar
+ if (hasFocus) {
+ if (voiceIsEnabled(getContext(), getResources().getString(R.string.voicesearch_prompt))) {
+ mVoiceInput.setVisibility(View.VISIBLE);
+ } else {
+ mVoiceInput.setVisibility(View.GONE);
+ }
+
+ if (qrCodeIsEnabled(getContext())) {
+ mQrCode.setVisibility(View.VISIBLE);
+ } else {
+ mQrCode.setVisibility(View.GONE);
+ }
+ }
+ }
+ }
+ });
+
+ mEditText.setOnSearchStateChangeListener(new OnSearchStateChangeListener() {
+ @Override
+ public void onSearchStateChange(boolean isActive) {
+ updateSearchIcon(isActive);
+ }
+ });
+
+ mVoiceInput.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ launchVoiceRecognizer();
+ }
+ });
+
+ mQrCode.setOnClickListener(new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ launchQRCodeReader();
+ }
+ });
+
+ // Set an inactive search icon on tablet devices when in editing mode
+ updateSearchIcon(false);
+ }
+
+ /**
+ * Update the search icon at the left of the edittext based
+ * on its state.
+ *
+ * @param isActive The state of the edittext. Active is when the initialized
+ * text has changed and is not empty.
+ */
+ void updateSearchIcon(boolean isActive) {
+ if (!HardwareUtils.isTablet()) {
+ return;
+ }
+
+ // When on tablet show a magnifying glass in editing mode
+ final int searchDrawableId = R.drawable.search_icon_active;
+ final Drawable searchDrawable;
+ if (!isActive) {
+ searchDrawable = DrawableUtil.tintDrawableWithColorRes(getContext(), searchDrawableId, R.color.placeholder_grey);
+ } else {
+ if (isPrivateMode()) {
+ searchDrawable = DrawableUtil.tintDrawableWithColorRes(getContext(), searchDrawableId, R.color.tabs_tray_icon_grey);
+ } else {
+ searchDrawable = getResources().getDrawable(searchDrawableId);
+ }
+ }
+
+ mSearchIcon.setImageDrawable(searchDrawable);
+ }
+
+ @Override
+ public void setOnFocusChangeListener(OnFocusChangeListener listener) {
+ mFocusChangeListener = listener;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ super.setEnabled(enabled);
+ mEditText.setEnabled(enabled);
+ }
+
+ @Override
+ public void setPrivateMode(boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+ mEditText.setPrivateMode(isPrivate);
+ }
+
+ /**
+ * Called when the parent gains focus (on app launch and resume)
+ */
+ public void onParentFocus() {
+ if (showKeyboardOnFocus) {
+ showKeyboardOnFocus = false;
+
+ Activity activity = GeckoAppShell.getGeckoInterface().getActivity();
+ activity.runOnUiThread(new Runnable() {
+ public void run() {
+ mEditText.requestFocus();
+ showSoftInput();
+ }
+ });
+ }
+
+ // Checking if qr code is supported after resuming the app
+ if (qrCodeIsEnabled(getContext())) {
+ mQrCode.setVisibility(View.VISIBLE);
+ } else {
+ mQrCode.setVisibility(View.GONE);
+ }
+ }
+
+ void setToolbarPrefs(final ToolbarPrefs prefs) {
+ mEditText.setToolbarPrefs(prefs);
+ }
+
+ private void showSoftInput() {
+ InputMethodManager imm =
+ (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(mEditText, InputMethodManager.SHOW_IMPLICIT);
+ }
+
+ void prepareShowAnimation(final PropertyAnimator animator) {
+ if (animator == null) {
+ mEditText.requestFocus();
+ showSoftInput();
+ return;
+ }
+
+ animator.addPropertyAnimationListener(new PropertyAnimationListener() {
+ @Override
+ public void onPropertyAnimationStart() {
+ mEditText.requestFocus();
+ }
+
+ @Override
+ public void onPropertyAnimationEnd() {
+ showSoftInput();
+ }
+ });
+ }
+
+ void setOnCommitListener(OnCommitListener listener) {
+ mEditText.setOnCommitListener(listener);
+ }
+
+ void setOnDismissListener(OnDismissListener listener) {
+ mEditText.setOnDismissListener(listener);
+ }
+
+ void setOnFilterListener(OnFilterListener listener) {
+ mEditText.setOnFilterListener(listener);
+ }
+
+ void onEditSuggestion(String suggestion) {
+ mEditText.setText(suggestion);
+ mEditText.setSelection(mEditText.getText().length());
+ mEditText.requestFocus();
+
+ showSoftInput();
+ }
+
+ void setText(String text) {
+ mEditText.setText(text);
+ }
+
+ String getText() {
+ return mEditText.getText().toString();
+ }
+
+ protected void saveTabEditingState(final TabEditingState editingState) {
+ editingState.lastEditingText = mEditText.getNonAutocompleteText();
+ editingState.selectionStart = mEditText.getSelectionStart();
+ editingState.selectionEnd = mEditText.getSelectionEnd();
+ }
+
+ protected void restoreTabEditingState(final TabEditingState editingState) {
+ mEditText.setText(editingState.lastEditingText);
+ mEditText.setSelection(editingState.selectionStart, editingState.selectionEnd);
+ }
+
+ private boolean voiceIsEnabled(Context context, String prompt) {
+ final boolean voiceIsSupported = InputOptionsUtils.supportsVoiceRecognizer(context, prompt);
+ if (!voiceIsSupported) {
+ return false;
+ }
+ return GeckoSharedPrefs.forApp(context)
+ .getBoolean(GeckoPreferences.PREFS_VOICE_INPUT_ENABLED, true);
+ }
+
+ private void launchVoiceRecognizer() {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "voice_input_launch");
+ final Intent intent = InputOptionsUtils.createVoiceRecognizerIntent(getResources().getString(R.string.voicesearch_prompt));
+
+ Activity activity = GeckoAppShell.getGeckoInterface().getActivity();
+ ActivityHandlerHelper.startIntentForActivity(activity, intent, new ActivityResultHandler() {
+ @Override
+ public void onActivityResult(int resultCode, Intent data) {
+ if (resultCode != Activity.RESULT_OK) {
+ return;
+ }
+
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "voice_input_success");
+ // We have RESULT_OK, not RESULT_NO_MATCH so it should be safe to assume that
+ // we have at least one match. We only need one: this will be
+ // used for showing the user search engines with this search term in it.
+ List<String> voiceStrings = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS);
+ String text = voiceStrings.get(0);
+ mEditText.setText(text);
+ mEditText.setSelection(0, text.length());
+
+ final InputMethodManager imm =
+ (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(mEditText, InputMethodManager.SHOW_IMPLICIT);
+ }
+ });
+ }
+
+ private boolean qrCodeIsEnabled(Context context) {
+ final boolean qrCodeIsSupported = InputOptionsUtils.supportsQrCodeReader(context);
+ if (!qrCodeIsSupported) {
+ return false;
+ }
+ return GeckoSharedPrefs.forApp(context)
+ .getBoolean(GeckoPreferences.PREFS_QRCODE_ENABLED, true);
+ }
+
+ private void launchQRCodeReader() {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "qrcode_input_launch");
+ final Intent intent = InputOptionsUtils.createQRCodeReaderIntent();
+
+ Activity activity = GeckoAppShell.getGeckoInterface().getActivity();
+ ActivityHandlerHelper.startIntentForActivity(activity, intent, new ActivityResultHandler() {
+ @Override
+ public void onActivityResult(int resultCode, Intent intent) {
+ if (resultCode == Activity.RESULT_OK) {
+ String text = intent.getStringExtra("SCAN_RESULT");
+ if (!StringUtils.isSearchQuery(text, false)) {
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "qrcode_input_success");
+ mEditText.setText(text);
+ mEditText.selectAll();
+
+ // Queuing up the keyboard show action.
+ // At this point the app has not resumed yet, and trying to show
+ // the keyboard will fail.
+ showKeyboardOnFocus = true;
+ }
+ }
+ // We can get the SCAN_RESULT_FORMAT, SCAN_RESULT_BYTES,
+ // SCAN_RESULT_ORIENTATION and SCAN_RESULT_ERROR_CORRECTION_LEVEL
+ // as well as the actual result, which may hold a URL.
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java
new file mode 100644
index 000000000..b385f815a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarEditText.java
@@ -0,0 +1,630 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.AboutPages;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.CustomEditText;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnCommitListener;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener;
+import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener;
+import org.mozilla.gecko.toolbar.ToolbarEditLayout.OnSearchStateChangeListener;
+import org.mozilla.gecko.util.GamepadUtils;
+import org.mozilla.gecko.util.StringUtils;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.text.Editable;
+import android.text.NoCopySpan;
+import android.text.Selection;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.style.BackgroundColorSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputConnectionWrapper;
+import android.view.inputmethod.InputMethodManager;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+/**
+* {@code ToolbarEditText} is the text entry used when the toolbar
+* is in edit state. It handles all the necessary input method machinery.
+* It's meant to be owned by {@code ToolbarEditLayout}.
+*/
+public class ToolbarEditText extends CustomEditText
+ implements AutocompleteHandler {
+
+ private static final String LOGTAG = "GeckoToolbarEditText";
+ private static final NoCopySpan AUTOCOMPLETE_SPAN = new NoCopySpan.Concrete();
+
+ private final Context mContext;
+
+ private OnCommitListener mCommitListener;
+ private OnDismissListener mDismissListener;
+ private OnFilterListener mFilterListener;
+ private OnSearchStateChangeListener mSearchStateChangeListener;
+
+ private ToolbarPrefs mPrefs;
+
+ // The previous autocomplete result returned to us
+ private String mAutoCompleteResult = "";
+ // Length of the user-typed portion of the result
+ private int mAutoCompletePrefixLength;
+ // If text change is due to us setting autocomplete
+ private boolean mSettingAutoComplete;
+ // Spans used for marking the autocomplete text
+ private Object[] mAutoCompleteSpans;
+ // Do not process autocomplete result
+ private boolean mDiscardAutoCompleteResult;
+
+ public ToolbarEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mContext = context;
+ }
+
+ void setOnCommitListener(OnCommitListener listener) {
+ mCommitListener = listener;
+ }
+
+ void setOnDismissListener(OnDismissListener listener) {
+ mDismissListener = listener;
+ }
+
+ void setOnFilterListener(OnFilterListener listener) {
+ mFilterListener = listener;
+ }
+
+ void setOnSearchStateChangeListener(OnSearchStateChangeListener listener) {
+ mSearchStateChangeListener = listener;
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ setOnKeyListener(new KeyListener());
+ setOnKeyPreImeListener(new KeyPreImeListener());
+ setOnSelectionChangedListener(new SelectionChangeListener());
+ addTextChangedListener(new TextChangeListener());
+ }
+
+ @Override
+ public void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+
+ // Make search icon inactive when edit toolbar search term isn't a user entered
+ // search term
+ final boolean isActive = !TextUtils.isEmpty(getText());
+ if (mSearchStateChangeListener != null) {
+ mSearchStateChangeListener.onSearchStateChange(isActive);
+ }
+
+ if (gainFocus) {
+ resetAutocompleteState();
+ return;
+ }
+
+ removeAutocomplete(getText());
+
+ final InputMethodManager imm = InputMethods.getInputMethodManager(mContext);
+ try {
+ imm.restartInput(this);
+ imm.hideSoftInputFromWindow(getWindowToken(), 0);
+ } catch (NullPointerException e) {
+ Log.e(LOGTAG, "InputMethodManagerService, why are you throwing"
+ + " a NullPointerException? See bug 782096", e);
+ }
+ }
+
+ @Override
+ public void setText(final CharSequence text, final TextView.BufferType type) {
+ final String textString = (text == null) ? "" : text.toString();
+
+ // If we're on the home or private browsing page, we don't set the "about" url.
+ final CharSequence finalText;
+ if (AboutPages.isAboutHome(textString) || AboutPages.isAboutPrivateBrowsing(textString)) {
+ finalText = "";
+ } else {
+ finalText = text;
+ }
+
+ super.setText(finalText, type);
+
+ // Any autocomplete text would have been overwritten, so reset our autocomplete states.
+ resetAutocompleteState();
+ }
+
+ @Override
+ public void sendAccessibilityEventUnchecked(AccessibilityEvent event) {
+ // We need to bypass the isShown() check in the default implementation
+ // for TYPE_VIEW_TEXT_SELECTION_CHANGED events so that accessibility
+ // services could detect a url change.
+ if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED &&
+ getParent() != null && !isShown()) {
+ onInitializeAccessibilityEvent(event);
+ dispatchPopulateAccessibilityEvent(event);
+ getParent().requestSendAccessibilityEvent(this, event);
+ } else {
+ super.sendAccessibilityEventUnchecked(event);
+ }
+ }
+
+ void setToolbarPrefs(final ToolbarPrefs prefs) {
+ mPrefs = prefs;
+ }
+
+ /**
+ * Mark the start of autocomplete changes so our text change
+ * listener does not react to changes in autocomplete text
+ */
+ private void beginSettingAutocomplete() {
+ beginBatchEdit();
+ mSettingAutoComplete = true;
+ }
+
+ /**
+ * Mark the end of autocomplete changes
+ */
+ private void endSettingAutocomplete() {
+ mSettingAutoComplete = false;
+ endBatchEdit();
+ }
+
+ /**
+ * Reset autocomplete states to their initial values
+ */
+ private void resetAutocompleteState() {
+ mAutoCompleteSpans = new Object[] {
+ // Span to mark the autocomplete text
+ AUTOCOMPLETE_SPAN,
+ // Span to change the autocomplete text color
+ new BackgroundColorSpan(getHighlightColor())
+ };
+
+ mAutoCompleteResult = "";
+
+ // Pretend we already autocompleted the existing text,
+ // so that actions like backspacing don't trigger autocompletion.
+ mAutoCompletePrefixLength = getText().length();
+
+ // Show the cursor.
+ setCursorVisible(true);
+ }
+
+ protected String getNonAutocompleteText() {
+ return getNonAutocompleteText(getText());
+ }
+
+ /**
+ * Get the portion of text that is not marked as autocomplete text.
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private static String getNonAutocompleteText(final Editable text) {
+ final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
+ if (start < 0) {
+ // No autocomplete text; return the whole string.
+ return text.toString();
+ }
+
+ // Only return the portion that's not autocomplete text
+ return TextUtils.substring(text, 0, start);
+ }
+
+ /**
+ * Remove any autocomplete text
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private boolean removeAutocomplete(final Editable text) {
+ final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
+ if (start < 0) {
+ // No autocomplete text
+ return false;
+ }
+
+ beginSettingAutocomplete();
+
+ // When we call delete() here, the autocomplete spans we set are removed as well.
+ text.delete(start, text.length());
+
+ // Keep mAutoCompletePrefixLength the same because the prefix has not changed.
+ // Clear mAutoCompleteResult to make sure we get fresh autocomplete text next time.
+ mAutoCompleteResult = "";
+
+ // Reshow the cursor.
+ setCursorVisible(true);
+
+ endSettingAutocomplete();
+ return true;
+ }
+
+ /**
+ * Convert any autocomplete text to regular text
+ *
+ * @param text Current text content that may include autocomplete text
+ */
+ private boolean commitAutocomplete(final Editable text) {
+ final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
+ if (start < 0) {
+ // No autocomplete text
+ return false;
+ }
+
+ beginSettingAutocomplete();
+
+ // Remove all spans here to convert from autocomplete text to regular text
+ for (final Object span : mAutoCompleteSpans) {
+ text.removeSpan(span);
+ }
+
+ // Keep mAutoCompleteResult the same because the result has not changed.
+ // Reset mAutoCompletePrefixLength because the prefix now includes the autocomplete text.
+ mAutoCompletePrefixLength = text.length();
+
+ // Reshow the cursor.
+ setCursorVisible(true);
+
+ endSettingAutocomplete();
+
+ // Filter on the new text
+ if (mFilterListener != null) {
+ mFilterListener.onFilter(text.toString(), null);
+ }
+ return true;
+ }
+
+ /**
+ * Add autocomplete text based on the result URI.
+ *
+ * @param result Result URI to be turned into autocomplete text
+ */
+ @Override
+ public final void onAutocomplete(final String result) {
+ // If mDiscardAutoCompleteResult is true, we temporarily disabled
+ // autocomplete (due to backspacing, etc.) and we should bail early.
+ if (mDiscardAutoCompleteResult) {
+ return;
+ }
+
+ if (!isEnabled() || result == null) {
+ mAutoCompleteResult = "";
+ return;
+ }
+
+ final Editable text = getText();
+ final int textLength = text.length();
+ final int resultLength = result.length();
+ final int autoCompleteStart = text.getSpanStart(AUTOCOMPLETE_SPAN);
+ mAutoCompleteResult = result;
+
+ if (autoCompleteStart > -1) {
+ // Autocomplete text already exists; we should replace existing autocomplete text.
+
+ // If the result and the current text don't have the same prefixes,
+ // the result is stale and we should wait for the another result to come in.
+ if (!TextUtils.regionMatches(result, 0, text, 0, autoCompleteStart)) {
+ return;
+ }
+
+ beginSettingAutocomplete();
+
+ // Replace the existing autocomplete text with new one.
+ // replace() preserves the autocomplete spans that we set before.
+ text.replace(autoCompleteStart, textLength, result, autoCompleteStart, resultLength);
+
+ // Reshow the cursor if there is no longer any autocomplete text.
+ if (autoCompleteStart == resultLength) {
+ setCursorVisible(true);
+ }
+
+ endSettingAutocomplete();
+
+ } else {
+ // No autocomplete text yet; we should add autocomplete text
+
+ // If the result prefix doesn't match the current text,
+ // the result is stale and we should wait for the another result to come in.
+ if (resultLength <= textLength ||
+ !TextUtils.regionMatches(result, 0, text, 0, textLength)) {
+ return;
+ }
+
+ final Object[] spans = text.getSpans(textLength, textLength, Object.class);
+ final int[] spanStarts = new int[spans.length];
+ final int[] spanEnds = new int[spans.length];
+ final int[] spanFlags = new int[spans.length];
+
+ // Save selection/composing span bounds so we can restore them later.
+ for (int i = 0; i < spans.length; i++) {
+ final Object span = spans[i];
+ final int spanFlag = text.getSpanFlags(span);
+
+ // We don't care about spans that are not selection or composing spans.
+ // For those spans, spanFlag[i] will be 0 and we don't restore them.
+ if ((spanFlag & Spanned.SPAN_COMPOSING) == 0 &&
+ (span != Selection.SELECTION_START) &&
+ (span != Selection.SELECTION_END)) {
+ continue;
+ }
+
+ spanStarts[i] = text.getSpanStart(span);
+ spanEnds[i] = text.getSpanEnd(span);
+ spanFlags[i] = spanFlag;
+ }
+
+ beginSettingAutocomplete();
+
+ // First add trailing text.
+ text.append(result, textLength, resultLength);
+
+ // Restore selection/composing spans.
+ for (int i = 0; i < spans.length; i++) {
+ final int spanFlag = spanFlags[i];
+ if (spanFlag == 0) {
+ // Skip if the span was ignored before.
+ continue;
+ }
+ text.setSpan(spans[i], spanStarts[i], spanEnds[i], spanFlag);
+ }
+
+ // Mark added text as autocomplete text.
+ for (final Object span : mAutoCompleteSpans) {
+ text.setSpan(span, textLength, resultLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ // Hide the cursor.
+ setCursorVisible(false);
+
+ // Make sure the autocomplete text is visible. If the autocomplete text is too
+ // long, it would appear the cursor will be scrolled out of view. However, this
+ // is not the case in practice, because EditText still makes sure the cursor is
+ // still in view.
+ bringPointIntoView(resultLength);
+
+ endSettingAutocomplete();
+ }
+ }
+
+ private static boolean hasCompositionString(Editable content) {
+ Object[] spans = content.getSpans(0, content.length(), Object.class);
+
+ if (spans != null) {
+ for (Object span : spans) {
+ if ((content.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ // Found composition string.
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Code to handle deleting autocomplete first when backspacing.
+ * If there is no autocomplete text, both removeAutocomplete() and commitAutocomplete()
+ * are no-ops and return false. Therefore we can use them here without checking explicitly
+ * if we have autocomplete text or not.
+ */
+ @Override
+ public InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ final InputConnection ic = super.onCreateInputConnection(outAttrs);
+ if (ic == null) {
+ return null;
+ }
+
+ return new InputConnectionWrapper(ic, false) {
+ @Override
+ public boolean deleteSurroundingText(final int beforeLength, final int afterLength) {
+ if (removeAutocomplete(getText())) {
+ // If we have autocomplete text, the cursor is at the boundary between
+ // regular and autocomplete text. So regardless of which direction we
+ // are deleting, we should delete the autocomplete text first.
+ // Make the IME aware that we interrupted the deleteSurroundingText call,
+ // by restarting the IME.
+ final InputMethodManager imm = InputMethods.getInputMethodManager(mContext);
+ if (imm != null) {
+ imm.restartInput(ToolbarEditText.this);
+ }
+ return false;
+ }
+ return super.deleteSurroundingText(beforeLength, afterLength);
+ }
+
+ private boolean removeAutocompleteOnComposing(final CharSequence text) {
+ final Editable editable = getText();
+ final int composingStart = BaseInputConnection.getComposingSpanStart(editable);
+ final int composingEnd = BaseInputConnection.getComposingSpanEnd(editable);
+ // We only delete the autocomplete text when the user is backspacing,
+ // i.e. when the composing text is getting shorter.
+ if (composingStart >= 0 &&
+ composingEnd >= 0 &&
+ (composingEnd - composingStart) > text.length() &&
+ removeAutocomplete(editable)) {
+ // Make the IME aware that we interrupted the setComposingText call,
+ // by having finishComposingText() send change notifications to the IME.
+ finishComposingText();
+ setComposingRegion(composingStart, composingEnd);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (removeAutocompleteOnComposing(text)) {
+ return false;
+ }
+ return super.commitText(text, newCursorPosition);
+ }
+
+ @Override
+ public boolean setComposingText(final CharSequence text, final int newCursorPosition) {
+ if (removeAutocompleteOnComposing(text)) {
+ return false;
+ }
+ return super.setComposingText(text, newCursorPosition);
+ }
+ };
+ }
+
+ private class SelectionChangeListener implements OnSelectionChangedListener {
+ @Override
+ public void onSelectionChanged(final int selStart, final int selEnd) {
+ // The user has repositioned the cursor somewhere. We need to adjust
+ // the autocomplete text depending on where the new cursor is.
+
+ final Editable text = getText();
+ final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
+
+ if (mSettingAutoComplete || start < 0 || (start == selStart && start == selEnd)) {
+ // Do not commit autocomplete text if there is no autocomplete text
+ // or if selection is still at start of autocomplete text
+ return;
+ }
+
+ if (selStart <= start && selEnd <= start) {
+ // The cursor is in user-typed text; remove any autocomplete text.
+ removeAutocomplete(text);
+ } else {
+ // The cursor is in the autocomplete text; commit it so it becomes regular text.
+ commitAutocomplete(text);
+ }
+ }
+ }
+
+ private class TextChangeListener implements TextWatcher {
+ @Override
+ public void afterTextChanged(final Editable editable) {
+ if (!isEnabled() || mSettingAutoComplete) {
+ return;
+ }
+
+ final String text = getNonAutocompleteText(editable);
+ final int textLength = text.length();
+ boolean doAutocomplete = mPrefs.shouldAutocomplete();
+
+ if (StringUtils.isSearchQuery(text, false)) {
+ doAutocomplete = false;
+ } else if (mAutoCompletePrefixLength > textLength) {
+ // If you're hitting backspace (the string is getting smaller), don't autocomplete
+ doAutocomplete = false;
+ }
+
+ mAutoCompletePrefixLength = textLength;
+
+ // If we are not autocompleting, we set mDiscardAutoCompleteResult to true
+ // to discard any autocomplete results that are in-flight, and vice versa.
+ mDiscardAutoCompleteResult = !doAutocomplete;
+
+ if (doAutocomplete && mAutoCompleteResult.startsWith(text)) {
+ // If this text already matches our autocomplete text, autocomplete likely
+ // won't change. Just reuse the old autocomplete value.
+ onAutocomplete(mAutoCompleteResult);
+ doAutocomplete = false;
+ } else {
+ // Otherwise, remove the old autocomplete text
+ // until any new autocomplete text gets added.
+ removeAutocomplete(editable);
+ }
+
+ // Update search icon with an active state since user is typing
+ if (mSearchStateChangeListener != null) {
+ mSearchStateChangeListener.onSearchStateChange(textLength > 0);
+ }
+
+ if (mFilterListener != null) {
+ mFilterListener.onFilter(text, doAutocomplete ? ToolbarEditText.this : null);
+ }
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count,
+ int after) {
+ // do nothing
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before,
+ int count) {
+ // do nothing
+ }
+ }
+
+ private class KeyPreImeListener implements OnKeyPreImeListener {
+ @Override
+ public boolean onKeyPreIme(View v, int keyCode, KeyEvent event) {
+ // We only want to process one event per tap
+ if (event.getAction() != KeyEvent.ACTION_DOWN) {
+ return false;
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ // If the edit text has a composition string, don't submit the text yet.
+ // ENTER is needed to commit the composition string.
+ final Editable content = getText();
+ if (!hasCompositionString(content)) {
+ if (mCommitListener != null) {
+ mCommitListener.onCommit();
+ }
+
+ return true;
+ }
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ // Drop the virtual keyboard.
+ clearFocus();
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ private class KeyListener implements View.OnKeyListener {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_ENTER || GamepadUtils.isActionKey(event)) {
+ if (event.getAction() != KeyEvent.ACTION_DOWN) {
+ return true;
+ }
+
+ if (mCommitListener != null) {
+ mCommitListener.onCommit();
+ }
+
+ return true;
+ }
+
+ if (GamepadUtils.isBackKey(event)) {
+ if (mDismissListener != null) {
+ mDismissListener.onDismiss();
+ }
+
+ return true;
+ }
+
+ if ((keyCode == KeyEvent.KEYCODE_DEL ||
+ (keyCode == KeyEvent.KEYCODE_FORWARD_DEL)) &&
+ removeAutocomplete(getText())) {
+ // Delete autocomplete text when backspacing or forward deleting.
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java
new file mode 100644
index 000000000..f881de154
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarPrefs.java
@@ -0,0 +1,78 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.toolbar;
+
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.util.ThreadUtils;
+
+class ToolbarPrefs {
+ private static final String PREF_AUTOCOMPLETE_ENABLED = "browser.urlbar.autocomplete.enabled";
+ private static final String PREF_TRIM_URLS = "browser.urlbar.trimURLs";
+
+ private static final String[] PREFS = {
+ PREF_AUTOCOMPLETE_ENABLED,
+ PREF_TRIM_URLS
+ };
+
+ private final TitlePrefsHandler HANDLER = new TitlePrefsHandler();
+
+ private volatile boolean enableAutocomplete;
+ private volatile boolean trimUrls;
+
+ ToolbarPrefs() {
+ // Skip autocompletion while Gecko is loading.
+ // We will get the correct pref value once Gecko is loaded.
+ enableAutocomplete = false;
+ trimUrls = true;
+ }
+
+ boolean shouldAutocomplete() {
+ return enableAutocomplete;
+ }
+
+ boolean shouldTrimUrls() {
+ return trimUrls;
+ }
+
+ void open() {
+ PrefsHelper.addObserver(PREFS, HANDLER);
+ }
+
+ void close() {
+ PrefsHelper.removeObserver(HANDLER);
+ }
+
+ private void triggerTitleChangeListener() {
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final Tabs tabs = Tabs.getInstance();
+ final Tab tab = tabs.getSelectedTab();
+ if (tab != null) {
+ tabs.notifyListeners(tab, Tabs.TabEvents.TITLE);
+ }
+ }
+ });
+ }
+
+ private class TitlePrefsHandler extends PrefsHelper.PrefHandlerBase {
+ @Override
+ public void prefValue(String pref, boolean value) {
+ if (PREF_AUTOCOMPLETE_ENABLED.equals(pref)) {
+ enableAutocomplete = value;
+
+ } else if (PREF_TRIM_URLS.equals(pref)) {
+ // Handles PREF_TRIM_URLS, which should usually be a boolean.
+ if (value != trimUrls) {
+ trimUrls = value;
+ triggerTitleChangeListener();
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java
new file mode 100644
index 000000000..43181cbef
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarProgressView.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.toolbar;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.widget.themed.ThemedImageView;
+import org.mozilla.gecko.util.WeakReferenceHandler;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.os.Message;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.animation.Animation;
+
+/**
+ * Progress view used for page loads.
+ *
+ * Because we're given limited information about the page load progress, the
+ * bar also includes incremental animation between each step to improve
+ * perceived performance.
+ */
+public class ToolbarProgressView extends ThemedImageView {
+ private static final int MAX_PROGRESS = 10000;
+ private static final int MSG_UPDATE = 0;
+ private static final int MSG_HIDE = 1;
+ private static final int STEPS = 10;
+ private static final int DELAY = 40;
+
+ private int mTargetProgress;
+ private int mIncrement;
+ private Rect mBounds;
+ private Handler mHandler;
+ private int mCurrentProgress;
+
+ private PorterDuffColorFilter mPrivateBrowsingColorFilter;
+
+ public ToolbarProgressView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(context);
+ }
+
+ public ToolbarProgressView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context);
+ }
+
+ private void init(Context ctx) {
+ mBounds = new Rect(0, 0, 0, 0);
+ mTargetProgress = 0;
+
+ mPrivateBrowsingColorFilter = new PorterDuffColorFilter(
+ ContextCompat.getColor(ctx, R.color.private_browsing_purple), PorterDuff.Mode.SRC_IN);
+
+ mHandler = new ToolbarProgressHandler(this);
+ }
+
+ @Override
+ public void onLayout(boolean f, int l, int t, int r, int b) {
+ mBounds.left = 0;
+ mBounds.right = (r - l) * mCurrentProgress / MAX_PROGRESS;
+ mBounds.top = 0;
+ mBounds.bottom = b - t;
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ final Drawable d = getDrawable();
+ d.setBounds(mBounds);
+ d.draw(canvas);
+ }
+
+ /**
+ * Immediately sets the progress bar to the given progress percentage.
+ *
+ * @param progress Percentage (0-100) to which progress bar should be set
+ */
+ void setProgress(int progressPercentage) {
+ mCurrentProgress = mTargetProgress = getAbsoluteProgress(progressPercentage);
+ updateBounds();
+
+ clearMessages();
+ }
+
+ /**
+ * Animates the progress bar from the current progress value to the given
+ * progress percentage.
+ *
+ * @param progress Percentage (0-100) to which progress bar should be animated
+ */
+ void animateProgress(int progressPercentage) {
+ final int absoluteProgress = getAbsoluteProgress(progressPercentage);
+ if (absoluteProgress <= mTargetProgress) {
+ // After we manually click stop, we can still receive page load
+ // events (e.g., DOMContentLoaded). Updating for other updates
+ // after a STOP event can freeze the progress bar, so guard against
+ // that here.
+ return;
+ }
+
+ mTargetProgress = absoluteProgress;
+ mIncrement = (mTargetProgress - mCurrentProgress) / STEPS;
+
+ clearMessages();
+ mHandler.sendEmptyMessage(MSG_UPDATE);
+ }
+
+ private void clearMessages() {
+ mHandler.removeMessages(MSG_UPDATE);
+ mHandler.removeMessages(MSG_HIDE);
+ }
+
+ private int getAbsoluteProgress(int progressPercentage) {
+ if (progressPercentage < 0) {
+ return 0;
+ }
+
+ if (progressPercentage > 100) {
+ return 100;
+ }
+
+ return progressPercentage * MAX_PROGRESS / 100;
+ }
+
+ private void updateBounds() {
+ mBounds.right = getWidth() * mCurrentProgress / MAX_PROGRESS;
+ invalidate();
+ }
+
+ @Override
+ public void setPrivateMode(final boolean isPrivate) {
+ super.setPrivateMode(isPrivate);
+
+ // Note: android:tint is better but ColorStateLists are not supported until API 21.
+ if (isPrivate) {
+ setColorFilter(mPrivateBrowsingColorFilter);
+ } else {
+ clearColorFilter();
+ }
+ }
+
+ private static class ToolbarProgressHandler extends WeakReferenceHandler<ToolbarProgressView> {
+ public ToolbarProgressHandler(final ToolbarProgressView that) {
+ super(that);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ final ToolbarProgressView that = mTarget.get();
+ if (that == null) {
+ return;
+ }
+
+ switch (msg.what) {
+ case MSG_UPDATE:
+ that.mCurrentProgress = Math.min(that.mTargetProgress, that.mCurrentProgress + that.mIncrement);
+
+ that.updateBounds();
+
+ if (that.mCurrentProgress < that.mTargetProgress) {
+ final int delay = (that.mTargetProgress < MAX_PROGRESS) ? DELAY : DELAY / 4;
+ sendMessageDelayed(that.mHandler.obtainMessage(msg.what), delay);
+ } else if (that.mCurrentProgress == MAX_PROGRESS) {
+ sendMessageDelayed(that.mHandler.obtainMessage(MSG_HIDE), DELAY);
+ }
+ break;
+
+ case MSG_HIDE:
+ that.setVisibility(View.GONE);
+ break;
+ }
+ }
+ };
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java b/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java
new file mode 100644
index 000000000..dcc62b6d4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/trackingprotection/TrackingProtectionPrompt.java
@@ -0,0 +1,131 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.trackingprotection;
+
+import org.mozilla.gecko.Locales;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MotionEvent;
+import android.view.View;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+
+public class TrackingProtectionPrompt extends Locales.LocaleAwareActivity {
+ public static final String LOGTAG = "Gecko" + TrackingProtectionPrompt.class.getSimpleName();
+
+ // Flag set during animation to prevent animation multiple-start.
+ private boolean isAnimating;
+
+ private View containerView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ showPrompt();
+ }
+
+ private void showPrompt() {
+ setContentView(R.layout.tracking_protection_prompt);
+
+ findViewById(R.id.ok_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onConfirmButtonPressed();
+ }
+ });
+ findViewById(R.id.link_text).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ slideOut();
+ final Intent settingsIntent = new Intent(TrackingProtectionPrompt.this, GeckoPreferences.class);
+ GeckoPreferences.setResourceToOpen(settingsIntent, "preferences_privacy");
+ startActivity(settingsIntent);
+
+ // Don't use a transition to settings if we're on a device where that
+ // would look bad.
+ if (HardwareUtils.IS_KINDLE_DEVICE) {
+ overridePendingTransition(0, 0);
+ }
+ }
+ });
+
+ containerView = findViewById(R.id.tracking_protection_inner_container);
+
+ containerView.setTranslationY(500);
+ containerView.setAlpha(0);
+
+ final Animator translateAnimator = ObjectAnimator.ofFloat(containerView, "translationY", 0);
+ translateAnimator.setDuration(400);
+
+ final Animator alphaAnimator = ObjectAnimator.ofFloat(containerView, "alpha", 1);
+ alphaAnimator.setStartDelay(200);
+ alphaAnimator.setDuration(600);
+
+ final AnimatorSet set = new AnimatorSet();
+ set.playTogether(alphaAnimator, translateAnimator);
+ set.setStartDelay(400);
+
+ set.start();
+ }
+
+ @Override
+ public void finish() {
+ super.finish();
+
+ // Don't perform an activity-dismiss animation.
+ overridePendingTransition(0, 0);
+ }
+
+ private void onConfirmButtonPressed() {
+ slideOut();
+ }
+
+ /**
+ * Slide the overlay down off the screen and destroy it.
+ */
+ private void slideOut() {
+ if (isAnimating) {
+ return;
+ }
+
+ isAnimating = true;
+
+ ObjectAnimator animator = ObjectAnimator.ofFloat(containerView, "translationY", containerView.getHeight());
+ animator.addListener(new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ finish();
+ }
+
+ });
+ animator.start();
+ }
+
+ /**
+ * Close the dialog if back is pressed.
+ */
+ @Override
+ public void onBackPressed() {
+ slideOut();
+ }
+
+ /**
+ * Close the dialog if the anything that isn't a button is tapped.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ slideOut();
+ return true;
+ }
+ }
diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java b/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java
new file mode 100644
index 000000000..f0ad78e77
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/updater/PostUpdateHandler.java
@@ -0,0 +1,120 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.updater;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import com.keepsafe.switchboard.SwitchBoard;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.delegates.BrowserAppDelegateWithReference;
+import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.preferences.GeckoPreferences;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Perform tasks in the background after the app has been installed/updated.
+ */
+public class PostUpdateHandler extends BrowserAppDelegateWithReference {
+ private static final String LOGTAG = "PostUpdateHandler";
+
+ @Override
+ public void onStart(final BrowserApp browserApp) {
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp);
+
+ // Check if this is a new installation or if the app has been updated since the last start.
+ if (!AppConstants.MOZ_APP_BUILDID.equals(prefs.getString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, null))) {
+ Log.d(LOGTAG, "Build ID changed since last start: '" + AppConstants.MOZ_APP_BUILDID + "', '" + prefs.getString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, null) + "'");
+
+ // Copy the bundled system add-ons from the APK to the data directory.
+ copyFeaturesFromAPK(browserApp);
+ }
+ }
+ });
+ }
+
+ /**
+ * Copies the /assets/features folder out of the APK and into the app's data directory.
+ */
+ private void copyFeaturesFromAPK(BrowserApp browserApp) {
+ Log.d(LOGTAG, "Copying system add-ons from APK to dataDir");
+
+ final String dataDir = browserApp.getApplicationInfo().dataDir;
+ final SharedPreferences prefs = GeckoSharedPrefs.forApp(browserApp);
+ final AssetManager assetManager = browserApp.getContext().getAssets();
+
+ try {
+ final String[] assetNames = assetManager.list("features");
+
+ for (int i = 0; i < assetNames.length; i++) {
+ final String assetPath = "features/" + assetNames[i];
+
+ Log.d(LOGTAG, "Copying '" + assetPath + "' from APK to dataDir");
+
+ final InputStream assetStream = assetManager.open(assetPath);
+ final File outFile = getDataFile(dataDir, assetPath);
+
+ if (outFile == null) {
+ continue;
+ }
+
+ final OutputStream outStream = new FileOutputStream(outFile);
+
+ try {
+ IOUtils.copy(assetStream, outStream);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error copying '" + assetPath + "' from APK to dataDir");
+ } finally {
+ outStream.close();
+ }
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error retrieving packaged system add-ons from APK", e);
+ }
+
+ // Save the Build ID so we don't perform post-update operations again until the app is updated.
+ prefs.edit().putString(GeckoPreferences.PREFS_APP_UPDATE_LAST_BUILD_ID, AppConstants.MOZ_APP_BUILDID).apply();
+ }
+
+ /**
+ * Return a File instance in the data directory, ensuring
+ * that the parent exists.
+ *
+ * @return null if the parents could not be created.
+ */
+ private File getDataFile(final String dataDir, final String name) {
+ File outFile = new File(dataDir, name);
+ File dir = outFile.getParentFile();
+
+ if (!dir.exists()) {
+ Log.d(LOGTAG, "Creating " + dir.getAbsolutePath());
+ if (!dir.mkdirs()) {
+ Log.e(LOGTAG, "Unable to create directories: " + dir.getAbsolutePath());
+ return null;
+ }
+ }
+
+ return outFile;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java
new file mode 100644
index 000000000..7ccc43e28
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateService.java
@@ -0,0 +1,795 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.updater;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.CrashHandler;
+import org.mozilla.gecko.R;
+
+import org.mozilla.apache.commons.codec.binary.Hex;
+
+import org.mozilla.gecko.permissions.Permissions;
+import org.mozilla.gecko.util.ProxySelector;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import android.Manifest;
+import android.app.AlarmManager;
+import android.app.IntentService;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.WifiLock;
+import android.os.Environment;
+import android.provider.Settings;
+import android.support.v4.app.NotificationManagerCompat;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.net.ConnectivityManagerCompat;
+import android.support.v4.app.NotificationCompat;
+import android.support.v4.app.NotificationCompat.Builder;
+import android.util.Log;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLConnection;
+import java.security.MessageDigest;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.TimeZone;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+public class UpdateService extends IntentService {
+ private static final int BUFSIZE = 8192;
+ private static final int NOTIFICATION_ID = 0x3e40ddbd;
+
+ private static final String LOGTAG = "UpdateService";
+
+ private static final int INTERVAL_LONG = 86400000; // in milliseconds
+ private static final int INTERVAL_SHORT = 14400000; // again, in milliseconds
+ private static final int INTERVAL_RETRY = 3600000;
+
+ private static final String PREFS_NAME = "UpdateService";
+ private static final String KEY_LAST_BUILDID = "UpdateService.lastBuildID";
+ private static final String KEY_LAST_HASH_FUNCTION = "UpdateService.lastHashFunction";
+ private static final String KEY_LAST_HASH_VALUE = "UpdateService.lastHashValue";
+ private static final String KEY_LAST_FILE_NAME = "UpdateService.lastFileName";
+ private static final String KEY_LAST_ATTEMPT_DATE = "UpdateService.lastAttemptDate";
+ private static final String KEY_AUTODOWNLOAD_POLICY = "UpdateService.autoDownloadPolicy";
+ private static final String KEY_UPDATE_URL = "UpdateService.updateUrl";
+
+ private SharedPreferences mPrefs;
+
+ private NotificationManagerCompat mNotificationManager;
+ private ConnectivityManager mConnectivityManager;
+ private Builder mBuilder;
+
+ private volatile WifiLock mWifiLock;
+
+ private boolean mDownloading;
+ private boolean mCancelDownload;
+ private boolean mApplyImmediately;
+
+ private CrashHandler mCrashHandler;
+
+ public enum AutoDownloadPolicy {
+ NONE(-1),
+ WIFI(0),
+ DISABLED(1),
+ ENABLED(2);
+
+ public final int value;
+
+ private AutoDownloadPolicy(int value) {
+ this.value = value;
+ }
+
+ private final static AutoDownloadPolicy[] sValues = AutoDownloadPolicy.values();
+
+ public static AutoDownloadPolicy get(int value) {
+ for (AutoDownloadPolicy id: sValues) {
+ if (id.value == value) {
+ return id;
+ }
+ }
+ return NONE;
+ }
+
+ public static AutoDownloadPolicy get(String name) {
+ for (AutoDownloadPolicy id: sValues) {
+ if (name.equalsIgnoreCase(id.toString())) {
+ return id;
+ }
+ }
+ return NONE;
+ }
+ }
+
+ private enum CheckUpdateResult {
+ // Keep these in sync with mobile/android/chrome/content/about.xhtml
+ NOT_AVAILABLE,
+ AVAILABLE,
+ DOWNLOADING,
+ DOWNLOADED
+ }
+
+
+ public UpdateService() {
+ super("updater");
+ }
+
+ @Override
+ public void onCreate () {
+ mCrashHandler = CrashHandler.createDefaultCrashHandler(getApplicationContext());
+
+ super.onCreate();
+
+ mPrefs = getSharedPreferences(PREFS_NAME, 0);
+ mNotificationManager = NotificationManagerCompat.from(this);
+ mConnectivityManager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE);
+ mWifiLock = ((WifiManager)getSystemService(Context.WIFI_SERVICE))
+ .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, PREFS_NAME);
+ mCancelDownload = false;
+ }
+
+ @Override
+ public void onDestroy() {
+ mCrashHandler.unregister();
+ mCrashHandler = null;
+
+ if (mWifiLock.isHeld()) {
+ mWifiLock.release();
+ }
+ }
+
+ @Override
+ public synchronized int onStartCommand (Intent intent, int flags, int startId) {
+ // If we are busy doing a download, the new Intent here would normally be queued for
+ // execution once that is done. In this case, however, we want to flip the boolean
+ // while that is running, so handle that now.
+ if (mDownloading && UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) {
+ Log.i(LOGTAG, "will apply update when download finished");
+
+ mApplyImmediately = true;
+ showDownloadNotification();
+ } else if (UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD.equals(intent.getAction())) {
+ mCancelDownload = true;
+ } else {
+ super.onStartCommand(intent, flags, startId);
+ }
+
+ return Service.START_REDELIVER_INTENT;
+ }
+
+ @Override
+ protected void onHandleIntent (final Intent intent) {
+ if (UpdateServiceHelper.ACTION_REGISTER_FOR_UPDATES.equals(intent.getAction())) {
+ AutoDownloadPolicy policy = AutoDownloadPolicy.get(
+ intent.getIntExtra(UpdateServiceHelper.EXTRA_AUTODOWNLOAD_NAME,
+ AutoDownloadPolicy.NONE.value));
+
+ if (policy != AutoDownloadPolicy.NONE) {
+ setAutoDownloadPolicy(policy);
+ }
+
+ String url = intent.getStringExtra(UpdateServiceHelper.EXTRA_UPDATE_URL_NAME);
+ if (url != null) {
+ setUpdateUrl(url);
+ }
+
+ registerForUpdates(false);
+ } else if (UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE.equals(intent.getAction())) {
+ startUpdate(intent.getIntExtra(UpdateServiceHelper.EXTRA_UPDATE_FLAGS_NAME, 0));
+ // Use this instead for forcing a download from about:fennec
+ // startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD | UpdateServiceHelper.FLAG_REINSTALL);
+ } else if (UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE.equals(intent.getAction())) {
+ // We always want to do the download and apply it here
+ mApplyImmediately = true;
+ startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD);
+ } else if (UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) {
+ applyUpdate(intent.getStringExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME));
+ }
+ }
+
+ private static boolean hasFlag(int flags, int flag) {
+ return (flags & flag) == flag;
+ }
+
+ private void sendCheckUpdateResult(CheckUpdateResult result) {
+ Intent resultIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT);
+ resultIntent.putExtra("result", result.toString());
+ sendBroadcast(resultIntent);
+ }
+
+ private int getUpdateInterval(boolean isRetry) {
+ int interval;
+ if (isRetry) {
+ interval = INTERVAL_RETRY;
+ } else if (!AppConstants.RELEASE_OR_BETA) {
+ interval = INTERVAL_SHORT;
+ } else {
+ interval = INTERVAL_LONG;
+ }
+
+ return interval;
+ }
+
+ private void registerForUpdates(boolean isRetry) {
+ Calendar lastAttempt = getLastAttemptDate();
+ Calendar now = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
+
+ int interval = getUpdateInterval(isRetry);
+
+ if (lastAttempt == null || (now.getTimeInMillis() - lastAttempt.getTimeInMillis()) > interval) {
+ // We've either never attempted an update, or we are passed the desired
+ // time. Start an update now.
+ Log.i(LOGTAG, "no update has ever been attempted, checking now");
+ startUpdate(0);
+ return;
+ }
+
+ AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ if (manager == null)
+ return;
+
+ PendingIntent pending = PendingIntent.getService(this, 0, new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE, null, this, UpdateService.class), PendingIntent.FLAG_UPDATE_CURRENT);
+ manager.cancel(pending);
+
+ lastAttempt.setTimeInMillis(lastAttempt.getTimeInMillis() + interval);
+ Log.i(LOGTAG, "next update will be at: " + lastAttempt.getTime());
+
+ manager.set(AlarmManager.RTC_WAKEUP, lastAttempt.getTimeInMillis(), pending);
+ }
+
+ private void startUpdate(final int flags) {
+ setLastAttemptDate();
+
+ NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo();
+ if (netInfo == null || !netInfo.isConnected()) {
+ Log.i(LOGTAG, "not connected to the network");
+ registerForUpdates(true);
+ sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE);
+ return;
+ }
+
+ registerForUpdates(false);
+
+ final UpdateInfo info = findUpdate(hasFlag(flags, UpdateServiceHelper.FLAG_REINSTALL));
+ boolean haveUpdate = (info != null);
+
+ if (!haveUpdate) {
+ Log.i(LOGTAG, "no update available");
+ sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE);
+ return;
+ }
+
+ Log.i(LOGTAG, "update available, buildID = " + info.buildID);
+
+ Permissions.from(this)
+ .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .doNotPrompt()
+ .andFallback(new Runnable() {
+ @Override
+ public void run() {
+ showPermissionNotification();
+ sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE);
+ }})
+ .run(new Runnable() {
+ @Override
+ public void run() {
+ startDownload(info, flags);
+ }});
+ }
+
+ private void startDownload(UpdateInfo info, int flags) {
+ AutoDownloadPolicy policy = getAutoDownloadPolicy();
+
+ // We only start a download automatically if one of following criteria are met:
+ //
+ // - We have a FORCE_DOWNLOAD flag passed in
+ // - The preference is set to 'always'
+ // - The preference is set to 'wifi' and we are using a non-metered network (i.e. the user
+ // is OK with large data transfers occurring)
+ //
+ boolean shouldStartDownload = hasFlag(flags, UpdateServiceHelper.FLAG_FORCE_DOWNLOAD) ||
+ policy == AutoDownloadPolicy.ENABLED ||
+ (policy == AutoDownloadPolicy.WIFI && !ConnectivityManagerCompat.isActiveNetworkMetered(mConnectivityManager));
+
+ if (!shouldStartDownload) {
+ Log.i(LOGTAG, "not initiating automatic update download due to policy " + policy.toString());
+ sendCheckUpdateResult(CheckUpdateResult.AVAILABLE);
+
+ // We aren't autodownloading here, so prompt to start the update
+ Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE);
+ notificationIntent.setClass(this, UpdateService.class);
+ PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ builder.setSmallIcon(R.drawable.ic_status_logo);
+ builder.setWhen(System.currentTimeMillis());
+ builder.setAutoCancel(true);
+ builder.setContentTitle(getString(R.string.updater_start_title));
+ builder.setContentText(getString(R.string.updater_start_select));
+ builder.setContentIntent(contentIntent);
+
+ mNotificationManager.notify(NOTIFICATION_ID, builder.build());
+
+ return;
+ }
+
+ File pkg = downloadUpdatePackage(info, hasFlag(flags, UpdateServiceHelper.FLAG_OVERWRITE_EXISTING));
+ if (pkg == null) {
+ sendCheckUpdateResult(CheckUpdateResult.NOT_AVAILABLE);
+ return;
+ }
+
+ Log.i(LOGTAG, "have update package at " + pkg);
+
+ saveUpdateInfo(info, pkg);
+ sendCheckUpdateResult(CheckUpdateResult.DOWNLOADED);
+
+ if (mApplyImmediately) {
+ applyUpdate(pkg);
+ } else {
+ // Prompt to apply the update
+
+ Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE);
+ notificationIntent.setClass(this, UpdateService.class);
+ notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, pkg.getAbsolutePath());
+ PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ builder.setSmallIcon(R.drawable.ic_status_logo);
+ builder.setWhen(System.currentTimeMillis());
+ builder.setAutoCancel(true);
+ builder.setContentTitle(getString(R.string.updater_apply_title));
+ builder.setContentText(getString(R.string.updater_apply_select));
+ builder.setContentIntent(contentIntent);
+
+ mNotificationManager.notify(NOTIFICATION_ID, builder.build());
+ }
+ }
+
+ private UpdateInfo findUpdate(boolean force) {
+ try {
+ URI uri = getUpdateURI(force);
+
+ if (uri == null) {
+ Log.e(LOGTAG, "failed to get update URI");
+ return null;
+ }
+
+ DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+ Document dom = builder.parse(ProxySelector.openConnectionWithProxy(uri).getInputStream());
+
+ NodeList nodes = dom.getElementsByTagName("update");
+ if (nodes == null || nodes.getLength() == 0)
+ return null;
+
+ Node updateNode = nodes.item(0);
+ Node buildIdNode = updateNode.getAttributes().getNamedItem("buildID");
+ if (buildIdNode == null)
+ return null;
+
+ nodes = dom.getElementsByTagName("patch");
+ if (nodes == null || nodes.getLength() == 0)
+ return null;
+
+ Node patchNode = nodes.item(0);
+ Node urlNode = patchNode.getAttributes().getNamedItem("URL");
+ Node hashFunctionNode = patchNode.getAttributes().getNamedItem("hashFunction");
+ Node hashValueNode = patchNode.getAttributes().getNamedItem("hashValue");
+ Node sizeNode = patchNode.getAttributes().getNamedItem("size");
+
+ if (urlNode == null || hashFunctionNode == null ||
+ hashValueNode == null || sizeNode == null) {
+ return null;
+ }
+
+ // Fill in UpdateInfo from the XML data
+ UpdateInfo info = new UpdateInfo();
+ info.uri = new URI(urlNode.getTextContent());
+ info.buildID = buildIdNode.getTextContent();
+ info.hashFunction = hashFunctionNode.getTextContent();
+ info.hashValue = hashValueNode.getTextContent();
+
+ try {
+ info.size = Integer.parseInt(sizeNode.getTextContent());
+ } catch (NumberFormatException e) {
+ Log.e(LOGTAG, "Failed to find APK size: ", e);
+ return null;
+ }
+
+ // Make sure we have all the stuff we need to apply the update
+ if (!info.isValid()) {
+ Log.e(LOGTAG, "missing some required update information, have: " + info);
+ return null;
+ }
+
+ return info;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "failed to check for update: ", e);
+ return null;
+ }
+ }
+
+ private MessageDigest createMessageDigest(String hashFunction) {
+ String javaHashFunction = null;
+
+ if ("sha512".equalsIgnoreCase(hashFunction)) {
+ javaHashFunction = "SHA-512";
+ } else {
+ Log.e(LOGTAG, "Unhandled hash function: " + hashFunction);
+ return null;
+ }
+
+ try {
+ return MessageDigest.getInstance(javaHashFunction);
+ } catch (java.security.NoSuchAlgorithmException e) {
+ Log.e(LOGTAG, "Couldn't find algorithm " + javaHashFunction, e);
+ return null;
+ }
+ }
+
+ private void showDownloadNotification() {
+ showDownloadNotification(null);
+ }
+
+ private void showDownloadNotification(File downloadFile) {
+
+ Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE);
+ notificationIntent.setClass(this, UpdateService.class);
+
+ Intent cancelIntent = new Intent(UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD);
+ cancelIntent.setClass(this, UpdateService.class);
+
+ if (downloadFile != null)
+ notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, downloadFile.getAbsolutePath());
+
+ PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ PendingIntent deleteIntent = PendingIntent.getService(this, 0, cancelIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+
+ mBuilder = new NotificationCompat.Builder(this);
+ mBuilder.setContentTitle(getResources().getString(R.string.updater_downloading_title))
+ .setContentText(mApplyImmediately ? "" : getResources().getString(R.string.updater_downloading_select))
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentIntent(contentIntent)
+ .setDeleteIntent(deleteIntent);
+
+ mBuilder.setProgress(100, 0, true);
+ mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
+ }
+
+ private void showDownloadFailure() {
+ Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE);
+ notificationIntent.setClass(this, UpdateService.class);
+ PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ builder.setSmallIcon(R.drawable.ic_status_logo);
+ builder.setWhen(System.currentTimeMillis());
+ builder.setContentTitle(getString(R.string.updater_downloading_title_failed));
+ builder.setContentText(getString(R.string.updater_downloading_retry));
+ builder.setContentIntent(contentIntent);
+
+ mNotificationManager.notify(NOTIFICATION_ID, builder.build());
+ }
+
+ private boolean deleteUpdatePackage(String path) {
+ if (path == null) {
+ return false;
+ }
+
+ File pkg = new File(path);
+ if (!pkg.exists()) {
+ return false;
+ }
+
+ pkg.delete();
+ Log.i(LOGTAG, "deleted update package: " + path);
+
+ return true;
+ }
+
+ private File downloadUpdatePackage(UpdateInfo info, boolean overwriteExisting) {
+ URL url = null;
+ try {
+ url = info.uri.toURL();
+ } catch (java.net.MalformedURLException e) {
+ Log.e(LOGTAG, "failed to read URL: ", e);
+ return null;
+ }
+
+ File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ path.mkdirs();
+ String fileName = new File(url.getFile()).getName();
+ File downloadFile = new File(path, fileName);
+
+ if (!overwriteExisting && info.buildID.equals(getLastBuildID()) && downloadFile.exists()) {
+ // The last saved buildID is the same as the one for the current update. We also have a file
+ // already downloaded, so it's probably the package we want. Verify it to be sure and just
+ // return that if it matches.
+
+ if (verifyDownloadedPackage(downloadFile)) {
+ Log.i(LOGTAG, "using existing update package");
+ return downloadFile;
+ } else {
+ // Didn't match, so we're going to download a new one.
+ downloadFile.delete();
+ }
+ }
+
+ if (!info.buildID.equals(getLastBuildID())) {
+ // Delete the previous package when a new version becomes available.
+ deleteUpdatePackage(getLastFileName());
+ }
+
+ Log.i(LOGTAG, "downloading update package");
+ sendCheckUpdateResult(CheckUpdateResult.DOWNLOADING);
+
+ OutputStream output = null;
+ InputStream input = null;
+
+ mDownloading = true;
+ mCancelDownload = false;
+ showDownloadNotification(downloadFile);
+
+ try {
+ NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo();
+ if (netInfo != null && netInfo.isConnected() &&
+ netInfo.getType() == ConnectivityManager.TYPE_WIFI) {
+ mWifiLock.acquire();
+ }
+
+ URLConnection conn = ProxySelector.openConnectionWithProxy(info.uri);
+ int length = conn.getContentLength();
+
+ output = new BufferedOutputStream(new FileOutputStream(downloadFile));
+ input = new BufferedInputStream(conn.getInputStream());
+
+ byte[] buf = new byte[BUFSIZE];
+ int len = 0;
+
+ int bytesRead = 0;
+ int lastNotify = 0;
+
+ while ((len = input.read(buf, 0, BUFSIZE)) > 0 && !mCancelDownload) {
+ output.write(buf, 0, len);
+ bytesRead += len;
+ // Updating the notification takes time so only do it every 1MB
+ if (bytesRead - lastNotify > 1048576) {
+ mBuilder.setProgress(length, bytesRead, false);
+ mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
+ lastNotify = bytesRead;
+ }
+ }
+
+ mNotificationManager.cancel(NOTIFICATION_ID);
+
+ // if the download was canceled by the user
+ // delete the update package
+ if (mCancelDownload) {
+ Log.i(LOGTAG, "download canceled by user!");
+ downloadFile.delete();
+
+ return null;
+ } else {
+ Log.i(LOGTAG, "completed update download!");
+ return downloadFile;
+ }
+ } catch (Exception e) {
+ downloadFile.delete();
+ showDownloadFailure();
+
+ Log.e(LOGTAG, "failed to download update: ", e);
+ return null;
+ } finally {
+ try {
+ if (input != null)
+ input.close();
+ } catch (java.io.IOException e) { }
+
+ try {
+ if (output != null)
+ output.close();
+ } catch (java.io.IOException e) { }
+
+ mDownloading = false;
+
+ if (mWifiLock.isHeld()) {
+ mWifiLock.release();
+ }
+ }
+ }
+
+ private boolean verifyDownloadedPackage(File updateFile) {
+ MessageDigest digest = createMessageDigest(getLastHashFunction());
+ if (digest == null)
+ return false;
+
+ InputStream input = null;
+
+ try {
+ input = new BufferedInputStream(new FileInputStream(updateFile));
+
+ byte[] buf = new byte[BUFSIZE];
+ int len;
+ while ((len = input.read(buf, 0, BUFSIZE)) > 0) {
+ digest.update(buf, 0, len);
+ }
+ } catch (java.io.IOException e) {
+ Log.e(LOGTAG, "Failed to verify update package: ", e);
+ return false;
+ } finally {
+ try {
+ if (input != null)
+ input.close();
+ } catch (java.io.IOException e) { }
+ }
+
+ String hex = Hex.encodeHexString(digest.digest());
+ if (!hex.equals(getLastHashValue())) {
+ Log.e(LOGTAG, "Package hash does not match");
+ return false;
+ }
+
+ return true;
+ }
+
+ private void applyUpdate(String updatePath) {
+ if (updatePath == null) {
+ updatePath = getLastFileName();
+ }
+
+ if (updatePath != null) {
+ applyUpdate(new File(updatePath));
+ }
+ }
+
+ private void applyUpdate(File updateFile) {
+ mApplyImmediately = false;
+
+ if (!updateFile.exists())
+ return;
+
+ Log.i(LOGTAG, "Verifying package: " + updateFile);
+
+ if (!verifyDownloadedPackage(updateFile)) {
+ Log.e(LOGTAG, "Not installing update, failed verification");
+ return;
+ }
+
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(Uri.fromFile(updateFile), "application/vnd.android.package-archive");
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ }
+
+ private void showPermissionNotification() {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", getPackageName(), null));
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0);
+
+ NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle()
+ .bigText(getString(R.string.updater_permission_text));
+
+ Notification notification = new NotificationCompat.Builder(this)
+ .setContentTitle(getString(R.string.updater_permission_title))
+ .setContentText(getString(R.string.updater_permission_text))
+ .setStyle(bigTextStyle)
+ .setAutoCancel(true)
+ .setSmallIcon(R.drawable.ic_status_logo)
+ .setColor(ContextCompat.getColor(this, R.color.rejection_red))
+ .setContentIntent(pendingIntent)
+ .build();
+
+ NotificationManagerCompat.from(this)
+ .notify(R.id.updateServicePermissionNotification, notification);
+ }
+
+ private String getLastBuildID() {
+ return mPrefs.getString(KEY_LAST_BUILDID, null);
+ }
+
+ private String getLastHashFunction() {
+ return mPrefs.getString(KEY_LAST_HASH_FUNCTION, null);
+ }
+
+ private String getLastHashValue() {
+ return mPrefs.getString(KEY_LAST_HASH_VALUE, null);
+ }
+
+ private String getLastFileName() {
+ return mPrefs.getString(KEY_LAST_FILE_NAME, null);
+ }
+
+ private Calendar getLastAttemptDate() {
+ long lastAttempt = mPrefs.getLong(KEY_LAST_ATTEMPT_DATE, -1);
+ if (lastAttempt < 0)
+ return null;
+
+ GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
+ cal.setTimeInMillis(lastAttempt);
+ return cal;
+ }
+
+ private void setLastAttemptDate() {
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putLong(KEY_LAST_ATTEMPT_DATE, System.currentTimeMillis());
+ editor.commit();
+ }
+
+ private AutoDownloadPolicy getAutoDownloadPolicy() {
+ return AutoDownloadPolicy.get(mPrefs.getInt(KEY_AUTODOWNLOAD_POLICY, AutoDownloadPolicy.WIFI.value));
+ }
+
+ private void setAutoDownloadPolicy(AutoDownloadPolicy policy) {
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putInt(KEY_AUTODOWNLOAD_POLICY, policy.value);
+ editor.commit();
+ }
+
+ private URI getUpdateURI(boolean force) {
+ return UpdateServiceHelper.expandUpdateURI(this, mPrefs.getString(KEY_UPDATE_URL, null), force);
+ }
+
+ private void setUpdateUrl(String url) {
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putString(KEY_UPDATE_URL, url);
+ editor.commit();
+ }
+
+ private void saveUpdateInfo(UpdateInfo info, File downloaded) {
+ SharedPreferences.Editor editor = mPrefs.edit();
+ editor.putString(KEY_LAST_BUILDID, info.buildID);
+ editor.putString(KEY_LAST_HASH_FUNCTION, info.hashFunction);
+ editor.putString(KEY_LAST_HASH_VALUE, info.hashValue);
+ editor.putString(KEY_LAST_FILE_NAME, downloaded.toString());
+ editor.commit();
+ }
+
+ private class UpdateInfo {
+ public URI uri;
+ public String buildID;
+ public String hashFunction;
+ public String hashValue;
+ public int size;
+
+ private boolean isNonEmpty(String s) {
+ return s != null && s.length() > 0;
+ }
+
+ public boolean isValid() {
+ return uri != null && isNonEmpty(buildID) &&
+ isNonEmpty(hashFunction) && isNonEmpty(hashValue) && size > 0;
+ }
+
+ @Override
+ public String toString() {
+ return "uri = " + uri + ", buildID = " + buildID + ", hashFunction = " + hashFunction + ", hashValue = " + hashValue + ", size = " + size;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java
new file mode 100644
index 000000000..c4d198ae7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/updater/UpdateServiceHelper.java
@@ -0,0 +1,213 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.updater;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.util.ContextUtils;
+import org.mozilla.gecko.util.GeckoJarReader;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ApplicationInfo;
+import android.os.Build;
+import android.util.Log;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class UpdateServiceHelper {
+ public static final String ACTION_REGISTER_FOR_UPDATES = AppConstants.ANDROID_PACKAGE_NAME + ".REGISTER_FOR_UPDATES";
+ public static final String ACTION_UNREGISTER_FOR_UPDATES = AppConstants.ANDROID_PACKAGE_NAME + ".UNREGISTER_FOR_UPDATES";
+ public static final String ACTION_CHECK_FOR_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".CHECK_FOR_UPDATE";
+ public static final String ACTION_CHECK_UPDATE_RESULT = AppConstants.ANDROID_PACKAGE_NAME + ".CHECK_UPDATE_RESULT";
+ public static final String ACTION_DOWNLOAD_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".DOWNLOAD_UPDATE";
+ public static final String ACTION_APPLY_UPDATE = AppConstants.ANDROID_PACKAGE_NAME + ".APPLY_UPDATE";
+ public static final String ACTION_CANCEL_DOWNLOAD = AppConstants.ANDROID_PACKAGE_NAME + ".CANCEL_DOWNLOAD";
+
+ // Flags for ACTION_CHECK_FOR_UPDATE
+ protected static final int FLAG_FORCE_DOWNLOAD = 1;
+ protected static final int FLAG_OVERWRITE_EXISTING = 1 << 1;
+ protected static final int FLAG_REINSTALL = 1 << 2;
+ protected static final int FLAG_RETRY = 1 << 3;
+
+ // Name of the Intent extra for the autodownload policy, used with ACTION_REGISTER_FOR_UPDATES
+ protected static final String EXTRA_AUTODOWNLOAD_NAME = "autodownload";
+
+ // Name of the Intent extra that holds the flags for ACTION_CHECK_FOR_UPDATE
+ protected static final String EXTRA_UPDATE_FLAGS_NAME = "updateFlags";
+
+ // Name of the Intent extra that holds the APK path, used with ACTION_APPLY_UPDATE
+ protected static final String EXTRA_PACKAGE_PATH_NAME = "packagePath";
+
+ // Name of the Intent extra for the update URL, used with ACTION_REGISTER_FOR_UPDATES
+ protected static final String EXTRA_UPDATE_URL_NAME = "updateUrl";
+
+ private static final String LOGTAG = "UpdateServiceHelper";
+ private static final String DEFAULT_UPDATE_LOCALE = "en-US";
+
+ // So that updates can be disabled by tests.
+ private static volatile boolean isEnabled = true;
+
+ private enum Pref {
+ AUTO_DOWNLOAD_POLICY("app.update.autodownload"),
+ UPDATE_URL("app.update.url.android");
+
+ public final String name;
+
+ private Pref(String name) {
+ this.name = name;
+ }
+
+ public final static String[] names;
+
+ @Override
+ public String toString() {
+ return this.name;
+ }
+
+ static {
+ ArrayList<String> nameList = new ArrayList<String>();
+
+ for (Pref id: Pref.values()) {
+ nameList.add(id.toString());
+ }
+
+ names = nameList.toArray(new String[0]);
+ }
+ }
+
+ @RobocopTarget
+ public static void setEnabled(final boolean enabled) {
+ isEnabled = enabled;
+ }
+
+ public static URI expandUpdateURI(Context context, String updateUri, boolean force) {
+ if (updateUri == null) {
+ return null;
+ }
+
+ PackageManager pm = context.getPackageManager();
+
+ String pkgSpecial = AppConstants.MOZ_PKG_SPECIAL != null ?
+ "-" + AppConstants.MOZ_PKG_SPECIAL :
+ "";
+ String locale = DEFAULT_UPDATE_LOCALE;
+
+ try {
+ ApplicationInfo info = pm.getApplicationInfo(AppConstants.ANDROID_PACKAGE_NAME, 0);
+ String updateLocaleUrl = "jar:jar:file://" + info.sourceDir + "!/" + AppConstants.OMNIJAR_NAME + "!/update.locale";
+
+ final String jarLocale = GeckoJarReader.getText(context, updateLocaleUrl);
+ if (jarLocale != null) {
+ locale = jarLocale.trim();
+ }
+ } catch (android.content.pm.PackageManager.NameNotFoundException e) {
+ // Shouldn't really be possible, but fallback to default locale
+ Log.i(LOGTAG, "Failed to read update locale file, falling back to " + locale);
+ }
+
+ String url = updateUri.replace("%PRODUCT%", AppConstants.MOZ_APP_BASENAME)
+ .replace("%VERSION%", AppConstants.MOZ_APP_VERSION)
+ .replace("%BUILD_ID%", force ? "0" : AppConstants.MOZ_APP_BUILDID)
+ .replace("%BUILD_TARGET%", "Android_" + AppConstants.MOZ_APP_ABI + pkgSpecial)
+ .replace("%LOCALE%", locale)
+ .replace("%CHANNEL%", AppConstants.MOZ_UPDATE_CHANNEL)
+ .replace("%OS_VERSION%", Build.VERSION.RELEASE)
+ .replace("%DISTRIBUTION%", "default")
+ .replace("%DISTRIBUTION_VERSION%", "default")
+ .replace("%MOZ_VERSION%", AppConstants.MOZILLA_VERSION);
+
+ try {
+ return new URI(url);
+ } catch (java.net.URISyntaxException e) {
+ Log.e(LOGTAG, "Failed to create update url: ", e);
+ return null;
+ }
+ }
+
+ public static boolean isUpdaterEnabled(final Context context) {
+ return AppConstants.MOZ_UPDATER && isEnabled && !ContextUtils.isInstalledFromGooglePlay(context);
+ }
+
+ public static void setUpdateUrl(Context context, String url) {
+ registerForUpdates(context, null, url);
+ }
+
+ public static void setAutoDownloadPolicy(Context context, UpdateService.AutoDownloadPolicy policy) {
+ registerForUpdates(context, policy, null);
+ }
+
+ public static void checkForUpdate(Context context) {
+ if (context == null) {
+ return;
+ }
+
+ context.startService(createIntent(context, ACTION_CHECK_FOR_UPDATE));
+ }
+
+ public static void downloadUpdate(Context context) {
+ if (context == null) {
+ return;
+ }
+
+ context.startService(createIntent(context, ACTION_DOWNLOAD_UPDATE));
+ }
+
+ public static void applyUpdate(Context context) {
+ if (context == null) {
+ return;
+ }
+
+ context.startService(createIntent(context, ACTION_APPLY_UPDATE));
+ }
+
+ public static void registerForUpdates(final Context context) {
+ if (!isUpdaterEnabled(context)) {
+ return;
+ }
+
+ final HashMap<String, Object> prefs = new HashMap<String, Object>();
+
+ PrefsHelper.getPrefs(Pref.names, new PrefsHelper.PrefHandlerBase() {
+ @Override public void prefValue(String pref, String value) {
+ prefs.put(pref, value);
+ }
+
+ @Override public void finish() {
+ UpdateServiceHelper.registerForUpdates(context,
+ UpdateService.AutoDownloadPolicy.get(
+ (String) prefs.get(Pref.AUTO_DOWNLOAD_POLICY.toString())),
+ (String) prefs.get(Pref.UPDATE_URL.toString()));
+ }
+ });
+ }
+
+ public static void registerForUpdates(Context context, UpdateService.AutoDownloadPolicy policy, String url) {
+ if (!isUpdaterEnabled(context)) {
+ return;
+ }
+
+ Intent intent = createIntent(context, ACTION_REGISTER_FOR_UPDATES);
+
+ if (policy != null) {
+ intent.putExtra(EXTRA_AUTODOWNLOAD_NAME, policy.value);
+ }
+
+ if (url != null) {
+ intent.putExtra(EXTRA_UPDATE_URL_NAME, url);
+ }
+
+ context.startService(intent);
+ }
+
+ private static Intent createIntent(Context context, String action) {
+ return new Intent(action, null, context, UpdateService.class);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java
new file mode 100644
index 000000000..ec227d1ce
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/ColorUtil.java
@@ -0,0 +1,44 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Color;
+
+public class ColorUtil {
+ public static int darken(final int color, final double fraction) {
+ int red = Color.red(color);
+ int green = Color.green(color);
+ int blue = Color.blue(color);
+ red = darkenColor(red, fraction);
+ green = darkenColor(green, fraction);
+ blue = darkenColor(blue, fraction);
+ final int alpha = Color.alpha(color);
+ return Color.argb(alpha, red, green, blue);
+ }
+
+ public static int getReadableTextColor(final int backgroundColor) {
+ final int greyValue = grayscaleFromRGB(backgroundColor);
+ // 186 chosen rather than the seemingly obvious 128 because of gamma.
+ if (greyValue < 186) {
+ return Color.WHITE;
+ } else {
+ return Color.BLACK;
+ }
+ }
+
+ private static int darkenColor(final int color, final double fraction) {
+ return (int) Math.max(color - (color * fraction), 0);
+ }
+
+ private static int grayscaleFromRGB(final int color) {
+ final int red = Color.red(color);
+ final int green = Color.green(color);
+ final int blue = Color.blue(color);
+ // Magic weighting taken from a stackoverflow post, supposedly related to how
+ // humans perceive color.
+ return (int) (0.299 * red + 0.587 * green + 0.114 * blue);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java
new file mode 100644
index 000000000..f3c9eef83
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/DrawableUtil.java
@@ -0,0 +1,66 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.CheckResult;
+import android.support.annotation.ColorInt;
+import android.support.annotation.ColorRes;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.NonNull;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.graphics.drawable.DrawableCompat;
+
+import org.mozilla.gecko.AppConstants;
+
+public class DrawableUtil {
+
+ /**
+ * Tints the given drawable with the given color and returns it.
+ */
+ @CheckResult
+ public static Drawable tintDrawable(@NonNull final Context context,
+ @DrawableRes final int drawableID,
+ @ColorInt final int color) {
+ final Drawable icon = DrawableCompat.wrap(ResourceDrawableUtils.getDrawable(context, drawableID)
+ .mutate());
+ DrawableCompat.setTint(icon, color);
+ return icon;
+ }
+
+ /**
+ * Tints the given drawable with the given color and returns it.
+ */
+ @CheckResult
+ public static Drawable tintDrawableWithColorRes(@NonNull final Context context,
+ @DrawableRes final int drawableID,
+ @ColorRes final int colorID) {
+ return tintDrawable(context, drawableID, ContextCompat.getColor(context, colorID));
+ }
+
+ /**
+ * Tints the given drawable with the given tint list and returns it. Note that you
+ * should no longer use the argument Drawable because the argument is not mutated
+ * on pre-Lollipop devices but is mutated on L+ due to differences in the Support
+ * Library implementation (bug 1193950).
+ */
+ @CheckResult
+ public static Drawable tintDrawableWithStateList(@NonNull final Drawable drawable,
+ @NonNull final ColorStateList colorList) {
+ final Drawable wrappedDrawable = DrawableCompat.wrap(drawable.mutate());
+ DrawableCompat.setTintList(wrappedDrawable, colorList);
+
+ // DrawableCompat on pre-L doesn't handle its bounds correctly, and by default therefore won't
+ // be rendered - we need to manually copy the bounds as a workaround:
+ if (AppConstants.Versions.preMarshmallow) {
+ wrappedDrawable.setBounds(0, 0, wrappedDrawable.getIntrinsicHeight(), wrappedDrawable.getIntrinsicHeight());
+ }
+
+ return wrappedDrawable;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java b/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java
new file mode 100644
index 000000000..1e5c2a723
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/ResourceDrawableUtils.java
@@ -0,0 +1,136 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.util;
+
+import android.content.Context;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.AppCompatDrawableManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.UIAsyncTask;
+
+import java.io.InputStream;
+import java.net.URL;
+
+import static org.mozilla.gecko.gfx.BitmapUtils.getBitmapFromDataURI;
+import static org.mozilla.gecko.gfx.BitmapUtils.getResource;
+
+public class ResourceDrawableUtils {
+ private static final String LOGTAG = "ResourceDrawableUtils";
+
+ public static Drawable getDrawable(@NonNull final Context context,
+ @DrawableRes final int drawableID) {
+ // TODO: upgrade this call to use AppCompatResources when upgrading to support library >= 24.2
+ // https://developer.android.com/reference/android/support/v7/content/res/AppCompatResources.html#getDrawable(android.content.Context,%20int)
+ return AppCompatDrawableManager.get().getDrawable(context, drawableID);
+ }
+
+ public interface BitmapLoader {
+ public void onBitmapFound(Drawable d);
+ }
+
+ public static void runOnBitmapFoundOnUiThread(final BitmapLoader loader, final Drawable d) {
+ if (ThreadUtils.isOnUiThread()) {
+ loader.onBitmapFound(d);
+ return;
+ }
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ loader.onBitmapFound(d);
+ }
+ });
+ }
+
+ /**
+ * Attempts to find a drawable associated with a given string, using its URI scheme to determine
+ * how to load the drawable. The BitmapLoader's `onBitmapFound` method is always called, and
+ * will be called with `null` if no drawable is found.
+ *
+ * The BitmapLoader `onBitmapFound` method always runs on the UI thread.
+ */
+ public static void getDrawable(final Context context, final String data, final BitmapLoader loader) {
+ if (TextUtils.isEmpty(data)) {
+ runOnBitmapFoundOnUiThread(loader, null);
+ return;
+ }
+
+ if (data.startsWith("data")) {
+ final BitmapDrawable d = new BitmapDrawable(context.getResources(), getBitmapFromDataURI(data));
+ runOnBitmapFoundOnUiThread(loader, d);
+ return;
+ }
+
+ if (data.startsWith("jar:") || data.startsWith("file://")) {
+ (new UIAsyncTask.WithoutParams<Drawable>(ThreadUtils.getBackgroundHandler()) {
+ @Override
+ public Drawable doInBackground() {
+ try {
+ if (data.startsWith("jar:jar")) {
+ return GeckoJarReader.getBitmapDrawable(
+ context, context.getResources(), data);
+ }
+
+ // Don't attempt to validate the JAR signature when loading an add-on icon
+ if (data.startsWith("jar:file")) {
+ return GeckoJarReader.getBitmapDrawable(
+ context, context.getResources(), Uri.decode(data));
+ }
+
+ final URL url = new URL(data);
+ final InputStream is = (InputStream) url.getContent();
+ try {
+ return Drawable.createFromStream(is, "src");
+ } finally {
+ is.close();
+ }
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Unable to set icon", e);
+ }
+ return null;
+ }
+
+ @Override
+ public void onPostExecute(Drawable drawable) {
+ loader.onBitmapFound(drawable);
+ }
+ }).execute();
+ return;
+ }
+
+ if (data.startsWith("-moz-icon://")) {
+ final Uri imageUri = Uri.parse(data);
+ final String ssp = imageUri.getSchemeSpecificPart();
+ final String resource = ssp.substring(ssp.lastIndexOf('/') + 1);
+
+ try {
+ final Drawable d = context.getPackageManager().getApplicationIcon(resource);
+ runOnBitmapFoundOnUiThread(loader, d);
+ } catch (Exception ex) { }
+
+ return;
+ }
+
+ if (data.startsWith("drawable://")) {
+ final Uri imageUri = Uri.parse(data);
+ final int id = getResource(context, imageUri);
+ final Drawable d = getDrawable(context, id);
+
+ runOnBitmapFoundOnUiThread(loader, d);
+ return;
+ }
+
+ runOnBitmapFoundOnUiThread(loader, null);
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java
new file mode 100644
index 000000000..6414dec9f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/TouchTargetUtil.java
@@ -0,0 +1,48 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Rect;
+import android.view.TouchDelegate;
+import android.view.View;
+
+import org.mozilla.gecko.R;
+
+public class TouchTargetUtil {
+ /**
+ * Ensures that a given targetView has a large enough touch area to ensure it can be selected.
+ * A TouchDelegate will be added to the enclosingView as necessary.
+ *
+ * @param targetView
+ * @param enclosingView
+ */
+ public static void ensureTargetHitArea(final View targetView, final View enclosingView) {
+ enclosingView.post(new Runnable() {
+ @Override
+ public void run() {
+ Rect delegateArea = new Rect();
+ targetView.getHitRect(delegateArea);
+
+ final int targetHitArea = enclosingView.getContext().getResources().getDimensionPixelSize(R.dimen.touch_target_size);
+
+ final int widthDelta = (targetHitArea - delegateArea.width()) / 2;
+ delegateArea.right += widthDelta;
+ delegateArea.left -= widthDelta;
+
+ final int heightDelta = (targetHitArea - delegateArea.height()) / 2;
+ delegateArea.bottom += heightDelta;
+ delegateArea.top -= heightDelta;
+
+ if (heightDelta <= 0 && widthDelta <= 0) {
+ return;
+ }
+
+ TouchDelegate touchDelegate = new TouchDelegate(delegateArea, targetView);
+ enclosingView.setTouchDelegate(touchDelegate);
+ }
+ });
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java
new file mode 100644
index 000000000..0033e72a0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/UnusedResourcesUtil.java
@@ -0,0 +1,128 @@
+package org.mozilla.gecko.util;
+
+import org.mozilla.gecko.R;
+
+/**
+ * (linter: UnusedResources) We use resources in places Android Lint can't check (e.g. JS) - this is
+ * a set of those references so Android Lint stops complaining.
+ */
+@SuppressWarnings("unused")
+final class UnusedResourcesUtil {
+ public static final int[] CONSTANTS = {
+ R.dimen.match_parent,
+ R.dimen.wrap_content,
+ };
+
+ public static final int[] USED_IN_BRANDING = {
+ R.drawable.large_icon
+ };
+
+ public static final int[] USED_IN_COLOR_PALETTE = {
+ R.color.private_browsing_purple, // This will be used eventually, then this item removed.
+ };
+
+ public static final int[] USED_IN_CRASH_REPORTER = {
+ R.string.crash_allow_contact2,
+ R.string.crash_close_label,
+ R.string.crash_comment,
+ R.string.crash_email,
+ R.string.crash_include_url2,
+ R.string.crash_message2,
+ R.string.crash_restart_label,
+ R.string.crash_send_report_message3,
+ R.string.crash_sorry,
+ };
+
+ public static final int[] USED_IN_JS = {
+ R.drawable.ab_search,
+ R.drawable.alert_camera,
+ R.drawable.alert_download,
+ R.drawable.alert_download_animation,
+ R.drawable.alert_mic,
+ R.drawable.alert_mic_camera,
+ R.drawable.casting,
+ R.drawable.casting_active,
+ R.drawable.close,
+ R.drawable.homepage_banner_firstrun,
+ R.drawable.icon_openinapp,
+ R.drawable.pause,
+ R.drawable.phone,
+ R.drawable.play,
+ R.drawable.reader,
+ R.drawable.reader_active,
+ R.drawable.sync_promo,
+ R.drawable.undo_button_icon,
+ };
+
+ public static final int[] USED_IN_MANIFEST = {
+ R.drawable.search_launcher,
+ R.string.crash_reporter_title,
+ R.xml.fxaccount_authenticator,
+ R.xml.fxaccount_syncadapter,
+ R.xml.search_widget_info,
+ R.xml.searchable,
+ };
+
+ public static final int[] USED_IN_SUGGESTEDSITES = {
+ R.drawable.suggestedsites_amazon,
+ R.drawable.suggestedsites_facebook,
+ R.drawable.suggestedsites_restricted_fxsupport,
+ R.drawable.suggestedsites_restricted_mozilla,
+ R.drawable.suggestedsites_twitter,
+ R.drawable.suggestedsites_webmaker,
+ R.drawable.suggestedsites_wikipedia,
+ R.drawable.suggestedsites_youtube,
+ };
+
+ public static final int[] USED_IN_BOOKMARKDEFAULTS = {
+ R.raw.bookmarkdefaults_favicon_addons,
+ R.raw.bookmarkdefaults_favicon_support,
+ R.raw.bookmarkdefaults_favicon_restricted_support,
+ R.raw.bookmarkdefaults_favicon_restricted_webmaker,
+ R.string.bookmarkdefaults_title_restricted_support,
+ R.string.bookmarkdefaults_url_restricted_support,
+ R.string.bookmarkdefaults_title_restricted_webmaker,
+ R.string.bookmarkdefaults_url_restricted_webmaker,
+ };
+
+ public static final int[] USED_IN_PREFS = {
+ R.xml.preferences_advanced,
+ R.xml.preferences_accessibility,
+ R.xml.preferences_home,
+ R.xml.preferences_privacy,
+ R.xml.preferences_privacy_clear_tablet,
+ R.xml.preferences_default_browser_tablet
+ };
+
+ // We are migrating to Gradle 2.10 and the Android Gradle plugin 2.0. The new plugin does find
+ // more unused resources but we are not ready to remove them yet. Some of the resources are going
+ // to be reused soon. This is a temporary solution so that the gradle migration is not blocked.
+ // See bug 1263390 / bug 1268414.
+ public static final int[] TEMPORARY_UNUSED_WHILE_MIGRATING_GRADLE = {
+ R.color.remote_tabs_setup_button_background_hit,
+
+ R.drawable.remote_tabs_setup_button_background,
+
+ R.style.TabsPanelSectionBase,
+ R.style.TabsPanelSection,
+ R.style.TabsPanelItemBase,
+ R.style.TabsPanelItem,
+ R.style.TabsPanelItem_TextAppearance,
+ R.style.TabsPanelItem_TextAppearance_Header,
+ R.style.TabsPanelItem_TextAppearance_Linkified,
+ R.style.TabWidget,
+ R.style.GeckoDialogTitle,
+ R.style.GeckoDialogTitle_SubTitle,
+ R.style.RemoteTabsPanelItem,
+ R.style.RemoteTabsPanelItem_TextAppearance,
+ R.style.RemoteTabsPanelItem_TextAppearance_Header,
+ R.style.RemoteTabsPanelItem_TextAppearance_Linkified,
+ R.style.RemoteTabsPanelItem_Button,
+ };
+
+ // String resources that are used in the full-pane Activity Stream that are temporarily
+ // not needed while Activity Stream is part of the HomePager
+ public static final int[] TEMPORARY_UNUSED_ACTIVITY_STREAM = {
+ R.string.activity_stream_topsites
+ };
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java b/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java
new file mode 100644
index 000000000..180e821e7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/util/ViewUtil.java
@@ -0,0 +1,33 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.util;
+
+import android.content.res.TypedArray;
+import android.view.View;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+
+public class ViewUtil {
+
+ /**
+ * Enable a circular touch ripple for a given view. This is intended for borderless views,
+ * such as (3-dot) menu buttons.
+ *
+ * Because of platform limitations a square ripple is used on Android 4.
+ */
+ public static void enableTouchRipple(View view) {
+ final TypedArray backgroundDrawableArray;
+ if (AppConstants.Versions.feature21Plus) {
+ backgroundDrawableArray = view.getContext().obtainStyledAttributes(new int[] { R.attr.selectableItemBackgroundBorderless });
+ } else {
+ backgroundDrawableArray = view.getContext().obtainStyledAttributes(new int[] { R.attr.selectableItemBackground });
+ }
+
+ // This call is deprecated, but the replacement setBackground(Drawable) isn't available
+ // until API 16.
+ view.setBackgroundDrawable(backgroundDrawableArray.getDrawable(0));
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java b/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java
new file mode 100644
index 000000000..8cde1ee05
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ActivityChooserModel.java
@@ -0,0 +1,1359 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Mozilla: Changing the package.
+ */
+//package android.widget;
+package org.mozilla.gecko.widget;
+
+// Mozilla: New import
+import android.accounts.Account;
+import android.content.pm.PackageManager;
+
+import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.db.TabsAccessor;
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.SyncStatusListener;
+import org.mozilla.gecko.overlays.ui.ShareDialog;
+import org.mozilla.gecko.R;
+import java.io.File;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ResolveInfo;
+import android.database.Cursor;
+import android.database.DataSetObservable;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Xml;
+
+/**
+ * Mozilla: Unused import.
+ */
+//import com.android.internal.content.PackageMonitor;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * <p>
+ * This class represents a data model for choosing a component for handing a
+ * given {@link Intent}. The model is responsible for querying the system for
+ * activities that can handle the given intent and order found activities
+ * based on historical data of previous choices. The historical data is stored
+ * in an application private file. If a client does not want to have persistent
+ * choice history the file can be omitted, thus the activities will be ordered
+ * based on historical usage for the current session.
+ * <p>
+ * </p>
+ * For each backing history file there is a singleton instance of this class. Thus,
+ * several clients that specify the same history file will share the same model. Note
+ * that if multiple clients are sharing the same model they should implement semantically
+ * equivalent functionality since setting the model intent will change the found
+ * activities and they may be inconsistent with the functionality of some of the clients.
+ * For example, choosing a share activity can be implemented by a single backing
+ * model and two different views for performing the selection. If however, one of the
+ * views is used for sharing but the other for importing, for example, then each
+ * view should be backed by a separate model.
+ * </p>
+ * <p>
+ * The way clients interact with this class is as follows:
+ * </p>
+ * <p>
+ * <pre>
+ * <code>
+ * // Get a model and set it to a couple of clients with semantically similar function.
+ * ActivityChooserModel dataModel =
+ * ActivityChooserModel.get(context, "task_specific_history_file_name.xml");
+ *
+ * ActivityChooserModelClient modelClient1 = getActivityChooserModelClient1();
+ * modelClient1.setActivityChooserModel(dataModel);
+ *
+ * ActivityChooserModelClient modelClient2 = getActivityChooserModelClient2();
+ * modelClient2.setActivityChooserModel(dataModel);
+ *
+ * // Set an intent to choose a an activity for.
+ * dataModel.setIntent(intent);
+ * <pre>
+ * <code>
+ * </p>
+ * <p>
+ * <strong>Note:</strong> This class is thread safe.
+ * </p>
+ *
+ * @hide
+ */
+public class ActivityChooserModel extends DataSetObservable {
+
+ /**
+ * Client that utilizes an {@link ActivityChooserModel}.
+ */
+ public interface ActivityChooserModelClient {
+
+ /**
+ * Sets the {@link ActivityChooserModel}.
+ *
+ * @param dataModel The model.
+ */
+ public void setActivityChooserModel(ActivityChooserModel dataModel);
+ }
+
+ /**
+ * Defines a sorter that is responsible for sorting the activities
+ * based on the provided historical choices and an intent.
+ */
+ public interface ActivitySorter {
+
+ /**
+ * Sorts the <code>activities</code> in descending order of relevance
+ * based on previous history and an intent.
+ *
+ * @param intent The {@link Intent}.
+ * @param activities Activities to be sorted.
+ * @param historicalRecords Historical records.
+ */
+ // This cannot be done by a simple comparator since an Activity weight
+ // is computed from history. Note that Activity implements Comparable.
+ public void sort(Intent intent, List<ActivityResolveInfo> activities,
+ List<HistoricalRecord> historicalRecords);
+ }
+
+ /**
+ * Listener for choosing an activity.
+ */
+ public interface OnChooseActivityListener {
+
+ /**
+ * Called when an activity has been chosen. The client can decide whether
+ * an activity can be chosen and if so the caller of
+ * {@link ActivityChooserModel#chooseActivity(int)} will receive and {@link Intent}
+ * for launching it.
+ * <p>
+ * <strong>Note:</strong> Modifying the intent is not permitted and
+ * any changes to the latter will be ignored.
+ * </p>
+ *
+ * @param host The listener's host model.
+ * @param intent The intent for launching the chosen activity.
+ * @return Whether the intent is handled and should not be delivered to clients.
+ *
+ * @see ActivityChooserModel#chooseActivity(int)
+ */
+ public boolean onChooseActivity(ActivityChooserModel host, Intent intent);
+ }
+
+ /**
+ * Flag for selecting debug mode.
+ */
+ private static final boolean DEBUG = false;
+
+ /**
+ * Tag used for logging.
+ */
+ static final String LOG_TAG = ActivityChooserModel.class.getSimpleName();
+
+ /**
+ * The root tag in the history file.
+ */
+ private static final String TAG_HISTORICAL_RECORDS = "historical-records";
+
+ /**
+ * The tag for a record in the history file.
+ */
+ private static final String TAG_HISTORICAL_RECORD = "historical-record";
+
+ /**
+ * Attribute for the activity.
+ */
+ private static final String ATTRIBUTE_ACTIVITY = "activity";
+
+ /**
+ * Attribute for the choice time.
+ */
+ private static final String ATTRIBUTE_TIME = "time";
+
+ /**
+ * Attribute for the choice weight.
+ */
+ private static final String ATTRIBUTE_WEIGHT = "weight";
+
+ /**
+ * The default maximal length of the choice history.
+ */
+ public static final int DEFAULT_HISTORY_MAX_LENGTH = 50;
+
+ /**
+ * The amount with which to inflate a chosen activity when set as default.
+ */
+ private static final int DEFAULT_ACTIVITY_INFLATION = 5;
+
+ /**
+ * Default weight for a choice record.
+ */
+ private static final float DEFAULT_HISTORICAL_RECORD_WEIGHT = 1.0f;
+
+ /**
+ * The extension of the history file.
+ */
+ private static final String HISTORY_FILE_EXTENSION = ".xml";
+
+ /**
+ * An invalid item index.
+ */
+ private static final int INVALID_INDEX = -1;
+
+ /**
+ * Lock to guard the model registry.
+ */
+ private static final Object sRegistryLock = new Object();
+
+ /**
+ * This the registry for data models.
+ */
+ private static final Map<String, ActivityChooserModel> sDataModelRegistry =
+ new HashMap<String, ActivityChooserModel>();
+
+ /**
+ * Lock for synchronizing on this instance.
+ */
+ private final Object mInstanceLock = new Object();
+
+ /**
+ * List of activities that can handle the current intent.
+ */
+ private final List<ActivityResolveInfo> mActivities = new ArrayList<ActivityResolveInfo>();
+
+ /**
+ * List with historical choice records.
+ */
+ private final List<HistoricalRecord> mHistoricalRecords = new ArrayList<HistoricalRecord>();
+
+ /**
+ * Monitor for added and removed packages.
+ */
+ /**
+ * Mozilla: Converted from a PackageMonitor to a DataModelPackageMonitor to avoid importing a new class.
+ */
+ private final DataModelPackageMonitor mPackageMonitor = new DataModelPackageMonitor();
+
+ /**
+ * Context for accessing resources.
+ */
+ final Context mContext;
+
+ /**
+ * The name of the history file that backs this model.
+ */
+ final String mHistoryFileName;
+
+ /**
+ * The intent for which a activity is being chosen.
+ */
+ private Intent mIntent;
+
+ /**
+ * The sorter for ordering activities based on intent and past choices.
+ */
+ private ActivitySorter mActivitySorter = new DefaultSorter();
+
+ /**
+ * The maximal length of the choice history.
+ */
+ private int mHistoryMaxSize = DEFAULT_HISTORY_MAX_LENGTH;
+
+ /**
+ * Flag whether choice history can be read. In general many clients can
+ * share the same data model and {@link #readHistoricalDataIfNeeded()} may be called
+ * by arbitrary of them any number of times. Therefore, this class guarantees
+ * that the very first read succeeds and subsequent reads can be performed
+ * only after a call to {@link #persistHistoricalDataIfNeeded()} followed by change
+ * of the share records.
+ */
+ boolean mCanReadHistoricalData = true;
+
+ /**
+ * Flag whether the choice history was read. This is used to enforce that
+ * before calling {@link #persistHistoricalDataIfNeeded()} a call to
+ * {@link #persistHistoricalDataIfNeeded()} has been made. This aims to avoid a
+ * scenario in which a choice history file exits, it is not read yet and
+ * it is overwritten. Note that always all historical records are read in
+ * full and the file is rewritten. This is necessary since we need to
+ * purge old records that are outside of the sliding window of past choices.
+ */
+ private boolean mReadShareHistoryCalled;
+
+ /**
+ * Flag whether the choice records have changed. In general many clients can
+ * share the same data model and {@link #persistHistoricalDataIfNeeded()} may be called
+ * by arbitrary of them any number of times. Therefore, this class guarantees
+ * that choice history will be persisted only if it has changed.
+ */
+ private boolean mHistoricalRecordsChanged = true;
+
+ /**
+ * Flag whether to reload the activities for the current intent.
+ */
+ boolean mReloadActivities;
+
+ /**
+ * Policy for controlling how the model handles chosen activities.
+ */
+ private OnChooseActivityListener mActivityChooserModelPolicy;
+
+ /**
+ * Mozilla: Share overlay variables.
+ */
+ private final SyncStatusListener mSyncStatusListener = new SyncStatusDelegate();
+
+ /**
+ * Gets the data model backed by the contents of the provided file with historical data.
+ * Note that only one data model is backed by a given file, thus multiple calls with
+ * the same file name will return the same model instance. If no such instance is present
+ * it is created.
+ *
+ * <p>
+ * <strong>Always use difference historical data files for semantically different actions.
+ * For example, sharing is different from importing.</strong>
+ * </p>
+ *
+ * @param context Context for loading resources.
+ * @param historyFileName File name with choice history, <code>null</code>
+ * if the model should not be backed by a file. In this case the activities
+ * will be ordered only by data from the current session.
+ *
+ * @return The model.
+ */
+ public static ActivityChooserModel get(Context context, String historyFileName) {
+ synchronized (sRegistryLock) {
+ ActivityChooserModel dataModel = sDataModelRegistry.get(historyFileName);
+ if (dataModel == null) {
+ dataModel = new ActivityChooserModel(context, historyFileName);
+ sDataModelRegistry.put(historyFileName, dataModel);
+ }
+ return dataModel;
+ }
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param context Context for loading resources.
+ * @param historyFileName The history XML file.
+ */
+ private ActivityChooserModel(Context context, String historyFileName) {
+ mContext = context.getApplicationContext();
+ if (!TextUtils.isEmpty(historyFileName)
+ && !historyFileName.endsWith(HISTORY_FILE_EXTENSION)) {
+ mHistoryFileName = historyFileName + HISTORY_FILE_EXTENSION;
+ } else {
+ mHistoryFileName = historyFileName;
+ }
+
+ /**
+ * Mozilla: Uses modified receiver
+ */
+ mPackageMonitor.register(mContext);
+
+ /**
+ * Mozilla: Add Sync Status Listener.
+ */
+ // TODO: We only need to add a sync status listener if the ShareDialog passes the intent filter.
+ FirefoxAccounts.addSyncStatusListener(mSyncStatusListener);
+ }
+
+ /**
+ * Sets an intent for which to choose a activity.
+ * <p>
+ * <strong>Note:</strong> Clients must set only semantically similar
+ * intents for each data model.
+ * <p>
+ *
+ * @param intent The intent.
+ */
+ public void setIntent(Intent intent) {
+ synchronized (mInstanceLock) {
+ if (mIntent == intent) {
+ return;
+ }
+ mIntent = intent;
+ mReloadActivities = true;
+ ensureConsistentState();
+ }
+ }
+
+ /**
+ * Gets the intent for which a activity is being chosen.
+ *
+ * @return The intent.
+ */
+ public Intent getIntent() {
+ synchronized (mInstanceLock) {
+ return mIntent;
+ }
+ }
+
+ /**
+ * Gets the number of activities that can handle the intent.
+ *
+ * @return The activity count.
+ *
+ * @see #setIntent(Intent)
+ */
+ public int getActivityCount() {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ return mActivities.size();
+ }
+ }
+
+ /**
+ * Gets an activity at a given index.
+ *
+ * @return The activity.
+ *
+ * @see ActivityResolveInfo
+ * @see #setIntent(Intent)
+ */
+ public ResolveInfo getActivity(int index) {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ return mActivities.get(index).resolveInfo;
+ }
+ }
+
+ /**
+ * Gets the index of a the given activity.
+ *
+ * @param activity The activity index.
+ *
+ * @return The index if found, -1 otherwise.
+ */
+ public int getActivityIndex(ResolveInfo activity) {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ List<ActivityResolveInfo> activities = mActivities;
+ final int activityCount = activities.size();
+ for (int i = 0; i < activityCount; i++) {
+ ActivityResolveInfo currentActivity = activities.get(i);
+ if (currentActivity.resolveInfo == activity) {
+ return i;
+ }
+ }
+ return INVALID_INDEX;
+ }
+ }
+
+ /**
+ * Chooses a activity to handle the current intent. This will result in
+ * adding a historical record for that action and construct intent with
+ * its component name set such that it can be immediately started by the
+ * client.
+ * <p>
+ * <strong>Note:</strong> By calling this method the client guarantees
+ * that the returned intent will be started. This intent is returned to
+ * the client solely to let additional customization before the start.
+ * </p>
+ *
+ * @return An {@link Intent} for launching the activity or null if the
+ * policy has consumed the intent or there is not current intent
+ * set via {@link #setIntent(Intent)}.
+ *
+ * @see HistoricalRecord
+ * @see OnChooseActivityListener
+ */
+ public Intent chooseActivity(int index) {
+ synchronized (mInstanceLock) {
+ if (mIntent == null) {
+ return null;
+ }
+
+ ensureConsistentState();
+
+ ActivityResolveInfo chosenActivity = mActivities.get(index);
+
+ ComponentName chosenName = new ComponentName(
+ chosenActivity.resolveInfo.activityInfo.packageName,
+ chosenActivity.resolveInfo.activityInfo.name);
+
+ Intent choiceIntent = new Intent(mIntent);
+ choiceIntent.setComponent(chosenName);
+
+ if (mActivityChooserModelPolicy != null) {
+ // Do not allow the policy to change the intent.
+ Intent choiceIntentCopy = new Intent(choiceIntent);
+ final boolean handled = mActivityChooserModelPolicy.onChooseActivity(this,
+ choiceIntentCopy);
+ if (handled) {
+ return null;
+ }
+ }
+
+ HistoricalRecord historicalRecord = new HistoricalRecord(chosenName,
+ System.currentTimeMillis(), DEFAULT_HISTORICAL_RECORD_WEIGHT);
+ addHistoricalRecord(historicalRecord);
+
+ return choiceIntent;
+ }
+ }
+
+ /**
+ * Sets the listener for choosing an activity.
+ *
+ * @param listener The listener.
+ */
+ public void setOnChooseActivityListener(OnChooseActivityListener listener) {
+ synchronized (mInstanceLock) {
+ mActivityChooserModelPolicy = listener;
+ }
+ }
+
+ /**
+ * Gets the default activity, The default activity is defined as the one
+ * with highest rank i.e. the first one in the list of activities that can
+ * handle the intent.
+ *
+ * @return The default activity, <code>null</code> id not activities.
+ *
+ * @see #getActivity(int)
+ */
+ public ResolveInfo getDefaultActivity() {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ if (!mActivities.isEmpty()) {
+ return mActivities.get(0).resolveInfo;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Sets the default activity. The default activity is set by adding a
+ * historical record with weight high enough that this activity will
+ * become the highest ranked. Such a strategy guarantees that the default
+ * will eventually change if not used. Also the weight of the record for
+ * setting a default is inflated with a constant amount to guarantee that
+ * it will stay as default for awhile.
+ *
+ * @param index The index of the activity to set as default.
+ */
+ public void setDefaultActivity(int index) {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+
+ ActivityResolveInfo newDefaultActivity = mActivities.get(index);
+ ActivityResolveInfo oldDefaultActivity = mActivities.get(0);
+
+ final float weight;
+ if (oldDefaultActivity != null) {
+ // Add a record with weight enough to boost the chosen at the top.
+ weight = oldDefaultActivity.weight - newDefaultActivity.weight
+ + DEFAULT_ACTIVITY_INFLATION;
+ } else {
+ weight = DEFAULT_HISTORICAL_RECORD_WEIGHT;
+ }
+
+ ComponentName defaultName = new ComponentName(
+ newDefaultActivity.resolveInfo.activityInfo.packageName,
+ newDefaultActivity.resolveInfo.activityInfo.name);
+ HistoricalRecord historicalRecord = new HistoricalRecord(defaultName,
+ System.currentTimeMillis(), weight);
+ addHistoricalRecord(historicalRecord);
+ }
+ }
+
+ /**
+ * Persists the history data to the backing file if the latter
+ * was provided. Calling this method before a call to {@link #readHistoricalDataIfNeeded()}
+ * throws an exception. Calling this method more than one without choosing an
+ * activity has not effect.
+ *
+ * @throws IllegalStateException If this method is called before a call to
+ * {@link #readHistoricalDataIfNeeded()}.
+ */
+ private void persistHistoricalDataIfNeeded() {
+ if (!mReadShareHistoryCalled) {
+ throw new IllegalStateException("No preceding call to #readHistoricalData");
+ }
+ if (!mHistoricalRecordsChanged) {
+ return;
+ }
+ mHistoricalRecordsChanged = false;
+ if (!TextUtils.isEmpty(mHistoryFileName)) {
+ /**
+ * Mozilla: Converted to a normal task.execute call so that this works on < ICS phones.
+ */
+ new PersistHistoryAsyncTask().execute(new ArrayList<HistoricalRecord>(mHistoricalRecords), mHistoryFileName);
+ }
+ }
+
+ /**
+ * Sets the sorter for ordering activities based on historical data and an intent.
+ *
+ * @param activitySorter The sorter.
+ *
+ * @see ActivitySorter
+ */
+ public void setActivitySorter(ActivitySorter activitySorter) {
+ synchronized (mInstanceLock) {
+ if (mActivitySorter == activitySorter) {
+ return;
+ }
+ mActivitySorter = activitySorter;
+ if (sortActivitiesIfNeeded()) {
+ notifyChanged();
+ }
+ }
+ }
+
+ /**
+ * Sets the maximal size of the historical data. Defaults to
+ * {@link #DEFAULT_HISTORY_MAX_LENGTH}
+ * <p>
+ * <strong>Note:</strong> Setting this property will immediately
+ * enforce the specified max history size by dropping enough old
+ * historical records to enforce the desired size. Thus, any
+ * records that exceed the history size will be discarded and
+ * irreversibly lost.
+ * </p>
+ *
+ * @param historyMaxSize The max history size.
+ */
+ public void setHistoryMaxSize(int historyMaxSize) {
+ synchronized (mInstanceLock) {
+ if (mHistoryMaxSize == historyMaxSize) {
+ return;
+ }
+ mHistoryMaxSize = historyMaxSize;
+ pruneExcessiveHistoricalRecordsIfNeeded();
+ if (sortActivitiesIfNeeded()) {
+ notifyChanged();
+ }
+ }
+ }
+
+ /**
+ * Gets the history max size.
+ *
+ * @return The history max size.
+ */
+ public int getHistoryMaxSize() {
+ synchronized (mInstanceLock) {
+ return mHistoryMaxSize;
+ }
+ }
+
+ /**
+ * Gets the history size.
+ *
+ * @return The history size.
+ */
+ public int getHistorySize() {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ return mHistoricalRecords.size();
+ }
+ }
+
+ public int getDistinctActivityCountInHistory() {
+ synchronized (mInstanceLock) {
+ ensureConsistentState();
+ final List<String> packages = new ArrayList<String>();
+ for (HistoricalRecord record : mHistoricalRecords) {
+ String activity = record.activity.flattenToString();
+ if (!packages.contains(activity)) {
+ packages.add(activity);
+ }
+ }
+ return packages.size();
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ super.finalize();
+
+ /**
+ * Mozilla: Not needed for the application.
+ */
+ mPackageMonitor.unregister();
+ FirefoxAccounts.removeSyncStatusListener(mSyncStatusListener);
+ }
+
+ /**
+ * Ensures the model is in a consistent state which is the
+ * activities for the current intent have been loaded, the
+ * most recent history has been read, and the activities
+ * are sorted.
+ */
+ private void ensureConsistentState() {
+ boolean stateChanged = loadActivitiesIfNeeded();
+ stateChanged |= readHistoricalDataIfNeeded();
+ pruneExcessiveHistoricalRecordsIfNeeded();
+ if (stateChanged) {
+ sortActivitiesIfNeeded();
+ notifyChanged();
+ }
+ }
+
+ /**
+ * Sorts the activities if necessary which is if there is a
+ * sorter, there are some activities to sort, and there is some
+ * historical data.
+ *
+ * @return Whether sorting was performed.
+ */
+ private boolean sortActivitiesIfNeeded() {
+ if (mActivitySorter != null && mIntent != null
+ && !mActivities.isEmpty() && !mHistoricalRecords.isEmpty()) {
+ mActivitySorter.sort(mIntent, mActivities,
+ Collections.unmodifiableList(mHistoricalRecords));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Loads the activities for the current intent if needed which is
+ * if they are not already loaded for the current intent.
+ *
+ * @return Whether loading was performed.
+ */
+ private boolean loadActivitiesIfNeeded() {
+ if (mReloadActivities && mIntent != null) {
+ mReloadActivities = false;
+ mActivities.clear();
+ List<ResolveInfo> resolveInfos = mContext.getPackageManager()
+ .queryIntentActivities(mIntent, 0);
+ final int resolveInfoCount = resolveInfos.size();
+
+ /**
+ * Mozilla: Temporary variables to prevent performance degradation in the loop.
+ */
+ final PackageManager packageManager = mContext.getPackageManager();
+ final String channelToRemoveLabel = mContext.getResources().getString(R.string.overlay_share_label);
+ final String shareDialogClassName = ShareDialog.class.getCanonicalName();
+
+ for (int i = 0; i < resolveInfoCount; i++) {
+ ResolveInfo resolveInfo = resolveInfos.get(i);
+
+ /**
+ * Mozilla: We want "Add to Firefox" to appear differently inside of Firefox than
+ * from external applications - override the name and icon here.
+ *
+ * Do not display the menu item if there are no devices to share to.
+ *
+ * Note: we check both the class name and the label to ensure we only change the
+ * label of the current channel.
+ */
+ if (shareDialogClassName.equals(resolveInfo.activityInfo.name) &&
+ channelToRemoveLabel.equals(resolveInfo.loadLabel(packageManager))) {
+ // Don't add the menu item if there are no devices to share to.
+ if (!hasOtherSyncClients()) {
+ continue;
+ }
+
+ resolveInfo.labelRes = R.string.overlay_share_send_other;
+ resolveInfo.icon = R.drawable.icon_shareplane;
+ }
+
+ mActivities.add(new ActivityResolveInfo(resolveInfo));
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Reads the historical data if necessary which is it has
+ * changed, there is a history file, and there is not persist
+ * in progress.
+ *
+ * @return Whether reading was performed.
+ */
+ private boolean readHistoricalDataIfNeeded() {
+ if (mCanReadHistoricalData && mHistoricalRecordsChanged &&
+ !TextUtils.isEmpty(mHistoryFileName)) {
+ mCanReadHistoricalData = false;
+ mReadShareHistoryCalled = true;
+ readHistoricalDataImpl();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Adds a historical record.
+ *
+ * @param historicalRecord The record to add.
+ * @return True if the record was added.
+ */
+ private boolean addHistoricalRecord(HistoricalRecord historicalRecord) {
+ final boolean added = mHistoricalRecords.add(historicalRecord);
+ if (added) {
+ mHistoricalRecordsChanged = true;
+ pruneExcessiveHistoricalRecordsIfNeeded();
+ persistHistoricalDataIfNeeded();
+ sortActivitiesIfNeeded();
+ notifyChanged();
+ }
+ return added;
+ }
+
+ /**
+ * Removes all historical records for this pkg.
+ *
+ * @param historicalRecord The pkg to delete records for.
+ * @return True if the record was added.
+ */
+ boolean removeHistoricalRecordsForPackage(final String pkg) {
+ boolean removed = false;
+
+ for (Iterator<HistoricalRecord> i = mHistoricalRecords.iterator(); i.hasNext();) {
+ final HistoricalRecord record = i.next();
+ if (record.activity.getPackageName().equals(pkg)) {
+ i.remove();
+ removed = true;
+ }
+ }
+
+ if (removed) {
+ mHistoricalRecordsChanged = true;
+ pruneExcessiveHistoricalRecordsIfNeeded();
+ persistHistoricalDataIfNeeded();
+ sortActivitiesIfNeeded();
+ notifyChanged();
+ }
+
+ return removed;
+ }
+
+ /**
+ * Prunes older excessive records to guarantee maxHistorySize.
+ */
+ private void pruneExcessiveHistoricalRecordsIfNeeded() {
+ final int pruneCount = mHistoricalRecords.size() - mHistoryMaxSize;
+ if (pruneCount <= 0) {
+ return;
+ }
+ mHistoricalRecordsChanged = true;
+ for (int i = 0; i < pruneCount; i++) {
+ HistoricalRecord prunedRecord = mHistoricalRecords.remove(0);
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Pruned: " + prunedRecord);
+ }
+ }
+ }
+
+ /**
+ * Represents a record in the history.
+ */
+ public final static class HistoricalRecord {
+
+ /**
+ * The activity name.
+ */
+ public final ComponentName activity;
+
+ /**
+ * The choice time.
+ */
+ public final long time;
+
+ /**
+ * The record weight.
+ */
+ public final float weight;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param activityName The activity component name flattened to string.
+ * @param time The time the activity was chosen.
+ * @param weight The weight of the record.
+ */
+ public HistoricalRecord(String activityName, long time, float weight) {
+ this(ComponentName.unflattenFromString(activityName), time, weight);
+ }
+
+ /**
+ * Creates a new instance.
+ *
+ * @param activityName The activity name.
+ * @param time The time the activity was chosen.
+ * @param weight The weight of the record.
+ */
+ public HistoricalRecord(ComponentName activityName, long time, float weight) {
+ this.activity = activityName;
+ this.time = time;
+ this.weight = weight;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((activity == null) ? 0 : activity.hashCode());
+ result = prime * result + (int) (time ^ (time >>> 32));
+ result = prime * result + Float.floatToIntBits(weight);
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ HistoricalRecord other = (HistoricalRecord) obj;
+ if (activity == null) {
+ if (other.activity != null) {
+ return false;
+ }
+ } else if (!activity.equals(other.activity)) {
+ return false;
+ }
+ if (time != other.time) {
+ return false;
+ }
+ if (Float.floatToIntBits(weight) != Float.floatToIntBits(other.weight)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("[");
+ builder.append("; activity:").append(activity);
+ builder.append("; time:").append(time);
+ builder.append("; weight:").append(new BigDecimal(weight));
+ builder.append("]");
+ return builder.toString();
+ }
+ }
+
+ /**
+ * Represents an activity.
+ */
+ public final class ActivityResolveInfo implements Comparable<ActivityResolveInfo> {
+
+ /**
+ * The {@link ResolveInfo} of the activity.
+ */
+ public final ResolveInfo resolveInfo;
+
+ /**
+ * Weight of the activity. Useful for sorting.
+ */
+ public float weight;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param resolveInfo activity {@link ResolveInfo}.
+ */
+ public ActivityResolveInfo(ResolveInfo resolveInfo) {
+ this.resolveInfo = resolveInfo;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 + Float.floatToIntBits(weight);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ ActivityResolveInfo other = (ActivityResolveInfo) obj;
+ if (Float.floatToIntBits(weight) != Float.floatToIntBits(other.weight)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int compareTo(ActivityResolveInfo another) {
+ return Float.floatToIntBits(another.weight) - Float.floatToIntBits(weight);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("[");
+ builder.append("resolveInfo:").append(resolveInfo.toString());
+ builder.append("; weight:").append(new BigDecimal(weight));
+ builder.append("]");
+ return builder.toString();
+ }
+ }
+
+ /**
+ * Default activity sorter implementation.
+ */
+ private final class DefaultSorter implements ActivitySorter {
+ private static final float WEIGHT_DECAY_COEFFICIENT = 0.95f;
+
+ private final Map<String, ActivityResolveInfo> mPackageNameToActivityMap =
+ new HashMap<String, ActivityResolveInfo>();
+
+ @Override
+ public void sort(Intent intent, List<ActivityResolveInfo> activities,
+ List<HistoricalRecord> historicalRecords) {
+ Map<String, ActivityResolveInfo> packageNameToActivityMap =
+ mPackageNameToActivityMap;
+ packageNameToActivityMap.clear();
+
+ final int activityCount = activities.size();
+ for (int i = 0; i < activityCount; i++) {
+ ActivityResolveInfo activity = activities.get(i);
+ activity.weight = 0.0f;
+
+ // Make sure we're using a non-ambiguous name here
+ ComponentName chosenName = new ComponentName(
+ activity.resolveInfo.activityInfo.packageName,
+ activity.resolveInfo.activityInfo.name);
+ String packageName = chosenName.flattenToString();
+ packageNameToActivityMap.put(packageName, activity);
+ }
+
+ final int lastShareIndex = historicalRecords.size() - 1;
+ float nextRecordWeight = 1;
+ for (int i = lastShareIndex; i >= 0; i--) {
+ HistoricalRecord historicalRecord = historicalRecords.get(i);
+ String packageName = historicalRecord.activity.flattenToString();
+ ActivityResolveInfo activity = packageNameToActivityMap.get(packageName);
+ if (activity != null) {
+ activity.weight += historicalRecord.weight * nextRecordWeight;
+ nextRecordWeight = nextRecordWeight * WEIGHT_DECAY_COEFFICIENT;
+ }
+ }
+
+ Collections.sort(activities);
+
+ if (DEBUG) {
+ for (int i = 0; i < activityCount; i++) {
+ Log.i(LOG_TAG, "Sorted: " + activities.get(i));
+ }
+ }
+ }
+ }
+
+ /**
+ * Command for reading the historical records from a file off the UI thread.
+ */
+ private void readHistoricalDataImpl() {
+ try {
+ GeckoProfile profile = GeckoProfile.get(mContext);
+ File f = profile.getFile(mHistoryFileName);
+ if (!f.exists()) {
+ // Fall back to the non-profile aware file if it exists...
+ File oldFile = new File(mHistoryFileName);
+ oldFile.renameTo(f);
+ }
+ readHistoricalDataFromStream(new FileInputStream(f));
+ } catch (FileNotFoundException fnfe) {
+ final Distribution dist = Distribution.getInstance(mContext);
+ dist.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+ @Override
+ public void distributionNotFound() {
+ }
+
+ @Override
+ public void distributionFound(Distribution distribution) {
+ try {
+ File distFile = dist.getDistributionFile("quickshare/" + mHistoryFileName);
+ if (distFile == null) {
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName);
+ }
+ return;
+ }
+ readHistoricalDataFromStream(new FileInputStream(distFile));
+ } catch (Exception ex) {
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName);
+ }
+ return;
+ }
+ }
+
+ @Override
+ public void distributionArrivedLate(Distribution distribution) {
+ distributionFound(distribution);
+ }
+ });
+ }
+ }
+
+ void readHistoricalDataFromStream(FileInputStream fis) {
+ try {
+ XmlPullParser parser = Xml.newPullParser();
+ parser.setInput(fis, null);
+
+ int type = XmlPullParser.START_DOCUMENT;
+ while (type != XmlPullParser.END_DOCUMENT && type != XmlPullParser.START_TAG) {
+ type = parser.next();
+ }
+
+ if (!TAG_HISTORICAL_RECORDS.equals(parser.getName())) {
+ throw new XmlPullParserException("Share records file does not start with "
+ + TAG_HISTORICAL_RECORDS + " tag.");
+ }
+
+ List<HistoricalRecord> historicalRecords = mHistoricalRecords;
+ historicalRecords.clear();
+
+ while (true) {
+ type = parser.next();
+ if (type == XmlPullParser.END_DOCUMENT) {
+ break;
+ }
+ if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) {
+ continue;
+ }
+ String nodeName = parser.getName();
+ if (!TAG_HISTORICAL_RECORD.equals(nodeName)) {
+ throw new XmlPullParserException("Share records file not well-formed.");
+ }
+
+ String activity = parser.getAttributeValue(null, ATTRIBUTE_ACTIVITY);
+ final long time =
+ Long.parseLong(parser.getAttributeValue(null, ATTRIBUTE_TIME));
+ final float weight =
+ Float.parseFloat(parser.getAttributeValue(null, ATTRIBUTE_WEIGHT));
+ HistoricalRecord readRecord = new HistoricalRecord(activity, time, weight);
+ historicalRecords.add(readRecord);
+
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Read " + readRecord.toString());
+ }
+ }
+
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Read " + historicalRecords.size() + " historical records.");
+ }
+ } catch (XmlPullParserException | IOException xppe) {
+ Log.e(LOG_TAG, "Error reading historical record file: " + mHistoryFileName, xppe);
+ } finally {
+ if (fis != null) {
+ try {
+ fis.close();
+ } catch (IOException ioe) {
+ /* ignore */
+ }
+ }
+ }
+ }
+
+ /**
+ * Command for persisting the historical records to a file off the UI thread.
+ */
+ private final class PersistHistoryAsyncTask extends AsyncTask<Object, Void, Void> {
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Void doInBackground(Object... args) {
+ List<HistoricalRecord> historicalRecords = (List<HistoricalRecord>) args[0];
+ String historyFileName = (String) args[1];
+
+ FileOutputStream fos = null;
+
+ try {
+ // Mozilla - Update the location we save files to
+ GeckoProfile profile = GeckoProfile.get(mContext);
+ File file = profile.getFile(historyFileName);
+ fos = new FileOutputStream(file);
+ } catch (FileNotFoundException fnfe) {
+ Log.e(LOG_TAG, "Error writing historical record file: " + historyFileName, fnfe);
+ return null;
+ }
+
+ XmlSerializer serializer = Xml.newSerializer();
+
+ try {
+ serializer.setOutput(fos, null);
+ serializer.startDocument("UTF-8", true);
+ serializer.startTag(null, TAG_HISTORICAL_RECORDS);
+
+ final int recordCount = historicalRecords.size();
+ for (int i = 0; i < recordCount; i++) {
+ HistoricalRecord record = historicalRecords.remove(0);
+ serializer.startTag(null, TAG_HISTORICAL_RECORD);
+ serializer.attribute(null, ATTRIBUTE_ACTIVITY,
+ record.activity.flattenToString());
+ serializer.attribute(null, ATTRIBUTE_TIME, String.valueOf(record.time));
+ serializer.attribute(null, ATTRIBUTE_WEIGHT, String.valueOf(record.weight));
+ serializer.endTag(null, TAG_HISTORICAL_RECORD);
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Wrote " + record.toString());
+ }
+ }
+
+ serializer.endTag(null, TAG_HISTORICAL_RECORDS);
+ serializer.endDocument();
+
+ if (DEBUG) {
+ Log.i(LOG_TAG, "Wrote " + recordCount + " historical records.");
+ }
+ } catch (IllegalArgumentException | IOException | IllegalStateException e) {
+ Log.e(LOG_TAG, "Error writing historical record file: " + mHistoryFileName, e);
+ } finally {
+ mCanReadHistoricalData = true;
+ if (fos != null) {
+ try {
+ fos.close();
+ } catch (IOException e) {
+ /* ignore */
+ }
+ }
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Keeps in sync the historical records and activities with the installed applications.
+ */
+ /**
+ * Mozilla: Adapted significantly
+ */
+ private static final String LOGTAG = "GeckoActivityChooserModel";
+ private final class DataModelPackageMonitor extends BroadcastReceiver {
+ Context mContext;
+
+ public DataModelPackageMonitor() { }
+
+ public void register(Context context) {
+ mContext = context;
+
+ String[] intents = new String[] {
+ Intent.ACTION_PACKAGE_REMOVED,
+ Intent.ACTION_PACKAGE_ADDED,
+ Intent.ACTION_PACKAGE_CHANGED
+ };
+
+ for (String intent : intents) {
+ IntentFilter removeFilter = new IntentFilter(intent);
+ removeFilter.addDataScheme("package");
+ context.registerReceiver(this, removeFilter);
+ }
+ }
+
+ public void unregister() {
+ mContext.unregisterReceiver(this);
+ mContext = null;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) {
+ String packageName = intent.getData().getSchemeSpecificPart();
+ removeHistoricalRecordsForPackage(packageName);
+ }
+
+ mReloadActivities = true;
+ }
+ }
+
+ /**
+ * Mozilla: Return whether or not there are other synced clients.
+ */
+ private boolean hasOtherSyncClients() {
+ // ClientsDatabaseAccessor returns stale data (bug 1145896) so we work around this by
+ // checking if we have accounts set up - if not, we can't have any clients.
+ if (!FirefoxAccounts.firefoxAccountsExist(mContext)) {
+ return false;
+ }
+
+ final BrowserDB browserDB = BrowserDB.from(mContext);
+ final TabsAccessor tabsAccessor = browserDB.getTabsAccessor();
+ final Cursor remoteClientsCursor = tabsAccessor
+ .getRemoteClientsByRecencyCursor(mContext);
+ if (remoteClientsCursor == null) {
+ return false;
+ }
+
+ try {
+ return remoteClientsCursor.getCount() > 0;
+ } finally {
+ remoteClientsCursor.close();
+ }
+ }
+
+ /**
+ * Mozilla: Reload activities on sync.
+ */
+ private class SyncStatusDelegate implements SyncStatusListener {
+ @Override
+ public Context getContext() {
+ return mContext;
+ }
+
+ @Override
+ public Account getAccount() {
+ return FirefoxAccounts.getFirefoxAccount(getContext());
+ }
+
+ @Override
+ public void onSyncStarted() {
+ }
+
+ @Override
+ public void onSyncFinished() {
+ // TODO: We only need to reload activities when the number of devices changes.
+ // This may not be worth it if we have to touch the DB to get the client count.
+ synchronized (mInstanceLock) {
+ mReloadActivities = true;
+ }
+ }
+ }
+}
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java
new file mode 100644
index 000000000..6bd1e36e4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/AllCapsTextView.java
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+public class AllCapsTextView extends TextView {
+
+ public AllCapsTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void setText(CharSequence text, BufferType type) {
+ super.setText(text.toString().toUpperCase(), type);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java b/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java
new file mode 100644
index 000000000..a504c5832
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/AnchoredPopup.java
@@ -0,0 +1,130 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.PopupWindow;
+import org.mozilla.gecko.util.HardwareUtils;
+
+/**
+ * AnchoredPopup is the base class for doorhanger notifications, and is anchored to the urlbar.
+ */
+public abstract class AnchoredPopup extends PopupWindow {
+ public interface OnVisibilityChangeListener {
+ public void onDoorHangerShow();
+ public void onDoorHangerHide();
+ }
+
+ private View mAnchor;
+ private OnVisibilityChangeListener onVisibilityChangeListener;
+
+ protected RoundedCornerLayout mContent;
+ protected boolean mInflated;
+
+ protected final Context mContext;
+
+ public AnchoredPopup(Context context) {
+ super(context);
+
+ mContext = context;
+
+ setAnimationStyle(R.style.PopupAnimation);
+ }
+
+ protected void init() {
+ // Hide the default window background. Passing null prevents the below setOutTouchable()
+ // call from working, so use an empty BitmapDrawable instead.
+ setBackgroundDrawable(new BitmapDrawable(mContext.getResources()));
+
+ // Allow the popup to be dismissed when touching outside.
+ setOutsideTouchable(true);
+
+ // PopupWindow has a default width and height of 0, so set the width here.
+ int width = (int) mContext.getResources().getDimension(R.dimen.doorhanger_width);
+ setWindowLayoutMode(0, ViewGroup.LayoutParams.WRAP_CONTENT);
+ setWidth(width);
+
+ final LayoutInflater inflater = LayoutInflater.from(mContext);
+ final View layout = inflater.inflate(R.layout.anchored_popup, null);
+ setContentView(layout);
+
+ mContent = (RoundedCornerLayout) layout.findViewById(R.id.content);
+
+ mInflated = true;
+ }
+
+ /**
+ * Sets the anchor for this popup.
+ *
+ * @param anchor Anchor view for positioning the arrow.
+ */
+ public void setAnchor(View anchor) {
+ mAnchor = anchor;
+ }
+
+ public void setOnVisibilityChangeListener(OnVisibilityChangeListener listener) {
+ onVisibilityChangeListener = listener;
+ }
+
+ /**
+ * Shows the popup with the arrow pointing to the center of the anchor view. If the anchor
+ * isn't visible, the popup will just be shown at the top of the root view.
+ */
+ public void show() {
+ if (!mInflated) {
+ throw new IllegalStateException("ArrowPopup#init() must be called before ArrowPopup#show()");
+ }
+
+ if (onVisibilityChangeListener != null) {
+ onVisibilityChangeListener.onDoorHangerShow();
+ }
+
+ final int[] anchorLocation = new int[2];
+ if (mAnchor != null) {
+ mAnchor.getLocationInWindow(anchorLocation);
+ }
+
+ // The doorhanger should overlap the bottom of the urlbar.
+ int offsetY = mContext.getResources().getDimensionPixelOffset(R.dimen.doorhanger_offsetY);
+ final View decorView = ((Activity) mContext).getWindow().getDecorView();
+
+ final boolean validAnchor = (mAnchor != null) && (anchorLocation[1] > 0);
+ if (HardwareUtils.isTablet()) {
+ if (validAnchor) {
+ showAsDropDown(mAnchor, 0, 0);
+ } else {
+ // The anchor will be offscreen if the dynamic toolbar is hidden, so anticipate the re-shown position
+ // of the toolbar.
+ final int offsetX = mContext.getResources().getDimensionPixelOffset(R.dimen.doorhanger_offsetX);
+ showAtLocation(decorView, Gravity.TOP | Gravity.LEFT, offsetX, offsetY);
+ }
+ } else {
+ // If the anchor is null or out of the window bounds, just show the popup at the top of the
+ // root view.
+ final View anchor = validAnchor ? mAnchor : decorView;
+
+ showAtLocation(anchor, Gravity.TOP | Gravity.CENTER_HORIZONTAL, 0, offsetY);
+ }
+ }
+
+ @Override
+ public void dismiss() {
+ super.dismiss();
+ if (onVisibilityChangeListener != null) {
+ onVisibilityChangeListener.onDoorHangerHide();
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java
new file mode 100644
index 000000000..f1343b0fb
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedHeightLayout.java
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.animation.HeightChangeAnimation;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.animation.Animation;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.RelativeLayout;
+
+public class AnimatedHeightLayout extends RelativeLayout {
+ private static final String LOGTAG = "GeckoAnimatedHeightLayout";
+ private static final int ANIMATION_DURATION = 100;
+ private boolean mAnimating;
+
+ public AnimatedHeightLayout(Context context) {
+ super(context, null);
+ }
+
+ public AnimatedHeightLayout(Context context, AttributeSet attrs) {
+ super(context, attrs, 0);
+ }
+
+ public AnimatedHeightLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int oldHeight = getMeasuredHeight();
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ int newHeight = getMeasuredHeight();
+
+ if (!mAnimating && oldHeight != 0 && oldHeight != newHeight) {
+ mAnimating = true;
+ setMeasuredDimension(getMeasuredWidth(), oldHeight);
+
+ // Animate the difference of suggestion row height
+ Animation anim = new HeightChangeAnimation(this, oldHeight, newHeight);
+ anim.setDuration(ANIMATION_DURATION);
+ anim.setInterpolator(new DecelerateInterpolator());
+ anim.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {}
+ @Override
+ public void onAnimationRepeat(Animation animation) {}
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ finishAnimation();
+ }
+ });
+ }
+ });
+ startAnimation(anim);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ finishAnimation();
+ }
+
+ void finishAnimation() {
+ if (mAnimating) {
+ getLayoutParams().height = LayoutParams.WRAP_CONTENT;
+ mAnimating = false;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java b/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java
new file mode 100644
index 000000000..4f1468203
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/BasicColorPicker.java
@@ -0,0 +1,140 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.ArrayAdapter;
+import android.widget.AdapterView;
+import android.widget.CheckedTextView;
+import android.widget.ListView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+public class BasicColorPicker extends ListView {
+ private final static String LOGTAG = "GeckoBasicColorPicker";
+ private final static List<Integer> DEFAULT_COLORS = Arrays.asList(Color.rgb(215, 57, 32),
+ Color.rgb(255, 134, 5),
+ Color.rgb(255, 203, 19),
+ Color.rgb(95, 173, 71),
+ Color.rgb(84, 201, 168),
+ Color.rgb(33, 161, 222),
+ Color.rgb(16, 36, 87),
+ Color.rgb(91, 32, 103),
+ Color.rgb(212, 221, 228),
+ Color.BLACK);
+
+ private static Drawable mCheckDrawable;
+ int mSelected;
+ final ColorPickerListAdapter mAdapter;
+
+ public BasicColorPicker(Context context) {
+ this(context, null);
+ }
+
+ public BasicColorPicker(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public BasicColorPicker(Context context, AttributeSet attrs, int style) {
+ this(context, attrs, style, DEFAULT_COLORS);
+ }
+
+ public BasicColorPicker(Context context, AttributeSet attrs, int style, List<Integer> colors) {
+ super(context, attrs, style);
+ mAdapter = new ColorPickerListAdapter(context, new ArrayList<Integer>(colors));
+ setAdapter(mAdapter);
+
+ setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ mSelected = position;
+ mAdapter.notifyDataSetChanged();
+ }
+ });
+ }
+
+ public int getColor() {
+ return mAdapter.getItem(mSelected);
+ }
+
+ public void setColor(int color) {
+ if (!DEFAULT_COLORS.contains(color)) {
+ mSelected = mAdapter.getCount();
+ mAdapter.add(color);
+ } else {
+ mSelected = DEFAULT_COLORS.indexOf(color);
+ }
+
+ setSelection(mSelected);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ Drawable getCheckDrawable() {
+ if (mCheckDrawable == null) {
+ Resources res = getContext().getResources();
+
+ TypedValue typedValue = new TypedValue();
+ getContext().getTheme().resolveAttribute(android.R.attr.listPreferredItemHeight, typedValue, true);
+ DisplayMetrics metrics = new android.util.DisplayMetrics();
+ ((WindowManager)getContext().getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay().getMetrics(metrics);
+ int height = (int) typedValue.getDimension(metrics);
+
+ Drawable background = res.getDrawable(R.drawable.color_picker_row_bg);
+ Rect r = new Rect();
+ background.getPadding(r);
+ height -= r.top + r.bottom;
+
+ mCheckDrawable = res.getDrawable(R.drawable.color_picker_checkmark);
+ mCheckDrawable.setBounds(0, 0, height, height);
+ }
+
+ return mCheckDrawable;
+ }
+
+ private class ColorPickerListAdapter extends ArrayAdapter<Integer> {
+ private final List<Integer> mColors;
+
+ public ColorPickerListAdapter(Context context, List<Integer> colors) {
+ super(context, R.layout.color_picker_row, colors);
+ mColors = colors;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View v = super.getView(position, convertView, parent);
+
+ Drawable d = v.getBackground();
+ d.setColorFilter(getItem(position), PorterDuff.Mode.MULTIPLY);
+ v.setBackgroundDrawable(d);
+
+ Drawable check = null;
+ CheckedTextView checked = ((CheckedTextView) v);
+ if (mSelected == position) {
+ check = getCheckDrawable();
+ }
+
+ checked.setCompoundDrawables(check, null, null, null);
+ checked.setText("");
+
+ return v;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java
new file mode 100644
index 000000000..b740592fe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/CheckableLinearLayout.java
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.CheckBox;
+import android.widget.Checkable;
+import android.widget.LinearLayout;
+
+
+public class CheckableLinearLayout extends LinearLayout implements Checkable {
+
+ private CheckBox mCheckBox;
+
+ public CheckableLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mCheckBox != null && mCheckBox.isChecked();
+ }
+
+ @Override
+ public void setChecked(boolean isChecked) {
+ if (mCheckBox != null) {
+ mCheckBox.setChecked(isChecked);
+ }
+ }
+
+ @Override
+ public void toggle() {
+ if (mCheckBox != null) {
+ mCheckBox.toggle();
+ }
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+
+ mCheckBox = (CheckBox) findViewById(R.id.checkbox);
+ mCheckBox.setClickable(false);
+ }
+}
+
+
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java b/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java
new file mode 100644
index 000000000..206341212
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ClickableWhenDisabledEditText.java
@@ -0,0 +1,25 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.EditText;
+
+public class ClickableWhenDisabledEditText extends EditText {
+ public ClickableWhenDisabledEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (!isEnabled() && event.getAction() == MotionEvent.ACTION_UP) {
+ return performClick();
+ }
+ return super.onTouchEvent(event);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java
new file mode 100644
index 000000000..96b20a6c3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ContentSecurityDoorHanger.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.support.v4.content.ContextCompat;
+import android.util.Log;
+import android.widget.Button;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.view.View;
+
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.toolbar.SiteIdentityPopup;
+
+import java.util.Locale;
+
+public class ContentSecurityDoorHanger extends DoorHanger {
+ private static final String LOGTAG = "GeckoSecurityDoorHanger";
+
+ private final TextView mTitle;
+ private final TextView mSecurityState;
+ private final TextView mMessage;
+
+ public ContentSecurityDoorHanger(Context context, DoorhangerConfig config, Type type) {
+ super(context, config, type);
+
+ mTitle = (TextView) findViewById(R.id.security_title);
+ mSecurityState = (TextView) findViewById(R.id.security_state);
+ mMessage = (TextView) findViewById(R.id.security_message);
+
+ loadConfig(config);
+ }
+
+ @Override
+ protected void loadConfig(DoorhangerConfig config) {
+ final String message = config.getMessage();
+ if (message != null) {
+ mMessage.setText(message);
+ }
+
+ final JSONObject options = config.getOptions();
+ if (options != null) {
+ setOptions(options);
+ }
+
+ final DoorhangerConfig.Link link = config.getLink();
+ if (link != null) {
+ addLink(link.label, link.url);
+ }
+
+ addButtonsToLayout(config);
+ }
+
+ @Override
+ protected int getContentResource() {
+ return R.layout.doorhanger_security;
+ }
+
+ @Override
+ public void setOptions(final JSONObject options) {
+ super.setOptions(options);
+ final JSONObject link = options.optJSONObject("link");
+ if (link != null) {
+ try {
+ final String linkLabel = link.getString("label");
+ final String linkUrl = link.getString("url");
+ addLink(linkLabel, linkUrl);
+ } catch (JSONException e) { }
+ }
+
+ final JSONObject trackingProtection = options.optJSONObject("tracking_protection");
+ if (trackingProtection != null) {
+ mTitle.setVisibility(VISIBLE);
+ mTitle.setText(R.string.doorhanger_tracking_title);
+ try {
+ final boolean enabled = trackingProtection.getBoolean("enabled");
+ if (enabled) {
+ mMessage.setText(R.string.doorhanger_tracking_message_enabled);
+ mSecurityState.setText(R.string.doorhanger_tracking_state_enabled);
+ mSecurityState.setTextColor(ContextCompat.getColor(getContext(), R.color.affirmative_green));
+ } else {
+ mMessage.setText(R.string.doorhanger_tracking_message_disabled);
+ mSecurityState.setText(R.string.doorhanger_tracking_state_disabled);
+ mSecurityState.setTextColor(ContextCompat.getColor(getContext(), R.color.rejection_red));
+ }
+ mMessage.setVisibility(VISIBLE);
+ mSecurityState.setVisibility(VISIBLE);
+ } catch (JSONException e) { }
+ }
+ }
+
+ @Override
+ protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) {
+ return new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra;
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra);
+
+ final JSONObject response = new JSONObject();
+ try {
+ switch (mType) {
+ case TRACKING:
+ response.put("allowContent", (id == SiteIdentityPopup.ButtonType.DISABLE.ordinal()));
+ response.put("contentType", ("tracking"));
+ break;
+ default:
+ Log.w(LOGTAG, "Unknown doorhanger type " + mType.toString());
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating onClick response", e);
+ }
+
+ mOnButtonClickListener.onButtonClick(response, ContentSecurityDoorHanger.this);
+ }
+ };
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java
new file mode 100644
index 000000000..63cb84c5a
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/CropImageView.java
@@ -0,0 +1,143 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+import org.mozilla.gecko.widget.themed.ThemedImageView;
+
+/**
+ * An ImageView which will always display at the given width and calculated height (based on the width and
+ * the supplied aspect ratio), drawn starting from the top left hand corner. A supplied drawable will be resized to fit
+ * the width of the view; if the resized drawable is too tall for the view then the drawable will be cropped at the
+ * bottom, however if the resized drawable is too short for the view to display whilst honouring it's given width and
+ * height then the drawable will be displayed at full height with the right hand side cropped.
+ */
+public abstract class CropImageView extends ThemedImageView {
+ public static final String LOGTAG = "Gecko" + CropImageView.class.getSimpleName();
+
+ private int viewWidth;
+ private int viewHeight;
+ private int drawableWidth;
+ private int drawableHeight;
+
+ private boolean resize = true;
+ private Matrix layoutCurrentMatrix = new Matrix();
+ private Matrix layoutNextMatrix = new Matrix();
+
+
+ public CropImageView(final Context context) {
+ this(context, null);
+ }
+
+ public CropImageView(final Context context, final AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CropImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ protected abstract float getAspectRatio();
+
+ protected void init() {
+ // Setting the pivots means that the image will be drawn from the top left hand corner. There are
+ // issues in Android 4.1 (16) which mean setting these values to 0 may not work.
+ // http://stackoverflow.com/questions/26658124/setpivotx-doesnt-work-on-android-4-1-1-nineoldandroids
+ setPivotX(1);
+ setPivotY(1);
+ }
+
+ /**
+ * Measure the view to determine the measured width and height.
+ * The height is constrained by the measured width.
+ *
+ * @param widthMeasureSpec horizontal space requirements as imposed by the parent.
+ * @param heightMeasureSpec vertical space requirements as imposed by the parent, but ignored.
+ */
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ // Default measuring.
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ // Force the height based on the aspect ratio.
+ viewWidth = getMeasuredWidth();
+ viewHeight = (int) (viewWidth * getAspectRatio());
+
+ setMeasuredDimension(viewWidth, viewHeight);
+
+ updateImageMatrix();
+ }
+
+ protected void updateImageMatrix() {
+ if (!resize || getDrawable() == null) {
+ return;
+ }
+
+ setScaleType(ImageView.ScaleType.MATRIX);
+
+ getDrawable().setBounds(0, 0, viewWidth, viewHeight);
+
+ final float horizontalScaleValue = (float) viewWidth / (float) drawableWidth;
+ final float verticalScaleValue = (float) viewHeight / (float) drawableHeight;
+
+ final float scale = Math.max(verticalScaleValue, horizontalScaleValue);
+
+ layoutNextMatrix.reset();
+ layoutNextMatrix.setScale(scale, scale);
+ setImageMatrix(layoutNextMatrix);
+
+ // You can't modify the matrix in place and we want to avoid allocation, so let's keep two references to two
+ // different matrix objects that we can swap when the values need to change
+ final Matrix swapReferenceMatrix = layoutCurrentMatrix;
+ layoutCurrentMatrix = layoutNextMatrix;
+ layoutNextMatrix = swapReferenceMatrix;
+ }
+
+ public void setImageBitmap(final Bitmap bm, final boolean resize) {
+ super.setImageBitmap(bm);
+
+ this.resize = resize;
+ updateImageMatrix();
+ }
+
+ @Override
+ public void setImageResource(final int resId) {
+ super.setImageResource(resId);
+ setImageMatrix(null);
+ resize = false;
+ }
+
+ @Override
+ public void setImageDrawable(final Drawable drawable) {
+ this.setImageDrawable(drawable, false);
+ }
+
+ public void setImageDrawable(final Drawable drawable, final boolean resize) {
+ super.setImageDrawable(drawable);
+
+ if (drawable != null) {
+ // Reset the matrix to ensure that any previous changes aren't carried through.
+ setImageMatrix(null);
+
+ drawableWidth = drawable.getIntrinsicWidth();
+ drawableHeight = drawable.getIntrinsicHeight();
+ } else {
+ drawableWidth = -1;
+ drawableHeight = -1;
+ }
+
+ this.resize = resize;
+
+ updateImageMatrix();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java b/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java
new file mode 100644
index 000000000..67f1bcd1d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java
@@ -0,0 +1,665 @@
+/*
+ * Copyright (C) 2007 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.widget;
+
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Locale;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.AppConstants.Versions;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.text.format.DateFormat;
+import android.text.format.DateUtils;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.WindowManager;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.CalendarView;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.NumberPicker;
+
+public class DateTimePicker extends FrameLayout {
+ private static final boolean DEBUG = true;
+ private static final String LOGTAG = "GeckoDateTimePicker";
+ private static final int DEFAULT_START_YEAR = 1;
+ private static final int DEFAULT_END_YEAR = 9999;
+ private static final char DATE_FORMAT_DAY = 'd';
+ private static final char DATE_FORMAT_MONTH = 'M';
+ private static final char DATE_FORMAT_YEAR = 'y';
+
+ boolean mYearEnabled = true;
+ boolean mMonthEnabled = true;
+ boolean mWeekEnabled;
+ boolean mDayEnabled = true;
+ boolean mHourEnabled = true;
+ boolean mMinuteEnabled = true;
+ boolean mIs12HourMode;
+ private boolean mCalendarEnabled;
+
+ // Size of the screen in inches;
+ private final int mScreenWidth;
+ private final int mScreenHeight;
+ private final OnValueChangeListener mOnChangeListener;
+ private final LinearLayout mPickers;
+ private final LinearLayout mDateSpinners;
+ private final LinearLayout mTimeSpinners;
+
+ final NumberPicker mDaySpinner;
+ final NumberPicker mMonthSpinner;
+ final NumberPicker mWeekSpinner;
+ final NumberPicker mYearSpinner;
+ final NumberPicker mHourSpinner;
+ final NumberPicker mMinuteSpinner;
+ final NumberPicker mAMPMSpinner;
+ private final CalendarView mCalendar;
+ private final EditText mDaySpinnerInput;
+ private final EditText mMonthSpinnerInput;
+ private final EditText mWeekSpinnerInput;
+ private final EditText mYearSpinnerInput;
+ private final EditText mHourSpinnerInput;
+ private final EditText mMinuteSpinnerInput;
+ private final EditText mAMPMSpinnerInput;
+ private Locale mCurrentLocale;
+ private String[] mShortMonths;
+ private String[] mShortAMPMs;
+ private int mNumberOfMonths;
+
+ Calendar mTempDate;
+ Calendar mCurrentDate;
+ private Calendar mMinDate;
+ private Calendar mMaxDate;
+ private final PickersState mState;
+
+ public static enum PickersState { DATE, MONTH, WEEK, TIME, DATETIME };
+
+ public class OnValueChangeListener implements NumberPicker.OnValueChangeListener {
+ @Override
+ public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
+ updateInputState();
+ mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis());
+ if (DEBUG) {
+ Log.d(LOGTAG, "SDK version > 10, using new behavior");
+ }
+
+ // The native date picker widget on these SDKs increments
+ // the next field when one field reaches the maximum.
+ if (picker == mDaySpinner && mDayEnabled) {
+ int maxDayOfMonth = mTempDate.getActualMaximum(Calendar.DAY_OF_MONTH);
+ int old = mTempDate.get(Calendar.DAY_OF_MONTH);
+ setTempDate(Calendar.DAY_OF_MONTH, old, newVal, 1, maxDayOfMonth);
+ } else if (picker == mMonthSpinner && mMonthEnabled) {
+ int old = mTempDate.get(Calendar.MONTH);
+ setTempDate(Calendar.MONTH, old, newVal, Calendar.JANUARY, Calendar.DECEMBER);
+ } else if (picker == mWeekSpinner) {
+ int old = mTempDate.get(Calendar.WEEK_OF_YEAR);
+ int maxWeekOfYear = mTempDate.getActualMaximum(Calendar.WEEK_OF_YEAR);
+ setTempDate(Calendar.WEEK_OF_YEAR, old, newVal, 0, maxWeekOfYear);
+ } else if (picker == mYearSpinner && mYearEnabled) {
+ int month = mTempDate.get(Calendar.MONTH);
+ mTempDate.set(Calendar.YEAR, newVal);
+ // Changing the year shouldn't change the month. (in case of non-leap year a Feb 29)
+ // change the day instead;
+ if (month != mTempDate.get(Calendar.MONTH)) {
+ mTempDate.set(Calendar.MONTH, month);
+ mTempDate.set(Calendar.DAY_OF_MONTH,
+ mTempDate.getActualMaximum(Calendar.DAY_OF_MONTH));
+ }
+ } else if (picker == mHourSpinner && mHourEnabled) {
+ if (mIs12HourMode) {
+ setTempDate(Calendar.HOUR, oldVal, newVal, 1, 12);
+ } else {
+ setTempDate(Calendar.HOUR_OF_DAY, oldVal, newVal, 0, 23);
+ }
+ } else if (picker == mMinuteSpinner && mMinuteEnabled) {
+ setTempDate(Calendar.MINUTE, oldVal, newVal, 0, 59);
+ } else if (picker == mAMPMSpinner && mHourEnabled) {
+ mTempDate.set(Calendar.AM_PM, newVal);
+ } else {
+ throw new IllegalArgumentException();
+ }
+ setDate(mTempDate);
+ if (mDayEnabled) {
+ mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
+ }
+ if (mWeekEnabled) {
+ mWeekSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.WEEK_OF_YEAR));
+ }
+ updateCalendar();
+ updateSpinners();
+ notifyDateChanged();
+ }
+
+ private void setTempDate(int field, int oldVal, int newVal, int min, int max) {
+ if (oldVal == max && newVal == min) {
+ mTempDate.add(field, 1);
+ } else if (oldVal == min && newVal == max) {
+ mTempDate.add(field, -1);
+ } else {
+ mTempDate.add(field, newVal - oldVal);
+ }
+ }
+ }
+
+ private static final NumberPicker.Formatter TWO_DIGIT_FORMATTER = new NumberPicker.Formatter() {
+ final StringBuilder mBuilder = new StringBuilder();
+
+ final java.util.Formatter mFmt = new java.util.Formatter(mBuilder, java.util.Locale.US);
+
+ final Object[] mArgs = new Object[1];
+
+ @Override
+ public String format(int value) {
+ mArgs[0] = value;
+ mBuilder.delete(0, mBuilder.length());
+ mFmt.format("%02d", mArgs);
+ return mFmt.toString();
+ }
+ };
+
+ private void displayPickers() {
+ setWeekShown(false);
+ set12HourShown(mIs12HourMode);
+ if (mState == PickersState.DATETIME) {
+ return;
+ }
+
+ setHourShown(false);
+ setMinuteShown(false);
+ if (mState == PickersState.WEEK) {
+ setDayShown(false);
+ setMonthShown(false);
+ setWeekShown(true);
+ } else if (mState == PickersState.MONTH) {
+ setDayShown(false);
+ }
+ }
+
+ public DateTimePicker(Context context) {
+ this(context, "", "", PickersState.DATE, null, null);
+ }
+
+ public DateTimePicker(Context context, String dateFormat, String dateTimeValue, PickersState state, String minDateValue, String maxDateValue) {
+ super(context);
+
+ setCurrentLocale(Locale.getDefault());
+
+ mState = state;
+ LayoutInflater inflater = LayoutInflater.from(context);
+ inflater.inflate(R.layout.datetime_picker, this, true);
+
+ mOnChangeListener = new OnValueChangeListener();
+
+ mDateSpinners = (LinearLayout)findViewById(R.id.date_spinners);
+ mTimeSpinners = (LinearLayout)findViewById(R.id.time_spinners);
+ mPickers = (LinearLayout)findViewById(R.id.datetime_picker);
+
+ // We will display differently according to the screen size width.
+ WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+ Display display = wm.getDefaultDisplay();
+ DisplayMetrics dm = new DisplayMetrics();
+ display.getMetrics(dm);
+ mScreenWidth = display.getWidth() / dm.densityDpi;
+ mScreenHeight = display.getHeight() / dm.densityDpi;
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "screen width: " + mScreenWidth + " screen height: " + mScreenHeight);
+ }
+
+ // Set the min / max attribute.
+ try {
+ if (minDateValue != null && !minDateValue.equals("")) {
+ mMinDate.setTime(new SimpleDateFormat(dateFormat).parse(minDateValue));
+ } else {
+ mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1);
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Error parsing format sting: " + ex);
+ mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1);
+ }
+
+ try {
+ if (maxDateValue != null && !maxDateValue.equals("")) {
+ mMaxDate.setTime(new SimpleDateFormat(dateFormat).parse(maxDateValue));
+ } else {
+ mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31);
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Error parsing format string: " + ex);
+ mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31);
+ }
+
+ // Find the initial date from the constructor arguments.
+ try {
+ if (!dateTimeValue.equals("")) {
+ mTempDate.setTime(new SimpleDateFormat(dateFormat).parse(dateTimeValue));
+ } else {
+ mTempDate.setTimeInMillis(System.currentTimeMillis());
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Error parsing format string: " + ex);
+ mTempDate.setTimeInMillis(System.currentTimeMillis());
+ }
+
+ if (mMaxDate.before(mMinDate)) {
+ // If the input date range is illogical/garbage, we should not restrict the input range (i.e. allow the
+ // user to select any date). If we try to make any assumptions based on the illogical min/max date we could
+ // potentially prevent the user from selecting dates that are in the developers intended range, so it's best
+ // to allow anything.
+ mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1);
+ mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31);
+ }
+
+ // mTempDate will either be a site-supplied value, or today's date otherwise. CalendarView implementations can
+ // crash if they're supplied an invalid date (i.e. a date not in the specified range), hence we need to set
+ // a sensible default date here.
+ if (mTempDate.before(mMinDate) || mTempDate.after(mMaxDate)) {
+ mTempDate.setTimeInMillis(mMinDate.getTimeInMillis());
+ }
+
+ // If we're displaying a date, the screen is wide enough
+ // (and if we're using an SDK where the calendar view exists)
+ // then display a calendar.
+ if (mState == PickersState.DATE || mState == PickersState.DATETIME) {
+ mCalendar = new CalendarView(context);
+ mCalendar.setVisibility(GONE);
+
+ mCalendar.setFocusable(true);
+ mCalendar.setFocusableInTouchMode(true);
+ mCalendar.setMaxDate(mMaxDate.getTimeInMillis());
+ mCalendar.setMinDate(mMinDate.getTimeInMillis());
+ mCalendar.setDate(mTempDate.getTimeInMillis(), false, false);
+
+ mCalendar.setOnDateChangeListener(new CalendarView.OnDateChangeListener() {
+ @Override
+ public void onSelectedDayChange(
+ CalendarView view, int year, int month, int monthDay) {
+ mTempDate.set(year, month, monthDay);
+ setDate(mTempDate);
+ notifyDateChanged();
+ }
+ });
+
+ final int height;
+ if (Versions.preLollipop) {
+ // The 4.X version of CalendarView doesn't request any height, resulting in
+ // the whole dialog not appearing unless we manually request height.
+ height = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200, getResources().getDisplayMetrics());;
+ } else {
+ height = LayoutParams.WRAP_CONTENT;
+ }
+
+ mPickers.addView(mCalendar, LayoutParams.MATCH_PARENT, height);
+
+ } else {
+ // If the screen is more wide than high, we are displaying day and
+ // time spinners, and if there is no calendar displayed, we should
+ // display the fields in one row.
+ if (mScreenWidth > mScreenHeight && mState == PickersState.DATETIME) {
+ mPickers.setOrientation(LinearLayout.HORIZONTAL);
+ }
+ mCalendar = null;
+ }
+
+ // Initialize all spinners.
+ mDaySpinner = setupSpinner(R.id.day, 1,
+ mTempDate.get(Calendar.DAY_OF_MONTH));
+ mDaySpinner.setFormatter(TWO_DIGIT_FORMATTER);
+ mDaySpinnerInput = (EditText) mDaySpinner.getChildAt(1);
+
+ mMonthSpinner = setupSpinner(R.id.month, 1,
+ mTempDate.get(Calendar.MONTH) + 1); // Month is 0-based
+ mMonthSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+ mMonthSpinner.setDisplayedValues(mShortMonths);
+ mMonthSpinnerInput = (EditText) mMonthSpinner.getChildAt(1);
+
+ mWeekSpinner = setupSpinner(R.id.week, 1,
+ mTempDate.get(Calendar.WEEK_OF_YEAR));
+ mWeekSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+ mWeekSpinnerInput = (EditText) mWeekSpinner.getChildAt(1);
+
+ mYearSpinner = setupSpinner(R.id.year, DEFAULT_START_YEAR,
+ DEFAULT_END_YEAR);
+ mYearSpinnerInput = (EditText) mYearSpinner.getChildAt(1);
+
+ mAMPMSpinner = setupSpinner(R.id.ampm, 0, 1);
+ mAMPMSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+
+ if (mIs12HourMode) {
+ mHourSpinner = setupSpinner(R.id.hour, 1, 12);
+ mAMPMSpinnerInput = (EditText) mAMPMSpinner.getChildAt(1);
+ mAMPMSpinner.setDisplayedValues(mShortAMPMs);
+ } else {
+ mHourSpinner = setupSpinner(R.id.hour, 0, 23);
+ mAMPMSpinnerInput = null;
+ }
+
+ mHourSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+ mHourSpinnerInput = (EditText) mHourSpinner.getChildAt(1);
+
+ mMinuteSpinner = setupSpinner(R.id.minute, 0, 59);
+ mMinuteSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+ mMinuteSpinnerInput = (EditText) mMinuteSpinner.getChildAt(1);
+
+ // The order in which the spinners are displayed are locale-dependent
+ reorderDateSpinners();
+
+ // Set the date to the initial date. Since this date can come from the user,
+ // it can fire an exception (out-of-bound date)
+ try {
+ updateDate(mTempDate);
+ } catch (Exception ex) {
+ }
+
+ // Display only the pickers needed for the current state.
+ displayPickers();
+ }
+
+ public NumberPicker setupSpinner(int id, int min, int max) {
+ NumberPicker mSpinner = (NumberPicker) findViewById(id);
+ mSpinner.setMinValue(min);
+ mSpinner.setMaxValue(max);
+ mSpinner.setOnValueChangedListener(mOnChangeListener);
+ mSpinner.setOnLongPressUpdateInterval(100);
+ return mSpinner;
+ }
+
+ public long getTimeInMillis() {
+ return mCurrentDate.getTimeInMillis();
+ }
+
+ private void reorderDateSpinners() {
+ mDateSpinners.removeAllViews();
+ char[] order = DateFormat.getDateFormatOrder(getContext());
+ final int spinnerCount = order.length;
+
+ for (int i = 0; i < spinnerCount; i++) {
+ switch (order[i]) {
+ case DATE_FORMAT_DAY:
+ mDateSpinners.addView(mDaySpinner);
+ break;
+ case DATE_FORMAT_MONTH:
+ mDateSpinners.addView(mMonthSpinner);
+ break;
+ case DATE_FORMAT_YEAR:
+ mDateSpinners.addView(mYearSpinner);
+ break;
+ default:
+ throw new IllegalArgumentException();
+ }
+ }
+
+ mDateSpinners.addView(mWeekSpinner);
+ }
+
+ void setDate(Calendar calendar) {
+ mCurrentDate = mTempDate;
+ if (mCurrentDate.before(mMinDate)) {
+ mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
+ } else if (mCurrentDate.after(mMaxDate)) {
+ mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
+ }
+ }
+
+ void updateInputState() {
+ InputMethodManager inputMethodManager = (InputMethodManager)
+ getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ if (mYearEnabled && inputMethodManager.isActive(mYearSpinnerInput)) {
+ mYearSpinnerInput.clearFocus();
+ inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+ } else if (mMonthEnabled && inputMethodManager.isActive(mMonthSpinnerInput)) {
+ mMonthSpinnerInput.clearFocus();
+ inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+ } else if (mDayEnabled && inputMethodManager.isActive(mDaySpinnerInput)) {
+ mDaySpinnerInput.clearFocus();
+ inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+ } else if (mHourEnabled && inputMethodManager.isActive(mHourSpinnerInput)) {
+ mHourSpinnerInput.clearFocus();
+ inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+ } else if (mMinuteEnabled && inputMethodManager.isActive(mMinuteSpinnerInput)) {
+ mMinuteSpinnerInput.clearFocus();
+ inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+ }
+ }
+
+ void updateSpinners() {
+ if (mDayEnabled) {
+ if (mCurrentDate.equals(mMinDate)) {
+ mDaySpinner.setMinValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
+ mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
+ } else if (mCurrentDate.equals(mMaxDate)) {
+ mDaySpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.DAY_OF_MONTH));
+ mDaySpinner.setMaxValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
+ } else {
+ mDaySpinner.setMinValue(1);
+ mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
+ }
+ mDaySpinner.setValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
+ }
+
+ if (mWeekEnabled) {
+ mWeekSpinner.setMinValue(1);
+ mWeekSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.WEEK_OF_YEAR));
+ mWeekSpinner.setValue(mCurrentDate.get(Calendar.WEEK_OF_YEAR));
+ }
+
+ if (mMonthEnabled) {
+ mMonthSpinner.setDisplayedValues(null);
+ if (mCurrentDate.equals(mMinDate)) {
+ mMonthSpinner.setMinValue(mCurrentDate.get(Calendar.MONTH));
+ mMonthSpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.MONTH));
+ } else if (mCurrentDate.equals(mMaxDate)) {
+ mMonthSpinner.setMinValue(mCurrentDate.getActualMinimum(Calendar.MONTH));
+ mMonthSpinner.setMaxValue(mCurrentDate.get(Calendar.MONTH));
+ } else {
+ mMonthSpinner.setMinValue(Calendar.JANUARY);
+ mMonthSpinner.setMaxValue(Calendar.DECEMBER);
+ }
+
+ String[] displayedValues = Arrays.copyOfRange(mShortMonths,
+ mMonthSpinner.getMinValue(), mMonthSpinner.getMaxValue() + 1);
+ mMonthSpinner.setDisplayedValues(displayedValues);
+ mMonthSpinner.setValue(mCurrentDate.get(Calendar.MONTH));
+ }
+
+ if (mYearEnabled) {
+ mYearSpinner.setMinValue(mMinDate.get(Calendar.YEAR));
+ mYearSpinner.setMaxValue(mMaxDate.get(Calendar.YEAR));
+ mYearSpinner.setValue(mCurrentDate.get(Calendar.YEAR));
+ }
+
+ if (mHourEnabled) {
+ if (mIs12HourMode) {
+ mHourSpinner.setValue(mCurrentDate.get(Calendar.HOUR));
+ mAMPMSpinner.setValue(mCurrentDate.get(Calendar.AM_PM));
+ mAMPMSpinner.setDisplayedValues(mShortAMPMs);
+ } else {
+ mHourSpinner.setValue(mCurrentDate.get(Calendar.HOUR_OF_DAY));
+ }
+ }
+ if (mMinuteEnabled) {
+ mMinuteSpinner.setValue(mCurrentDate.get(Calendar.MINUTE));
+ }
+ }
+
+ void updateCalendar() {
+ if (mCalendarEnabled) {
+ mCalendar.setDate(mCurrentDate.getTimeInMillis(), false, false);
+ }
+ }
+
+ void notifyDateChanged() {
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
+ }
+
+ public void toggleCalendar(boolean shown) {
+ if ((mState != PickersState.DATE && mState != PickersState.DATETIME)) {
+ return;
+ }
+
+ if (shown) {
+ mCalendarEnabled = true;
+ mCalendar.setVisibility(VISIBLE);
+ setYearShown(false);
+ setWeekShown(false);
+ setMonthShown(false);
+ setDayShown(false);
+ } else {
+ mCalendar.setVisibility(GONE);
+ setYearShown(true);
+ setMonthShown(true);
+ setDayShown(true);
+ mPickers.setOrientation(LinearLayout.HORIZONTAL);
+ mCalendarEnabled = false;
+ }
+ }
+
+ private void setYearShown(boolean shown) {
+ if (shown) {
+ toggleCalendar(false);
+ mYearSpinner.setVisibility(VISIBLE);
+ mYearEnabled = true;
+ } else {
+ mYearSpinner.setVisibility(GONE);
+ mYearEnabled = false;
+ }
+ }
+
+ private void setWeekShown(boolean shown) {
+ if (shown) {
+ toggleCalendar(false);
+ mWeekSpinner.setVisibility(VISIBLE);
+ mWeekEnabled = true;
+ } else {
+ mWeekSpinner.setVisibility(GONE);
+ mWeekEnabled = false;
+ }
+ }
+
+ private void setMonthShown(boolean shown) {
+ if (shown) {
+ toggleCalendar(false);
+ mMonthSpinner.setVisibility(VISIBLE);
+ mMonthEnabled = true;
+ } else {
+ mMonthSpinner.setVisibility(GONE);
+ mMonthEnabled = false;
+ }
+ }
+
+ private void setDayShown(boolean shown) {
+ if (shown) {
+ toggleCalendar(false);
+ mDaySpinner.setVisibility(VISIBLE);
+ mDayEnabled = true;
+ } else {
+ mDaySpinner.setVisibility(GONE);
+ mDayEnabled = false;
+ }
+ }
+
+ private void set12HourShown(boolean shown) {
+ if (shown) {
+ mAMPMSpinner.setVisibility(VISIBLE);
+ } else {
+ mAMPMSpinner.setVisibility(GONE);
+ }
+ }
+
+ private void setHourShown(boolean shown) {
+ if (shown) {
+ mHourSpinner.setVisibility(VISIBLE);
+ mHourEnabled = true;
+ } else {
+ mHourSpinner.setVisibility(GONE);
+ mAMPMSpinner.setVisibility(GONE);
+ mTimeSpinners.setVisibility(GONE);
+ mHourEnabled = false;
+ }
+ }
+
+ private void setMinuteShown(boolean shown) {
+ if (shown) {
+ mMinuteSpinner.setVisibility(VISIBLE);
+ mTimeSpinners.findViewById(R.id.mincolon).setVisibility(VISIBLE);
+ mMinuteEnabled = true;
+ } else {
+ mMinuteSpinner.setVisibility(GONE);
+ mTimeSpinners.findViewById(R.id.mincolon).setVisibility(GONE);
+ mMinuteEnabled = false;
+ }
+ }
+
+ private void setCurrentLocale(Locale locale) {
+ if (locale.equals(mCurrentLocale)) {
+ return;
+ }
+
+ mCurrentLocale = locale;
+ mIs12HourMode = !DateFormat.is24HourFormat(getContext());
+ mTempDate = getCalendarForLocale(mTempDate, locale);
+ mMinDate = getCalendarForLocale(mMinDate, locale);
+ mMaxDate = getCalendarForLocale(mMaxDate, locale);
+ mCurrentDate = getCalendarForLocale(mCurrentDate, locale);
+
+ mNumberOfMonths = mTempDate.getActualMaximum(Calendar.MONTH) + 1;
+
+ mShortAMPMs = new String[2];
+ mShortAMPMs[0] = DateUtils.getAMPMString(Calendar.AM);
+ mShortAMPMs[1] = DateUtils.getAMPMString(Calendar.PM);
+
+ mShortMonths = new String[mNumberOfMonths];
+ for (int i = 0; i < mNumberOfMonths; i++) {
+ mShortMonths[i] = DateUtils.getMonthString(Calendar.JANUARY + i,
+ DateUtils.LENGTH_MEDIUM);
+ }
+ }
+
+ private Calendar getCalendarForLocale(Calendar oldCalendar, Locale locale) {
+ if (oldCalendar == null) {
+ return Calendar.getInstance(locale);
+ }
+
+ final long currentTimeMillis = oldCalendar.getTimeInMillis();
+ Calendar newCalendar = Calendar.getInstance(locale);
+ newCalendar.setTimeInMillis(currentTimeMillis);
+ return newCalendar;
+ }
+
+ public void updateDate(Calendar calendar) {
+ if (mCurrentDate.equals(calendar)) {
+ return;
+ }
+ mCurrentDate.setTimeInMillis(calendar.getTimeInMillis());
+ if (mCurrentDate.before(mMinDate)) {
+ mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
+ } else if (mCurrentDate.after(mMaxDate)) {
+ mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
+ }
+ updateSpinners();
+ notifyDateChanged();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java
new file mode 100644
index 000000000..cb8716af7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultDoorHanger.java
@@ -0,0 +1,190 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.support.v4.content.ContextCompat;
+import android.text.Html;
+import android.text.Spanned;
+import android.util.Log;
+import android.widget.Button;
+import android.widget.TextView;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.prompts.PromptInput;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class DefaultDoorHanger extends DoorHanger {
+ private static final String LOGTAG = "GeckoDefaultDoorHanger";
+
+ private static int sSpinnerTextColor = -1;
+
+ private final TextView mMessage;
+ private List<PromptInput> mInputs;
+ private CheckBox mCheckBox;
+
+ public DefaultDoorHanger(Context context, DoorhangerConfig config, Type type) {
+ super(context, config, type);
+
+ mMessage = (TextView) findViewById(R.id.doorhanger_message);
+
+ if (sSpinnerTextColor == -1) {
+ sSpinnerTextColor = ContextCompat.getColor(context, R.color.text_color_primary_disable_only);
+ }
+
+ switch (mType) {
+ case GEOLOCATION:
+ mIcon.setImageResource(R.drawable.location);
+ mIcon.setVisibility(VISIBLE);
+ break;
+
+ case DESKTOPNOTIFICATION2:
+ mIcon.setImageResource(R.drawable.push_notification);
+ mIcon.setVisibility(VISIBLE);
+ break;
+ }
+
+ loadConfig(config);
+ }
+
+ @Override
+ protected void loadConfig(DoorhangerConfig config) {
+ final String message = config.getMessage();
+ if (message != null) {
+ setMessage(message);
+ }
+
+ final JSONObject options = config.getOptions();
+ if (options != null) {
+ setOptions(options);
+ }
+
+ final DoorhangerConfig.Link link = config.getLink();
+ if (link != null) {
+ addLink(link.label, link.url);
+ }
+
+ addButtonsToLayout(config);
+ }
+
+ @Override
+ protected int getContentResource() {
+ return R.layout.default_doorhanger;
+ }
+
+ private List<PromptInput> getInputs() {
+ return mInputs;
+ }
+
+ private CheckBox getCheckBox() {
+ return mCheckBox;
+ }
+
+ @Override
+ public void setOptions(final JSONObject options) {
+ super.setOptions(options);
+
+ final JSONArray inputs = options.optJSONArray("inputs");
+ if (inputs != null) {
+ mInputs = new ArrayList<PromptInput>();
+
+ final ViewGroup group = (ViewGroup) findViewById(R.id.doorhanger_inputs);
+ group.setVisibility(VISIBLE);
+
+ for (int i = 0; i < inputs.length(); i++) {
+ try {
+ PromptInput input = PromptInput.getInput(inputs.getJSONObject(i));
+ mInputs.add(input);
+
+ final int padding = mResources.getDimensionPixelSize(R.dimen.doorhanger_section_padding_medium);
+ View v = input.getView(getContext());
+ styleInput(input, v);
+ v.setPadding(0, 0, 0, padding);
+ group.addView(v);
+ } catch (JSONException ex) { }
+ }
+ }
+
+ final String checkBoxText = options.optString("checkbox");
+ if (!TextUtils.isEmpty(checkBoxText)) {
+ mCheckBox = (CheckBox) findViewById(R.id.doorhanger_checkbox);
+ mCheckBox.setText(checkBoxText);
+ if (options.has("checkboxState")) {
+ final boolean checkBoxState = options.optBoolean("checkboxState");
+ mCheckBox.setChecked(checkBoxState);
+ }
+ mCheckBox.setVisibility(VISIBLE);
+ }
+ }
+
+ @Override
+ protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) {
+ return new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra;
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra);
+
+ final JSONObject response = new JSONObject();
+ try {
+ response.put("callback", id);
+
+ CheckBox checkBox = getCheckBox();
+ // If the checkbox is being used, pass its value
+ if (checkBox != null) {
+ response.put("checked", checkBox.isChecked());
+ }
+
+ List<PromptInput> doorHangerInputs = getInputs();
+ if (doorHangerInputs != null) {
+ JSONObject inputs = new JSONObject();
+ for (PromptInput input : doorHangerInputs) {
+ inputs.put(input.getId(), input.getValue());
+ }
+ response.put("inputs", inputs);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating onClick response", e);
+ }
+
+ mOnButtonClickListener.onButtonClick(response, DefaultDoorHanger.this);
+ }
+ };
+ }
+
+ private void setMessage(String message) {
+ Spanned markupMessage = Html.fromHtml(message);
+ mMessage.setText(markupMessage);
+ }
+
+ private void styleInput(PromptInput input, View view) {
+ if (input instanceof PromptInput.MenulistInput) {
+ styleDropdownInputs(input, view);
+ }
+ view.setPadding(0, 0, 0, mResources.getDimensionPixelSize(R.dimen.doorhanger_subsection_padding));
+ }
+
+ private void styleDropdownInputs(PromptInput input, View view) {
+ PromptInput.MenulistInput spinInput = (PromptInput.MenulistInput) input;
+
+ if (spinInput.textView != null) {
+ spinInput.textView.setTextColor(sSpinnerTextColor);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java
new file mode 100644
index 000000000..5beec3a5c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DefaultItemAnimatorBase.java
@@ -0,0 +1,685 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.support.annotation.NonNull;
+import android.support.v4.animation.AnimatorCompatHelper;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.ViewPropertyAnimatorCompat;
+import android.support.v4.view.ViewPropertyAnimatorListener;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.SimpleItemAnimator;
+import android.view.View;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This basically follows the approach taken by Wasabeef:
+ * <a href="https://github.com/wasabeef/recyclerview-animators">https://github.com/wasabeef/recyclerview-animators</a>
+ * based off of Android's DefaultItemAnimator from October 2016:
+ * <a href="https://github.com/android/platform_frameworks_support/blob/432f3317f8a9b8cf98277938ea5df4021e983055/v7/recyclerview/src/android/support/v7/widget/DefaultItemAnimator.java">
+ * https://github.com/android/platform_frameworks_support/blob/432f3317f8a9b8cf98277938ea5df4021e983055/v7/recyclerview/src/android/support/v7/widget/DefaultItemAnimator.java
+ * </a>
+ * <p>
+ * Usage Notes:
+ * </p>
+ * <ul>
+ * <li>You <strong>must</strong> add a Default*VpaListener to your animate*Impl animation - the
+ * listener takes care of animation bookkeeping.</li>
+ * <li>You should call {@link #resetAnimation(RecyclerView.ViewHolder)} at some point in
+ * preAnimate*Impl if you choose to proceed with the animation. Some animations will want to
+ * know some or all of the current animation values for initializing their own animation
+ * values before resetting the current animation, so this class does not provide the reset
+ * service itself.</li>
+ * <li>{@link #resetViewProperties(View)} is used to reset a view any time an animation ends or
+ * gets canceled - you should redefine resetViewProperties if the version here doesn't reset
+ * all of the properties you're animating.</li>
+ * </ul>
+ */
+public class DefaultItemAnimatorBase extends SimpleItemAnimator {
+ private List<RecyclerView.ViewHolder> pendingRemovals = new ArrayList<>();
+ private List<RecyclerView.ViewHolder> pendingAdditions = new ArrayList<>();
+ private List<MoveInfo> pendingMoves = new ArrayList<>();
+ private List<ChangeInfo> pendingChanges = new ArrayList<>();
+
+ private List<List<RecyclerView.ViewHolder>> additionsList = new ArrayList<>();
+ private List<List<MoveInfo>> movesList = new ArrayList<>();
+ private List<List<ChangeInfo>> changesList = new ArrayList<>();
+
+ private List<RecyclerView.ViewHolder> addAnimations = new ArrayList<>();
+ private List<RecyclerView.ViewHolder> moveAnimations = new ArrayList<>();
+ private List<RecyclerView.ViewHolder> removeAnimations = new ArrayList<>();
+ private List<RecyclerView.ViewHolder> changeAnimations = new ArrayList<>();
+
+ protected static class MoveInfo {
+ public RecyclerView.ViewHolder holder;
+ public int fromX, fromY, toX, toY;
+
+ public MoveInfo(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
+ this.holder = holder;
+ this.fromX = fromX;
+ this.fromY = fromY;
+ this.toX = toX;
+ this.toY = toY;
+ }
+ }
+
+ protected static class ChangeInfo {
+ public RecyclerView.ViewHolder oldHolder, newHolder;
+ public int fromX, fromY, toX, toY;
+
+ public ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder) {
+ this.oldHolder = oldHolder;
+ this.newHolder = newHolder;
+ }
+
+ public ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder,
+ int fromX, int fromY, int toX, int toY) {
+ this(oldHolder, newHolder);
+ this.fromX = fromX;
+ this.fromY = fromY;
+ this.toX = toX;
+ this.toY = toY;
+ }
+
+ @Override
+ public String toString() {
+ return "ChangeInfo{" +
+ "oldHolder=" + oldHolder +
+ ", newHolder=" + newHolder +
+ ", fromX=" + fromX +
+ ", fromY=" + fromY +
+ ", toX=" + toX +
+ ", toY=" + toY +
+ '}';
+ }
+ }
+
+ @Override
+ public void runPendingAnimations() {
+ final boolean removalsPending = !pendingRemovals.isEmpty();
+ final boolean movesPending = !pendingMoves.isEmpty();
+ final boolean changesPending = !pendingChanges.isEmpty();
+ final boolean additionsPending = !pendingAdditions.isEmpty();
+ if (!removalsPending && !movesPending && !additionsPending && !changesPending) {
+ return;
+ }
+ // First, remove stuff.
+ for (final RecyclerView.ViewHolder holder : pendingRemovals) {
+ animateRemoveImpl(holder);
+ }
+ pendingRemovals.clear();
+ // Next, move stuff.
+ if (movesPending) {
+ final List<MoveInfo> moves = new ArrayList<>();
+ moves.addAll(pendingMoves);
+ movesList.add(moves);
+ pendingMoves.clear();
+ final Runnable mover = new Runnable() {
+ @Override
+ public void run() {
+ for (final MoveInfo moveInfo : moves) {
+ animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY,
+ moveInfo.toX, moveInfo.toY);
+ }
+ moves.clear();
+ movesList.remove(moves);
+ }
+ };
+ if (removalsPending) {
+ final View view = moves.get(0).holder.itemView;
+ ViewCompat.postOnAnimationDelayed(view, mover, getRemoveDuration());
+ } else {
+ mover.run();
+ }
+ }
+ // Next, change stuff, to run in parallel with move animations.
+ if (changesPending) {
+ final List<ChangeInfo> changes = new ArrayList<>();
+ changes.addAll(pendingChanges);
+ changesList.add(changes);
+ pendingChanges.clear();
+ final Runnable changer = new Runnable() {
+ @Override
+ public void run() {
+ for (final ChangeInfo change : changes) {
+ animateChangeImpl(change);
+ }
+ changes.clear();
+ changesList.remove(changes);
+ }
+ };
+ if (removalsPending) {
+ RecyclerView.ViewHolder holder = changes.get(0).oldHolder;
+ ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDuration());
+ } else {
+ changer.run();
+ }
+ }
+ // Next, add stuff.
+ if (additionsPending) {
+ final List<RecyclerView.ViewHolder> additions = new ArrayList<>();
+ additions.addAll(pendingAdditions);
+ additionsList.add(additions);
+ pendingAdditions.clear();
+ final Runnable adder = new Runnable() {
+ public void run() {
+ for (final RecyclerView.ViewHolder holder : additions) {
+ animateAddImpl(holder);
+ }
+ additions.clear();
+ additionsList.remove(additions);
+ }
+ };
+ if (removalsPending || movesPending || changesPending) {
+ final long removeDuration = removalsPending ? getRemoveDuration() : 0;
+ final long moveDuration = movesPending ? getMoveDuration() : 0;
+ final long changeDuration = changesPending ? getChangeDuration() : 0;
+ final long totalDelay = removeDuration + Math.max(moveDuration, changeDuration);
+ final View view = additions.get(0).itemView;
+ ViewCompat.postOnAnimationDelayed(view, adder, totalDelay);
+ } else {
+ adder.run();
+ }
+ }
+ }
+
+ @Override
+ public boolean animateRemove(final RecyclerView.ViewHolder holder) {
+ if (!preAnimateRemoveImpl(holder)) {
+ dispatchRemoveFinished(holder);
+ return false;
+ }
+ pendingRemovals.add(holder);
+ return true;
+ }
+
+ protected boolean preAnimateRemoveImpl(final RecyclerView.ViewHolder holder) {
+ resetAnimation(holder);
+ return true;
+ }
+
+ protected void animateRemoveImpl(final RecyclerView.ViewHolder holder) {
+ ViewCompat.animate(holder.itemView)
+ .setDuration(getRemoveDuration())
+ .alpha(0)
+ .setListener(new DefaultRemoveVpaListener(holder))
+ .start();
+ }
+
+ @Override
+ public boolean animateAdd(final RecyclerView.ViewHolder holder) {
+ if (!preAnimateAddImpl(holder)) {
+ dispatchAddFinished(holder);
+ return false;
+ }
+ pendingAdditions.add(holder);
+ return true;
+ }
+
+ protected boolean preAnimateAddImpl(RecyclerView.ViewHolder holder) {
+ resetAnimation(holder);
+ holder.itemView.setAlpha(0);
+ return true;
+ }
+
+ protected void animateAddImpl(final RecyclerView.ViewHolder holder) {
+ ViewCompat.animate(holder.itemView)
+ .setDuration(getAddDuration())
+ .alpha(1)
+ .setListener(new DefaultAddVpaListener(holder))
+ .start();
+ }
+
+ @Override
+ public boolean animateMove(final RecyclerView.ViewHolder holder,
+ int fromX, int fromY, int toX, int toY) {
+ final View view = holder.itemView;
+ fromX += ViewCompat.getTranslationX(holder.itemView);
+ fromY += ViewCompat.getTranslationY(holder.itemView);
+ final int deltaX = toX - fromX;
+ final int deltaY = toY - fromY;
+ if (deltaX == 0 && deltaY == 0) {
+ dispatchMoveFinished(holder);
+ return false;
+ }
+ resetAnimation(holder);
+ if (deltaX != 0) {
+ ViewCompat.setTranslationX(view, -deltaX);
+ }
+ if (deltaY != 0) {
+ ViewCompat.setTranslationY(view, -deltaY);
+ }
+ pendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY));
+ return true;
+ }
+
+ protected void animateMoveImpl(final RecyclerView.ViewHolder holder,
+ int fromX, int fromY, int toX, int toY) {
+ final View view = holder.itemView;
+ final int deltaX = toX - fromX;
+ final int deltaY = toY - fromY;
+ if (deltaX != 0) {
+ ViewCompat.animate(view).translationX(0);
+ }
+ if (deltaY != 0) {
+ ViewCompat.animate(view).translationY(0);
+ }
+ // TODO: make EndActions end listeners instead, since end actions aren't called when
+ // vpas are canceled (and can't end them. why?)
+ // need listener functionality in VPACompat for this. Ick.
+ final ViewPropertyAnimatorCompat animation = ViewCompat.animate(view);
+ moveAnimations.add(holder);
+ animation.setDuration(getMoveDuration()).setListener(new VpaListenerAdapter() {
+ @Override
+ public void onAnimationStart(View view) {
+ dispatchMoveStarting(holder);
+ }
+ @Override
+ public void onAnimationCancel(View view) {
+ resetViewProperties(view);
+ }
+ @Override
+ public void onAnimationEnd(View view) {
+ animation.setListener(null);
+ dispatchMoveFinished(holder);
+ moveAnimations.remove(holder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+
+ @Override
+ public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder,
+ int fromX, int fromY, int toX, int toY) {
+ if (oldHolder == newHolder) {
+ // Don't know how to run change animations when the same view holder is re-used.
+ // Run a move animation to handle position changes (if there are any).
+ if (fromX != toX || fromY != toY) {
+ // *Don't* call dispatchChangeFinished here, it leads to unbalanced isRecyclable calls.
+ return animateMove(oldHolder, fromX, fromY, toX, toY);
+ }
+ dispatchChangeFinished(oldHolder, true);
+ return false;
+ }
+ final float prevTranslationX = ViewCompat.getTranslationX(oldHolder.itemView);
+ final float prevTranslationY = ViewCompat.getTranslationY(oldHolder.itemView);
+ final float prevAlpha = ViewCompat.getAlpha(oldHolder.itemView);
+ resetAnimation(oldHolder);
+ final int deltaX = (int) (toX - fromX - prevTranslationX);
+ final int deltaY = (int) (toY - fromY - prevTranslationY);
+ // Recover previous translation state after ending animation.
+ ViewCompat.setTranslationX(oldHolder.itemView, prevTranslationX);
+ ViewCompat.setTranslationY(oldHolder.itemView, prevTranslationY);
+ ViewCompat.setAlpha(oldHolder.itemView, prevAlpha);
+ if (newHolder != null) {
+ // Carry over translation values.
+ resetAnimation(newHolder);
+ ViewCompat.setTranslationX(newHolder.itemView, -deltaX);
+ ViewCompat.setTranslationY(newHolder.itemView, -deltaY);
+ ViewCompat.setAlpha(newHolder.itemView, 0);
+ }
+ pendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY));
+ return true;
+ }
+
+ protected void animateChangeImpl(final ChangeInfo changeInfo) {
+ final RecyclerView.ViewHolder holder = changeInfo.oldHolder;
+ final View view = holder == null ? null : holder.itemView;
+ final RecyclerView.ViewHolder newHolder = changeInfo.newHolder;
+ final View newView = newHolder != null ? newHolder.itemView : null;
+ if (view != null) {
+ final ViewPropertyAnimatorCompat oldViewAnim = ViewCompat.animate(view).setDuration(
+ getChangeDuration());
+ changeAnimations.add(changeInfo.oldHolder);
+ oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX);
+ oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY);
+ oldViewAnim.alpha(0).setListener(new VpaListenerAdapter() {
+ @Override
+ public void onAnimationStart(View view) {
+ dispatchChangeStarting(changeInfo.oldHolder, true);
+ }
+
+ @Override
+ public void onAnimationEnd(View view) {
+ oldViewAnim.setListener(null);
+ resetViewProperties(view);
+ dispatchChangeFinished(changeInfo.oldHolder, true);
+ changeAnimations.remove(changeInfo.oldHolder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+ }
+ if (newView != null) {
+ final ViewPropertyAnimatorCompat newViewAnimation = ViewCompat.animate(newView);
+ changeAnimations.add(changeInfo.newHolder);
+ newViewAnimation.translationX(0).translationY(0).setDuration(getChangeDuration()).
+ alpha(1).setListener(new VpaListenerAdapter() {
+ @Override
+ public void onAnimationStart(View view) {
+ dispatchChangeStarting(changeInfo.newHolder, false);
+ }
+ @Override
+ public void onAnimationEnd(View view) {
+ newViewAnimation.setListener(null);
+ resetViewProperties(view);
+ dispatchChangeFinished(changeInfo.newHolder, false);
+ changeAnimations.remove(changeInfo.newHolder);
+ dispatchFinishedWhenDone();
+ }
+ }).start();
+}
+ }
+
+ private void endChangeAnimation(List<ChangeInfo> infoList, RecyclerView.ViewHolder item) {
+ for (int i = infoList.size() - 1; i >= 0; i--) {
+ final ChangeInfo changeInfo = infoList.get(i);
+ if (endChangeAnimationIfNecessary(changeInfo, item)) {
+ if (changeInfo.oldHolder == null && changeInfo.newHolder == null) {
+ infoList.remove(changeInfo);
+ }
+ }
+ }
+ }
+
+ private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) {
+ if (changeInfo.oldHolder != null) {
+ endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder);
+ }
+ if (changeInfo.newHolder != null) {
+ endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder);
+ }
+ }
+
+ private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, RecyclerView.ViewHolder item) {
+ boolean oldItem = false;
+ if (changeInfo.newHolder == item) {
+ changeInfo.newHolder = null;
+ } else if (changeInfo.oldHolder == item) {
+ changeInfo.oldHolder = null;
+ oldItem = true;
+ } else {
+ return false;
+ }
+ resetViewProperties(item.itemView);
+ dispatchChangeFinished(item, oldItem);
+ return true;
+ }
+
+ /**
+ * Called to reset all properties possibly animated by any and all defined animations.
+ */
+ protected void resetViewProperties(View view) {
+ view.setTranslationX(0);
+ view.setTranslationY(0);
+ view.setAlpha(1);
+ }
+
+ @Override
+ public void endAnimation(RecyclerView.ViewHolder item) {
+
+ final View view = item.itemView;
+ // This calls dispatch*Finished, resets view properties, and removes item from current
+ // animations list if the view is currently being animated.
+ ViewCompat.animate(view).cancel();
+ // TODO if some other animations are chained to end, how do we cancel them as well?
+ for (int i = pendingMoves.size() - 1; i >= 0; i--) {
+ final MoveInfo moveInfo = pendingMoves.get(i);
+ if (moveInfo.holder == item) {
+ resetViewProperties(view);
+ dispatchMoveFinished(item);
+ pendingMoves.remove(i);
+ }
+ }
+ endChangeAnimation(pendingChanges, item);
+ if (pendingRemovals.remove(item)) {
+ resetViewProperties(view);
+ dispatchRemoveFinished(item);
+ }
+ if (pendingAdditions.remove(item)) {
+ resetViewProperties(view);
+ dispatchAddFinished(item);
+ }
+
+ for (int i = changesList.size() - 1; i >= 0; i--) {
+ final List<ChangeInfo> changes = changesList.get(i);
+ endChangeAnimation(changes, item);
+ if (changes.isEmpty()) {
+ changesList.remove(i);
+ }
+ }
+ for (int i = movesList.size() - 1; i >= 0; i--) {
+ final List<MoveInfo> moves = movesList.get(i);
+ for (int j = moves.size() - 1; j >= 0; j--) {
+ final MoveInfo moveInfo = moves.get(j);
+ if (moveInfo.holder == item) {
+ resetViewProperties(view);
+ dispatchMoveFinished(item);
+ moves.remove(j);
+ if (moves.isEmpty()) {
+ movesList.remove(i);
+ }
+ break;
+ }
+ }
+ }
+ for (int i = additionsList.size() - 1; i >= 0; i--) {
+ final List<RecyclerView.ViewHolder> additions = additionsList.get(i);
+ if (additions.remove(item)) {
+ resetViewProperties(view);
+ dispatchAddFinished(item);
+ if (additions.isEmpty()) {
+ additionsList.remove(i);
+ }
+ }
+ }
+ dispatchFinishedWhenDone();
+ }
+
+ protected void resetAnimation(RecyclerView.ViewHolder holder) {
+ AnimatorCompatHelper.clearInterpolator(holder.itemView);
+ endAnimation(holder);
+ }
+
+ @Override
+ public boolean isRunning() {
+ return (!pendingAdditions.isEmpty() ||
+ !pendingChanges.isEmpty() ||
+ !pendingMoves.isEmpty() ||
+ !pendingRemovals.isEmpty() ||
+ !moveAnimations.isEmpty() ||
+ !removeAnimations.isEmpty() ||
+ !addAnimations.isEmpty() ||
+ !changeAnimations.isEmpty() ||
+ !movesList.isEmpty() ||
+ !additionsList.isEmpty() ||
+ !changesList.isEmpty());
+ }
+
+ /**
+ * Check the state of currently pending and running animations. If there are none
+ * pending/running, call {@link #dispatchAnimationsFinished()} to notify any
+ * listeners.
+ */
+ protected void dispatchFinishedWhenDone() {
+ if (!isRunning()) {
+ dispatchAnimationsFinished();
+ }
+ }
+
+ @Override
+ public void endAnimations() {
+ int count = pendingMoves.size();
+ for (int i = count - 1; i >= 0; i--) {
+ final MoveInfo item = pendingMoves.get(i);
+ resetViewProperties(item.holder.itemView);
+ dispatchMoveFinished(item.holder);
+ pendingMoves.remove(i);
+ }
+ count = pendingRemovals.size();
+ for (int i = count - 1; i >= 0; i--) {
+ final RecyclerView.ViewHolder item = pendingRemovals.get(i);
+ resetViewProperties(item.itemView);
+ dispatchRemoveFinished(item);
+ pendingRemovals.remove(i);
+ }
+ count = pendingAdditions.size();
+ for (int i = count - 1; i >= 0; i--) {
+ final RecyclerView.ViewHolder item = pendingAdditions.get(i);
+ resetViewProperties(item.itemView);
+ dispatchAddFinished(item);
+ pendingAdditions.remove(i);
+ }
+ count = pendingChanges.size();
+ for (int i = count - 1; i >= 0; i--) {
+ endChangeAnimationIfNecessary(pendingChanges.get(i));
+ }
+ pendingChanges.clear();
+ if (!isRunning()) {
+ return;
+ }
+
+ int listCount = movesList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ final List<MoveInfo> moves = movesList.get(i);
+ count = moves.size();
+ for (int j = count - 1; j >= 0; j--) {
+ final MoveInfo moveInfo = moves.get(j);
+ final RecyclerView.ViewHolder item = moveInfo.holder;
+ resetViewProperties(item.itemView);
+ dispatchMoveFinished(item);
+ moves.remove(j);
+ if (moves.isEmpty()) {
+ movesList.remove(moves);
+ }
+ }
+ }
+ listCount = additionsList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ final List<RecyclerView.ViewHolder> additions = additionsList.get(i);
+ count = additions.size();
+ for (int j = count - 1; j >= 0; j--) {
+ final RecyclerView.ViewHolder item = additions.get(j);
+ resetViewProperties(item.itemView);
+ dispatchAddFinished(item);
+ additions.remove(j);
+ if (additions.isEmpty()) {
+ additionsList.remove(additions);
+ }
+ }
+ }
+ listCount = changesList.size();
+ for (int i = listCount - 1; i >= 0; i--) {
+ final List<ChangeInfo> changes = changesList.get(i);
+ count = changes.size();
+ for (int j = count - 1; j >= 0; j--) {
+ endChangeAnimationIfNecessary(changes.get(j));
+ if (changes.isEmpty()) {
+ changesList.remove(changes);
+ }
+ }
+ }
+
+ cancelAll(removeAnimations);
+ cancelAll(moveAnimations);
+ cancelAll(addAnimations);
+ cancelAll(changeAnimations);
+
+ dispatchAnimationsFinished();
+ }
+
+ public void cancelAll(List<RecyclerView.ViewHolder> viewHolders) {
+ for (int i = viewHolders.size() - 1; i >= 0; i--) {
+ ViewCompat.animate(viewHolders.get(i).itemView).cancel();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * If the payload list is not empty, DefaultItemAnimator returns <code>true</code>.
+ * When this is the case:
+ * <ul>
+ * <li>If you override
+ * {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)},
+ * both ViewHolder arguments will be the same instance.
+ * </li>
+ * <li>
+ * If you are not overriding
+ * {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)},
+ * then DefaultItemAnimator will call
+ * {@link #animateMove(RecyclerView.ViewHolder, int, int, int, int)} and run a move animation
+ * instead.
+ * </li>
+ * </ul>
+ */
+ @Override
+ public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder,
+ @NonNull List<Object> payloads) {
+ return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads);
+ }
+
+ private class VpaListenerAdapter implements ViewPropertyAnimatorListener {
+ @Override
+ public void onAnimationStart(View view) {}
+
+ // Note that onAnimationEnd is called (in addition to OnAnimationCancel) whenever an
+ // animation is canceled.
+ @Override
+ public void onAnimationEnd(View view) {
+ resetViewProperties(view);
+ view.animate().setListener(null);
+ }
+
+ @Override
+ public void onAnimationCancel(View view) {}
+ }
+
+ protected class DefaultRemoveVpaListener extends VpaListenerAdapter {
+ private final RecyclerView.ViewHolder viewHolder;
+
+ public DefaultRemoveVpaListener(final RecyclerView.ViewHolder holder) {
+ viewHolder = holder;
+ }
+
+ @Override
+ public void onAnimationStart(View view) {
+ removeAnimations.add(viewHolder);
+ dispatchRemoveStarting(viewHolder);
+ }
+
+ @Override
+ public void onAnimationEnd(View view) {
+ removeAnimations.remove(viewHolder);
+ dispatchRemoveFinished(viewHolder);
+ dispatchFinishedWhenDone();
+ super.onAnimationEnd(view);
+ }
+ }
+
+ protected class DefaultAddVpaListener extends VpaListenerAdapter {
+ private final RecyclerView.ViewHolder viewHolder;
+
+ public DefaultAddVpaListener(final RecyclerView.ViewHolder holder) {
+ viewHolder = holder;
+ }
+
+ @Override
+ public void onAnimationStart(View view) {
+ addAnimations.add(viewHolder);
+ dispatchAddStarting(viewHolder);
+ }
+
+ @Override
+ public void onAnimationEnd(View view) {
+ addAnimations.remove(viewHolder);
+ dispatchAddFinished(viewHolder);
+ dispatchFinishedWhenDone();
+ super.onAnimationEnd(view);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java
new file mode 100644
index 000000000..7d32278e4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DoorHanger.java
@@ -0,0 +1,220 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.support.v4.content.ContextCompat;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewStub;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import org.json.JSONObject;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import java.util.Locale;
+
+public abstract class DoorHanger extends LinearLayout {
+
+ public static DoorHanger Get(Context context, DoorhangerConfig config) {
+ final Type type = config.getType();
+ switch (type) {
+ case LOGIN:
+ return new LoginDoorHanger(context, config);
+ case TRACKING:
+ return new ContentSecurityDoorHanger(context, config, type);
+ }
+ return new DefaultDoorHanger(context, config, type);
+ }
+
+ // Doorhanger types created from Gecko are checked against enum strings to determine type.
+ public static enum Type { DEFAULT, LOGIN, TRACKING, GEOLOCATION, DESKTOPNOTIFICATION2, WEBRTC, VIBRATION }
+
+ public interface OnButtonClickListener {
+ public void onButtonClick(JSONObject response, DoorHanger doorhanger);
+ }
+
+ private static final String LOGTAG = "GeckoDoorHanger";
+
+ // Divider between doorhangers.
+ private final View mDivider;
+
+ private final Button mNegativeButton;
+ private final Button mPositiveButton;
+ protected final OnButtonClickListener mOnButtonClickListener;
+
+ // The tab this doorhanger is associated with.
+ private final int mTabId;
+
+ // DoorHanger identifier.
+ private final String mIdentifier;
+
+ protected final Type mType;
+
+ protected final ImageView mIcon;
+ protected final TextView mLink;
+ protected final TextView mDoorhangerTitle;
+
+ protected final Context mContext;
+ protected final Resources mResources;
+
+ protected int mDividerColor;
+
+ protected boolean mPersistWhileVisible;
+ protected int mPersistenceCount;
+ protected long mTimeout;
+
+ protected DoorHanger(Context context, DoorhangerConfig config, Type type) {
+ super(context);
+
+ mContext = context;
+ mResources = context.getResources();
+ mTabId = config.getTabId();
+ mIdentifier = config.getId();
+ mType = type;
+
+ LayoutInflater.from(context).inflate(R.layout.doorhanger, this);
+ setOrientation(VERTICAL);
+
+ mDivider = findViewById(R.id.divider_doorhanger);
+ mIcon = (ImageView) findViewById(R.id.doorhanger_icon);
+ mLink = (TextView) findViewById(R.id.doorhanger_link);
+ mDoorhangerTitle = (TextView) findViewById(R.id.doorhanger_title);
+
+ mNegativeButton = (Button) findViewById(R.id.doorhanger_button_negative);
+ mPositiveButton = (Button) findViewById(R.id.doorhanger_button_positive);
+ mOnButtonClickListener = config.getButtonClickListener();
+
+ mDividerColor = ContextCompat.getColor(context, R.color.toolbar_divider_grey);
+
+ final ViewStub contentStub = (ViewStub) findViewById(R.id.content);
+ contentStub.setLayoutResource(getContentResource());
+ contentStub.inflate();
+
+ final String typeExtra = mType.toString().toLowerCase(Locale.US);
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.DOORHANGER, typeExtra);
+ }
+
+ protected abstract int getContentResource();
+
+ protected abstract void loadConfig(DoorhangerConfig config);
+
+ protected void setOptions(final JSONObject options) {
+ final int persistence = options.optInt("persistence");
+ if (persistence > 0) {
+ mPersistenceCount = persistence;
+ }
+
+ mPersistWhileVisible = options.optBoolean("persistWhileVisible");
+
+ final long timeout = options.optLong("timeout");
+ if (timeout > 0) {
+ mTimeout = timeout;
+ }
+ }
+
+ protected void addButtonsToLayout(DoorhangerConfig config) {
+ final DoorhangerConfig.ButtonConfig negativeButtonConfig = config.getNegativeButtonConfig();
+ final DoorhangerConfig.ButtonConfig positiveButtonConfig = config.getPositiveButtonConfig();
+
+ if (negativeButtonConfig != null) {
+ mNegativeButton.setText(negativeButtonConfig.label);
+ mNegativeButton.setOnClickListener(makeOnButtonClickListener(negativeButtonConfig.callback, "negative"));
+ mNegativeButton.setVisibility(VISIBLE);
+ }
+
+ if (positiveButtonConfig != null) {
+ mPositiveButton.setText(positiveButtonConfig.label);
+ mPositiveButton.setOnClickListener(makeOnButtonClickListener(positiveButtonConfig.callback, "positive"));
+ mPositiveButton.setVisibility(VISIBLE);
+ }
+ }
+
+ public int getTabId() {
+ return mTabId;
+ }
+
+ public String getIdentifier() {
+ return mIdentifier;
+ }
+
+ public void showDivider() {
+ mDivider.setVisibility(View.VISIBLE);
+ }
+
+ public void hideDivider() {
+ mDivider.setVisibility(View.GONE);
+ }
+
+ public void setIcon(int resId) {
+ mIcon.setImageResource(resId);
+ mIcon.setVisibility(View.VISIBLE);
+ }
+
+ protected void addLink(String label, final String url) {
+ mLink.setText(label);
+ mLink.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ final String typeExtra = mType.toString().toLowerCase(Locale.US);
+ Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.DOORHANGER, typeExtra);
+ Tabs.getInstance().loadUrlInTab(url);
+ }
+ });
+ mLink.setVisibility(VISIBLE);
+ }
+
+ protected abstract OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra);
+
+ /*
+ * Checks with persistence and timeout options to see if it's okay to remove a doorhanger.
+ *
+ * @param isShowing Whether or not this doorhanger is currently visible to the user.
+ * (e.g. the DoorHanger view might be VISIBLE, but its parent could be hidden)
+ */
+ public boolean shouldRemove(boolean isShowing) {
+ if (mPersistWhileVisible && isShowing) {
+ // We still want to decrement mPersistence, even if the popup is showing
+ if (mPersistenceCount != 0)
+ mPersistenceCount--;
+ return false;
+ }
+
+ // If persistence is set to -1, the doorhanger will never be
+ // automatically removed.
+ if (mPersistenceCount != 0) {
+ mPersistenceCount--;
+ return false;
+ }
+
+ if (System.currentTimeMillis() <= mTimeout) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public void showTitle(Bitmap favicon, String title) {
+ mDoorhangerTitle.setText(title);
+ mDoorhangerTitle.setCompoundDrawablesWithIntrinsicBounds(new BitmapDrawable(getResources(), favicon), null, null, null);
+ if (favicon != null) {
+ mDoorhangerTitle.setCompoundDrawablePadding((int) mContext.getResources().getDimension(R.dimen.doorhanger_drawable_padding));
+ }
+ mDoorhangerTitle.setVisibility(VISIBLE);
+ }
+
+ public void hideTitle() {
+ mDoorhangerTitle.setVisibility(GONE);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java b/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java
new file mode 100644
index 000000000..98f1e57e1
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DoorhangerConfig.java
@@ -0,0 +1,127 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.widget.DoorHanger.Type;
+
+public class DoorhangerConfig {
+
+ public static class Link {
+ public final String label;
+ public final String url;
+
+ private Link(String label, String url) {
+ this.label = label;
+ this.url = url;
+ }
+ }
+
+ public static class ButtonConfig {
+ public final String label;
+ public final int callback;
+
+ public ButtonConfig(String label, int callback) {
+ this.label = label;
+ this.callback = callback;
+ }
+ }
+ private static final String LOGTAG = "DoorhangerConfig";
+
+ private final int tabId;
+ private final String id;
+ private final DoorHanger.OnButtonClickListener buttonClickListener;
+ private final DoorHanger.Type type;
+ private String message;
+ private JSONObject options;
+ private Link link;
+ private ButtonConfig positiveButtonConfig;
+ private ButtonConfig negativeButtonConfig;
+
+ public DoorhangerConfig(Type type, DoorHanger.OnButtonClickListener listener) {
+ // XXX: This should only be used by SiteIdentityPopup doorhangers which
+ // don't need tab or id references, until bug 1141904 unifies doorhangers.
+
+ this(-1, null, type, listener);
+ }
+
+ public DoorhangerConfig(int tabId, String id, DoorHanger.Type type, DoorHanger.OnButtonClickListener buttonClickListener) {
+ this.tabId = tabId;
+ this.id = id;
+ this.type = type;
+ this.buttonClickListener = buttonClickListener;
+ }
+
+ public int getTabId() {
+ return tabId;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setOptions(JSONObject options) {
+ this.options = options;
+
+ // Set link if there is a link provided in options.
+ final JSONObject linkObj = options.optJSONObject("link");
+ if (linkObj != null) {
+ try {
+ setLink(linkObj.getString("label"), linkObj.getString("url"));
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Malformed link object in options");
+ }
+ }
+ }
+
+ public JSONObject getOptions() {
+ return options;
+ }
+
+ public void setButton(String label, int callbackId, boolean isPositive) {
+ final ButtonConfig buttonConfig = new ButtonConfig(label, callbackId);
+ if (isPositive) {
+ positiveButtonConfig = buttonConfig;
+ } else {
+ negativeButtonConfig = buttonConfig;
+ }
+ }
+
+ public ButtonConfig getPositiveButtonConfig() {
+ return positiveButtonConfig;
+ }
+
+ public ButtonConfig getNegativeButtonConfig() {
+ return negativeButtonConfig;
+ }
+
+ public DoorHanger.OnButtonClickListener getButtonClickListener() {
+ return this.buttonClickListener;
+ }
+
+ public void setLink(String label, String url) {
+ this.link = new Link(label, url);
+ }
+
+ public Link getLink() {
+ return link;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java
new file mode 100644
index 000000000..44f88e668
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/EllipsisTextView.java
@@ -0,0 +1,65 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.TextView;
+
+/**
+ * Text view that correctly handles maxLines and ellipsizing for Android < 2.3.
+ */
+public class EllipsisTextView extends TextView {
+ private final String ellipsis;
+
+ private final int maxLines;
+ private CharSequence originalText;
+
+ public EllipsisTextView(Context context) {
+ this(context, null);
+ }
+
+ public EllipsisTextView(Context context, AttributeSet attrs) {
+ this(context, attrs, android.R.attr.textViewStyle);
+ }
+
+ public EllipsisTextView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ ellipsis = getResources().getString(R.string.ellipsis);
+
+ TypedArray a = context.getTheme()
+ .obtainStyledAttributes(attrs, R.styleable.EllipsisTextView, 0, 0);
+ maxLines = a.getInteger(R.styleable.EllipsisTextView_ellipsizeAtLine, 1);
+ a.recycle();
+ }
+
+ public void setOriginalText(CharSequence text) {
+ originalText = text;
+ setText(text);
+ }
+
+ @Override
+ public void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+
+ // There is extra space, start over with the original text
+ if (getLineCount() < maxLines) {
+ setText(originalText);
+ }
+
+ // If we are over the max line attribute, ellipsize
+ if (getLineCount() > maxLines) {
+ final int endIndex = getLayout().getLineEnd(maxLines - 1) - 1 - ellipsis.length();
+ final String text = getText().subSequence(0, endIndex) + ellipsis;
+ // Make sure that we don't change originalText
+ setText(text);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java b/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java
new file mode 100644
index 000000000..b4d1e13d9
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ExternalIntentDuringPrivateBrowsingPromptFragment.java
@@ -0,0 +1,106 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.ActivityHandlerHelper;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import android.support.v4.app.DialogFragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v7.app.AlertDialog;
+import android.util.Log;
+
+import java.util.List;
+
+/**
+ * A DialogFragment to contain a dialog that appears when the user clicks an Intent:// URI during private browsing. The
+ * dialog appears to notify the user that a clicked link will open in an external application, potentially leaking their
+ * browsing history.
+ */
+public class ExternalIntentDuringPrivateBrowsingPromptFragment extends DialogFragment {
+ private static final String LOGTAG = ExternalIntentDuringPrivateBrowsingPromptFragment.class.getSimpleName();
+ private static final String FRAGMENT_TAG = "ExternalIntentPB";
+
+ private static final String KEY_APPLICATION_NAME = "matchingApplicationName";
+ private static final String KEY_INTENT = "intent";
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Bundle args = getArguments();
+ final CharSequence matchingApplicationName = args.getCharSequence(KEY_APPLICATION_NAME);
+ final Intent intent = args.getParcelable(KEY_INTENT);
+
+ final Context context = getActivity();
+ final String promptMessage = context.getString(R.string.intent_uri_private_browsing_prompt, matchingApplicationName);
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ builder.setMessage(promptMessage)
+ .setTitle(intent.getDataString())
+ .setPositiveButton(R.string.button_yes, new DialogInterface.OnClickListener() {
+ public void onClick(final DialogInterface dialog, final int id) {
+ context.startActivity(intent);
+ }
+ })
+ .setNegativeButton(R.string.button_no, null /* we do nothing if the user rejects */ );
+ return builder.create();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ GeckoApplication.watchReference(getActivity(), this);
+ }
+
+ /**
+ * @return true if the Activity is started or a dialog is shown. false if the Activity fails to start.
+ */
+ public static boolean showDialogOrAndroidChooser(final Context context, final FragmentManager fragmentManager,
+ final Intent intent) {
+ final Tab selectedTab = Tabs.getInstance().getSelectedTab();
+ if (selectedTab == null || !selectedTab.isPrivate()) {
+ return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, intent);
+ }
+
+ final PackageManager pm = context.getPackageManager();
+ final List<ResolveInfo> matchingActivities = pm.queryIntentActivities(intent, 0);
+ if (matchingActivities.size() == 1) {
+ final ExternalIntentDuringPrivateBrowsingPromptFragment fragment = new ExternalIntentDuringPrivateBrowsingPromptFragment();
+
+ final Bundle args = new Bundle(2);
+ args.putCharSequence(KEY_APPLICATION_NAME, matchingActivities.get(0).loadLabel(pm));
+ args.putParcelable(KEY_INTENT, intent);
+ fragment.setArguments(args);
+
+ fragment.show(fragmentManager, FRAGMENT_TAG);
+ // We don't know the results of the user interaction with the fragment so just return true.
+ return true;
+ } else if (matchingActivities.size() > 1) {
+ // We want to show the Android Intent Chooser. However, we have no way of distinguishing regular tabs from
+ // private tabs to the chooser. Thus, if a user chooses "Always" in regular browsing mode, the chooser will
+ // not be shown and the URL will be opened. Therefore we explicitly show the chooser (which notably does not
+ // have an "Always" option).
+ final String androidChooserTitle =
+ context.getResources().getString(R.string.intent_uri_private_browsing_multiple_match_title);
+ final Intent chooserIntent = Intent.createChooser(intent, androidChooserTitle);
+ return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, chooserIntent);
+ } else {
+ // Normally, we show about:neterror when an Intent does not resolve
+ // but we don't have the references here to do that so log instead.
+ Log.w(LOGTAG, "showDialogOrAndroidChooser unexpectedly called with Intent that does not resolve");
+ return false;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java
new file mode 100644
index 000000000..08bb55ef6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedMultiColorTextView.java
@@ -0,0 +1,108 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+
+/**
+ * Fades the end of the text by gecko:fadeWidth amount,
+ * if the text is too long and requires an ellipsis.
+ *
+ * This implementation is an improvement over Android's built-in fadingEdge
+ * but potentially slower than the {@link org.mozilla.gecko.widget.FadedSingleColorTextView}.
+ * It works for text of multiple colors but only one background color. It works by
+ * drawing a gradient rectangle with the background color over the text, fading it out.
+ */
+public class FadedMultiColorTextView extends FadedTextView {
+ private final ColorStateList fadeBackgroundColorList;
+
+ private final Paint fadePaint;
+ private FadedTextGradient backgroundGradient;
+
+ public FadedMultiColorTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ fadePaint = new Paint();
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FadedMultiColorTextView);
+ fadeBackgroundColorList =
+ a.getColorStateList(R.styleable.FadedMultiColorTextView_fadeBackgroundColor);
+ a.recycle();
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ final boolean needsEllipsis = needsEllipsis();
+ if (needsEllipsis) {
+ final int right = getWidth() - getCompoundPaddingRight();
+ final float left = right - fadeWidth;
+
+ updateGradientShader(needsEllipsis, right);
+
+ // Shrink height of gradient to prevent it overlaying parent view border.
+ // The shrunk size just nee to cover the text itself.
+ final float density = getResources().getDisplayMetrics().density;
+ final float h = Math.abs(fadePaint.getFontMetrics().top) + 1;
+ final float l = fadePaint.getFontMetrics().bottom + 1;
+ final float top = getBaseline() - h * density;
+ final float bottom = getBaseline() + l * density;
+
+ canvas.drawRect(left, top, right, bottom, fadePaint);
+ }
+ }
+
+ private void updateGradientShader(final boolean needsEllipsis, final int gradientEndRight) {
+ final int backgroundColor =
+ fadeBackgroundColorList.getColorForState(getDrawableState(), Color.RED);
+
+ final boolean needsNewGradient = (backgroundGradient == null ||
+ backgroundGradient.getBackgroundColor() != backgroundColor ||
+ backgroundGradient.getEndRight() != gradientEndRight);
+
+ if (needsEllipsis && needsNewGradient) {
+ backgroundGradient = new FadedTextGradient(gradientEndRight, fadeWidth, backgroundColor);
+ fadePaint.setShader(backgroundGradient);
+ }
+ }
+
+ private static class FadedTextGradient extends LinearGradient {
+ private final int endRight;
+ private final int backgroundColor;
+
+ public FadedTextGradient(final int gradientEndRight, final int fadeWidth,
+ final int backgroundColor) {
+ super(gradientEndRight - fadeWidth, 0, gradientEndRight, 0,
+ getColorWithZeroedAlpha(backgroundColor), backgroundColor, Shader.TileMode.CLAMP);
+
+ this.endRight = gradientEndRight;
+ this.backgroundColor = backgroundColor;
+ }
+
+ private static int getColorWithZeroedAlpha(final int color) {
+ return Color.argb(0, Color.red(color), Color.green(color), Color.blue(color));
+ }
+
+ public int getEndRight() {
+ return endRight;
+ }
+
+ public int getBackgroundColor() {
+ return backgroundColor;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java
new file mode 100644
index 000000000..866b7ecbd
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedSingleColorTextView.java
@@ -0,0 +1,74 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.LinearGradient;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+
+/**
+ * Fades the end of the text by gecko:fadeWidth amount,
+ * if the text is too long and requires an ellipsis.
+ *
+ * This implementation is an improvement over Android's built-in fadingEdge
+ * and the fastest of Fennec's implementations. However, it only works for
+ * text of one color. It works by applying a linear gradient directly to the text.
+ */
+public class FadedSingleColorTextView extends FadedTextView {
+ // Shader for the fading edge.
+ private FadedTextGradient mTextGradient;
+
+ public FadedSingleColorTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ private void updateGradientShader() {
+ final int color = getCurrentTextColor();
+ final int width = getAvailableWidth();
+
+ final boolean needsNewGradient = (mTextGradient == null ||
+ mTextGradient.getColor() != color ||
+ mTextGradient.getWidth() != width);
+
+ final boolean needsEllipsis = needsEllipsis();
+ if (needsEllipsis && needsNewGradient) {
+ mTextGradient = new FadedTextGradient(width, fadeWidth, color);
+ }
+
+ getPaint().setShader(needsEllipsis ? mTextGradient : null);
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ updateGradientShader();
+ super.onDraw(canvas);
+ }
+
+ private static class FadedTextGradient extends LinearGradient {
+ private final int mWidth;
+ private final int mColor;
+
+ public FadedTextGradient(int width, int fadeWidth, int color) {
+ super(0, 0, width, 0,
+ new int[] { color, color, 0x0 },
+ new float[] { 0, ((float) (width - fadeWidth) / width), 1.0f },
+ Shader.TileMode.CLAMP);
+
+ mWidth = width;
+ mColor = color;
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getColor() {
+ return mColor;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java
new file mode 100644
index 000000000..e10433083
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FadedTextView.java
@@ -0,0 +1,48 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.text.Layout;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.widget.themed.ThemedTextView;
+
+/**
+ * An implementation of FadedTextView should fade the end of the text
+ * by gecko:fadeWidth amount, if the text is too long and requires an ellipsis.
+ */
+public abstract class FadedTextView extends ThemedTextView {
+ // Width of the fade effect from end of the view.
+ protected final int fadeWidth;
+
+ public FadedTextView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+
+ setSingleLine(true);
+ setEllipsize(null);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FadedTextView);
+ fadeWidth = a.getDimensionPixelSize(R.styleable.FadedTextView_fadeWidth, 0);
+ a.recycle();
+ }
+
+ protected int getAvailableWidth() {
+ return getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight();
+ }
+
+ protected boolean needsEllipsis() {
+ final int width = getAvailableWidth();
+ if (width <= 0) {
+ return false;
+ }
+
+ final Layout layout = getLayout();
+ return (layout != null && layout.getLineWidth(0) > width);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java
new file mode 100644
index 000000000..4652345b4
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FaviconView.java
@@ -0,0 +1,268 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.icons.IconCallback;
+import org.mozilla.gecko.icons.IconResponse;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.widget.ImageView;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * Special version of ImageView for favicons.
+ * Displays solid colour background around Favicon to fill space not occupied by the icon. Colour
+ * selected is the dominant colour of the provided Favicon.
+ */
+public class FaviconView extends ImageView {
+ private static final String LOGTAG = "GeckoFaviconView";
+
+ private static String DEFAULT_FAVICON_KEY = FaviconView.class.getSimpleName() + "DefaultFavicon";
+
+ // Default x/y-radius of the oval used to round the corners of the background (dp)
+ private static final int DEFAULT_CORNER_RADIUS_DP = 4;
+
+ private Bitmap mIconBitmap;
+
+ // Reference to the unscaled bitmap, if any, to prevent repeated assignments of the same bitmap
+ // to the view from causing repeated rescalings (Some of the callers do this)
+ private Bitmap mUnscaledBitmap;
+
+ private int mActualWidth;
+ private int mActualHeight;
+
+ // Flag indicating if the most recently assigned image is considered likely to need scaling.
+ private boolean mScalingExpected;
+
+ // Dominant color of the favicon.
+ private int mDominantColor;
+
+ // Paint for drawing the background.
+ private static final Paint sBackgroundPaint;
+
+ // Size of the background rectangle.
+ private final RectF mBackgroundRect;
+
+ // The x/y-radius of the oval used to round the corners of the background (pixels)
+ private final float mBackgroundCornerRadius;
+
+ // Type of the border whose value is defined in attrs.xml .
+ private final boolean isDominantBorderEnabled;
+
+ // boolean switch for overriding scaletype, whose value is defined in attrs.xml .
+ private final boolean isOverrideScaleTypeEnabled;
+
+ // boolean switch for disabling rounded corners, value defined in attrs.xml .
+ private final boolean areRoundCornersEnabled;
+
+ // Initializing the static paints.
+ static {
+ sBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ sBackgroundPaint.setStyle(Paint.Style.FILL);
+ }
+
+ public FaviconView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.FaviconView, 0, 0);
+
+ try {
+ isDominantBorderEnabled = a.getBoolean(R.styleable.FaviconView_dominantBorderEnabled, true);
+ isOverrideScaleTypeEnabled = a.getBoolean(R.styleable.FaviconView_overrideScaleType, true);
+ areRoundCornersEnabled = a.getBoolean(R.styleable.FaviconView_enableRoundCorners, true);
+ } finally {
+ a.recycle();
+ }
+
+ if (isOverrideScaleTypeEnabled) {
+ setScaleType(ImageView.ScaleType.CENTER);
+ }
+
+ final DisplayMetrics metrics = getResources().getDisplayMetrics();
+
+ mBackgroundRect = new RectF(0, 0, 0, 0);
+ mBackgroundCornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_CORNER_RADIUS_DP, metrics);
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+
+ // No point rechecking the image if there hasn't really been any change.
+ if (w == mActualWidth && h == mActualHeight) {
+ return;
+ }
+
+ mActualWidth = w;
+ mActualHeight = h;
+
+ mBackgroundRect.right = w;
+ mBackgroundRect.bottom = h;
+
+ formatImage();
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ if (isDominantBorderEnabled) {
+ sBackgroundPaint.setColor(mDominantColor & 0x7FFFFFFF);
+
+ if (areRoundCornersEnabled) {
+ canvas.drawRoundRect(mBackgroundRect, mBackgroundCornerRadius, mBackgroundCornerRadius, sBackgroundPaint);
+ } else {
+ canvas.drawRect(mBackgroundRect, sBackgroundPaint);
+ }
+ }
+
+ super.onDraw(canvas);
+ }
+
+ /**
+ * Formats the image for display, if the prerequisite data are available. Upscales tiny Favicons to
+ * normal sized ones, replaces null bitmaps with the default Favicon, and fills all remaining space
+ * in this view with the coloured background.
+ */
+ private void formatImage() {
+ // We're waiting for both onSizeChanged and updateImage to be called before scaling.
+ if (mIconBitmap == null || mActualWidth == 0 || mActualHeight == 0) {
+ showNoImage();
+ return;
+ }
+
+ if (mScalingExpected && mActualWidth != mIconBitmap.getWidth()) {
+ scaleBitmap();
+ // Don't scale the image every time something changes.
+ mScalingExpected = false;
+ }
+
+ setImageBitmap(mIconBitmap);
+
+ // After scaling, determine if we have empty space around the scaled image which we need to
+ // fill with the coloured background. If applicable, show it.
+ // We assume Favicons are still squares and only bother with the background if more than 3px
+ // of it would be displayed.
+ if (Math.abs(mIconBitmap.getWidth() - mActualWidth) < 3) {
+ mDominantColor = 0;
+ }
+ }
+
+ private void scaleBitmap() {
+ // If the Favicon can be resized to fill the view exactly without an enlargment of more than
+ // a factor of two, do so.
+ int doubledSize = mIconBitmap.getWidth() * 2;
+ if (mActualWidth > doubledSize) {
+ // If the view is more than twice the size of the image, just double the image size
+ // and do the rest with padding.
+ mIconBitmap = Bitmap.createScaledBitmap(mIconBitmap, doubledSize, doubledSize, true);
+ } else {
+ // Otherwise, scale the image to fill the view.
+ mIconBitmap = Bitmap.createScaledBitmap(mIconBitmap, mActualWidth, mActualWidth, true);
+ }
+ }
+
+ /**
+ * Sets the icon displayed in this Favicon view to the bitmap provided. If the size of the view
+ * has been set, the display will be updated right away, otherwise the update will be deferred
+ * until then. The key provided is used to cache the result of the calculation of the dominant
+ * colour of the provided image - this value is used to draw the coloured background in this view
+ * if the icon is not large enough to fill it.
+ *
+ * @param allowScaling If true, allows the provided bitmap to be scaled by this FaviconView.
+ * Typically, you should prefer using Favicons obtained via the caching system
+ * (Favicons class), so as to exploit caching.
+ */
+ private void updateImageInternal(IconResponse response, boolean allowScaling) {
+ // Reassigning the same bitmap? Don't bother.
+ if (mUnscaledBitmap == response.getBitmap()) {
+ return;
+ }
+ mUnscaledBitmap = response.getBitmap();
+ mIconBitmap = response.getBitmap();
+ mDominantColor = response.getColor();
+ mScalingExpected = allowScaling;
+
+ // Possibly update the display.
+ formatImage();
+ }
+
+ private void showNoImage() {
+ setImageDrawable(null);
+ mDominantColor = 0;
+ }
+
+ /**
+ * Clear image and background shown by this view.
+ */
+ public void clearImage() {
+ showNoImage();
+ mUnscaledBitmap = null;
+ mIconBitmap = null;
+ mDominantColor = 0;
+ mScalingExpected = false;
+ }
+
+ /**
+ * Update the displayed image and apply the scaling logic.
+ * The scaling logic will attempt to resize the image to fit correctly inside the view in a way
+ * that avoids unreasonable levels of loss of quality.
+ * Scaling is necessary only when the icon being provided is not drawn from the Favicon cache
+ * introduced in Bug 914296.
+ *
+ * Due to Bug 913746, icons bundled for search engines are not available to the cache, so must
+ * always have the scaling logic applied here. At the time of writing, this is the only case in
+ * which the scaling logic here is applied.
+ */
+ public void updateAndScaleImage(IconResponse response) {
+ updateImageInternal(response, true);
+ }
+
+ /**
+ * Update the image displayed in the Favicon view without scaling. Images larger than the view
+ * will be centrally cropped. Images smaller than the view will be placed centrally and the
+ * extra space filled with the dominant colour of the provided image.
+ */
+ public void updateImage(IconResponse response) {
+ updateImageInternal(response, false);
+ }
+
+ public Bitmap getBitmap() {
+ return mIconBitmap;
+ }
+
+ /**
+ * Create an IconCallback implementation that will update this view after an icon has been loaded.
+ */
+ public IconCallback createIconCallback() {
+ return new Callback(this);
+ }
+
+ private static class Callback implements IconCallback {
+ private final WeakReference<FaviconView> viewReference;
+
+ private Callback(FaviconView view) {
+ this.viewReference = new WeakReference<FaviconView>(view);
+ }
+
+ @Override
+ public void onIconResponse(IconResponse response) {
+ final FaviconView view = viewReference.get();
+ if (view == null) {
+ return;
+ }
+
+ view.updateImage(response);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java b/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java
new file mode 100644
index 000000000..f1662896e
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FilledCardView.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.support.v7.widget.CardView;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.AppConstants;
+
+/**
+ * CardView that ensures its content can fill the entire card. Use this instead of CardView
+ * if you want to fill the card with e.g. images, backgrounds, etc.
+ *
+ * On API < 21, CardView content isn't clipped for performance reasons. We work around this by disabling
+ * rounded corners on those devices.
+ */
+public class FilledCardView extends CardView {
+
+ public FilledCardView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ // Disable corners on < lollipop:
+ // CardView only supports clipping content on API >= 21 (for performance reasons). Without
+ // content clipping, any cards that provide their own content that fills the card will look
+ // ugly: by default there is a 2px white edge along the top and sides (i.e. an inset corresponding
+ // to the corner radius), if we disable the inset then the corners overlap.
+ // It's possible to implement custom clipping, however given that the support library
+ // chose not to support this for performance reasons, we too have chosen to just disable
+ // corners on < 21, see Bug 1271428.
+ if (AppConstants.Versions.preLollipop) {
+ setRadius(0);
+ }
+
+ setUseCompatPadding(true);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java
new file mode 100644
index 000000000..042e74851
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/FlowLayout.java
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+
+public class FlowLayout extends ViewGroup {
+ private int mSpacing;
+
+ public FlowLayout(Context context) {
+ super(context);
+ }
+
+ public FlowLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ TypedArray a = context.obtainStyledAttributes(attrs, org.mozilla.gecko.R.styleable.FlowLayout);
+ mSpacing = a.getDimensionPixelSize(R.styleable.FlowLayout_spacing, (int) context.getResources().getDimension(R.dimen.flow_layout_spacing));
+ a.recycle();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ final int parentWidth = MeasureSpec.getSize(widthMeasureSpec);
+ final int childCount = getChildCount();
+ int rowWidth = 0;
+ int totalWidth = 0;
+ int totalHeight = 0;
+ boolean firstChild = true;
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child.getVisibility() == GONE)
+ continue;
+
+ measureChild(child, widthMeasureSpec, heightMeasureSpec);
+
+ final int childWidth = child.getMeasuredWidth();
+ final int childHeight = child.getMeasuredHeight();
+
+ if (firstChild || (rowWidth + childWidth > parentWidth)) {
+ rowWidth = 0;
+ totalHeight += childHeight;
+ if (!firstChild)
+ totalHeight += mSpacing;
+ firstChild = false;
+ }
+
+ rowWidth += childWidth;
+
+ if (rowWidth > totalWidth)
+ totalWidth = rowWidth;
+
+ rowWidth += mSpacing;
+ }
+
+ setMeasuredDimension(totalWidth, totalHeight);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ final int childCount = getChildCount();
+ final int totalWidth = r - l;
+ int x = 0;
+ int y = 0;
+ int prevChildHeight = 0;
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child.getVisibility() == GONE)
+ continue;
+
+ final int childWidth = child.getMeasuredWidth();
+ final int childHeight = child.getMeasuredHeight();
+ if (x + childWidth > totalWidth) {
+ x = 0;
+ y += prevChildHeight + mSpacing;
+ }
+ prevChildHeight = childHeight;
+ child.layout(x, y, x + childWidth, y + childHeight);
+ x += childWidth + mSpacing;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java
new file mode 100644
index 000000000..d864792a6
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoActionProvider.java
@@ -0,0 +1,360 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.app.Activity;
+import android.net.Uri;
+import android.support.design.widget.Snackbar;
+import android.util.Base64;
+import android.view.Menu;
+
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.SnackbarBuilder;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+import org.mozilla.gecko.overlays.ui.ShareDialog;
+import org.mozilla.gecko.menu.MenuItemSwitcherLayout;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.view.MenuItem;
+import android.view.MenuItem.OnMenuItemClickListener;
+import android.view.SubMenu;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.text.TextUtils;
+import android.webkit.MimeTypeMap;
+import android.webkit.URLUtil;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class GeckoActionProvider {
+ private static final int MAX_HISTORY_SIZE_DEFAULT = 2;
+
+ /**
+ * A listener to know when a target was selected.
+ * When setting a provider, the activity can listen to this,
+ * to close the menu.
+ */
+ public interface OnTargetSelectedListener {
+ public void onTargetSelected();
+ }
+
+ final Context mContext;
+
+ public final static String DEFAULT_MIME_TYPE = "text/plain";
+
+ public static final String DEFAULT_HISTORY_FILE_NAME = "history.xml";
+
+ // History file.
+ String mHistoryFileName = DEFAULT_HISTORY_FILE_NAME;
+
+ OnTargetSelectedListener mOnTargetListener;
+
+ private final Callbacks mCallbacks = new Callbacks();
+
+ private static final HashMap<String, GeckoActionProvider> mProviders = new HashMap<String, GeckoActionProvider>();
+
+ private static String getFilenameFromMimeType(String mimeType) {
+ String[] mime = mimeType.split("/");
+
+ // All text mimetypes use the default provider
+ if ("text".equals(mime[0])) {
+ return DEFAULT_HISTORY_FILE_NAME;
+ }
+
+ return "history-" + mime[0] + ".xml";
+ }
+
+ // Gets the action provider for a particular mimetype
+ public static GeckoActionProvider getForType(String mimeType, Context context) {
+ if (!mProviders.keySet().contains(mimeType)) {
+ GeckoActionProvider provider = new GeckoActionProvider(context);
+
+ // For empty types, we just return a default provider
+ if (TextUtils.isEmpty(mimeType)) {
+ return provider;
+ }
+
+ provider.setHistoryFileName(getFilenameFromMimeType(mimeType));
+ mProviders.put(mimeType, provider);
+ }
+ return mProviders.get(mimeType);
+ }
+
+ public GeckoActionProvider(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Creates the action view using the default history size.
+ */
+ public View onCreateActionView(final ActionViewType viewType) {
+ return onCreateActionView(MAX_HISTORY_SIZE_DEFAULT, viewType);
+ }
+
+ public View onCreateActionView(final int maxHistorySize, final ActionViewType viewType) {
+ // Create the view and set its data model.
+ ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+ final MenuItemSwitcherLayout view;
+ switch (viewType) {
+ case DEFAULT:
+ view = new MenuItemSwitcherLayout(mContext, null);
+ break;
+
+ case CONTEXT_MENU:
+ view = new MenuItemSwitcherLayout(mContext, null);
+ view.initContextMenuStyles();
+ break;
+
+ default:
+ throw new IllegalArgumentException(
+ "Unknown " + ActionViewType.class.getSimpleName() + ": " + viewType);
+ }
+ view.addActionButtonClickListener(mCallbacks);
+
+ final PackageManager packageManager = mContext.getPackageManager();
+ int historySize = dataModel.getDistinctActivityCountInHistory();
+ if (historySize > maxHistorySize) {
+ historySize = maxHistorySize;
+ }
+
+ // Historical data is dependent on past selection of activities.
+ // Activity count is determined by the number of activities that can handle
+ // the particular intent. When no intent is set, the activity count is 0,
+ // while the history count can be a valid number.
+ if (historySize > dataModel.getActivityCount()) {
+ return view;
+ }
+
+ for (int i = 0; i < historySize; i++) {
+ view.addActionButton(dataModel.getActivity(i).loadIcon(packageManager),
+ dataModel.getActivity(i).loadLabel(packageManager));
+ }
+
+ return view;
+ }
+
+ public boolean hasSubMenu() {
+ return true;
+ }
+
+ public void onPrepareSubMenu(SubMenu subMenu) {
+ // Clear since the order of items may change.
+ subMenu.clear();
+
+ ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+ PackageManager packageManager = mContext.getPackageManager();
+
+ // Populate the sub-menu with a sub set of the activities.
+ final String shareDialogClassName = ShareDialog.class.getCanonicalName();
+ final String sendTabLabel = mContext.getResources().getString(R.string.overlay_share_send_other);
+ final int count = dataModel.getActivityCount();
+ for (int i = 0; i < count; i++) {
+ ResolveInfo activity = dataModel.getActivity(i);
+ final CharSequence activityLabel = activity.loadLabel(packageManager);
+
+ // Pin internal actions to the top. Note:
+ // the order here does not affect quick share.
+ final int order;
+ if (shareDialogClassName.equals(activity.activityInfo.name) &&
+ sendTabLabel.equals(activityLabel)) {
+ order = Menu.FIRST + i;
+ } else {
+ order = Menu.FIRST + (i | Menu.CATEGORY_SECONDARY);
+ }
+
+ subMenu.add(0, i, order, activityLabel)
+ .setIcon(activity.loadIcon(packageManager))
+ .setOnMenuItemClickListener(mCallbacks);
+ }
+ }
+
+ public void setHistoryFileName(String historyFile) {
+ mHistoryFileName = historyFile;
+ }
+
+ public Intent getIntent() {
+ ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+ return dataModel.getIntent();
+ }
+
+ public void setIntent(Intent intent) {
+ ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+ dataModel.setIntent(intent);
+
+ // Inform the target listener to refresh it's UI, if needed.
+ if (mOnTargetListener != null) {
+ mOnTargetListener.onTargetSelected();
+ }
+ }
+
+ public void setOnTargetSelectedListener(OnTargetSelectedListener listener) {
+ mOnTargetListener = listener;
+ }
+
+ public ArrayList<ResolveInfo> getSortedActivities() {
+ ArrayList<ResolveInfo> infos = new ArrayList<ResolveInfo>();
+
+ ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+
+ // Populate the sub-menu with a sub set of the activities.
+ final int count = dataModel.getActivityCount();
+ for (int i = 0; i < count; i++) {
+ infos.add(dataModel.getActivity(i));
+ }
+
+ return infos;
+ }
+
+ public void chooseActivity(int position) {
+ mCallbacks.chooseActivity(position);
+ }
+
+ /**
+ * Listener for handling default activity / menu item clicks.
+ */
+ private class Callbacks implements OnMenuItemClickListener,
+ OnClickListener {
+ void chooseActivity(int index) {
+ final ActivityChooserModel dataModel = ActivityChooserModel.get(mContext, mHistoryFileName);
+ final Intent launchIntent = dataModel.chooseActivity(index);
+ if (launchIntent != null) {
+ // This may cause a download to happen. Make sure we're on the background thread.
+ ThreadUtils.postToBackgroundThread(new Runnable() {
+ @Override
+ public void run() {
+ // Share image downloads the image before sharing it.
+ String type = launchIntent.getType();
+ if (Intent.ACTION_SEND.equals(launchIntent.getAction()) && type != null && type.startsWith("image/")) {
+ downloadImageForIntent(launchIntent);
+ }
+
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
+ mContext.startActivity(launchIntent);
+ }
+ });
+ }
+
+ if (mOnTargetListener != null) {
+ mOnTargetListener.onTargetSelected();
+ }
+ }
+
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ chooseActivity(item.getItemId());
+
+ // Context: Sharing via chrome mainmenu list (no explicit session is active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.LIST, "actionprovider");
+ return true;
+ }
+
+ @Override
+ public void onClick(View view) {
+ Integer index = (Integer) view.getTag();
+ chooseActivity(index);
+
+ // Context: Sharing via chrome mainmenu and content contextmenu quickshare (no explicit session is active)
+ Telemetry.sendUIEvent(TelemetryContract.Event.SHARE, TelemetryContract.Method.BUTTON, "actionprovider");
+ }
+ }
+
+ public enum ActionViewType {
+ DEFAULT,
+ CONTEXT_MENU,
+ }
+
+
+ /**
+ * Downloads the URI pointed to by a share intent, and alters the intent to point to the
+ * locally stored file.
+ *
+ * @param intent share intent to alter in place.
+ */
+ public void downloadImageForIntent(final Intent intent) {
+ final String src = IntentUtils.getStringExtraSafe(intent, Intent.EXTRA_TEXT);
+ final File dir = GeckoApp.getTempDirectory();
+
+ if (src == null || dir == null) {
+ // We should be, but currently aren't, statically guaranteed an Activity context.
+ // Try our best.
+ if (mContext instanceof Activity) {
+ SnackbarBuilder.builder((Activity) mContext)
+ .message(mContext.getApplicationContext().getString(R.string.share_image_failed))
+ .duration(Snackbar.LENGTH_LONG)
+ .buildAndShow();
+ }
+ return;
+ }
+
+ GeckoApp.deleteTempFiles();
+
+ String type = intent.getType();
+ OutputStream os = null;
+ try {
+ // Create a temporary file for the image
+ if (src.startsWith("data:")) {
+ final int dataStart = src.indexOf(",");
+
+ String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type);
+
+ // If we weren't given an explicit mimetype, try to dig one out of the data uri.
+ if (TextUtils.isEmpty(extension) && dataStart > 5) {
+ type = src.substring(5, dataStart).replace(";base64", "");
+ extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type);
+ }
+
+ final File imageFile = File.createTempFile("image", "." + extension, dir);
+ os = new FileOutputStream(imageFile);
+
+ byte[] buf = Base64.decode(src.substring(dataStart + 1), Base64.DEFAULT);
+ os.write(buf);
+
+ // Only alter the intent when we're sure everything has worked
+ intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(imageFile));
+ } else {
+ InputStream is = null;
+ try {
+ final byte[] buf = new byte[2048];
+ final URL url = new URL(src);
+ final String filename = URLUtil.guessFileName(src, null, type);
+ is = url.openStream();
+
+ final File imageFile = new File(dir, filename);
+ os = new FileOutputStream(imageFile);
+
+ int length;
+ while ((length = is.read(buf)) != -1) {
+ os.write(buf, 0, length);
+ }
+
+ // Only alter the intent when we're sure everything has worked
+ intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(imageFile));
+ } finally {
+ IOUtils.safeStreamClose(is);
+ }
+ }
+ } catch (IOException ex) {
+ // If something went wrong, we'll just leave the intent un-changed
+ } finally {
+ IOUtils.safeStreamClose(os);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java
new file mode 100644
index 000000000..7e7f50662
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/GeckoPopupMenu.java
@@ -0,0 +1,189 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.menu.GeckoMenu;
+import org.mozilla.gecko.menu.GeckoMenuInflater;
+import org.mozilla.gecko.menu.MenuPanel;
+import org.mozilla.gecko.menu.MenuPopup;
+
+import android.content.Context;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+
+/**
+ * A PopupMenu that uses the custom GeckoMenu. This menu is
+ * usually tied to an anchor, and show as a dropdrown from the anchor.
+ */
+public class GeckoPopupMenu implements GeckoMenu.Callback,
+ GeckoMenu.MenuPresenter {
+
+ // An interface for listeners for dismissal.
+ public static interface OnDismissListener {
+ public boolean onDismiss(GeckoMenu menu);
+ }
+
+ // An interface for listeners for menu item click events.
+ public static interface OnMenuItemClickListener {
+ public boolean onMenuItemClick(MenuItem item);
+ }
+
+ // An interface for listeners for menu item long click events.
+ public static interface OnMenuItemLongClickListener {
+ public boolean onMenuItemLongClick(MenuItem item);
+ }
+
+ private View mAnchor;
+
+ private MenuPopup mMenuPopup;
+ private MenuPanel mMenuPanel;
+
+ private GeckoMenu mMenu;
+ private GeckoMenuInflater mMenuInflater;
+
+ private OnDismissListener mDismissListener;
+ private OnMenuItemClickListener mClickListener;
+ private OnMenuItemLongClickListener mLongClickListener;
+
+ public GeckoPopupMenu(Context context) {
+ initialize(context, null);
+ }
+
+ public GeckoPopupMenu(Context context, View anchor) {
+ initialize(context, anchor);
+ }
+
+ /**
+ * This method creates an empty menu and attaches the necessary listeners.
+ * If an anchor is supplied, it is stored as well.
+ */
+ private void initialize(Context context, View anchor) {
+ mMenu = new GeckoMenu(context, null);
+ mMenu.setCallback(this);
+ mMenu.setMenuPresenter(this);
+ mMenuInflater = new GeckoMenuInflater(context);
+
+ mMenuPopup = new MenuPopup(context);
+ mMenuPanel = new MenuPanel(context, null);
+
+ mMenuPanel.addView(mMenu);
+ mMenuPopup.setPanelView(mMenuPanel);
+
+ setAnchor(anchor);
+ }
+
+ /**
+ * Returns the menu that is current being shown.
+ *
+ * @return The menu being shown.
+ */
+ public GeckoMenu getMenu() {
+ return mMenu;
+ }
+
+ /**
+ * Returns the menu inflater that was used to create the menu.
+ *
+ * @return The menu inflater used.
+ */
+ public MenuInflater getMenuInflater() {
+ return mMenuInflater;
+ }
+
+ /**
+ * Inflates a menu resource to the menu using the menu inflater.
+ *
+ * @param menuRes The menu resource to be inflated.
+ */
+ public void inflate(int menuRes) {
+ if (menuRes > 0) {
+ mMenuInflater.inflate(menuRes, mMenu);
+ }
+ }
+
+ /**
+ * Set a different anchor after the menu is inflated.
+ *
+ * @param anchor The new anchor for the popup.
+ */
+ public void setAnchor(View anchor) {
+ mAnchor = anchor;
+
+ // Reposition the popup if the anchor changes while it's showing.
+ if (mMenuPopup.isShowing()) {
+ mMenuPopup.dismiss();
+ mMenuPopup.showAsDropDown(mAnchor);
+ }
+ }
+
+ public void setOnDismissListener(OnDismissListener listener) {
+ mDismissListener = listener;
+ }
+
+ public void setOnMenuItemClickListener(OnMenuItemClickListener listener) {
+ mClickListener = listener;
+ }
+
+ public void setOnMenuItemLongClickListener(OnMenuItemLongClickListener listener) {
+ mLongClickListener = listener;
+ }
+
+ /**
+ * Show the inflated menu.
+ */
+ public void show() {
+ if (!mMenuPopup.isShowing())
+ mMenuPopup.showAsDropDown(mAnchor);
+ }
+
+ /**
+ * Hide the inflated menu.
+ */
+ public void dismiss() {
+ if (mMenuPopup.isShowing()) {
+ mMenuPopup.dismiss();
+
+ if (mDismissListener != null)
+ mDismissListener.onDismiss(mMenu);
+ }
+ }
+
+ @Override
+ public boolean onMenuItemClick(MenuItem item) {
+ if (mClickListener != null) {
+ return mClickListener.onMenuItemClick(item);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onMenuItemLongClick(MenuItem item) {
+ if (mLongClickListener != null) {
+ return mLongClickListener.onMenuItemLongClick(item);
+ }
+ return false;
+ }
+
+ @Override
+ public void openMenu() {
+ show();
+ }
+
+ @Override
+ public void showMenu(View menu) {
+ mMenuPanel.removeAllViews();
+ mMenuPanel.addView(menu);
+
+ openMenu();
+ }
+
+ @Override
+ public void closeMenu() {
+ dismiss();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java b/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java
new file mode 100644
index 000000000..9c98e8a0d
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/HistoryDividerItemDecoration.java
@@ -0,0 +1,66 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.home.CombinedHistoryItem;
+
+public class HistoryDividerItemDecoration extends RecyclerView.ItemDecoration {
+ private final int mDividerHeight;
+ private final Paint mDividerPaint;
+
+ public HistoryDividerItemDecoration(Context context) {
+ mDividerHeight = (int) context.getResources().getDimension(R.dimen.page_row_divider_height);
+
+ mDividerPaint = new Paint();
+ mDividerPaint.setColor(ContextCompat.getColor(context, R.color.toolbar_divider_grey));
+ mDividerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
+ final int position = parent.getChildAdapterPosition(view);
+ if (position == RecyclerView.NO_POSITION) {
+ // This view is no longer corresponds to an adapter position (pending changes).
+ return;
+ }
+
+ if (parent.getAdapter().getItemViewType(position) !=
+ CombinedHistoryItem.ItemType.itemTypeToViewType(CombinedHistoryItem.ItemType.SECTION_HEADER)) {
+ outRect.set(0, 0, 0, mDividerHeight);
+ }
+ }
+
+ @Override
+ public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
+ if (parent.getChildCount() == 0) {
+ return;
+ }
+
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ final View child = parent.getChildAt(i);
+ final int position = parent.getChildAdapterPosition(child);
+
+ if (position == RecyclerView.NO_POSITION) {
+ // This view is no longer corresponds to an adapter position (pending changes).
+ continue;
+ }
+
+ if (parent.getAdapter().getItemViewType(position) !=
+ CombinedHistoryItem.ItemType.itemTypeToViewType(CombinedHistoryItem.ItemType.SECTION_HEADER)) {
+ final float bottom = child.getBottom() + child.getTranslationY();
+ c.drawRect(0, bottom, parent.getWidth(), bottom + mDividerHeight, mDividerPaint);
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java b/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java
new file mode 100644
index 000000000..71987bf8c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/IconTabWidget.java
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.TabWidget;
+import android.widget.TextView;
+
+public class IconTabWidget extends TabWidget {
+ OnTabChangedListener mListener;
+ private final int mButtonLayoutId;
+ private final boolean mIsIcon;
+
+ public static interface OnTabChangedListener {
+ public void onTabChanged(int tabIndex);
+ }
+
+ public IconTabWidget(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.IconTabWidget);
+ mButtonLayoutId = a.getResourceId(R.styleable.IconTabWidget_android_layout, 0);
+ mIsIcon = (a.getInt(R.styleable.IconTabWidget_display, 0x00) == 0x00);
+ a.recycle();
+
+ if (mButtonLayoutId == 0) {
+ throw new RuntimeException("You must supply layout attribute");
+ }
+ }
+
+ public View addTab(final int imageResId, final int stringResId) {
+ View button = LayoutInflater.from(getContext()).inflate(mButtonLayoutId, this, false);
+ if (mIsIcon) {
+ ((ImageButton) button).setImageResource(imageResId);
+ button.setContentDescription(getContext().getString(stringResId));
+ } else {
+ ((TextView) button).setText(getContext().getString(stringResId));
+ }
+
+ addView(button);
+ button.setOnClickListener(new TabClickListener(getTabCount() - 1));
+ button.setOnFocusChangeListener(this);
+ return button;
+ }
+
+ public void setTabSelectionListener(OnTabChangedListener listener) {
+ mListener = listener;
+ }
+
+ @Override
+ public void onFocusChange(View view, boolean hasFocus) {
+ }
+
+ private class TabClickListener implements OnClickListener {
+ private final int mIndex;
+
+ public TabClickListener(int index) {
+ mIndex = index;
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (mListener != null)
+ mListener.onTabChanged(mIndex);
+ }
+ }
+
+ /**
+ * Fetch the Drawable icon corresponding to the given panel.
+ * @param panel to fetch icon for.
+ * @return Drawable instance, or null if no icon is being displayed, or the icon does not exist.
+ */
+ public Drawable getIconDrawable(int index) {
+ if (!mIsIcon) {
+ return null;
+ }
+ // We can have multiple views in the tabs panel for each child. This finds the
+ // first view corresponding to the given tab. This varies by Android
+ // version. The first view should always be our ImageButton, but let's
+ // be safe.
+ final View view = getChildTabViewAt(index);
+ if (view instanceof ImageButton) {
+ return ((ImageButton) view).getDrawable();
+ }
+ return null;
+ }
+
+ public void setIconDrawable(int index, int resource) {
+ if (!mIsIcon) {
+ return;
+ }
+ // We can have multiple views in the tabs panel for each child. This finds the
+ // first view corresponding to the given tab. This varies by Android
+ // version. The first view should always be our ImageButton, but let's
+ // be safe.
+ final View view = getChildTabViewAt(index);
+ if (view instanceof ImageButton) {
+ ((ImageButton) view).setImageResource(resource);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java b/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java
new file mode 100644
index 000000000..232674813
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/LoginDoorHanger.java
@@ -0,0 +1,228 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.method.PasswordTransformationMethod;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Telemetry;
+import org.mozilla.gecko.TelemetryContract;
+
+import java.util.Locale;
+
+public class LoginDoorHanger extends DoorHanger {
+ private static final String LOGTAG = "LoginDoorHanger";
+ private enum ActionType { EDIT, SELECT }
+
+ private final TextView mMessage;
+ private final DoorhangerConfig.ButtonConfig mButtonConfig;
+
+ public LoginDoorHanger(Context context, DoorhangerConfig config) {
+ super(context, config, Type.LOGIN);
+
+ mMessage = (TextView) findViewById(R.id.doorhanger_message);
+ mIcon.setImageResource(R.drawable.icon_key);
+ mIcon.setVisibility(View.VISIBLE);
+
+ mButtonConfig = config.getPositiveButtonConfig();
+
+ loadConfig(config);
+ }
+
+ private void setMessage(String message) {
+ Spanned markupMessage = Html.fromHtml(message);
+ mMessage.setText(markupMessage);
+ }
+
+ @Override
+ protected void loadConfig(DoorhangerConfig config) {
+ setOptions(config.getOptions());
+ setMessage(config.getMessage());
+ // Store the positive callback id for nested dialogs that need the same callback id.
+ addButtonsToLayout(config);
+ }
+
+ @Override
+ protected int getContentResource() {
+ return R.layout.login_doorhanger;
+ }
+
+ @Override
+ protected void setOptions(final JSONObject options) {
+ super.setOptions(options);
+
+ final JSONObject actionText = options.optJSONObject("actionText");
+ addActionText(actionText);
+ }
+
+ @Override
+ protected OnClickListener makeOnButtonClickListener(final int id, final String telemetryExtra) {
+ return new Button.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final String expandedExtra = mType.toString().toLowerCase(Locale.US) + "-" + telemetryExtra;
+ Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.DOORHANGER, expandedExtra);
+
+ final JSONObject response = new JSONObject();
+ try {
+ response.put("callback", id);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error making doorhanger response message", e);
+ }
+ mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this);
+ }
+ };
+ }
+
+ /**
+ * Add sub-text to the doorhanger and add the click action.
+ *
+ * If the parsing the action from the JSON throws, the text is left visible, but there is no
+ * click action.
+ * @param actionTextObj JSONObject containing blob for making an action.
+ */
+ private void addActionText(JSONObject actionTextObj) {
+ if (actionTextObj == null) {
+ mLink.setVisibility(View.GONE);
+ return;
+ }
+
+ // Make action.
+ try {
+ final JSONObject bundle = actionTextObj.getJSONObject("bundle");
+ final ActionType type = ActionType.valueOf(actionTextObj.getString("type"));
+ final AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
+
+ switch (type) {
+ case EDIT:
+ builder.setTitle(mResources.getString(R.string.doorhanger_login_edit_title));
+
+ final View view = LayoutInflater.from(mContext).inflate(R.layout.login_edit_dialog, null);
+ final EditText username = (EditText) view.findViewById(R.id.username_edit);
+ username.setText(bundle.getString("username"));
+ final EditText password = (EditText) view.findViewById(R.id.password_edit);
+ password.setText(bundle.getString("password"));
+ final CheckBox passwordCheckbox = (CheckBox) view.findViewById(R.id.checkbox_toggle_password);
+ passwordCheckbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ if (isChecked) {
+ password.setTransformationMethod(null);
+ } else {
+ password.setTransformationMethod(PasswordTransformationMethod.getInstance());
+ }
+ }
+ });
+ builder.setView(view);
+
+ builder.setPositiveButton(mButtonConfig.label, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ JSONObject response = new JSONObject();
+ try {
+ response.put("callback", mButtonConfig.callback);
+ final JSONObject inputs = new JSONObject();
+ inputs.put("username", username.getText());
+ inputs.put("password", password.getText());
+ response.put("inputs", inputs);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error creating doorhanger reply message");
+ response = null;
+ Toast.makeText(mContext, mResources.getString(R.string.doorhanger_login_edit_toast_error), Toast.LENGTH_SHORT).show();
+ }
+ mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this);
+ }
+ });
+ builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+ });
+ String text = actionTextObj.optString("text");
+ if (TextUtils.isEmpty(text)) {
+ text = mResources.getString(R.string.doorhanger_login_no_username);
+ }
+ mLink.setText(text);
+ mLink.setVisibility(View.VISIBLE);
+ break;
+
+ case SELECT:
+ try {
+ builder.setTitle(mResources.getString(R.string.doorhanger_login_select_title));
+ final JSONArray logins = bundle.getJSONArray("logins");
+ final int numLogins = logins.length();
+ final CharSequence[] usernames = new CharSequence[numLogins];
+ final String[] passwords = new String[numLogins];
+ final String noUser = mResources.getString(R.string.doorhanger_login_no_username);
+ for (int i = 0; i < numLogins; i++) {
+ final JSONObject login = (JSONObject) logins.get(i);
+ String user = login.getString("username");
+ if (TextUtils.isEmpty(user)) {
+ user = noUser;
+ }
+ usernames[i] = user;
+ passwords[i] = login.getString("password");
+ }
+ builder.setItems(usernames, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ final JSONObject response = new JSONObject();
+ try {
+ response.put("callback", mButtonConfig.callback);
+ response.put("password", passwords[which]);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error making login select dialog JSON", e);
+ }
+ mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this);
+ }
+ });
+ builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+ });
+ mLink.setText(R.string.doorhanger_login_select_action_text);
+ mLink.setVisibility(View.VISIBLE);
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Problem creating list of logins");
+ }
+ break;
+ }
+
+ final Dialog dialog = builder.create();
+ mLink.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ dialog.show();
+ }
+ });
+
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Error fetching actionText from JSON", e);
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java b/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java
new file mode 100644
index 000000000..a0c6049c5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/RecyclerViewClickSupport.java
@@ -0,0 +1,105 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+import org.mozilla.gecko.R;
+
+/**
+ * {@link RecyclerViewClickSupport} implementation that will notify an OnClickListener about clicks and long clicks
+ * on items displayed by the RecyclerView.
+ * @see <a href="http://www.littlerobots.nl/blog/Handle-Android-RecyclerView-Clicks/">littlerobots.nl</a>
+ */
+public class RecyclerViewClickSupport {
+ private final RecyclerView mRecyclerView;
+ private OnItemClickListener mOnItemClickListener;
+ private OnItemLongClickListener mOnItemLongClickListener;
+ private View.OnClickListener mOnClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mOnItemClickListener != null) {
+ RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
+ mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v);
+ }
+ }
+ };
+ private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (mOnItemLongClickListener != null) {
+ RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
+ return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v);
+ }
+ return false;
+ }
+ };
+ private RecyclerView.OnChildAttachStateChangeListener mAttachListener
+ = new RecyclerView.OnChildAttachStateChangeListener() {
+ @Override
+ public void onChildViewAttachedToWindow(View view) {
+ if (mOnItemClickListener != null) {
+ view.setOnClickListener(mOnClickListener);
+ }
+ if (mOnItemLongClickListener != null) {
+ view.setOnLongClickListener(mOnLongClickListener);
+ }
+ }
+
+ @Override
+ public void onChildViewDetachedFromWindow(View view) {
+
+ }
+ };
+
+ private RecyclerViewClickSupport(RecyclerView recyclerView) {
+ mRecyclerView = recyclerView;
+ mRecyclerView.setTag(R.id.recycler_view_click_support, this);
+ mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener);
+ }
+
+ public static RecyclerViewClickSupport addTo(RecyclerView view) {
+ RecyclerViewClickSupport support = (RecyclerViewClickSupport) view.getTag(R.id.recycler_view_click_support);
+ if (support == null) {
+ support = new RecyclerViewClickSupport(view);
+ }
+ return support;
+ }
+
+ public static RecyclerViewClickSupport removeFrom(RecyclerView view) {
+ RecyclerViewClickSupport support = (RecyclerViewClickSupport) view.getTag(R.id.recycler_view_click_support);
+ if (support != null) {
+ support.detach(view);
+ }
+ return support;
+ }
+
+ public RecyclerViewClickSupport setOnItemClickListener(OnItemClickListener listener) {
+ mOnItemClickListener = listener;
+ return this;
+ }
+
+ public RecyclerViewClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) {
+ mOnItemLongClickListener = listener;
+ return this;
+ }
+
+ private void detach(RecyclerView view) {
+ view.removeOnChildAttachStateChangeListener(mAttachListener);
+ view.setTag(R.id.recycler_view_click_support, null);
+ }
+
+ public interface OnItemClickListener {
+
+ void onItemClicked(RecyclerView recyclerView, int position, View v);
+ }
+
+ public interface OnItemLongClickListener {
+
+ boolean onItemLongClicked(RecyclerView recyclerView, int position, View v);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java b/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java
new file mode 100644
index 000000000..ff0709cb7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ResizablePathDrawable.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.res.ColorStateList;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.Shape;
+
+public class ResizablePathDrawable extends ShapeDrawable {
+ // An attribute mirroring the super class' value. getAlpha() is only
+ // available in API 19+ so to use that alpha value, we have to mirror it.
+ private int alpha = 255;
+
+ private final ColorStateList colorStateList;
+ private int currentColor;
+
+ public ResizablePathDrawable(NonScaledPathShape shape, int color) {
+ this(shape, ColorStateList.valueOf(color));
+ }
+
+ public ResizablePathDrawable(NonScaledPathShape shape, ColorStateList colorStateList) {
+ super(shape);
+ this.colorStateList = colorStateList;
+ updateColor(getState());
+ }
+
+ private boolean updateColor(int[] stateSet) {
+ int newColor = colorStateList.getColorForState(stateSet, Color.WHITE);
+ if (newColor != currentColor) {
+ currentColor = newColor;
+ alpha = Color.alpha(currentColor);
+ invalidateSelf();
+ return true;
+ }
+
+ return false;
+ }
+
+ public Path getPath() {
+ final NonScaledPathShape shape = (NonScaledPathShape) getShape();
+ return shape.path;
+ }
+
+ @Override
+ public boolean isStateful() {
+ return true;
+ }
+
+ @Override
+ protected void onDraw(Shape shape, Canvas canvas, Paint paint) {
+ paint.setColor(currentColor);
+ // setAlpha overrides the alpha value in set color. Since we just set the color,
+ // the alpha value is reset: override the alpha value with the old value. We don't
+ // set alpha if the color is transparent.
+ //
+ // Note: We *should* be able to call Shape.setAlpha, rather than Paint.setAlpha, but
+ // then the opacity doesn't change - dunno why but probably not worth the time.
+ if (currentColor != Color.TRANSPARENT) {
+ paint.setAlpha(alpha);
+ }
+
+ super.onDraw(shape, canvas, paint);
+ }
+
+ @Override
+ public void setAlpha(final int alpha) {
+ super.setAlpha(alpha);
+ this.alpha = alpha;
+ }
+
+ @Override
+ protected boolean onStateChange(int[] stateSet) {
+ return updateColor(stateSet);
+ }
+
+ /**
+ * Path-based shape implementation that re-creates the path
+ * when it gets resized as opposed to PathShape's scaling
+ * behaviour.
+ */
+ public static class NonScaledPathShape extends Shape {
+ private Path path;
+
+ public NonScaledPathShape() {
+ path = new Path();
+ }
+
+ @Override
+ public void draw(Canvas canvas, Paint paint) {
+ // No point in drawing the shape if it's not
+ // going to be visible.
+ if (paint.getColor() == Color.TRANSPARENT) {
+ return;
+ }
+
+ canvas.drawPath(path, paint);
+ }
+
+ protected Path getPath() {
+ return path;
+ }
+
+ @Override
+ public NonScaledPathShape clone() throws CloneNotSupportedException {
+ final NonScaledPathShape clonedShape = (NonScaledPathShape) super.clone();
+ clonedShape.path = new Path(path);
+ return clonedShape;
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java
new file mode 100644
index 000000000..a102981ee
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/RoundedCornerLayout.java
@@ -0,0 +1,79 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.widget.LinearLayout;
+
+public class RoundedCornerLayout extends LinearLayout {
+ private static final String LOGTAG = "Gecko" + RoundedCornerLayout.class.getSimpleName();
+ private float cornerRadius;
+
+ private Path path;
+ boolean cannotClipPath;
+
+ public RoundedCornerLayout(Context context) {
+ super(context);
+ init(context);
+ }
+
+ public RoundedCornerLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context);
+ }
+
+ public RoundedCornerLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init(context);
+ }
+
+ private void init(Context context) {
+ // Bug 1201081 - clipPath with hardware acceleration crashes on r11-18.
+ cannotClipPath = !AppConstants.Versions.feature19Plus;
+
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+
+ cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX,
+ getResources().getDimensionPixelSize(R.dimen.doorhanger_rounded_corner_radius), metrics);
+
+ setWillNotDraw(false);
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ if (cannotClipPath) {
+ return;
+ }
+
+ final RectF r = new RectF(0, 0, w, h);
+ path = new Path();
+ path.addRoundRect(r, cornerRadius, cornerRadius, Path.Direction.CW);
+ path.close();
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ if (cannotClipPath) {
+ super.draw(canvas);
+ return;
+ }
+
+ canvas.save();
+ canvas.clipPath(path);
+ super.draw(canvas);
+ canvas.restore();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java b/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java
new file mode 100644
index 000000000..4d4a92275
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/SiteLogins.java
@@ -0,0 +1,16 @@
+package org.mozilla.gecko.widget;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+public class SiteLogins {
+ private final JSONArray logins;
+
+ public SiteLogins(JSONArray logins) {
+ this.logins = logins;
+ }
+
+ public JSONArray getLogins() {
+ return logins;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java
new file mode 100644
index 000000000..0b77e9d1c
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredImageView.java
@@ -0,0 +1,21 @@
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+final class SquaredImageView extends ImageView {
+ public SquaredImageView(Context context) {
+ super(context);
+ }
+
+ public SquaredImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ setMeasuredDimension(getMeasuredWidth(), getMeasuredWidth());
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java
new file mode 100644
index 000000000..c0dca0bec
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/SquaredRelativeLayout.java
@@ -0,0 +1,33 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.RelativeLayout;
+
+public class SquaredRelativeLayout extends RelativeLayout {
+ public SquaredRelativeLayout(Context context) {
+ super(context);
+ }
+
+ public SquaredRelativeLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SquaredRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ int squareMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY);
+
+ super.onMeasure(squareMeasureSpec, squareMeasureSpec);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java b/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java
new file mode 100644
index 000000000..8267fe8a3
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/SwipeDismissListViewTouchListener.java
@@ -0,0 +1,356 @@
+/*
+ * Copyright 2012 Roman Nurik
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.widget;
+
+import android.graphics.Rect;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.AbsListView.RecyclerListener;
+import android.widget.ListView;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.view.ViewPropertyAnimator;
+
+import org.mozilla.gecko.R;
+
+/**
+ * This code is based off of Jake Wharton's NOA port (https://github.com/JakeWharton/SwipeToDismissNOA)
+ * of Roman Nurik's SwipeToDismiss library. It has been modified for better support with async
+ * adapters.
+ *
+ * A {@link android.view.View.OnTouchListener} that makes the list items in a {@link ListView}
+ * dismissable. {@link ListView} is given special treatment because by default it handles touches
+ * for its list items... i.e. it's in charge of drawing the pressed state (the list selector),
+ * handling list item clicks, etc.
+ *
+ * <p>After creating the listener, the caller should also call
+ * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}, passing
+ * in the scroll listener returned by {@link #makeScrollListener()}. If a scroll listener is
+ * already assigned, the caller should still pass scroll changes through to this listener. This will
+ * ensure that this {@link SwipeDismissListViewTouchListener} is paused during list view
+ * scrolling.</p>
+ *
+ * <p>Example usage:</p>
+ *
+ * <pre>
+ * SwipeDismissListViewTouchListener touchListener =
+ * new SwipeDismissListViewTouchListener(
+ * listView,
+ * new SwipeDismissListViewTouchListener.OnDismissCallback() {
+ * public void onDismiss(ListView listView, int[] reverseSortedPositions) {
+ * for (int position : reverseSortedPositions) {
+ * adapter.remove(adapter.getItem(position));
+ * }
+ * adapter.notifyDataSetChanged();
+ * }
+ * });
+ * listView.setOnTouchListener(touchListener);
+ * listView.setOnScrollListener(touchListener.makeScrollListener());
+ * </pre>
+ *
+ * <p>For a generalized {@link android.view.View.OnTouchListener} that makes any view dismissable,
+ * see {@link SwipeDismissTouchListener}.</p>
+ *
+ * @see SwipeDismissTouchListener
+ */
+public class SwipeDismissListViewTouchListener implements View.OnTouchListener {
+ // Cached ViewConfiguration and system-wide constant values
+ private final int mSlop;
+ private final int mMinFlingVelocity;
+ private final int mMaxFlingVelocity;
+ private final long mAnimationTime;
+
+ // Fixed properties
+ private final ListView mListView;
+ private final OnDismissCallback mCallback;
+ private int mViewWidth = 1; // 1 and not 0 to prevent dividing by zero
+
+ // Transient properties
+ private float mDownX;
+ private boolean mSwiping;
+ private VelocityTracker mVelocityTracker;
+ private int mDownPosition;
+ private View mDownView;
+ private boolean mPaused;
+ private boolean mDismissing;
+
+ /**
+ * The callback interface used by {@link SwipeDismissListViewTouchListener} to inform its client
+ * about a successful dismissal of a list item.
+ */
+ public interface OnDismissCallback {
+ /**
+ * Called when the user has indicated they she would like to dismiss one or more list item
+ * positions.
+ *
+ * @param listView The originating {@link ListView}.
+ * @param position The position being dismissed.
+ */
+ void onDismiss(ListView listView, int position);
+ }
+
+ /**
+ * Constructs a new swipe-to-dismiss touch listener for the given list view.
+ *
+ * @param listView The list view whose items should be dismissable.
+ * @param callback The callback to trigger when the user has indicated that she would like to
+ * dismiss one or more list items.
+ */
+ public SwipeDismissListViewTouchListener(ListView listView, OnDismissCallback callback) {
+ ViewConfiguration vc = ViewConfiguration.get(listView.getContext());
+ mSlop = vc.getScaledTouchSlop();
+ mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
+ mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
+ mAnimationTime = listView.getContext().getResources().getInteger(
+ android.R.integer.config_shortAnimTime);
+ mListView = listView;
+ mCallback = callback;
+ }
+
+ /**
+ * Enables or disables (pauses or resumes) watching for swipe-to-dismiss gestures.
+ *
+ * @param enabled Whether or not to watch for gestures.
+ */
+ public void setEnabled(boolean enabled) {
+ mPaused = !enabled;
+ }
+
+ /**
+ * Returns an {@link android.widget.AbsListView.OnScrollListener} to be added to the
+ * {@link ListView} using
+ * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}.
+ * If a scroll listener is already assigned, the caller should still pass scroll changes
+ * through to this listener. This will ensure that this
+ * {@link SwipeDismissListViewTouchListener} is paused during list view scrolling.</p>
+ *
+ * @see {@link SwipeDismissListViewTouchListener}
+ */
+ public AbsListView.OnScrollListener makeScrollListener() {
+ return new AbsListView.OnScrollListener() {
+ @Override
+ public void onScrollStateChanged(AbsListView absListView, int scrollState) {
+ setEnabled(scrollState != AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+ }
+
+ @Override
+ public void onScroll(AbsListView absListView, int i, int i1, int i2) {
+ }
+ };
+ }
+
+ /**
+ * Returns a {@link android.widget.AbsListView.RecyclerListener} to be added to the
+ * {@link ListView} using {@link ListView#setRecyclerListener(RecyclerListener)}.
+ */
+ public AbsListView.RecyclerListener makeRecyclerListener() {
+ return new AbsListView.RecyclerListener() {
+ @Override
+ public void onMovedToScrapHeap(View view) {
+ final Object tag = view.getTag(R.id.original_height);
+
+ // To reset the view to the correct height after its animation, the view's height
+ // is stored in its tag. Reset the view here.
+ if (tag instanceof Integer) {
+ view.setAlpha(1f);
+ view.setTranslationX(0);
+ final ViewGroup.LayoutParams lp = view.getLayoutParams();
+ lp.height = (int) tag;
+ view.setLayoutParams(lp);
+ view.setTag(R.id.original_height, null);
+ }
+ }
+ };
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ if (mViewWidth < 2) {
+ mViewWidth = mListView.getWidth();
+ }
+
+ switch (motionEvent.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ if (mPaused) {
+ return false;
+ }
+
+ if (mDismissing) {
+ return true;
+ }
+
+ // TODO: ensure this is a finger, and set a flag
+
+ // Find the child view that was touched (perform a hit test)
+ Rect rect = new Rect();
+ int childCount = mListView.getChildCount();
+ int[] listViewCoords = new int[2];
+ mListView.getLocationOnScreen(listViewCoords);
+ int x = (int) motionEvent.getRawX() - listViewCoords[0];
+ int y = (int) motionEvent.getRawY() - listViewCoords[1];
+ View child;
+ for (int i = 0; i < childCount; i++) {
+ child = mListView.getChildAt(i);
+ child.getHitRect(rect);
+ if (rect.contains(x, y)) {
+ mDownView = child;
+ break;
+ }
+ }
+
+ if (mDownView != null) {
+ mDownX = motionEvent.getRawX();
+ mDownPosition = mListView.getPositionForView(mDownView);
+
+ mVelocityTracker = VelocityTracker.obtain();
+ mVelocityTracker.addMovement(motionEvent);
+ }
+ view.onTouchEvent(motionEvent);
+ return true;
+ }
+
+ case MotionEvent.ACTION_UP: {
+ if (mVelocityTracker == null) {
+ break;
+ }
+
+ float deltaX = motionEvent.getRawX() - mDownX;
+ mVelocityTracker.addMovement(motionEvent);
+ mVelocityTracker.computeCurrentVelocity(1000);
+ float velocityX = Math.abs(mVelocityTracker.getXVelocity());
+ float velocityY = Math.abs(mVelocityTracker.getYVelocity());
+ boolean dismiss = false;
+ boolean dismissRight = false;
+ if (Math.abs(deltaX) > mViewWidth / 2) {
+ dismiss = true;
+ dismissRight = deltaX > 0;
+ } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity
+ && velocityY < velocityX) {
+ dismiss = true;
+ dismissRight = mVelocityTracker.getXVelocity() > 0;
+ }
+ if (dismiss) {
+ // dismiss
+ mDismissing = true;
+ final View downView = mDownView; // mDownView gets null'd before animation ends
+ final int downPosition = mDownPosition;
+ mDownView.animate()
+ .translationX(dismissRight ? mViewWidth : -mViewWidth)
+ .alpha(0)
+ .setDuration(mAnimationTime)
+ .setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ performDismiss(downView, downPosition);
+ }
+ });
+ } else {
+ // cancel
+ mDownView.animate()
+ .translationX(0)
+ .alpha(1)
+ .setDuration(mAnimationTime)
+ .setListener(null);
+ }
+
+ if (mVelocityTracker != null) {
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ }
+
+ mDownX = 0;
+ mDownView = null;
+ mDownPosition = ListView.INVALID_POSITION;
+ mSwiping = false;
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ if (mVelocityTracker == null || mPaused) {
+ break;
+ }
+
+ mVelocityTracker.addMovement(motionEvent);
+ float deltaX = motionEvent.getRawX() - mDownX;
+ if (Math.abs(deltaX) > mSlop) {
+ mSwiping = true;
+ mListView.requestDisallowInterceptTouchEvent(true);
+
+ // Cancel ListView's touch (un-highlighting the item)
+ MotionEvent cancelEvent = MotionEvent.obtain(motionEvent);
+ cancelEvent.setAction(MotionEvent.ACTION_CANCEL |
+ (motionEvent.getActionIndex()
+ << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ mListView.onTouchEvent(cancelEvent);
+ cancelEvent.recycle();
+ }
+
+ if (mSwiping) {
+ mDownView.setTranslationX(deltaX);
+ mDownView.setAlpha(Math.max(0f, Math.min(1f, 1f - 2f * Math.abs(deltaX) / mViewWidth)));
+ return true;
+ }
+ break;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Animate the dismissed list item to zero-height and fire the dismiss callback when it finishes.
+ *
+ * @param dismissView ListView item to dismiss
+ * @param dismissPosition Position of dismissed item
+ */
+ private void performDismiss(final View dismissView, final int dismissPosition) {
+ final ViewGroup.LayoutParams lp = dismissView.getLayoutParams();
+ final int originalHeight = lp.height;
+
+ ValueAnimator animator = ValueAnimator.ofInt(dismissView.getHeight(), 1).setDuration(mAnimationTime);
+
+ animator.addListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ // Since the view is still a part of the ListView, we can't reset the animated
+ // properties yet; otherwise, the view would briefly reappear. Store the original
+ // height in the view's tag to flag it for the recycler. This is racy since the user
+ // could scroll the dismissed view off the screen, then back on the screen, before
+ // it's removed from the adapter, causing the dismissed view to briefly reappear.
+ dismissView.setTag(R.id.original_height, originalHeight);
+
+ mCallback.onDismiss(mListView, dismissPosition);
+ mDismissing = false;
+ }
+ });
+
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ lp.height = (Integer) valueAnimator.getAnimatedValue();
+ dismissView.setLayoutParams(lp);
+ }
+ });
+
+ animator.start();
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java b/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java
new file mode 100644
index 000000000..848e2f6ed
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/TabThumbnailWrapper.java
@@ -0,0 +1,38 @@
+package org.mozilla.gecko.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.widget.themed.ThemedRelativeLayout;
+
+
+public class TabThumbnailWrapper extends ThemedRelativeLayout {
+ private boolean mRecording;
+ private static final int[] STATE_RECORDING = { R.attr.state_recording };
+
+ public TabThumbnailWrapper(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ public TabThumbnailWrapper(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (mRecording) {
+ mergeDrawableStates(drawableState, STATE_RECORDING);
+ }
+ return drawableState;
+ }
+
+ public void setRecording(boolean recording) {
+ if (mRecording != recording) {
+ mRecording = recording;
+ refreshDrawableState();
+ }
+ }
+
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java
new file mode 100644
index 000000000..5ab00ea7f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/ThumbnailView.java
@@ -0,0 +1,86 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.widget.themed.ThemedImageView;
+
+/* Special version of ImageView for thumbnails. Scales a thumbnail so that it maintains its aspect
+ * ratio and so that the images width and height are the same size or greater than the view size
+ */
+public class ThumbnailView extends ThemedImageView {
+ private static final String LOGTAG = "GeckoThumbnailView";
+
+ final private Matrix mMatrix;
+ private int mWidthSpec = -1;
+ private int mHeightSpec = -1;
+ private boolean mLayoutChanged;
+ private boolean mScale = false;
+
+ public ThumbnailView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mMatrix = new Matrix();
+ mLayoutChanged = true;
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ if (!mScale) {
+ super.onDraw(canvas);
+ return;
+ }
+
+ Drawable d = getDrawable();
+ if (mLayoutChanged) {
+ int w1 = d.getIntrinsicWidth();
+ int h1 = d.getIntrinsicHeight();
+ int w2 = getWidth();
+ int h2 = getHeight();
+
+ float scale = ((w2 / h2) < (w1 / h1)) ? (float) h2 / h1 : (float) w2 / w1;
+ mMatrix.setScale(scale, scale);
+ }
+
+ int saveCount = canvas.save();
+ canvas.concat(mMatrix);
+ d.draw(canvas);
+ canvas.restoreToCount(saveCount);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // OnLayout.changed isn't a reliable measure of whether or not the size of this view has changed
+ // neither is onSizeChanged called often enough. Instead, we track changes in size ourselves, and
+ // only invalidate this matrix if we have a new width/height spec
+ if (widthMeasureSpec != mWidthSpec || heightMeasureSpec != mHeightSpec) {
+ mWidthSpec = widthMeasureSpec;
+ mHeightSpec = heightMeasureSpec;
+ mLayoutChanged = true;
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ @Override
+ public void setImageDrawable(Drawable drawable) {
+ if (drawable == null) {
+ drawable = ContextCompat.getDrawable(getContext(), R.drawable.tab_panel_tab_background);
+ setScaleType(ScaleType.FIT_XY);
+ mScale = false;
+ } else {
+ mScale = true;
+ setScaleType(ScaleType.FIT_CENTER);
+ }
+
+ super.setImageDrawable(drawable);
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java b/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java
new file mode 100644
index 000000000..52e0b1fd0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/TouchDelegateWithReset.java
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.graphics.Rect;
+import android.view.MotionEvent;
+import android.view.TouchDelegate;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+/**
+ * This is a copy of TouchDelegate from
+ * https://github.com/android/platform_frameworks_base/blob/4b1a8f46d6ec55796bf77fd8921a5a242a219278/core/java/android/view/TouchDelegate.java
+ * with a fix to reset mDelegateTargeted on each new gesture - the sole substantive change is a new
+ * else leg in the ACTION_DOWN case of onTouchEvent marked by "START|END BUG FIX" comments.
+ */
+
+/**
+ * Helper class to handle situations where you want a view to have a larger touch area than its
+ * actual view bounds. The view whose touch area is changed is called the delegate view. This
+ * class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an
+ * instance that specifies the bounds that should be mapped to the delegate and the delegate
+ * view itself.
+ * <p>
+ * The ancestor should then forward all of its touch events received in its
+ * {@link android.view.View#onTouchEvent(MotionEvent)} to {@link #onTouchEvent(MotionEvent)}.
+ * </p>
+ */
+public class TouchDelegateWithReset extends TouchDelegate {
+
+ /**
+ * View that should receive forwarded touch events
+ */
+ private View mDelegateView;
+
+ /**
+ * Bounds in local coordinates of the containing view that should be mapped to the delegate
+ * view. This rect is used for initial hit testing.
+ */
+ private Rect mBounds;
+
+ /**
+ * mBounds inflated to include some slop. This rect is to track whether the motion events
+ * should be considered to be be within the delegate view.
+ */
+ private Rect mSlopBounds;
+
+ /**
+ * True if the delegate had been targeted on a down event (intersected mBounds).
+ */
+ private boolean mDelegateTargeted;
+
+ private int mSlop;
+
+ /**
+ * Constructor
+ *
+ * @param bounds Bounds in local coordinates of the containing view that should be mapped to
+ * the delegate view
+ * @param delegateView The view that should receive motion events
+ */
+ public TouchDelegateWithReset(Rect bounds, View delegateView) {
+ super(bounds, delegateView);
+
+ mBounds = bounds;
+
+ mSlop = ViewConfiguration.get(delegateView.getContext()).getScaledTouchSlop();
+ mSlopBounds = new Rect(bounds);
+ mSlopBounds.inset(-mSlop, -mSlop);
+ mDelegateView = delegateView;
+ }
+
+ /**
+ * Will forward touch events to the delegate view if the event is within the bounds
+ * specified in the constructor.
+ *
+ * @param event The touch event to forward
+ * @return True if the event was forwarded to the delegate, false otherwise.
+ */
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ int x = (int)event.getX();
+ int y = (int)event.getY();
+ boolean sendToDelegate = false;
+ boolean hit = true;
+ boolean handled = false;
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ Rect bounds = mBounds;
+
+ if (bounds.contains(x, y)) {
+ mDelegateTargeted = true;
+ sendToDelegate = true;
+ } /* START BUG FIX */
+ else {
+ mDelegateTargeted = false;
+ }
+ /* END BUG FIX */
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_MOVE:
+ sendToDelegate = mDelegateTargeted;
+ if (sendToDelegate) {
+ Rect slopBounds = mSlopBounds;
+ if (!slopBounds.contains(x, y)) {
+ hit = false;
+ }
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ sendToDelegate = mDelegateTargeted;
+ mDelegateTargeted = false;
+ break;
+ }
+ if (sendToDelegate) {
+ final View delegateView = mDelegateView;
+
+ if (hit) {
+ // Offset event coordinates to be inside the target view
+ event.setLocation(delegateView.getWidth() / 2, delegateView.getHeight() / 2);
+ } else {
+ // Offset event coordinates to be outside the target view (in case it does
+ // something like tracking pressed state)
+ int slop = mSlop;
+ event.setLocation(-(slop * 2), -(slop * 2));
+ }
+ handled = delegateView.dispatchTouchEvent(event);
+ }
+ return handled;
+ }
+} \ No newline at end of file
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java b/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java
new file mode 100644
index 000000000..b5ad36ab7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/TwoWayView.java
@@ -0,0 +1,7191 @@
+/*
+ * Copyright (C) 2013 Lucas Rocha
+ *
+ * This code is based on bits and pieces of Android's AbsListView,
+ * Listview, and StaggeredGridView.
+ *
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.mozilla.gecko.widget;
+
+import org.mozilla.gecko.R;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.TransitionDrawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.support.v4.util.LongSparseArray;
+import android.support.v4.util.SparseArrayCompat;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.KeyEventCompat;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.VelocityTrackerCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.widget.EdgeEffectCompat;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.FocusFinder;
+import android.view.HapticFeedbackConstants;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.SoundEffectConstants;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.AdapterView;
+import android.widget.Checkable;
+import android.widget.ListAdapter;
+import android.widget.Scroller;
+
+import static android.os.Build.VERSION_CODES.HONEYCOMB;
+
+/*
+ * Implementation Notes:
+ *
+ * Some terminology:
+ *
+ * index - index of the items that are currently visible
+ * position - index of the items in the cursor
+ *
+ * Given the bi-directional nature of this view, the source code
+ * usually names variables with 'start' to mean 'top' or 'left'; and
+ * 'end' to mean 'bottom' or 'right', depending on the current
+ * orientation of the widget.
+ */
+
+/**
+ * A view that shows items in a vertical or horizontal scrolling list.
+ * The items come from the {@link ListAdapter} associated with this view.
+ */
+public class TwoWayView extends AdapterView<ListAdapter> implements
+ ViewTreeObserver.OnTouchModeChangeListener {
+ private static final String LOGTAG = "TwoWayView";
+
+ private static final int NO_POSITION = -1;
+ private static final int INVALID_POINTER = -1;
+
+ public static final int[] STATE_NOTHING = new int[] { 0 };
+
+ private static final int TOUCH_MODE_REST = -1;
+ private static final int TOUCH_MODE_DOWN = 0;
+ private static final int TOUCH_MODE_TAP = 1;
+ private static final int TOUCH_MODE_DONE_WAITING = 2;
+ private static final int TOUCH_MODE_DRAGGING = 3;
+ private static final int TOUCH_MODE_FLINGING = 4;
+ private static final int TOUCH_MODE_OVERSCROLL = 5;
+
+ private static final int TOUCH_MODE_UNKNOWN = -1;
+ private static final int TOUCH_MODE_ON = 0;
+ private static final int TOUCH_MODE_OFF = 1;
+
+ private static final int LAYOUT_NORMAL = 0;
+ private static final int LAYOUT_FORCE_TOP = 1;
+ private static final int LAYOUT_SET_SELECTION = 2;
+ private static final int LAYOUT_FORCE_BOTTOM = 3;
+ private static final int LAYOUT_SPECIFIC = 4;
+ private static final int LAYOUT_SYNC = 5;
+ private static final int LAYOUT_MOVE_SELECTION = 6;
+
+ private static final int SYNC_SELECTED_POSITION = 0;
+ private static final int SYNC_FIRST_POSITION = 1;
+
+ private static final int SYNC_MAX_DURATION_MILLIS = 100;
+
+ private static final int CHECK_POSITION_SEARCH_DISTANCE = 20;
+
+ private static final float MAX_SCROLL_FACTOR = 0.33f;
+
+ private static final int MIN_SCROLL_PREVIEW_PIXELS = 10;
+
+ public static enum ChoiceMode {
+ NONE,
+ SINGLE,
+ MULTIPLE
+ }
+
+ public static enum Orientation {
+ HORIZONTAL,
+ VERTICAL
+ }
+
+ private final Context mContext;
+
+ private ListAdapter mAdapter;
+
+ private boolean mIsVertical;
+
+ private int mItemMargin;
+
+ private boolean mInLayout;
+ private boolean mBlockLayoutRequests;
+
+ private boolean mIsAttached;
+
+ private final RecycleBin mRecycler;
+ private AdapterDataSetObserver mDataSetObserver;
+
+ private boolean mItemsCanFocus;
+
+ final boolean[] mIsScrap = new boolean[1];
+
+ private boolean mDataChanged;
+ private int mItemCount;
+ private int mOldItemCount;
+ private boolean mHasStableIds;
+ private boolean mAreAllItemsSelectable;
+
+ private int mFirstPosition;
+ private int mSpecificStart;
+
+ private SavedState mPendingSync;
+
+ private PositionScroller mPositionScroller;
+ private Runnable mPositionScrollAfterLayout;
+
+ private final int mTouchSlop;
+ private final int mMaximumVelocity;
+ private final int mFlingVelocity;
+ private float mLastTouchPos;
+ private float mTouchRemainderPos;
+ private int mActivePointerId;
+
+ private final Rect mTempRect;
+
+ private final ArrowScrollFocusResult mArrowScrollFocusResult;
+
+ private Rect mTouchFrame;
+ private int mMotionPosition;
+ private CheckForTap mPendingCheckForTap;
+ private CheckForLongPress mPendingCheckForLongPress;
+ private CheckForKeyLongPress mPendingCheckForKeyLongPress;
+ private PerformClick mPerformClick;
+ private Runnable mTouchModeReset;
+ private int mResurrectToPosition;
+
+ private boolean mIsChildViewEnabled;
+
+ private boolean mDrawSelectorOnTop;
+ private Drawable mSelector;
+ private int mSelectorPosition;
+ private final Rect mSelectorRect;
+
+ private int mOverScroll;
+ private final int mOverscrollDistance;
+
+ private boolean mDesiredFocusableState;
+ private boolean mDesiredFocusableInTouchModeState;
+
+ private SelectionNotifier mSelectionNotifier;
+
+ private boolean mNeedSync;
+ private int mSyncMode;
+ private int mSyncPosition;
+ private long mSyncRowId;
+ private long mSyncSize;
+ private int mSelectedStart;
+
+ private int mNextSelectedPosition;
+ private long mNextSelectedRowId;
+ private int mSelectedPosition;
+ private long mSelectedRowId;
+ private int mOldSelectedPosition;
+ private long mOldSelectedRowId;
+
+ private ChoiceMode mChoiceMode;
+ private int mCheckedItemCount;
+ private SparseBooleanArray mCheckStates;
+ LongSparseArray<Integer> mCheckedIdStates;
+
+ private ContextMenuInfo mContextMenuInfo;
+
+ private int mLayoutMode;
+ private int mTouchMode;
+ private int mLastTouchMode;
+ private VelocityTracker mVelocityTracker;
+ private final Scroller mScroller;
+
+ private EdgeEffectCompat mStartEdge;
+ private EdgeEffectCompat mEndEdge;
+
+ private OnScrollListener mOnScrollListener;
+ private int mLastScrollState;
+
+ private View mEmptyView;
+
+ private ListItemAccessibilityDelegate mAccessibilityDelegate;
+
+ private int mLastAccessibilityScrollEventFromIndex;
+ private int mLastAccessibilityScrollEventToIndex;
+
+ public interface OnScrollListener {
+
+ /**
+ * The view is not scrolling. Note navigating the list using the trackball counts as
+ * being in the idle state since these transitions are not animated.
+ */
+ public static int SCROLL_STATE_IDLE = 0;
+
+ /**
+ * The user is scrolling using touch, and their finger is still on the screen
+ */
+ public static int SCROLL_STATE_TOUCH_SCROLL = 1;
+
+ /**
+ * The user had previously been scrolling using touch and had performed a fling. The
+ * animation is now coasting to a stop
+ */
+ public static int SCROLL_STATE_FLING = 2;
+
+ /**
+ * Callback method to be invoked while the list view or grid view is being scrolled. If the
+ * view is being scrolled, this method will be called before the next frame of the scroll is
+ * rendered. In particular, it will be called before any calls to
+ * {@link android.widget.Adapter#getView(int, View, ViewGroup)}.
+ *
+ * @param view The view whose scroll state is being reported
+ *
+ * @param scrollState The current scroll state. One of {@link #SCROLL_STATE_IDLE},
+ * {@link #SCROLL_STATE_TOUCH_SCROLL} or {@link #SCROLL_STATE_IDLE}.
+ */
+ public void onScrollStateChanged(TwoWayView view, int scrollState);
+
+ /**
+ * Callback method to be invoked when the list or grid has been scrolled. This will be
+ * called after the scroll has completed
+ * @param view The view whose scroll state is being reported
+ * @param firstVisibleItem the index of the first visible cell (ignore if
+ * visibleItemCount == 0)
+ * @param visibleItemCount the number of visible cells
+ * @param totalItemCount the number of items in the list adaptor
+ */
+ public void onScroll(TwoWayView view, int firstVisibleItem, int visibleItemCount,
+ int totalItemCount);
+ }
+
+ /**
+ * A RecyclerListener is used to receive a notification whenever a View is placed
+ * inside the RecycleBin's scrap heap. This listener is used to free resources
+ * associated to Views placed in the RecycleBin.
+ *
+ * @see TwoWayView.RecycleBin
+ * @see TwoWayView#setRecyclerListener(TwoWayView.RecyclerListener)
+ */
+ public static interface RecyclerListener {
+ /**
+ * Indicates that the specified View was moved into the recycler's scrap heap.
+ * The view is not displayed on screen any more and any expensive resource
+ * associated with the view should be discarded.
+ *
+ * @param view
+ */
+ void onMovedToScrapHeap(View view);
+ }
+
+ public TwoWayView(Context context) {
+ this(context, null);
+ }
+
+ public TwoWayView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public TwoWayView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ mContext = context;
+
+ mLayoutMode = LAYOUT_NORMAL;
+ mTouchMode = TOUCH_MODE_REST;
+ mLastTouchMode = TOUCH_MODE_UNKNOWN;
+
+ mLastScrollState = OnScrollListener.SCROLL_STATE_IDLE;
+
+ final ViewConfiguration vc = ViewConfiguration.get(context);
+ mTouchSlop = vc.getScaledTouchSlop();
+ mMaximumVelocity = vc.getScaledMaximumFlingVelocity();
+ mFlingVelocity = vc.getScaledMinimumFlingVelocity();
+ mOverscrollDistance = getScaledOverscrollDistance(vc);
+
+ mScroller = new Scroller(context);
+
+ mIsVertical = true;
+
+ mTempRect = new Rect();
+
+ mArrowScrollFocusResult = new ArrowScrollFocusResult();
+
+ mSelectorPosition = INVALID_POSITION;
+
+ mSelectorRect = new Rect();
+
+ mResurrectToPosition = INVALID_POSITION;
+
+ mNextSelectedPosition = INVALID_POSITION;
+ mNextSelectedRowId = INVALID_ROW_ID;
+ mSelectedPosition = INVALID_POSITION;
+ mSelectedRowId = INVALID_ROW_ID;
+ mOldSelectedPosition = INVALID_POSITION;
+ mOldSelectedRowId = INVALID_ROW_ID;
+
+ mChoiceMode = ChoiceMode.NONE;
+
+ mRecycler = new RecycleBin();
+
+ mAreAllItemsSelectable = true;
+
+ setClickable(true);
+ setFocusableInTouchMode(true);
+ setWillNotDraw(false);
+ setAlwaysDrawnWithCacheEnabled(false);
+ setWillNotDraw(false);
+ setClipToPadding(false);
+
+ ViewCompat.setOverScrollMode(this, ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS);
+
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TwoWayView, defStyle, 0);
+
+ mDrawSelectorOnTop = a.getBoolean(
+ R.styleable.TwoWayView_android_drawSelectorOnTop, false);
+
+ Drawable d = a.getDrawable(R.styleable.TwoWayView_android_listSelector);
+ if (d != null) {
+ setSelector(d);
+ }
+
+ int orientation = a.getInt(R.styleable.TwoWayView_android_orientation, -1);
+ if (orientation >= 0) {
+ setOrientation(Orientation.values()[orientation]);
+ }
+
+ int choiceMode = a.getInt(R.styleable.TwoWayView_android_choiceMode, -1);
+ if (choiceMode >= 0) {
+ setChoiceMode(ChoiceMode.values()[choiceMode]);
+ }
+
+ a.recycle();
+ }
+
+ public void setOrientation(Orientation orientation) {
+ final boolean isVertical = (orientation == Orientation.VERTICAL);
+ if (mIsVertical == isVertical) {
+ return;
+ }
+
+ mIsVertical = isVertical;
+
+ resetState();
+ mRecycler.clear();
+
+ requestLayout();
+ }
+
+ public Orientation getOrientation() {
+ return (mIsVertical ? Orientation.VERTICAL : Orientation.HORIZONTAL);
+ }
+
+ public void setItemMargin(int itemMargin) {
+ if (mItemMargin == itemMargin) {
+ return;
+ }
+
+ mItemMargin = itemMargin;
+ requestLayout();
+ }
+
+ @SuppressWarnings("unused")
+ public int getItemMargin() {
+ return mItemMargin;
+ }
+
+ /**
+ * Indicates that the views created by the ListAdapter can contain focusable
+ * items.
+ *
+ * @param itemsCanFocus true if items can get focus, false otherwise
+ */
+ @SuppressWarnings("unused")
+ public void setItemsCanFocus(boolean itemsCanFocus) {
+ mItemsCanFocus = itemsCanFocus;
+ if (!itemsCanFocus) {
+ setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
+ }
+ }
+
+ /**
+ * @return Whether the views created by the ListAdapter can contain focusable
+ * items.
+ */
+ @SuppressWarnings("unused")
+ public boolean getItemsCanFocus() {
+ return mItemsCanFocus;
+ }
+
+ /**
+ * Set the listener that will receive notifications every time the list scrolls.
+ *
+ * @param l the scroll listener
+ */
+ public void setOnScrollListener(OnScrollListener l) {
+ mOnScrollListener = l;
+ invokeOnItemScrollListener();
+ }
+
+ /**
+ * Sets the recycler listener to be notified whenever a View is set aside in
+ * the recycler for later reuse. This listener can be used to free resources
+ * associated to the View.
+ *
+ * @param l The recycler listener to be notified of views set aside
+ * in the recycler.
+ *
+ * @see TwoWayView.RecycleBin
+ * @see TwoWayView.RecyclerListener
+ */
+ public void setRecyclerListener(RecyclerListener l) {
+ mRecycler.mRecyclerListener = l;
+ }
+
+ /**
+ * Controls whether the selection highlight drawable should be drawn on top of the item or
+ * behind it.
+ *
+ * @param drawSelectorOnTop If true, the selector will be drawn on the item it is highlighting.
+ * The default is false.
+ *
+ * @attr ref android.R.styleable#AbsListView_drawSelectorOnTop
+ */
+ @SuppressWarnings("unused")
+ public void setDrawSelectorOnTop(boolean drawSelectorOnTop) {
+ mDrawSelectorOnTop = drawSelectorOnTop;
+ }
+
+ /**
+ * Set a Drawable that should be used to highlight the currently selected item.
+ *
+ * @param resID A Drawable resource to use as the selection highlight.
+ *
+ * @attr ref android.R.styleable#AbsListView_listSelector
+ */
+ @SuppressWarnings("unused")
+ public void setSelector(int resID) {
+ setSelector(getResources().getDrawable(resID));
+ }
+
+ /**
+ * Set a Drawable that should be used to highlight the currently selected item.
+ *
+ * @param selector A Drawable to use as the selection highlight.
+ *
+ * @attr ref android.R.styleable#AbsListView_listSelector
+ */
+ public void setSelector(Drawable selector) {
+ if (mSelector != null) {
+ mSelector.setCallback(null);
+ unscheduleDrawable(mSelector);
+ }
+
+ mSelector = selector;
+ Rect padding = new Rect();
+ selector.getPadding(padding);
+
+ selector.setCallback(this);
+ updateSelectorState();
+ }
+
+ /**
+ * Returns the selector {@link android.graphics.drawable.Drawable} that is used to draw the
+ * selection in the list.
+ *
+ * @return the drawable used to display the selector
+ */
+ @SuppressWarnings("unused")
+ public Drawable getSelector() {
+ return mSelector;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getSelectedItemPosition() {
+ return mNextSelectedPosition;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public long getSelectedItemId() {
+ return mNextSelectedRowId;
+ }
+
+ /**
+ * Returns the number of items currently selected. This will only be valid
+ * if the choice mode is not {@link ChoiceMode#NONE} (default).
+ *
+ * <p>To determine the specific items that are currently selected, use one of
+ * the <code>getChecked*</code> methods.
+ *
+ * @return The number of items currently selected
+ *
+ * @see #getCheckedItemPosition()
+ * @see #getCheckedItemPositions()
+ * @see #getCheckedItemIds()
+ */
+ @SuppressWarnings("unused")
+ public int getCheckedItemCount() {
+ return mCheckedItemCount;
+ }
+
+ /**
+ * Returns the checked state of the specified position. The result is only
+ * valid if the choice mode has been set to {@link ChoiceMode#SINGLE}
+ * or {@link ChoiceMode#MULTIPLE}.
+ *
+ * @param position The item whose checked state to return
+ * @return The item's checked state or <code>false</code> if choice mode
+ * is invalid
+ *
+ * @see #setChoiceMode(ChoiceMode)
+ */
+ public boolean isItemChecked(int position) {
+ if (mChoiceMode == ChoiceMode.NONE && mCheckStates != null) {
+ return mCheckStates.get(position);
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the currently checked item. The result is only valid if the choice
+ * mode has been set to {@link ChoiceMode#SINGLE}.
+ *
+ * @return The position of the currently checked item or
+ * {@link #INVALID_POSITION} if nothing is selected
+ *
+ * @see #setChoiceMode(ChoiceMode)
+ */
+ public int getCheckedItemPosition() {
+ if (mChoiceMode == ChoiceMode.SINGLE && mCheckStates != null && mCheckStates.size() == 1) {
+ return mCheckStates.keyAt(0);
+ }
+
+ return INVALID_POSITION;
+ }
+
+ /**
+ * Returns the set of checked items in the list. The result is only valid if
+ * the choice mode has not been set to {@link ChoiceMode#NONE}.
+ *
+ * @return A SparseBooleanArray which will return true for each call to
+ * get(int position) where position is a position in the list,
+ * or <code>null</code> if the choice mode is set to
+ * {@link ChoiceMode#NONE}.
+ */
+ public SparseBooleanArray getCheckedItemPositions() {
+ if (mChoiceMode != ChoiceMode.NONE) {
+ return mCheckStates;
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the set of checked items ids. The result is only valid if the
+ * choice mode has not been set to {@link ChoiceMode#NONE} and the adapter
+ * has stable IDs. ({@link ListAdapter#hasStableIds()} == {@code true})
+ *
+ * @return A new array which contains the id of each checked item in the
+ * list.
+ */
+ public long[] getCheckedItemIds() {
+ if (mChoiceMode == ChoiceMode.NONE || mCheckedIdStates == null || mAdapter == null) {
+ return new long[0];
+ }
+
+ final LongSparseArray<Integer> idStates = mCheckedIdStates;
+ final int count = idStates.size();
+ final long[] ids = new long[count];
+
+ for (int i = 0; i < count; i++) {
+ ids[i] = idStates.keyAt(i);
+ }
+
+ return ids;
+ }
+
+ /**
+ * Sets the checked state of the specified position. The is only valid if
+ * the choice mode has been set to {@link ChoiceMode#SINGLE} or
+ * {@link ChoiceMode#MULTIPLE}.
+ *
+ * @param position The item whose checked state is to be checked
+ * @param value The new checked state for the item
+ */
+ @SuppressWarnings("unused")
+ public void setItemChecked(int position, boolean value) {
+ if (mChoiceMode == ChoiceMode.NONE) {
+ return;
+ }
+
+ if (mChoiceMode == ChoiceMode.MULTIPLE) {
+ boolean oldValue = mCheckStates.get(position);
+ mCheckStates.put(position, value);
+
+ if (mCheckedIdStates != null && mAdapter.hasStableIds()) {
+ if (value) {
+ mCheckedIdStates.put(mAdapter.getItemId(position), position);
+ } else {
+ mCheckedIdStates.delete(mAdapter.getItemId(position));
+ }
+ }
+
+ if (oldValue != value) {
+ if (value) {
+ mCheckedItemCount++;
+ } else {
+ mCheckedItemCount--;
+ }
+ }
+ } else {
+ boolean updateIds = mCheckedIdStates != null && mAdapter.hasStableIds();
+
+ // Clear all values if we're checking something, or unchecking the currently
+ // selected item
+ if (value || isItemChecked(position)) {
+ mCheckStates.clear();
+
+ if (updateIds) {
+ mCheckedIdStates.clear();
+ }
+ }
+
+ // This may end up selecting the value we just cleared but this way
+ // we ensure length of mCheckStates is 1, a fact getCheckedItemPosition relies on
+ if (value) {
+ mCheckStates.put(position, true);
+
+ if (updateIds) {
+ mCheckedIdStates.put(mAdapter.getItemId(position), position);
+ }
+
+ mCheckedItemCount = 1;
+ } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) {
+ mCheckedItemCount = 0;
+ }
+ }
+
+ // Do not generate a data change while we are in the layout phase
+ if (!mInLayout && !mBlockLayoutRequests) {
+ mDataChanged = true;
+ rememberSyncState();
+ requestLayout();
+ }
+ }
+
+ /**
+ * Clear any choices previously set
+ */
+ @SuppressWarnings("unused")
+ public void clearChoices() {
+ if (mCheckStates != null) {
+ mCheckStates.clear();
+ }
+
+ if (mCheckedIdStates != null) {
+ mCheckedIdStates.clear();
+ }
+
+ mCheckedItemCount = 0;
+ }
+
+ /**
+ * @see #setChoiceMode(ChoiceMode)
+ *
+ * @return The current choice mode
+ */
+ @SuppressWarnings("unused")
+ public ChoiceMode getChoiceMode() {
+ return mChoiceMode;
+ }
+
+ /**
+ * Defines the choice behavior for the List. By default, Lists do not have any choice behavior
+ * ({@link ChoiceMode#NONE}). By setting the choiceMode to {@link ChoiceMode#SINGLE}, the
+ * List allows up to one item to be in a chosen state. By setting the choiceMode to
+ * {@link ChoiceMode#MULTIPLE}, the list allows any number of items to be chosen.
+ *
+ * @param choiceMode One of {@link ChoiceMode#NONE}, {@link ChoiceMode#SINGLE}, or
+ * {@link ChoiceMode#MULTIPLE}
+ */
+ public void setChoiceMode(ChoiceMode choiceMode) {
+ mChoiceMode = choiceMode;
+
+ if (mChoiceMode != ChoiceMode.NONE) {
+ if (mCheckStates == null) {
+ mCheckStates = new SparseBooleanArray();
+ }
+
+ if (mCheckedIdStates == null && mAdapter != null && mAdapter.hasStableIds()) {
+ mCheckedIdStates = new LongSparseArray<Integer>();
+ }
+ }
+ }
+
+ @Override
+ public ListAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ @Override
+ public void setAdapter(ListAdapter adapter) {
+ if (mAdapter != null && mDataSetObserver != null) {
+ mAdapter.unregisterDataSetObserver(mDataSetObserver);
+ }
+
+ resetState();
+ mRecycler.clear();
+
+ mAdapter = adapter;
+ mDataChanged = true;
+
+ mOldSelectedPosition = INVALID_POSITION;
+ mOldSelectedRowId = INVALID_ROW_ID;
+
+ if (mCheckStates != null) {
+ mCheckStates.clear();
+ }
+
+ if (mCheckedIdStates != null) {
+ mCheckedIdStates.clear();
+ }
+
+ if (mAdapter != null) {
+ mOldItemCount = mItemCount;
+ mItemCount = adapter.getCount();
+
+ mDataSetObserver = new AdapterDataSetObserver();
+ mAdapter.registerDataSetObserver(mDataSetObserver);
+
+ mRecycler.setViewTypeCount(adapter.getViewTypeCount());
+
+ mHasStableIds = adapter.hasStableIds();
+ mAreAllItemsSelectable = adapter.areAllItemsEnabled();
+
+ if (mChoiceMode != ChoiceMode.NONE && mHasStableIds && mCheckedIdStates == null) {
+ mCheckedIdStates = new LongSparseArray<Integer>();
+ }
+
+ final int position = lookForSelectablePosition(0);
+ setSelectedPositionInt(position);
+ setNextSelectedPositionInt(position);
+
+ if (mItemCount == 0) {
+ checkSelectionChanged();
+ }
+ } else {
+ mItemCount = 0;
+ mHasStableIds = false;
+ mAreAllItemsSelectable = true;
+
+ checkSelectionChanged();
+ }
+
+ checkFocus();
+ requestLayout();
+ }
+
+ @Override
+ public int getFirstVisiblePosition() {
+ return mFirstPosition;
+ }
+
+ @Override
+ public int getLastVisiblePosition() {
+ return mFirstPosition + getChildCount() - 1;
+ }
+
+ @Override
+ public int getCount() {
+ return mItemCount;
+ }
+
+ @Override
+ public int getPositionForView(View view) {
+ View child = view;
+ try {
+ View v;
+ while (!(v = (View) child.getParent()).equals(this)) {
+ child = v;
+ }
+ } catch (ClassCastException e) {
+ // We made it up to the window without find this list view
+ return INVALID_POSITION;
+ }
+
+ // Search the children for the list item
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ if (getChildAt(i).equals(child)) {
+ return mFirstPosition + i;
+ }
+ }
+
+ // Child not found!
+ return INVALID_POSITION;
+ }
+
+ @Override
+ public void getFocusedRect(Rect r) {
+ View view = getSelectedView();
+
+ if (view != null && view.getParent() == this) {
+ // The focused rectangle of the selected view offset into the
+ // coordinate space of this view.
+ view.getFocusedRect(r);
+ offsetDescendantRectToMyCoords(view, r);
+ } else {
+ super.getFocusedRect(r);
+ }
+ }
+
+ @Override
+ protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+
+ if (gainFocus && mSelectedPosition < 0 && !isInTouchMode()) {
+ if (!mIsAttached && mAdapter != null) {
+ // Data may have changed while we were detached and it's valid
+ // to change focus while detached. Refresh so we don't die.
+ mDataChanged = true;
+ mOldItemCount = mItemCount;
+ mItemCount = mAdapter.getCount();
+ }
+
+ resurrectSelection();
+ }
+
+ final ListAdapter adapter = mAdapter;
+ int closetChildIndex = INVALID_POSITION;
+ int closestChildStart = 0;
+
+ if (adapter != null && gainFocus && previouslyFocusedRect != null) {
+ previouslyFocusedRect.offset(getScrollX(), getScrollY());
+
+ // Don't cache the result of getChildCount or mFirstPosition here,
+ // it could change in layoutChildren.
+ if (adapter.getCount() < getChildCount() + mFirstPosition) {
+ mLayoutMode = LAYOUT_NORMAL;
+ layoutChildren();
+ }
+
+ // Figure out which item should be selected based on previously
+ // focused rect.
+ Rect otherRect = mTempRect;
+ int minDistance = Integer.MAX_VALUE;
+ final int childCount = getChildCount();
+ final int firstPosition = mFirstPosition;
+
+ for (int i = 0; i < childCount; i++) {
+ // Only consider selectable views
+ if (!adapter.isEnabled(firstPosition + i)) {
+ continue;
+ }
+
+ View other = getChildAt(i);
+ other.getDrawingRect(otherRect);
+ offsetDescendantRectToMyCoords(other, otherRect);
+ int distance = getDistance(previouslyFocusedRect, otherRect, direction);
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closetChildIndex = i;
+ closestChildStart = getChildStartEdge(other);
+ }
+ }
+ }
+
+ if (closetChildIndex >= 0) {
+ setSelectionFromOffset(closetChildIndex + mFirstPosition, closestChildStart);
+ } else {
+ requestLayout();
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ final ViewTreeObserver treeObserver = getViewTreeObserver();
+ treeObserver.addOnTouchModeChangeListener(this);
+
+ if (mAdapter != null && mDataSetObserver == null) {
+ mDataSetObserver = new AdapterDataSetObserver();
+ mAdapter.registerDataSetObserver(mDataSetObserver);
+
+ // Data may have changed while we were detached. Refresh.
+ mDataChanged = true;
+ mOldItemCount = mItemCount;
+ mItemCount = mAdapter.getCount();
+ }
+
+ mIsAttached = true;
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ // Detach any view left in the scrap heap
+ mRecycler.clear();
+
+ final ViewTreeObserver treeObserver = getViewTreeObserver();
+ treeObserver.removeOnTouchModeChangeListener(this);
+
+ if (mAdapter != null) {
+ mAdapter.unregisterDataSetObserver(mDataSetObserver);
+ mDataSetObserver = null;
+ }
+
+ if (mPerformClick != null) {
+ removeCallbacks(mPerformClick);
+ }
+
+ if (mTouchModeReset != null) {
+ removeCallbacks(mTouchModeReset);
+ mTouchModeReset.run();
+ }
+
+ finishSmoothScrolling();
+
+ mIsAttached = false;
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasWindowFocus) {
+ super.onWindowFocusChanged(hasWindowFocus);
+
+ final int touchMode = isInTouchMode() ? TOUCH_MODE_ON : TOUCH_MODE_OFF;
+
+ if (!hasWindowFocus) {
+ if (!mScroller.isFinished()) {
+ finishSmoothScrolling();
+ if (mOverScroll != 0) {
+ mOverScroll = 0;
+ finishEdgeGlows();
+ invalidate();
+ }
+ }
+
+ if (touchMode == TOUCH_MODE_OFF) {
+ // Remember the last selected element
+ mResurrectToPosition = mSelectedPosition;
+ }
+ } else {
+ // If we changed touch mode since the last time we had focus
+ if (touchMode != mLastTouchMode && mLastTouchMode != TOUCH_MODE_UNKNOWN) {
+ // If we come back in trackball mode, we bring the selection back
+ if (touchMode == TOUCH_MODE_OFF) {
+ // This will trigger a layout
+ resurrectSelection();
+
+ // If we come back in touch mode, then we want to hide the selector
+ } else {
+ hideSelector();
+ mLayoutMode = LAYOUT_NORMAL;
+ layoutChildren();
+ }
+ }
+ }
+
+ mLastTouchMode = touchMode;
+ }
+
+ @Override
+ protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
+ boolean needsInvalidate = false;
+
+ if (mIsVertical && mOverScroll != scrollY) {
+ onScrollChanged(getScrollX(), scrollY, getScrollX(), mOverScroll);
+ mOverScroll = scrollY;
+ needsInvalidate = true;
+ } else if (!mIsVertical && mOverScroll != scrollX) {
+ onScrollChanged(scrollX, getScrollY(), mOverScroll, getScrollY());
+ mOverScroll = scrollX;
+ needsInvalidate = true;
+ }
+
+ if (needsInvalidate) {
+ invalidate();
+ awakenScrollbarsInternal();
+ }
+ }
+
+ @TargetApi(9)
+ private boolean overScrollByInternal(int deltaX, int deltaY,
+ int scrollX, int scrollY,
+ int scrollRangeX, int scrollRangeY,
+ int maxOverScrollX, int maxOverScrollY,
+ boolean isTouchEvent) {
+ if (Build.VERSION.SDK_INT < 9) {
+ return false;
+ }
+
+ return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX,
+ scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
+ }
+
+ @Override
+ @TargetApi(9)
+ public void setOverScrollMode(int mode) {
+ if (Build.VERSION.SDK_INT < 9) {
+ return;
+ }
+
+ if (mode != ViewCompat.OVER_SCROLL_NEVER) {
+ if (mStartEdge == null) {
+ Context context = getContext();
+
+ mStartEdge = new EdgeEffectCompat(context);
+ mEndEdge = new EdgeEffectCompat(context);
+ }
+ } else {
+ mStartEdge = null;
+ mEndEdge = null;
+ }
+
+ super.setOverScrollMode(mode);
+ }
+
+ public int pointToPosition(int x, int y) {
+ Rect frame = mTouchFrame;
+ if (frame == null) {
+ mTouchFrame = new Rect();
+ frame = mTouchFrame;
+ }
+
+ final int count = getChildCount();
+ for (int i = count - 1; i >= 0; i--) {
+ final View child = getChildAt(i);
+
+ if (child.getVisibility() == View.VISIBLE) {
+ child.getHitRect(frame);
+
+ if (frame.contains(x, y)) {
+ return mFirstPosition + i;
+ }
+ }
+ }
+ return INVALID_POSITION;
+ }
+
+ @Override
+ protected float getTopFadingEdgeStrength() {
+ if (!mIsVertical) {
+ return 0f;
+ }
+
+ final float fadingEdge = super.getTopFadingEdgeStrength();
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return fadingEdge;
+ } else {
+ if (mFirstPosition > 0) {
+ return 1.0f;
+ }
+
+ final int top = getChildAt(0).getTop();
+ final int paddingTop = getPaddingTop();
+
+ final float length = (float) getVerticalFadingEdgeLength();
+
+ return (top < paddingTop ? (float) -(top - paddingTop) / length : fadingEdge);
+ }
+ }
+
+ @Override
+ protected float getBottomFadingEdgeStrength() {
+ if (!mIsVertical) {
+ return 0f;
+ }
+
+ final float fadingEdge = super.getBottomFadingEdgeStrength();
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return fadingEdge;
+ } else {
+ if (mFirstPosition + childCount - 1 < mItemCount - 1) {
+ return 1.0f;
+ }
+
+ final int bottom = getChildAt(childCount - 1).getBottom();
+ final int paddingBottom = getPaddingBottom();
+
+ final int height = getHeight();
+ final float length = (float) getVerticalFadingEdgeLength();
+
+ return (bottom > height - paddingBottom ?
+ (float) (bottom - height + paddingBottom) / length : fadingEdge);
+ }
+ }
+
+ @Override
+ protected float getLeftFadingEdgeStrength() {
+ if (mIsVertical) {
+ return 0f;
+ }
+
+ final float fadingEdge = super.getLeftFadingEdgeStrength();
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return fadingEdge;
+ } else {
+ if (mFirstPosition > 0) {
+ return 1.0f;
+ }
+
+ final int left = getChildAt(0).getLeft();
+ final int paddingLeft = getPaddingLeft();
+
+ final float length = (float) getHorizontalFadingEdgeLength();
+
+ return (left < paddingLeft ? (float) -(left - paddingLeft) / length : fadingEdge);
+ }
+ }
+
+ @Override
+ protected float getRightFadingEdgeStrength() {
+ if (mIsVertical) {
+ return 0f;
+ }
+
+ final float fadingEdge = super.getRightFadingEdgeStrength();
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return fadingEdge;
+ } else {
+ if (mFirstPosition + childCount - 1 < mItemCount - 1) {
+ return 1.0f;
+ }
+
+ final int right = getChildAt(childCount - 1).getRight();
+ final int paddingRight = getPaddingRight();
+
+ final int width = getWidth();
+ final float length = (float) getHorizontalFadingEdgeLength();
+
+ return (right > width - paddingRight ?
+ (float) (right - width + paddingRight) / length : fadingEdge);
+ }
+ }
+
+ @Override
+ protected int computeVerticalScrollExtent() {
+ final int count = getChildCount();
+ if (count == 0) {
+ return 0;
+ }
+
+ int extent = count * 100;
+
+ View child = getChildAt(0);
+ final int childTop = child.getTop();
+
+ int childHeight = child.getHeight();
+ if (childHeight > 0) {
+ extent += (childTop * 100) / childHeight;
+ }
+
+ child = getChildAt(count - 1);
+ final int childBottom = child.getBottom();
+
+ childHeight = child.getHeight();
+ if (childHeight > 0) {
+ extent -= ((childBottom - getHeight()) * 100) / childHeight;
+ }
+
+ return extent;
+ }
+
+ @Override
+ protected int computeHorizontalScrollExtent() {
+ final int count = getChildCount();
+ if (count == 0) {
+ return 0;
+ }
+
+ int extent = count * 100;
+
+ View child = getChildAt(0);
+ final int childLeft = child.getLeft();
+
+ int childWidth = child.getWidth();
+ if (childWidth > 0) {
+ extent += (childLeft * 100) / childWidth;
+ }
+
+ child = getChildAt(count - 1);
+ final int childRight = child.getRight();
+
+ childWidth = child.getWidth();
+ if (childWidth > 0) {
+ extent -= ((childRight - getWidth()) * 100) / childWidth;
+ }
+
+ return extent;
+ }
+
+ @Override
+ protected int computeVerticalScrollOffset() {
+ final int firstPosition = mFirstPosition;
+ final int childCount = getChildCount();
+
+ if (firstPosition < 0 || childCount == 0) {
+ return 0;
+ }
+
+ final View child = getChildAt(0);
+ final int childTop = child.getTop();
+
+ int childHeight = child.getHeight();
+ if (childHeight > 0) {
+ return Math.max(firstPosition * 100 - (childTop * 100) / childHeight, 0);
+ }
+
+ return 0;
+ }
+
+ @Override
+ protected int computeHorizontalScrollOffset() {
+ final int firstPosition = mFirstPosition;
+ final int childCount = getChildCount();
+
+ if (firstPosition < 0 || childCount == 0) {
+ return 0;
+ }
+
+ final View child = getChildAt(0);
+ final int childLeft = child.getLeft();
+
+ int childWidth = child.getWidth();
+ if (childWidth > 0) {
+ return Math.max(firstPosition * 100 - (childLeft * 100) / childWidth, 0);
+ }
+
+ return 0;
+ }
+
+ @Override
+ protected int computeVerticalScrollRange() {
+ int result = Math.max(mItemCount * 100, 0);
+
+ if (mIsVertical && mOverScroll != 0) {
+ // Compensate for overscroll
+ result += Math.abs((int) ((float) mOverScroll / getHeight() * mItemCount * 100));
+ }
+
+ return result;
+ }
+
+ @Override
+ protected int computeHorizontalScrollRange() {
+ int result = Math.max(mItemCount * 100, 0);
+
+ if (!mIsVertical && mOverScroll != 0) {
+ // Compensate for overscroll
+ result += Math.abs((int) ((float) mOverScroll / getWidth() * mItemCount * 100));
+ }
+
+ return result;
+ }
+
+ @Override
+ public boolean showContextMenuForChild(View originalView) {
+ final int longPressPosition = getPositionForView(originalView);
+ if (longPressPosition >= 0) {
+ final long longPressId = mAdapter.getItemId(longPressPosition);
+ boolean handled = false;
+
+ OnItemLongClickListener listener = getOnItemLongClickListener();
+ if (listener != null) {
+ handled = listener.onItemLongClick(TwoWayView.this, originalView,
+ longPressPosition, longPressId);
+ }
+
+ if (!handled) {
+ mContextMenuInfo = createContextMenuInfo(
+ getChildAt(longPressPosition - mFirstPosition),
+ longPressPosition, longPressId);
+
+ handled = super.showContextMenuForChild(originalView);
+ }
+
+ return handled;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+ if (disallowIntercept) {
+ recycleVelocityTracker();
+ }
+
+ super.requestDisallowInterceptTouchEvent(disallowIntercept);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ if (!mIsAttached || mAdapter == null) {
+ return false;
+ }
+
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ initOrResetVelocityTracker();
+ mVelocityTracker.addMovement(ev);
+
+ mScroller.abortAnimation();
+ if (mPositionScroller != null) {
+ mPositionScroller.stop();
+ }
+
+ final float x = ev.getX();
+ final float y = ev.getY();
+
+ mLastTouchPos = (mIsVertical ? y : x);
+
+ final int motionPosition = findMotionRowOrColumn((int) mLastTouchPos);
+
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ mTouchRemainderPos = 0;
+
+ if (mTouchMode == TOUCH_MODE_FLINGING) {
+ return true;
+ } else if (motionPosition >= 0) {
+ mMotionPosition = motionPosition;
+ mTouchMode = TOUCH_MODE_DOWN;
+ }
+
+ break;
+
+ case MotionEvent.ACTION_MOVE: {
+ if (mTouchMode != TOUCH_MODE_DOWN) {
+ break;
+ }
+
+ initVelocityTrackerIfNotExists();
+ mVelocityTracker.addMovement(ev);
+
+ final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
+ if (index < 0) {
+ Log.e(LOGTAG, "onInterceptTouchEvent could not find pointer with id " +
+ mActivePointerId + " - did TwoWayView receive an inconsistent " +
+ "event stream?");
+ return false;
+ }
+
+ final float pos;
+ if (mIsVertical) {
+ pos = MotionEventCompat.getY(ev, index);
+ } else {
+ pos = MotionEventCompat.getX(ev, index);
+ }
+
+ final float diff = pos - mLastTouchPos + mTouchRemainderPos;
+ final int delta = (int) diff;
+ mTouchRemainderPos = diff - delta;
+
+ if (maybeStartScrolling(delta)) {
+ return true;
+ }
+
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ mActivePointerId = INVALID_POINTER;
+ mTouchMode = TOUCH_MODE_REST;
+ recycleVelocityTracker();
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+
+ break;
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (!isEnabled()) {
+ // A disabled view that is clickable still consumes the touch
+ // events, it just doesn't respond to them.
+ return isClickable() || isLongClickable();
+ }
+
+ if (!mIsAttached || mAdapter == null) {
+ return false;
+ }
+
+ boolean needsInvalidate = false;
+
+ initVelocityTrackerIfNotExists();
+ mVelocityTracker.addMovement(ev);
+
+ final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ if (mDataChanged) {
+ break;
+ }
+
+ mVelocityTracker.clear();
+ mScroller.abortAnimation();
+ if (mPositionScroller != null) {
+ mPositionScroller.stop();
+ }
+
+ final float x = ev.getX();
+ final float y = ev.getY();
+
+ mLastTouchPos = (mIsVertical ? y : x);
+
+ int motionPosition = pointToPosition((int) x, (int) y);
+
+ mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+ mTouchRemainderPos = 0;
+
+ if (mDataChanged) {
+ break;
+ }
+
+ if (mTouchMode == TOUCH_MODE_FLINGING) {
+ mTouchMode = TOUCH_MODE_DRAGGING;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+ motionPosition = findMotionRowOrColumn((int) mLastTouchPos);
+ } else if (mMotionPosition >= 0 && mAdapter.isEnabled(mMotionPosition)) {
+ mTouchMode = TOUCH_MODE_DOWN;
+ triggerCheckForTap();
+ }
+
+ mMotionPosition = motionPosition;
+
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
+ if (index < 0) {
+ Log.e(LOGTAG, "onInterceptTouchEvent could not find pointer with id " +
+ mActivePointerId + " - did TwoWayView receive an inconsistent " +
+ "event stream?");
+ return false;
+ }
+
+ final float pos;
+ if (mIsVertical) {
+ pos = MotionEventCompat.getY(ev, index);
+ } else {
+ pos = MotionEventCompat.getX(ev, index);
+ }
+
+ if (mDataChanged) {
+ // Re-sync everything if data has been changed
+ // since the scroll operation can query the adapter.
+ layoutChildren();
+ }
+
+ final float diff = pos - mLastTouchPos + mTouchRemainderPos;
+ final int delta = (int) diff;
+ mTouchRemainderPos = diff - delta;
+
+ switch (mTouchMode) {
+ case TOUCH_MODE_DOWN:
+ case TOUCH_MODE_TAP:
+ case TOUCH_MODE_DONE_WAITING:
+ // Check if we have moved far enough that it looks more like a
+ // scroll than a tap
+ maybeStartScrolling(delta);
+ break;
+
+ case TOUCH_MODE_DRAGGING:
+ case TOUCH_MODE_OVERSCROLL:
+ mLastTouchPos = pos;
+ maybeScroll(delta);
+ break;
+ }
+
+ break;
+ }
+
+ case MotionEvent.ACTION_CANCEL:
+ cancelCheckForTap();
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+
+ setPressed(false);
+ View motionView = this.getChildAt(mMotionPosition - mFirstPosition);
+ if (motionView != null) {
+ motionView.setPressed(false);
+ }
+
+ if (mStartEdge != null && mEndEdge != null) {
+ needsInvalidate = mStartEdge.onRelease() | mEndEdge.onRelease();
+ }
+
+ recycleVelocityTracker();
+
+ break;
+
+ case MotionEvent.ACTION_UP: {
+ switch (mTouchMode) {
+ case TOUCH_MODE_DOWN:
+ case TOUCH_MODE_TAP:
+ case TOUCH_MODE_DONE_WAITING: {
+ final int motionPosition = mMotionPosition;
+ final View child = getChildAt(motionPosition - mFirstPosition);
+
+ final float x = ev.getX();
+ final float y = ev.getY();
+
+ final boolean inList;
+ if (mIsVertical) {
+ inList = x > getPaddingLeft() && x < getWidth() - getPaddingRight();
+ } else {
+ inList = y > getPaddingTop() && y < getHeight() - getPaddingBottom();
+ }
+
+ if (child != null && !child.hasFocusable() && inList) {
+ if (mTouchMode != TOUCH_MODE_DOWN) {
+ child.setPressed(false);
+ }
+
+ if (mPerformClick == null) {
+ mPerformClick = new PerformClick();
+ }
+
+ final PerformClick performClick = mPerformClick;
+ performClick.mClickMotionPosition = motionPosition;
+ performClick.rememberWindowAttachCount();
+
+ mResurrectToPosition = motionPosition;
+
+ if (mTouchMode == TOUCH_MODE_DOWN || mTouchMode == TOUCH_MODE_TAP) {
+ if (mTouchMode == TOUCH_MODE_DOWN) {
+ cancelCheckForTap();
+ } else {
+ cancelCheckForLongPress();
+ }
+
+ mLayoutMode = LAYOUT_NORMAL;
+
+ if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
+ mTouchMode = TOUCH_MODE_TAP;
+
+ setPressed(true);
+ positionSelector(mMotionPosition, child);
+ child.setPressed(true);
+
+ if (mSelector != null) {
+ Drawable d = mSelector.getCurrent();
+ if (d != null && d instanceof TransitionDrawable) {
+ ((TransitionDrawable) d).resetTransition();
+ }
+ }
+
+ if (mTouchModeReset != null) {
+ removeCallbacks(mTouchModeReset);
+ }
+
+ mTouchModeReset = new Runnable() {
+ @Override
+ public void run() {
+ mTouchMode = TOUCH_MODE_REST;
+
+ setPressed(false);
+ child.setPressed(false);
+
+ if (!mDataChanged) {
+ performClick.run();
+ }
+
+ mTouchModeReset = null;
+ }
+ };
+
+ postDelayed(mTouchModeReset,
+ ViewConfiguration.getPressedStateDuration());
+ } else {
+ mTouchMode = TOUCH_MODE_REST;
+ updateSelectorState();
+ }
+ } else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
+ performClick.run();
+ }
+ }
+
+ mTouchMode = TOUCH_MODE_REST;
+
+ finishSmoothScrolling();
+ updateSelectorState();
+
+ break;
+ }
+
+ case TOUCH_MODE_DRAGGING:
+ if (contentFits()) {
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+ break;
+ }
+
+ mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
+
+ final float velocity;
+ if (mIsVertical) {
+ velocity = VelocityTrackerCompat.getYVelocity(mVelocityTracker,
+ mActivePointerId);
+ } else {
+ velocity = VelocityTrackerCompat.getXVelocity(mVelocityTracker,
+ mActivePointerId);
+ }
+
+ if (Math.abs(velocity) >= mFlingVelocity) {
+ mTouchMode = TOUCH_MODE_FLINGING;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING);
+
+ mScroller.fling(0, 0,
+ (int) (mIsVertical ? 0 : velocity),
+ (int) (mIsVertical ? velocity : 0),
+ (mIsVertical ? 0 : Integer.MIN_VALUE),
+ (mIsVertical ? 0 : Integer.MAX_VALUE),
+ (mIsVertical ? Integer.MIN_VALUE : 0),
+ (mIsVertical ? Integer.MAX_VALUE : 0));
+
+ mLastTouchPos = 0;
+ needsInvalidate = true;
+ } else {
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+ }
+
+ break;
+
+ case TOUCH_MODE_OVERSCROLL:
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+ break;
+ }
+
+ cancelCheckForTap();
+ cancelCheckForLongPress();
+ setPressed(false);
+
+ if (mStartEdge != null && mEndEdge != null) {
+ needsInvalidate |= mStartEdge.onRelease() | mEndEdge.onRelease();
+ }
+
+ recycleVelocityTracker();
+
+ break;
+ }
+ }
+
+ if (needsInvalidate) {
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+
+ return true;
+ }
+
+ @Override
+ public void onTouchModeChanged(boolean isInTouchMode) {
+ if (isInTouchMode) {
+ // Get rid of the selection when we enter touch mode
+ hideSelector();
+
+ // Layout, but only if we already have done so previously.
+ // (Otherwise may clobber a LAYOUT_SYNC layout that was requested to restore
+ // state.)
+ if (getWidth() > 0 && getHeight() > 0 && getChildCount() > 0) {
+ layoutChildren();
+ }
+
+ updateSelectorState();
+ } else {
+ final int touchMode = mTouchMode;
+ if (touchMode == TOUCH_MODE_OVERSCROLL) {
+ finishSmoothScrolling();
+ if (mOverScroll != 0) {
+ mOverScroll = 0;
+ finishEdgeGlows();
+ invalidate();
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ return handleKeyEvent(keyCode, 1, event);
+ }
+
+ @Override
+ public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) {
+ return handleKeyEvent(keyCode, repeatCount, event);
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ return handleKeyEvent(keyCode, 1, event);
+ }
+
+ @Override
+ public void sendAccessibilityEvent(int eventType) {
+ // Since this class calls onScrollChanged even if the mFirstPosition and the
+ // child count have not changed we will avoid sending duplicate accessibility
+ // events.
+ if (eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) {
+ final int firstVisiblePosition = getFirstVisiblePosition();
+ final int lastVisiblePosition = getLastVisiblePosition();
+
+ if (mLastAccessibilityScrollEventFromIndex == firstVisiblePosition
+ && mLastAccessibilityScrollEventToIndex == lastVisiblePosition) {
+ return;
+ } else {
+ mLastAccessibilityScrollEventFromIndex = firstVisiblePosition;
+ mLastAccessibilityScrollEventToIndex = lastVisiblePosition;
+ }
+ }
+
+ super.sendAccessibilityEvent(eventType);
+ }
+
+ @Override
+ @TargetApi(14)
+ public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(event);
+ event.setClassName(TwoWayView.class.getName());
+ }
+
+ @Override
+ @TargetApi(14)
+ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+ super.onInitializeAccessibilityNodeInfo(info);
+ info.setClassName(TwoWayView.class.getName());
+
+ AccessibilityNodeInfoCompat infoCompat = new AccessibilityNodeInfoCompat(info);
+
+ if (isEnabled()) {
+ if (getFirstVisiblePosition() > 0) {
+ infoCompat.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
+ }
+
+ if (getLastVisiblePosition() < getCount() - 1) {
+ infoCompat.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
+ }
+ }
+ }
+
+ @Override
+ @TargetApi(16)
+ public boolean performAccessibilityAction(int action, Bundle arguments) {
+ if (super.performAccessibilityAction(action, arguments)) {
+ return true;
+ }
+
+ switch (action) {
+ case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
+ if (isEnabled() && getLastVisiblePosition() < getCount() - 1) {
+ // TODO: Use some form of smooth scroll instead
+ scrollListItemsBy(getAvailableSize());
+ return true;
+ }
+ return false;
+
+ case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
+ if (isEnabled() && mFirstPosition > 0) {
+ // TODO: Use some form of smooth scroll instead
+ scrollListItemsBy(-getAvailableSize());
+ return true;
+ }
+ return false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return true if child is an ancestor of parent, (or equal to the parent).
+ */
+ private boolean isViewAncestorOf(View child, View parent) {
+ if (child == parent) {
+ return true;
+ }
+
+ final ViewParent theParent = child.getParent();
+
+ return (theParent instanceof ViewGroup) &&
+ isViewAncestorOf((View) theParent, parent);
+ }
+
+ private void forceValidFocusDirection(int direction) {
+ if (mIsVertical && direction != View.FOCUS_UP && direction != View.FOCUS_DOWN) {
+ throw new IllegalArgumentException("Focus direction must be one of"
+ + " {View.FOCUS_UP, View.FOCUS_DOWN} for vertical orientation");
+ } else if (!mIsVertical && direction != View.FOCUS_LEFT && direction != View.FOCUS_RIGHT) {
+ throw new IllegalArgumentException("Focus direction must be one of"
+ + " {View.FOCUS_LEFT, View.FOCUS_RIGHT} for vertical orientation");
+ }
+ }
+
+ private void forceValidInnerFocusDirection(int direction) {
+ if (mIsVertical && direction != View.FOCUS_LEFT && direction != View.FOCUS_RIGHT) {
+ throw new IllegalArgumentException("Direction must be one of"
+ + " {View.FOCUS_LEFT, View.FOCUS_RIGHT} for vertical orientation");
+ } else if (!mIsVertical && direction != View.FOCUS_UP && direction != View.FOCUS_DOWN) {
+ throw new IllegalArgumentException("direction must be one of"
+ + " {View.FOCUS_UP, View.FOCUS_DOWN} for horizontal orientation");
+ }
+ }
+
+ /**
+ * Scrolls up or down by the number of items currently present on screen.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return whether selection was moved
+ */
+ boolean pageScroll(int direction) {
+ forceValidFocusDirection(direction);
+
+ boolean forward = false;
+ int nextPage = -1;
+
+ if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) {
+ nextPage = Math.max(0, mSelectedPosition - getChildCount() - 1);
+ } else if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) {
+ nextPage = Math.min(mItemCount - 1, mSelectedPosition + getChildCount() - 1);
+ forward = true;
+ }
+
+ if (nextPage < 0) {
+ return false;
+ }
+
+ final int position = lookForSelectablePosition(nextPage, forward);
+ if (position >= 0) {
+ mLayoutMode = LAYOUT_SPECIFIC;
+ mSpecificStart = getStartEdge() + getFadingEdgeLength();
+
+ if (forward && position > mItemCount - getChildCount()) {
+ mLayoutMode = LAYOUT_FORCE_BOTTOM;
+ }
+
+ if (!forward && position < getChildCount()) {
+ mLayoutMode = LAYOUT_FORCE_TOP;
+ }
+
+ setSelectionInt(position);
+ invokeOnItemScrollListener();
+
+ if (!awakenScrollbarsInternal()) {
+ invalidate();
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Go to the last or first item if possible (not worrying about panning across or navigating
+ * within the internal focus of the currently selected item.)
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return whether selection was moved
+ */
+ boolean fullScroll(int direction) {
+ forceValidFocusDirection(direction);
+
+ boolean moved = false;
+ if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) {
+ if (mSelectedPosition != 0) {
+ int position = lookForSelectablePosition(0, true);
+ if (position >= 0) {
+ mLayoutMode = LAYOUT_FORCE_TOP;
+ setSelectionInt(position);
+ invokeOnItemScrollListener();
+ }
+
+ moved = true;
+ }
+ } else if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) {
+ if (mSelectedPosition < mItemCount - 1) {
+ int position = lookForSelectablePosition(mItemCount - 1, true);
+ if (position >= 0) {
+ mLayoutMode = LAYOUT_FORCE_BOTTOM;
+ setSelectionInt(position);
+ invokeOnItemScrollListener();
+ }
+
+ moved = true;
+ }
+ }
+
+ if (moved && !awakenScrollbarsInternal()) {
+ awakenScrollbarsInternal();
+ invalidate();
+ }
+
+ return moved;
+ }
+
+ /**
+ * To avoid horizontal/vertical focus searches changing the selected item,
+ * we manually focus search within the selected item (as applicable), and
+ * prevent focus from jumping to something within another item.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return Whether this consumes the key event.
+ */
+ private boolean handleFocusWithinItem(int direction) {
+ forceValidInnerFocusDirection(direction);
+
+ final int numChildren = getChildCount();
+
+ if (mItemsCanFocus && numChildren > 0 && mSelectedPosition != INVALID_POSITION) {
+ final View selectedView = getSelectedView();
+
+ if (selectedView != null && selectedView.hasFocus() &&
+ selectedView instanceof ViewGroup) {
+
+ final View currentFocus = selectedView.findFocus();
+ final View nextFocus = FocusFinder.getInstance().findNextFocus(
+ (ViewGroup) selectedView, currentFocus, direction);
+
+ if (nextFocus != null) {
+ // Do the math to get interesting rect in next focus' coordinates
+ currentFocus.getFocusedRect(mTempRect);
+ offsetDescendantRectToMyCoords(currentFocus, mTempRect);
+ offsetRectIntoDescendantCoords(nextFocus, mTempRect);
+
+ if (nextFocus.requestFocus(direction, mTempRect)) {
+ return true;
+ }
+ }
+
+ // We are blocking the key from being handled (by returning true)
+ // if the global result is going to be some other view within this
+ // list. This is to achieve the overall goal of having horizontal/vertical
+ // d-pad navigation remain in the current item depending on the current
+ // orientation in this view.
+ final View globalNextFocus = FocusFinder.getInstance().findNextFocus(
+ (ViewGroup) getRootView(), currentFocus, direction);
+
+ if (globalNextFocus != null) {
+ return isViewAncestorOf(globalNextFocus, this);
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Scrolls to the next or previous item if possible.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return whether selection was moved
+ */
+ private boolean arrowScroll(int direction) {
+ forceValidFocusDirection(direction);
+
+ try {
+ mInLayout = true;
+
+ final boolean handled = arrowScrollImpl(direction);
+ if (handled) {
+ playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));
+ }
+
+ return handled;
+ } finally {
+ mInLayout = false;
+ }
+ }
+
+ /**
+ * When selection changes, it is possible that the previously selected or the
+ * next selected item will change its size. If so, we need to offset some folks,
+ * and re-layout the items as appropriate.
+ *
+ * @param selectedView The currently selected view (before changing selection).
+ * should be <code>null</code> if there was no previous selection.
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ * @param newSelectedPosition The position of the next selection.
+ * @param newFocusAssigned whether new focus was assigned. This matters because
+ * when something has focus, we don't want to show selection (ugh).
+ */
+ private void handleNewSelectionChange(View selectedView, int direction, int newSelectedPosition,
+ boolean newFocusAssigned) {
+ forceValidFocusDirection(direction);
+
+ if (newSelectedPosition == INVALID_POSITION) {
+ throw new IllegalArgumentException("newSelectedPosition needs to be valid");
+ }
+
+ // Whether or not we are moving down/right or up/left, we want to preserve the
+ // top/left of whatever view is at the start:
+ // - moving down/right: the view that had selection
+ // - moving up/left: the view that is getting selection
+ final int selectedIndex = mSelectedPosition - mFirstPosition;
+ final int nextSelectedIndex = newSelectedPosition - mFirstPosition;
+ int startViewIndex, endViewIndex;
+ boolean topSelected = false;
+ View startView;
+ View endView;
+
+ if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) {
+ startViewIndex = nextSelectedIndex;
+ endViewIndex = selectedIndex;
+ startView = getChildAt(startViewIndex);
+ endView = selectedView;
+ topSelected = true;
+ } else {
+ startViewIndex = selectedIndex;
+ endViewIndex = nextSelectedIndex;
+ startView = selectedView;
+ endView = getChildAt(endViewIndex);
+ }
+
+ final int numChildren = getChildCount();
+
+ // start with top view: is it changing size?
+ if (startView != null) {
+ startView.setSelected(!newFocusAssigned && topSelected);
+ measureAndAdjustDown(startView, startViewIndex, numChildren);
+ }
+
+ // is the bottom view changing size?
+ if (endView != null) {
+ endView.setSelected(!newFocusAssigned && !topSelected);
+ measureAndAdjustDown(endView, endViewIndex, numChildren);
+ }
+ }
+
+ /**
+ * Re-measure a child, and if its height changes, lay it out preserving its
+ * top, and adjust the children below it appropriately.
+ *
+ * @param child The child
+ * @param childIndex The view group index of the child.
+ * @param numChildren The number of children in the view group.
+ */
+ private void measureAndAdjustDown(View child, int childIndex, int numChildren) {
+ int oldSize = getChildSize(child);
+ measureChild(child);
+
+ if (getChildMeasuredSize(child) == oldSize) {
+ return;
+ }
+
+ // lay out the view, preserving its top
+ relayoutMeasuredChild(child);
+
+ // adjust views below appropriately
+ final int sizeDelta = getChildMeasuredSize(child) - oldSize;
+ for (int i = childIndex + 1; i < numChildren; i++) {
+ getChildAt(i).offsetTopAndBottom(sizeDelta);
+ }
+ }
+
+ /**
+ * Do an arrow scroll based on focus searching. If a new view is
+ * given focus, return the selection delta and amount to scroll via
+ * an {@link ArrowScrollFocusResult}, otherwise, return null.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return The result if focus has changed, or <code>null</code>.
+ */
+ private ArrowScrollFocusResult arrowScrollFocused(final int direction) {
+ forceValidFocusDirection(direction);
+
+ final View selectedView = getSelectedView();
+ final View newFocus;
+ final int searchPoint;
+
+ if (selectedView != null && selectedView.hasFocus()) {
+ View oldFocus = selectedView.findFocus();
+ newFocus = FocusFinder.getInstance().findNextFocus(this, oldFocus, direction);
+ } else {
+ if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) {
+ boolean fadingEdgeShowing = (mFirstPosition > 0);
+ final int start = getStartEdge() +
+ (fadingEdgeShowing ? getArrowScrollPreviewLength() : 0);
+
+ final int selectedStart;
+ if (selectedView != null) {
+ selectedStart = getChildStartEdge(selectedView);
+ } else {
+ selectedStart = start;
+ }
+
+ searchPoint = Math.max(selectedStart, start);
+ } else {
+ final boolean fadingEdgeShowing =
+ (mFirstPosition + getChildCount() - 1) < mItemCount;
+ final int end = getEndEdge() - (fadingEdgeShowing ? getArrowScrollPreviewLength() : 0);
+
+ final int selectedEnd;
+ if (selectedView != null) {
+ selectedEnd = getChildEndEdge(selectedView);
+ } else {
+ selectedEnd = end;
+ }
+
+ searchPoint = Math.min(selectedEnd, end);
+ }
+
+ final int x = (mIsVertical ? 0 : searchPoint);
+ final int y = (mIsVertical ? searchPoint : 0);
+ mTempRect.set(x, y, x, y);
+
+ newFocus = FocusFinder.getInstance().findNextFocusFromRect(this, mTempRect, direction);
+ }
+
+ if (newFocus != null) {
+ final int positionOfNewFocus = positionOfNewFocus(newFocus);
+
+ // If the focus change is in a different new position, make sure
+ // we aren't jumping over another selectable position.
+ if (mSelectedPosition != INVALID_POSITION && positionOfNewFocus != mSelectedPosition) {
+ final int selectablePosition = lookForSelectablePositionOnScreen(direction);
+
+ final boolean movingForward =
+ (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT);
+ final boolean movingBackward =
+ (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT);
+
+ if (selectablePosition != INVALID_POSITION &&
+ ((movingForward && selectablePosition < positionOfNewFocus) ||
+ (movingBackward && selectablePosition > positionOfNewFocus))) {
+ return null;
+ }
+ }
+
+ int focusScroll = amountToScrollToNewFocus(direction, newFocus, positionOfNewFocus);
+
+ final int maxScrollAmount = getMaxScrollAmount();
+ if (focusScroll < maxScrollAmount) {
+ // Not moving too far, safe to give next view focus
+ newFocus.requestFocus(direction);
+ mArrowScrollFocusResult.populate(positionOfNewFocus, focusScroll);
+ return mArrowScrollFocusResult;
+ } else if (distanceToView(newFocus) < maxScrollAmount) {
+ // Case to consider:
+ // Too far to get entire next focusable on screen, but by going
+ // max scroll amount, we are getting it at least partially in view,
+ // so give it focus and scroll the max amount.
+ newFocus.requestFocus(direction);
+ mArrowScrollFocusResult.populate(positionOfNewFocus, maxScrollAmount);
+ return mArrowScrollFocusResult;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return The maximum amount a list view will scroll in response to
+ * an arrow event.
+ */
+ public int getMaxScrollAmount() {
+ return (int) (MAX_SCROLL_FACTOR * getSize());
+ }
+
+ /**
+ * @return The amount to preview next items when arrow scrolling.
+ */
+ private int getArrowScrollPreviewLength() {
+ return mItemMargin + Math.max(MIN_SCROLL_PREVIEW_PIXELS, getFadingEdgeLength());
+ }
+
+ /**
+ * @param newFocus The view that would have focus.
+ * @return the position that contains newFocus
+ */
+ private int positionOfNewFocus(View newFocus) {
+ final int numChildren = getChildCount();
+
+ for (int i = 0; i < numChildren; i++) {
+ final View child = getChildAt(i);
+ if (isViewAncestorOf(newFocus, child)) {
+ return mFirstPosition + i;
+ }
+ }
+
+ throw new IllegalArgumentException("newFocus is not a child of any of the"
+ + " children of the list!");
+ }
+
+ /**
+ * Handle an arrow scroll going up or down. Take into account whether items are selectable,
+ * whether there are focusable items, etc.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return Whether any scrolling, selection or focus change occurred.
+ */
+ private boolean arrowScrollImpl(int direction) {
+ forceValidFocusDirection(direction);
+
+ if (getChildCount() <= 0) {
+ return false;
+ }
+
+ View selectedView = getSelectedView();
+ int selectedPos = mSelectedPosition;
+
+ int nextSelectedPosition = lookForSelectablePositionOnScreen(direction);
+ int amountToScroll = amountToScroll(direction, nextSelectedPosition);
+
+ // If we are moving focus, we may OVERRIDE the default behaviour
+ final ArrowScrollFocusResult focusResult = (mItemsCanFocus ? arrowScrollFocused(direction) : null);
+ if (focusResult != null) {
+ nextSelectedPosition = focusResult.getSelectedPosition();
+ amountToScroll = focusResult.getAmountToScroll();
+ }
+
+ boolean needToRedraw = (focusResult != null);
+ if (nextSelectedPosition != INVALID_POSITION) {
+ handleNewSelectionChange(selectedView, direction, nextSelectedPosition, focusResult != null);
+
+ setSelectedPositionInt(nextSelectedPosition);
+ setNextSelectedPositionInt(nextSelectedPosition);
+
+ selectedView = getSelectedView();
+ selectedPos = nextSelectedPosition;
+
+ if (mItemsCanFocus && focusResult == null) {
+ // There was no new view found to take focus, make sure we
+ // don't leave focus with the old selection.
+ final View focused = getFocusedChild();
+ if (focused != null) {
+ focused.clearFocus();
+ }
+ }
+
+ needToRedraw = true;
+ checkSelectionChanged();
+ }
+
+ if (amountToScroll > 0) {
+ scrollListItemsBy(direction == View.FOCUS_UP || direction == View.FOCUS_LEFT ?
+ amountToScroll : -amountToScroll);
+ needToRedraw = true;
+ }
+
+ // If we didn't find a new focusable, make sure any existing focused
+ // item that was panned off screen gives up focus.
+ if (mItemsCanFocus && focusResult == null &&
+ selectedView != null && selectedView.hasFocus()) {
+ final View focused = selectedView.findFocus();
+ if (!isViewAncestorOf(focused, this) || distanceToView(focused) > 0) {
+ focused.clearFocus();
+ }
+ }
+
+ // If the current selection is panned off, we need to remove the selection
+ if (nextSelectedPosition == INVALID_POSITION && selectedView != null
+ && !isViewAncestorOf(selectedView, this)) {
+ selectedView = null;
+ hideSelector();
+
+ // But we don't want to set the ressurect position (that would make subsequent
+ // unhandled key events bring back the item we just scrolled off)
+ mResurrectToPosition = INVALID_POSITION;
+ }
+
+ if (needToRedraw) {
+ if (selectedView != null) {
+ positionSelector(selectedPos, selectedView);
+ mSelectedStart = getChildStartEdge(selectedView);
+ }
+
+ if (!awakenScrollbarsInternal()) {
+ invalidate();
+ }
+
+ invokeOnItemScrollListener();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine how much we need to scroll in order to get the next selected view
+ * visible. The amount is capped at {@link #getMaxScrollAmount()}.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ * @param nextSelectedPosition The position of the next selection, or
+ * {@link #INVALID_POSITION} if there is no next selectable position
+ *
+ * @return The amount to scroll. Note: this is always positive! Direction
+ * needs to be taken into account when actually scrolling.
+ */
+ private int amountToScroll(int direction, int nextSelectedPosition) {
+ forceValidFocusDirection(direction);
+
+ final int numChildren = getChildCount();
+
+ if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) {
+ final int end = getEndEdge();
+
+ int indexToMakeVisible = numChildren - 1;
+ if (nextSelectedPosition != INVALID_POSITION) {
+ indexToMakeVisible = nextSelectedPosition - mFirstPosition;
+ }
+
+ final int positionToMakeVisible = mFirstPosition + indexToMakeVisible;
+ final View viewToMakeVisible = getChildAt(indexToMakeVisible);
+
+ int goalEnd = end;
+ if (positionToMakeVisible < mItemCount - 1) {
+ goalEnd -= getArrowScrollPreviewLength();
+ }
+
+ final int viewToMakeVisibleStart = getChildStartEdge(viewToMakeVisible);
+ final int viewToMakeVisibleEnd = getChildEndEdge(viewToMakeVisible);
+
+ if (viewToMakeVisibleEnd <= goalEnd) {
+ // Target item is fully visible
+ return 0;
+ }
+
+ if (nextSelectedPosition != INVALID_POSITION &&
+ (goalEnd - viewToMakeVisibleStart) >= getMaxScrollAmount()) {
+ // Item already has enough of it visible, changing selection is good enough
+ return 0;
+ }
+
+ int amountToScroll = (viewToMakeVisibleEnd - goalEnd);
+
+ if (mFirstPosition + numChildren == mItemCount) {
+ final int lastChildEnd = getChildEndEdge(getChildAt(numChildren - 1));
+
+ // Last is last in list -> Make sure we don't scroll past it
+ final int max = lastChildEnd - end;
+ amountToScroll = Math.min(amountToScroll, max);
+ }
+
+ return Math.min(amountToScroll, getMaxScrollAmount());
+ } else {
+ final int start = getStartEdge();
+
+ int indexToMakeVisible = 0;
+ if (nextSelectedPosition != INVALID_POSITION) {
+ indexToMakeVisible = nextSelectedPosition - mFirstPosition;
+ }
+
+ final int positionToMakeVisible = mFirstPosition + indexToMakeVisible;
+ final View viewToMakeVisible = getChildAt(indexToMakeVisible);
+
+ int goalStart = start;
+ if (positionToMakeVisible > 0) {
+ goalStart += getArrowScrollPreviewLength();
+ }
+
+ final int viewToMakeVisibleStart = getChildStartEdge(viewToMakeVisible);
+ final int viewToMakeVisibleEnd = getChildEndEdge(viewToMakeVisible);
+
+ if (viewToMakeVisibleStart >= goalStart) {
+ // Item is fully visible
+ return 0;
+ }
+
+ if (nextSelectedPosition != INVALID_POSITION &&
+ (viewToMakeVisibleEnd - goalStart) >= getMaxScrollAmount()) {
+ // Item already has enough of it visible, changing selection is good enough
+ return 0;
+ }
+
+ int amountToScroll = (goalStart - viewToMakeVisibleStart);
+
+ if (mFirstPosition == 0) {
+ final int firstChildStart = getChildStartEdge(getChildAt(0));
+
+ // First is first in list -> make sure we don't scroll past it
+ final int max = start - firstChildStart;
+ amountToScroll = Math.min(amountToScroll, max);
+ }
+
+ return Math.min(amountToScroll, getMaxScrollAmount());
+ }
+ }
+
+ /**
+ * Determine how much we need to scroll in order to get newFocus in view.
+ *
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ * @param newFocus The view that would take focus.
+ * @param positionOfNewFocus The position of the list item containing newFocus
+ *
+ * @return The amount to scroll. Note: this is always positive! Direction
+ * needs to be taken into account when actually scrolling.
+ */
+ private int amountToScrollToNewFocus(int direction, View newFocus, int positionOfNewFocus) {
+ forceValidFocusDirection(direction);
+
+ int amountToScroll = 0;
+
+ newFocus.getDrawingRect(mTempRect);
+ offsetDescendantRectToMyCoords(newFocus, mTempRect);
+
+ if (direction == View.FOCUS_UP || direction == View.FOCUS_LEFT) {
+ final int start = getStartEdge();
+ final int newFocusStart = (mIsVertical ? mTempRect.top : mTempRect.left);
+
+ if (newFocusStart < start) {
+ amountToScroll = start - newFocusStart;
+ if (positionOfNewFocus > 0) {
+ amountToScroll += getArrowScrollPreviewLength();
+ }
+ }
+ } else {
+ final int end = getEndEdge();
+ final int newFocusEnd = (mIsVertical ? mTempRect.bottom : mTempRect.right);
+
+ if (newFocusEnd > end) {
+ amountToScroll = newFocusEnd - end;
+ if (positionOfNewFocus < mItemCount - 1) {
+ amountToScroll += getArrowScrollPreviewLength();
+ }
+ }
+ }
+
+ return amountToScroll;
+ }
+
+ /**
+ * Determine the distance to the nearest edge of a view in a particular
+ * direction.
+ *
+ * @param descendant A descendant of this list.
+ * @return The distance, or 0 if the nearest edge is already on screen.
+ */
+ private int distanceToView(View descendant) {
+ descendant.getDrawingRect(mTempRect);
+ offsetDescendantRectToMyCoords(descendant, mTempRect);
+
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ final int viewStart = (mIsVertical ? mTempRect.top : mTempRect.left);
+ final int viewEnd = (mIsVertical ? mTempRect.bottom : mTempRect.right);
+
+ int distance = 0;
+ if (viewEnd < start) {
+ distance = start - viewEnd;
+ } else if (viewStart > end) {
+ distance = viewStart - end;
+ }
+
+ return distance;
+ }
+
+ private boolean handleKeyScroll(KeyEvent event, int count, int direction) {
+ boolean handled = false;
+
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded();
+ if (!handled) {
+ while (count-- > 0) {
+ if (arrowScroll(direction)) {
+ handled = true;
+ } else {
+ break;
+ }
+ }
+ }
+ } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) {
+ handled = resurrectSelectionIfNeeded() || fullScroll(direction);
+ }
+
+ return handled;
+ }
+
+ private boolean handleKeyEvent(int keyCode, int count, KeyEvent event) {
+ if (mAdapter == null || !mIsAttached) {
+ return false;
+ }
+
+ if (mDataChanged) {
+ layoutChildren();
+ }
+
+ boolean handled = false;
+ final int action = event.getAction();
+
+ if (action != KeyEvent.ACTION_UP) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_DPAD_UP:
+ if (mIsVertical) {
+ handled = handleKeyScroll(event, count, View.FOCUS_UP);
+ } else if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = handleFocusWithinItem(View.FOCUS_UP);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_DOWN: {
+ if (mIsVertical) {
+ handled = handleKeyScroll(event, count, View.FOCUS_DOWN);
+ } else if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = handleFocusWithinItem(View.FOCUS_DOWN);
+ }
+ break;
+ }
+
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (!mIsVertical) {
+ handled = handleKeyScroll(event, count, View.FOCUS_LEFT);
+ } else if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = handleFocusWithinItem(View.FOCUS_LEFT);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (!mIsVertical) {
+ handled = handleKeyScroll(event, count, View.FOCUS_RIGHT);
+ } else if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = handleFocusWithinItem(View.FOCUS_RIGHT);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ case KeyEvent.KEYCODE_ENTER:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded();
+ if (!handled
+ && event.getRepeatCount() == 0 && getChildCount() > 0) {
+ keyPressed();
+ handled = true;
+ }
+ }
+ break;
+
+ case KeyEvent.KEYCODE_SPACE:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded() ||
+ pageScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT);
+ } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_SHIFT_ON)) {
+ handled = resurrectSelectionIfNeeded() ||
+ fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT);
+ }
+
+ handled = true;
+ break;
+
+ case KeyEvent.KEYCODE_PAGE_UP:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded() ||
+ pageScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT);
+ } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) {
+ handled = resurrectSelectionIfNeeded() ||
+ fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded() ||
+ pageScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT);
+ } else if (KeyEventCompat.hasModifiers(event, KeyEvent.META_ALT_ON)) {
+ handled = resurrectSelectionIfNeeded() ||
+ fullScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded() ||
+ fullScroll(mIsVertical ? View.FOCUS_UP : View.FOCUS_LEFT);
+ }
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_END:
+ if (KeyEventCompat.hasNoModifiers(event)) {
+ handled = resurrectSelectionIfNeeded() ||
+ fullScroll(mIsVertical ? View.FOCUS_DOWN : View.FOCUS_RIGHT);
+ }
+ break;
+ }
+ }
+
+ if (handled) {
+ return true;
+ }
+
+ switch (action) {
+ case KeyEvent.ACTION_DOWN:
+ return super.onKeyDown(keyCode, event);
+
+ case KeyEvent.ACTION_UP:
+ if (!isEnabled()) {
+ return true;
+ }
+
+ if (isClickable() && isPressed() &&
+ mSelectedPosition >= 0 && mAdapter != null &&
+ mSelectedPosition < mAdapter.getCount()) {
+
+ final View child = getChildAt(mSelectedPosition - mFirstPosition);
+ if (child != null) {
+ performItemClick(child, mSelectedPosition, mSelectedRowId);
+ child.setPressed(false);
+ }
+
+ setPressed(false);
+ return true;
+ }
+
+ return false;
+
+ case KeyEvent.ACTION_MULTIPLE:
+ return super.onKeyMultiple(keyCode, count, event);
+
+ default:
+ return false;
+ }
+ }
+
+ private void initOrResetVelocityTracker() {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ } else {
+ mVelocityTracker.clear();
+ }
+ }
+
+ private void initVelocityTrackerIfNotExists() {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ }
+
+ private void recycleVelocityTracker() {
+ if (mVelocityTracker != null) {
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ }
+ }
+
+ /**
+ * Notify our scroll listener (if there is one) of a change in scroll state
+ */
+ private void invokeOnItemScrollListener() {
+ if (mOnScrollListener != null) {
+ mOnScrollListener.onScroll(this, mFirstPosition, getChildCount(), mItemCount);
+ }
+
+ // Dummy values, View's implementation does not use these.
+ onScrollChanged(0, 0, 0, 0);
+ }
+
+ private void reportScrollStateChange(int newState) {
+ if (newState == mLastScrollState) {
+ return;
+ }
+
+ if (mOnScrollListener != null) {
+ mLastScrollState = newState;
+ mOnScrollListener.onScrollStateChanged(this, newState);
+ }
+ }
+
+ private boolean maybeStartScrolling(int delta) {
+ final boolean isOverScroll = (mOverScroll != 0);
+ if (Math.abs(delta) <= mTouchSlop && !isOverScroll) {
+ return false;
+ }
+
+ if (isOverScroll) {
+ mTouchMode = TOUCH_MODE_OVERSCROLL;
+ } else {
+ mTouchMode = TOUCH_MODE_DRAGGING;
+ }
+
+ // Time to start stealing events! Once we've stolen them, don't
+ // let anyone steal from us.
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ parent.requestDisallowInterceptTouchEvent(true);
+ }
+
+ cancelCheckForLongPress();
+
+ setPressed(false);
+ View motionView = getChildAt(mMotionPosition - mFirstPosition);
+ if (motionView != null) {
+ motionView.setPressed(false);
+ }
+
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+
+ return true;
+ }
+
+ private void maybeScroll(int delta) {
+ if (mTouchMode == TOUCH_MODE_DRAGGING) {
+ handleDragChange(delta);
+ } else if (mTouchMode == TOUCH_MODE_OVERSCROLL) {
+ handleOverScrollChange(delta);
+ }
+ }
+
+ private void handleDragChange(int delta) {
+ // Time to start stealing events! Once we've stolen them, don't
+ // let anyone steal from us.
+ final ViewParent parent = getParent();
+ if (parent != null) {
+ parent.requestDisallowInterceptTouchEvent(true);
+ }
+
+ final int motionIndex;
+ if (mMotionPosition >= 0) {
+ motionIndex = mMotionPosition - mFirstPosition;
+ } else {
+ // If we don't have a motion position that we can reliably track,
+ // pick something in the middle to make a best guess at things below.
+ motionIndex = getChildCount() / 2;
+ }
+
+ int motionViewPrevStart = 0;
+ View motionView = this.getChildAt(motionIndex);
+ if (motionView != null) {
+ motionViewPrevStart = getChildStartEdge(motionView);
+ }
+
+ boolean atEdge = scrollListItemsBy(delta);
+
+ motionView = this.getChildAt(motionIndex);
+ if (motionView != null) {
+ final int motionViewRealStart = getChildStartEdge(motionView);
+
+ if (atEdge) {
+ final int overscroll = -delta - (motionViewRealStart - motionViewPrevStart);
+ updateOverScrollState(delta, overscroll);
+ }
+ }
+ }
+
+ private void updateOverScrollState(int delta, int overscroll) {
+ overScrollByInternal((mIsVertical ? 0 : overscroll),
+ (mIsVertical ? overscroll : 0),
+ (mIsVertical ? 0 : mOverScroll),
+ (mIsVertical ? mOverScroll : 0),
+ 0, 0,
+ (mIsVertical ? 0 : mOverscrollDistance),
+ (mIsVertical ? mOverscrollDistance : 0),
+ true);
+
+ if (Math.abs(mOverscrollDistance) == Math.abs(mOverScroll)) {
+ // Break fling velocity if we impacted an edge
+ if (mVelocityTracker != null) {
+ mVelocityTracker.clear();
+ }
+ }
+
+ final int overscrollMode = ViewCompat.getOverScrollMode(this);
+ if (overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
+ (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits())) {
+ mTouchMode = TOUCH_MODE_OVERSCROLL;
+
+ float pull = (float) overscroll / getSize();
+ if (delta > 0) {
+ mStartEdge.onPull(pull);
+
+ if (!mEndEdge.isFinished()) {
+ mEndEdge.onRelease();
+ }
+ } else if (delta < 0) {
+ mEndEdge.onPull(pull);
+
+ if (!mStartEdge.isFinished()) {
+ mStartEdge.onRelease();
+ }
+ }
+
+ if (delta != 0) {
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+ }
+
+ private void handleOverScrollChange(int delta) {
+ final int oldOverScroll = mOverScroll;
+ final int newOverScroll = oldOverScroll - delta;
+
+ int overScrollDistance = -delta;
+ if ((newOverScroll < 0 && oldOverScroll >= 0) ||
+ (newOverScroll > 0 && oldOverScroll <= 0)) {
+ overScrollDistance = -oldOverScroll;
+ delta += overScrollDistance;
+ } else {
+ delta = 0;
+ }
+
+ if (overScrollDistance != 0) {
+ updateOverScrollState(delta, overScrollDistance);
+ }
+
+ if (delta != 0) {
+ if (mOverScroll != 0) {
+ mOverScroll = 0;
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+
+ scrollListItemsBy(delta);
+ mTouchMode = TOUCH_MODE_DRAGGING;
+
+ // We did not scroll the full amount. Treat this essentially like the
+ // start of a new touch scroll
+ mMotionPosition = findClosestMotionRowOrColumn((int) mLastTouchPos);
+ mTouchRemainderPos = 0;
+ }
+ }
+
+ /**
+ * What is the distance between the source and destination rectangles given the direction of
+ * focus navigation between them? The direction basically helps figure out more quickly what is
+ * self evident by the relationship between the rects...
+ *
+ * @param source the source rectangle
+ * @param dest the destination rectangle
+ * @param direction the direction
+ * @return the distance between the rectangles
+ */
+ private static int getDistance(Rect source, Rect dest, int direction) {
+ int sX, sY; // source x, y
+ int dX, dY; // dest x, y
+
+ switch (direction) {
+ case View.FOCUS_RIGHT:
+ sX = source.right;
+ sY = source.top + source.height() / 2;
+ dX = dest.left;
+ dY = dest.top + dest.height() / 2;
+ break;
+
+ case View.FOCUS_DOWN:
+ sX = source.left + source.width() / 2;
+ sY = source.bottom;
+ dX = dest.left + dest.width() / 2;
+ dY = dest.top;
+ break;
+
+ case View.FOCUS_LEFT:
+ sX = source.left;
+ sY = source.top + source.height() / 2;
+ dX = dest.right;
+ dY = dest.top + dest.height() / 2;
+ break;
+
+ case View.FOCUS_UP:
+ sX = source.left + source.width() / 2;
+ sY = source.top;
+ dX = dest.left + dest.width() / 2;
+ dY = dest.bottom;
+ break;
+
+ case View.FOCUS_FORWARD:
+ case View.FOCUS_BACKWARD:
+ sX = source.right + source.width() / 2;
+ sY = source.top + source.height() / 2;
+ dX = dest.left + dest.width() / 2;
+ dY = dest.top + dest.height() / 2;
+ break;
+
+ default:
+ throw new IllegalArgumentException("direction must be one of "
+ + "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT, "
+ + "FOCUS_FORWARD, FOCUS_BACKWARD}.");
+ }
+
+ int deltaX = dX - sX;
+ int deltaY = dY - sY;
+
+ return deltaY * deltaY + deltaX * deltaX;
+ }
+
+ private int findMotionRowOrColumn(int motionPos) {
+ int childCount = getChildCount();
+ if (childCount == 0) {
+ return INVALID_POSITION;
+ }
+
+ for (int i = 0; i < childCount; i++) {
+ final View v = getChildAt(i);
+ if (motionPos <= getChildEndEdge(v)) {
+ return mFirstPosition + i;
+ }
+ }
+
+ return INVALID_POSITION;
+ }
+
+ private int findClosestMotionRowOrColumn(int motionPos) {
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return INVALID_POSITION;
+ }
+
+ final int motionRow = findMotionRowOrColumn(motionPos);
+ if (motionRow != INVALID_POSITION) {
+ return motionRow;
+ } else {
+ return mFirstPosition + childCount - 1;
+ }
+ }
+
+ @TargetApi(9)
+ private int getScaledOverscrollDistance(ViewConfiguration vc) {
+ if (Build.VERSION.SDK_INT < 9) {
+ return 0;
+ }
+
+ return vc.getScaledOverscrollDistance();
+ }
+
+ private int getStartEdge() {
+ return (mIsVertical ? getPaddingTop() : getPaddingLeft());
+ }
+
+ private int getEndEdge() {
+ if (mIsVertical) {
+ return (getHeight() - getPaddingBottom());
+ } else {
+ return (getWidth() - getPaddingRight());
+ }
+ }
+
+ private int getSize() {
+ return (mIsVertical ? getHeight() : getWidth());
+ }
+
+ private int getAvailableSize() {
+ if (mIsVertical) {
+ return getHeight() - getPaddingBottom() - getPaddingTop();
+ } else {
+ return getWidth() - getPaddingRight() - getPaddingLeft();
+ }
+ }
+
+ private int getChildStartEdge(View child) {
+ return (mIsVertical ? child.getTop() : child.getLeft());
+ }
+
+ private int getChildEndEdge(View child) {
+ return (mIsVertical ? child.getBottom() : child.getRight());
+ }
+
+ private int getChildSize(View child) {
+ return (mIsVertical ? child.getHeight() : child.getWidth());
+ }
+
+ private int getChildMeasuredSize(View child) {
+ return (mIsVertical ? child.getMeasuredHeight() : child.getMeasuredWidth());
+ }
+
+ private int getFadingEdgeLength() {
+ return (mIsVertical ? getVerticalFadingEdgeLength() : getHorizontalFadingEdgeLength());
+ }
+
+ private int getMinSelectionPixel(int start, int fadingEdgeLength, int selectedPosition) {
+ // First pixel we can draw the selection into.
+ int selectionPixelStart = start;
+ if (selectedPosition > 0) {
+ selectionPixelStart += fadingEdgeLength;
+ }
+
+ return selectionPixelStart;
+ }
+
+ private int getMaxSelectionPixel(int end, int fadingEdgeLength,
+ int selectedPosition) {
+ int selectionPixelEnd = end;
+ if (selectedPosition != mItemCount - 1) {
+ selectionPixelEnd -= fadingEdgeLength;
+ }
+
+ return selectionPixelEnd;
+ }
+
+ private boolean contentFits() {
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return true;
+ }
+
+ if (childCount != mItemCount) {
+ return false;
+ }
+
+ View first = getChildAt(0);
+ View last = getChildAt(childCount - 1);
+
+ return (getChildStartEdge(first) >= getStartEdge() &&
+ getChildEndEdge(last) <= getEndEdge());
+ }
+
+ private void triggerCheckForTap() {
+ if (mPendingCheckForTap == null) {
+ mPendingCheckForTap = new CheckForTap();
+ }
+
+ postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
+ }
+
+ private void cancelCheckForTap() {
+ if (mPendingCheckForTap == null) {
+ return;
+ }
+
+ removeCallbacks(mPendingCheckForTap);
+ }
+
+ private void triggerCheckForLongPress() {
+ if (mPendingCheckForLongPress == null) {
+ mPendingCheckForLongPress = new CheckForLongPress();
+ }
+
+ mPendingCheckForLongPress.rememberWindowAttachCount();
+
+ postDelayed(mPendingCheckForLongPress,
+ ViewConfiguration.getLongPressTimeout());
+ }
+
+ private void cancelCheckForLongPress() {
+ if (mPendingCheckForLongPress == null) {
+ return;
+ }
+
+ removeCallbacks(mPendingCheckForLongPress);
+ }
+
+ private boolean scrollListItemsBy(int incrementalDelta) {
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ return true;
+ }
+
+ final int firstStart = getChildStartEdge(getChildAt(0));
+ final int lastEnd = getChildEndEdge(getChildAt(childCount - 1));
+
+ final int paddingTop = getPaddingTop();
+ final int paddingLeft = getPaddingLeft();
+
+ final int paddingStart = (mIsVertical ? paddingTop : paddingLeft);
+
+ final int spaceBefore = paddingStart - firstStart;
+ final int end = getEndEdge();
+ final int spaceAfter = lastEnd - end;
+
+ final int size = getAvailableSize();
+
+ if (incrementalDelta < 0) {
+ incrementalDelta = Math.max(-(size - 1), incrementalDelta);
+ } else {
+ incrementalDelta = Math.min(size - 1, incrementalDelta);
+ }
+
+ final int firstPosition = mFirstPosition;
+
+ final boolean cannotScrollDown = (firstPosition == 0 &&
+ firstStart >= paddingStart && incrementalDelta >= 0);
+ final boolean cannotScrollUp = (firstPosition + childCount == mItemCount &&
+ lastEnd <= end && incrementalDelta <= 0);
+
+ if (cannotScrollDown || cannotScrollUp) {
+ return incrementalDelta != 0;
+ }
+
+ final boolean inTouchMode = isInTouchMode();
+ if (inTouchMode) {
+ hideSelector();
+ }
+
+ int start = 0;
+ int count = 0;
+
+ final boolean down = (incrementalDelta < 0);
+ if (down) {
+ int childrenStart = -incrementalDelta + paddingStart;
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final int childEnd = getChildEndEdge(child);
+
+ if (childEnd >= childrenStart) {
+ break;
+ }
+
+ count++;
+ mRecycler.addScrapView(child, firstPosition + i);
+ }
+ } else {
+ int childrenEnd = end - incrementalDelta;
+
+ for (int i = childCount - 1; i >= 0; i--) {
+ final View child = getChildAt(i);
+ final int childStart = getChildStartEdge(child);
+
+ if (childStart <= childrenEnd) {
+ break;
+ }
+
+ start = i;
+ count++;
+ mRecycler.addScrapView(child, firstPosition + i);
+ }
+ }
+
+ mBlockLayoutRequests = true;
+
+ if (count > 0) {
+ detachViewsFromParent(start, count);
+ }
+
+ // invalidate before moving the children to avoid unnecessary invalidate
+ // calls to bubble up from the children all the way to the top
+ if (!awakenScrollbarsInternal()) {
+ invalidate();
+ }
+
+ offsetChildren(incrementalDelta);
+
+ if (down) {
+ mFirstPosition += count;
+ }
+
+ final int absIncrementalDelta = Math.abs(incrementalDelta);
+ if (spaceBefore < absIncrementalDelta || spaceAfter < absIncrementalDelta) {
+ fillGap(down);
+ }
+
+ if (!inTouchMode && mSelectedPosition != INVALID_POSITION) {
+ final int childIndex = mSelectedPosition - mFirstPosition;
+ if (childIndex >= 0 && childIndex < getChildCount()) {
+ positionSelector(mSelectedPosition, getChildAt(childIndex));
+ }
+ } else if (mSelectorPosition != INVALID_POSITION) {
+ final int childIndex = mSelectorPosition - mFirstPosition;
+ if (childIndex >= 0 && childIndex < getChildCount()) {
+ positionSelector(INVALID_POSITION, getChildAt(childIndex));
+ }
+ } else {
+ mSelectorRect.setEmpty();
+ }
+
+ mBlockLayoutRequests = false;
+
+ invokeOnItemScrollListener();
+
+ return false;
+ }
+
+ @TargetApi(14)
+ private final float getCurrVelocity() {
+ if (Build.VERSION.SDK_INT >= 14) {
+ return mScroller.getCurrVelocity();
+ }
+
+ return 0;
+ }
+
+ @TargetApi(5)
+ private boolean awakenScrollbarsInternal() {
+ return (Build.VERSION.SDK_INT >= 5) && super.awakenScrollBars();
+ }
+
+ @Override
+ public void computeScroll() {
+ if (!mScroller.computeScrollOffset()) {
+ return;
+ }
+
+ final int pos;
+ if (mIsVertical) {
+ pos = mScroller.getCurrY();
+ } else {
+ pos = mScroller.getCurrX();
+ }
+
+ final int diff = (int) (pos - mLastTouchPos);
+ mLastTouchPos = pos;
+
+ final boolean stopped = scrollListItemsBy(diff);
+
+ if (!stopped && !mScroller.isFinished()) {
+ ViewCompat.postInvalidateOnAnimation(this);
+ } else {
+ if (stopped) {
+ final int overScrollMode = ViewCompat.getOverScrollMode(this);
+ if (overScrollMode != ViewCompat.OVER_SCROLL_NEVER) {
+ final EdgeEffectCompat edge =
+ (diff > 0 ? mStartEdge : mEndEdge);
+
+ boolean needsInvalidate =
+ edge.onAbsorb(Math.abs((int) getCurrVelocity()));
+
+ if (needsInvalidate) {
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+
+ finishSmoothScrolling();
+ }
+
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+ }
+ }
+
+ private void finishEdgeGlows() {
+ if (mStartEdge != null) {
+ mStartEdge.finish();
+ }
+
+ if (mEndEdge != null) {
+ mEndEdge.finish();
+ }
+ }
+
+ private boolean drawStartEdge(Canvas canvas) {
+ if (mStartEdge.isFinished()) {
+ return false;
+ }
+
+ if (mIsVertical) {
+ return mStartEdge.draw(canvas);
+ }
+
+ final int restoreCount = canvas.save();
+ final int height = getHeight();
+
+ canvas.translate(0, height);
+ canvas.rotate(270);
+
+ final boolean needsInvalidate = mStartEdge.draw(canvas);
+ canvas.restoreToCount(restoreCount);
+ return needsInvalidate;
+ }
+
+ private boolean drawEndEdge(Canvas canvas) {
+ if (mEndEdge.isFinished()) {
+ return false;
+ }
+
+ final int restoreCount = canvas.save();
+ final int width = getWidth();
+ final int height = getHeight();
+
+ if (mIsVertical) {
+ canvas.translate(-width, height);
+ canvas.rotate(180, width, 0);
+ } else {
+ canvas.translate(width, 0);
+ canvas.rotate(90);
+ }
+
+ final boolean needsInvalidate = mEndEdge.draw(canvas);
+ canvas.restoreToCount(restoreCount);
+ return needsInvalidate;
+ }
+
+ private void finishSmoothScrolling() {
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+
+ mScroller.abortAnimation();
+ if (mPositionScroller != null) {
+ mPositionScroller.stop();
+ }
+ }
+
+ private void drawSelector(Canvas canvas) {
+ if (!mSelectorRect.isEmpty()) {
+ final Drawable selector = mSelector;
+ selector.setBounds(mSelectorRect);
+ selector.draw(canvas);
+ }
+ }
+
+ private void useDefaultSelector() {
+ setSelector(getResources().getDrawable(
+ android.R.drawable.list_selector_background));
+ }
+
+ private boolean shouldShowSelector() {
+ return (hasFocus() && !isInTouchMode()) || touchModeDrawsInPressedState();
+ }
+
+ private void positionSelector(int position, View selected) {
+ if (position != INVALID_POSITION) {
+ mSelectorPosition = position;
+ }
+
+ mSelectorRect.set(selected.getLeft(), selected.getTop(), selected.getRight(),
+ selected.getBottom());
+
+ final boolean isChildViewEnabled = mIsChildViewEnabled;
+ if (selected.isEnabled() != isChildViewEnabled) {
+ mIsChildViewEnabled = !isChildViewEnabled;
+
+ if (getSelectedItemPosition() != INVALID_POSITION) {
+ refreshDrawableState();
+ }
+ }
+ }
+
+ private void hideSelector() {
+ if (mSelectedPosition != INVALID_POSITION) {
+ if (mLayoutMode != LAYOUT_SPECIFIC) {
+ mResurrectToPosition = mSelectedPosition;
+ }
+
+ if (mNextSelectedPosition >= 0 && mNextSelectedPosition != mSelectedPosition) {
+ mResurrectToPosition = mNextSelectedPosition;
+ }
+
+ setSelectedPositionInt(INVALID_POSITION);
+ setNextSelectedPositionInt(INVALID_POSITION);
+
+ mSelectedStart = 0;
+ }
+ }
+
+ private void setSelectedPositionInt(int position) {
+ mSelectedPosition = position;
+ mSelectedRowId = getItemIdAtPosition(position);
+ }
+
+ private void setSelectionInt(int position) {
+ setNextSelectedPositionInt(position);
+ boolean awakeScrollbars = false;
+
+ final int selectedPosition = mSelectedPosition;
+ if (selectedPosition >= 0) {
+ if (position == selectedPosition - 1) {
+ awakeScrollbars = true;
+ } else if (position == selectedPosition + 1) {
+ awakeScrollbars = true;
+ }
+ }
+
+ layoutChildren();
+
+ if (awakeScrollbars) {
+ awakenScrollbarsInternal();
+ }
+ }
+
+ private void setNextSelectedPositionInt(int position) {
+ mNextSelectedPosition = position;
+ mNextSelectedRowId = getItemIdAtPosition(position);
+
+ // If we are trying to sync to the selection, update that too
+ if (mNeedSync && mSyncMode == SYNC_SELECTED_POSITION && position >= 0) {
+ mSyncPosition = position;
+ mSyncRowId = mNextSelectedRowId;
+ }
+ }
+
+ private boolean touchModeDrawsInPressedState() {
+ switch (mTouchMode) {
+ case TOUCH_MODE_TAP:
+ case TOUCH_MODE_DONE_WAITING:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Sets the selector state to "pressed" and posts a CheckForKeyLongPress to see if
+ * this is a long press.
+ */
+ private void keyPressed() {
+ if (!isEnabled() || !isClickable()) {
+ return;
+ }
+
+ final Drawable selector = mSelector;
+ final Rect selectorRect = mSelectorRect;
+
+ if (selector != null && (isFocused() || touchModeDrawsInPressedState())
+ && !selectorRect.isEmpty()) {
+
+ final View child = getChildAt(mSelectedPosition - mFirstPosition);
+
+ if (child != null) {
+ if (child.hasFocusable()) {
+ return;
+ }
+
+ child.setPressed(true);
+ }
+
+ setPressed(true);
+
+ final boolean longClickable = isLongClickable();
+ final Drawable d = selector.getCurrent();
+ if (d != null && d instanceof TransitionDrawable) {
+ if (longClickable) {
+ ((TransitionDrawable) d).startTransition(
+ ViewConfiguration.getLongPressTimeout());
+ } else {
+ ((TransitionDrawable) d).resetTransition();
+ }
+ }
+
+ if (longClickable && !mDataChanged) {
+ if (mPendingCheckForKeyLongPress == null) {
+ mPendingCheckForKeyLongPress = new CheckForKeyLongPress();
+ }
+
+ mPendingCheckForKeyLongPress.rememberWindowAttachCount();
+ postDelayed(mPendingCheckForKeyLongPress, ViewConfiguration.getLongPressTimeout());
+ }
+ }
+ }
+
+ private void updateSelectorState() {
+ if (mSelector != null) {
+ if (shouldShowSelector()) {
+ mSelector.setState(getDrawableState());
+ } else {
+ mSelector.setState(STATE_NOTHING);
+ }
+ }
+ }
+
+ private void checkSelectionChanged() {
+ if ((mSelectedPosition != mOldSelectedPosition) || (mSelectedRowId != mOldSelectedRowId)) {
+ selectionChanged();
+ mOldSelectedPosition = mSelectedPosition;
+ mOldSelectedRowId = mSelectedRowId;
+ }
+ }
+
+ private void selectionChanged() {
+ OnItemSelectedListener listener = getOnItemSelectedListener();
+ if (listener == null) {
+ return;
+ }
+
+ if (mInLayout || mBlockLayoutRequests) {
+ // If we are in a layout traversal, defer notification
+ // by posting. This ensures that the view tree is
+ // in a consistent state and is able to accommodate
+ // new layout or invalidate requests.
+ if (mSelectionNotifier == null) {
+ mSelectionNotifier = new SelectionNotifier();
+ }
+
+ post(mSelectionNotifier);
+ } else {
+ fireOnSelected();
+ performAccessibilityActionsOnSelected();
+ }
+ }
+
+ private void fireOnSelected() {
+ OnItemSelectedListener listener = getOnItemSelectedListener();
+ if (listener == null) {
+ return;
+ }
+
+ final int selection = getSelectedItemPosition();
+ if (selection >= 0) {
+ View v = getSelectedView();
+ listener.onItemSelected(this, v, selection,
+ mAdapter.getItemId(selection));
+ } else {
+ listener.onNothingSelected(this);
+ }
+ }
+
+ private void performAccessibilityActionsOnSelected() {
+ final int position = getSelectedItemPosition();
+ if (position >= 0) {
+ // We fire selection events here not in View
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
+ }
+ }
+
+ private int lookForSelectablePosition(int position) {
+ return lookForSelectablePosition(position, true);
+ }
+
+ private int lookForSelectablePosition(int position, boolean lookDown) {
+ final ListAdapter adapter = mAdapter;
+ if (adapter == null || isInTouchMode()) {
+ return INVALID_POSITION;
+ }
+
+ final int itemCount = mItemCount;
+ if (!mAreAllItemsSelectable) {
+ if (lookDown) {
+ position = Math.max(0, position);
+ while (position < itemCount && !adapter.isEnabled(position)) {
+ position++;
+ }
+ } else {
+ position = Math.min(position, itemCount - 1);
+ while (position >= 0 && !adapter.isEnabled(position)) {
+ position--;
+ }
+ }
+
+ if (position < 0 || position >= itemCount) {
+ return INVALID_POSITION;
+ }
+
+ return position;
+ } else {
+ if (position < 0 || position >= itemCount) {
+ return INVALID_POSITION;
+ }
+
+ return position;
+ }
+ }
+
+ /**
+ * @param direction either {@link View#FOCUS_UP} or {@link View#FOCUS_DOWN} or
+ * {@link View#FOCUS_LEFT} or {@link View#FOCUS_RIGHT} depending on the
+ * current view orientation.
+ *
+ * @return The position of the next selectable position of the views that
+ * are currently visible, taking into account the fact that there might
+ * be no selection. Returns {@link #INVALID_POSITION} if there is no
+ * selectable view on screen in the given direction.
+ */
+ private int lookForSelectablePositionOnScreen(int direction) {
+ forceValidFocusDirection(direction);
+
+ final int firstPosition = mFirstPosition;
+ final ListAdapter adapter = getAdapter();
+
+ if (direction == View.FOCUS_DOWN || direction == View.FOCUS_RIGHT) {
+ int startPos = (mSelectedPosition != INVALID_POSITION ?
+ mSelectedPosition + 1 : firstPosition);
+
+ if (startPos >= adapter.getCount()) {
+ return INVALID_POSITION;
+ }
+
+ if (startPos < firstPosition) {
+ startPos = firstPosition;
+ }
+
+ final int lastVisiblePos = getLastVisiblePosition();
+
+ for (int pos = startPos; pos <= lastVisiblePos; pos++) {
+ if (adapter.isEnabled(pos)
+ && getChildAt(pos - firstPosition).getVisibility() == View.VISIBLE) {
+ return pos;
+ }
+ }
+ } else {
+ final int last = firstPosition + getChildCount() - 1;
+
+ int startPos = (mSelectedPosition != INVALID_POSITION) ?
+ mSelectedPosition - 1 : firstPosition + getChildCount() - 1;
+
+ if (startPos < 0 || startPos >= adapter.getCount()) {
+ return INVALID_POSITION;
+ }
+
+ if (startPos > last) {
+ startPos = last;
+ }
+
+ for (int pos = startPos; pos >= firstPosition; pos--) {
+ if (adapter.isEnabled(pos)
+ && getChildAt(pos - firstPosition).getVisibility() == View.VISIBLE) {
+ return pos;
+ }
+ }
+ }
+
+ return INVALID_POSITION;
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+ updateSelectorState();
+ }
+
+ @Override
+ protected int[] onCreateDrawableState(int extraSpace) {
+ // If the child view is enabled then do the default behavior.
+ if (mIsChildViewEnabled) {
+ // Common case
+ return super.onCreateDrawableState(extraSpace);
+ }
+
+ // The selector uses this View's drawable state. The selected child view
+ // is disabled, so we need to remove the enabled state from the drawable
+ // states.
+ final int enabledState = ENABLED_STATE_SET[0];
+
+ // If we don't have any extra space, it will return one of the static state arrays,
+ // and clearing the enabled state on those arrays is a bad thing! If we specify
+ // we need extra space, it will create+copy into a new array that safely mutable.
+ int[] state = super.onCreateDrawableState(extraSpace + 1);
+ int enabledPos = -1;
+ for (int i = state.length - 1; i >= 0; i--) {
+ if (state[i] == enabledState) {
+ enabledPos = i;
+ break;
+ }
+ }
+
+ // Remove the enabled state
+ if (enabledPos >= 0) {
+ System.arraycopy(state, enabledPos + 1, state, enabledPos,
+ state.length - enabledPos - 1);
+ }
+
+ return state;
+ }
+
+ @Override
+ protected boolean canAnimate() {
+ return (super.canAnimate() && mItemCount > 0);
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ final boolean drawSelectorOnTop = mDrawSelectorOnTop;
+ if (!drawSelectorOnTop) {
+ drawSelector(canvas);
+ }
+
+ super.dispatchDraw(canvas);
+
+ if (drawSelectorOnTop) {
+ drawSelector(canvas);
+ }
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ boolean needsInvalidate = false;
+
+ if (mStartEdge != null) {
+ needsInvalidate |= drawStartEdge(canvas);
+ }
+
+ if (mEndEdge != null) {
+ needsInvalidate |= drawEndEdge(canvas);
+ }
+
+ if (needsInvalidate) {
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+
+ @Override
+ public void requestLayout() {
+ if (!mInLayout && !mBlockLayoutRequests) {
+ super.requestLayout();
+ }
+ }
+
+ @Override
+ public View getSelectedView() {
+ if (mItemCount > 0 && mSelectedPosition >= 0) {
+ return getChildAt(mSelectedPosition - mFirstPosition);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public void setSelection(int position) {
+ setSelectionFromOffset(position, 0);
+ }
+
+ public void setSelectionFromOffset(int position, int offset) {
+ if (mAdapter == null) {
+ return;
+ }
+
+ if (!isInTouchMode()) {
+ position = lookForSelectablePosition(position);
+ if (position >= 0) {
+ setNextSelectedPositionInt(position);
+ }
+ } else {
+ mResurrectToPosition = position;
+ }
+
+ if (position >= 0) {
+ mLayoutMode = LAYOUT_SPECIFIC;
+
+ if (mIsVertical) {
+ mSpecificStart = getPaddingTop() + offset;
+ } else {
+ mSpecificStart = getPaddingLeft() + offset;
+ }
+
+ if (mNeedSync) {
+ mSyncPosition = position;
+ mSyncRowId = mAdapter.getItemId(position);
+ }
+
+ requestLayout();
+ }
+ }
+
+ public void scrollBy(int offset) {
+ scrollListItemsBy(-offset);
+ }
+
+ /**
+ * Smoothly scroll to the specified adapter position. The view will
+ * scroll such that the indicated position is displayed.
+ * @param position Scroll to this adapter position.
+ */
+ public void smoothScrollToPosition(int position) {
+ if (mPositionScroller == null) {
+ mPositionScroller = new PositionScroller();
+ }
+ mPositionScroller.start(position);
+ }
+
+ /**
+ * Smoothly scroll to the specified adapter position. The view will scroll
+ * such that the indicated position is displayed <code>offset</code> pixels from
+ * the top/left edge of the view, according to the orientation. If this is
+ * impossible, (e.g. the offset would scroll the first or last item beyond the boundaries
+ * of the list) it will get as close as possible. The scroll will take
+ * <code>duration</code> milliseconds to complete.
+ *
+ * @param position Position to scroll to
+ * @param offset Desired distance in pixels of <code>position</code> from the top/left
+ * of the view when scrolling is finished
+ * @param duration Number of milliseconds to use for the scroll
+ */
+ public void smoothScrollToPositionFromOffset(int position, int offset, int duration) {
+ if (mPositionScroller == null) {
+ mPositionScroller = new PositionScroller();
+ }
+ mPositionScroller.startWithOffset(position, offset, duration);
+ }
+
+ /**
+ * Smoothly scroll to the specified adapter position. The view will scroll
+ * such that the indicated position is displayed <code>offset</code> pixels from
+ * the top edge of the view. If this is impossible, (e.g. the offset would scroll
+ * the first or last item beyond the boundaries of the list) it will get as close
+ * as possible.
+ *
+ * @param position Position to scroll to
+ * @param offset Desired distance in pixels of <code>position</code> from the top
+ * of the view when scrolling is finished
+ */
+ public void smoothScrollToPositionFromOffset(int position, int offset) {
+ if (mPositionScroller == null) {
+ mPositionScroller = new PositionScroller();
+ }
+ mPositionScroller.startWithOffset(position, offset);
+ }
+
+ /**
+ * Smoothly scroll to the specified adapter position. The view will
+ * scroll such that the indicated position is displayed, but it will
+ * stop early if scrolling further would scroll boundPosition out of
+ * view.
+ *
+ * @param position Scroll to this adapter position.
+ * @param boundPosition Do not scroll if it would move this adapter
+ * position out of view.
+ */
+ public void smoothScrollToPosition(int position, int boundPosition) {
+ if (mPositionScroller == null) {
+ mPositionScroller = new PositionScroller();
+ }
+ mPositionScroller.start(position, boundPosition);
+ }
+
+ /**
+ * Smoothly scroll by distance pixels over duration milliseconds.
+ * @param distance Distance to scroll in pixels.
+ * @param duration Duration of the scroll animation in milliseconds.
+ */
+ public void smoothScrollBy(int distance, int duration) {
+ // No sense starting to scroll if we're not going anywhere
+ final int firstPosition = mFirstPosition;
+ final int childCount = getChildCount();
+ final int lastPosition = firstPosition + childCount;
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ if (distance == 0 || mItemCount == 0 || childCount == 0 ||
+ (firstPosition == 0 && getChildStartEdge(getChildAt(0)) == start && distance < 0) ||
+ (lastPosition == mItemCount &&
+ getChildEndEdge(getChildAt(childCount - 1)) == end && distance > 0)) {
+ finishSmoothScrolling();
+ } else {
+ mScroller.startScroll(0, 0,
+ mIsVertical ? 0 : -distance,
+ mIsVertical ? -distance : 0,
+ duration);
+
+ mLastTouchPos = 0;
+
+ mTouchMode = TOUCH_MODE_FLINGING;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING);
+
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // Dispatch in the normal way
+ boolean handled = super.dispatchKeyEvent(event);
+ if (!handled) {
+ // If we didn't handle it...
+ final View focused = getFocusedChild();
+ if (focused != null && event.getAction() == KeyEvent.ACTION_DOWN) {
+ // ... and our focused child didn't handle it
+ // ... give it to ourselves so we can scroll if necessary
+ handled = onKeyDown(event.getKeyCode(), event);
+ }
+ }
+
+ return handled;
+ }
+
+ @Override
+ protected void dispatchSetPressed(boolean pressed) {
+ // Don't dispatch setPressed to our children. We call setPressed on ourselves to
+ // get the selector in the right state, but we don't want to press each child.
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (mSelector == null) {
+ useDefaultSelector();
+ }
+
+ int widthMode = MeasureSpec.getMode(widthMeasureSpec);
+ int heightMode = MeasureSpec.getMode(heightMeasureSpec);
+ int widthSize = MeasureSpec.getSize(widthMeasureSpec);
+ int heightSize = MeasureSpec.getSize(heightMeasureSpec);
+
+ int childWidth = 0;
+ int childHeight = 0;
+
+ mItemCount = (mAdapter == null ? 0 : mAdapter.getCount());
+ if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED ||
+ heightMode == MeasureSpec.UNSPECIFIED)) {
+ final View child = obtainView(0, mIsScrap);
+
+ final int secondaryMeasureSpec =
+ (mIsVertical ? widthMeasureSpec : heightMeasureSpec);
+
+ measureScrapChild(child, 0, secondaryMeasureSpec);
+
+ childWidth = child.getMeasuredWidth();
+ childHeight = child.getMeasuredHeight();
+
+ if (recycleOnMeasure()) {
+ mRecycler.addScrapView(child, -1);
+ }
+ }
+
+ if (widthMode == MeasureSpec.UNSPECIFIED) {
+ widthSize = getPaddingLeft() + getPaddingRight() + childWidth;
+ if (mIsVertical) {
+ widthSize += getVerticalScrollbarWidth();
+ }
+ }
+
+ if (heightMode == MeasureSpec.UNSPECIFIED) {
+ heightSize = getPaddingTop() + getPaddingBottom() + childHeight;
+ if (!mIsVertical) {
+ heightSize += getHorizontalScrollbarHeight();
+ }
+ }
+
+ if (mIsVertical && heightMode == MeasureSpec.AT_MOST) {
+ heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
+ }
+
+ if (!mIsVertical && widthMode == MeasureSpec.AT_MOST) {
+ widthSize = measureWidthOfChildren(heightMeasureSpec, 0, NO_POSITION, widthSize, -1);
+ }
+
+ setMeasuredDimension(widthSize, heightSize);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ mInLayout = true;
+
+ if (changed) {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ getChildAt(i).forceLayout();
+ }
+
+ mRecycler.markChildrenDirty();
+ }
+
+ layoutChildren();
+
+ mInLayout = false;
+
+ final int width = r - l - getPaddingLeft() - getPaddingRight();
+ final int height = b - t - getPaddingTop() - getPaddingBottom();
+
+ if (mStartEdge != null && mEndEdge != null) {
+ if (mIsVertical) {
+ mStartEdge.setSize(width, height);
+ mEndEdge.setSize(width, height);
+ } else {
+ mStartEdge.setSize(height, width);
+ mEndEdge.setSize(height, width);
+ }
+ }
+ }
+
+ private void layoutChildren() {
+ if (getWidth() == 0 || getHeight() == 0) {
+ return;
+ }
+
+ final boolean blockLayoutRequests = mBlockLayoutRequests;
+ if (!blockLayoutRequests) {
+ mBlockLayoutRequests = true;
+ } else {
+ return;
+ }
+
+ try {
+ invalidate();
+
+ if (mAdapter == null) {
+ resetState();
+ return;
+ }
+
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ int childCount = getChildCount();
+ int index = 0;
+ int delta = 0;
+
+ View focusLayoutRestoreView = null;
+
+ View selected = null;
+ View oldSelected = null;
+ View newSelected = null;
+ View oldFirstChild = null;
+
+ switch (mLayoutMode) {
+ case LAYOUT_SET_SELECTION:
+ index = mNextSelectedPosition - mFirstPosition;
+ if (index >= 0 && index < childCount) {
+ newSelected = getChildAt(index);
+ }
+
+ break;
+
+ case LAYOUT_FORCE_TOP:
+ case LAYOUT_FORCE_BOTTOM:
+ case LAYOUT_SPECIFIC:
+ case LAYOUT_SYNC:
+ break;
+
+ case LAYOUT_MOVE_SELECTION:
+ default:
+ // Remember the previously selected view
+ index = mSelectedPosition - mFirstPosition;
+ if (index >= 0 && index < childCount) {
+ oldSelected = getChildAt(index);
+ }
+
+ // Remember the previous first child
+ oldFirstChild = getChildAt(0);
+
+ if (mNextSelectedPosition >= 0) {
+ delta = mNextSelectedPosition - mSelectedPosition;
+ }
+
+ // Caution: newSelected might be null
+ newSelected = getChildAt(index + delta);
+ }
+
+ final boolean dataChanged = mDataChanged;
+ if (dataChanged) {
+ handleDataChanged();
+ }
+
+ // Handle the empty set by removing all views that are visible
+ // and calling it a day
+ if (mItemCount == 0) {
+ resetState();
+ return;
+ } else if (mItemCount != mAdapter.getCount()) {
+ throw new IllegalStateException("The content of the adapter has changed but "
+ + "TwoWayView did not receive a notification. Make sure the content of "
+ + "your adapter is not modified from a background thread, but only "
+ + "from the UI thread. [in TwoWayView(" + getId() + ", " + getClass()
+ + ") with Adapter(" + mAdapter.getClass() + ")]");
+ }
+
+ setSelectedPositionInt(mNextSelectedPosition);
+
+ // Reset the focus restoration
+ View focusLayoutRestoreDirectChild = null;
+
+ // Pull all children into the RecycleBin.
+ // These views will be reused if possible
+ final int firstPosition = mFirstPosition;
+ final RecycleBin recycleBin = mRecycler;
+
+ if (dataChanged) {
+ for (int i = 0; i < childCount; i++) {
+ recycleBin.addScrapView(getChildAt(i), firstPosition + i);
+ }
+ } else {
+ recycleBin.fillActiveViews(childCount, firstPosition);
+ }
+
+ // Take focus back to us temporarily to avoid the eventual
+ // call to clear focus when removing the focused child below
+ // from messing things up when ViewAncestor assigns focus back
+ // to someone else.
+ final View focusedChild = getFocusedChild();
+ if (focusedChild != null) {
+ // We can remember the focused view to restore after relayout if the
+ // data hasn't changed, or if the focused position is a header or footer.
+ if (!dataChanged) {
+ focusLayoutRestoreDirectChild = focusedChild;
+
+ // Remember the specific view that had focus
+ focusLayoutRestoreView = findFocus();
+ if (focusLayoutRestoreView != null) {
+ // Tell it we are going to mess with it
+ focusLayoutRestoreView.onStartTemporaryDetach();
+ }
+ }
+
+ requestFocus();
+ }
+
+ // FIXME: We need a way to save current accessibility focus here
+ // so that it can be restored after we re-attach the children on each
+ // layout round.
+
+ detachAllViewsFromParent();
+
+ switch (mLayoutMode) {
+ case LAYOUT_SET_SELECTION:
+ if (newSelected != null) {
+ final int newSelectedStart = getChildStartEdge(newSelected);
+ selected = fillFromSelection(newSelectedStart, start, end);
+ } else {
+ selected = fillFromMiddle(start, end);
+ }
+
+ break;
+
+ case LAYOUT_SYNC:
+ selected = fillSpecific(mSyncPosition, mSpecificStart);
+ break;
+
+ case LAYOUT_FORCE_BOTTOM:
+ selected = fillBefore(mItemCount - 1, end);
+ adjustViewsStartOrEnd();
+ break;
+
+ case LAYOUT_FORCE_TOP:
+ mFirstPosition = 0;
+ selected = fillFromOffset(start);
+ adjustViewsStartOrEnd();
+ break;
+
+ case LAYOUT_SPECIFIC:
+ selected = fillSpecific(reconcileSelectedPosition(), mSpecificStart);
+ break;
+
+ case LAYOUT_MOVE_SELECTION:
+ selected = moveSelection(oldSelected, newSelected, delta, start, end);
+ break;
+
+ default:
+ if (childCount == 0) {
+ final int position = lookForSelectablePosition(0);
+ setSelectedPositionInt(position);
+ selected = fillFromOffset(start);
+ } else {
+ if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) {
+ int offset = start;
+ if (oldSelected != null) {
+ offset = getChildStartEdge(oldSelected);
+ }
+ selected = fillSpecific(mSelectedPosition, offset);
+ } else if (mFirstPosition < mItemCount) {
+ int offset = start;
+ if (oldFirstChild != null) {
+ offset = getChildStartEdge(oldFirstChild);
+ }
+
+ selected = fillSpecific(mFirstPosition, offset);
+ } else {
+ selected = fillSpecific(0, start);
+ }
+ }
+
+ break;
+
+ }
+
+ recycleBin.scrapActiveViews();
+
+ if (selected != null) {
+ if (mItemsCanFocus && hasFocus() && !selected.hasFocus()) {
+ final boolean focusWasTaken = (selected == focusLayoutRestoreDirectChild &&
+ focusLayoutRestoreView != null &&
+ focusLayoutRestoreView.requestFocus()) || selected.requestFocus();
+
+ if (!focusWasTaken) {
+ // Selected item didn't take focus, fine, but still want
+ // to make sure something else outside of the selected view
+ // has focus
+ final View focused = getFocusedChild();
+ if (focused != null) {
+ focused.clearFocus();
+ }
+
+ positionSelector(INVALID_POSITION, selected);
+ } else {
+ selected.setSelected(false);
+ mSelectorRect.setEmpty();
+ }
+ } else {
+ positionSelector(INVALID_POSITION, selected);
+ }
+
+ mSelectedStart = getChildStartEdge(selected);
+ } else {
+ if (mTouchMode > TOUCH_MODE_DOWN && mTouchMode < TOUCH_MODE_DRAGGING) {
+ View child = getChildAt(mMotionPosition - mFirstPosition);
+
+ if (child != null) {
+ positionSelector(mMotionPosition, child);
+ }
+ } else {
+ mSelectedStart = 0;
+ mSelectorRect.setEmpty();
+ }
+
+ // Even if there is not selected position, we may need to restore
+ // focus (i.e. something focusable in touch mode)
+ if (hasFocus() && focusLayoutRestoreView != null) {
+ focusLayoutRestoreView.requestFocus();
+ }
+ }
+
+ // Tell focus view we are done mucking with it, if it is still in
+ // our view hierarchy.
+ if (focusLayoutRestoreView != null
+ && focusLayoutRestoreView.getWindowToken() != null) {
+ focusLayoutRestoreView.onFinishTemporaryDetach();
+ }
+
+ mLayoutMode = LAYOUT_NORMAL;
+ mDataChanged = false;
+ mNeedSync = false;
+
+ setNextSelectedPositionInt(mSelectedPosition);
+ if (mItemCount > 0) {
+ checkSelectionChanged();
+ }
+
+ invokeOnItemScrollListener();
+ } finally {
+ if (!blockLayoutRequests) {
+ mBlockLayoutRequests = false;
+ mDataChanged = false;
+ }
+ }
+ }
+
+ protected boolean recycleOnMeasure() {
+ return true;
+ }
+
+ private void offsetChildren(int offset) {
+ final int childCount = getChildCount();
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+
+ if (mIsVertical) {
+ child.offsetTopAndBottom(offset);
+ } else {
+ child.offsetLeftAndRight(offset);
+ }
+ }
+ }
+
+ private View moveSelection(View oldSelected, View newSelected, int delta, int start,
+ int end) {
+ final int fadingEdgeLength = getFadingEdgeLength();
+ final int selectedPosition = mSelectedPosition;
+
+ final int oldSelectedStart = getChildStartEdge(oldSelected);
+ final int oldSelectedEnd = getChildEndEdge(oldSelected);
+
+ final int minStart = getMinSelectionPixel(start, fadingEdgeLength, selectedPosition);
+ final int maxEnd = getMaxSelectionPixel(end, fadingEdgeLength, selectedPosition);
+
+ View selected = null;
+
+ if (delta > 0) {
+ /*
+ * Case 1: Scrolling down.
+ */
+
+ /*
+ * Before After
+ * | | | |
+ * +-------+ +-------+
+ * | A | | A |
+ * | 1 | => +-------+
+ * +-------+ | B |
+ * | B | | 2 |
+ * +-------+ +-------+
+ * | | | |
+ *
+ * Try to keep the top of the previously selected item where it was.
+ * oldSelected = A
+ * selected = B
+ */
+
+ // Put oldSelected (A) where it belongs
+ oldSelected = makeAndAddView(selectedPosition - 1, oldSelectedStart, true, false);
+
+ final int itemMargin = mItemMargin;
+
+ // Now put the new selection (B) below that
+ selected = makeAndAddView(selectedPosition, oldSelectedEnd + itemMargin, true, true);
+
+ final int selectedStart = getChildStartEdge(selected);
+ final int selectedEnd = getChildEndEdge(selected);
+
+ // Some of the newly selected item extends below the bottom of the list
+ if (selectedEnd > end) {
+ // Find space available above the selection into which we can scroll upwards
+ final int spaceBefore = selectedStart - minStart;
+
+ // Find space required to bring the bottom of the selected item fully into view
+ final int spaceAfter = selectedEnd - maxEnd;
+
+ // Don't scroll more than half the size of the list
+ final int halfSpace = (end - start) / 2;
+ int offset = Math.min(spaceBefore, spaceAfter);
+ offset = Math.min(offset, halfSpace);
+
+ if (mIsVertical) {
+ oldSelected.offsetTopAndBottom(-offset);
+ selected.offsetTopAndBottom(-offset);
+ } else {
+ oldSelected.offsetLeftAndRight(-offset);
+ selected.offsetLeftAndRight(-offset);
+ }
+ }
+
+ // Fill in views before and after
+ fillBefore(mSelectedPosition - 2, selectedStart - itemMargin);
+ adjustViewsStartOrEnd();
+ fillAfter(mSelectedPosition + 1, selectedEnd + itemMargin);
+ } else if (delta < 0) {
+ /*
+ * Case 2: Scrolling up.
+ */
+
+ /*
+ * Before After
+ * | | | |
+ * +-------+ +-------+
+ * | A | | A |
+ * +-------+ => | 1 |
+ * | B | +-------+
+ * | 2 | | B |
+ * +-------+ +-------+
+ * | | | |
+ *
+ * Try to keep the top of the item about to become selected where it was.
+ * newSelected = A
+ * olSelected = B
+ */
+
+ if (newSelected != null) {
+ // Try to position the top of newSel (A) where it was before it was selected
+ final int newSelectedStart = getChildStartEdge(newSelected);
+ selected = makeAndAddView(selectedPosition, newSelectedStart, true, true);
+ } else {
+ // If (A) was not on screen and so did not have a view, position
+ // it above the oldSelected (B)
+ selected = makeAndAddView(selectedPosition, oldSelectedStart, false, true);
+ }
+
+ final int selectedStart = getChildStartEdge(selected);
+ final int selectedEnd = getChildEndEdge(selected);
+
+ // Some of the newly selected item extends above the top of the list
+ if (selectedStart < minStart) {
+ // Find space required to bring the top of the selected item fully into view
+ final int spaceBefore = minStart - selectedStart;
+
+ // Find space available below the selection into which we can scroll downwards
+ final int spaceAfter = maxEnd - selectedEnd;
+
+ // Don't scroll more than half the height of the list
+ final int halfSpace = (end - start) / 2;
+ int offset = Math.min(spaceBefore, spaceAfter);
+ offset = Math.min(offset, halfSpace);
+
+ if (mIsVertical) {
+ selected.offsetTopAndBottom(offset);
+ } else {
+ selected.offsetLeftAndRight(offset);
+ }
+ }
+
+ // Fill in views above and below
+ fillBeforeAndAfter(selected, selectedPosition);
+ } else {
+ /*
+ * Case 3: Staying still
+ */
+
+ selected = makeAndAddView(selectedPosition, oldSelectedStart, true, true);
+
+ final int selectedStart = getChildStartEdge(selected);
+ final int selectedEnd = getChildEndEdge(selected);
+
+ // We're staying still...
+ if (oldSelectedStart < start) {
+ // ... but the top of the old selection was off screen.
+ // (This can happen if the data changes size out from under us)
+ int newEnd = selectedEnd;
+ if (newEnd < start + 20) {
+ // Not enough visible -- bring it onscreen
+ if (mIsVertical) {
+ selected.offsetTopAndBottom(start - selectedStart);
+ } else {
+ selected.offsetLeftAndRight(start - selectedStart);
+ }
+ }
+ }
+
+ // Fill in views above and below
+ fillBeforeAndAfter(selected, selectedPosition);
+ }
+
+ return selected;
+ }
+
+ void confirmCheckedPositionsById() {
+ // Clear out the positional check states, we'll rebuild it below from IDs.
+ mCheckStates.clear();
+
+ for (int checkedIndex = 0; checkedIndex < mCheckedIdStates.size(); checkedIndex++) {
+ final long id = mCheckedIdStates.keyAt(checkedIndex);
+ final int lastPos = mCheckedIdStates.valueAt(checkedIndex);
+
+ final long lastPosId = mAdapter.getItemId(lastPos);
+ if (id != lastPosId) {
+ // Look around to see if the ID is nearby. If not, uncheck it.
+ final int start = Math.max(0, lastPos - CHECK_POSITION_SEARCH_DISTANCE);
+ final int end = Math.min(lastPos + CHECK_POSITION_SEARCH_DISTANCE, mItemCount);
+ boolean found = false;
+
+ for (int searchPos = start; searchPos < end; searchPos++) {
+ final long searchId = mAdapter.getItemId(searchPos);
+ if (id == searchId) {
+ found = true;
+ mCheckStates.put(searchPos, true);
+ mCheckedIdStates.setValueAt(checkedIndex, searchPos);
+ break;
+ }
+ }
+
+ if (!found) {
+ mCheckedIdStates.delete(id);
+ checkedIndex--;
+ mCheckedItemCount--;
+ }
+ } else {
+ mCheckStates.put(lastPos, true);
+ }
+ }
+ }
+
+ private void handleDataChanged() {
+ if (mChoiceMode != ChoiceMode.NONE && mAdapter != null && mAdapter.hasStableIds()) {
+ confirmCheckedPositionsById();
+ }
+
+ mRecycler.clearTransientStateViews();
+
+ final int itemCount = mItemCount;
+ if (itemCount > 0) {
+ int newPos;
+ int selectablePos;
+
+ // Find the row we are supposed to sync to
+ if (mNeedSync) {
+ // Update this first, since setNextSelectedPositionInt inspects it
+ mNeedSync = false;
+ mPendingSync = null;
+
+ switch (mSyncMode) {
+ case SYNC_SELECTED_POSITION:
+ if (isInTouchMode()) {
+ // We saved our state when not in touch mode. (We know this because
+ // mSyncMode is SYNC_SELECTED_POSITION.) Now we are trying to
+ // restore in touch mode. Just leave mSyncPosition as it is (possibly
+ // adjusting if the available range changed) and return.
+ mLayoutMode = LAYOUT_SYNC;
+ mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1);
+
+ return;
+ } else {
+ // See if we can find a position in the new data with the same
+ // id as the old selection. This will change mSyncPosition.
+ newPos = findSyncPosition();
+ if (newPos >= 0) {
+ // Found it. Now verify that new selection is still selectable
+ selectablePos = lookForSelectablePosition(newPos, true);
+ if (selectablePos == newPos) {
+ // Same row id is selected
+ mSyncPosition = newPos;
+
+ if (mSyncSize == getSize()) {
+ // If we are at the same height as when we saved state, try
+ // to restore the scroll position too.
+ mLayoutMode = LAYOUT_SYNC;
+ } else {
+ // We are not the same height as when the selection was saved, so
+ // don't try to restore the exact position
+ mLayoutMode = LAYOUT_SET_SELECTION;
+ }
+
+ // Restore selection
+ setNextSelectedPositionInt(newPos);
+ return;
+ }
+ }
+ }
+ break;
+
+ case SYNC_FIRST_POSITION:
+ // Leave mSyncPosition as it is -- just pin to available range
+ mLayoutMode = LAYOUT_SYNC;
+ mSyncPosition = Math.min(Math.max(0, mSyncPosition), itemCount - 1);
+
+ return;
+ }
+ }
+
+ if (!isInTouchMode()) {
+ // We couldn't find matching data -- try to use the same position
+ newPos = getSelectedItemPosition();
+
+ // Pin position to the available range
+ if (newPos >= itemCount) {
+ newPos = itemCount - 1;
+ }
+ if (newPos < 0) {
+ newPos = 0;
+ }
+
+ // Make sure we select something selectable -- first look down
+ selectablePos = lookForSelectablePosition(newPos, true);
+
+ if (selectablePos >= 0) {
+ setNextSelectedPositionInt(selectablePos);
+ return;
+ } else {
+ // Looking down didn't work -- try looking up
+ selectablePos = lookForSelectablePosition(newPos, false);
+ if (selectablePos >= 0) {
+ setNextSelectedPositionInt(selectablePos);
+ return;
+ }
+ }
+ } else {
+ // We already know where we want to resurrect the selection
+ if (mResurrectToPosition >= 0) {
+ return;
+ }
+ }
+ }
+
+ // Nothing is selected. Give up and reset everything.
+ mLayoutMode = LAYOUT_FORCE_TOP;
+ mSelectedPosition = INVALID_POSITION;
+ mSelectedRowId = INVALID_ROW_ID;
+ mNextSelectedPosition = INVALID_POSITION;
+ mNextSelectedRowId = INVALID_ROW_ID;
+ mNeedSync = false;
+ mPendingSync = null;
+ mSelectorPosition = INVALID_POSITION;
+
+ checkSelectionChanged();
+ }
+
+ private int reconcileSelectedPosition() {
+ int position = mSelectedPosition;
+ if (position < 0) {
+ position = mResurrectToPosition;
+ }
+
+ position = Math.max(0, position);
+ position = Math.min(position, mItemCount - 1);
+
+ return position;
+ }
+
+ boolean resurrectSelection() {
+ final int childCount = getChildCount();
+ if (childCount <= 0) {
+ return false;
+ }
+
+ int selectedStart = 0;
+ int selectedPosition;
+
+ int start = getStartEdge();
+ int end = getEndEdge();
+
+ final int firstPosition = mFirstPosition;
+ final int toPosition = mResurrectToPosition;
+ boolean down = true;
+
+ if (toPosition >= firstPosition && toPosition < firstPosition + childCount) {
+ selectedPosition = toPosition;
+
+ final View selected = getChildAt(selectedPosition - mFirstPosition);
+ selectedStart = getChildStartEdge(selected);
+
+ final int selectedEnd = getChildEndEdge(selected);
+
+ // We are scrolled, don't get in the fade
+ if (selectedStart < start) {
+ selectedStart = start + getFadingEdgeLength();
+ } else if (selectedEnd > end) {
+ selectedStart = end - getChildMeasuredSize(selected) - getFadingEdgeLength();
+ }
+ } else if (toPosition < firstPosition) {
+ // Default to selecting whatever is first
+ selectedPosition = firstPosition;
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final int childStart = getChildStartEdge(child);
+
+ if (i == 0) {
+ // Remember the position of the first item
+ selectedStart = childStart;
+
+ // See if we are scrolled at all
+ if (firstPosition > 0 || childStart < start) {
+ // If we are scrolled, don't select anything that is
+ // in the fade region
+ start += getFadingEdgeLength();
+ }
+ }
+
+ if (childStart >= start) {
+ // Found a view whose top is fully visible
+ selectedPosition = firstPosition + i;
+ selectedStart = childStart;
+ break;
+ }
+ }
+ } else {
+ final int itemCount = mItemCount;
+ selectedPosition = firstPosition + childCount - 1;
+ down = false;
+
+ for (int i = childCount - 1; i >= 0; i--) {
+ final View child = getChildAt(i);
+ final int childStart = getChildStartEdge(child);
+ final int childEnd = getChildEndEdge(child);
+
+ if (i == childCount - 1) {
+ selectedStart = childStart;
+
+ if (firstPosition + childCount < itemCount || childEnd > end) {
+ end -= getFadingEdgeLength();
+ }
+ }
+
+ if (childEnd <= end) {
+ selectedPosition = firstPosition + i;
+ selectedStart = childStart;
+ break;
+ }
+ }
+ }
+
+ mResurrectToPosition = INVALID_POSITION;
+
+ finishSmoothScrolling();
+
+ mTouchMode = TOUCH_MODE_REST;
+ reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
+
+ mSpecificStart = selectedStart;
+
+ selectedPosition = lookForSelectablePosition(selectedPosition, down);
+ if (selectedPosition >= firstPosition && selectedPosition <= getLastVisiblePosition()) {
+ mLayoutMode = LAYOUT_SPECIFIC;
+ updateSelectorState();
+ setSelectionInt(selectedPosition);
+ invokeOnItemScrollListener();
+ } else {
+ selectedPosition = INVALID_POSITION;
+ }
+
+ return selectedPosition >= 0;
+ }
+
+ /**
+ * If there is a selection returns false.
+ * Otherwise resurrects the selection and returns true if resurrected.
+ */
+ boolean resurrectSelectionIfNeeded() {
+ if (mSelectedPosition < 0 && resurrectSelection()) {
+ updateSelectorState();
+ return true;
+ }
+
+ return false;
+ }
+
+ private int getChildWidthMeasureSpec(LayoutParams lp) {
+ if (!mIsVertical && lp.width == LayoutParams.WRAP_CONTENT) {
+ return MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ } else if (mIsVertical) {
+ final int maxWidth = getWidth() - getPaddingLeft() - getPaddingRight();
+ return MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY);
+ } else {
+ return MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY);
+ }
+ }
+
+ private int getChildHeightMeasureSpec(LayoutParams lp) {
+ if (mIsVertical && lp.height == LayoutParams.WRAP_CONTENT) {
+ return MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ } else if (!mIsVertical) {
+ final int maxHeight = getHeight() - getPaddingTop() - getPaddingBottom();
+ return MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.EXACTLY);
+ } else {
+ return MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY);
+ }
+ }
+
+ private void measureChild(View child) {
+ measureChild(child, (LayoutParams) child.getLayoutParams());
+ }
+
+ private void measureChild(View child, LayoutParams lp) {
+ final int widthSpec = getChildWidthMeasureSpec(lp);
+ final int heightSpec = getChildHeightMeasureSpec(lp);
+ child.measure(widthSpec, heightSpec);
+ }
+
+ private void relayoutMeasuredChild(View child) {
+ final int w = child.getMeasuredWidth();
+ final int h = child.getMeasuredHeight();
+
+ final int childLeft = getPaddingLeft();
+ final int childRight = childLeft + w;
+ final int childTop = child.getTop();
+ final int childBottom = childTop + h;
+
+ child.layout(childLeft, childTop, childRight, childBottom);
+ }
+
+ private void measureScrapChild(View scrapChild, int position, int secondaryMeasureSpec) {
+ LayoutParams lp = (LayoutParams) scrapChild.getLayoutParams();
+ if (lp == null) {
+ lp = generateDefaultLayoutParams();
+ scrapChild.setLayoutParams(lp);
+ }
+
+ lp.viewType = mAdapter.getItemViewType(position);
+ lp.forceAdd = true;
+
+ final int widthMeasureSpec;
+ final int heightMeasureSpec;
+ if (mIsVertical) {
+ widthMeasureSpec = secondaryMeasureSpec;
+ heightMeasureSpec = getChildHeightMeasureSpec(lp);
+ } else {
+ widthMeasureSpec = getChildWidthMeasureSpec(lp);
+ heightMeasureSpec = secondaryMeasureSpec;
+ }
+
+ scrapChild.measure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ /**
+ * Measures the height of the given range of children (inclusive) and
+ * returns the height with this TwoWayView's padding and item margin heights
+ * included. If maxHeight is provided, the measuring will stop when the
+ * current height reaches maxHeight.
+ *
+ * @param widthMeasureSpec The width measure spec to be given to a child's
+ * {@link View#measure(int, int)}.
+ * @param startPosition The position of the first child to be shown.
+ * @param endPosition The (inclusive) position of the last child to be
+ * shown. Specify {@link #NO_POSITION} if the last child should be
+ * the last available child from the adapter.
+ * @param maxHeight The maximum height that will be returned (if all the
+ * children don't fit in this value, this value will be
+ * returned).
+ * @param disallowPartialChildPosition In general, whether the returned
+ * height should only contain entire children. This is more
+ * powerful--it is the first inclusive position at which partial
+ * children will not be allowed. Example: it looks nice to have
+ * at least 3 completely visible children, and in portrait this
+ * will most likely fit; but in landscape there could be times
+ * when even 2 children can not be completely shown, so a value
+ * of 2 (remember, inclusive) would be good (assuming
+ * startPosition is 0).
+ * @return The height of this TwoWayView with the given children.
+ */
+ private int measureHeightOfChildren(int widthMeasureSpec, int startPosition, int endPosition,
+ final int maxHeight, int disallowPartialChildPosition) {
+
+ final int paddingTop = getPaddingTop();
+ final int paddingBottom = getPaddingBottom();
+
+ final ListAdapter adapter = mAdapter;
+ if (adapter == null) {
+ return paddingTop + paddingBottom;
+ }
+
+ // Include the padding of the list
+ int returnedHeight = paddingTop + paddingBottom;
+ final int itemMargin = mItemMargin;
+
+ // The previous height value that was less than maxHeight and contained
+ // no partial children
+ int prevHeightWithoutPartialChild = 0;
+ int i;
+ View child;
+
+ // mItemCount - 1 since endPosition parameter is inclusive
+ endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition;
+ final RecycleBin recycleBin = mRecycler;
+ final boolean shouldRecycle = recycleOnMeasure();
+ final boolean[] isScrap = mIsScrap;
+
+ for (i = startPosition; i <= endPosition; ++i) {
+ child = obtainView(i, isScrap);
+
+ measureScrapChild(child, i, widthMeasureSpec);
+
+ if (i > 0) {
+ // Count the item margin for all but one child
+ returnedHeight += itemMargin;
+ }
+
+ // Recycle the view before we possibly return from the method
+ if (shouldRecycle) {
+ recycleBin.addScrapView(child, -1);
+ }
+
+ returnedHeight += child.getMeasuredHeight();
+
+ if (returnedHeight >= maxHeight) {
+ // We went over, figure out which height to return. If returnedHeight > maxHeight,
+ // then the i'th position did not fit completely.
+ return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1)
+ && (i > disallowPartialChildPosition) // We've past the min pos
+ && (prevHeightWithoutPartialChild > 0) // We have a prev height
+ && (returnedHeight != maxHeight) // i'th child did not fit completely
+ ? prevHeightWithoutPartialChild
+ : maxHeight;
+ }
+
+ if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) {
+ prevHeightWithoutPartialChild = returnedHeight;
+ }
+ }
+
+ // At this point, we went through the range of children, and they each
+ // completely fit, so return the returnedHeight
+ return returnedHeight;
+ }
+
+ /**
+ * Measures the width of the given range of children (inclusive) and
+ * returns the width with this TwoWayView's padding and item margin widths
+ * included. If maxWidth is provided, the measuring will stop when the
+ * current width reaches maxWidth.
+ *
+ * @param heightMeasureSpec The height measure spec to be given to a child's
+ * {@link View#measure(int, int)}.
+ * @param startPosition The position of the first child to be shown.
+ * @param endPosition The (inclusive) position of the last child to be
+ * shown. Specify {@link #NO_POSITION} if the last child should be
+ * the last available child from the adapter.
+ * @param maxWidth The maximum width that will be returned (if all the
+ * children don't fit in this value, this value will be
+ * returned).
+ * @param disallowPartialChildPosition In general, whether the returned
+ * width should only contain entire children. This is more
+ * powerful--it is the first inclusive position at which partial
+ * children will not be allowed. Example: it looks nice to have
+ * at least 3 completely visible children, and in portrait this
+ * will most likely fit; but in landscape there could be times
+ * when even 2 children can not be completely shown, so a value
+ * of 2 (remember, inclusive) would be good (assuming
+ * startPosition is 0).
+ * @return The width of this TwoWayView with the given children.
+ */
+ private int measureWidthOfChildren(int heightMeasureSpec, int startPosition, int endPosition,
+ final int maxWidth, int disallowPartialChildPosition) {
+
+ final int paddingLeft = getPaddingLeft();
+ final int paddingRight = getPaddingRight();
+
+ final ListAdapter adapter = mAdapter;
+ if (adapter == null) {
+ return paddingLeft + paddingRight;
+ }
+
+ // Include the padding of the list
+ int returnedWidth = paddingLeft + paddingRight;
+ final int itemMargin = mItemMargin;
+
+ // The previous height value that was less than maxHeight and contained
+ // no partial children
+ int prevWidthWithoutPartialChild = 0;
+ int i;
+ View child;
+
+ // mItemCount - 1 since endPosition parameter is inclusive
+ endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition;
+ final RecycleBin recycleBin = mRecycler;
+ final boolean shouldRecycle = recycleOnMeasure();
+ final boolean[] isScrap = mIsScrap;
+
+ for (i = startPosition; i <= endPosition; ++i) {
+ child = obtainView(i, isScrap);
+
+ measureScrapChild(child, i, heightMeasureSpec);
+
+ if (i > 0) {
+ // Count the item margin for all but one child
+ returnedWidth += itemMargin;
+ }
+
+ // Recycle the view before we possibly return from the method
+ if (shouldRecycle) {
+ recycleBin.addScrapView(child, -1);
+ }
+
+ returnedWidth += child.getMeasuredWidth();
+
+ if (returnedWidth >= maxWidth) {
+ // We went over, figure out which width to return. If returnedWidth > maxWidth,
+ // then the i'th position did not fit completely.
+ return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1)
+ && (i > disallowPartialChildPosition) // We've past the min pos
+ && (prevWidthWithoutPartialChild > 0) // We have a prev width
+ && (returnedWidth != maxWidth) // i'th child did not fit completely
+ ? prevWidthWithoutPartialChild
+ : maxWidth;
+ }
+
+ if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) {
+ prevWidthWithoutPartialChild = returnedWidth;
+ }
+ }
+
+ // At this point, we went through the range of children, and they each
+ // completely fit, so return the returnedWidth
+ return returnedWidth;
+ }
+
+ private View makeAndAddView(int position, int offset, boolean flow, boolean selected) {
+ final int top;
+ final int left;
+
+ // Compensate item margin on the first item of the list if the item margin
+ // is negative to avoid incorrect offset for the very first child.
+ if (mIsVertical) {
+ top = offset;
+ left = getPaddingLeft();
+ } else {
+ top = getPaddingTop();
+ left = offset;
+ }
+
+ if (!mDataChanged) {
+ // Try to use an existing view for this position
+ final View activeChild = mRecycler.getActiveView(position);
+ if (activeChild != null) {
+ // Found it -- we're using an existing child
+ // This just needs to be positioned
+ setupChild(activeChild, position, top, left, flow, selected, true);
+
+ return activeChild;
+ }
+ }
+
+ // Make a new view for this position, or convert an unused view if possible
+ final View child = obtainView(position, mIsScrap);
+
+ // This needs to be positioned and measured
+ setupChild(child, position, top, left, flow, selected, mIsScrap[0]);
+
+ return child;
+ }
+
+ @TargetApi(11)
+ private void setupChild(View child, int position, int top, int left,
+ boolean flow, boolean selected, boolean recycled) {
+ final boolean isSelected = selected && shouldShowSelector();
+ final boolean updateChildSelected = isSelected != child.isSelected();
+ final int touchMode = mTouchMode;
+
+ final boolean isPressed = touchMode > TOUCH_MODE_DOWN && touchMode < TOUCH_MODE_DRAGGING &&
+ mMotionPosition == position;
+
+ final boolean updateChildPressed = isPressed != child.isPressed();
+ final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();
+
+ // Respect layout params that are already in the view. Otherwise make some up...
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp == null) {
+ lp = generateDefaultLayoutParams();
+ }
+
+ lp.viewType = mAdapter.getItemViewType(position);
+
+ if (recycled && !lp.forceAdd) {
+ attachViewToParent(child, (flow ? -1 : 0), lp);
+ } else {
+ lp.forceAdd = false;
+ addViewInLayout(child, (flow ? -1 : 0), lp, true);
+ }
+
+ if (updateChildSelected) {
+ child.setSelected(isSelected);
+ }
+
+ if (updateChildPressed) {
+ child.setPressed(isPressed);
+ }
+
+ if (mChoiceMode != ChoiceMode.NONE && mCheckStates != null) {
+ if (child instanceof Checkable) {
+ ((Checkable) child).setChecked(mCheckStates.get(position));
+ } else if (Build.VERSION.SDK_INT >= HONEYCOMB) {
+ child.setActivated(mCheckStates.get(position));
+ }
+ }
+
+ if (needToMeasure) {
+ measureChild(child, lp);
+ } else {
+ cleanupLayoutState(child);
+ }
+
+ final int w = child.getMeasuredWidth();
+ final int h = child.getMeasuredHeight();
+
+ final int childTop = (mIsVertical && !flow ? top - h : top);
+ final int childLeft = (!mIsVertical && !flow ? left - w : left);
+
+ if (needToMeasure) {
+ final int childRight = childLeft + w;
+ final int childBottom = childTop + h;
+
+ child.layout(childLeft, childTop, childRight, childBottom);
+ } else {
+ child.offsetLeftAndRight(childLeft - child.getLeft());
+ child.offsetTopAndBottom(childTop - child.getTop());
+ }
+ }
+
+ void fillGap(boolean down) {
+ final int childCount = getChildCount();
+
+ if (down) {
+ final int start = getStartEdge();
+ final int lastEnd = getChildEndEdge(getChildAt(childCount - 1));
+ final int offset = (childCount > 0 ? lastEnd + mItemMargin : start);
+ fillAfter(mFirstPosition + childCount, offset);
+ correctTooHigh(getChildCount());
+ } else {
+ final int end = getEndEdge();
+ final int firstStart = getChildStartEdge(getChildAt(0));
+ final int offset = (childCount > 0 ? firstStart - mItemMargin : end);
+ fillBefore(mFirstPosition - 1, offset);
+ correctTooLow(getChildCount());
+ }
+ }
+
+ private View fillBefore(int pos, int nextOffset) {
+ View selectedView = null;
+
+ final int start = getStartEdge();
+
+ while (nextOffset > start && pos >= 0) {
+ boolean isSelected = (pos == mSelectedPosition);
+
+ View child = makeAndAddView(pos, nextOffset, false, isSelected);
+ nextOffset = getChildStartEdge(child) - mItemMargin;
+
+ if (isSelected) {
+ selectedView = child;
+ }
+
+ pos--;
+ }
+
+ mFirstPosition = pos + 1;
+
+ return selectedView;
+ }
+
+ private View fillAfter(int pos, int nextOffset) {
+ View selectedView = null;
+
+ final int end = getEndEdge();
+
+ while (nextOffset < end && pos < mItemCount) {
+ boolean selected = (pos == mSelectedPosition);
+
+ View child = makeAndAddView(pos, nextOffset, true, selected);
+ nextOffset = getChildEndEdge(child) + mItemMargin;
+
+ if (selected) {
+ selectedView = child;
+ }
+
+ pos++;
+ }
+
+ return selectedView;
+ }
+
+ private View fillSpecific(int position, int offset) {
+ final boolean tempIsSelected = (position == mSelectedPosition);
+ View temp = makeAndAddView(position, offset, true, tempIsSelected);
+
+ // Possibly changed again in fillBefore if we add rows above this one.
+ mFirstPosition = position;
+
+ final int offsetBefore = getChildStartEdge(temp) - mItemMargin;
+ final View before = fillBefore(position - 1, offsetBefore);
+
+ // This will correct for the top of the first view not touching the top of the list
+ adjustViewsStartOrEnd();
+
+ final int offsetAfter = getChildEndEdge(temp) + mItemMargin;
+ final View after = fillAfter(position + 1, offsetAfter);
+
+ final int childCount = getChildCount();
+ if (childCount > 0) {
+ correctTooHigh(childCount);
+ }
+
+ if (tempIsSelected) {
+ return temp;
+ } else if (before != null) {
+ return before;
+ } else {
+ return after;
+ }
+ }
+
+ private View fillFromOffset(int nextOffset) {
+ mFirstPosition = Math.min(mFirstPosition, mSelectedPosition);
+ mFirstPosition = Math.min(mFirstPosition, mItemCount - 1);
+
+ if (mFirstPosition < 0) {
+ mFirstPosition = 0;
+ }
+
+ return fillAfter(mFirstPosition, nextOffset);
+ }
+
+ private View fillFromMiddle(int start, int end) {
+ final int size = end - start;
+ int position = reconcileSelectedPosition();
+
+ View selected = makeAndAddView(position, start, true, true);
+ mFirstPosition = position;
+
+ if (mIsVertical) {
+ int selectedHeight = selected.getMeasuredHeight();
+ if (selectedHeight <= size) {
+ selected.offsetTopAndBottom((size - selectedHeight) / 2);
+ }
+ } else {
+ int selectedWidth = selected.getMeasuredWidth();
+ if (selectedWidth <= size) {
+ selected.offsetLeftAndRight((size - selectedWidth) / 2);
+ }
+ }
+
+ fillBeforeAndAfter(selected, position);
+ correctTooHigh(getChildCount());
+
+ return selected;
+ }
+
+ private void fillBeforeAndAfter(View selected, int position) {
+ final int offsetBefore = getChildStartEdge(selected) + mItemMargin;
+ fillBefore(position - 1, offsetBefore);
+
+ adjustViewsStartOrEnd();
+
+ final int offsetAfter = getChildEndEdge(selected) + mItemMargin;
+ fillAfter(position + 1, offsetAfter);
+ }
+
+ private View fillFromSelection(int selectedTop, int start, int end) {
+ int fadingEdgeLength = getFadingEdgeLength();
+ final int selectedPosition = mSelectedPosition;
+
+ final int minStart = getMinSelectionPixel(start, fadingEdgeLength, selectedPosition);
+ final int maxEnd = getMaxSelectionPixel(end, fadingEdgeLength, selectedPosition);
+
+ View selected = makeAndAddView(selectedPosition, selectedTop, true, true);
+
+ final int selectedStart = getChildStartEdge(selected);
+ final int selectedEnd = getChildEndEdge(selected);
+
+ // Some of the newly selected item extends below the bottom of the list
+ if (selectedEnd > maxEnd) {
+ // Find space available above the selection into which we can scroll
+ // upwards
+ final int spaceAbove = selectedStart - minStart;
+
+ // Find space required to bring the bottom of the selected item
+ // fully into view
+ final int spaceBelow = selectedEnd - maxEnd;
+
+ final int offset = Math.min(spaceAbove, spaceBelow);
+
+ // Now offset the selected item to get it into view
+ selected.offsetTopAndBottom(-offset);
+ } else if (selectedStart < minStart) {
+ // Find space required to bring the top of the selected item fully
+ // into view
+ final int spaceAbove = minStart - selectedStart;
+
+ // Find space available below the selection into which we can scroll
+ // downwards
+ final int spaceBelow = maxEnd - selectedEnd;
+
+ final int offset = Math.min(spaceAbove, spaceBelow);
+
+ // Offset the selected item to get it into view
+ selected.offsetTopAndBottom(offset);
+ }
+
+ // Fill in views above and below
+ fillBeforeAndAfter(selected, selectedPosition);
+ correctTooHigh(getChildCount());
+
+ return selected;
+ }
+
+ private void correctTooHigh(int childCount) {
+ // First see if the last item is visible. If it is not, it is OK for the
+ // top of the list to be pushed up.
+ final int lastPosition = mFirstPosition + childCount - 1;
+ if (lastPosition != mItemCount - 1 || childCount == 0) {
+ return;
+ }
+
+ // Get the last child end edge
+ final int lastEnd = getChildEndEdge(getChildAt(childCount - 1));
+
+ // This is bottom of our drawable area
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ // This is how far the end edge of the last view is from the end of the
+ // drawable area
+ int endOffset = end - lastEnd;
+
+ View firstChild = getChildAt(0);
+ int firstStart = getChildStartEdge(firstChild);
+
+ // Make sure we are 1) Too high, and 2) Either there are more rows above the
+ // first row or the first row is scrolled off the top of the drawable area
+ if (endOffset > 0 && (mFirstPosition > 0 || firstStart < start)) {
+ if (mFirstPosition == 0) {
+ // Don't pull the top too far down
+ endOffset = Math.min(endOffset, start - firstStart);
+ }
+
+ // Move everything down
+ offsetChildren(endOffset);
+
+ if (mFirstPosition > 0) {
+ firstStart = getChildStartEdge(firstChild);
+
+ // Fill the gap that was opened above mFirstPosition with more rows, if
+ // possible
+ fillBefore(mFirstPosition - 1, firstStart - mItemMargin);
+
+ // Close up the remaining gap
+ adjustViewsStartOrEnd();
+ }
+ }
+ }
+
+ private void correctTooLow(int childCount) {
+ // First see if the first item is visible. If it is not, it is OK for the
+ // bottom of the list to be pushed down.
+ if (mFirstPosition != 0 || childCount == 0) {
+ return;
+ }
+
+ final int firstStart = getChildStartEdge(getChildAt(0));
+
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ // This is how far the start edge of the first view is from the start of the
+ // drawable area
+ int startOffset = firstStart - start;
+
+ View last = getChildAt(childCount - 1);
+ int lastEnd = getChildEndEdge(last);
+
+ int lastPosition = mFirstPosition + childCount - 1;
+
+ // Make sure we are 1) Too low, and 2) Either there are more columns/rows below the
+ // last column/row or the last column/row is scrolled off the end of the
+ // drawable area
+ if (startOffset > 0) {
+ if (lastPosition < mItemCount - 1 || lastEnd > end) {
+ if (lastPosition == mItemCount - 1) {
+ // Don't pull the bottom too far up
+ startOffset = Math.min(startOffset, lastEnd - end);
+ }
+
+ // Move everything up
+ offsetChildren(-startOffset);
+
+ if (lastPosition < mItemCount - 1) {
+ lastEnd = getChildEndEdge(last);
+
+ // Fill the gap that was opened below the last position with more rows, if
+ // possible
+ fillAfter(lastPosition + 1, lastEnd + mItemMargin);
+
+ // Close up the remaining gap
+ adjustViewsStartOrEnd();
+ }
+ } else if (lastPosition == mItemCount - 1) {
+ adjustViewsStartOrEnd();
+ }
+ }
+ }
+
+ private void adjustViewsStartOrEnd() {
+ if (getChildCount() == 0) {
+ return;
+ }
+
+ int delta = getChildStartEdge(getChildAt(0)) - getStartEdge();
+
+ // If item margin is negative we shouldn't apply it in the
+ // first item of the list to avoid offsetting it incorrectly.
+ if (mItemMargin >= 0 || mFirstPosition != 0) {
+ delta -= mItemMargin;
+ }
+
+ if (delta < 0) {
+ // We only are looking to see if we are too low, not too high
+ delta = 0;
+ }
+
+ if (delta != 0) {
+ offsetChildren(-delta);
+ }
+ }
+
+ @TargetApi(14)
+ private SparseBooleanArray cloneCheckStates() {
+ if (mCheckStates == null) {
+ return null;
+ }
+
+ SparseBooleanArray checkedStates;
+
+ if (Build.VERSION.SDK_INT >= 14) {
+ checkedStates = mCheckStates.clone();
+ } else {
+ checkedStates = new SparseBooleanArray();
+
+ for (int i = 0; i < mCheckStates.size(); i++) {
+ checkedStates.put(mCheckStates.keyAt(i), mCheckStates.valueAt(i));
+ }
+ }
+
+ return checkedStates;
+ }
+
+ private int findSyncPosition() {
+ int itemCount = mItemCount;
+
+ if (itemCount == 0) {
+ return INVALID_POSITION;
+ }
+
+ final long idToMatch = mSyncRowId;
+
+ // If there isn't a selection don't hunt for it
+ if (idToMatch == INVALID_ROW_ID) {
+ return INVALID_POSITION;
+ }
+
+ // Pin seed to reasonable values
+ int seed = mSyncPosition;
+ seed = Math.max(0, seed);
+ seed = Math.min(itemCount - 1, seed);
+
+ long endTime = SystemClock.uptimeMillis() + SYNC_MAX_DURATION_MILLIS;
+
+ long rowId;
+
+ // first position scanned so far
+ int first = seed;
+
+ // last position scanned so far
+ int last = seed;
+
+ // True if we should move down on the next iteration
+ boolean next = false;
+
+ // True when we have looked at the first item in the data
+ boolean hitFirst;
+
+ // True when we have looked at the last item in the data
+ boolean hitLast;
+
+ // Get the item ID locally (instead of getItemIdAtPosition), so
+ // we need the adapter
+ final ListAdapter adapter = mAdapter;
+ if (adapter == null) {
+ return INVALID_POSITION;
+ }
+
+ while (SystemClock.uptimeMillis() <= endTime) {
+ rowId = adapter.getItemId(seed);
+ if (rowId == idToMatch) {
+ // Found it!
+ return seed;
+ }
+
+ hitLast = (last == itemCount - 1);
+ hitFirst = (first == 0);
+
+ if (hitLast && hitFirst) {
+ // Looked at everything
+ break;
+ }
+
+ if (hitFirst || (next && !hitLast)) {
+ // Either we hit the top, or we are trying to move down
+ last++;
+ seed = last;
+
+ // Try going up next time
+ next = false;
+ } else if (hitLast || (!next && !hitFirst)) {
+ // Either we hit the bottom, or we are trying to move up
+ first--;
+ seed = first;
+
+ // Try going down next time
+ next = true;
+ }
+ }
+
+ return INVALID_POSITION;
+ }
+
+ @TargetApi(16)
+ private View obtainView(int position, boolean[] isScrap) {
+ isScrap[0] = false;
+
+ View scrapView = mRecycler.getTransientStateView(position);
+ if (scrapView != null) {
+ return scrapView;
+ }
+
+ scrapView = mRecycler.getScrapView(position);
+
+ final View child;
+ if (scrapView != null) {
+ child = mAdapter.getView(position, scrapView, this);
+
+ if (child != scrapView) {
+ mRecycler.addScrapView(scrapView, position);
+ } else {
+ isScrap[0] = true;
+ }
+ } else {
+ child = mAdapter.getView(position, null, this);
+ }
+
+ if (ViewCompat.getImportantForAccessibility(child) == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
+ ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ }
+
+ if (mHasStableIds) {
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+
+ if (lp == null) {
+ lp = generateDefaultLayoutParams();
+ } else if (!checkLayoutParams(lp)) {
+ lp = generateLayoutParams(lp);
+ }
+
+ lp.id = mAdapter.getItemId(position);
+
+ child.setLayoutParams(lp);
+ }
+
+ if (mAccessibilityDelegate == null) {
+ mAccessibilityDelegate = new ListItemAccessibilityDelegate();
+ }
+
+ ViewCompat.setAccessibilityDelegate(child, mAccessibilityDelegate);
+
+ return child;
+ }
+
+ void resetState() {
+ mScroller.forceFinished(true);
+
+ removeAllViewsInLayout();
+
+ mSelectedStart = 0;
+ mFirstPosition = 0;
+ mDataChanged = false;
+ mNeedSync = false;
+ mPendingSync = null;
+ mOldSelectedPosition = INVALID_POSITION;
+ mOldSelectedRowId = INVALID_ROW_ID;
+
+ mOverScroll = 0;
+
+ setSelectedPositionInt(INVALID_POSITION);
+ setNextSelectedPositionInt(INVALID_POSITION);
+
+ mSelectorPosition = INVALID_POSITION;
+ mSelectorRect.setEmpty();
+
+ invalidate();
+ }
+
+ private void rememberSyncState() {
+ if (getChildCount() == 0) {
+ return;
+ }
+
+ mNeedSync = true;
+
+ if (mSelectedPosition >= 0) {
+ View child = getChildAt(mSelectedPosition - mFirstPosition);
+
+ mSyncRowId = mNextSelectedRowId;
+ mSyncPosition = mNextSelectedPosition;
+
+ if (child != null) {
+ mSpecificStart = getChildStartEdge(child);
+ }
+
+ mSyncMode = SYNC_SELECTED_POSITION;
+ } else {
+ // Sync the based on the offset of the first view
+ View child = getChildAt(0);
+ ListAdapter adapter = getAdapter();
+
+ if (mFirstPosition >= 0 && mFirstPosition < adapter.getCount()) {
+ mSyncRowId = adapter.getItemId(mFirstPosition);
+ } else {
+ mSyncRowId = NO_ID;
+ }
+
+ mSyncPosition = mFirstPosition;
+
+ if (child != null) {
+ mSpecificStart = getChildStartEdge(child);
+ }
+
+ mSyncMode = SYNC_FIRST_POSITION;
+ }
+ }
+
+ private ContextMenuInfo createContextMenuInfo(View view, int position, long id) {
+ return new AdapterContextMenuInfo(view, position, id);
+ }
+
+ @TargetApi(11)
+ private void updateOnScreenCheckedViews() {
+ final int firstPos = mFirstPosition;
+ final int count = getChildCount();
+
+ for (int i = 0; i < count; i++) {
+ final View child = getChildAt(i);
+ final int position = firstPos + i;
+
+ if (child instanceof Checkable) {
+ ((Checkable) child).setChecked(mCheckStates.get(position));
+ } else if (Build.VERSION.SDK_INT >= HONEYCOMB) {
+ child.setActivated(mCheckStates.get(position));
+ }
+ }
+ }
+
+ @Override
+ public boolean performItemClick(View view, int position, long id) {
+ boolean checkedStateChanged = false;
+
+ if (mChoiceMode == ChoiceMode.MULTIPLE) {
+ boolean checked = !mCheckStates.get(position, false);
+ mCheckStates.put(position, checked);
+
+ if (mCheckedIdStates != null && mAdapter.hasStableIds()) {
+ if (checked) {
+ mCheckedIdStates.put(mAdapter.getItemId(position), position);
+ } else {
+ mCheckedIdStates.delete(mAdapter.getItemId(position));
+ }
+ }
+
+ if (checked) {
+ mCheckedItemCount++;
+ } else {
+ mCheckedItemCount--;
+ }
+
+ checkedStateChanged = true;
+ } else if (mChoiceMode == ChoiceMode.SINGLE) {
+ boolean checked = !mCheckStates.get(position, false);
+ if (checked) {
+ mCheckStates.clear();
+ mCheckStates.put(position, true);
+
+ if (mCheckedIdStates != null && mAdapter.hasStableIds()) {
+ mCheckedIdStates.clear();
+ mCheckedIdStates.put(mAdapter.getItemId(position), position);
+ }
+
+ mCheckedItemCount = 1;
+ } else if (mCheckStates.size() == 0 || !mCheckStates.valueAt(0)) {
+ mCheckedItemCount = 0;
+ }
+
+ checkedStateChanged = true;
+ }
+
+ if (checkedStateChanged) {
+ updateOnScreenCheckedViews();
+ }
+
+ return super.performItemClick(view, position, id);
+ }
+
+ private boolean performLongPress(final View child,
+ final int longPressPosition, final long longPressId) {
+ // CHOICE_MODE_MULTIPLE_MODAL takes over long press.
+ boolean handled = false;
+
+ OnItemLongClickListener listener = getOnItemLongClickListener();
+ if (listener != null) {
+ handled = listener.onItemLongClick(TwoWayView.this, child,
+ longPressPosition, longPressId);
+ }
+
+ if (!handled) {
+ mContextMenuInfo = createContextMenuInfo(child, longPressPosition, longPressId);
+ handled = super.showContextMenuForChild(TwoWayView.this);
+ }
+
+ if (handled) {
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ }
+
+ return handled;
+ }
+
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ if (mIsVertical) {
+ return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+ } else {
+ return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
+ }
+ }
+
+ @Override
+ protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
+ return new LayoutParams(lp);
+ }
+
+ @Override
+ protected boolean checkLayoutParams(ViewGroup.LayoutParams lp) {
+ return lp instanceof LayoutParams;
+ }
+
+ @Override
+ public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected ContextMenuInfo getContextMenuInfo() {
+ return mContextMenuInfo;
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ SavedState ss = new SavedState(superState);
+
+ if (mPendingSync != null) {
+ ss.selectedId = mPendingSync.selectedId;
+ ss.firstId = mPendingSync.firstId;
+ ss.viewStart = mPendingSync.viewStart;
+ ss.position = mPendingSync.position;
+ ss.size = mPendingSync.size;
+
+ return ss;
+ }
+
+ boolean haveChildren = (getChildCount() > 0 && mItemCount > 0);
+ long selectedId = getSelectedItemId();
+ ss.selectedId = selectedId;
+ ss.size = getSize();
+
+ if (selectedId >= 0) {
+ ss.viewStart = mSelectedStart;
+ ss.position = getSelectedItemPosition();
+ ss.firstId = INVALID_POSITION;
+ } else if (haveChildren && mFirstPosition > 0) {
+ // Remember the position of the first child.
+ // We only do this if we are not currently at the top of
+ // the list, for two reasons:
+ //
+ // (1) The list may be in the process of becoming empty, in
+ // which case mItemCount may not be 0, but if we try to
+ // ask for any information about position 0 we will crash.
+ //
+ // (2) Being "at the top" seems like a special case, anyway,
+ // and the user wouldn't expect to end up somewhere else when
+ // they revisit the list even if its content has changed.
+
+ ss.viewStart = getChildStartEdge(getChildAt(0));
+
+ int firstPos = mFirstPosition;
+ if (firstPos >= mItemCount) {
+ firstPos = mItemCount - 1;
+ }
+
+ ss.position = firstPos;
+ ss.firstId = mAdapter.getItemId(firstPos);
+ } else {
+ ss.viewStart = 0;
+ ss.firstId = INVALID_POSITION;
+ ss.position = 0;
+ }
+
+ if (mCheckStates != null) {
+ ss.checkState = cloneCheckStates();
+ }
+
+ if (mCheckedIdStates != null) {
+ final LongSparseArray<Integer> idState = new LongSparseArray<Integer>();
+
+ final int count = mCheckedIdStates.size();
+ for (int i = 0; i < count; i++) {
+ idState.put(mCheckedIdStates.keyAt(i), mCheckedIdStates.valueAt(i));
+ }
+
+ ss.checkIdState = idState;
+ }
+
+ ss.checkedItemCount = mCheckedItemCount;
+
+ return ss;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ SavedState ss = (SavedState) state;
+ super.onRestoreInstanceState(ss.getSuperState());
+
+ mDataChanged = true;
+ mSyncSize = ss.size;
+
+ if (ss.selectedId >= 0) {
+ mNeedSync = true;
+ mPendingSync = ss;
+ mSyncRowId = ss.selectedId;
+ mSyncPosition = ss.position;
+ mSpecificStart = ss.viewStart;
+ mSyncMode = SYNC_SELECTED_POSITION;
+ } else if (ss.firstId >= 0) {
+ setSelectedPositionInt(INVALID_POSITION);
+
+ // Do this before setting mNeedSync since setNextSelectedPosition looks at mNeedSync
+ setNextSelectedPositionInt(INVALID_POSITION);
+
+ mSelectorPosition = INVALID_POSITION;
+ mNeedSync = true;
+ mPendingSync = ss;
+ mSyncRowId = ss.firstId;
+ mSyncPosition = ss.position;
+ mSpecificStart = ss.viewStart;
+ mSyncMode = SYNC_FIRST_POSITION;
+ }
+
+ if (ss.checkState != null) {
+ mCheckStates = ss.checkState;
+ }
+
+ if (ss.checkIdState != null) {
+ mCheckedIdStates = ss.checkIdState;
+ }
+
+ mCheckedItemCount = ss.checkedItemCount;
+
+ requestLayout();
+ }
+
+ public static class LayoutParams extends ViewGroup.LayoutParams {
+ /**
+ * Type of this view as reported by the adapter
+ */
+ int viewType;
+
+ /**
+ * The stable ID of the item this view displays
+ */
+ long id = -1;
+
+ /**
+ * The position the view was removed from when pulled out of the
+ * scrap heap.
+ * @hide
+ */
+ int scrappedFromPosition;
+
+ /**
+ * When a TwoWayView is measured with an AT_MOST measure spec, it needs
+ * to obtain children views to measure itself. When doing so, the children
+ * are not attached to the window, but put in the recycler which assumes
+ * they've been attached before. Setting this flag will force the reused
+ * view to be attached to the window rather than just attached to the
+ * parent.
+ */
+ boolean forceAdd;
+
+ public LayoutParams(int width, int height) {
+ super(width, height);
+
+ if (this.width == MATCH_PARENT) {
+ Log.w(LOGTAG, "Constructing LayoutParams with width FILL_PARENT " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.width = WRAP_CONTENT;
+ }
+
+ if (this.height == MATCH_PARENT) {
+ Log.w(LOGTAG, "Constructing LayoutParams with height FILL_PARENT " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.height = WRAP_CONTENT;
+ }
+ }
+
+ public LayoutParams(Context c, AttributeSet attrs) {
+ super(c, attrs);
+
+ if (this.width == MATCH_PARENT) {
+ Log.w(LOGTAG, "Inflation setting LayoutParams width to MATCH_PARENT - " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.width = MATCH_PARENT;
+ }
+
+ if (this.height == MATCH_PARENT) {
+ Log.w(LOGTAG, "Inflation setting LayoutParams height to MATCH_PARENT - " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.height = WRAP_CONTENT;
+ }
+ }
+
+ public LayoutParams(ViewGroup.LayoutParams other) {
+ super(other);
+
+ if (this.width == MATCH_PARENT) {
+ Log.w(LOGTAG, "Constructing LayoutParams with width MATCH_PARENT - " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.width = WRAP_CONTENT;
+ }
+
+ if (this.height == MATCH_PARENT) {
+ Log.w(LOGTAG, "Constructing LayoutParams with height MATCH_PARENT - " +
+ "does not make much sense as the view might change orientation. " +
+ "Falling back to WRAP_CONTENT");
+ this.height = WRAP_CONTENT;
+ }
+ }
+ }
+
+ class RecycleBin {
+ private RecyclerListener mRecyclerListener;
+ private int mFirstActivePosition;
+ private View[] mActiveViews = new View[0];
+ private ArrayList<View>[] mScrapViews;
+ private int mViewTypeCount;
+ private ArrayList<View> mCurrentScrap;
+ private SparseArrayCompat<View> mTransientStateViews;
+
+ public void setViewTypeCount(int viewTypeCount) {
+ if (viewTypeCount < 1) {
+ throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
+ for (int i = 0; i < viewTypeCount; i++) {
+ scrapViews[i] = new ArrayList<View>();
+ }
+
+ mViewTypeCount = viewTypeCount;
+ mCurrentScrap = scrapViews[0];
+ mScrapViews = scrapViews;
+ }
+
+ public void markChildrenDirty() {
+ if (mViewTypeCount == 1) {
+ final ArrayList<View> scrap = mCurrentScrap;
+ final int scrapCount = scrap.size();
+
+ for (int i = 0; i < scrapCount; i++) {
+ scrap.get(i).forceLayout();
+ }
+ } else {
+ final int typeCount = mViewTypeCount;
+ for (int i = 0; i < typeCount; i++) {
+ for (View scrap : mScrapViews[i]) {
+ scrap.forceLayout();
+ }
+ }
+ }
+
+ if (mTransientStateViews != null) {
+ final int count = mTransientStateViews.size();
+ for (int i = 0; i < count; i++) {
+ mTransientStateViews.valueAt(i).forceLayout();
+ }
+ }
+ }
+
+ public boolean shouldRecycleViewType(int viewType) {
+ return viewType >= 0;
+ }
+
+ void clear() {
+ if (mViewTypeCount == 1) {
+ final ArrayList<View> scrap = mCurrentScrap;
+ final int scrapCount = scrap.size();
+
+ for (int i = 0; i < scrapCount; i++) {
+ removeDetachedView(scrap.remove(scrapCount - 1 - i), false);
+ }
+ } else {
+ final int typeCount = mViewTypeCount;
+ for (int i = 0; i < typeCount; i++) {
+ final ArrayList<View> scrap = mScrapViews[i];
+ final int scrapCount = scrap.size();
+
+ for (int j = 0; j < scrapCount; j++) {
+ removeDetachedView(scrap.remove(scrapCount - 1 - j), false);
+ }
+ }
+ }
+
+ if (mTransientStateViews != null) {
+ mTransientStateViews.clear();
+ }
+ }
+
+ void fillActiveViews(int childCount, int firstActivePosition) {
+ if (mActiveViews.length < childCount) {
+ mActiveViews = new View[childCount];
+ }
+
+ mFirstActivePosition = firstActivePosition;
+
+ final View[] activeViews = mActiveViews;
+ for (int i = 0; i < childCount; i++) {
+ View child = getChildAt(i);
+
+ // Note: We do place AdapterView.ITEM_VIEW_TYPE_IGNORE in active views.
+ // However, we will NOT place them into scrap views.
+ activeViews[i] = child;
+ }
+ }
+
+ View getActiveView(int position) {
+ final int index = position - mFirstActivePosition;
+ final View[] activeViews = mActiveViews;
+
+ if (index >= 0 && index < activeViews.length) {
+ final View match = activeViews[index];
+ activeViews[index] = null;
+
+ return match;
+ }
+
+ return null;
+ }
+
+ View getTransientStateView(int position) {
+ if (mTransientStateViews == null) {
+ return null;
+ }
+
+ final int index = mTransientStateViews.indexOfKey(position);
+ if (index < 0) {
+ return null;
+ }
+
+ final View result = mTransientStateViews.valueAt(index);
+ mTransientStateViews.removeAt(index);
+
+ return result;
+ }
+
+ void clearTransientStateViews() {
+ if (mTransientStateViews != null) {
+ mTransientStateViews.clear();
+ }
+ }
+
+ View getScrapView(int position) {
+ if (mViewTypeCount == 1) {
+ return retrieveFromScrap(mCurrentScrap, position);
+ } else {
+ int whichScrap = mAdapter.getItemViewType(position);
+ if (whichScrap >= 0 && whichScrap < mScrapViews.length) {
+ return retrieveFromScrap(mScrapViews[whichScrap], position);
+ }
+ }
+
+ return null;
+ }
+
+ @TargetApi(14)
+ void addScrapView(View scrap, int position) {
+ LayoutParams lp = (LayoutParams) scrap.getLayoutParams();
+ if (lp == null) {
+ return;
+ }
+
+ lp.scrappedFromPosition = position;
+
+ final int viewType = lp.viewType;
+ final boolean scrapHasTransientState = ViewCompat.hasTransientState(scrap);
+
+ // Don't put views that should be ignored into the scrap heap
+ if (!shouldRecycleViewType(viewType) || scrapHasTransientState) {
+ if (scrapHasTransientState) {
+ if (mTransientStateViews == null) {
+ mTransientStateViews = new SparseArrayCompat<View>();
+ }
+
+ mTransientStateViews.put(position, scrap);
+ }
+
+ return;
+ }
+
+ if (mViewTypeCount == 1) {
+ mCurrentScrap.add(scrap);
+ } else {
+ mScrapViews[viewType].add(scrap);
+ }
+
+ // FIXME: Unfortunately, ViewCompat.setAccessibilityDelegate() doesn't accept
+ // null delegates.
+ if (Build.VERSION.SDK_INT >= 14) {
+ scrap.setAccessibilityDelegate(null);
+ }
+
+ if (mRecyclerListener != null) {
+ mRecyclerListener.onMovedToScrapHeap(scrap);
+ }
+ }
+
+ @TargetApi(14)
+ void scrapActiveViews() {
+ final View[] activeViews = mActiveViews;
+ final boolean multipleScraps = (mViewTypeCount > 1);
+
+ ArrayList<View> scrapViews = mCurrentScrap;
+ final int count = activeViews.length;
+
+ for (int i = count - 1; i >= 0; i--) {
+ final View victim = activeViews[i];
+ if (victim != null) {
+ final LayoutParams lp = (LayoutParams) victim.getLayoutParams();
+ int whichScrap = lp.viewType;
+
+ activeViews[i] = null;
+
+ final boolean scrapHasTransientState = ViewCompat.hasTransientState(victim);
+ if (!shouldRecycleViewType(whichScrap) || scrapHasTransientState) {
+ if (scrapHasTransientState) {
+ removeDetachedView(victim, false);
+
+ if (mTransientStateViews == null) {
+ mTransientStateViews = new SparseArrayCompat<View>();
+ }
+
+ mTransientStateViews.put(mFirstActivePosition + i, victim);
+ }
+
+ continue;
+ }
+
+ if (multipleScraps) {
+ scrapViews = mScrapViews[whichScrap];
+ }
+
+ lp.scrappedFromPosition = mFirstActivePosition + i;
+ scrapViews.add(victim);
+
+ // FIXME: Unfortunately, ViewCompat.setAccessibilityDelegate() doesn't accept
+ // null delegates.
+ if (Build.VERSION.SDK_INT >= 14) {
+ victim.setAccessibilityDelegate(null);
+ }
+
+ if (mRecyclerListener != null) {
+ mRecyclerListener.onMovedToScrapHeap(victim);
+ }
+ }
+ }
+
+ pruneScrapViews();
+ }
+
+ private void pruneScrapViews() {
+ final int maxViews = mActiveViews.length;
+ final int viewTypeCount = mViewTypeCount;
+ final ArrayList<View>[] scrapViews = mScrapViews;
+
+ for (int i = 0; i < viewTypeCount; ++i) {
+ final ArrayList<View> scrapPile = scrapViews[i];
+ int size = scrapPile.size();
+ final int extras = size - maxViews;
+
+ size--;
+
+ for (int j = 0; j < extras; j++) {
+ removeDetachedView(scrapPile.remove(size--), false);
+ }
+ }
+
+ if (mTransientStateViews != null) {
+ for (int i = 0; i < mTransientStateViews.size(); i++) {
+ final View v = mTransientStateViews.valueAt(i);
+ if (!ViewCompat.hasTransientState(v)) {
+ mTransientStateViews.removeAt(i);
+ i--;
+ }
+ }
+ }
+ }
+
+ void reclaimScrapViews(List<View> views) {
+ if (mViewTypeCount == 1) {
+ views.addAll(mCurrentScrap);
+ } else {
+ final int viewTypeCount = mViewTypeCount;
+ final ArrayList<View>[] scrapViews = mScrapViews;
+
+ for (int i = 0; i < viewTypeCount; ++i) {
+ final ArrayList<View> scrapPile = scrapViews[i];
+ views.addAll(scrapPile);
+ }
+ }
+ }
+
+ View retrieveFromScrap(ArrayList<View> scrapViews, int position) {
+ int size = scrapViews.size();
+ if (size <= 0) {
+ return null;
+ }
+
+ for (int i = 0; i < size; i++) {
+ final View scrapView = scrapViews.get(i);
+ final LayoutParams lp = (LayoutParams) scrapView.getLayoutParams();
+
+ if (lp.scrappedFromPosition == position) {
+ scrapViews.remove(i);
+ return scrapView;
+ }
+ }
+
+ return scrapViews.remove(size - 1);
+ }
+ }
+
+ @Override
+ public void setEmptyView(View emptyView) {
+ super.setEmptyView(emptyView);
+ mEmptyView = emptyView;
+ updateEmptyStatus();
+ }
+
+ @Override
+ public void setFocusable(boolean focusable) {
+ final ListAdapter adapter = getAdapter();
+ final boolean empty = (adapter == null || adapter.getCount() == 0);
+
+ mDesiredFocusableState = focusable;
+ if (!focusable) {
+ mDesiredFocusableInTouchModeState = false;
+ }
+
+ super.setFocusable(focusable && !empty);
+ }
+
+ @Override
+ public void setFocusableInTouchMode(boolean focusable) {
+ final ListAdapter adapter = getAdapter();
+ final boolean empty = (adapter == null || adapter.getCount() == 0);
+
+ mDesiredFocusableInTouchModeState = focusable;
+ if (focusable) {
+ mDesiredFocusableState = true;
+ }
+
+ super.setFocusableInTouchMode(focusable && !empty);
+ }
+
+ private void checkFocus() {
+ final ListAdapter adapter = getAdapter();
+ final boolean focusable = (adapter != null && adapter.getCount() > 0);
+
+ // The order in which we set focusable in touch mode/focusable may matter
+ // for the client, see View.setFocusableInTouchMode() comments for more
+ // details
+ super.setFocusableInTouchMode(focusable && mDesiredFocusableInTouchModeState);
+ super.setFocusable(focusable && mDesiredFocusableState);
+
+ if (mEmptyView != null) {
+ updateEmptyStatus();
+ }
+ }
+
+ private void updateEmptyStatus() {
+ final boolean isEmpty = (mAdapter == null || mAdapter.isEmpty());
+
+ if (isEmpty) {
+ if (mEmptyView != null) {
+ mEmptyView.setVisibility(View.VISIBLE);
+ setVisibility(View.GONE);
+ } else {
+ // If the caller just removed our empty view, make sure the list
+ // view is visible
+ setVisibility(View.VISIBLE);
+ }
+
+ // We are now GONE, so pending layouts will not be dispatched.
+ // Force one here to make sure that the state of the list matches
+ // the state of the adapter.
+ if (mDataChanged) {
+ layout(getLeft(), getTop(), getRight(), getBottom());
+ }
+ } else {
+ if (mEmptyView != null) {
+ mEmptyView.setVisibility(View.GONE);
+ }
+
+ setVisibility(View.VISIBLE);
+ }
+ }
+
+ private class AdapterDataSetObserver extends DataSetObserver {
+ private Parcelable mInstanceState = null;
+
+ @Override
+ public void onChanged() {
+ mDataChanged = true;
+ mOldItemCount = mItemCount;
+ mItemCount = getAdapter().getCount();
+
+ // Detect the case where a cursor that was previously invalidated has
+ // been re-populated with new data.
+ if (TwoWayView.this.mHasStableIds && mInstanceState != null
+ && mOldItemCount == 0 && mItemCount > 0) {
+ TwoWayView.this.onRestoreInstanceState(mInstanceState);
+ mInstanceState = null;
+ } else {
+ rememberSyncState();
+ }
+
+ checkFocus();
+ requestLayout();
+ }
+
+ @Override
+ public void onInvalidated() {
+ mDataChanged = true;
+
+ if (TwoWayView.this.mHasStableIds) {
+ // Remember the current state for the case where our hosting activity is being
+ // stopped and later restarted
+ mInstanceState = TwoWayView.this.onSaveInstanceState();
+ }
+
+ // Data is invalid so we should reset our state
+ mOldItemCount = mItemCount;
+ mItemCount = 0;
+
+ mSelectedPosition = INVALID_POSITION;
+ mSelectedRowId = INVALID_ROW_ID;
+
+ mNextSelectedPosition = INVALID_POSITION;
+ mNextSelectedRowId = INVALID_ROW_ID;
+
+ mNeedSync = false;
+
+ checkFocus();
+ requestLayout();
+ }
+ }
+
+ static class SavedState extends BaseSavedState {
+ long selectedId;
+ long firstId;
+ int viewStart;
+ int position;
+ int size;
+ int checkedItemCount;
+ SparseBooleanArray checkState;
+ LongSparseArray<Integer> checkIdState;
+
+ /**
+ * Constructor called from {@link TwoWayView#onSaveInstanceState()}
+ */
+ SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ /**
+ * Constructor called from {@link #CREATOR}
+ */
+ private SavedState(Parcel in) {
+ super(in);
+
+ selectedId = in.readLong();
+ firstId = in.readLong();
+ viewStart = in.readInt();
+ position = in.readInt();
+ size = in.readInt();
+
+ checkedItemCount = in.readInt();
+ checkState = in.readSparseBooleanArray();
+
+ final int N = in.readInt();
+ if (N > 0) {
+ checkIdState = new LongSparseArray<Integer>();
+ for (int i = 0; i < N; i++) {
+ final long key = in.readLong();
+ final int value = in.readInt();
+ checkIdState.put(key, value);
+ }
+ }
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+
+ out.writeLong(selectedId);
+ out.writeLong(firstId);
+ out.writeInt(viewStart);
+ out.writeInt(position);
+ out.writeInt(size);
+
+ out.writeInt(checkedItemCount);
+ out.writeSparseBooleanArray(checkState);
+
+ final int N = checkIdState != null ? checkIdState.size() : 0;
+ out.writeInt(N);
+
+ for (int i = 0; i < N; i++) {
+ out.writeLong(checkIdState.keyAt(i));
+ out.writeInt(checkIdState.valueAt(i));
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "TwoWayView.SavedState{"
+ + Integer.toHexString(System.identityHashCode(this))
+ + " selectedId=" + selectedId
+ + " firstId=" + firstId
+ + " viewStart=" + viewStart
+ + " size=" + size
+ + " position=" + position
+ + " checkState=" + checkState + "}";
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR
+ = new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+
+ private class SelectionNotifier implements Runnable {
+ @Override
+ public void run() {
+ if (mDataChanged) {
+ // Data has changed between when this SelectionNotifier
+ // was posted and now. We need to wait until the AdapterView
+ // has been synched to the new data.
+ if (mAdapter != null) {
+ post(this);
+ }
+ } else {
+ fireOnSelected();
+ performAccessibilityActionsOnSelected();
+ }
+ }
+ }
+
+ private class WindowRunnable {
+ private int mOriginalAttachCount;
+
+ public void rememberWindowAttachCount() {
+ mOriginalAttachCount = getWindowAttachCount();
+ }
+
+ public boolean sameWindow() {
+ return hasWindowFocus() && getWindowAttachCount() == mOriginalAttachCount;
+ }
+ }
+
+ private class PerformClick extends WindowRunnable implements Runnable {
+ int mClickMotionPosition;
+
+ @Override
+ public void run() {
+ if (mDataChanged) {
+ return;
+ }
+
+ final ListAdapter adapter = mAdapter;
+ final int motionPosition = mClickMotionPosition;
+
+ if (adapter != null && mItemCount > 0 &&
+ motionPosition != INVALID_POSITION &&
+ motionPosition < adapter.getCount() && sameWindow()) {
+
+ final View child = getChildAt(motionPosition - mFirstPosition);
+ if (child != null) {
+ performItemClick(child, motionPosition, adapter.getItemId(motionPosition));
+ }
+ }
+ }
+ }
+
+ private final class CheckForTap implements Runnable {
+ @Override
+ public void run() {
+ if (mTouchMode != TOUCH_MODE_DOWN) {
+ return;
+ }
+
+ mTouchMode = TOUCH_MODE_TAP;
+
+ final View child = getChildAt(mMotionPosition - mFirstPosition);
+ if (child != null && !child.hasFocusable()) {
+ mLayoutMode = LAYOUT_NORMAL;
+
+ if (!mDataChanged) {
+ setPressed(true);
+ child.setPressed(true);
+
+ layoutChildren();
+ positionSelector(mMotionPosition, child);
+ refreshDrawableState();
+
+ positionSelector(mMotionPosition, child);
+ refreshDrawableState();
+
+ final boolean longClickable = isLongClickable();
+
+ if (mSelector != null) {
+ Drawable d = mSelector.getCurrent();
+
+ if (d != null && d instanceof TransitionDrawable) {
+ if (longClickable) {
+ final int longPressTimeout = ViewConfiguration.getLongPressTimeout();
+ ((TransitionDrawable) d).startTransition(longPressTimeout);
+ } else {
+ ((TransitionDrawable) d).resetTransition();
+ }
+ }
+ }
+
+ if (longClickable) {
+ triggerCheckForLongPress();
+ } else {
+ mTouchMode = TOUCH_MODE_DONE_WAITING;
+ }
+ } else {
+ mTouchMode = TOUCH_MODE_DONE_WAITING;
+ }
+ }
+ }
+ }
+
+ private class CheckForLongPress extends WindowRunnable implements Runnable {
+ @Override
+ public void run() {
+ final int motionPosition = mMotionPosition;
+ final View child = getChildAt(motionPosition - mFirstPosition);
+
+ if (child != null) {
+ final long longPressId = mAdapter.getItemId(mMotionPosition);
+
+ boolean handled = false;
+ if (sameWindow() && !mDataChanged) {
+ handled = performLongPress(child, motionPosition, longPressId);
+ }
+
+ if (handled) {
+ mTouchMode = TOUCH_MODE_REST;
+ setPressed(false);
+ child.setPressed(false);
+ } else {
+ mTouchMode = TOUCH_MODE_DONE_WAITING;
+ }
+ }
+ }
+ }
+
+ private class CheckForKeyLongPress extends WindowRunnable implements Runnable {
+ public void run() {
+ if (!isPressed() || mSelectedPosition < 0) {
+ return;
+ }
+
+ final int index = mSelectedPosition - mFirstPosition;
+ final View v = getChildAt(index);
+
+ if (!mDataChanged) {
+ boolean handled = false;
+
+ if (sameWindow()) {
+ handled = performLongPress(v, mSelectedPosition, mSelectedRowId);
+ }
+
+ if (handled) {
+ setPressed(false);
+ v.setPressed(false);
+ }
+ } else {
+ setPressed(false);
+
+ if (v != null) {
+ v.setPressed(false);
+ }
+ }
+ }
+ }
+
+ private static class ArrowScrollFocusResult {
+ private int mSelectedPosition;
+ private int mAmountToScroll;
+
+ /**
+ * How {@link TwoWayView#arrowScrollFocused} returns its values.
+ */
+ void populate(int selectedPosition, int amountToScroll) {
+ mSelectedPosition = selectedPosition;
+ mAmountToScroll = amountToScroll;
+ }
+
+ public int getSelectedPosition() {
+ return mSelectedPosition;
+ }
+
+ public int getAmountToScroll() {
+ return mAmountToScroll;
+ }
+ }
+
+ private class ListItemAccessibilityDelegate extends AccessibilityDelegateCompat {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+
+ final int position = getPositionForView(host);
+ final ListAdapter adapter = getAdapter();
+
+ // Cannot perform actions on invalid items
+ if (position == INVALID_POSITION || adapter == null) {
+ return;
+ }
+
+ // Cannot perform actions on disabled items
+ if (!isEnabled() || !adapter.isEnabled(position)) {
+ return;
+ }
+
+ if (position == getSelectedItemPosition()) {
+ info.setSelected(true);
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION);
+ } else {
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_SELECT);
+ }
+
+ if (isClickable()) {
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
+ info.setClickable(true);
+ }
+
+ if (isLongClickable()) {
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
+ info.setLongClickable(true);
+ }
+ }
+
+ @Override
+ public boolean performAccessibilityAction(View host, int action, Bundle arguments) {
+ if (super.performAccessibilityAction(host, action, arguments)) {
+ return true;
+ }
+
+ final int position = getPositionForView(host);
+ final ListAdapter adapter = getAdapter();
+
+ // Cannot perform actions on invalid items
+ if (position == INVALID_POSITION || adapter == null) {
+ return false;
+ }
+
+ // Cannot perform actions on disabled items
+ if (!isEnabled() || !adapter.isEnabled(position)) {
+ return false;
+ }
+
+ final long id = getItemIdAtPosition(position);
+
+ switch (action) {
+ case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION:
+ if (getSelectedItemPosition() == position) {
+ setSelection(INVALID_POSITION);
+ return true;
+ }
+ return false;
+
+ case AccessibilityNodeInfoCompat.ACTION_SELECT:
+ if (getSelectedItemPosition() != position) {
+ setSelection(position);
+ return true;
+ }
+ return false;
+
+ case AccessibilityNodeInfoCompat.ACTION_CLICK:
+ return isClickable() && performItemClick(host, position, id);
+
+ case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK:
+ return isLongClickable() && performLongPress(host, position, id);
+ }
+
+ return false;
+ }
+ }
+
+ private class PositionScroller implements Runnable {
+ private static final int SCROLL_DURATION = 200;
+
+ private static final int MOVE_AFTER_POS = 1;
+ private static final int MOVE_BEFORE_POS = 2;
+ private static final int MOVE_AFTER_BOUND = 3;
+ private static final int MOVE_BEFORE_BOUND = 4;
+ private static final int MOVE_OFFSET = 5;
+
+ private int mMode;
+ private int mTargetPosition;
+ private int mBoundPosition;
+ private int mLastSeenPosition;
+ private int mScrollDuration;
+ private final int mExtraScroll;
+
+ private int mOffsetFromStart;
+
+ PositionScroller() {
+ mExtraScroll = ViewConfiguration.get(mContext).getScaledFadingEdgeLength();
+ }
+
+ void start(final int position) {
+ stop();
+
+ if (mDataChanged) {
+ // Wait until we're back in a stable state to try this.
+ mPositionScrollAfterLayout = new Runnable() {
+ @Override public void run() {
+ start(position);
+ }
+ };
+
+ return;
+ }
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ // Can't scroll without children.
+ return;
+ }
+
+ final int firstPosition = mFirstPosition;
+ final int lastPosition = firstPosition + childCount - 1;
+
+ final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position));
+
+ final int viewTravelCount;
+ if (clampedPosition < firstPosition) {
+ viewTravelCount = firstPosition - clampedPosition + 1;
+ mMode = MOVE_BEFORE_POS;
+ } else if (clampedPosition > lastPosition) {
+ viewTravelCount = clampedPosition - lastPosition + 1;
+ mMode = MOVE_AFTER_POS;
+ } else {
+ scrollToVisible(clampedPosition, INVALID_POSITION, SCROLL_DURATION);
+ return;
+ }
+
+ if (viewTravelCount > 0) {
+ mScrollDuration = SCROLL_DURATION / viewTravelCount;
+ } else {
+ mScrollDuration = SCROLL_DURATION;
+ }
+
+ mTargetPosition = clampedPosition;
+ mBoundPosition = INVALID_POSITION;
+ mLastSeenPosition = INVALID_POSITION;
+
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ }
+
+ void start(final int position, final int boundPosition) {
+ stop();
+
+ if (boundPosition == INVALID_POSITION) {
+ start(position);
+ return;
+ }
+
+ if (mDataChanged) {
+ // Wait until we're back in a stable state to try this.
+ mPositionScrollAfterLayout = new Runnable() {
+ @Override public void run() {
+ start(position, boundPosition);
+ }
+ };
+
+ return;
+ }
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ // Can't scroll without children.
+ return;
+ }
+
+ final int firstPosition = mFirstPosition;
+ final int lastPosition = firstPosition + childCount - 1;
+
+ final int clampedPosition = Math.max(0, Math.min(getCount() - 1, position));
+
+ final int viewTravelCount;
+ if (clampedPosition < firstPosition) {
+ final int boundPositionFromLast = lastPosition - boundPosition;
+ if (boundPositionFromLast < 1) {
+ // Moving would shift our bound position off the screen. Abort.
+ return;
+ }
+
+ final int positionTravel = firstPosition - clampedPosition + 1;
+ final int boundTravel = boundPositionFromLast - 1;
+ if (boundTravel < positionTravel) {
+ viewTravelCount = boundTravel;
+ mMode = MOVE_BEFORE_BOUND;
+ } else {
+ viewTravelCount = positionTravel;
+ mMode = MOVE_BEFORE_POS;
+ }
+ } else if (clampedPosition > lastPosition) {
+ final int boundPositionFromFirst = boundPosition - firstPosition;
+ if (boundPositionFromFirst < 1) {
+ // Moving would shift our bound position off the screen. Abort.
+ return;
+ }
+
+ final int positionTravel = clampedPosition - lastPosition + 1;
+ final int boundTravel = boundPositionFromFirst - 1;
+ if (boundTravel < positionTravel) {
+ viewTravelCount = boundTravel;
+ mMode = MOVE_AFTER_BOUND;
+ } else {
+ viewTravelCount = positionTravel;
+ mMode = MOVE_AFTER_POS;
+ }
+ } else {
+ scrollToVisible(clampedPosition, boundPosition, SCROLL_DURATION);
+ return;
+ }
+
+ if (viewTravelCount > 0) {
+ mScrollDuration = SCROLL_DURATION / viewTravelCount;
+ } else {
+ mScrollDuration = SCROLL_DURATION;
+ }
+
+ mTargetPosition = clampedPosition;
+ mBoundPosition = boundPosition;
+ mLastSeenPosition = INVALID_POSITION;
+
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ }
+
+ void startWithOffset(int position, int offset) {
+ startWithOffset(position, offset, SCROLL_DURATION);
+ }
+
+ void startWithOffset(final int position, int offset, final int duration) {
+ stop();
+
+ if (mDataChanged) {
+ // Wait until we're back in a stable state to try this.
+ final int postOffset = offset;
+ mPositionScrollAfterLayout = new Runnable() {
+ @Override public void run() {
+ startWithOffset(position, postOffset, duration);
+ }
+ };
+
+ return;
+ }
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ // Can't scroll without children.
+ return;
+ }
+
+ offset += getStartEdge();
+
+ mTargetPosition = Math.max(0, Math.min(getCount() - 1, position));
+ mOffsetFromStart = offset;
+ mBoundPosition = INVALID_POSITION;
+ mLastSeenPosition = INVALID_POSITION;
+ mMode = MOVE_OFFSET;
+
+ final int firstPosition = mFirstPosition;
+ final int lastPosition = firstPosition + childCount - 1;
+
+ final int viewTravelCount;
+ if (mTargetPosition < firstPosition) {
+ viewTravelCount = firstPosition - mTargetPosition;
+ } else if (mTargetPosition > lastPosition) {
+ viewTravelCount = mTargetPosition - lastPosition;
+ } else {
+ // On-screen, just scroll.
+ final View targetView = getChildAt(mTargetPosition - firstPosition);
+ final int targetStart = getChildStartEdge(targetView);
+ smoothScrollBy(targetStart - offset, duration);
+ return;
+ }
+
+ // Estimate how many screens we should travel
+ final float screenTravelCount = (float) viewTravelCount / childCount;
+ mScrollDuration = screenTravelCount < 1 ?
+ duration : (int) (duration / screenTravelCount);
+ mLastSeenPosition = INVALID_POSITION;
+
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ }
+
+ /**
+ * Scroll such that targetPos is in the visible padded region without scrolling
+ * boundPos out of view. Assumes targetPos is onscreen.
+ */
+ void scrollToVisible(int targetPosition, int boundPosition, int duration) {
+ final int childCount = getChildCount();
+ final int firstPosition = mFirstPosition;
+ final int lastPosition = firstPosition + childCount - 1;
+
+ final int start = getStartEdge();
+ final int end = getEndEdge();
+
+ if (targetPosition < firstPosition || targetPosition > lastPosition) {
+ Log.w(LOGTAG, "scrollToVisible called with targetPosition " + targetPosition +
+ " not visible [" + firstPosition + ", " + lastPosition + "]");
+ }
+
+ if (boundPosition < firstPosition || boundPosition > lastPosition) {
+ // boundPos doesn't matter, it's already offscreen.
+ boundPosition = INVALID_POSITION;
+ }
+
+ final View targetChild = getChildAt(targetPosition - firstPosition);
+ final int targetStart = getChildStartEdge(targetChild);
+ final int targetEnd = getChildEndEdge(targetChild);
+
+ int scrollBy = 0;
+ if (targetEnd > end) {
+ scrollBy = targetEnd - end;
+ }
+ if (targetStart < start) {
+ scrollBy = targetStart - start;
+ }
+
+ if (scrollBy == 0) {
+ return;
+ }
+
+ if (boundPosition >= 0) {
+ final View boundChild = getChildAt(boundPosition - firstPosition);
+ final int boundStart = getChildStartEdge(boundChild);
+ final int boundEnd = getChildEndEdge(boundChild);
+ final int absScroll = Math.abs(scrollBy);
+
+ if (scrollBy < 0 && boundEnd + absScroll > end) {
+ // Don't scroll the bound view off the end of the screen.
+ scrollBy = Math.max(0, boundEnd - end);
+ } else if (scrollBy > 0 && boundStart - absScroll < start) {
+ // Don't scroll the bound view off the top of the screen.
+ scrollBy = Math.min(0, boundStart - start);
+ }
+ }
+
+ smoothScrollBy(scrollBy, duration);
+ }
+
+ void stop() {
+ removeCallbacks(this);
+ }
+
+ @Override
+ public void run() {
+ final int size = getAvailableSize();
+ final int firstPosition = mFirstPosition;
+
+ final int startPadding = (mIsVertical ? getPaddingTop() : getPaddingLeft());
+ final int endPadding = (mIsVertical ? getPaddingBottom() : getPaddingRight());
+
+ switch (mMode) {
+ case MOVE_AFTER_POS: {
+ final int lastViewIndex = getChildCount() - 1;
+ if (lastViewIndex < 0) {
+ return;
+ }
+
+ final int lastPosition = firstPosition + lastViewIndex;
+ if (lastPosition == mLastSeenPosition) {
+ // No new views, let things keep going.
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ return;
+ }
+
+ final View lastView = getChildAt(lastViewIndex);
+ final int lastViewSize = getChildSize(lastView);
+ final int lastViewStart = getChildStartEdge(lastView);
+ final int lastViewPixelsShowing = size - lastViewStart;
+ final int extraScroll = lastPosition < mItemCount - 1 ?
+ Math.max(endPadding, mExtraScroll) : endPadding;
+
+ final int scrollBy = lastViewSize - lastViewPixelsShowing + extraScroll;
+ smoothScrollBy(scrollBy, mScrollDuration);
+
+ mLastSeenPosition = lastPosition;
+ if (lastPosition < mTargetPosition) {
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ }
+
+ break;
+ }
+
+ case MOVE_AFTER_BOUND: {
+ final int nextViewIndex = 1;
+ final int childCount = getChildCount();
+ if (firstPosition == mBoundPosition ||
+ childCount <= nextViewIndex ||
+ firstPosition + childCount >= mItemCount) {
+ return;
+ }
+
+ final int nextPosition = firstPosition + nextViewIndex;
+
+ if (nextPosition == mLastSeenPosition) {
+ // No new views, let things keep going.
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ return;
+ }
+
+ final View nextView = getChildAt(nextViewIndex);
+ final int nextViewSize = getChildSize(nextView);
+ final int nextViewStart = getChildStartEdge(nextView);
+ final int extraScroll = Math.max(endPadding, mExtraScroll);
+ if (nextPosition < mBoundPosition) {
+ smoothScrollBy(Math.max(0, nextViewSize + nextViewStart - extraScroll),
+ mScrollDuration);
+ mLastSeenPosition = nextPosition;
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ } else {
+ if (nextViewSize > extraScroll) {
+ smoothScrollBy(nextViewSize - extraScroll, mScrollDuration);
+ }
+ }
+
+ break;
+ }
+
+ case MOVE_BEFORE_POS: {
+ if (firstPosition == mLastSeenPosition) {
+ // No new views, let things keep going.
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ return;
+ }
+
+ final View firstView = getChildAt(0);
+ if (firstView == null) {
+ return;
+ }
+
+ final int firstViewTop = getChildStartEdge(firstView);
+ final int extraScroll = firstPosition > 0 ?
+ Math.max(mExtraScroll, startPadding) : startPadding;
+
+ smoothScrollBy(firstViewTop - extraScroll, mScrollDuration);
+ mLastSeenPosition = firstPosition;
+
+ if (firstPosition > mTargetPosition) {
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ }
+
+ break;
+ }
+
+ case MOVE_BEFORE_BOUND: {
+ final int lastViewIndex = getChildCount() - 2;
+ if (lastViewIndex < 0) {
+ return;
+ }
+
+ final int lastPosition = firstPosition + lastViewIndex;
+
+ if (lastPosition == mLastSeenPosition) {
+ // No new views, let things keep going.
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ return;
+ }
+
+ final View lastView = getChildAt(lastViewIndex);
+ final int lastViewSize = getChildSize(lastView);
+ final int lastViewStart = getChildStartEdge(lastView);
+ final int lastViewPixelsShowing = size - lastViewStart;
+ final int extraScroll = Math.max(startPadding, mExtraScroll);
+
+ mLastSeenPosition = lastPosition;
+
+ if (lastPosition > mBoundPosition) {
+ smoothScrollBy(-(lastViewPixelsShowing - extraScroll), mScrollDuration);
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ } else {
+ final int end = size - extraScroll;
+ final int lastViewEnd = lastViewStart + lastViewSize;
+ if (end > lastViewEnd) {
+ smoothScrollBy(-(end - lastViewEnd), mScrollDuration);
+ }
+ }
+
+ break;
+ }
+
+ case MOVE_OFFSET: {
+ if (mLastSeenPosition == firstPosition) {
+ // No new views, let things keep going.
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ return;
+ }
+
+ mLastSeenPosition = firstPosition;
+
+ final int childCount = getChildCount();
+ final int position = mTargetPosition;
+ final int lastPos = firstPosition + childCount - 1;
+
+ int viewTravelCount = 0;
+ if (position < firstPosition) {
+ viewTravelCount = firstPosition - position + 1;
+ } else if (position > lastPos) {
+ viewTravelCount = position - lastPos;
+ }
+
+ // Estimate how many screens we should travel
+ final float screenTravelCount = (float) viewTravelCount / childCount;
+
+ final float modifier = Math.min(Math.abs(screenTravelCount), 1.f);
+ if (position < firstPosition) {
+ final int distance = (int) (-getSize() * modifier);
+ final int duration = (int) (mScrollDuration * modifier);
+ smoothScrollBy(distance, duration);
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ } else if (position > lastPos) {
+ final int distance = (int) (getSize() * modifier);
+ final int duration = (int) (mScrollDuration * modifier);
+ smoothScrollBy(distance, duration);
+ ViewCompat.postOnAnimation(TwoWayView.this, this);
+ } else {
+ // On-screen, just scroll.
+ final View targetView = getChildAt(position - firstPosition);
+ final int targetStart = getChildStartEdge(targetView);
+ final int distance = targetStart - mOffsetFromStart;
+ final int duration = (int) (mScrollDuration *
+ ((float) Math.abs(distance) / getSize()));
+ smoothScrollBy(distance, duration);
+ }
+
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java
new file mode 100644
index 000000000..c84686e90
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedEditText.java
@@ -0,0 +1,172 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedEditText extends android.widget.EditText
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedEditText(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedEditText(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java
new file mode 100644
index 000000000..a95fe2d9f
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedFrameLayout.java
@@ -0,0 +1,172 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedFrameLayout extends android.widget.FrameLayout
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedFrameLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java
new file mode 100644
index 000000000..88e94c6c7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageButton.java
@@ -0,0 +1,200 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedImageButton extends android.widget.ImageButton
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedImageButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedImageButton(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+
+ final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+ drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+ themedA.recycle();
+
+ // Apply the tint initially - the Drawable is
+ // initially set by XML via super's constructor.
+ setTintedImageDrawable(getDrawable());
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ @Override
+ public void setImageDrawable(final Drawable drawable) {
+ setTintedImageDrawable(drawable);
+ }
+
+ private void setTintedImageDrawable(final Drawable drawable) {
+ final Drawable tintedDrawable;
+ if (drawableColors == null || R.id.bookmark == getId()) {
+ // NB: The bookmarked state uses a blue star, so this is a hack to keep it untinted.
+ // NB: If we tint a drawable with a null ColorStateList, it will override
+ // any existing colorFilters and tint... so don't!
+ tintedDrawable = drawable;
+ } else if (drawable == null) {
+ tintedDrawable = null;
+ } else {
+ tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors);
+ }
+ super.setImageDrawable(tintedDrawable);
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java
new file mode 100644
index 000000000..befbe6fb5
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedImageView.java
@@ -0,0 +1,199 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedImageView extends android.widget.ImageView
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedImageView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+
+ final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+ drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+ themedA.recycle();
+
+ // Apply the tint initially - the Drawable is
+ // initially set by XML via super's constructor.
+ setTintedImageDrawable(getDrawable());
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ @Override
+ public void setImageDrawable(final Drawable drawable) {
+ setTintedImageDrawable(drawable);
+ }
+
+ private void setTintedImageDrawable(final Drawable drawable) {
+ final Drawable tintedDrawable;
+ if (drawableColors == null) {
+ // NB: If we tint a drawable with a null ColorStateList, it will override
+ // any existing colorFilters and tint... so don't!
+ tintedDrawable = drawable;
+ } else if (drawable == null) {
+ tintedDrawable = null;
+ } else {
+ tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors);
+ }
+ super.setImageDrawable(tintedDrawable);
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java
new file mode 100644
index 000000000..87ec58ce0
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedLinearLayout.java
@@ -0,0 +1,167 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedLinearLayout extends android.widget.LinearLayout
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java
new file mode 100644
index 000000000..14ef25c62
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedRelativeLayout.java
@@ -0,0 +1,172 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedRelativeLayout extends android.widget.RelativeLayout
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedRelativeLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java
new file mode 100644
index 000000000..294abd9ba
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextSwitcher.java
@@ -0,0 +1,167 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedTextSwitcher extends android.widget.TextSwitcher
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedTextSwitcher(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java
new file mode 100644
index 000000000..51a23a406
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedTextView.java
@@ -0,0 +1,172 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedTextView extends android.widget.TextView
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedTextView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java
new file mode 100644
index 000000000..77ecfd271
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java
@@ -0,0 +1,172 @@
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class ThemedView extends android.view.View
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public ThemedView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+ public ThemedView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag
new file mode 100644
index 000000000..e731a0ebe
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/ThemedView.java.frag
@@ -0,0 +1,211 @@
+//#filter substitution
+// This file is generated by generate_themed_views.py; do not edit.
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget.themed;
+
+import android.support.v4.content.ContextCompat;
+import org.mozilla.gecko.GeckoApplication;
+import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.DrawableUtil;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+public class Themed@VIEW_NAME_SUFFIX@ extends @BASE_TYPE@
+ implements LightweightTheme.OnChangeListener {
+ private LightweightTheme theme;
+
+ private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
+ private static final int[] STATE_LIGHT = { R.attr.state_light };
+ private static final int[] STATE_DARK = { R.attr.state_dark };
+
+ protected static final int[] PRIVATE_PRESSED_STATE_SET = { R.attr.state_private, android.R.attr.state_pressed };
+ protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
+ protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
+
+ private boolean isPrivate;
+ private boolean isLight;
+ private boolean isDark;
+ private boolean autoUpdateTheme; // always false if there's no theme.
+
+ private ColorStateList drawableColors;
+
+ public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize(context, attrs, 0);
+ }
+
+//#ifdef STYLE_CONSTRUCTOR
+ public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initialize(context, attrs, defStyle);
+ }
+
+//#endif
+ private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
+ // The theme can be null, particularly if we might be instantiating this
+ // View in an IDE, with no ambient GeckoApplication.
+ final Context applicationContext = context.getApplicationContext();
+ if (applicationContext instanceof GeckoApplication) {
+ theme = ((GeckoApplication) applicationContext).getLightweightTheme();
+ }
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
+ autoUpdateTheme = theme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
+ a.recycle();
+//#if TINT_FOREGROUND_DRAWABLE
+
+ final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+ drawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+ themedA.recycle();
+
+ // Apply the tint initially - the Drawable is
+ // initially set by XML via super's constructor.
+ setTintedImageDrawable(getDrawable());
+//#endif
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (autoUpdateTheme)
+ theme.removeListener(this);
+ }
+
+ @Override
+ public int[] onCreateDrawableState(int extraSpace) {
+ final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+ if (isPrivate)
+ mergeDrawableStates(drawableState, STATE_PRIVATE_MODE);
+ else if (isLight)
+ mergeDrawableStates(drawableState, STATE_LIGHT);
+ else if (isDark)
+ mergeDrawableStates(drawableState, STATE_DARK);
+
+ return drawableState;
+ }
+
+ @Override
+ public void onLightweightThemeChanged() {
+ if (autoUpdateTheme && theme.isEnabled())
+ setTheme(theme.isLightTheme());
+ }
+
+ @Override
+ public void onLightweightThemeReset() {
+ if (autoUpdateTheme)
+ resetTheme();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ onLightweightThemeChanged();
+ }
+
+ public boolean isPrivateMode() {
+ return isPrivate;
+ }
+
+ public void setPrivateMode(boolean isPrivate) {
+ if (this.isPrivate != isPrivate) {
+ this.isPrivate = isPrivate;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setTheme(boolean isLight) {
+ // Set the theme only if it is different from existing theme.
+ if ((isLight && this.isLight != isLight) ||
+ (!isLight && this.isDark == isLight)) {
+ if (isLight) {
+ this.isLight = true;
+ this.isDark = false;
+ } else {
+ this.isLight = false;
+ this.isDark = true;
+ }
+
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void resetTheme() {
+ if (isLight || isDark) {
+ isLight = false;
+ isDark = false;
+ refreshDrawableState();
+ invalidate();
+ }
+ }
+
+ public void setAutoUpdateTheme(boolean autoUpdateTheme) {
+ if (theme == null) {
+ return;
+ }
+
+ if (this.autoUpdateTheme != autoUpdateTheme) {
+ this.autoUpdateTheme = autoUpdateTheme;
+
+ if (autoUpdateTheme)
+ theme.addListener(this);
+ else
+ theme.removeListener(this);
+ }
+ }
+
+//#ifdef TINT_FOREGROUND_DRAWABLE
+ @Override
+ public void setImageDrawable(final Drawable drawable) {
+ setTintedImageDrawable(drawable);
+ }
+
+ private void setTintedImageDrawable(final Drawable drawable) {
+ final Drawable tintedDrawable;
+//#ifdef BOOKMARK_NO_TINT
+ if (drawableColors == null || R.id.bookmark == getId()) {
+ // NB: The bookmarked state uses a blue star, so this is a hack to keep it untinted.
+//#else
+ if (drawableColors == null) {
+//#endif
+ // NB: If we tint a drawable with a null ColorStateList, it will override
+ // any existing colorFilters and tint... so don't!
+ tintedDrawable = drawable;
+ } else if (drawable == null) {
+ tintedDrawable = null;
+ } else {
+ tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, drawableColors);
+ }
+ super.setImageDrawable(tintedDrawable);
+ }
+
+//#endif
+ public ColorDrawable getColorDrawable(int id) {
+ return new ColorDrawable(ContextCompat.getColor(getContext(), id));
+ }
+
+ protected LightweightTheme getTheme() {
+ return theme;
+ }
+}
diff --git a/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py b/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py
new file mode 100644
index 000000000..3b5a00b40
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/themed/generate_themed_views.py
@@ -0,0 +1,72 @@
+#!/bin/python
+
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+'''
+Script to generate Themed*.java source files for Fennec.
+
+This script runs the preprocessor on a input template and writes
+updated files into the source directory.
+
+To update the themed views, update the input template
+(ThemedView.java.frag) and run the script using 'mach python <script.py>'. Use version control to
+examine the differences, and don't forget to commit the changes to the
+template and the outputs.
+'''
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+)
+
+import os
+
+from mozbuild.preprocessor import Preprocessor
+
+__DIR__ = os.path.dirname(os.path.abspath(__file__))
+
+template = os.path.join(__DIR__, 'ThemedView.java.frag')
+dest_format_string = 'Themed%(VIEW_NAME_SUFFIX)s.java'
+
+views = [
+ dict(VIEW_NAME_SUFFIX='EditText',
+ BASE_TYPE='android.widget.EditText',
+ STYLE_CONSTRUCTOR=1),
+ dict(VIEW_NAME_SUFFIX='FrameLayout',
+ BASE_TYPE='android.widget.FrameLayout',
+ STYLE_CONSTRUCTOR=1),
+ dict(VIEW_NAME_SUFFIX='ImageButton',
+ BASE_TYPE='android.widget.ImageButton',
+ STYLE_CONSTRUCTOR=1,
+ TINT_FOREGROUND_DRAWABLE=1,
+ BOOKMARK_NO_TINT=1),
+ dict(VIEW_NAME_SUFFIX='ImageView',
+ BASE_TYPE='android.widget.ImageView',
+ STYLE_CONSTRUCTOR=1,
+ TINT_FOREGROUND_DRAWABLE=1),
+ dict(VIEW_NAME_SUFFIX='LinearLayout',
+ BASE_TYPE='android.widget.LinearLayout'),
+ dict(VIEW_NAME_SUFFIX='RelativeLayout',
+ BASE_TYPE='android.widget.RelativeLayout',
+ STYLE_CONSTRUCTOR=1),
+ dict(VIEW_NAME_SUFFIX='TextSwitcher',
+ BASE_TYPE='android.widget.TextSwitcher'),
+ dict(VIEW_NAME_SUFFIX='TextView',
+ BASE_TYPE='android.widget.TextView',
+ STYLE_CONSTRUCTOR=1),
+ dict(VIEW_NAME_SUFFIX='View',
+ BASE_TYPE='android.view.View',
+ STYLE_CONSTRUCTOR=1),
+]
+
+for view in views:
+ pp = Preprocessor(defines=view, marker='//#')
+
+ dest = os.path.join(__DIR__, dest_format_string % view)
+ with open(template, 'rU') as input:
+ with open(dest, 'wt') as output:
+ pp.processFile(input=input, output=output)
+ print('%s' % dest)