diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/22.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/22.json new file mode 100644 index 000000000000..20faa52ad3b6 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/22.json @@ -0,0 +1,746 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "d6e385bcc19ae0df396817590763b709", + "entities": [ + { + "tableName": "tds_tracker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `defaultAction` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `categories` TEXT NOT NULL, `rules` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultAction", + "columnName": "defaultAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `prevalence` REAL NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevalence", + "columnName": "prevalence", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_domain_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `entityName` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityName", + "columnName": "entityName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporary_tracking_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_bloom_filter_spec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `errorRate` REAL NOT NULL, `totalEntries` INTEGER NOT NULL, `sha256` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorRate", + "columnName": "errorRate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalEntries", + "columnName": "totalEntries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_whitelisted_domain", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "network_leaderboard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkName` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`networkName`))", + "fields": [ + { + "fieldPath": "networkName", + "columnName": "networkName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "networkName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sites_visited", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `url` TEXT, `title` TEXT, `skipHome` INTEGER NOT NULL, `viewed` INTEGER NOT NULL, `position` INTEGER NOT NULL, `tabPreviewFile` TEXT, PRIMARY KEY(`tabId`))", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "skipHome", + "columnName": "skipHome", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewed", + "columnName": "viewed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabPreviewFile", + "columnName": "tabPreviewFile", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "tabId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tabs_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tabs_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tab_selection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tabId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tab_selection_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tab_selection_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "survey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`surveyId` TEXT NOT NULL, `url` TEXT, `daysInstalled` INTEGER, `status` TEXT NOT NULL, PRIMARY KEY(`surveyId`))", + "fields": [ + { + "fieldPath": "surveyId", + "columnName": "surveyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "daysInstalled", + "columnName": "daysInstalled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "surveyId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissed_cta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ctaId` TEXT NOT NULL, PRIMARY KEY(`ctaId`))", + "fields": [ + { + "fieldPath": "ctaId", + "columnName": "ctaId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "ctaId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_days_used", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_enjoyment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventType` INTEGER NOT NULL, `promptCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `primaryKey` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptCount", + "columnName": "promptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryKey", + "columnName": "primaryKey", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "primaryKey" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "notificationId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "privacy_protection_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `blocked_tracker_count` INTEGER NOT NULL, `upgrade_count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedTrackerCount", + "columnName": "blocked_tracker_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradeCount", + "columnName": "upgrade_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UncaughtExceptionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exceptionSource` TEXT NOT NULL, `message` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `version` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exceptionSource", + "columnName": "exceptionSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tdsMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `eTag` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "userStage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER NOT NULL, `appStage` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appStage", + "columnName": "appStage", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "fireproofWebsites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd6e385bcc19ae0df396817590763b709')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 47563ed50e3d..b778cca00b13 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -52,13 +52,28 @@ import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta -import com.duckduckgo.app.cta.ui.* import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository +import com.duckduckgo.app.cta.ui.Cta +import com.duckduckgo.app.cta.ui.CtaViewModel +import com.duckduckgo.app.cta.ui.DaxBubbleCta +import com.duckduckgo.app.cta.ui.DaxDialogCta +import com.duckduckgo.app.cta.ui.HomePanelCta +import com.duckduckgo.app.cta.ui.UseOurAppCta +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_DOMAIN +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.notification.model.UseOurAppNotification +import com.duckduckgo.app.global.events.db.UserEventEntity +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.useourapp.UseOurAppDetector +import com.duckduckgo.app.notification.db.NotificationDao +import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao @@ -188,6 +203,12 @@ class BrowserTabViewModelTest { @Mock private lateinit var mockNavigationAwareLoginDetector: NavigationAwareLoginDetector + @Mock + private lateinit var mockUserEventsStore: UserEventsStore + + @Mock + private lateinit var mockNotificationDao: NotificationDao + private lateinit var mockAutoCompleteApi: AutoCompleteApi private lateinit var ctaViewModel: CtaViewModel @@ -227,6 +248,8 @@ class BrowserTabViewModelTest { mockSettingsStore, mockOnboardingStore, mockUserStageStore, + mockUserEventsStore, + UseOurAppDetector(mockUserEventsStore), coroutineRule.testDispatcherProvider ) @@ -263,6 +286,9 @@ class BrowserTabViewModelTest { dispatchers = coroutineRule.testDispatcherProvider, fireproofWebsiteRepository = FireproofWebsiteRepository(fireproofWebsiteDao, coroutineRule.testDispatcherProvider), navigationAwareLoginDetector = mockNavigationAwareLoginDetector, + userEventsStore = mockUserEventsStore, + notificationDao = mockNotificationDao, + useOurAppDetector = UseOurAppDetector(mockUserEventsStore), variantManager = mockVariantManager ) @@ -313,19 +339,31 @@ class BrowserTabViewModelTest { } @Test - fun whenViewIsResumedAndBrowserShowingThenKeyboardHidden() { - setBrowserShowing(true) - testee.onViewResumed() + fun whenViewBecomesVisibleAndHomeShowingAndUserIsNotInUseOurAppOnboardingStageThenKeyboardShown() = coroutineRule.runBlocking { + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.ESTABLISHED) + setBrowserShowing(false) + + testee.onViewVisible() verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - assertTrue(commandCaptor.allValues.contains(Command.HideKeyboard)) + assertTrue(commandCaptor.allValues.contains(Command.ShowKeyboard)) } @Test - fun whenViewIsResumedAndHomeShowingThenKeyboardShown() { + fun whenViewBecomesVisibleAndHomeShowingAndUserIsInUseOurAppOnboardingStageThenKeyboardHidden() = coroutineRule.runBlocking { + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.USE_OUR_APP_ONBOARDING) setBrowserShowing(false) - testee.onViewResumed() + + testee.onViewVisible() verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - assertTrue(commandCaptor.allValues.contains(Command.ShowKeyboard)) + assertTrue(commandCaptor.allValues.contains(Command.HideKeyboard)) + } + + @Test + fun whenViewBecomesVisibleAndBrowserShowingThenKeyboardHidden() { + setBrowserShowing(true) + testee.onViewVisible() + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertTrue(commandCaptor.allValues.contains(Command.HideKeyboard)) } @Test @@ -1509,7 +1547,15 @@ class BrowserTabViewModelTest { fun whenUserPressesBackAndNotSkippingHomeThenWebViewPreviewNotGenerated() { setupNavigation(isBrowsing = true, canGoBack = false, skipHome = false) testee.onUserPressedBack() - verify(mockCommandObserver, never()).onChanged(commandCaptor.capture()) + assertFalse(commandCaptor.allValues.contains(Command.GenerateWebViewPreviewImage)) + } + + @Test + fun whenUserPressesBackAndGoesToHomeThenKeyboardShown() { + setupNavigation(isBrowsing = true, canGoBack = false, skipHome = false) + testee.onUserPressedBack() + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertTrue(commandCaptor.allValues.contains(Command.ShowKeyboard)) } @Test @@ -1650,6 +1696,16 @@ class BrowserTabViewModelTest { assertCommandIssued() } + @Test + fun whenUserClickedUseOurAppCtaOkButtonThenLaunchAddHomeShortcutAndNavigateCommand() { + whenever(mockOmnibarConverter.convertQueryToUrl(USE_OUR_APP_SHORTCUT_URL, null)).thenReturn(USE_OUR_APP_SHORTCUT_URL) + val cta = UseOurAppCta() + setCta(cta) + testee.onUserClickCtaOkButton() + assertCommandIssued() + assertCommandIssued() + } + @Test fun whenSurveyCtaDismissedAndNoOtherCtaPossibleCtaIsNull() = coroutineRule.runBlocking { givenShownCtas(CtaId.DAX_INTRO, CtaId.DAX_END) @@ -1727,6 +1783,14 @@ class BrowserTabViewModelTest { verify(mockSurveyDao).cancelScheduledSurveys() } + @Test + fun whenUserClickedSecondaryCtaButtonInUseOurAppCtaThenLaunchShowKeyboardCommand() { + val cta = UseOurAppCta() + setCta(cta) + testee.onUserClickCtaSecondaryButton() + assertCommandIssued() + } + @Test fun whenSurrogateDetectedThenSiteUpdated() { givenOneActiveTabSelected() @@ -1915,6 +1979,30 @@ class BrowserTabViewModelTest { } } + @Test + fun whenLoginDetectedAndUrlIsUseOurAppThenRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(null) + loginEventLiveData.value = givenLoginDetected(USE_OUR_APP_SHORTCUT_URL) + + verify(mockUserEventsStore).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + + @Test + fun whenLoginDetectedAndUrlIsNotUseOurAppThenDoNotRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(null) + loginEventLiveData.value = givenLoginDetected("example.com") + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + + @Test + fun whenLoginDetectedAndDialogAlreadySeenThenDoNotRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)) + loginEventLiveData.value = givenLoginDetected(USE_OUR_APP_SHORTCUT_URL) + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + @Test fun whenUserBrowsingPressesBackThenCannotAddBookmark() { setupNavigation(skipHome = false, isBrowsing = true, canGoBack = false) @@ -2057,6 +2145,127 @@ class BrowserTabViewModelTest { testee.onUserSubmittedQuery("about:blank") } + @Test + fun whenViewReadyIfDomainSameAsUseOurAppAfterNotificationSeenThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + whenever(mockNotificationDao.exists(UseOurAppNotification.ID)).thenReturn(true) + + testee.onViewReady() + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_NOTIFICATION) + } + + @Test + fun whenViewReadyIfDomainSameAsUseOurAppAfterShortcutAddedThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + + testee.onViewReady() + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_SHORTCUT) + } + + @Test + fun whenViewReadyIfDomainSameAsUseOurAppAfterDeleteCtaShownThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION)).thenReturn(true) + + testee.onViewReady() + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) + } + + @Test + fun whenViewReadyIfDomainIsNotTheSameAsUseOurAppAfterNotificationSeenThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockNotificationDao.exists(UseOurAppNotification.ID)).thenReturn(true) + + testee.onViewReady() + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_NOTIFICATION) + } + + @Test + fun whenViewReadyIfDomainIsNotTheSameAsUseOurAppAfterShortcutAddedThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + + testee.onViewReady() + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_SHORTCUT) + } + + @Test + fun whenViewReadyIfDomainIsNotTheSameAsUseOurAppAfterDeleteCtaShownThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION)).thenReturn(true) + + testee.onViewReady() + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) + } + + @Test + fun whenPageChangedIfPreviousOneWasNotUseOurAppSiteAfterNotificationSeenThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockNotificationDao.exists(UseOurAppNotification.ID)).thenReturn(true) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_NOTIFICATION) + } + + @Test + fun whenPageChangedIfPreviousOneWasNotUseOurAppSiteAfterShortcutAddedThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_SHORTCUT) + } + + @Test + fun whenPageChangedIfPreviousOneWasNotUseOurAppSiteAfterDeleteCtaShownThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION)).thenReturn(true) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) + } + + @Test + fun whenPageChangedIfPreviousOneWasUseOurAppSiteAfterNotificationSeenThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + whenever(mockNotificationDao.exists(UseOurAppNotification.ID)).thenReturn(true) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_NOTIFICATION) + } + + @Test + fun whenPageChangedIfPreviousOneWasUseOurAppSiteAfterShortcutAddedThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_SHORTCUT) + } + + @Test + fun whenPageChangedIfPreviousOneWasUseOurAppSiteThenAfterDeleteCtaShownPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION)).thenReturn(true) + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } @@ -2096,6 +2305,26 @@ class BrowserTabViewModelTest { testee.loadData("TAB_ID", "https://example.com", false) } + private fun givenUseOurAppSiteSelected() { + whenever(mockOmnibarConverter.convertQueryToUrl(USE_OUR_APP_DOMAIN, null)).thenReturn(USE_OUR_APP_DOMAIN) + val site: Site = mock() + whenever(site.url).thenReturn(USE_OUR_APP_DOMAIN) + val siteLiveData = MutableLiveData() + siteLiveData.value = site + whenever(mockTabsRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) + testee.loadData("TAB_ID", USE_OUR_APP_DOMAIN, false) + } + + private fun givenUseOurAppSiteIsNotSelected() { + whenever(mockOmnibarConverter.convertQueryToUrl("example.com", null)).thenReturn("example.com") + val site: Site = mock() + whenever(site.url).thenReturn("example.com") + val siteLiveData = MutableLiveData() + siteLiveData.value = site + whenever(mockTabsRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) + testee.loadData("TAB_ID", "example.com", false) + } + private fun givenFireproofWebsiteDomain(vararg fireproofWebsitesDomain: String) { fireproofWebsitesDomain.forEach { fireproofWebsiteDao.insert(FireproofWebsiteEntity(domain = it)) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index 4e7b0d35b7e7..ced386fb72e5 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -19,18 +19,25 @@ package com.duckduckgo.app.browser import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer +import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.DisplayMessage import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter +import com.duckduckgo.app.cta.db.DismissedCtaDao +import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder import com.duckduckgo.app.global.rating.PromptCount +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL import com.duckduckgo.app.privacy.ui.PrivacyDashboardActivity +import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -42,14 +49,17 @@ import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.MockitoAnnotations -import java.util.Arrays.asList +@ExperimentalCoroutinesApi class BrowserViewModelTest { @get:Rule @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + @Mock private lateinit var mockCommandObserver: Observer @@ -71,6 +81,12 @@ class BrowserViewModelTest { @Mock private lateinit var mockAppEnjoymentPromptEmitter: AppEnjoymentPromptEmitter + @Mock + private lateinit var mockPixel: Pixel + + @Mock + private lateinit var mockDismissedCtaDao: DismissedCtaDao + private lateinit var testee: BrowserViewModel @Before @@ -84,7 +100,10 @@ class BrowserViewModelTest { queryUrlConverter = mockOmnibarEntryConverter, dataClearer = mockAutomaticDataClearer, appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, - appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder + appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, + ctaDao = mockDismissedCtaDao, + dispatchers = coroutinesTestRule.testDispatcherProvider, + pixel = mockPixel ) testee.command.observeForever(mockCommandObserver) @@ -122,7 +141,7 @@ class BrowserViewModelTest { @Test fun whenTabsUpdatedWithTabsThenNewTabNotLaunched() = runBlocking { - testee.onTabsUpdated(asList(TabEntity(TAB_ID, "", "", false, true, 0))) + testee.onTabsUpdated(listOf(TabEntity(TAB_ID, "", "", skipHome = false, viewed = true, position = 0))) verify(mockCommandObserver, never()).onChanged(any()) } @@ -165,6 +184,44 @@ class BrowserViewModelTest { assertTrue(testee.viewState.value!!.hideWebContent) } + @Test + fun whenOpenShortcutThenSelectByUrlOrNewTab() = coroutinesTestRule.runBlocking { + val url = "example.com" + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenShortcut(url) + verify(mockTabRepository).selectByUrlOrNewTab(url) + } + + @Test + fun whenOpenShortcutIfUrlIsUseOurAppUrlAndCtaHasBeenSeenThenFirePixel() { + givenUseOurAppCtaHasBeenSeen() + val url = USE_OUR_APP_SHORTCUT_URL + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenShortcut(url) + verify(mockPixel).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_OPENED) + } + + @Test + fun whenOpenShortcutIfUrlIsUseOurAppUrlAndCtaHasNotBeenSeenThenDoNotFireUseOurAppPixel() { + val url = USE_OUR_APP_SHORTCUT_URL + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenShortcut(url) + verify(mockPixel, never()).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_OPENED) + verify(mockPixel).fire(Pixel.PixelName.SHORTCUT_OPENED) + } + + @Test + fun whenOpenShortcutIfUrlIsNotUSeOurAppUrlThenFirePixel() { + val url = "example.com" + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenShortcut(url) + verify(mockPixel).fire(Pixel.PixelName.SHORTCUT_OPENED) + } + + private fun givenUseOurAppCtaHasBeenSeen() { + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP)).thenReturn(true) + } + companion object { const val TAB_ID = "TAB_ID" } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt index 8b6ee33ae6b0..348df5a773e7 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt @@ -23,6 +23,8 @@ import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.browser.logindetection.LoginDetectionJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.runBlocking import com.duckduckgo.app.settings.db.SettingsDataStore import com.nhaarman.mockitokotlin2.* @@ -38,8 +40,9 @@ class JsLoginDetectorTest { var coroutinesTestRule = CoroutineTestRule() private val settingsDataStore: SettingsDataStore = mock() + private val userEventsStore: UserEventsStore = mock() - private val testee = JsLoginDetector(settingsDataStore) + private val testee = JsLoginDetector(settingsDataStore, UseOurAppDetector(userEventsStore)) @UiThreadTest @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt new file mode 100644 index 000000000000..c8c45b745555 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.browser.shortcut + +import android.content.Intent +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.cta.db.DismissedCtaDao +import com.duckduckgo.app.cta.model.CtaId +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL +import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.pixels.Pixel +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class ShortcutReceiverTest { + + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private val mockUserEventsStore: UserEventsStore = mock() + private val mockPixel: Pixel = mock() + private val mockDismissedCtaDao: DismissedCtaDao = mock() + private lateinit var testee: ShortcutReceiver + + @Before + fun before() { + testee = ShortcutReceiver(mockUserEventsStore, mockDismissedCtaDao, coroutinesTestRule.testDispatcherProvider, mockPixel) + } + + @Test + fun whenIntentReceivedIfUrlIsFromUseOurAppUrlThenRegisterTimestamp() = coroutinesTestRule.runBlocking { + givenUseOurAppCtaHasBeenSeen() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockUserEventsStore).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } + + @Test + fun whenIntentReceivedIfUrlIsFromUseOurAppUrlThenFirePixel() { + givenUseOurAppCtaHasBeenSeen() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockPixel).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) + } + + @Test + fun whenIntentReceivedIfUrlIsNotFromUseOurAppUrlThenDoNotRegisterEvent() = coroutinesTestRule.runBlocking { + givenUseOurAppCtaHasBeenSeen() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } + + @Test + fun whenIntentReceivedIfUrlIsNotFromUseOurAppUrlThenFireShortcutAddedPixel() { + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockPixel).fire(Pixel.PixelName.SHORTCUT_ADDED) + } + + @Test + fun whenIntentReceivedAndCtaNotSeenIfUrlIsFromUseOurAppUrlThenDoNotFireUseOurAppPixelAndFireShortcutPixel() = coroutinesTestRule.runBlocking { + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) + + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockPixel, never()).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) + verify(mockPixel).fire(Pixel.PixelName.SHORTCUT_ADDED) + } + + private fun givenUseOurAppCtaHasBeenSeen() { + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP)).thenReturn(true) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaTest.kt index 0c4c79f40ab0..77fc4aa3b265 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaTest.kt @@ -338,6 +338,42 @@ class CtaTest { assertEquals("FacebookwithZero", value) } + @Test + fun whenCtaIsUseOurAppReturnEmptyOkParameters() { + val testee = UseOurAppCta() + assertTrue(testee.pixelOkParameters().isEmpty()) + } + + @Test + fun whenCtaIsUseOurAppReturnEmptyCancelParameters() { + val testee = UseOurAppCta() + assertTrue(testee.pixelCancelParameters().isEmpty()) + } + + @Test + fun whenCtaIsUseOurAppReturnEmptyShownParameters() { + val testee = UseOurAppCta() + assertTrue(testee.pixelShownParameters().isEmpty()) + } + + @Test + fun whenCtaIsUseOurAppDeletionReturnEmptyOkParameters() { + val testee = UseOurAppDeletionCta() + assertTrue(testee.pixelOkParameters().isEmpty()) + } + + @Test + fun whenCtaIsUseOurAppDeletionReturnEmptyCancelParameters() { + val testee = UseOurAppDeletionCta() + assertTrue(testee.pixelCancelParameters().isEmpty()) + } + + @Test + fun whenCtaIsUseOurAppDeletionReturnEmptyShownParameters() { + val testee = UseOurAppDeletionCta() + assertTrue(testee.pixelShownParameters().isEmpty()) + } + private fun site( url: String = "http://www.test.com", uri: Uri? = Uri.parse(url), diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 2eb7e21ad725..d37e3b56d8cb 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -25,9 +25,14 @@ import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.events.db.UserEventEntity +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore @@ -106,6 +111,9 @@ class CtaViewModelTest { @Mock private lateinit var mockUserStageStore: UserStageStore + @Mock + private lateinit var mockUserEventsStore: UserEventsStore + private val requiredDaxOnboardingCtas: List = listOf( CtaId.DAX_INTRO, CtaId.DAX_DIALOG_SERP, @@ -139,6 +147,8 @@ class CtaViewModelTest { mockSettingsDataStore, mockOnboardingStore, mockUserStageStore, + mockUserEventsStore, + UseOurAppDetector(mockUserEventsStore), coroutineRule.testDispatcherProvider ) } @@ -186,14 +196,6 @@ class CtaViewModelTest { verify(mockPixel).fire(eq(SURVEY_CTA_LAUNCHED), any(), any()) } - @Test - fun whenCtaSecondaryButtonClickedPixelIsFired() { - val secondaryButtonCta = mock() - whenever(secondaryButtonCta.secondaryButtonPixel).thenReturn(ONBOARDING_DAX_ALL_CTA_HIDDEN) - testee.onUserClickCtaSecondaryButton(secondaryButtonCta) - verify(mockPixel).fire(eq(ONBOARDING_DAX_ALL_CTA_HIDDEN), any(), any()) - } - @Test fun whenCtaDismissedPixelIsFired() = coroutineRule.runBlocking { testee.onUserDismissedCta(HomePanelCta.Survey(Survey("abc", "http://example.com", 1, SCHEDULED))) @@ -420,17 +422,102 @@ class CtaViewModelTest { @Test fun whenRefreshCtaWhileBrowsingWithDaxOnboardingCompletedButNotAllCtasWereShownThenReturnNull() = runBlockingTest { givenShownDaxOnboardingCtas(listOf(CtaId.DAX_INTRO)) - givenDaxOnboardingCompleted() + givenUserIsEstablished() val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true) assertNull(value) } + @Test + fun whenRefreshCtaOnHomeTabAndUseOurAppOnboardingActiveThenUseOurAppCtaShown() = runBlockingTest { + givenUseOurAppActive() + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = false) + assertTrue(value is UseOurAppCta) + } + + @Test + fun whenRefreshCtaOnHomeTabAndUseOurAppOnboardingActiveAndHideTipsIsTrueThenReturnNull() = runBlockingTest { + givenUseOurAppActive() + whenever(mockSettingsDataStore.hideTips).thenReturn(true) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = false) + assertNull(value) + } + + @Test + fun whenRefreshCtaOnHomeTabAndUseOurAppOnboardingActiveAndCtaShownThenReturnNull() = runBlockingTest { + givenUseOurAppActive() + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP)).thenReturn(true) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = false) + assertNull(value) + } + + @Test + fun whenRefreshCtaWhileBrowsingAndSiteIsUseOurAppAndTwoDaysSinceShortcutAddedThenShowUseOurAppDeletionCta() = runBlockingTest { + givenUserIsEstablished() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site(url = USE_OUR_APP_SHORTCUT_URL)) + assertTrue(value is UseOurAppDeletionCta) + } + + @Test + fun whenRefreshCtaWhileBrowsingAndSiteIsUseOurAppAndTwoDaysSinceShortcutAddedAndHideTipsIsTrueThenReturnNull() = runBlockingTest { + givenUserIsEstablished() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + whenever(mockSettingsDataStore.hideTips).thenReturn(true) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site(url = USE_OUR_APP_SHORTCUT_URL)) + assertNull(value) + } + + @Test + fun whenRefreshCtaWhileBrowsingAndSiteIsUseOurAppAndTwoDaysSinceShortcutAddedAndCtaShownThenReturnNull() = runBlockingTest { + givenUserIsEstablished() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + whenever(mockDismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION)).thenReturn(true) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site(url = USE_OUR_APP_SHORTCUT_URL)) + assertNull(value) + } + + @Test + fun whenRefreshCtaWhileBrowsingAndSiteIsUseOurAppAndLessThanTwoDaysSinceShortcutAddedThenReturnNull() = runBlockingTest { + givenUserIsEstablished() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site(url = USE_OUR_APP_SHORTCUT_URL)) + assertNull(value) + } + + @Test + fun whenRefreshCtaWhileBrowsingAndSiteIsNotUseOurAppAndTwoDaysSinceShortcutAddedThenReturnNull() = runBlockingTest { + givenUserIsEstablished() + val timestampEntity = UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(timestampEntity) + + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site(url = "test")) + assertNull(value) + } + + @Test + fun whenUseOurAppCtaDismissedThenStageCompleted() = coroutineRule.runBlocking { + givenUseOurAppActive() + testee.onUserDismissedCta(UseOurAppCta()) + verify(mockUserStageStore).stageCompleted(AppStage.USE_OUR_APP_ONBOARDING) + } + private suspend fun givenDaxOnboardingActive() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) } - private suspend fun givenDaxOnboardingCompleted() { + private suspend fun givenUserIsEstablished() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.ESTABLISHED) } @@ -448,6 +535,10 @@ class CtaViewModelTest { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) } + private suspend fun givenUseOurAppActive() { + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.USE_OUR_APP_ONBOARDING) + } + private fun site( url: String = "http://www.test.com", uri: Uri? = Uri.parse(url), diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt index e82bc18113dd..3173f8df0377 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt @@ -16,20 +16,26 @@ package com.duckduckgo.app.global.db +import android.content.ContentValues import android.content.Context import android.content.SharedPreferences +import android.database.sqlite.SQLiteDatabase import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.room.migration.Migration import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.blockingObserve +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.global.exception.UncaughtExceptionEntity import com.duckduckgo.app.global.exception.UncaughtExceptionSource +import com.duckduckgo.app.global.useourapp.MigrationManager import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.settings.db.SettingsDataStore import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock @@ -55,8 +61,12 @@ class AppDatabaseTest { val testHelper = MigrationTestHelper(getInstrumentation(), AppDatabase::class.qualifiedName, FrameworkSQLiteOpenHelperFactory()) private val context = mock() + private val mockSettingsDataStore = mock() + private val mockAddToHomeCapabilityDetector = mock() + private val mockUseOurAppMigrationManager = mock() - private val migrationsProvider: MigrationsProvider = MigrationsProvider(context) + private val migrationsProvider: MigrationsProvider = + MigrationsProvider(context, mockSettingsDataStore, mockAddToHomeCapabilityDetector, mockUseOurAppMigrationManager) @Before fun setup() { @@ -187,15 +197,17 @@ class AppDatabaseTest { @Test fun whenMigratingFromVersion17To18IfUserDidNotSeeOnboardingThenMigrateToNew() = coroutineRule.runBlocking { givenUserNeverSawOnboarding() - createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) - assertEquals(AppStage.NEW, database().userStageDao().currentUserAppStage()?.appStage) + val database = createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) + val stage = getUserStage(database) + assertEquals(AppStage.NEW.name, stage) } @Test - fun whenMigratingFromVersion17To18IfUserSeeOnboardingThenMigrateToEstablished() = coroutineRule.runBlocking { + fun whenMigratingFromVersion17To18IfUserSeeOnboardingThenMigrateToEstablished() { givenUserSawOnboarding() - createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) - assertEquals(AppStage.ESTABLISHED, database().userStageDao().currentUserAppStage()?.appStage) + val database = createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) + val stage = getUserStage(database) + assertEquals(AppStage.ESTABLISHED.name, stage) } @Test @@ -216,19 +228,117 @@ class AppDatabaseTest { createDatabaseAndMigrate(20, 21, migrationsProvider.MIGRATION_20_TO_21) } + @Test + fun whenMigratingFromVersion21To22ThenValidationSucceeds() { + createDatabaseAndMigrate(21, 22, migrationsProvider.MIGRATION_21_TO_22) + } + + @Test + fun whenMigratingFromVersion21To22IfUserIsEstablishedAndConditionsAreMetThenMigrateToNotification() { + testHelper.createDatabase(TEST_DB_NAME, 21).use { + givenUseOurAppStateIs(canMigrate = true, hideTips = false, canAddToHome = true) + givenUserStageIs(it, AppStage.ESTABLISHED) + + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + val stage = getUserStage(it) + + assertEquals(AppStage.USE_OUR_APP_NOTIFICATION.name, stage) + } + } + + @Test + fun whenMigratingFromVersion21To22IfUserIsEstablishedAndHideTipsIsTrueThenDoNotMigrateToNotification() { + testHelper.createDatabase(TEST_DB_NAME, 21).use { + givenUseOurAppStateIs(canMigrate = true, hideTips = true, canAddToHome = true) + givenUserStageIs(it, AppStage.ESTABLISHED) + + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + val stage = getUserStage(it) + + assertEquals(AppStage.ESTABLISHED.name, stage) + } + } + + @Test + fun whenMigratingFromVersion21To22IfUserIsEstablishedAndHomeShortcutNotSupportedThenDoNotMigrateToNotification() { + testHelper.createDatabase(TEST_DB_NAME, 21).use { + givenUseOurAppStateIs(canMigrate = true, hideTips = false, canAddToHome = false) + givenUserStageIs(it, AppStage.ESTABLISHED) + + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + val stage = getUserStage(it) + + assertEquals(AppStage.ESTABLISHED.name, stage) + } + } + + @Test + fun whenMigratingFromVersion21To22IfUserIsNotEstablishedThenDoNotMigrateToNotification() { + testHelper.createDatabase(TEST_DB_NAME, 21).use { + givenUseOurAppStateIs(canMigrate = true, hideTips = false, canAddToHome = false) + givenUserStageIs(it, AppStage.ESTABLISHED) + + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + val stage = getUserStage(it) + + assertEquals(AppStage.ESTABLISHED.name, stage) + } + } + + @Test + fun whenMigratingFromVersion21To22IfShouldNotRunMigrationThenDoNotMigrateToNotification2() { + testHelper.createDatabase(TEST_DB_NAME, 21).use { + givenUseOurAppStateIs(canMigrate = false) + givenUserStageIs(it, AppStage.ESTABLISHED) + + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + val stage = getUserStage(it) + + assertEquals(AppStage.ESTABLISHED.name, stage) + } + } + + private fun givenUseOurAppStateIs(canMigrate: Boolean = true, hideTips: Boolean = false, canAddToHome: Boolean = true) { + whenever(mockUseOurAppMigrationManager.shouldRunMigration()).thenReturn(canMigrate) + whenever(mockSettingsDataStore.hideTips).thenReturn(hideTips) + whenever(mockAddToHomeCapabilityDetector.isAddToHomeSupported()).thenReturn(canAddToHome) + } + + private fun givenUserStageIs(database: SupportSQLiteDatabase, appStage: AppStage) { + val values: ContentValues = ContentValues().apply { + put("key", 1) + put("appStage", appStage.name) + } + + database.apply { + insert("userStage", SQLiteDatabase.CONFLICT_REPLACE, values) + } + } + + private fun getUserStage(database: SupportSQLiteDatabase): String { + var stage = "" + + database.query("SELECT appStage from userStage limit 1").apply { + moveToFirst() + stage = getString(0) + } + return stage + } + private fun createDatabase(version: Int) { testHelper.createDatabase(TEST_DB_NAME, version).close() } - private fun runMigrations(newVersion: Int, vararg migrations: Migration) { - testHelper.runMigrationsAndValidate(TEST_DB_NAME, newVersion, true, *migrations) + private fun runMigrations(newVersion: Int, vararg migrations: Migration): SupportSQLiteDatabase { + return testHelper.runMigrationsAndValidate(TEST_DB_NAME, newVersion, true, *migrations) } - private fun createDatabaseAndMigrate(originalVersion: Int, newVersion: Int, vararg migrations: Migration) { + private fun createDatabaseAndMigrate(originalVersion: Int, newVersion: Int, vararg migrations: Migration): SupportSQLiteDatabase { createDatabase(originalVersion) - runMigrations(newVersion, *migrations) + return runMigrations(newVersion, *migrations) } + @Deprecated("Don't use anymore, instead execute a query directly to the database, see getUserStage as an example. Using this methods runs all the migrations using the latest schema version and cannot be used to validate intermediate migrations") private fun database(): AppDatabase { val database = Room .databaseBuilder(getInstrumentation().targetContext, AppDatabase::class.java, TEST_DB_NAME) diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/events/db/UserEventsDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/events/db/UserEventsDaoTest.kt new file mode 100644 index 000000000000..1e82c01385a8 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/global/events/db/UserEventsDaoTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.events.db + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule + +@ExperimentalCoroutinesApi +class UserEventsDaoTest { + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private lateinit var db: AppDatabase + + private lateinit var dao: UserEventsDao + + private lateinit var testee: AppUserEventsStore + + @Before + fun before() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java).build() + dao = db.userEventsDao() + testee = AppUserEventsStore(dao, coroutineRule.testDispatcherProvider) + } + + @After + fun after() { + db.close() + } + + @Test + fun whenGetUserEventAndDatabaseEmptyThenReturnNull() = coroutineRule.runBlocking { + assertNull(testee.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + } + + @Test + fun whenInsertingUserEventThenTimestampIsNotNull() = coroutineRule.runBlocking { + testee.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + + assertNotNull(testee.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)?.timestamp) + } + + @Test + fun whenInsertingSameUserEventThenReplaceOldTimestamp() = coroutineRule.runBlocking { + testee.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + val timestamp = testee.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)?.timestamp + + testee.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + + assertNotEquals(timestamp, testee.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)?.timestamp) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/useourapp/UseOurAppDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/useourapp/UseOurAppDetectorTest.kt new file mode 100644 index 000000000000..5e69dd985b64 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/global/useourapp/UseOurAppDetectorTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.useourapp + +import android.webkit.WebView +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.browser.logindetection.WebNavigationEvent +import com.duckduckgo.app.global.events.db.UserEventEntity +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.runBlocking +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class UseOurAppDetectorTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private lateinit var testee: UseOurAppDetector + + private val mockUserEventsStore: UserEventsStore = mock() + + @Before + fun setup() { + testee = UseOurAppDetector(mockUserEventsStore) + } + + @Test + fun whenCheckingIfUrlIsFromUseOurAppDomainThenReturnTrue() { + assertTrue(testee.isUseOurAppUrl("http://www.facebook.com")) + } + + @Test + fun whenCheckingIfMobileUrlIsFromUseOurAppDomainThenReturnTrue() { + assertTrue(testee.isUseOurAppUrl("http://m.facebook.com")) + } + + @Test + fun whenCheckingIfMobileOnlyDomainIsFromUseOurAppDomainThenReturnTrue() { + assertTrue(testee.isUseOurAppUrl("m.facebook.com")) + } + + @Test + fun whenCheckingIfOnlyDomainUrlIsFromUseOurAppDomainThenReturnTrue() { + assertTrue(testee.isUseOurAppUrl("facebook.com")) + } + + @Test + fun whenCheckingIfUrlIsFromUseOurAppDomainThenReturnFalse() { + assertFalse(testee.isUseOurAppUrl("http://example.com")) + } + + @Test + fun whenAllowLoginDetectionAndShortcutNotAddedThenReturnFalse() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(null) + + assertFalse(testee.allowLoginDetection(WebNavigationEvent.OnPageStarted(webView))) + } + + @Test + fun whenAllowLoginDetectionAndFireProofAlreadySeenThenReturnFalse() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)) + + assertFalse(testee.allowLoginDetection(WebNavigationEvent.OnPageStarted(webView))) + } + + @Test + fun whenAllowLoginDetectionWithOnPageStartedEventAndUrlIsUseOurAppThenReturnTrue() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(webView.url).thenReturn("http://m.facebook.com") + givenShortcutIsAddedAndFireproofNotSeen() + + assertTrue(testee.allowLoginDetection(WebNavigationEvent.OnPageStarted(webView))) + } + + @Test + fun whenAllowLoginDetectionWithOnPageStartedEventAndUrlIsNotUseOurAppThenReturnFalse() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(webView.url).thenReturn("http://example.com") + givenShortcutIsAddedAndFireproofNotSeen() + + assertFalse(testee.allowLoginDetection(WebNavigationEvent.OnPageStarted(webView))) + } + + @Test + fun whenAllowLoginDetectionWithShouldInterceptEventAndUrlIsUseOurAppThenReturnTrue() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(webView.url).thenReturn("http://m.facebook.com") + givenShortcutIsAddedAndFireproofNotSeen() + + assertTrue(testee.allowLoginDetection(WebNavigationEvent.ShouldInterceptRequest(webView, mock()))) + } + + @Test + fun whenAllowLoginDetectionWithOnShouldInterceptEventAndUrlIsNotUseOurAppThenReturnFalse() = coroutineRule.runBlocking { + val webView: WebView = mock() + whenever(webView.url).thenReturn("http://example.com") + givenShortcutIsAddedAndFireproofNotSeen() + + assertFalse(testee.allowLoginDetection(WebNavigationEvent.ShouldInterceptRequest(webView, mock()))) + } + + @Test + fun whenRegisterIfFireproofSeenForTheFirstTimeAndUrlIsUseOurAppThenRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(null) + + testee.registerIfFireproofSeenForTheFirstTime("http://m.facebook.com") + + verify(mockUserEventsStore).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + + @Test + fun whenRegisterIfFireproofSeenForTheFirstTimeAndUrlIsNotUseOurAppThenDoNotRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(null) + + testee.registerIfFireproofSeenForTheFirstTime("example.com") + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + + @Test + fun whenRegisterIfFireproofSeenForTheFirstTimeButAlreadySeenThenDoNotRegisterUserEvent() = coroutineRule.runBlocking { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)) + + testee.registerIfFireproofSeenForTheFirstTime("http://m.facebook.com") + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + + private suspend fun givenShortcutIsAddedAndFireproofNotSeen() { + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)).thenReturn(UserEventEntity(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED)) + whenever(mockUserEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN)).thenReturn(null) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt index b1c4fe1ae70c..2599c70d4a96 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt @@ -32,7 +32,10 @@ import com.duckduckgo.app.notification.NotificationScheduler.DripA1NotificationW import com.duckduckgo.app.notification.NotificationScheduler.DripA2NotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.DripB1NotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.DripB2NotificationWorker +import com.duckduckgo.app.notification.NotificationScheduler.UseOurAppNotificationWorker import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.VariantManager.VariantFeature.DripNotification @@ -45,6 +48,7 @@ import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day3ClearData import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -61,6 +65,7 @@ class AndroidNotificationSchedulerTest { var coroutinesTestRule = CoroutineTestRule() private val variantManager: VariantManager = mock() + private val mockUserStageStore: UserStageStore = mock() private val clearNotification: SchedulableNotification = mock() private val privacyNotification: SchedulableNotification = mock() private val dripA1Notification: SchedulableNotification = mock() @@ -84,7 +89,8 @@ class AndroidNotificationSchedulerTest { dripA2Notification, dripB1Notification, dripB2Notification, - variantManager + variantManager, + mockUserStageStore ) } @@ -106,7 +112,7 @@ class AndroidNotificationSchedulerTest { whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test @@ -116,7 +122,7 @@ class AndroidNotificationSchedulerTest { whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test @@ -126,7 +132,7 @@ class AndroidNotificationSchedulerTest { whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -136,7 +142,7 @@ class AndroidNotificationSchedulerTest { whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } // Drip A1 @@ -148,7 +154,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripA1NotificationWorker::class.jvmName) + assertNotificationScheduled(DripA1NotificationWorker::class.jvmName) } @Test @@ -159,7 +165,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -170,7 +176,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } @Test @@ -181,7 +187,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripA1NotificationWorker::class.jvmName) + assertNotificationScheduled(DripA1NotificationWorker::class.jvmName) } // Drip A2 @@ -193,7 +199,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripA2NotificationWorker::class.jvmName) + assertNotificationScheduled(DripA2NotificationWorker::class.jvmName) } @Test @@ -204,7 +210,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -215,7 +221,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } @Test @@ -226,7 +232,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripA2NotificationWorker::class.jvmName) + assertNotificationScheduled(DripA2NotificationWorker::class.jvmName) } // Drip B1 @@ -239,7 +245,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripB1NotificationWorker::class.jvmName) + assertNotificationScheduled(DripB1NotificationWorker::class.jvmName) } @Test @@ -250,7 +256,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -261,7 +267,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } @Test @@ -272,7 +278,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripB1NotificationWorker::class.jvmName) + assertNotificationScheduled(DripB1NotificationWorker::class.jvmName) } // Drip B2 @@ -285,7 +291,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripB2NotificationWorker::class.jvmName) + assertNotificationScheduled(DripB2NotificationWorker::class.jvmName) } @Test @@ -296,7 +302,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -307,7 +313,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } @Test @@ -318,7 +324,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(DripB2NotificationWorker::class.jvmName) + assertNotificationScheduled(DripB2NotificationWorker::class.jvmName) } // Control @@ -330,7 +336,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test @@ -341,7 +347,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test @@ -352,7 +358,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test @@ -363,7 +369,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } // Null variant @@ -379,7 +385,7 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() } @Test @@ -394,7 +400,45 @@ class AndroidNotificationSchedulerTest { testee.scheduleNextNotification() - assertNoUnusedAppNotificationScheduled() + assertNoNotificationScheduled() + } + + @Test + fun whenStageIsUseOurAppNotificationThenNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + givenStageIsUseOurAppNotification() + + testee.scheduleNextNotification() + + assertNotificationScheduled(UseOurAppNotificationWorker::class.jvmName, NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenStageIsUseOurAppNotificationAndNotificationScheduledThenStageCompleted() = runBlocking { + givenNoInactiveUserNotifications() + givenStageIsUseOurAppNotification() + + testee.scheduleNextNotification() + + verify(mockUserStageStore).stageCompleted(AppStage.USE_OUR_APP_NOTIFICATION) + } + + @Test + fun whenStageIsUseOurAppNotificationThenNoNotificationScheduled() = runBlocking { + givenStageIsEstablished() + + testee.scheduleNextNotification() + + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenStageIsNotUseOurAppNotificationThenNoNotificationScheduled() = runBlocking { + givenStageIsEstablished() + + testee.scheduleNextNotification() + + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) } private fun setDripA1Variant() { @@ -433,12 +477,27 @@ class AndroidNotificationSchedulerTest { ) } - private fun assertUnusedAppNotificationScheduled(workerName: String) { - assertTrue(getScheduledWorkers(NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG).any { it.tags.contains(workerName) }) + private suspend fun givenNoInactiveUserNotifications() { + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + } + + private suspend fun givenStageIsUseOurAppNotification() { + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.USE_OUR_APP_NOTIFICATION) + } + + private suspend fun givenStageIsEstablished() { + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.ESTABLISHED) + } + + private fun assertNotificationScheduled(workerName: String, tag: String = NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG) { + assertTrue(getScheduledWorkers(tag).any { it.tags.contains(workerName) }) } - private fun assertNoUnusedAppNotificationScheduled() { - assertTrue(getScheduledWorkers(NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG).isEmpty()) + private fun assertNoNotificationScheduled(tag: String = NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG) { + assertTrue(getScheduledWorkers(tag).isEmpty()) } private fun getScheduledWorkers(tag: String): List { diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationHandlerServiceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationHandlerServiceTest.kt index 6f1227ef632f..43c7eac4e8eb 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationHandlerServiceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationHandlerServiceTest.kt @@ -19,20 +19,32 @@ package com.duckduckgo.app.notification import android.content.Intent import androidx.core.app.NotificationManagerCompat import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.notification.NotificationHandlerService.Companion.PIXEL_SUFFIX_EXTRA import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CANCEL import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CLEAR_DATA_LAUNCH +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.USE_OUR_APP +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Before +import org.junit.Rule import org.junit.Test +@ExperimentalCoroutinesApi class NotificationHandlerServiceTest { + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + private var mockPixel: Pixel = mock() + private var mockUserStageStore: UserStageStore = mock() private var testee = NotificationHandlerService() private val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -41,6 +53,8 @@ class NotificationHandlerServiceTest { testee.pixel = mockPixel testee.context = context testee.notificationManager = NotificationManagerCompat.from(context) + testee.userStageStore = mockUserStageStore + testee.dispatcher = coroutinesTestRule.testDispatcherProvider } @Test @@ -60,4 +74,22 @@ class NotificationHandlerServiceTest { testee.onHandleIntent(intent) verify(mockPixel).fire(eq("mnot_c_abc"), any(), any()) } + + @Test + fun whenIntentIsUseOurAppThenCorrespondingPixelIsFired() { + val intent = Intent(context, NotificationHandlerService::class.java) + intent.type = USE_OUR_APP + intent.putExtra(PIXEL_SUFFIX_EXTRA, "abc") + testee.onHandleIntent(intent) + verify(mockPixel).fire(eq("mnot_l_abc"), any(), any()) + } + + @Test + fun whenIntentIsUseOurAppThenRegisterInUseOurAppOnboardingStage() = coroutinesTestRule.runBlocking { + val intent = Intent(context, NotificationHandlerService::class.java) + intent.type = USE_OUR_APP + intent.putExtra(PIXEL_SUFFIX_EXTRA, "abc") + testee.onHandleIntent(intent) + verify(mockUserStageStore).moveToStage(AppStage.USE_OUR_APP_ONBOARDING) + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/model/UseOurAppNotificationTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/model/UseOurAppNotificationTest.kt new file mode 100644 index 000000000000..88e55d3f2aa9 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/model/UseOurAppNotificationTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.notification.model + +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.notification.db.NotificationDao +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class UseOurAppNotificationTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val notificationsDao: NotificationDao = mock() + + private lateinit var testee: UseOurAppNotification + + @Before + fun before() { + testee = UseOurAppNotification(context, notificationsDao) + } + + @Test + fun whenNotificationNotSeenThenCanShowIsTrue() = runBlocking { + whenever(notificationsDao.exists(any())).thenReturn(false) + assertTrue(testee.canShow()) + } + + @Test + fun whenNotificationAlreadySeenThenCanShowIsFalse() = runBlocking { + whenever(notificationsDao.exists(any())).thenReturn(true) + assertFalse(testee.canShow()) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt b/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt index 4ddec2a19abb..554b8aa6e6d8 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt @@ -19,11 +19,14 @@ package com.duckduckgo.app.onboarding.store import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.runBlocking import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +@ExperimentalCoroutinesApi class AppUserStageStoreTest { @get:Rule @@ -60,6 +63,24 @@ class AppUserStageStoreTest { assertEquals(AppStage.ESTABLISHED, nextStage) } + @Test + fun whenStageUseOurAppNotificationCompletedThenStageEstablishedReturned() = coroutineRule.runBlocking { + givenCurrentStage(AppStage.USE_OUR_APP_NOTIFICATION) + + val nextStage = testee.stageCompleted(AppStage.USE_OUR_APP_NOTIFICATION) + + assertEquals(AppStage.ESTABLISHED, nextStage) + } + + @Test + fun whenStageUseOurAppOnboardingCompletedThenStageEstablishedReturned() = coroutineRule.runBlocking { + givenCurrentStage(AppStage.USE_OUR_APP_ONBOARDING) + + val nextStage = testee.stageCompleted(AppStage.USE_OUR_APP_ONBOARDING) + + assertEquals(AppStage.ESTABLISHED, nextStage) + } + @Test fun whenStageEstablishedCompletedThenStageEstablishedReturned() = coroutineRule.runBlocking { givenCurrentStage(AppStage.ESTABLISHED) @@ -69,6 +90,12 @@ class AppUserStageStoreTest { assertEquals(AppStage.ESTABLISHED, nextStage) } + @Test + fun whenMoveToStageThenUpdateUserStageInDao() = coroutineRule.runBlocking { + testee.moveToStage(AppStage.USE_OUR_APP_ONBOARDING) + verify(userStageDao).updateUserStage(AppStage.USE_OUR_APP_ONBOARDING) + } + private suspend fun givenCurrentStage(appStage: AppStage) { whenever(userStageDao.currentUserAppStage()).thenReturn(UserStage(appStage = appStage)) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt index 7eb604824a7c..2807e5d9583d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt @@ -20,10 +20,14 @@ package com.duckduckgo.app.tabs.model import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData +import androidx.room.Room import androidx.test.annotation.UiThreadTest +import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.InstantSchedulersRule +import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister +import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.privacy.model.PrivacyPractices @@ -229,6 +233,43 @@ class TabDataRepositoryTest { assertTrue(captor.firstValue.position == 1) } + @Test + fun whenSelectByUrlOrNewTabIfUrlAlreadyExistedInATabThenSelectTheTab() = runBlocking { + val db = createDatabase() + val dao = db.tabsDao() + dao.insertTab(TabEntity(tabId = "id", url = "http://www.example.com", skipHome = false, viewed = true, position = 0)) + + testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister) + + testee.selectByUrlOrNewTab("http://www.example.com") + + val value = testee.liveSelectedTab.blockingObserve()?.tabId + assertEquals("id", value) + + db.close() + } + + @Test + fun whenSelectByUrlOrNewTabIfUrlNotExistedInATabThenAddNewTab() = runBlocking { + val db = createDatabase() + val dao = db.tabsDao() + + testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister) + + testee.selectByUrlOrNewTab("http://www.example.com") + + val value = testee.liveSelectedTab.blockingObserve()?.url + assertEquals("http://www.example.com", value) + + db.close() + } + + private fun createDatabase(): AppDatabase { + return Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + } + companion object { const val TAB_ID = "abcd" } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index bd2a483dfca9..f7ae7d72d72d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -32,6 +32,7 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command.Refresh import com.duckduckgo.app.browser.rating.ui.AppEnjoymentDialogFragment import com.duckduckgo.app.browser.rating.ui.GiveFeedbackDialogFragment import com.duckduckgo.app.browser.rating.ui.RateAppDialogFragment +import com.duckduckgo.app.browser.shortcut.ShortcutBuilder import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel @@ -205,9 +206,14 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { val sharedText = intent.intentText if (sharedText != null) { - Timber.w("opening in new tab requested for $sharedText") - launch { viewModel.onOpenInNewTabRequested(sharedText, true) } - return + if (intent.getBooleanExtra(ShortcutBuilder.SHORTCUT_EXTRA_ARG, false)) { + Timber.d("Shortcut opened with url $sharedText") + launch { viewModel.onOpenShortcut(sharedText) } + } else { + Timber.w("opening in new tab requested for $sharedText") + launch { viewModel.onOpenInNewTabRequested(sharedText, true) } + return + } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 6d0314188731..79bba56e4bdb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -44,7 +44,6 @@ import androidx.annotation.AnyThread import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat -import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.view.* @@ -366,8 +365,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } private fun addHomeShortcut(homeShortcut: Command.AddHomeShortcut, context: Context) { - val shortcutInfo = shortcutBuilder.buildPinnedPageShortcut(context, homeShortcut) - ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null) + shortcutBuilder.requestPinShortcut(context, homeShortcut) } private fun configureObservers() { @@ -431,7 +429,6 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi private fun showHome() { errorSnackbar.dismiss() newTabLayout.show() - showKeyboardImmediately() appBarLayout.setExpanded(true) webView?.onPause() webView?.hide() @@ -1212,6 +1209,10 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi viewModel.onUserClickCtaOkButton() } + override fun onDaxDialogSecondaryCtaClick() { + viewModel.onUserClickCtaSecondaryButton() + } + private fun launchHideTipsDialog(context: Context, cta: Cta) { AlertDialog.Builder(context) .setTitle(R.string.hideTipsTitle) @@ -1615,14 +1616,14 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi when (configuration) { is HomePanelCta -> showHomeCta(configuration) is DaxBubbleCta -> showDaxCta(configuration) - is DaxDialogCta -> showDaxDialogCta(configuration) + is DialogCta -> showDaxDialogCta(configuration) is HomeTopPanelCta -> showHomeTopCta(configuration) } viewModel.onCtaShown() } - private fun showDaxDialogCta(configuration: DaxDialogCta) { + private fun showDaxDialogCta(configuration: DialogCta) { hideHomeCta() hideDaxCta() activity?.let { activity -> diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 7ebae5877891..b77e991ee24c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -60,7 +60,13 @@ import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment.HttpAuthenticationListener -import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.cta.ui.Cta +import com.duckduckgo.app.cta.ui.CtaViewModel +import com.duckduckgo.app.cta.ui.DaxDialogCta +import com.duckduckgo.app.cta.ui.DialogCta +import com.duckduckgo.app.cta.ui.HomePanelCta +import com.duckduckgo.app.cta.ui.HomeTopPanelCta +import com.duckduckgo.app.cta.ui.UseOurAppCta import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.* @@ -68,6 +74,14 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.domainMatchesUrl +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.toDesktopUri +import com.duckduckgo.app.global.useourapp.UseOurAppDetector +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_TITLE +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL +import com.duckduckgo.app.notification.db.NotificationDao +import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.PrivacyGrade @@ -117,6 +131,9 @@ class BrowserTabViewModel( private val searchCountDao: SearchCountDao, private val pixel: Pixel, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), + private val userEventsStore: UserEventsStore, + private val notificationDao: NotificationDao, + private val useOurAppDetector: UseOurAppDetector, private val variantManager: VariantManager ) : WebViewClientListener, EditBookmarkListener, HttpAuthenticationListener, ViewModel() { @@ -272,6 +289,8 @@ class BrowserTabViewModel( private val loginDetectionObserver = Observer { loginEvent -> Timber.i("LoginDetection for $loginEvent") + viewModelScope.launch { useOurAppDetector.registerIfFireproofSeenForTheFirstTime(loginEvent.forwardedToDomain) } + if (!isFireproofWebsite(loginEvent.forwardedToDomain)) { pixel.fire(PixelName.FIREPROOF_LOGIN_DIALOG_SHOWN) command.value = AskToFireproofWebsite(FireproofWebsiteEntity(loginEvent.forwardedToDomain)) @@ -296,6 +315,7 @@ class BrowserTabViewModel( fun onViewReady() { url?.let { + sendPixelIfUseOurAppSiteVisitedFirstTime(it) onUserSubmittedQuery(it) } } @@ -357,7 +377,6 @@ class BrowserTabViewModel( } fun onViewResumed() { - command.value = if (!currentBrowserViewState().browserShowing) ShowKeyboard else HideKeyboard if (currentGlobalLayoutState() is Invalidated && currentBrowserViewState().browserShowing) { showErrorWithAction() } @@ -366,7 +385,12 @@ class BrowserTabViewModel( fun onViewVisible() { // we expect refreshCta to be called when a site is fully loaded if browsingShowing -trackers data available-. if (!currentBrowserViewState().browserShowing) { - refreshCta() + viewModelScope.launch { + val cta = refreshCta() + showOrHideKeyboard(cta) // we hide the keyboard when showing a DialogCta type in the home screen otherwise we show it + } + } else { + command.value = HideKeyboard } } @@ -525,6 +549,7 @@ class BrowserTabViewModel( return true } else if (!skipHome) { navigateHome() + command.value = ShowKeyboard return true } @@ -585,7 +610,15 @@ class BrowserTabViewModel( private fun pageChanged(url: String, title: String?) { Timber.v("Page changed: $url") + val previousUrl = site?.url + buildSiteFactory(url, title) + + // Navigating from different website to use our app website + if (!useOurAppDetector.isUseOurAppUrl(previousUrl)) { + sendPixelIfUseOurAppSiteVisitedFirstTime(url) + } + command.value = RefreshUserAgent(site?.uri?.host, currentBrowserViewState().isDesktopBrowsingMode) val currentOmnibarViewState = currentOmnibarViewState() @@ -624,6 +657,26 @@ class BrowserTabViewModel( registerSiteVisit() } + private fun sendPixelIfUseOurAppSiteVisitedFirstTime(url: String) { + if (useOurAppDetector.isUseOurAppUrl(url)) { + viewModelScope.launch { sendUseOurAppSiteVisitedPixel() } + } + } + + private suspend fun sendUseOurAppSiteVisitedPixel() { + withContext(dispatchers.io()) { + val isShortcutAdded = userEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + val isUseOurAppNotificationSeen = notificationDao.exists(UseOurAppNotification.ID) + val deleteCtaShown = ctaViewModel.useOurAppDeletionDialogShown() + + when { + deleteCtaShown -> pixel.fire(PixelName.UOA_VISITED_AFTER_DELETE_CTA) + isShortcutAdded != null -> pixel.fire(PixelName.UOA_VISITED_AFTER_SHORTCUT) + isUseOurAppNotificationSeen -> pixel.fire(PixelName.UOA_VISITED_AFTER_NOTIFICATION) + } + } + } + private fun shouldShowDaxIcon(currentUrl: String?, showPrivacyGrade: Boolean): Boolean { if (!variantManager.getVariant().hasFeature(VariantManager.VariantFeature.SerpHeaderRemoval)) { return false @@ -1144,7 +1197,9 @@ class BrowserTabViewModel( fun onSurveyChanged(survey: Survey?) { val activeSurvey = ctaViewModel.onSurveyChanged(survey) if (activeSurvey != null) { - refreshCta() + viewModelScope.launch { + refreshCta() + } } } @@ -1157,15 +1212,19 @@ class BrowserTabViewModel( ctaViewModel.onCtaShown(cta) } - fun refreshCta() { + suspend fun refreshCta(): Cta? { if (currentGlobalLayoutState() is Browser) { - viewModelScope.launch { - val cta = withContext(dispatchers.io()) { - ctaViewModel.refreshCta(dispatchers.io(), currentBrowserViewState().browserShowing, siteLiveData.value) - } - ctaViewState.value = currentCtaViewState().copy(cta = cta) + val cta = withContext(dispatchers.io()) { + ctaViewModel.refreshCta(dispatchers.io(), currentBrowserViewState().browserShowing, siteLiveData.value) } + ctaViewState.value = currentCtaViewState().copy(cta = cta) + return cta } + return null + } + + private fun showOrHideKeyboard(cta: Cta?) { + command.value = if (cta is DialogCta) HideKeyboard else ShowKeyboard } fun registerDaxBubbleCtaDismissed() { @@ -1185,12 +1244,24 @@ class BrowserTabViewModel( is HomePanelCta.Survey -> LaunchSurvey(cta.survey) is HomePanelCta.AddWidgetAuto -> LaunchAddWidget is HomePanelCta.AddWidgetInstructions -> LaunchLegacyAddWidget + is UseOurAppCta -> navigateToUrlAndLaunchShortcut(url = USE_OUR_APP_SHORTCUT_URL, title = USE_OUR_APP_SHORTCUT_TITLE) else -> return } } - fun onUserClickCtaSecondaryButton(cta: SecondaryButtonCta) { - ctaViewModel.onUserClickCtaSecondaryButton(cta) + private fun navigateToUrlAndLaunchShortcut(url: String, title: String): AddHomeShortcut { + onUserSubmittedQuery(url) + return AddHomeShortcut(title, url) + } + + fun onUserClickCtaSecondaryButton() { + viewModelScope.launch { + val cta = currentCtaViewState().cta ?: return@launch + ctaViewModel.onUserDismissedCta(cta) + if (cta is UseOurAppCta) { + command.value = ShowKeyboard + } + } } fun onUserHideDaxDialog() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 6b80b16988a9..36f50fdb69f3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -27,14 +27,20 @@ import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.rating.ui.AppEnjoymentDialogFragment import com.duckduckgo.app.browser.rating.ui.GiveFeedbackDialogFragment import com.duckduckgo.app.browser.rating.ui.RateAppDialogFragment +import com.duckduckgo.app.cta.db.DismissedCtaDao +import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.global.ApplicationClearDataState +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder import com.duckduckgo.app.global.rating.PromptCount +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL import com.duckduckgo.app.privacy.ui.PrivacyDashboardActivity.Companion.RELOAD_RESULT_CODE +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import kotlinx.coroutines.CoroutineScope @@ -48,7 +54,10 @@ class BrowserViewModel( private val queryUrlConverter: OmnibarEntryConverter, private val dataClearer: DataClearer, private val appEnjoymentPromptEmitter: AppEnjoymentPromptEmitter, - private val appEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder + private val appEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder, + private val ctaDao: DismissedCtaDao, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), + private val pixel: Pixel ) : AppEnjoymentDialogFragment.Listener, RateAppDialogFragment.Listener, GiveFeedbackDialogFragment.Listener, @@ -197,4 +206,15 @@ class BrowserViewModel( override fun onUserCancelledGiveFeedbackDialog(promptCount: PromptCount) { onUserDeclinedToGiveFeedback(promptCount) } + + fun onOpenShortcut(url: String) { + launch(dispatchers.io()) { + tabRepository.selectByUrlOrNewTab(queryUrlConverter.convertQueryToUrl(url)) + if (ctaDao.exists(CtaId.USE_OUR_APP) && url == USE_OUR_APP_SHORTCUT_URL) { + pixel.fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_OPENED) + } else { + pixel.fire(Pixel.PixelName.SHORTCUT_OPENED) + } + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index 7cbcdec8d694..376ac08a0d85 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -45,6 +45,7 @@ import com.duckduckgo.app.global.device.DeviceInfo import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.httpsupgrade.HttpsUpgrader import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.referral.AppReferrerDataStore @@ -220,8 +221,8 @@ class BrowserModule { } @Provides - fun domLoginDetector(settingsDataStore: SettingsDataStore): DOMLoginDetector { - return JsLoginDetector(settingsDataStore) + fun domLoginDetector(settingsDataStore: SettingsDataStore, useOurAppDetector: UseOurAppDetector): DOMLoginDetector { + return JsLoginDetector(settingsDataStore, useOurAppDetector) } @Provides diff --git a/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt index 7e1f52553e6e..0ebd5c8936ed 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt @@ -22,6 +22,7 @@ import android.webkit.WebView import androidx.annotation.UiThread import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.logindetection.LoginDetectionJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.settings.db.SettingsDataStore import timber.log.Timber import javax.inject.Inject @@ -36,7 +37,8 @@ sealed class WebNavigationEvent { data class ShouldInterceptRequest(val webView: WebView, val request: WebResourceRequest) : WebNavigationEvent() } -class JsLoginDetector @Inject constructor(private val settingsDataStore: SettingsDataStore) : DOMLoginDetector { +class JsLoginDetector @Inject constructor(private val settingsDataStore: SettingsDataStore, private val useOurAppDetector: UseOurAppDetector) : + DOMLoginDetector { private val javaScriptDetector = JavaScriptDetector() private val loginPathRegex = Regex("login|sign-in|signin|sessions") @@ -46,7 +48,7 @@ class JsLoginDetector @Inject constructor(private val settingsDataStore: Setting @UiThread override fun onEvent(event: WebNavigationEvent) { - if (settingsDataStore.appLoginDetection) { + if (settingsDataStore.appLoginDetection || useOurAppDetector.allowLoginDetection(event)) { when (event) { is WebNavigationEvent.OnPageStarted -> injectLoginFormDetectionJS(event.webView) is WebNavigationEvent.ShouldInterceptRequest -> { diff --git a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutBuilder.kt b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutBuilder.kt index 6b00cf252833..5fefe3ac6ce2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutBuilder.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutBuilder.kt @@ -16,27 +16,32 @@ package com.duckduckgo.app.browser.shortcut +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.BrowserTabViewModel import com.duckduckgo.app.browser.R -import java.util.* +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL +import java.util.UUID import javax.inject.Inject class ShortcutBuilder @Inject constructor() { - fun buildPinnedPageShortcut(context: Context, homeShortcut: BrowserTabViewModel.Command.AddHomeShortcut): ShortcutInfoCompat { + private fun buildPinnedPageShortcut(context: Context, homeShortcut: BrowserTabViewModel.Command.AddHomeShortcut): ShortcutInfoCompat { val intent = Intent(context, BrowserActivity::class.java) intent.action = Intent.ACTION_VIEW intent.putExtra(Intent.EXTRA_TEXT, homeShortcut.url) + intent.putExtra(SHORTCUT_EXTRA_ARG, true) - val icon = if (homeShortcut.icon != null) { - IconCompat.createWithBitmap(homeShortcut.icon) - } else { - IconCompat.createWithResource(context, R.drawable.logo_mini) + val icon = when { + homeShortcut.url == USE_OUR_APP_SHORTCUT_URL -> IconCompat.createWithResource(context, R.drawable.ic_fb_favicon) + homeShortcut.icon != null -> IconCompat.createWithBitmap(homeShortcut.icon) + else -> IconCompat.createWithResource(context, R.drawable.logo_mini) } return ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString()) @@ -45,4 +50,27 @@ class ShortcutBuilder @Inject constructor() { .setIcon(icon) .build() } + + private fun buildPendingIntent(context: Context, url: String, title: String): PendingIntent? { + val pinnedShortcutCallbackIntent = Intent(USE_OUR_APP_SHORTCUT_ADDED_ACTION) + pinnedShortcutCallbackIntent.putExtra(SHORTCUT_URL_ARG, url) + pinnedShortcutCallbackIntent.putExtra(SHORTCUT_TITLE_ARG, title) + return PendingIntent.getBroadcast(context, USE_OUR_APP_SHORTCUT_ADDED_CODE, pinnedShortcutCallbackIntent, FLAG_UPDATE_CURRENT) + } + + fun requestPinShortcut(context: Context, homeShortcut: BrowserTabViewModel.Command.AddHomeShortcut) { + val shortcutInfo = buildPinnedPageShortcut(context, homeShortcut) + val pendingIntent = buildPendingIntent(context, homeShortcut.url, homeShortcut.title) + + ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, pendingIntent?.intentSender) + } + + companion object { + const val USE_OUR_APP_SHORTCUT_ADDED_ACTION: String = "useOurAppShortcutAdded" + const val USE_OUR_APP_SHORTCUT_ADDED_CODE = 9000 + + const val SHORTCUT_EXTRA_ARG = "shortCutAdded" + const val SHORTCUT_URL_ARG = "shortcutUrl" + const val SHORTCUT_TITLE_ARG = "shortcutTitle" + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt new file mode 100644 index 000000000000..f7354be5fa05 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.browser.shortcut + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.Toast +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.shortcut.ShortcutBuilder.Companion.SHORTCUT_TITLE_ARG +import com.duckduckgo.app.browser.shortcut.ShortcutBuilder.Companion.SHORTCUT_URL_ARG +import com.duckduckgo.app.cta.db.DismissedCtaDao +import com.duckduckgo.app.cta.model.CtaId +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL +import com.duckduckgo.app.statistics.pixels.Pixel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ShortcutReceiver @Inject constructor( + private val userEventsStore: UserEventsStore, + private val ctaDao: DismissedCtaDao, + private val dispatcher: DispatcherProvider, + private val pixel: Pixel +) : + BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + val originUrl = intent?.getStringExtra(SHORTCUT_URL_ARG) + val title = intent?.getStringExtra(SHORTCUT_TITLE_ARG) + + if (!IGNORE_MANUFACTURERS_LIST.contains(Build.MANUFACTURER)) { + context?.let { + Toast.makeText(it, it.getString(R.string.useOurAppShortcutAddedText, title), Toast.LENGTH_SHORT).show() + } + } + + GlobalScope.launch(dispatcher.io()) { + if (ctaDao.exists(CtaId.USE_OUR_APP) && originUrl == USE_OUR_APP_SHORTCUT_URL) { + userEventsStore.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + pixel.fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) + } else { + pixel.fire(Pixel.PixelName.SHORTCUT_ADDED) + } + } + } + + companion object { + val IGNORE_MANUFACTURERS_LIST = listOf("samsung", "huawei") + } +} diff --git a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt index e5edb06269e7..faf0f82adf40 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt @@ -28,7 +28,9 @@ enum class CtaId { DAX_DIALOG_TRACKERS_FOUND, DAX_DIALOG_NETWORK, DAX_DIALOG_OTHER, - DAX_END + DAX_END, + USE_OUR_APP, + USE_OUR_APP_DELETION } @Entity( diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index 27a817ee4bb4..c48d26969995 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -68,10 +68,54 @@ interface Cta { fun pixelOkParameters(): Map } -interface SecondaryButtonCta { - val secondaryButtonPixel: Pixel.PixelName? +class UseOurAppCta( + @StringRes val text: Int = R.string.useOurAppDialogText, + @StringRes val okButton: Int = R.string.useOurAppDialogButtonText, + @StringRes val cancelButton: Int = R.string.useOurAppDialogCancelButtonText, + override val ctaId: CtaId = CtaId.USE_OUR_APP, + override val shownPixel: Pixel.PixelName? = Pixel.PixelName.USE_OUR_APP_DIALOG_SHOWN, + override val okPixel: Pixel.PixelName? = Pixel.PixelName.USE_OUR_APP_DIALOG_OK, + override val cancelPixel: Pixel.PixelName? = null +) : Cta, DialogCta { - fun pixelSecondaryButtonParameters(): Map + override fun createCta(activity: FragmentActivity): DaxDialog = + TypewriterDaxDialog.newInstance( + daxText = activity.resources.getString(text), + primaryButtonText = activity.resources.getString(okButton), + secondaryButtonText = activity.resources.getString(cancelButton), + dismissible = false, + showHideButton = false + ) + + override fun pixelCancelParameters(): Map = emptyMap() + + override fun pixelOkParameters(): Map = emptyMap() + + override fun pixelShownParameters(): Map = emptyMap() +} + +class UseOurAppDeletionCta( + @StringRes val text: Int = R.string.useOurAppDeletionDialogText, + @StringRes val okButton: Int = R.string.daxDialogGotIt, + override val ctaId: CtaId = CtaId.USE_OUR_APP_DELETION, + override val shownPixel: Pixel.PixelName? = Pixel.PixelName.USE_OUR_APP_DIALOG_DELETE_SHOWN, + override val okPixel: Pixel.PixelName? = null, + override val cancelPixel: Pixel.PixelName? = null +) : Cta, DialogCta { + + override fun createCta(activity: FragmentActivity): DaxDialog = + TypewriterDaxDialog.newInstance( + daxText = activity.resources.getString(text), + primaryButtonText = activity.resources.getString(okButton), + dismissible = false, + showHideButton = false + ) + + override fun pixelCancelParameters(): Map = emptyMap() + + override fun pixelOkParameters(): Map = emptyMap() + + override fun pixelShownParameters(): Map = emptyMap() } sealed class DaxDialogCta( diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 061ae9d0f9ed..529ed2ea4fcf 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -28,13 +28,16 @@ import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.orderedTrackingEntities +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.store.daxOnboardingActive +import com.duckduckgo.app.onboarding.store.useOurAppOnboarding import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.settings.db.SettingsDataStore -import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.survey.db.SurveyDao @@ -42,6 +45,7 @@ import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.widget.ui.WidgetCapabilities import kotlinx.coroutines.withContext import timber.log.Timber +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext @@ -58,6 +62,8 @@ class CtaViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val onboardingStore: OnboardingStore, private val userStageStore: UserStageStore, + private val userEventsStore: UserEventsStore, + private val useOurAppDetector: UseOurAppDetector, private val dispatchers: DispatcherProvider ) { val surveyLiveData: LiveData = surveyDao.getLiveScheduled() @@ -111,6 +117,13 @@ class CtaViewModel @Inject constructor( } } + private suspend fun completeStageIfUserInUseOurAppCompleted() { + if (useOurAppActive()) { + Timber.d("Completing USE OUR APP ONBOARDING") + userStageStore.stageCompleted(AppStage.USE_OUR_APP_ONBOARDING) + } + } + suspend fun onUserDismissedCta(cta: Cta) { withContext(dispatchers.io()) { cta.cancelPixel?.let { @@ -124,6 +137,7 @@ class CtaViewModel @Inject constructor( dismissedCtaDao.insert(DismissedCta(cta.ctaId)) } + completeStageIfUserInUseOurAppCompleted() completeStageIfDaxOnboardingCompleted() } } @@ -134,12 +148,6 @@ class CtaViewModel @Inject constructor( } } - fun onUserClickCtaSecondaryButton(cta: SecondaryButtonCta) { - cta.secondaryButtonPixel?.let { - pixel.fire(it, cta.pixelSecondaryButtonParameters()) - } - } - suspend fun refreshCta(dispatcher: CoroutineContext, isBrowserShowing: Boolean, site: Site? = null): Cta? { surveyCta()?.let { return it @@ -162,6 +170,9 @@ class CtaViewModel @Inject constructor( canShowDaxCtaEndOfJourney() -> { DaxBubbleCta.DaxEndCta(onboardingStore, appInstallStore) } + canShowUseOurAppDialog() -> { + UseOurAppCta() + } canShowWidgetCta() -> { if (widgetCapabilities.supportsAutomaticWidgetAdd) AddWidgetAuto else AddWidgetInstructions } @@ -174,6 +185,7 @@ class CtaViewModel @Inject constructor( canShowDaxDialogCta() -> { getDaxDialogCta(site) } + canShowUseOurAppDeletionDialog(site) -> UseOurAppDeletionCta() else -> null } } @@ -190,6 +202,20 @@ class CtaViewModel @Inject constructor( return null } + @WorkerThread + private suspend fun canShowUseOurAppDeletionDialog(site: Site?): Boolean = + !settingsDataStore.hideTips && !useOurAppDeletionDialogShown() && useOurAppDetector.isUseOurAppUrl(site?.url) && twoDaysSinceShortcutAdded() + + @WorkerThread + private suspend fun twoDaysSinceShortcutAdded(): Boolean { + val timestampKey = userEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) ?: return false + val days = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - timestampKey.timestamp) + return (days >= 2) + } + + @WorkerThread + private suspend fun canShowUseOurAppDialog(): Boolean = !settingsDataStore.hideTips && useOurAppActive() && !useOurAppDialogShown() + @WorkerThread private fun canShowWidgetCta(): Boolean { return widgetCapabilities.supportsStandardWidgetAdd && @@ -248,7 +274,9 @@ class CtaViewModel @Inject constructor( } } - private fun variant(): Variant = variantManager.getVariant() + fun useOurAppDeletionDialogShown(): Boolean = dismissedCtaDao.exists(CtaId.USE_OUR_APP_DELETION) + + private fun useOurAppDialogShown(): Boolean = dismissedCtaDao.exists(CtaId.USE_OUR_APP) private fun daxDialogIntroShown(): Boolean = dismissedCtaDao.exists(CtaId.DAX_INTRO) @@ -264,6 +292,8 @@ class CtaViewModel @Inject constructor( private fun isSerpUrl(url: String): Boolean = url.contains(DaxDialogCta.SERP) + private suspend fun useOurAppActive(): Boolean = userStageStore.useOurAppOnboarding() + private suspend fun daxOnboardingActive(): Boolean = userStageStore.daxOnboardingActive() private suspend fun allOnboardingCtasShown(): Boolean { diff --git a/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt b/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt index 1d56596f8b16..1dfa77c1171d 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt @@ -32,10 +32,12 @@ import com.duckduckgo.app.notification.NotificationScheduler.DripA1NotificationW import com.duckduckgo.app.notification.NotificationScheduler.DripA2NotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.DripB1NotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.DripB2NotificationWorker +import com.duckduckgo.app.notification.NotificationScheduler.UseOurAppNotificationWorker import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.AppFeatureNotification import com.duckduckgo.app.notification.model.WebsiteNotification import com.duckduckgo.app.notification.model.ClearDataNotification +import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.OfflinePixelScheduler @@ -52,6 +54,7 @@ class DaggerWorkerFactory( private val notificationFactory: NotificationFactory, private val clearDataNotification: ClearDataNotification, private val privacyProtectionNotification: PrivacyProtectionNotification, + private val useOurAppNotification: UseOurAppNotification, private val configurationDownloader: ConfigurationDownloader, private val dripA1Notification: WebsiteNotification, private val dripA2Notification: WebsiteNotification, @@ -77,6 +80,7 @@ class DaggerWorkerFactory( is DripA2NotificationWorker -> injectDripA2NotificationWorker(instance) is DripB1NotificationWorker -> injectDripB1NotificationWorker(instance) is DripB2NotificationWorker -> injectDripB2NotificationWorker(instance) + is UseOurAppNotificationWorker -> injectUseOurAppNotificationWorker(instance) else -> Timber.i("No injection required for worker $workerClassName") } @@ -148,4 +152,12 @@ class DaggerWorkerFactory( worker.pixel = pixel worker.notification = dripB2Notification } + + private fun injectUseOurAppNotificationWorker(worker: UseOurAppNotificationWorker) { + worker.manager = notificationManager + worker.notificationDao = notificationDao + worker.factory = notificationFactory + worker.pixel = pixel + worker.notification = useOurAppNotification + } } diff --git a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt index 134af216b55f..383b9a8f5357 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -82,4 +82,7 @@ class DaoModule { @Provides fun fireproofWebsiteDao(database: AppDatabase) = database.fireproofWebsiteDao() + + @Provides + fun userEventsDao(database: AppDatabase) = database.userEventsDao() } diff --git a/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt b/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt index 02947e17e4aa..088f39bb3bc2 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt @@ -18,8 +18,11 @@ package com.duckduckgo.app.di import android.content.Context import androidx.room.Room +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.db.MigrationsProvider +import com.duckduckgo.app.global.useourapp.UseOurAppMigrationManager +import com.duckduckgo.app.settings.db.SettingsDataStore import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -36,7 +39,12 @@ class DatabaseModule { } @Provides - fun provideDatabaseMigrations(context: Context): MigrationsProvider { - return MigrationsProvider(context) + fun provideDatabaseMigrations( + context: Context, + settingsDataStore: SettingsDataStore, + addToHomeCapabilityDetector: AddToHomeCapabilityDetector, + useOurAppMigrationManager: UseOurAppMigrationManager + ): MigrationsProvider { + return MigrationsProvider(context, settingsDataStore, addToHomeCapabilityDetector, useOurAppMigrationManager) } } diff --git a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt index b4e0ff7b0c46..e67f7f83776b 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt @@ -28,8 +28,10 @@ import com.duckduckgo.app.notification.NotificationScheduler import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.AppFeatureNotification import com.duckduckgo.app.notification.model.ClearDataNotification +import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.notification.model.WebsiteNotification +import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.VariantManager @@ -59,6 +61,14 @@ class NotificationModule { return LocalBroadcastManager.getInstance(context) } + @Provides + fun provideUseOurAppNotification( + context: Context, + notificationDao: NotificationDao + ): UseOurAppNotification { + return UseOurAppNotification(context, notificationDao) + } + @Provides fun provideClearDataNotification( context: Context, @@ -150,7 +160,8 @@ class NotificationModule { @Named("dripA2Notification") dripA2Notification: WebsiteNotification, @Named("dripB1Notification") dripB1Notification: AppFeatureNotification, @Named("dripB2Notification") dripB2Notification: AppFeatureNotification, - variantManager: VariantManager + variantManager: VariantManager, + stageStore: UserStageStore ): AndroidNotificationScheduler { return NotificationScheduler( workManager, @@ -160,7 +171,8 @@ class NotificationModule { dripA2Notification, dripB1Notification, dripB2Notification, - variantManager + variantManager, + stageStore ) } diff --git a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt index dd6562647d54..d3b31a68de4e 100644 --- a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt @@ -20,6 +20,8 @@ import com.duckduckgo.app.fire.UnsentForgetAllPixelStore import com.duckduckgo.app.fire.UnsentForgetAllPixelStoreSharedPreferences import com.duckduckgo.app.global.install.AppInstallSharedPreferences import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.global.events.db.AppUserEventsStore +import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.onboarding.store.AppUserStageStore import com.duckduckgo.app.onboarding.store.OnboardingSharedPreferences import com.duckduckgo.app.onboarding.store.OnboardingStore @@ -60,5 +62,8 @@ abstract class StoreModule { abstract fun bindOfflinePixelDataStore(store: OfflinePixelCountSharedPreferences): OfflinePixelCountDataStore @Binds - abstract fun bindUserStageStore(userStageDao: AppUserStageStore): UserStageStore + abstract fun bindUserStageStore(userStageStore: AppUserStageStore): UserStageStore + + @Binds + abstract fun bindUserEventsStore(userEventsStore: AppUserEventsStore): UserEventsStore } diff --git a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt index 6d216e7ff295..fc5d0a5b2f7e 100644 --- a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.di +import com.duckduckgo.app.global.useourapp.UseOurAppMigrationManager import com.duckduckgo.app.statistics.ExperimentationVariantManager import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.WeightedRandomizer @@ -34,4 +35,10 @@ class VariantModule { @Provides fun weightedRandomizer() = WeightedRandomizer() + + @Provides + @Singleton + fun useOurAppMigrationManager(weightedRandomizer: WeightedRandomizer): UseOurAppMigrationManager { + return UseOurAppMigrationManager(weightedRandomizer) + } } diff --git a/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt b/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt index c869ff47e042..b87c915621d8 100644 --- a/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt @@ -28,6 +28,7 @@ import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.AppFeatureNotification import com.duckduckgo.app.notification.model.WebsiteNotification import com.duckduckgo.app.notification.model.ClearDataNotification +import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.OfflinePixelSender @@ -61,6 +62,7 @@ class WorkerModule { notificationFactory: NotificationFactory, clearDataNotification: ClearDataNotification, privacyProtectionNotification: PrivacyProtectionNotification, + useOurAppNotification: UseOurAppNotification, configurationDownloader: ConfigurationDownloader, @Named("dripA1Notification") dripA1Notification: WebsiteNotification, @Named("dripA2Notification") dripA2Notification: WebsiteNotification, @@ -77,6 +79,7 @@ class WorkerModule { notificationFactory, clearDataNotification, privacyProtectionNotification, + useOurAppNotification, configurationDownloader, dripA1Notification, dripA2Notification, diff --git a/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt b/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt index 729622256a10..361e2cd36b8c 100644 --- a/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt +++ b/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.global import android.app.Application +import android.content.IntentFilter import android.os.Build import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver @@ -25,6 +26,8 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.WorkerFactory import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserObserver +import com.duckduckgo.app.browser.shortcut.ShortcutBuilder +import com.duckduckgo.app.browser.shortcut.ShortcutReceiver import com.duckduckgo.app.di.AppComponent import com.duckduckgo.app.di.DaggerAppComponent import com.duckduckgo.app.fire.DataClearer @@ -150,6 +153,9 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO @Inject lateinit var atbInitializer: AtbInitializer + @Inject + lateinit var shortcutReceiver: ShortcutReceiver + @Inject lateinit var variantManager: VariantManager @@ -293,6 +299,7 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO Timber.i("Suppressing app launch pixel") return } + registerReceiver(shortcutReceiver, IntentFilter(ShortcutBuilder.USE_OUR_APP_SHORTCUT_ADDED_ACTION)) pixel.fire(APP_LAUNCH) } @@ -305,6 +312,11 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO } } + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onAppStopped() { + unregisterReceiver(shortcutReceiver) + } + companion object { private const val APP_RESTART_CAUSED_BY_FIRE_GRACE_PERIOD: Long = 10_000L } diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index 5231b7cff2b3..eec4fb406830 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.browser.favicon.FaviconDownloader import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector import com.duckduckgo.app.browser.omnibar.QueryUrlConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage +import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.ui.CtaViewModel import com.duckduckgo.app.feedback.api.FeedbackSubmitter import com.duckduckgo.app.feedback.ui.common.FeedbackViewModel @@ -44,10 +45,12 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder +import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.icon.api.IconModifier import com.duckduckgo.app.icon.ui.ChangeIconViewModel import com.duckduckgo.app.launch.LaunchViewModel -import com.duckduckgo.app.notification.AndroidNotificationScheduler +import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.OnboardingPageManager import com.duckduckgo.app.onboarding.ui.OnboardingViewModel @@ -111,7 +114,10 @@ class ViewModelFactory @Inject constructor( private val onboardingPageManager: OnboardingPageManager, private val appInstallationReferrerStateListener: AppInstallationReferrerStateListener, private val appIconModifier: IconModifier, - private val notificationScheduler: AndroidNotificationScheduler, + private val userEventsStore: UserEventsStore, + private val notificationDao: NotificationDao, + private val userOurAppDetector: UseOurAppDetector, + private val dismissedCtaDao: DismissedCtaDao, private val dispatcherProvider: DispatcherProvider ) : ViewModelProvider.NewInstanceFactory() { @@ -170,11 +176,13 @@ class ViewModelFactory @Inject constructor( private fun browserViewModel(): BrowserViewModel { return BrowserViewModel( - tabRepository, - queryUrlConverter, - dataClearer, - appEnjoymentPromptEmitter, - appEnjoymentUserEventRecorder + tabRepository = tabRepository, + queryUrlConverter = queryUrlConverter, + dataClearer = dataClearer, + appEnjoymentPromptEmitter = appEnjoymentPromptEmitter, + appEnjoymentUserEventRecorder = appEnjoymentUserEventRecorder, + ctaDao = dismissedCtaDao, + pixel = pixel ) } @@ -199,6 +207,9 @@ class ViewModelFactory @Inject constructor( ctaViewModel = ctaViewModel, searchCountDao = searchCountDao, pixel = pixel, + userEventsStore = userEventsStore, + notificationDao = notificationDao, + useOurAppDetector = userOurAppDetector, variantManager = variantManager ) diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index cf634d945d9a..936ee90715f8 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -24,6 +24,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.rating.db.AppEnjoymentDao import com.duckduckgo.app.browser.rating.db.AppEnjoymentEntity import com.duckduckgo.app.browser.rating.db.AppEnjoymentTypeConverter @@ -35,6 +36,10 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.global.exception.UncaughtExceptionDao import com.duckduckgo.app.global.exception.UncaughtExceptionEntity import com.duckduckgo.app.global.exception.UncaughtExceptionSourceConverter +import com.duckduckgo.app.global.events.db.UserEventsDao +import com.duckduckgo.app.global.events.db.UserEventEntity +import com.duckduckgo.app.global.events.db.UserEventTypeConverter +import com.duckduckgo.app.global.useourapp.MigrationManager import com.duckduckgo.app.httpsupgrade.db.HttpsBloomFilterSpecDao import com.duckduckgo.app.httpsupgrade.db.HttpsWhitelistDao import com.duckduckgo.app.httpsupgrade.model.HttpsBloomFilterSpec @@ -45,6 +50,7 @@ import com.duckduckgo.app.onboarding.store.* import com.duckduckgo.app.privacy.db.* import com.duckduckgo.app.privacy.model.PrivacyProtectionCountsEntity import com.duckduckgo.app.privacy.model.UserWhitelistedDomain +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.db.TabsDao @@ -56,9 +62,10 @@ import com.duckduckgo.app.usage.app.AppDaysUsedDao import com.duckduckgo.app.usage.app.AppDaysUsedEntity import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity +import java.util.Locale @Database( - exportSchema = true, version = 21, entities = [ + exportSchema = true, version = 22, entities = [ TdsTracker::class, TdsEntity::class, TdsDomainEntity::class, @@ -81,7 +88,8 @@ import com.duckduckgo.app.usage.search.SearchCountEntity UncaughtExceptionEntity::class, TdsMetadata::class, UserStage::class, - FireproofWebsiteEntity::class + FireproofWebsiteEntity::class, + UserEventEntity::class ] ) @@ -94,7 +102,8 @@ import com.duckduckgo.app.usage.search.SearchCountEntity RuleTypeConverter::class, CategoriesTypeConverter::class, UncaughtExceptionSourceConverter::class, - StageTypeConverter::class + StageTypeConverter::class, + UserEventTypeConverter::class ) abstract class AppDatabase : RoomDatabase() { @@ -119,10 +128,16 @@ abstract class AppDatabase : RoomDatabase() { abstract fun tdsDao(): TdsMetadataDao abstract fun userStageDao(): UserStageDao abstract fun fireproofWebsiteDao(): FireproofWebsiteDao + abstract fun userEventsDao(): UserEventsDao } @Suppress("PropertyName") -class MigrationsProvider(val context: Context) { +class MigrationsProvider( + val context: Context, + val settingsDataStore: SettingsDataStore, + val addToHomeCapabilityDetector: AddToHomeCapabilityDetector, + val useOurAppMigrationManager: MigrationManager +) { val MIGRATION_1_TO_2: Migration = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { @@ -292,6 +307,26 @@ class MigrationsProvider(val context: Context) { } } + val MIGRATION_21_TO_22: Migration = object : Migration(21, 22) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `user_events` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))") + + if (canUserBeMigratedToUseOurAppFlow(database)) { + if (useOurAppMigrationManager.shouldRunMigration()) { + database.execSQL("UPDATE $USER_STAGE_TABLE_NAME SET appStage = \"${AppStage.USE_OUR_APP_NOTIFICATION}\" WHERE appStage = \"${AppStage.ESTABLISHED}\"") + } + } + } + } + + private fun canUserBeMigratedToUseOurAppFlow(database: SupportSQLiteDatabase): Boolean = + isEnglishLocale() && isUserEstablished(database) && !settingsDataStore.hideTips && addToHomeCapabilityDetector.isAddToHomeSupported() + + private fun isEnglishLocale(): Boolean { + val locale = Locale.getDefault() + return locale != null && locale.language == "en" + } + val ALL_MIGRATIONS: List get() = listOf( MIGRATION_1_TO_2, @@ -313,9 +348,21 @@ class MigrationsProvider(val context: Context) { MIGRATION_17_TO_18, MIGRATION_18_TO_19, MIGRATION_19_TO_20, - MIGRATION_20_TO_21 + MIGRATION_20_TO_21, + MIGRATION_21_TO_22 ) + private fun isUserEstablished(database: SupportSQLiteDatabase): Boolean { + var stage: String + + database.query("SELECT appStage from userStage limit 1").apply { + moveToFirst() + if (count == 0) return false + stage = getString(0) + } + return (stage == AppStage.ESTABLISHED.name) + } + @Deprecated( message = "This class should be only used by database migrations.", replaceWith = ReplaceWith(expression = "UserStageStore", imports = ["com.duckduckgo.app.onboarding.store"]) diff --git a/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventEntity.kt b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventEntity.kt new file mode 100644 index 000000000000..17dfab2abfbf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventEntity.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.events.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverter + +@Entity(tableName = "user_events") +data class UserEventEntity( + @PrimaryKey val id: UserEventKey, + val timestamp: Long = System.currentTimeMillis() +) + +enum class UserEventKey { + USE_OUR_APP_SHORTCUT_ADDED, + USE_OUR_APP_FIREPROOF_DIALOG_SEEN +} + +class UserEventTypeConverter { + + @TypeConverter + fun toKey(stage: String): UserEventKey { + return UserEventKey.valueOf(stage) + } + + @TypeConverter + fun fromKey(stage: UserEventKey): String { + return stage.name + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsDao.kt b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsDao.kt new file mode 100644 index 000000000000..dea2a6db5d35 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.events.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface UserEventsDao { + + @Query("select * from user_events where id=:userEventKey") + suspend fun getUserEvent(userEventKey: UserEventKey): UserEventEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(userEventEntity: UserEventEntity) +} diff --git a/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsStore.kt b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsStore.kt new file mode 100644 index 000000000000..e1777f25346f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/events/db/UserEventsStore.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.events.db + +import com.duckduckgo.app.global.DispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface UserEventsStore { + suspend fun getUserEvent(userEventKey: UserEventKey): UserEventEntity? + suspend fun registerUserEvent(userEventKey: UserEventKey) +} + +class AppUserEventsStore @Inject constructor( + private val userEventsDao: UserEventsDao, + private val dispatcher: DispatcherProvider +) : UserEventsStore { + + override suspend fun getUserEvent(userEventKey: UserEventKey): UserEventEntity? { + return withContext(dispatcher.io()) { + userEventsDao.getUserEvent(userEventKey) + } + } + + override suspend fun registerUserEvent(userEventKey: UserEventKey) { + withContext(dispatcher.io()) { + userEventsDao.insert(UserEventEntity(userEventKey)) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt new file mode 100644 index 000000000000..a526bc9ed71e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.useourapp + +import android.net.Uri +import androidx.core.net.toUri +import com.duckduckgo.app.browser.logindetection.WebNavigationEvent +import com.duckduckgo.app.global.baseHost +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.events.db.UserEventsStore +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +class UseOurAppDetector @Inject constructor(val userEventsStore: UserEventsStore) { + + fun isUseOurAppUrl(url: String?): Boolean { + if (url == null) return false + return isUseOurAppUrl(url.toUri()) + } + + fun allowLoginDetection(event: WebNavigationEvent): Boolean { + val canShowFireproof = runBlocking { + if (userEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) == null) { + false + } else { + (userEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) == null) + } + } + + return if (canShowFireproof) { + when (event) { + is WebNavigationEvent.OnPageStarted -> isUseOurAppUrl(event.webView.url) + is WebNavigationEvent.ShouldInterceptRequest -> isUseOurAppUrl(event.webView.url) + } + } else { + false + } + } + + suspend fun registerIfFireproofSeenForTheFirstTime(url: String) { + if (userEventsStore.getUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) == null && isUseOurAppUrl(url)) { + userEventsStore.registerUserEvent(UserEventKey.USE_OUR_APP_FIREPROOF_DIALOG_SEEN) + } + } + + private fun isUseOurAppUrl(uri: Uri): Boolean { + return domainMatchesUrl(uri) + } + + private fun domainMatchesUrl(uri: Uri): Boolean { + return uri.baseHost?.contains(USE_OUR_APP_DOMAIN) ?: false + } + + companion object { + const val USE_OUR_APP_SHORTCUT_URL: String = "https://m.facebook.com/" + const val USE_OUR_APP_SHORTCUT_TITLE: String = "Facebook" + const val USE_OUR_APP_DOMAIN = "facebook.com" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppMigrationManager.kt b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppMigrationManager.kt new file mode 100644 index 000000000000..7a8628a199b9 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppMigrationManager.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.global.useourapp + +import com.duckduckgo.app.statistics.IndexRandomizer +import com.duckduckgo.app.statistics.Probabilistic + +interface MigrationManager { + fun shouldRunMigration(): Boolean +} + +class UseOurAppMigrationManager constructor(private val indexRandomizer: IndexRandomizer) : MigrationManager { + + override fun shouldRunMigration(): Boolean { + val randomizedIndex = indexRandomizer.random(MIGRATION_VARIANTS) + val variant = MIGRATION_VARIANTS[randomizedIndex] + return (variant == useOurAppMigration) + } + + companion object { + private val defaultMigration = MigrationWeight(0.9) // do not migrate 90% of old users + private val useOurAppMigration = MigrationWeight(0.1) // migrate 10% of old users + + val MIGRATION_VARIANTS = listOf( + defaultMigration, + useOurAppMigration + ) + } +} + +data class MigrationWeight(override val weight: Double) : Probabilistic diff --git a/app/src/main/java/com/duckduckgo/app/global/view/DaxDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/DaxDialog.kt index d35d9e276bb7..899dfddca6e3 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/DaxDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/DaxDialog.kt @@ -41,6 +41,7 @@ interface DaxDialog { interface DaxDialogListener { fun onDaxDialogDismiss() fun onDaxDialogPrimaryCtaClick() + fun onDaxDialogSecondaryCtaClick() fun onDaxDialogHideClick() } @@ -52,6 +53,7 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { private var toolbarDimmed: Boolean = true private var dismissible: Boolean = true private var typingDelayInMs: Long = DEFAULT_TYPING_DELAY + private var showHideButton: Boolean = true private var daxDialogListener: DaxDialogListener? = null @@ -94,6 +96,9 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { if (containsKey(ARG_TYPING_DELAY)) { typingDelayInMs = getLong(ARG_TYPING_DELAY) } + if (containsKey(ARG_SHOW_HIDE_BUTTON)) { + showHideButton = getBoolean(ARG_SHOW_HIDE_BUTTON) + } } } @@ -150,6 +155,12 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { dismiss() } + secondaryCta.setOnClickListener { + dialogText.cancelAnimation() + daxDialogListener?.onDaxDialogSecondaryCtaClick() + dismiss() + } + if (dismissible) { dialogContainer.setOnClickListener { dialogText.cancelAnimation() @@ -172,6 +183,7 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { secondaryCta.text = secondaryButtonText secondaryCta.visibility = if (secondaryButtonText.isEmpty()) View.GONE else View.VISIBLE dialogText.typingDelayInMs = typingDelayInMs + hideText.visibility = if (showHideButton) View.VISIBLE else View.GONE } } @@ -183,7 +195,8 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { secondaryButtonText: String? = "", toolbarDimmed: Boolean = true, dismissible: Boolean = true, - typingDelayInMs: Long = DEFAULT_TYPING_DELAY + typingDelayInMs: Long = DEFAULT_TYPING_DELAY, + showHideButton: Boolean = true ): TypewriterDaxDialog { return TypewriterDaxDialog().apply { arguments = Bundle().apply { @@ -193,6 +206,7 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { putBoolean(ARG_TOOLBAR_DIMMED, toolbarDimmed) putBoolean(ARG_DISMISSIBLE, dismissible) putLong(ARG_TYPING_DELAY, typingDelayInMs) + putBoolean(ARG_SHOW_HIDE_BUTTON, showHideButton) } } } @@ -204,5 +218,6 @@ class TypewriterDaxDialog : DialogFragment(), DaxDialog { private const val ARG_TOOLBAR_DIMMED = "toolbarDimmed" private const val ARG_DISMISSIBLE = "isDismissible" private const val ARG_TYPING_DELAY = "typingDelay" + private const val ARG_SHOW_HIDE_BUTTON = "showHideButton" } } diff --git a/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt b/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt index d591b8285f57..8fef0e7a2d23 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt @@ -23,6 +23,9 @@ import androidx.work.* import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.Notification import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.app.onboarding.store.useOurAppNotification import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.VariantManager.VariantFeature.DripNotification import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripB2Notification @@ -51,13 +54,32 @@ class NotificationScheduler( private val dripA2Notification: SchedulableNotification, private val dripB1Notification: SchedulableNotification, private val dripB2Notification: SchedulableNotification, - private val variantManager: VariantManager + private val variantManager: VariantManager, + private val userStageStore: UserStageStore ) : AndroidNotificationScheduler { override suspend fun scheduleNextNotification() { + scheduleUseOurAppNotification() scheduleInactiveUserNotifications() } + private suspend fun scheduleUseOurAppNotification() { + if (userStageStore.useOurAppNotification()) { + val operation = scheduleUniqueNotification( + OneTimeWorkRequestBuilder(), + 1, + TimeUnit.DAYS, + USE_OUR_APP_WORK_REQUEST_TAG + ) + try { + operation.await() + userStageStore.stageCompleted(AppStage.USE_OUR_APP_NOTIFICATION) + } catch (e: Exception) { + Timber.v("Notification could not be scheduled: $e") + } + } + } + private suspend fun scheduleInactiveUserNotifications() { workManager.cancelAllWorkByTag(UNUSED_APP_WORK_REQUEST_TAG) @@ -94,6 +116,16 @@ class NotificationScheduler( private fun isNotDripVariant(): Boolean = !variant().hasFeature(DripNotification) + private fun scheduleUniqueNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit, tag: String): Operation { + Timber.v("Scheduling unique notification") + val request = builder + .addTag(tag) + .setInitialDelay(duration, unit) + .build() + + return workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.KEEP, request) + } + private fun scheduleNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit, tag: String) { Timber.v("Scheduling notification") val request = builder @@ -114,6 +146,7 @@ class NotificationScheduler( class DripA2NotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) class DripB1NotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) class DripB2NotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) + class UseOurAppNotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) open class SchedulableNotificationWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -144,5 +177,6 @@ class NotificationScheduler( companion object { const val UNUSED_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.schedule" + const val USE_OUR_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.useOurApp" } } diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt index 91b6b446797a..0b4cb8be138a 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt @@ -26,11 +26,15 @@ import androidx.core.app.NotificationManagerCompat import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.icon.ui.ChangeIconActivity import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CHANGE_ICON_FEATURE +import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.APP_LAUNCH import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CANCEL import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CLEAR_DATA_LAUNCH +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.USE_OUR_APP import com.duckduckgo.app.notification.model.NotificationSpec import com.duckduckgo.app.notification.model.WebsiteNotificationSpecification +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.settings.SettingsActivity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel @@ -38,6 +42,8 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_CANCELL import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_LAUNCHED import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.WEBSITE import dagger.android.AndroidInjection +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -58,6 +64,12 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { @Inject lateinit var settingsDataStore: SettingsDataStore + @Inject + lateinit var userStageStore: UserStageStore + + @Inject + lateinit var dispatcher: DispatcherProvider + override fun onCreate() { super.onCreate() AndroidInjection.inject(this) @@ -72,6 +84,12 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { CANCEL -> onCancelled(pixelSuffix) WEBSITE -> onWebsiteNotification(intent, pixelSuffix) CHANGE_ICON_FEATURE -> onCustomizeIconLaunched(pixelSuffix) + USE_OUR_APP -> { + GlobalScope.launch(dispatcher.io()) { + userStageStore.moveToStage(AppStage.USE_OUR_APP_ONBOARDING) + onAppLaunched(pixelSuffix) + } + } } if (intent.getBooleanExtra(NOTIFICATION_AUTO_CANCEL, true)) { @@ -134,6 +152,7 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { const val CANCEL = "com.duckduckgo.notification.cancel" const val WEBSITE = "com.duckduckgo.notification.website" const val CHANGE_ICON_FEATURE = "com.duckduckgo.notification.app.feature.changeIcon" + const val USE_OUR_APP = "com.duckduckgo.notification.flow.useOurApp" } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt index 00266580f38c..fc959e7d9f6d 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt @@ -50,6 +50,7 @@ class NotificationRegistrar @Inject constructor( const val PrivacyProtection = 101 const val Article = 103 // 102 was used for the search notification hence using 103 moving forward const val AppFeature = 104 + const val UseOurApp = 105 } object ChannelType { diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/UseOurAppNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/UseOurAppNotification.kt new file mode 100644 index 000000000000..97b2e4eef052 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/notification/model/UseOurAppNotification.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * 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 com.duckduckgo.app.notification.model + +import android.content.Context +import android.os.Bundle +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.notification.NotificationHandlerService +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CANCEL +import com.duckduckgo.app.notification.NotificationRegistrar +import com.duckduckgo.app.notification.db.NotificationDao +import com.duckduckgo.app.statistics.pixels.Pixel +import timber.log.Timber + +class UseOurAppNotification( + private val context: Context, + private val notificationDao: NotificationDao +) : SchedulableNotification { + + override val id = ID + override val launchIntent = NotificationHandlerService.NotificationEvent.USE_OUR_APP + override val cancelIntent = CANCEL + + override suspend fun canShow(): Boolean { + if (notificationDao.exists(id)) { + Timber.v("Notification already seen") + return false + } + + return true + } + + override suspend fun buildSpecification(): NotificationSpec { + return UseOurAppSpecification(context) + } + + companion object { + const val ID = "com.duckduckgo.privacytips.useOurApp" + } +} + +class UseOurAppSpecification(context: Context) : NotificationSpec { + override val channel = NotificationRegistrar.ChannelType.TUTORIALS + override val systemId = NotificationRegistrar.NotificationId.UseOurApp + override val name = "Use our app" + override val icon = R.drawable.notification_logo + override val title: String = context.getString(R.string.useOurAppNotificationTitle) + override val description: String = context.getString(R.string.useOurAppNotificationDescription) + override val launchButton: String? = null + override val closeButton: String? = null + override val pixelSuffix = Pixel.PixelName.USE_OUR_APP_NOTIFICATION_SUFFIX.pixelName + override val autoCancel = true + override val bundle: Bundle = Bundle() + override val color: Int = R.color.ic_launcher_red_background +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStage.kt index c46f825cf44d..8ebf5c76f568 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStage.kt @@ -31,6 +31,8 @@ data class UserStage( enum class AppStage { NEW, DAX_ONBOARDING, + USE_OUR_APP_NOTIFICATION, + USE_OUR_APP_ONBOARDING, ESTABLISHED; } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt index 047442293f81..6c517284bbf1 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt @@ -23,6 +23,7 @@ import javax.inject.Inject interface UserStageStore { suspend fun getUserAppStage(): AppStage suspend fun stageCompleted(appStage: AppStage): AppStage + suspend fun moveToStage(appStage: AppStage) } class AppUserStageStore @Inject constructor( @@ -41,6 +42,8 @@ class AppUserStageStore @Inject constructor( val newAppStage = when (appStage) { AppStage.NEW -> AppStage.DAX_ONBOARDING AppStage.DAX_ONBOARDING -> AppStage.ESTABLISHED + AppStage.USE_OUR_APP_NOTIFICATION -> AppStage.ESTABLISHED + AppStage.USE_OUR_APP_ONBOARDING -> AppStage.ESTABLISHED AppStage.ESTABLISHED -> AppStage.ESTABLISHED } @@ -51,6 +54,10 @@ class AppUserStageStore @Inject constructor( return@withContext newAppStage } } + + override suspend fun moveToStage(appStage: AppStage) { + userStageDao.updateUserStage(appStage) + } } suspend fun UserStageStore.isNewUser(): Boolean { @@ -60,3 +67,11 @@ suspend fun UserStageStore.isNewUser(): Boolean { suspend fun UserStageStore.daxOnboardingActive(): Boolean { return this.getUserAppStage() == AppStage.DAX_ONBOARDING } + +suspend fun UserStageStore.useOurAppOnboarding(): Boolean { + return this.getUserAppStage() == AppStage.USE_OUR_APP_ONBOARDING +} + +suspend fun UserStageStore.useOurAppNotification(): Boolean { + return this.getUserAppStage() == AppStage.USE_OUR_APP_NOTIFICATION +} diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index be03083f1f04..461029cc22a0 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -193,7 +193,20 @@ interface Pixel { FIREPROOF_WEBSITE_DELETED("m_fw_d"), FIREPROOF_LOGIN_TOGGLE_ENABLED("m_fw_d_e"), FIREPROOF_LOGIN_TOGGLE_DISABLED("m_fw_d_d"), - FIREPROOF_WEBSITE_UNDO("m_fw_u") + FIREPROOF_WEBSITE_UNDO("m_fw_u"), + + USE_OUR_APP_NOTIFICATION_SUFFIX("uoa"), + USE_OUR_APP_DIALOG_SHOWN("m_uoa_d"), + USE_OUR_APP_DIALOG_OK("m_uoa_d_ok"), + USE_OUR_APP_SHORTCUT_ADDED("m_uoa_s_a"), + USE_OUR_APP_DIALOG_DELETE_SHOWN("m_uoa_dd"), + UOA_VISITED_AFTER_SHORTCUT("m_uoa_vas"), + UOA_VISITED_AFTER_NOTIFICATION("m_uoa_van"), + UOA_VISITED_AFTER_DELETE_CTA("m_uoa_vad"), + + USE_OUR_APP_SHORTCUT_OPENED("m_sho_uoa_o"), + SHORTCUT_ADDED("m_sho_a"), + SHORTCUT_OPENED("m_sho_o"), } object PixelParameter { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt index e95a3862cc60..04a8bc29d419 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt @@ -44,6 +44,9 @@ abstract class TabsDao { @Query("select * from tabs where tabId = :tabId") abstract fun tab(tabId: String): TabEntity? + @Query("select tabId from tabs where url LIKE :url") + abstract suspend fun selectTabByUrl(url: String): String? + @Insert(onConflict = OnConflictStrategy.REPLACE) abstract fun insertTab(tab: TabEntity) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index e70c014fae72..ae37751c3454 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -85,6 +85,15 @@ class TabDataRepository @Inject constructor( } } + override suspend fun selectByUrlOrNewTab(url: String) { + val tabId = tabsDao.selectTabByUrl(url) + if (tabId != null) { + select(tabId) + } else { + add(url, skipHome = true, isDefaultTab = false) + } + } + override suspend fun addNewTabAfterExistingTab(url: String?, tabId: String) { databaseExecutor().scheduleDirect { val position = tabsDao.tab(tabId)?.position ?: -1 diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index ad7e2067d4ec..f0c1ee09bfaa 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -49,4 +49,6 @@ interface TabRepository { suspend fun select(tabId: String) fun updateTabPreviewImage(tabId: String, fileName: String?) + + suspend fun selectByUrlOrNewTab(url: String) } diff --git a/app/src/main/res/drawable/ic_fb_favicon.png b/app/src/main/res/drawable/ic_fb_favicon.png new file mode 100644 index 000000000000..32416f0e5232 Binary files /dev/null and b/app/src/main/res/drawable/ic_fb_favicon.png differ diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 15cd5cc99222..253bb78486f7 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -41,4 +41,14 @@ Ask When Signing In Websites rely on cookies to keep you signed in. When you Fireproof a site, cookies won\'t be erased and you\'ll stay signed in, even after using the Fire Button. We still block third-party trackers found on Fireproof websites. + + + Did you know the Facebook app can make requests for data even when you\'re not using it?<br/><br/>Replace the app with a home screen shortcut that opens Facebook in DuckDuckGo. Then delete the Facebook app. + Add Facebook Shortcut + Not Now + Worried about Facebook tracking you? + Here\'s a simple way to reduce its reach. + Success! %s has been added to your home screen. + Checking your feed in DuckDuckGo is a great alternative to using the Facebook app!<br/><br/>But if the Facebook app is on your phone, it can make requests for data even when you\'re not using it.<br/><br/>Prevent this by deleting it now! +