diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/23.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/23.json new file mode 100644 index 000000000000..6d542cf760e5 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/23.json @@ -0,0 +1,746 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "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 b778cca00b13..ba957ba2ac48 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -2175,6 +2175,15 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) } + @Test + fun whenViewReadyIfDomainSameAsUseOurAppThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + + testee.onViewReady() + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED) + } + @Test fun whenViewReadyIfDomainIsNotTheSameAsUseOurAppAfterNotificationSeenThenPixelNotSent() = coroutineRule.runBlocking { givenUseOurAppSiteIsNotSelected() @@ -2205,6 +2214,15 @@ class BrowserTabViewModelTest { verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) } + @Test + fun whenViewReadyIfDomainIsNotTheSameAsUseOurAppAThenPixelNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + + testee.onViewReady() + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED) + } + @Test fun whenPageChangedIfPreviousOneWasNotUseOurAppSiteAfterNotificationSeenThenPixelSent() = coroutineRule.runBlocking { givenUseOurAppSiteIsNotSelected() @@ -2235,6 +2253,15 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) } + @Test + fun whenPageChangedIfPreviousOneWasNotUseOurAppSiteThenPixelSent() = coroutineRule.runBlocking { + givenUseOurAppSiteIsNotSelected() + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel).fire(Pixel.PixelName.UOA_VISITED) + } + @Test fun whenPageChangedIfPreviousOneWasUseOurAppSiteAfterNotificationSeenThenPixelNotSent() = coroutineRule.runBlocking { givenUseOurAppSiteSelected() @@ -2266,6 +2293,15 @@ class BrowserTabViewModelTest { verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED_AFTER_DELETE_CTA) } + @Test + fun whenPageChangedIfPreviousOneWasUseOurAppSiteThenNotSent() = coroutineRule.runBlocking { + givenUseOurAppSiteSelected() + + loadUrl(USE_OUR_APP_DOMAIN, isBrowserShowing = true) + + verify(mockPixel, never()).fire(Pixel.PixelName.UOA_VISITED) + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } 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 ced386fb72e5..d1ea2f74bdcc 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -23,14 +23,14 @@ 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.events.db.UserEventsStore 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.global.useourapp.UseOurAppDetector +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_DOMAIN import com.duckduckgo.app.privacy.ui.PrivacyDashboardActivity import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.pixels.Pixel @@ -85,7 +85,7 @@ class BrowserViewModelTest { private lateinit var mockPixel: Pixel @Mock - private lateinit var mockDismissedCtaDao: DismissedCtaDao + private lateinit var mockUserEventsStore: UserEventsStore private lateinit var testee: BrowserViewModel @@ -101,7 +101,7 @@ class BrowserViewModelTest { dataClearer = mockAutomaticDataClearer, appEnjoymentPromptEmitter = mockAppEnjoymentPromptEmitter, appEnjoymentUserEventRecorder = mockAppEnjoymentUserEventRecorder, - ctaDao = mockDismissedCtaDao, + useOurAppDetector = UseOurAppDetector(mockUserEventsStore), dispatchers = coroutinesTestRule.testDispatcherProvider, pixel = mockPixel ) @@ -193,23 +193,13 @@ class BrowserViewModelTest { } @Test - fun whenOpenShortcutIfUrlIsUseOurAppUrlAndCtaHasBeenSeenThenFirePixel() { - givenUseOurAppCtaHasBeenSeen() - val url = USE_OUR_APP_SHORTCUT_URL + fun whenOpenShortcutIfUrlIsUseOurAppDomainThenFirePixel() { + val url = "http://m.$USE_OUR_APP_DOMAIN" 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" @@ -218,10 +208,6 @@ class BrowserViewModelTest { 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/shortcut/ShortcutReceiverTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt index c8c45b745555..718eb88b8b4e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt @@ -18,12 +18,12 @@ 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.global.useourapp.UseOurAppDetector import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.Variant +import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never @@ -42,19 +42,25 @@ class ShortcutReceiverTest { private val mockUserEventsStore: UserEventsStore = mock() private val mockPixel: Pixel = mock() - private val mockDismissedCtaDao: DismissedCtaDao = mock() + private val mockVariantManager: VariantManager = mock() private lateinit var testee: ShortcutReceiver @Before fun before() { - testee = ShortcutReceiver(mockUserEventsStore, mockDismissedCtaDao, coroutinesTestRule.testDispatcherProvider, mockPixel) + testee = ShortcutReceiver( + mockUserEventsStore, + coroutinesTestRule.testDispatcherProvider, + UseOurAppDetector(mockUserEventsStore), + mockVariantManager, + mockPixel + ) } @Test - fun whenIntentReceivedIfUrlIsFromUseOurAppUrlThenRegisterTimestamp() = coroutinesTestRule.runBlocking { - givenUseOurAppCtaHasBeenSeen() + fun whenIntentReceivedIfUrlIsFromUseOurAppDomainAndVariantIsInAppUsageThenRegisterTimestamp() = coroutinesTestRule.runBlocking { + setInAppUsageVariant() val intent = Intent() - intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") testee.onReceive(null, intent) @@ -62,50 +68,63 @@ class ShortcutReceiverTest { } @Test - fun whenIntentReceivedIfUrlIsFromUseOurAppUrlThenFirePixel() { - givenUseOurAppCtaHasBeenSeen() + fun whenIntentReceivedIfUrlIsFromUseOurAppDomainAndVariantIsNotInAppUsageThenDoNotRegisterTimestamp() = coroutinesTestRule.runBlocking { + setDefaultVariant() val intent = Intent() - intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") testee.onReceive(null, intent) - verify(mockPixel).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) } @Test - fun whenIntentReceivedIfUrlIsNotFromUseOurAppUrlThenDoNotRegisterEvent() = coroutinesTestRule.runBlocking { - givenUseOurAppCtaHasBeenSeen() + fun whenIntentReceivedIfUrlContainsUseOurAppDomainThenFirePixel() { + setDefaultVariant() val intent = Intent() - intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") testee.onReceive(null, intent) - verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + verify(mockPixel).fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) } @Test - fun whenIntentReceivedIfUrlIsNotFromUseOurAppUrlThenFireShortcutAddedPixel() { + fun whenIntentReceivedIfUrlIsNotFromUseOurAppDomainThenDoNotRegisterEvent() = coroutinesTestRule.runBlocking { + setDefaultVariant() 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) + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) } @Test - fun whenIntentReceivedAndCtaNotSeenIfUrlIsFromUseOurAppUrlThenDoNotFireUseOurAppPixelAndFireShortcutPixel() = coroutinesTestRule.runBlocking { + fun whenIntentReceivedIfUrlIsNotFromUseOurAppDomainThenFireShortcutAddedPixel() { + setDefaultVariant() val intent = Intent() - intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, USE_OUR_APP_SHORTCUT_URL) - + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") 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) + private fun setDefaultVariant() { + whenever(mockVariantManager.getVariant()).thenReturn(VariantManager.DEFAULT_VARIANT) + } + + private fun setInAppUsageVariant() { + whenever(mockVariantManager.getVariant()).thenReturn( + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.InAppUsage, + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true }) + ) } } 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 3173f8df0377..b5d7ae10626d 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 @@ -32,7 +32,6 @@ 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 @@ -63,10 +62,9 @@ class AppDatabaseTest { private val context = mock() private val mockSettingsDataStore = mock() private val mockAddToHomeCapabilityDetector = mock() - private val mockUseOurAppMigrationManager = mock() private val migrationsProvider: MigrationsProvider = - MigrationsProvider(context, mockSettingsDataStore, mockAddToHomeCapabilityDetector, mockUseOurAppMigrationManager) + MigrationsProvider(context, mockSettingsDataStore, mockAddToHomeCapabilityDetector) @Before fun setup() { @@ -234,38 +232,16 @@ class AppDatabaseTest { } @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) - } + fun whenMigratingFromVersion22To23ThenValidationSucceeds() { + createDatabaseAndMigrate(22, 23, migrationsProvider.MIGRATION_22_TO_23) } @Test - fun whenMigratingFromVersion21To22IfUserIsEstablishedAndHomeShortcutNotSupportedThenDoNotMigrateToNotification() { - testHelper.createDatabase(TEST_DB_NAME, 21).use { - givenUseOurAppStateIs(canMigrate = true, hideTips = false, canAddToHome = false) - givenUserStageIs(it, AppStage.ESTABLISHED) + fun whenMigratingFromVersion22To23IfUserStageIsUseOurAppNotificationThenMigrateToEstablished() { + testHelper.createDatabase(TEST_DB_NAME, 22).use { + givenUserStageIs(it, AppStage.USE_OUR_APP_NOTIFICATION) - testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 23, true, migrationsProvider.MIGRATION_22_TO_23) val stage = getUserStage(it) assertEquals(AppStage.ESTABLISHED.name, stage) @@ -273,37 +249,17 @@ class AppDatabaseTest { } @Test - fun whenMigratingFromVersion21To22IfUserIsNotEstablishedThenDoNotMigrateToNotification() { - testHelper.createDatabase(TEST_DB_NAME, 21).use { - givenUseOurAppStateIs(canMigrate = true, hideTips = false, canAddToHome = false) + fun whenMigratingFromVersion22To23IfUserStageIsNotUseOurAppNotificationThenDoNotMigrateToEstablished() { + testHelper.createDatabase(TEST_DB_NAME, 22).use { givenUserStageIs(it, AppStage.ESTABLISHED) - testHelper.runMigrationsAndValidate(TEST_DB_NAME, 22, true, migrationsProvider.MIGRATION_21_TO_22) + testHelper.runMigrationsAndValidate(TEST_DB_NAME, 23, true, migrationsProvider.MIGRATION_22_TO_23) 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) @@ -316,7 +272,7 @@ class AppDatabaseTest { } private fun getUserStage(database: SupportSQLiteDatabase): String { - var stage = "" + var stage: String database.query("SELECT appStage from userStage limit 1").apply { moveToFirst() 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 2599c70d4a96..b2091d962018 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt @@ -28,27 +28,12 @@ import androidx.work.testing.WorkManagerTestInitHelper import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.notification.NotificationScheduler.ClearDataNotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.PrivacyNotificationWorker -import com.duckduckgo.app.notification.NotificationScheduler.DripA1NotificationWorker -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 -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1PrivacyNotification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripA1Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripA2Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripB1Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripB2Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day3ClearDataNotification 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 @@ -65,13 +50,9 @@ 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() - private val dripA2Notification: SchedulableNotification = mock() - private val dripB1Notification: SchedulableNotification = mock() - private val dripB2Notification: SchedulableNotification = mock() + private val useOurAppNotification: SchedulableNotification = mock() private val context = InstrumentationRegistry.getInstrumentation().targetContext private lateinit var workManager: WorkManager @@ -80,17 +61,13 @@ class AndroidNotificationSchedulerTest { @Before fun before() { initializeWorkManager() - whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + testee = NotificationScheduler( workManager, clearNotification, privacyNotification, - dripA1Notification, - dripA2Notification, - dripB1Notification, - dripB2Notification, - variantManager, - mockUserStageStore + useOurAppNotification, + variantManager ) } @@ -107,7 +84,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationClearDataCanShowThenPrivacyNotificationIsScheduled() = runBlocking { - whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() @@ -117,7 +94,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationCanShowButClearDataCannotThenPrivacyNotificationIsScheduled() = runBlocking { - whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() @@ -127,7 +104,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationCannotShowAndClearNotificationCanShowThenClearNotificationIsScheduled() = runBlocking { - whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() @@ -137,7 +114,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationAndClearNotificationCannotShowThenNoNotificationScheduled() = runBlocking { - whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() @@ -145,351 +122,125 @@ class AndroidNotificationSchedulerTest { assertNoNotificationScheduled() } - // Drip A1 - @Test - fun whenDripA1VariantAndDripA1NotificationCanShowAndClearNotificationCannotShowThenDripA1NotificationIsScheduled() = runBlocking { - setDripA1Variant() - whenever(dripA1Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripA1NotificationWorker::class.jvmName) - } - @Test - fun whenDripA1VariantAndClearNotificationCanShotAndDripA1NotificationCannotShowThenClearDataNotificationScheduled() = runBlocking { - setDripA1Variant() - whenever(dripA1Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) - } - - @Test - fun whenDripA1VariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setDripA1Variant() - whenever(dripA1Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNoNotificationScheduled() - } - - @Test - fun whenDripA1VariantAndDripA1NotificationAndClearDataNotificationCanShowThenDripA1NotificationScheduled() = runBlocking { - setDripA1Variant() - whenever(dripA1Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripA1NotificationWorker::class.jvmName) - } - - // Drip A2 - @Test - fun whenDripA2VariantAndDripA2NotificationCanShowAndClearNotificationCannotShowThenDripA2NotificationIsScheduled() = runBlocking { - setDripA2Variant() - whenever(dripA2Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripA2NotificationWorker::class.jvmName) - } - - @Test - fun whenDripA2VariantAndClearNotificationCanShotAndDripA2NotificationCannotShowThenClearDataNotificationScheduled() = runBlocking { - setDripA2Variant() - whenever(dripA2Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) - } - - @Test - fun whenDripA2VariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setDripA2Variant() - whenever(dripA2Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNoNotificationScheduled() - } - - @Test - fun whenDripA2VariantAndDripA2NotificationAndClearDataNotificationCanShowThenDripA2NotificationScheduled() = runBlocking { - setDripA2Variant() - whenever(dripA2Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripA2NotificationWorker::class.jvmName) - } - - // Drip B1 - - @Test - fun whenDripB1VariantAndDripB1NotificationCanShowAndClearNotificationCannotShowThenDripB1NotificationIsScheduled() = runBlocking { - setDripB1Variant() - whenever(dripB1Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripB1NotificationWorker::class.jvmName) - } - - @Test - fun whenDripB1VariantAndClearNotificationCanShotAndDripB1NotificationCannotShowThenClearDataNotificationScheduled() = runBlocking { - setDripB1Variant() - whenever(dripB1Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) - } - - @Test - fun whenDripB1VariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setDripB1Variant() - whenever(dripB1Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNoNotificationScheduled() - } - - @Test - fun whenDripB1VariantAndDripB1NotificationAndClearDataNotificationCanShowThenDripB1NotificationScheduled() = runBlocking { - setDripB1Variant() - whenever(dripB1Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(true) - - testee.scheduleNextNotification() - - assertNotificationScheduled(DripB1NotificationWorker::class.jvmName) - } - - // Drip B2 - - @Test - fun whenDripB2VariantAndDripB2NotificationCanShowAndClearNotificationCannotShowThenDripB2NotificationIsScheduled() = runBlocking { - setDripB2Variant() - whenever(dripB2Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(false) + fun whenInAppUsageVariantAndUseOurAppNotificationCanShowThenNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertNotificationScheduled(DripB2NotificationWorker::class.jvmName) + assertNotificationScheduled(UseOurAppNotificationWorker::class.jvmName, NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) } @Test - fun whenDripB2VariantAndClearNotificationCanShotAndDripB2NotificationCannotShowThenClearDataNotificationScheduled() = runBlocking { - setDripB2Variant() - whenever(dripB2Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(true) + fun whenInAppUsageVariantUseOurAppNotificationCannotShowThenNoNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageVariant() + whenever(useOurAppNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() - assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) } @Test - fun whenDripB2VariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setDripB2Variant() - whenever(dripB2Notification.canShow()).thenReturn(false) - whenever(clearNotification.canShow()).thenReturn(false) + fun whenInAppUsageSecondControlVariantThenNoNotificationScheduled() = runBlocking { + setInAppUsageSecondControlVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertNoNotificationScheduled() + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) } @Test - fun whenDripB2VariantAndDripB2NotificationAndClearDataNotificationCanShowThenDripB2NotificationScheduled() = runBlocking { - setDripB2Variant() - whenever(dripB2Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(true) + fun whenInAppUsageControlVariantThenNoNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageControlVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertNotificationScheduled(DripB2NotificationWorker::class.jvmName) + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) } - // Control @Test - fun whenControlVariantAndPrivacyNotificationAndClearDataNotificationCanShowThenPrivacyNotificationScheduled() = runBlocking { - setNotificationControlVariant() + fun whenInAppUsageControlVariantAndPrivacyNotificationClearDataCanShowThenPrivacyNotificationIsScheduled() = runBlocking { + setInAppUsageControlVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(true) - testee.scheduleNextNotification() assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test - fun whenControlVariantAndPrivacyNotificationCanShowAndClearDataCannotShowThenPrivacyNotificationScheduled() = runBlocking { - setNotificationControlVariant() + fun whenInAppUsageControlVariantAndPrivacyNotificationCanShowButClearDataCannotThenPrivacyNotificationIsScheduled() = runBlocking { + setInAppUsageControlVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(false) - testee.scheduleNextNotification() assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) } @Test - fun whenControlVariantAndPrivacyNotificationCannotShowAndClearNotificationCanThenClearNotificationScheduled() = runBlocking { - setNotificationControlVariant() + fun whenInAppUsageControlVariantAndPrivacyNotificationCannotShowAndClearNotificationCanShowThenClearNotificationScheduled() = runBlocking { + setInAppUsageControlVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(true) - testee.scheduleNextNotification() assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) } @Test - fun whenControlVariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setNotificationControlVariant() + fun whenInAppUsageControlVariantAndPrivacyNotificationAndClearNotificationCannotShowThenNoNotificationScheduled() = runBlocking { + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - assertNoNotificationScheduled() - } - - // Null variant - @Test - fun whenNullVariantAndAllNotificationsCanShowThenNoNotificationScheduled() = runBlocking { - setNotificationNullVariant() - whenever(privacyNotification.canShow()).thenReturn(true) - whenever(dripA1Notification.canShow()).thenReturn(true) - whenever(dripA2Notification.canShow()).thenReturn(true) - whenever(dripB1Notification.canShow()).thenReturn(true) - whenever(dripB2Notification.canShow()).thenReturn(true) - whenever(clearNotification.canShow()).thenReturn(true) - testee.scheduleNextNotification() assertNoNotificationScheduled() } - @Test - fun whenNullVariantAndNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { - setNotificationNullVariant() + private suspend fun givenNoInactiveUserNotifications() { whenever(privacyNotification.canShow()).thenReturn(false) - whenever(dripA1Notification.canShow()).thenReturn(false) - whenever(dripA2Notification.canShow()).thenReturn(false) - whenever(dripB1Notification.canShow()).thenReturn(false) - whenever(dripB2Notification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(false) - - testee.scheduleNextNotification() - - 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() { - whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification, Day1DripA1Notification, Day3ClearDataNotification), filterBy = { true }) - ) - } - - private fun setDripA2Variant() { - whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification, Day1DripA2Notification, Day3ClearDataNotification), filterBy = { true }) - ) - } - - private fun setDripB1Variant() { - whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification, Day1DripB1Notification, Day3ClearDataNotification), filterBy = { true }) - ) - } - - private fun setDripB2Variant() { + private fun setInAppUsageVariant() { whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification, Day1DripB2Notification, Day3ClearDataNotification), filterBy = { true }) + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.InAppUsage, + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true }) ) } - private fun setNotificationControlVariant() { + private fun setInAppUsageSecondControlVariant() { whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification, Day1PrivacyNotification, Day3ClearDataNotification), filterBy = { true }) + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true }) ) } - private fun setNotificationNullVariant() { - whenever(variantManager.getVariant()).thenReturn( - Variant("test", features = listOf(DripNotification), filterBy = { true }) - ) + private fun setInAppUsageControlVariant() { + whenever(variantManager.getVariant()).thenReturn(Variant("test", features = emptyList(), filterBy = { true })) } - 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 setDefaultVariant() { + whenever(variantManager.getVariant()).thenReturn(DEFAULT_VARIANT) } private fun assertNotificationScheduled(workerName: String, tag: String = NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG) { 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 index 88e55d3f2aa9..7a94b3398881 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/model/UseOurAppNotificationTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/model/UseOurAppNotificationTest.kt @@ -17,7 +17,9 @@ package com.duckduckgo.app.notification.model import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.notification.db.NotificationDao +import com.duckduckgo.app.settings.db.SettingsDataStore import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever @@ -30,23 +32,45 @@ class UseOurAppNotificationTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val notificationsDao: NotificationDao = mock() + private val mockSettingsDataStore: SettingsDataStore = mock() + private val mockAddToHomeCapabilityDetector: AddToHomeCapabilityDetector = mock() private lateinit var testee: UseOurAppNotification @Before fun before() { - testee = UseOurAppNotification(context, notificationsDao) + testee = UseOurAppNotification(context, notificationsDao, mockSettingsDataStore, mockAddToHomeCapabilityDetector) } @Test - fun whenNotificationNotSeenThenCanShowIsTrue() = runBlocking { + fun whenNotificationNotSeenAndHideTipsIsFalseAndAddToHomeSupportedThenCanShowIsTrue() = runBlocking { + whenever(mockAddToHomeCapabilityDetector.isAddToHomeSupported()).thenReturn(true) whenever(notificationsDao.exists(any())).thenReturn(false) + whenever(mockSettingsDataStore.hideTips).thenReturn(false) assertTrue(testee.canShow()) } @Test - fun whenNotificationAlreadySeenThenCanShowIsFalse() = runBlocking { + fun whenNotificationAlreadySeenThenCanShowIsFalse() = runBlocking { whenever(notificationsDao.exists(any())).thenReturn(true) + whenever(mockAddToHomeCapabilityDetector.isAddToHomeSupported()).thenReturn(true) + whenever(mockSettingsDataStore.hideTips).thenReturn(false) + assertFalse(testee.canShow()) + } + + @Test + fun whenHideTipsIsTrueThenCanShowIsFalse() = runBlocking { + whenever(notificationsDao.exists(any())).thenReturn(false) + whenever(mockAddToHomeCapabilityDetector.isAddToHomeSupported()).thenReturn(true) + whenever(mockSettingsDataStore.hideTips).thenReturn(true) + assertFalse(testee.canShow()) + } + + @Test + fun whenAddToHomeNotSupportedThenCanShowIsFalse() = runBlocking { + whenever(notificationsDao.exists(any())).thenReturn(false) + whenever(mockAddToHomeCapabilityDetector.isAddToHomeSupported()).thenReturn(false) + whenever(mockSettingsDataStore.hideTips).thenReturn(false) 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 554b8aa6e6d8..6224b3ab5585 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 @@ -17,14 +17,21 @@ package com.duckduckgo.app.onboarding.store import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.Variant +import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT +import com.nhaarman.mockitokotlin2.any 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.assertEquals import org.junit.Rule import org.junit.Test +import java.util.concurrent.TimeUnit @ExperimentalCoroutinesApi class AppUserStageStoreTest { @@ -32,12 +39,14 @@ class AppUserStageStoreTest { @get:Rule var coroutineRule = CoroutineTestRule() - private val userStageDao = mock() + private val userStageDao: UserStageDao = mock() + private val variantManager: VariantManager = mock() + private val appInstallStore: AppInstallStore = mock() - private val testee = AppUserStageStore(userStageDao, coroutineRule.testDispatcherProvider) + private val testee = AppUserStageStore(userStageDao, coroutineRule.testDispatcherProvider, variantManager, appInstallStore) @Test - fun whenGetUserAppStageThenRetunCurrentStage() = coroutineRule.runBlocking { + fun whenGetUserAppStageThenReturnCurrentStage() = coroutineRule.runBlocking { givenCurrentStage(AppStage.DAX_ONBOARDING) val userAppStage = testee.getUserAppStage() @@ -96,6 +105,54 @@ class AppUserStageStoreTest { verify(userStageDao).updateUserStage(AppStage.USE_OUR_APP_ONBOARDING) } + @Test + fun whenAppResumedAndInstalledFor3DaysAndKillOnboardingFeatureNotActiveIfUserInOnboardingThenDoNotUpdateUserStage() = coroutineRule.runBlocking { + givenDefaultVariant() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 3) + + testee.onAppResumed() + + verify(userStageDao, never()).updateUserStage(AppStage.ESTABLISHED) + } + + @Test + fun whenAppResumedAndInstalledFor3DaysAndKillOnboardingFeatureActiveIfUserInOnboardingThenMoveToEstablished() = coroutineRule.runBlocking { + givenKillOnboardingFeature() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 3) + + testee.onAppResumed() + + verify(userStageDao).updateUserStage(AppStage.ESTABLISHED) + } + + @Test + fun whenAppResumedAndInstalledForLess3DaysAndKillOnboardingFeatureActiveThenDoNotUpdateUserStage() = coroutineRule.runBlocking { + givenKillOnboardingFeature() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 2) + + testee.onAppResumed() + + verify(userStageDao, never()).updateUserStage(any()) + } + + private fun givenAppInstalledByDays(days: Long) { + whenever(appInstallStore.hasInstallTimestampRecorded()).thenReturn(true) + whenever(appInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days) - 1) + } + + private fun givenDefaultVariant() { + whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + } + + private fun givenKillOnboardingFeature() { + whenever(variantManager.getVariant()).thenReturn( + Variant("test", features = listOf(VariantManager.VariantFeature.KillOnboarding), filterBy = { true }) + ) + } + private suspend fun givenCurrentStage(appStage: AppStage) { whenever(userStageDao.currentUserAppStage()).thenReturn(UserStage(appStage = appStage)) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt index 4146285b527a..f96a39068ebd 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt @@ -137,14 +137,14 @@ class VariantManagerTest { @Test fun serpHeaderControlVariantHasExpectedWeightAndNoFeatures() { val variant = variants.first { it.key == "zg" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(0, variant.features.size) } @Test fun serpHeaderVariantHasExpectedWeightAndSERPHeaderRemovalFeature() { val variant = variants.first { it.key == "zi" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(1, variant.features.size) assertEquals(SerpHeaderRemoval, variant.features[0]) } @@ -152,11 +152,37 @@ class VariantManagerTest { @Test fun serpHeaderVariantHasExpectedWeightAndSERPHeaderQueryReplacementFeature() { val variant = variants.first { it.key == "zh" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(1, variant.features.size) assertEquals(SerpHeaderQueryReplacement, variant.features[0]) } + @Test + fun inBrowserControlVariantHasExpectedWeightAndNoFeatures() { + val variant = variants.first { it.key == "zj" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(0, variant.features.size) + } + + @Test + fun inBrowserSecondControlVariantHasExpectedWeightAndRemoveDay1And3NotificationsAndKillOnboardingFeatures() { + val variant = variants.first { it.key == "zk" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(2, variant.features.size) + assertTrue(variant.hasFeature(KillOnboarding)) + assertTrue(variant.hasFeature(RemoveDay1AndDay3Notifications)) + } + + @Test + fun inBrowserInAppUsageVariantHasExpectedWeightAndRemoveDay1And3NotificationsAndKillOnboardingAndInAppUsageFeatures() { + val variant = variants.first { it.key == "zl" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(3, variant.features.size) + assertTrue(variant.hasFeature(KillOnboarding)) + assertTrue(variant.hasFeature(RemoveDay1AndDay3Notifications)) + assertTrue(variant.hasFeature(InAppUsage)) + } + @Test fun verifyNoDuplicateVariantNames() { val existingNames = mutableSetOf() diff --git a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt index 9cf35607a05b..7071b0524771 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -273,7 +273,7 @@ class SystemSearchViewModelTest { override suspend fun currentUserAppStage() = UserStage(appStage = AppStage.NEW) override fun insert(userStage: UserStage) {} } - return AppUserStageStore(emptyUserStageDao, coroutineRule.testDispatcherProvider) + return AppUserStageStore(emptyUserStageDao, coroutineRule.testDispatcherProvider, mock(), mock()) } companion object { 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 2807e5d9583d..45caf5cb5ba8 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 @@ -28,8 +28,11 @@ 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.events.db.UserEventsStore import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory +import com.duckduckgo.app.global.useourapp.UseOurAppDetector +import com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_DOMAIN import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.tabs.db.TabsDao import com.duckduckgo.app.trackerdetection.EntityLookup @@ -37,6 +40,7 @@ import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.anyOrNull import com.nhaarman.mockitokotlin2.argumentCaptor import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,8 +49,6 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations class TabDataRepositoryTest { @@ -62,30 +64,30 @@ class TabDataRepositoryTest { @get:Rule var coroutinesTestRule = CoroutineTestRule() - @Mock - private lateinit var mockDao: TabsDao + private val mockDao: TabsDao = mock() - @Mock - private lateinit var mockPrivacyPractices: PrivacyPractices + private val mockPrivacyPractices: PrivacyPractices = mock() - @Mock - private lateinit var mockEntityLookup: EntityLookup + private val mockEntityLookup: EntityLookup = mock() - @Mock - private lateinit var mockWebViewPreviewPersister: WebViewPreviewPersister + private val mockWebViewPreviewPersister: WebViewPreviewPersister = mock() + + private val mockUserEventsStore: UserEventsStore = mock() + + private val useOurAppDetector = UseOurAppDetector(mockUserEventsStore) private lateinit var testee: TabDataRepository @UiThreadTest @Before fun before() { - MockitoAnnotations.initMocks(this) runBlocking { whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) testee = TabDataRepository( mockDao, SiteFactory(mockPrivacyPractices, mockEntityLookup), - mockWebViewPreviewPersister + mockWebViewPreviewPersister, + useOurAppDetector ) } } @@ -239,7 +241,7 @@ class TabDataRepositoryTest { 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 = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister, useOurAppDetector) testee.selectByUrlOrNewTab("http://www.example.com") @@ -254,7 +256,7 @@ class TabDataRepositoryTest { val db = createDatabase() val dao = db.tabsDao() - testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister) + testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister, useOurAppDetector) testee.selectByUrlOrNewTab("http://www.example.com") @@ -264,6 +266,37 @@ class TabDataRepositoryTest { db.close() } + @Test + fun whenSelectByUrlOrNewTabIfUrlAlreadyExistedInATabAndMatchesTheUseOurAppDomainThenSelectTheTab() = runBlocking { + val db = createDatabase() + val dao = db.tabsDao() + dao.insertTab(TabEntity(tabId = "id", url = "http://www.$USE_OUR_APP_DOMAIN/test", skipHome = false, viewed = true, position = 0)) + + testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister, useOurAppDetector) + + testee.selectByUrlOrNewTab("http://m.$USE_OUR_APP_DOMAIN") + + val value = testee.liveSelectedTab.blockingObserve()?.tabId + assertEquals("id", value) + + db.close() + } + + @Test + fun whenSelectByUrlOrNewTabIfUrlNotExistedInATabAndUrlMatchesUseOurAppDomainThenAddNewTabWithCorrectUrl() = runBlocking { + val db = createDatabase() + val dao = db.tabsDao() + + testee = TabDataRepository(dao, SiteFactory(mockPrivacyPractices, mockEntityLookup), mockWebViewPreviewPersister, useOurAppDetector) + + testee.selectByUrlOrNewTab("http://m.$USE_OUR_APP_DOMAIN") + + val value = testee.liveSelectedTab.blockingObserve()?.url + assertEquals("http://m.$USE_OUR_APP_DOMAIN", value) + + db.close() + } + private fun createDatabase(): AppDatabase { return Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() 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 b77e991ee24c..a1a2edf8d17d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -673,6 +673,7 @@ class BrowserTabViewModel( 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) + else -> pixel.fire(PixelName.UOA_VISITED) } } } 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 36f50fdb69f3..99aae2e61b37 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -27,8 +27,6 @@ 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 @@ -38,7 +36,7 @@ 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.global.useourapp.UseOurAppDetector 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 @@ -55,9 +53,9 @@ class BrowserViewModel( private val dataClearer: DataClearer, private val appEnjoymentPromptEmitter: AppEnjoymentPromptEmitter, private val appEnjoymentUserEventRecorder: AppEnjoymentUserEventRecorder, - private val ctaDao: DismissedCtaDao, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), - private val pixel: Pixel + private val pixel: Pixel, + private val useOurAppDetector: UseOurAppDetector ) : AppEnjoymentDialogFragment.Listener, RateAppDialogFragment.Listener, GiveFeedbackDialogFragment.Listener, @@ -210,7 +208,7 @@ class BrowserViewModel( 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) { + if (useOurAppDetector.isUseOurAppUrl(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/shortcut/ShortcutBuilder.kt b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutBuilder.kt index 5fefe3ac6ce2..cff139b23c7c 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 @@ -26,11 +26,11 @@ 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 com.duckduckgo.app.global.useourapp.UseOurAppDetector.Companion.USE_OUR_APP_SHORTCUT_URL +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import java.util.UUID import javax.inject.Inject -class ShortcutBuilder @Inject constructor() { +class ShortcutBuilder @Inject constructor(private val useOurAppDetector: UseOurAppDetector) { private fun buildPinnedPageShortcut(context: Context, homeShortcut: BrowserTabViewModel.Command.AddHomeShortcut): ShortcutInfoCompat { val intent = Intent(context, BrowserActivity::class.java) @@ -39,7 +39,7 @@ class ShortcutBuilder @Inject constructor() { intent.putExtra(SHORTCUT_EXTRA_ARG, true) val icon = when { - homeShortcut.url == USE_OUR_APP_SHORTCUT_URL -> IconCompat.createWithResource(context, R.drawable.ic_fb_favicon) + useOurAppDetector.isUseOurAppUrl(homeShortcut.url) -> IconCompat.createWithResource(context, R.drawable.ic_fb_favicon) homeShortcut.icon != null -> IconCompat.createWithBitmap(homeShortcut.icon) else -> IconCompat.createWithResource(context, R.drawable.logo_mini) } 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 index f7354be5fa05..761436f3f214 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt @@ -24,12 +24,11 @@ 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.global.useourapp.UseOurAppDetector +import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -37,8 +36,9 @@ import javax.inject.Inject class ShortcutReceiver @Inject constructor( private val userEventsStore: UserEventsStore, - private val ctaDao: DismissedCtaDao, private val dispatcher: DispatcherProvider, + private val useOurAppDetector: UseOurAppDetector, + private val variantManager: VariantManager, private val pixel: Pixel ) : BroadcastReceiver() { @@ -54,9 +54,11 @@ class ShortcutReceiver @Inject constructor( } 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) + if (useOurAppDetector.isUseOurAppUrl(originUrl)) { pixel.fire(Pixel.PixelName.USE_OUR_APP_SHORTCUT_ADDED) + if (variantManager.getVariant().hasFeature(VariantManager.VariantFeature.InAppUsage)) { + userEventsStore.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } } else { pixel.fire(Pixel.PixelName.SHORTCUT_ADDED) } 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 088f39bb3bc2..836d1867c5a7 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt @@ -21,7 +21,6 @@ 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 @@ -42,9 +41,8 @@ class DatabaseModule { fun provideDatabaseMigrations( context: Context, settingsDataStore: SettingsDataStore, - addToHomeCapabilityDetector: AddToHomeCapabilityDetector, - useOurAppMigrationManager: UseOurAppMigrationManager + addToHomeCapabilityDetector: AddToHomeCapabilityDetector ): MigrationsProvider { - return MigrationsProvider(context, settingsDataStore, addToHomeCapabilityDetector, useOurAppMigrationManager) + return MigrationsProvider(context, settingsDataStore, addToHomeCapabilityDetector) } } 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 e67f7f83776b..a224d7921131 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt @@ -21,6 +21,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.WorkManager +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.notification.AndroidNotificationScheduler import com.duckduckgo.app.notification.NotificationFactory import com.duckduckgo.app.notification.NotificationHandlerService @@ -31,7 +32,6 @@ 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 @@ -64,9 +64,11 @@ class NotificationModule { @Provides fun provideUseOurAppNotification( context: Context, - notificationDao: NotificationDao + notificationDao: NotificationDao, + settingsDataStore: SettingsDataStore, + addToHomeCapabilityDetector: AddToHomeCapabilityDetector ): UseOurAppNotification { - return UseOurAppNotification(context, notificationDao) + return UseOurAppNotification(context, notificationDao, settingsDataStore, addToHomeCapabilityDetector) } @Provides @@ -156,23 +158,15 @@ class NotificationModule { workManager: WorkManager, clearDataNotification: ClearDataNotification, privacyProtectionNotification: PrivacyProtectionNotification, - @Named("dripA1Notification") dripA1Notification: WebsiteNotification, - @Named("dripA2Notification") dripA2Notification: WebsiteNotification, - @Named("dripB1Notification") dripB1Notification: AppFeatureNotification, - @Named("dripB2Notification") dripB2Notification: AppFeatureNotification, - variantManager: VariantManager, - stageStore: UserStageStore + useOurAppNotification: UseOurAppNotification, + variantManager: VariantManager ): AndroidNotificationScheduler { return NotificationScheduler( workManager, clearDataNotification, privacyProtectionNotification, - dripA1Notification, - dripA2Notification, - dripB1Notification, - dripB2Notification, - variantManager, - stageStore + useOurAppNotification, + variantManager ) } 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 0f82e7fd951e..2b3007fb50c2 100644 --- a/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt +++ b/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt @@ -43,6 +43,7 @@ import com.duckduckgo.app.httpsupgrade.HttpsUpgrader import com.duckduckgo.app.job.AppConfigurationSyncer import com.duckduckgo.app.job.WorkScheduler import com.duckduckgo.app.notification.NotificationRegistrar +import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.referral.AppInstallationReferrerStateListener import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.AtbInitializer @@ -159,6 +160,9 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO @Inject lateinit var variantManager: VariantManager + @Inject + lateinit var userStageStore: UserStageStore + private var launchedByFireAction: Boolean = false open lateinit var daggerAppComponent: AppComponent @@ -181,6 +185,7 @@ open class DuckDuckGoApplication : HasAndroidInjector, Application(), LifecycleO it.addObserver(defaultBrowserObserver) it.addObserver(appEnjoymentLifecycleObserver) it.addObserver(dataClearerForegroundAppRestartPixel) + it.addObserver(userStageStore) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { 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 eec4fb406830..3d77ff5d4d08 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -181,7 +181,7 @@ class ViewModelFactory @Inject constructor( dataClearer = dataClearer, appEnjoymentPromptEmitter = appEnjoymentPromptEmitter, appEnjoymentUserEventRecorder = appEnjoymentUserEventRecorder, - ctaDao = dismissedCtaDao, + useOurAppDetector = userOurAppDetector, pixel = pixel ) } 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 936ee90715f8..312e012e1f48 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 @@ -39,7 +39,6 @@ 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 @@ -62,10 +61,9 @@ 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 = 22, entities = [ + exportSchema = true, version = 23, entities = [ TdsTracker::class, TdsEntity::class, TdsDomainEntity::class, @@ -135,8 +133,7 @@ abstract class AppDatabase : RoomDatabase() { class MigrationsProvider( val context: Context, val settingsDataStore: SettingsDataStore, - val addToHomeCapabilityDetector: AddToHomeCapabilityDetector, - val useOurAppMigrationManager: MigrationManager + val addToHomeCapabilityDetector: AddToHomeCapabilityDetector ) { val MIGRATION_1_TO_2: Migration = object : Migration(1, 2) { @@ -310,21 +307,13 @@ class MigrationsProvider( 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 MIGRATION_22_TO_23: Migration = object : Migration(22, 23) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("UPDATE $USER_STAGE_TABLE_NAME SET appStage = \"${AppStage.ESTABLISHED}\" WHERE appStage = \"${AppStage.USE_OUR_APP_NOTIFICATION}\"") + } } val ALL_MIGRATIONS: List @@ -349,20 +338,10 @@ class MigrationsProvider( MIGRATION_18_TO_19, MIGRATION_19_TO_20, MIGRATION_20_TO_21, - MIGRATION_21_TO_22 + MIGRATION_21_TO_22, + MIGRATION_22_TO_23 ) - 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/useourapp/UseOurAppDetector.kt b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt index a526bc9ed71e..34e41682dafc 100644 --- a/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/global/useourapp/UseOurAppDetector.kt @@ -69,5 +69,6 @@ class UseOurAppDetector @Inject constructor(val userEventsStore: UserEventsStore 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" + const val USE_OUR_APP_DOMAIN_QUERY = "%facebook.com%" } } 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 8fef0e7a2d23..7adbb504c8da 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt @@ -23,17 +23,7 @@ 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 -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripB1Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripA2Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1DripA1Notification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day1PrivacyNotification -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.Day3ClearDataNotification import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_SHOWN import timber.log.Timber @@ -50,12 +40,8 @@ class NotificationScheduler( private val workManager: WorkManager, private val clearDataNotification: SchedulableNotification, private val privacyNotification: SchedulableNotification, - private val dripA1Notification: SchedulableNotification, - private val dripA2Notification: SchedulableNotification, - private val dripB1Notification: SchedulableNotification, - private val dripB2Notification: SchedulableNotification, - private val variantManager: VariantManager, - private val userStageStore: UserStageStore + private val useOurAppNotification: SchedulableNotification, + private val variantManager: VariantManager ) : AndroidNotificationScheduler { override suspend fun scheduleNextNotification() { @@ -64,16 +50,15 @@ class NotificationScheduler( } private suspend fun scheduleUseOurAppNotification() { - if (userStageStore.useOurAppNotification()) { + if (variant().hasFeature(VariantManager.VariantFeature.InAppUsage) && useOurAppNotification.canShow()) { val operation = scheduleUniqueNotification( OneTimeWorkRequestBuilder(), - 1, + 3, 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") } @@ -84,22 +69,10 @@ class NotificationScheduler( workManager.cancelAllWorkByTag(UNUSED_APP_WORK_REQUEST_TAG) when { - variant().hasFeature(Day1DripA1Notification) && dripA1Notification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) - } - variant().hasFeature(Day1DripA2Notification) && dripA2Notification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) - } - variant().hasFeature(Day1DripB1Notification) && dripB1Notification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) - } - variant().hasFeature(Day1DripB2Notification) && dripB2Notification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) - } - (isNotDripVariant() || isDripVariantAndHasPrivacyFeature()) && privacyNotification.canShow() -> { + (!variant().hasFeature(VariantManager.VariantFeature.RemoveDay1AndDay3Notifications) && privacyNotification.canShow()) -> { scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } - (isNotDripVariant() || isDripVariantAndHasClearDataFeature()) && clearDataNotification.canShow() -> { + (!variant().hasFeature(VariantManager.VariantFeature.RemoveDay1AndDay3Notifications) && clearDataNotification.canShow()) -> { scheduleNotification(OneTimeWorkRequestBuilder(), 3, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } else -> Timber.v("Notifications not enabled for this variant") @@ -108,14 +81,6 @@ class NotificationScheduler( private fun variant() = variantManager.getVariant() - private fun isDripVariantAndHasPrivacyFeature(): Boolean = isFromDripNotificationVariant() && variant().hasFeature(Day1PrivacyNotification) - - private fun isDripVariantAndHasClearDataFeature(): Boolean = isFromDripNotificationVariant() && variant().hasFeature(Day3ClearDataNotification) - - private fun isFromDripNotificationVariant(): Boolean = variant().hasFeature(DripNotification) - - 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 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 index 97b2e4eef052..0b376cc279ef 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/model/UseOurAppNotification.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/model/UseOurAppNotification.kt @@ -19,16 +19,20 @@ package com.duckduckgo.app.notification.model import android.content.Context import android.os.Bundle import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector 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.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import timber.log.Timber class UseOurAppNotification( private val context: Context, - private val notificationDao: NotificationDao + private val notificationDao: NotificationDao, + private val settingsDataStore: SettingsDataStore, + private val addToHomeCapabilityDetector: AddToHomeCapabilityDetector ) : SchedulableNotification { override val id = ID @@ -36,7 +40,7 @@ class UseOurAppNotification( override val cancelIntent = CANCEL override suspend fun canShow(): Boolean { - if (notificationDao.exists(id)) { + if (notificationDao.exists(id) || settingsDataStore.hideTips || !addToHomeCapabilityDetector.isAddToHomeSupported()) { Timber.v("Notification already seen") return false } 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 6c517284bbf1..8a76b7cf8127 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 @@ -16,11 +16,19 @@ package com.duckduckgo.app.onboarding.store +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.statistics.VariantManager +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit import javax.inject.Inject -interface UserStageStore { +interface UserStageStore : LifecycleObserver { suspend fun getUserAppStage(): AppStage suspend fun stageCompleted(appStage: AppStage): AppStage suspend fun moveToStage(appStage: AppStage) @@ -28,8 +36,18 @@ interface UserStageStore { class AppUserStageStore @Inject constructor( private val userStageDao: UserStageDao, - private val dispatcher: DispatcherProvider -) : UserStageStore { + private val dispatcher: DispatcherProvider, + private val variantManager: VariantManager, + private val appInstallStore: AppInstallStore +) : UserStageStore, LifecycleObserver { + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun onAppResumed() { + GlobalScope.launch(dispatcher.io()) { + moveUserToEstablished3DaysAfterInstall() + } + } + override suspend fun getUserAppStage(): AppStage { return withContext(dispatcher.io()) { val userStage = userStageDao.currentUserAppStage() @@ -58,6 +76,17 @@ class AppUserStageStore @Inject constructor( override suspend fun moveToStage(appStage: AppStage) { userStageDao.updateUserStage(appStage) } + + private suspend fun moveUserToEstablished3DaysAfterInstall() { + if (variantManager.getVariant().hasFeature(VariantManager.VariantFeature.KillOnboarding)) { + if (appInstallStore.hasInstallTimestampRecorded() && daxOnboardingActive()) { + val days = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - appInstallStore.installTimestamp) + if (days >= 3) { + moveToStage(AppStage.ESTABLISHED) + } + } + } + } } suspend fun UserStageStore.isNewUser(): Boolean { @@ -68,6 +97,10 @@ suspend fun UserStageStore.daxOnboardingActive(): Boolean { return this.getUserAppStage() == AppStage.DAX_ONBOARDING } +suspend fun UserStageStore.isEstablished(): Boolean { + return this.getUserAppStage() == AppStage.ESTABLISHED +} + suspend fun UserStageStore.useOurAppOnboarding(): Boolean { return this.getUserAppStage() == AppStage.USE_OUR_APP_ONBOARDING } diff --git a/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt b/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt index 446d4b981881..aa17a87b3a13 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt @@ -37,6 +37,9 @@ interface VariantManager { object Day1DripB2Notification : VariantFeature() object SerpHeaderQueryReplacement : VariantFeature() object SerpHeaderRemoval : VariantFeature() + object InAppUsage : VariantFeature() + object KillOnboarding : VariantFeature() + object RemoveDay1AndDay3Notifications : VariantFeature() } companion object { @@ -85,22 +88,22 @@ interface VariantManager { filterBy = { isEnglishLocale() }), // Single Search Bar Experiments - // Disabled until Drip Notifications Experiments are completed - Variant( - key = "zg", - weight = 1.0, - features = emptyList(), - filterBy = { noFilter() }), + Variant(key = "zg", weight = 0.0, features = emptyList(), filterBy = { noFilter() }), + Variant(key = "zh", weight = 0.0, features = listOf(VariantFeature.SerpHeaderQueryReplacement), filterBy = { noFilter() }), + Variant(key = "zi", weight = 0.0, features = listOf(VariantFeature.SerpHeaderRemoval), filterBy = { noFilter() }), + + // InAppUsage Experiments + Variant(key = "zj", weight = 1.0, features = emptyList(), filterBy = { isEnglishLocale() }), Variant( - key = "zh", + key = "zk", weight = 1.0, - features = listOf(VariantFeature.SerpHeaderQueryReplacement), - filterBy = { noFilter() }), + features = listOf(VariantFeature.KillOnboarding, VariantFeature.RemoveDay1AndDay3Notifications), + filterBy = { isEnglishLocale() }), Variant( - key = "zi", + key = "zl", weight = 1.0, - features = listOf(VariantFeature.SerpHeaderRemoval), - filterBy = { noFilter() }) + features = listOf(VariantFeature.KillOnboarding, VariantFeature.InAppUsage, VariantFeature.RemoveDay1AndDay3Notifications), + filterBy = { isEnglishLocale() }) // All groups in an experiment (control and variants) MUST use the same filters ) 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 461029cc22a0..a7d8357b5039 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 @@ -203,6 +203,7 @@ interface Pixel { UOA_VISITED_AFTER_SHORTCUT("m_uoa_vas"), UOA_VISITED_AFTER_NOTIFICATION("m_uoa_van"), UOA_VISITED_AFTER_DELETE_CTA("m_uoa_vad"), + UOA_VISITED("m_uoa_v"), USE_OUR_APP_SHORTCUT_OPENED("m_sho_uoa_o"), SHORTCUT_ADDED("m_sho_a"), 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 04a8bc29d419..b0992d22a882 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,8 +44,8 @@ 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? + @Query("select tabId from tabs where url LIKE :query") + abstract suspend fun selectTabByUrl(query: 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 ae37751c3454..9081db6074e8 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 @@ -22,6 +22,7 @@ import androidx.lifecycle.MutableLiveData import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory +import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.tabs.db.TabsDao import io.reactivex.Scheduler import io.reactivex.schedulers.Schedulers @@ -36,7 +37,8 @@ import javax.inject.Singleton class TabDataRepository @Inject constructor( private val tabsDao: TabsDao, private val siteFactory: SiteFactory, - private val webViewPreviewPersister: WebViewPreviewPersister + private val webViewPreviewPersister: WebViewPreviewPersister, + private val useOurAppDetector: UseOurAppDetector ) : TabRepository { override val liveTabs: LiveData> = tabsDao.liveTabs() @@ -86,7 +88,13 @@ class TabDataRepository @Inject constructor( } override suspend fun selectByUrlOrNewTab(url: String) { - val tabId = tabsDao.selectTabByUrl(url) + val query = if (useOurAppDetector.isUseOurAppUrl(url)) { + UseOurAppDetector.USE_OUR_APP_DOMAIN_QUERY + } else { + url + } + + val tabId = tabsDao.selectTabByUrl(query) if (tabId != null) { select(tabId) } else {