diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationSchedulerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationSchedulerTest.kt index d5493610aa27..40c32cc0d77a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationSchedulerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationSchedulerTest.kt @@ -14,7 +14,6 @@ * limitations under the License. */ - @file:Suppress("RemoveExplicitTypeArguments") package com.duckduckgo.app.notification @@ -23,9 +22,9 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager import com.duckduckgo.app.CoroutineTestRule -import com.duckduckgo.app.notification.NotificationScheduler.ClearDataNotificationWorker -import com.duckduckgo.app.notification.NotificationScheduler.PrivacyNotificationWorker +import com.duckduckgo.app.notification.AndroidNotificationScheduler.* import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.notification.model.SearchNotification import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT import com.nhaarman.mockitokotlin2.any @@ -33,6 +32,7 @@ import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -48,6 +48,7 @@ class NotificationSchedulerTest { private val variantManager: VariantManager = mock() private val clearNotification: SchedulableNotification = mock() private val privacyNotification: SchedulableNotification = mock() + private val searchPromptNotification: SearchNotification = mock() private val context = InstrumentationRegistry.getInstrumentation().targetContext private var workManager = WorkManager.getInstance(context) @@ -56,56 +57,137 @@ class NotificationSchedulerTest { @Before fun before() { whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) - testee = NotificationScheduler( + testee = AndroidNotificationScheduler( workManager, clearNotification, - privacyNotification + privacyNotification, + searchPromptNotification ) } + @After + fun resetWorkers() { + workManager.cancelAllWorkByTag(AndroidNotificationScheduler.CONTINUOUS_APP_USE_REQUEST_TAG) + } + + @Test + fun whenPrivacyNotificationClearDataAndSearchPromptCanShowThenBothAreScheduled() = runBlocking { + whenever(privacyNotification.canShow()).thenReturn(true) + whenever(clearNotification.canShow()).thenReturn(true) + whenever(searchPromptNotification.canShow()).thenReturn(true) + testee.scheduleNextNotification() + + assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertContinuousAppUseNotificationScheduled(SearchPromptNotificationWorker::class.jvmName) + } + @Test - fun whenBothPrivacyNotificationAndCleatDataCanShowThenPrivacyNotificationScheduled() = runBlocking { + fun whenPrivacyNotificationClearDataAndSearchPromptCanShowThenPrivacyNotificationScheduled() = runBlocking { whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(true) + whenever(searchPromptNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() - assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + + assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNoContinuousAppNotificationScheduled() } @Test - fun whenPrivacyNotificationCanShowAndCleatDataCannotThenPrivacyNotificationScheduled() = runBlocking { + fun whenPrivacyNotificationAndSearchPromptCanShowButClearDataCannotThenThenBothAreScheduled() = runBlocking { whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(false) + whenever(searchPromptNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + + assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertContinuousAppUseNotificationScheduled(SearchPromptNotificationWorker::class.jvmName) } @Test - fun whenPrivacyNotificationCannotShowAndClearNotificationCanShowThenNotificationScheduled() = runBlocking { + fun whenPrivacyNotificationCanShowButClearDataAndSearchPromptCannotThenPrivacyNotificationScheduled() = runBlocking { + whenever(privacyNotification.canShow()).thenReturn(true) + whenever(clearNotification.canShow()).thenReturn(false) + whenever(searchPromptNotification.canShow()).thenReturn(false) + testee.scheduleNextNotification() + + assertUnusedAppNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + assertNoContinuousAppNotificationScheduled() + } + + @Test + fun whenPrivacyNotificationAndSearchPromptCannotShowAndClearNotificationCanShowThenBothAreScheduled() = runBlocking { whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(true) + whenever(searchPromptNotification.canShow()).thenReturn(true) + testee.scheduleNextNotification() + + assertUnusedAppNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + assertContinuousAppUseNotificationScheduled(SearchPromptNotificationWorker::class.jvmName) + } + + @Test + fun whenPrivacyNotificationAndClearNotificationCannotShowButSearchPromptCanShowThenNotificationScheduled() = runBlocking { + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + whenever(searchPromptNotification.canShow()).thenReturn(true) + testee.scheduleNextNotification() + + assertContinuousAppUseNotificationScheduled(SearchPromptNotificationWorker::class.jvmName) + assertNoUnusedAppNotificationScheduled() + } + + @Test + fun whenPrivacyNotificationAndClearNotificationCannotShowButSearchPromptCanThenSearchPromptNotificationScheduled() = runBlocking { + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + whenever(searchPromptNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() - assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + + assertContinuousAppUseNotificationScheduled(SearchPromptNotificationWorker::class.jvmName) + assertNoUnusedAppNotificationScheduled() } @Test fun whenNoNotificationCanShowThenNoNotificationScheduled() = runBlocking { whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(false) + whenever(searchPromptNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() + assertNoNotificationScheduled() } - private fun assertNotificationScheduled(workerName: String) { - assertTrue(getScheduledWorkers().any { it.tags.contains(workerName) }) + private fun assertUnusedAppNotificationScheduled(workerName: String) { + assertTrue(getUnusedAppScheduledWorkers().any { it.tags.contains(workerName) }) + } + + private fun assertContinuousAppUseNotificationScheduled(workerName: String) { + assertTrue(getContinuousAppUseScheduledWorkers().any { it.tags.contains(workerName) }) + } + + private fun assertNoUnusedAppNotificationScheduled() { + assertTrue(getUnusedAppScheduledWorkers().isEmpty()) + } + + private fun assertNoContinuousAppNotificationScheduled() { + assertTrue(getContinuousAppUseScheduledWorkers().isEmpty()) } private fun assertNoNotificationScheduled() { - assertTrue(getScheduledWorkers().isEmpty()) + assertTrue(getUnusedAppScheduledWorkers().isEmpty()) + assertTrue(getContinuousAppUseScheduledWorkers().isEmpty()) + } + + private fun getUnusedAppScheduledWorkers(): List { + return workManager + .getWorkInfosByTag(AndroidNotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG) + .get() + .filter { it.state == WorkInfo.State.ENQUEUED } } - private fun getScheduledWorkers(): List { + private fun getContinuousAppUseScheduledWorkers(): List { return workManager - .getWorkInfosByTag(NotificationScheduler.WORK_REQUEST_TAG) + .getWorkInfosByTag(AndroidNotificationScheduler.CONTINUOUS_APP_USE_REQUEST_TAG) .get() .filter { it.state == WorkInfo.State.ENQUEUED } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt index 8ea271c1e3a0..b9f7a2e7af9d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt @@ -25,6 +25,7 @@ import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.global.DuckDuckGoTheme import com.duckduckgo.app.icon.api.AppIcon +import com.duckduckgo.app.notification.NotificationScheduler import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.settings.clear.ClearWhatOption.CLEAR_NONE import com.duckduckgo.app.settings.clear.ClearWhenOption.APP_EXIT_ONLY @@ -32,7 +33,11 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel -import com.nhaarman.mockitokotlin2.* +import com.nhaarman.mockitokotlin2.KArgumentCaptor +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.atLeastOnce +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever import org.junit.Assert.* import org.junit.Before import org.junit.Rule @@ -62,6 +67,9 @@ class SettingsViewModelTest { @Mock private lateinit var mockVariantManager: VariantManager + @Mock + private lateinit var notificationScheduler: NotificationScheduler + @Mock private lateinit var mockPixel: Pixel @@ -74,7 +82,7 @@ class SettingsViewModelTest { context = InstrumentationRegistry.getInstrumentation().targetContext commandCaptor = argumentCaptor() - testee = SettingsViewModel(mockAppSettingsDataStore, mockDefaultBrowserDetector, mockVariantManager, mockPixel) + testee = SettingsViewModel(mockAppSettingsDataStore, mockDefaultBrowserDetector, mockVariantManager, mockPixel, notificationScheduler) testee.command.observeForever(commandObserver) whenever(mockAppSettingsDataStore.automaticallyClearWhenOption).thenReturn(APP_EXIT_ONLY) @@ -222,5 +230,32 @@ class SettingsViewModelTest { assertEquals(Command.LaunchAppIcon, commandCaptor.firstValue) } + @Test + fun whenSearchNotificationWasPreviouslyEnabledThenViewStateIndicatesIt() { + whenever(mockAppSettingsDataStore.searchNotificationEnabled).thenReturn(true) + testee.start() + assertTrue(latestViewState().searchNotificationEnabled) + } + + @Test + fun whenSearchNotificationToggledOnThenDataStoreIsUpdatedAndNotificationShown() { + testee.onSearchNotificationSettingChanged(true) + verify(mockAppSettingsDataStore).searchNotificationEnabled = true + verify(notificationScheduler).launchStickySearchNotification() + verify(mockPixel).fire(Pixel.PixelName.QUICK_SEARCH_NOTIFICATION_ENABLED) + + assertTrue(latestViewState().searchNotificationEnabled) + } + + @Test + fun whenSearchNotificationToggledOffThenDataStoreIsUpdatedAndNotificationRemoved() { + testee.onSearchNotificationSettingChanged(false) + verify(mockAppSettingsDataStore).searchNotificationEnabled = false + verify(notificationScheduler).dismissStickySearchNotification() + verify(mockPixel).fire(Pixel.PixelName.QUICK_SEARCH_NOTIFICATION_DISABLED) + + assertFalse(latestViewState().searchNotificationEnabled) + } + private fun latestViewState() = testee.viewState.value!! } \ No newline at end of file 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 aade76b3d29c..f5cf225e06ce 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt @@ -17,7 +17,6 @@ package com.duckduckgo.app.statistics import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT -import com.duckduckgo.app.statistics.VariantManager.Companion.RESERVED_EU_AUCTION_VARIANT import com.duckduckgo.app.statistics.VariantManager.VariantFeature.* import org.junit.Assert.* import org.junit.Test @@ -33,14 +32,14 @@ class VariantManagerTest { @Test fun serpControlVariantIsInactiveAndHasNoFeatures() { val variant = variants.first { it.key == "sc" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(0, variant.features.size) } @Test fun serpExperimentalVariantIsInactiveAndHasNoFeatures() { val variant = variants.first { it.key == "se" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(0, variant.features.size) } @@ -75,18 +74,18 @@ class VariantManagerTest { // CTA on Concept Test experiments @Test - fun insertCtaConceptTestControlVariantIsActiveAndHasConceptTestAndHasExpectedFeatures() { + fun insertCtaConceptTestControlVariantIsInactiveAndHasConceptTestAndHasExpectedFeatures() { val variant = variants.first { it.key == "mj" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(2, variant.features.size) assertTrue(variant.hasFeature(ConceptTest)) assertTrue(variant.hasFeature(SuppressOnboardingDefaultBrowserContinueScreen)) } @Test - fun insertCtaConceptTestWithCtasAsDaxDialogsExperimentalVariantIsActiveAndHasExpectedFeatures() { + fun insertCtaConceptTestWithCtasAsDaxDialogsExperimentalVariantIsInactiveAndHasExpectedFeatures() { val variant = variants.first { it.key == "mh" } - assertEqualsDouble(1.0, variant.weight) + assertEqualsDouble(0.0, variant.weight) assertEquals(5, variant.features.size) assertTrue(variant.hasFeature(ConceptTest)) assertTrue(variant.hasFeature(SuppressHomeTabWidgetCta)) @@ -95,6 +94,23 @@ class VariantManagerTest { assertTrue(variant.hasFeature(SearchWidgetDaxCta)) } + // Search Notification Experiment + + @Test + fun searchNotificationControlVariantIsActiveAndHasNoFeatures() { + val variant = variants.first { it.key == "mf" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(0, variant.features.size) + } + + @Test + fun searchNotificationVariantIsActiveAndHasStickySearchNotificationFeature() { + val variant = variants.first { it.key == "mg" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(1, variant.features.size) + assertTrue(variant.hasFeature(StickySearchNotification)) + } + @Test fun verifyNoDuplicateVariantNames() { val existingNames = mutableSetOf() diff --git a/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt b/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt index 3ba5626a323e..c97f9590a577 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaggerWorkerFactory.kt @@ -23,12 +23,13 @@ import androidx.work.WorkerFactory import androidx.work.WorkerParameters import com.duckduckgo.app.fire.DataClearingWorker import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.notification.AndroidNotificationScheduler.* import com.duckduckgo.app.notification.NotificationFactory -import com.duckduckgo.app.notification.NotificationScheduler.ClearDataNotificationWorker -import com.duckduckgo.app.notification.NotificationScheduler.PrivacyNotificationWorker import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.ClearDataNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification +import com.duckduckgo.app.notification.model.SearchPromptNotification +import com.duckduckgo.app.notification.model.StickySearchNotification import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.OfflinePixelScheduler import com.duckduckgo.app.statistics.api.OfflinePixelSender @@ -44,6 +45,8 @@ class DaggerWorkerFactory( private val notificationFactory: NotificationFactory, private val clearDataNotification: ClearDataNotification, private val privacyProtectionNotification: PrivacyProtectionNotification, + private val stickySearchPromptNotification: SearchPromptNotification, + private val stickySearchNotification: StickySearchNotification, private val pixel: Pixel ) : WorkerFactory() { @@ -58,6 +61,9 @@ class DaggerWorkerFactory( is DataClearingWorker -> injectDataClearWorker(instance) is ClearDataNotificationWorker -> injectClearDataNotificationWorker(instance) is PrivacyNotificationWorker -> injectPrivacyNotificationWorker(instance) + is SearchPromptNotificationWorker -> injectSearchPromptNotificationWorker(instance) + is StickySearchNotificationWorker -> injectStickySearchNotificationWorker(instance) + is DismissSearchNotificationWorker -> injectDismissSearchNotificationWorker(instance) else -> Timber.i("No injection required for worker $workerClassName") } @@ -89,4 +95,25 @@ class DaggerWorkerFactory( worker.notification = privacyProtectionNotification } + private fun injectSearchPromptNotificationWorker(worker: SearchPromptNotificationWorker) { + worker.manager = notificationManager + worker.notificationDao = notificationDao + worker.factory = notificationFactory + worker.pixel = pixel + worker.notification = stickySearchPromptNotification + } + + private fun injectStickySearchNotificationWorker(worker: StickySearchNotificationWorker) { + worker.manager = notificationManager + worker.notificationDao = notificationDao + worker.factory = notificationFactory + worker.pixel = pixel + worker.notification = stickySearchNotification + } + + private fun injectDismissSearchNotificationWorker(worker: DismissSearchNotificationWorker) { + worker.manager = notificationManager + worker.notificationDao = notificationDao + worker.notification = stickySearchNotification + } } \ No newline at end of file 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 f95aafea2874..2e4ee9fdab14 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt @@ -21,13 +21,17 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.WorkManager +import com.duckduckgo.app.notification.AndroidNotificationScheduler import com.duckduckgo.app.notification.NotificationFactory import com.duckduckgo.app.notification.NotificationScheduler import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.ClearDataNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification +import com.duckduckgo.app.notification.model.SearchPromptNotification +import com.duckduckgo.app.notification.model.StickySearchNotification import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.VariantManager import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -71,17 +75,37 @@ class NotificationModule { return PrivacyProtectionNotification(context, notificationDao, privacyProtectionCountDao) } + @Provides + fun provideStickySearchNotification( + context: Context, + notificationDao: NotificationDao + ): StickySearchNotification { + return StickySearchNotification(context, notificationDao) + } + + @Provides + fun provideSearchPromptNotification( + context: Context, + notificationDao: NotificationDao, + variantManager: VariantManager, + settingsDataStore: SettingsDataStore + ): SearchPromptNotification { + return SearchPromptNotification(context, notificationDao, variantManager, settingsDataStore) + } + @Provides @Singleton fun providesNotificationScheduler( workManager: WorkManager, clearDataNotification: ClearDataNotification, - privacyProtectionNotification: PrivacyProtectionNotification + privacyProtectionNotification: PrivacyProtectionNotification, + stickySearchNotification: StickySearchNotification ): NotificationScheduler { - return NotificationScheduler( + return AndroidNotificationScheduler( workManager, clearDataNotification, - privacyProtectionNotification + privacyProtectionNotification, + stickySearchNotification ) } diff --git a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt index 76b32795d080..74c1b42c3469 100644 --- a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt @@ -28,7 +28,6 @@ import dagger.Module import dagger.Provides import javax.inject.Singleton - @Module class VariantModule { diff --git a/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt b/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt index 9f7445d6945c..64d1683bf102 100644 --- a/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/WorkerModule.kt @@ -26,6 +26,8 @@ import com.duckduckgo.app.notification.NotificationFactory import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.ClearDataNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification +import com.duckduckgo.app.notification.model.SearchPromptNotification +import com.duckduckgo.app.notification.model.StickySearchNotification import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.OfflinePixelSender import com.duckduckgo.app.statistics.pixels.Pixel @@ -57,6 +59,8 @@ class WorkerModule { notificationFactory: NotificationFactory, clearDataNotification: ClearDataNotification, privacyProtectionNotification: PrivacyProtectionNotification, + stickySearchNotification: StickySearchNotification, + stickySearchPromptNotification: SearchPromptNotification, pixel: Pixel ): WorkerFactory { return DaggerWorkerFactory( @@ -68,6 +72,8 @@ class WorkerModule { notificationFactory, clearDataNotification, privacyProtectionNotification, + stickySearchPromptNotification, + stickySearchNotification, pixel ) } 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 40cda41d353a..08631e0c8f91 100644 --- a/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt +++ b/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt @@ -298,7 +298,6 @@ open class DuckDuckGoApplication : HasActivityInjector, HasServiceInjector, HasS notificationRegistrar.updateStatus() GlobalScope.launch { notificationScheduler.scheduleNextNotification() - atbInitializer.initializeAfterReferrerAvailable() } } 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 7140c2a2a780..203db987d56b 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -45,6 +45,7 @@ import com.duckduckgo.app.icon.api.AppIconModifier import com.duckduckgo.app.icon.api.IconModifier import com.duckduckgo.app.icon.ui.ChangeIconViewModel import com.duckduckgo.app.launch.LaunchViewModel +import com.duckduckgo.app.notification.NotificationScheduler import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.ui.OnboardingPageManager import com.duckduckgo.app.onboarding.ui.OnboardingViewModel @@ -110,7 +111,8 @@ class ViewModelFactory @Inject constructor( private val feedbackSubmitter: FeedbackSubmitter, private val onboardingPageManager: OnboardingPageManager, private val appInstallationReferrerStateListener: AppInstallationReferrerStateListener, - private val appIconModifier: IconModifier + private val appIconModifier: IconModifier, + private val notificationScheduler: NotificationScheduler ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class) = @@ -153,7 +155,8 @@ class ViewModelFactory @Inject constructor( appSettingsPreferencesStore, defaultBrowserDetector, variantManager, - pixel + pixel, + notificationScheduler ) } diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationFactory.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationFactory.kt index 4fff07552c90..d36fd68956b1 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationFactory.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.notification import android.app.Notification import android.app.PendingIntent import android.content.Context +import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -47,6 +48,68 @@ class NotificationFactory @Inject constructor(val context: Context, val manager: builder.addAction(specification.icon, it, launchIntent) } + specification.closeButton?.let { + builder.addAction(specification.icon, it, cancelIntent) + } + + return builder.build() + } + + fun createSearchNotificationPrompt( + specification: NotificationSpec, + launchIntent: PendingIntent, + cancelIntent: PendingIntent, + pressIntent: PendingIntent, + layoutId: Int, + priority: Int + ): Notification { + + val notificationLayout = RemoteViews(context.packageName, layoutId) + + val builder = NotificationCompat.Builder(context, specification.channel.id) + .setPriority(priority) + .setCustomContentView(notificationLayout) + .setCustomBigContentView(notificationLayout) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setSmallIcon(specification.icon) + .setContentIntent(pressIntent) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .addAction(R.drawable.ic_empty_drawable, specification.launchButton, launchIntent) + .addAction(R.drawable.ic_empty_drawable, specification.closeButton, cancelIntent) + return builder.build() + } + + fun createSearchNotification( + specification: NotificationSpec, + launchIntent: PendingIntent, + cancelIntent: PendingIntent, + layoutId: Int, + priority: Int + ): Notification { + + val notificationLayout = RemoteViews(context.packageName, layoutId) + + val builder = NotificationCompat.Builder(context, specification.channel.id) + .setPriority(priority) + .setCustomContentView(notificationLayout) + .setCustomBigContentView(notificationLayout) + .setSmallIcon(specification.icon) + .setContentIntent(launchIntent) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setAutoCancel(false) + .setOnlyAlertOnce(true) + .setOngoing(true) + + specification.launchButton?.let { + builder.addAction(specification.icon, it, launchIntent) + } + + specification.closeButton?.let { + builder.addAction(specification.icon, it, cancelIntent) + } + return builder.build() } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt index 328ba228cc05..5f3438ab2bb0 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationHandlerService.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.notification import android.app.IntentService +import android.app.PendingIntent import android.app.TaskStackBuilder import android.content.Context import android.content.Intent @@ -26,10 +27,17 @@ import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.APP_LAUNCH import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CANCEL import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.CLEAR_DATA_LAUNCH +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.QUICK_SEARCH_LAUNCH +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.QUICK_SEARCH_KEEP +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.QUICK_SEARCH_PROMPT_LAUNCH +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.QUICK_SEARCH_REMOVE +import com.duckduckgo.app.notification.model.NotificationSpec import com.duckduckgo.app.settings.SettingsActivity +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_CANCELLED import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_LAUNCHED +import com.duckduckgo.app.systemsearch.SystemSearchActivity import dagger.android.AndroidInjection import timber.log.Timber import javax.inject.Inject @@ -45,6 +53,12 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { @Inject lateinit var notificationManager: NotificationManagerCompat + @Inject + lateinit var notificationScheduler: NotificationScheduler + + @Inject + lateinit var settingsDataStore: SettingsDataStore + override fun onCreate() { super.onCreate() AndroidInjection.inject(this) @@ -57,10 +71,17 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { APP_LAUNCH -> onAppLaunched(pixelSuffix) CLEAR_DATA_LAUNCH -> onClearDataLaunched(pixelSuffix) CANCEL -> onCancelled(pixelSuffix) + QUICK_SEARCH_PROMPT_LAUNCH -> onQuickSearchPromptRequest() + QUICK_SEARCH_REMOVE -> onQuickSearchNotificationRemove(intent) + QUICK_SEARCH_KEEP -> onQuickSearchNotificationKeep(intent) + QUICK_SEARCH_LAUNCH -> onQuickSearchRequest() + } + + if (intent.getBooleanExtra(NOTIFICATION_AUTO_CANCEL, true)) { + val notificationId = intent.getIntExtra(NOTIFICATION_SYSTEM_ID_EXTRA, 0) + clearNotification(notificationId) + closeNotificationPanel() } - val notificationId = intent.getIntExtra(NOTIFICATION_SYSTEM_ID_EXTRA, 0) - clearNotification(notificationId) - closeNotificationPanel() } private fun onAppLaunched(pixelSuffix: String) { @@ -84,6 +105,40 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { pixel.fire("${NOTIFICATION_CANCELLED.pixelName}_$pixelSuffix") } + private fun onQuickSearchNotificationKeep(intent: Intent) { + Timber.i("Sticky Search Notification Requested!") + settingsDataStore.searchNotificationEnabled = true + notificationScheduler.launchStickySearchNotification() + pixel.fire(Pixel.PixelName.QUICK_SEARCH_PROMPT_NOTIFICATION_KEEP) + } + + private fun onQuickSearchNotificationRemove(intent: Intent) { + Timber.i("Sticky Search Notification Dismissed!") + val notificationId = intent.getIntExtra(NOTIFICATION_SYSTEM_ID_EXTRA, 0) + pixel.fire(Pixel.PixelName.QUICK_SEARCH_PROMPT_NOTIFICATION_REMOVE) + settingsDataStore.searchNotificationEnabled = false + clearNotification(notificationId) + closeNotificationPanel() + } + + private fun onQuickSearchPromptRequest() { + Timber.i("Search from Prompt Notification Requested!") + val searchIntent = SystemSearchActivity.fromNotification(context) + TaskStackBuilder.create(context) + .addNextIntentWithParentStack(searchIntent) + .startActivities() + pixel.fire(Pixel.PixelName.QUICK_SEARCH_PROMPT_NOTIFICATION_LAUNCHED) + } + + private fun onQuickSearchRequest() { + Timber.i("Search from Notification Requested!") + val searchIntent = SystemSearchActivity.fromNotification(context) + TaskStackBuilder.create(context) + .addNextIntentWithParentStack(searchIntent) + .startActivities() + pixel.fire(Pixel.PixelName.QUICK_SEARCH_NOTIFICATION_LAUNCHED) + } + private fun clearNotification(notificationId: Int) { notificationManager.cancel(notificationId) } @@ -97,10 +152,24 @@ class NotificationHandlerService : IntentService("NotificationHandlerService") { const val APP_LAUNCH = "com.duckduckgo.notification.launch.app" const val CLEAR_DATA_LAUNCH = "com.duckduckgo.notification.launch.clearData" const val CANCEL = "com.duckduckgo.notification.cancel" + const val QUICK_SEARCH_PROMPT_LAUNCH = "com.duckduckgo.notification.search.launch" + const val QUICK_SEARCH_KEEP = "com.duckduckgo.notification.search.keep" + const val QUICK_SEARCH_REMOVE = "com.duckduckgo.notification.search.remove" + const val QUICK_SEARCH_LAUNCH = "com.duckduckgo.notification.search" } companion object { const val PIXEL_SUFFIX_EXTRA = "PIXEL_SUFFIX_EXTRA" const val NOTIFICATION_SYSTEM_ID_EXTRA = "NOTIFICATION_SYSTEM_ID" + const val NOTIFICATION_AUTO_CANCEL = "NOTIFICATION_AUTO_CANCEL" + + fun pendingNotificationHandlerIntent(context: Context, eventType: String, specification: NotificationSpec): PendingIntent { + val intent = Intent(context, NotificationHandlerService::class.java) + intent.type = eventType + intent.putExtra(PIXEL_SUFFIX_EXTRA, specification.pixelSuffix) + intent.putExtra(NOTIFICATION_SYSTEM_ID_EXTRA, specification.systemId) + intent.putExtra(NOTIFICATION_AUTO_CANCEL, specification.autoCancel) + return PendingIntent.getService(context, 0, intent, 0)!! + } } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt index 53fdb0f40f3c..da369a2d6d24 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationRegistrar.kt @@ -48,6 +48,7 @@ class NotificationRegistrar @Inject constructor( object NotificationId { const val ClearData = 100 const val PrivacyProtection = 101 + const val StickySearch = 102 } object ChannelType { @@ -66,12 +67,18 @@ class NotificationRegistrar @Inject constructor( R.string.notificationChannelTutorials, NotificationManagerCompat.IMPORTANCE_DEFAULT ) + val SEARCH = Channel( + "com.duckduckgo.search", + R.string.notificationChannelSearch, + NotificationManagerCompat.IMPORTANCE_DEFAULT + ) } private val channels = (listOf( ChannelType.FILE_DOWNLOADING, ChannelType.FILE_DOWNLOADED, - ChannelType.TUTORIALS + ChannelType.TUTORIALS, + ChannelType.SEARCH )) fun registerApp() { @@ -96,6 +103,7 @@ class NotificationRegistrar @Inject constructor( SDK_INT >= O -> manager.notificationChannels.all { it.importance != IMPORTANCE_NONE } else -> true } + updateStatus(systemEnabled && allChannelsEnabled) } diff --git a/app/src/main/java/com/duckduckgo/app/notification/NotificationScheduler.kt b/app/src/main/java/com/duckduckgo/app/notification/NotificationScheduler.kt index bb03ea264074..6d2db085b3e5 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/NotificationScheduler.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/NotificationScheduler.kt @@ -16,49 +16,100 @@ package com.duckduckgo.app.notification -import android.app.PendingIntent -import android.app.PendingIntent.getService import android.content.Context import android.content.Intent +import androidx.annotation.WorkerThread import androidx.core.app.NotificationManagerCompat -import androidx.work.* +import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.app.notification.NotificationHandlerService.Companion.NOTIFICATION_AUTO_CANCEL import com.duckduckgo.app.notification.NotificationHandlerService.Companion.NOTIFICATION_SYSTEM_ID_EXTRA import com.duckduckgo.app.notification.NotificationHandlerService.Companion.PIXEL_SUFFIX_EXTRA import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.Notification -import com.duckduckgo.app.notification.model.NotificationSpec import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.notification.model.SearchNotification +import com.duckduckgo.app.notification.model.SearchPromptNotification +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.NOTIFICATION_SHOWN import timber.log.Timber import java.util.concurrent.TimeUnit -import javax.inject.Inject -class NotificationScheduler @Inject constructor( +@WorkerThread +interface NotificationScheduler { + suspend fun scheduleNextNotification() + fun launchStickySearchNotification() + fun dismissStickySearchNotification() + fun launchSearchPromptNotification() +} + +class AndroidNotificationScheduler( private val workManager: WorkManager, private val clearDataNotification: SchedulableNotification, - private val privacyNotification: SchedulableNotification -) { + private val privacyNotification: SchedulableNotification, + private val searchPromptNotification: SearchNotification +) : NotificationScheduler { + + override suspend fun scheduleNextNotification() { + scheduleInactiveUserNotifications() + scheduleActiveUserNotifications() + } - suspend fun scheduleNextNotification() { + private suspend fun scheduleActiveUserNotifications() { + if (searchPromptNotification.canShow()) { + scheduleNotification(OneTimeWorkRequestBuilder(), 2, TimeUnit.DAYS, CONTINUOUS_APP_USE_REQUEST_TAG) + } + } - workManager.cancelAllWorkByTag(WORK_REQUEST_TAG) + private suspend fun scheduleInactiveUserNotifications() { + workManager.cancelAllWorkByTag(UNUSED_APP_WORK_REQUEST_TAG) when { privacyNotification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS) + scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } clearDataNotification.canShow() -> { - scheduleNotification(OneTimeWorkRequestBuilder(), 3, TimeUnit.DAYS) + scheduleNotification(OneTimeWorkRequestBuilder(), 3, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } else -> Timber.v("Notifications not enabled for this variant") } } - private fun scheduleNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit) { + override fun launchStickySearchNotification() { + Timber.v("Posting sticky notification") + val request = OneTimeWorkRequestBuilder() + .addTag(STICKY_REQUEST_TAG) + .build() + + workManager.enqueue(request) + } + + override fun dismissStickySearchNotification() { + Timber.v("Dismissing sticky notification") + val request = OneTimeWorkRequestBuilder() + .addTag(STICKY_REQUEST_TAG) + .build() + + workManager.enqueue(request) + } + + override fun launchSearchPromptNotification() { + Timber.v("Posting sticky search prompt notification") + val request = OneTimeWorkRequestBuilder() + .addTag(STICKY_PROMPT_REQUEST_TAG) + .build() + + workManager.enqueue(request) + } + + private fun scheduleNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit, tag: String) { Timber.v("Scheduling notification") val request = builder - .addTag(WORK_REQUEST_TAG) + .addTag(tag) .setInitialDelay(duration, unit) .build() @@ -88,8 +139,8 @@ class NotificationScheduler @Inject constructor( } val specification = notification.buildSpecification() - val launchIntent = pendingNotificationHandlerIntent(context, notification.launchIntent, specification) - val cancelIntent = pendingNotificationHandlerIntent(context, notification.cancelIntent, specification) + val launchIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.launchIntent, specification) + val cancelIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.cancelIntent, specification) val systemNotification = factory.createNotification(specification, launchIntent, cancelIntent) notificationDao.insert(Notification(notification.id)) manager.notify(specification.systemId, systemNotification) @@ -97,17 +148,88 @@ class NotificationScheduler @Inject constructor( pixel.fire("${NOTIFICATION_SHOWN.pixelName}_${specification.pixelSuffix}") return Result.success() } + } + + class SearchPromptNotificationWorker(val context: Context, val params: WorkerParameters) : CoroutineWorker(context, params) { + lateinit var manager: NotificationManagerCompat + lateinit var factory: NotificationFactory + lateinit var notificationDao: NotificationDao + lateinit var notification: SearchNotification + lateinit var pixel: Pixel + + override suspend fun doWork(): Result { + + if (!notification.canShow()) { + Timber.v("Notification no longer showable") + return Result.success() + } + + val specification = notification.buildSpecification() + + val launchIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.launchIntent, specification) + val cancelIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.cancelIntent, specification) + val pressIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.pressIntent, specification) + + val systemNotification = + factory.createSearchNotificationPrompt( + specification, + launchIntent, + cancelIntent, + pressIntent, + notification.layoutId, + specification.channel.priority + ) + + notificationDao.insert(Notification(notification.id)) + manager.notify(NotificationRegistrar.NotificationId.StickySearch, systemNotification) + + pixel.fire(Pixel.PixelName.QUICK_SEARCH_PROMPT_NOTIFICATION_SHOWN) + return Result.success() + } + } + + class StickySearchNotificationWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + lateinit var manager: NotificationManagerCompat + lateinit var factory: NotificationFactory + lateinit var notificationDao: NotificationDao + lateinit var notification: SearchNotification + lateinit var pixel: Pixel + + override suspend fun doWork(): Result { + + val specification = notification.buildSpecification() + + val launchIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.launchIntent, specification) + val cancelIntent = NotificationHandlerService.pendingNotificationHandlerIntent(context, notification.cancelIntent, specification) + + val systemNotification = + factory.createSearchNotification(specification, launchIntent, cancelIntent, notification.layoutId, specification.channel.priority) + + notificationDao.insert(Notification(notification.id)) + manager.notify(NotificationRegistrar.NotificationId.StickySearch, systemNotification) + + return Result.success() + } + } + + class DismissSearchNotificationWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + lateinit var manager: NotificationManagerCompat + lateinit var notificationDao: NotificationDao + lateinit var notification: SearchNotification - private fun pendingNotificationHandlerIntent(context: Context, eventType: String, specification: NotificationSpec): PendingIntent { - val intent = Intent(context, NotificationHandlerService::class.java) - intent.type = eventType - intent.putExtra(PIXEL_SUFFIX_EXTRA, specification.pixelSuffix) - intent.putExtra(NOTIFICATION_SYSTEM_ID_EXTRA, specification.systemId) - return getService(context, 0, intent, 0)!! + override suspend fun doWork(): Result { + val specification = notification.buildSpecification() + manager.cancel(specification.systemId) + return Result.success() } } companion object { - const val WORK_REQUEST_TAG = "com.duckduckgo.notification.schedule" + const val UNUSED_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.schedule" + const val CONTINUOUS_APP_USE_REQUEST_TAG = "com.duckduckgo.notification.schedule.continuous" + const val STICKY_REQUEST_TAG = "com.duckduckgo.notification.sticky" + const val STICKY_PROMPT_REQUEST_TAG = "com.duckduckgo.notification.sticky.prompt" } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt index c82c084e1235..23284c85523e 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt @@ -64,5 +64,7 @@ class ClearDataSpecification(context: Context) : NotificationSpec { override val title: String = context.getString(R.string.clearNotificationTitle) override val description: String = context.getString(R.string.clearNotificationDescription) override val launchButton: String? = null + override val closeButton: String? = null override val pixelSuffix = "cd" + override val autoCancel = true } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/PrivacyProtectionNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/PrivacyProtectionNotification.kt index f0717a752d1a..30ea9e5c9c26 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/model/PrivacyProtectionNotification.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/model/PrivacyProtectionNotification.kt @@ -24,7 +24,6 @@ import com.duckduckgo.app.notification.NotificationRegistrar import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao - class PrivacyProtectionNotification( private val context: Context, private val notificationDao: NotificationDao, @@ -55,6 +54,8 @@ class PrivacyProtectionNotificationSpecification(context: Context, trackers: Int override val name = "Privacy protection" override val icon = R.drawable.notification_sheild_lock override val launchButton: String = context.getString(R.string.privacyProtectionNotificationLaunchButton) + override val closeButton: String? = null + override val autoCancel = true override val title: String = when { trackers < TRACKER_THRESHOLD && upgrades < UPGRADE_THRESHOLD -> context.getString(R.string.privacyProtectionNotificationDefaultTitle) diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/SchedulableNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/SchedulableNotification.kt index ea4f138b98d5..d427c8fb2eb9 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/model/SchedulableNotification.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/model/SchedulableNotification.kt @@ -26,6 +26,16 @@ interface SchedulableNotification { suspend fun buildSpecification(): NotificationSpec } +interface SearchNotification { + val id: String + val layoutId: Int + val pressIntent: String + val launchIntent: String + val cancelIntent: String + suspend fun canShow(): Boolean + suspend fun buildSpecification(): NotificationSpec +} + interface NotificationSpec { val channel: NotificationRegistrar.Channel val systemId: Int @@ -34,6 +44,8 @@ interface NotificationSpec { val title: String val description: String val launchButton: String? + val closeButton: String? val pixelSuffix: String + val autoCancel: Boolean } diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/SearchPromptNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/SearchPromptNotification.kt new file mode 100644 index 000000000000..4b37c6f52798 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/notification/model/SearchPromptNotification.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.notification.model + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.notification.NotificationHandlerService +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.VariantManager +import timber.log.Timber + +class SearchPromptNotification( + private val context: Context, + private val notificationDao: NotificationDao, + private val variantManager: VariantManager, + private val settingsDataStore: SettingsDataStore +) : SearchNotification { + + override val id = "com.duckduckgo.privacy.search.stickyPrompt" + + override val pressIntent = NotificationHandlerService.NotificationEvent.QUICK_SEARCH_PROMPT_LAUNCH + + override val launchIntent = NotificationHandlerService.NotificationEvent.QUICK_SEARCH_KEEP + + override val cancelIntent = NotificationHandlerService.NotificationEvent.QUICK_SEARCH_REMOVE + + override val layoutId = R.layout.search_notification_prompt + + override suspend fun canShow(): Boolean { + + val variant = variantManager.getVariant() + if (!variant.hasFeature(VariantManager.VariantFeature.StickySearchNotification)){ + Timber.v("Notification Variant is not enabled ") + return false + } + + if (notificationDao.exists(id)) { + Timber.v("Notification already seen") + return false + } + + if (settingsDataStore.searchNotificationEnabled) { + Timber.v("Notification is already enabled") + return false + } + + return true + } + + override suspend fun buildSpecification(): NotificationSpec { + return StickySearchPromptSpecification(context) + } +} + +class StickySearchPromptSpecification(context: Context) : NotificationSpec { + override val channel = NotificationRegistrar.ChannelType.SEARCH + override val systemId = NotificationRegistrar.NotificationId.StickySearch + override val name = "Add sticky search notification" + override val icon = R.drawable.notification_logo + override val title: String = context.getString(R.string.stickySearchPromptNotificationTitle) + override val description: String = context.getString(R.string.stickySearchPromptNotificationTitle) + override val launchButton: String = context.getString(R.string.stickySearchPromptKeep) + override val closeButton: String = context.getString(R.string.stickySearchPromptRemove) + override val pixelSuffix = "" + override val autoCancel = false +} + diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/StickySearchNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/StickySearchNotification.kt new file mode 100644 index 000000000000..3e400ae5082a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/notification/model/StickySearchNotification.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.notification.model + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.notification.NotificationHandlerService.NotificationEvent.QUICK_SEARCH_LAUNCH +import com.duckduckgo.app.notification.NotificationRegistrar +import com.duckduckgo.app.notification.db.NotificationDao +import timber.log.Timber + +class StickySearchNotification( + private val context: Context, + private val notificationDao: NotificationDao +) : SearchNotification { + + override val id = "com.duckduckgo.privacy.search.sticky" + + override val pressIntent = QUICK_SEARCH_LAUNCH + + override val launchIntent = QUICK_SEARCH_LAUNCH + + override val cancelIntent = QUICK_SEARCH_LAUNCH + + override val layoutId = R.layout.search_notification + + override suspend fun canShow(): Boolean { + + if (notificationDao.exists(id)) { + Timber.v("Notification already seen") + return false + } + + return true + } + + override suspend fun buildSpecification(): NotificationSpec { + return StickySearchNotificationSpecification(context) + } +} + +class StickySearchNotificationSpecification(context: Context) : NotificationSpec { + + override val channel = NotificationRegistrar.ChannelType.SEARCH + override val systemId = NotificationRegistrar.NotificationId.StickySearch + override val name = context.getString(R.string.stickySearchNotification) + override val icon = R.drawable.notification_logo + override val launchButton: String? = null + override val closeButton: String? = null + override val title: String = "" + override val description: String = "" + override val pixelSuffix: String = "" + override val autoCancel = false +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index a778cc0b48e9..076907a68b51 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -33,8 +33,10 @@ import com.duckduckgo.app.browser.R import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.sendThemeChangedBroadcast +import com.duckduckgo.app.global.view.gone import com.duckduckgo.app.global.view.launchDefaultAppActivity import com.duckduckgo.app.icon.ui.ChangeIconActivity +import com.duckduckgo.app.global.view.show import com.duckduckgo.app.settings.SettingsViewModel.AutomaticallyClearData import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.settings.clear.ClearWhatOption @@ -52,6 +54,16 @@ import kotlinx.android.synthetic.main.content_settings_other.version import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhatSetting import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhenSetting import kotlinx.android.synthetic.main.include_toolbar.toolbar +import kotlinx.android.synthetic.main.content_settings_general.autocompleteToggle +import kotlinx.android.synthetic.main.content_settings_general.lightThemeToggle +import kotlinx.android.synthetic.main.content_settings_general.searchNotificationToggle +import kotlinx.android.synthetic.main.content_settings_general.setAsDefaultBrowserSetting +import kotlinx.android.synthetic.main.content_settings_other.about +import kotlinx.android.synthetic.main.content_settings_other.provideFeedback +import kotlinx.android.synthetic.main.content_settings_other.version +import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhatSetting +import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhenSetting +import kotlinx.android.synthetic.main.include_toolbar.toolbar import javax.inject.Inject class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFragment.Listener, SettingsAutomaticallyClearWhenFragment.Listener { @@ -71,6 +83,10 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra viewModel.onAutocompleteSettingChanged(isChecked) } + private val searchNotificationToggleListener = OnCheckedChangeListener { _, isChecked -> + viewModel.onSearchNotificationSettingChanged(isChecked) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) @@ -103,9 +119,11 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra version.setSubtitle(it.version) lightThemeToggle.quietlySetIsChecked(it.lightThemeEnabled, lightThemeToggleListener) autocompleteToggle.quietlySetIsChecked(it.autoCompleteSuggestionsEnabled, autocompleteToggleListener) + searchNotificationToggle.quietlySetIsChecked(it.searchNotificationEnabled, searchNotificationToggleListener) updateDefaultBrowserViewVisibility(it) updateAutomaticClearDataOptions(it.automaticallyClearData) changeAppIcon.setImageResource(it.appIcon.icon) + showSearchNotification(it.showSearchNotificationToggle) } }) @@ -125,6 +143,14 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra automaticallyClearWhenSetting.isEnabled = whenOptionEnabled } + private fun showSearchNotification(enabled: Boolean) { + if (enabled) { + searchNotificationToggle.show() + } else { + searchNotificationToggle.gone() + } + } + private fun launchAutomaticallyClearWhatDialog() { val dialog = SettingsAutomaticallyClearWhatFragment.create(viewModel.viewState.value?.automaticallyClearData?.clearWhatOption) dialog.show(supportFragmentManager, CLEAR_WHAT_DIALOG_TAG) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 23a3747a166f..86a2b39ac0dd 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -23,9 +23,11 @@ import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.global.DuckDuckGoTheme import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.icon.api.AppIcon +import com.duckduckgo.app.notification.NotificationScheduler import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName @@ -37,7 +39,8 @@ class SettingsViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val defaultWebBrowserCapability: DefaultBrowserDetector, private val variantManager: VariantManager, - private val pixel: Pixel + private val pixel: Pixel, + private val notificationScheduler: NotificationScheduler ) : ViewModel() { data class ViewState( @@ -45,6 +48,8 @@ class SettingsViewModel @Inject constructor( val version: String = "", val lightThemeEnabled: Boolean = false, val autoCompleteSuggestionsEnabled: Boolean = true, + val showSearchNotificationToggle: Boolean = false, + val searchNotificationEnabled: Boolean = false, val showDefaultBrowserSetting: Boolean = false, val isAppDefaultBrowser: Boolean = false, val automaticallyClearData: AutomaticallyClearData = AutomaticallyClearData(ClearWhatOption.CLEAR_NONE, ClearWhenOption.APP_EXIT_ONLY), @@ -86,6 +91,8 @@ class SettingsViewModel @Inject constructor( loading = false, lightThemeEnabled = isLightTheme, autoCompleteSuggestionsEnabled = settingsDataStore.autoCompleteSuggestionsEnabled, + showSearchNotificationToggle = isSearchNotificationFeatureEnabled(variant), + searchNotificationEnabled = settingsDataStore.searchNotificationEnabled, isAppDefaultBrowser = defaultBrowserAlready, showDefaultBrowserSetting = defaultWebBrowserCapability.deviceSupportsDefaultBrowserConfiguration(), version = obtainVersion(variant.key), @@ -115,10 +122,23 @@ class SettingsViewModel @Inject constructor( fun onAutocompleteSettingChanged(enabled: Boolean) { Timber.i("User changed autocomplete setting, is now enabled: $enabled") settingsDataStore.autoCompleteSuggestionsEnabled = enabled - viewState.value = currentViewState().copy(autoCompleteSuggestionsEnabled = enabled) } + fun onSearchNotificationSettingChanged(enabled: Boolean) { + Timber.i("User changed search notification setting, is now enabled: $enabled") + settingsDataStore.searchNotificationEnabled = enabled + if (enabled){ + notificationScheduler.launchStickySearchNotification() + pixel.fire(QUICK_SEARCH_NOTIFICATION_ENABLED) + + } else { + notificationScheduler.dismissStickySearchNotification() + pixel.fire(QUICK_SEARCH_NOTIFICATION_DISABLED) + } + viewState.value = currentViewState().copy(searchNotificationEnabled = enabled) + } + private fun obtainVersion(variantKey: String): String { val formattedVariantKey = if (variantKey.isBlank()) " " else " $variantKey " return "${BuildConfig.VERSION_NAME}$formattedVariantKey(${BuildConfig.VERSION_CODE})" @@ -189,4 +209,8 @@ class SettingsViewModel @Inject constructor( } } + private fun isSearchNotificationFeatureEnabled(variant: Variant): Boolean { + return variant.hasFeature(VariantManager.VariantFeature.StickySearchNotification) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index 22b34c076e2e..01ffd7800afa 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -32,6 +32,7 @@ interface SettingsDataStore { var theme: DuckDuckGoTheme? var hideTips: Boolean var autoCompleteSuggestionsEnabled: Boolean + var searchNotificationEnabled: Boolean var appIcon: AppIcon var appIconChanged: Boolean @@ -93,6 +94,10 @@ class SettingsSharedPreferences @Inject constructor(private val context: Context get() = preferences.getBoolean(KEY_APP_ICON_CHANGED, false) set(enabled) = preferences.edit(commit = true) { putBoolean(KEY_APP_ICON_CHANGED, enabled) } + override var searchNotificationEnabled: Boolean + get() = preferences.getBoolean(KEY_SEARCH_NOTIFICATION, false) + set(enabled) = preferences.edit { putBoolean(KEY_SEARCH_NOTIFICATION, enabled) } + override var appUsedSinceLastClear: Boolean get() = preferences.getBoolean(KEY_APP_USED_SINCE_LAST_CLEAR, true) set(enabled) = preferences.edit(commit = true) { putBoolean(KEY_APP_USED_SINCE_LAST_CLEAR, enabled) } @@ -158,5 +163,6 @@ class SettingsSharedPreferences @Inject constructor(private val context: Context } else { AppIcon.DEFAULT } + const val KEY_SEARCH_NOTIFICATION = "SEARCH_NOTIFICATION" } } \ No newline at end of file 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 9e6ce8747c36..00a84b2d434b 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt @@ -22,7 +22,7 @@ import com.duckduckgo.app.statistics.VariantManager.Companion.referrerVariant import com.duckduckgo.app.statistics.VariantManager.VariantFeature.* import com.duckduckgo.app.statistics.store.StatisticsDataStore import timber.log.Timber -import java.util.* +import java.util.Locale @WorkerThread interface VariantManager { @@ -35,6 +35,7 @@ interface VariantManager { object SuppressOnboardingDefaultBrowserContinueScreen : VariantFeature() object DefaultBrowserDaxCta : VariantFeature() object SearchWidgetDaxCta : VariantFeature() + object StickySearchNotification : VariantFeature() } companion object { @@ -47,8 +48,8 @@ interface VariantManager { val ACTIVE_VARIANTS = listOf( // SERP variants. "sc" may also be used as a shared control for mobile experiments in // the future if we can filter by app version - Variant(key = "sc", weight = 1.0, features = emptyList(), filterBy = { noFilter() }), - Variant(key = "se", weight = 1.0, features = emptyList(), filterBy = { noFilter() }), + Variant(key = "sc", weight = 0.0, features = emptyList(), filterBy = { noFilter() }), + Variant(key = "se", weight = 0.0, features = emptyList(), filterBy = { noFilter() }), // Concept test experiment Variant(key = "mc", weight = 0.0, features = emptyList(), filterBy = { isEnglishLocale() }), @@ -66,12 +67,12 @@ interface VariantManager { // Insert CTAs on Concept test experiment Variant( key = "mj", - weight = 1.0, + weight = 0.0, features = listOf(ConceptTest, SuppressOnboardingDefaultBrowserContinueScreen), filterBy = { isEnglishLocale() }), Variant( key = "mh", - weight = 1.0, + weight = 0.0, features = listOf( ConceptTest, SuppressHomeTabWidgetCta, @@ -79,6 +80,18 @@ interface VariantManager { DefaultBrowserDaxCta, SearchWidgetDaxCta ), + filterBy = { isEnglishLocale() }), + + // Quick Search Notification Experiment + Variant( + key = "mf", + weight = 1.0, + features = emptyList(), + filterBy = { isEnglishLocale() }), + Variant( + key = "mg", + weight = 1.0, + features = listOf(StickySearchNotification), 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 d4a0f491b292..1943b2d50d02 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 @@ -94,6 +94,7 @@ interface Pixel { WIDGETS_ADDED(pixelName = "m_w_a"), WIDGETS_DELETED(pixelName = "m_w_d"), + APP_NOTIFICATION_LAUNCH(pixelName = "m_n_l"), APP_WIDGET_LAUNCH(pixelName = "m_w_l"), APP_ASSIST_LAUNCH(pixelName = "m_a_l"), APP_SYSTEM_SEARCH_BOX_LAUNCH(pixelName = "m_ssb_l"), @@ -162,6 +163,14 @@ interface Pixel { AUTOCOMPLETE_SEARCH_SELECTION("m_aut_s_s"), CHANGE_APP_ICON_OPENED("m_ic"), + + QUICK_SEARCH_PROMPT_NOTIFICATION_SHOWN("m_qs_pn_s"), + QUICK_SEARCH_PROMPT_NOTIFICATION_LAUNCHED("m_qs_pn_l"), + QUICK_SEARCH_PROMPT_NOTIFICATION_KEEP("m_qs_pn_k"), + QUICK_SEARCH_PROMPT_NOTIFICATION_REMOVE("m_qs_pn_r"), + QUICK_SEARCH_NOTIFICATION_ENABLED("m_qs_sn_e"), + QUICK_SEARCH_NOTIFICATION_DISABLED("m_qs_sn_d"), + QUICK_SEARCH_NOTIFICATION_LAUNCHED("m_qs_sn_l") } object PixelParameter { diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 23c224a1af39..409073fea7d7 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -90,6 +90,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { when { launchedFromAssist(intent) -> pixel.fire(PixelName.APP_ASSIST_LAUNCH) launchedFromWidget(intent) -> pixel.fire(PixelName.APP_WIDGET_LAUNCH) + launchedFromNotification(intent) -> pixel.fire(PixelName.APP_NOTIFICATION_LAUNCH) launchedFromSystemSearchBox(intent) -> pixel.fire(PixelName.APP_SYSTEM_SEARCH_BOX_LAUNCH) } } @@ -254,14 +255,27 @@ class SystemSearchActivity : DuckDuckGoActivity() { return intent.getBooleanExtra(WIDGET_SEARCH_EXTRA, false) } + private fun launchedFromNotification(intent: Intent): Boolean { + return intent.getBooleanExtra(NOTIFICATION_SEARCH_EXTRA, false) + } + companion object { + const val NOTIFICATION_SEARCH_EXTRA = "NOTIFICATION_SEARCH_EXTRA" const val WIDGET_SEARCH_EXTRA = "WIDGET_SEARCH_EXTRA" const val NEW_SEARCH_ACTION = "com.duckduckgo.mobile.android.NEW_SEARCH" - fun intent(context: Context, widgetSearch: Boolean = false): Intent { + fun fromWidget(context: Context): Intent { + val intent = Intent(context, SystemSearchActivity::class.java) + intent.putExtra(WIDGET_SEARCH_EXTRA, true) + intent.putExtra(NOTIFICATION_SEARCH_EXTRA, false) + return intent + } + + fun fromNotification(context: Context): Intent { val intent = Intent(context, SystemSearchActivity::class.java) - intent.putExtra(WIDGET_SEARCH_EXTRA, widgetSearch) + intent.putExtra(WIDGET_SEARCH_EXTRA, false) + intent.putExtra(NOTIFICATION_SEARCH_EXTRA, true) return intent } } diff --git a/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt index ca1c04baa2d8..7c8139db27e9 100644 --- a/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt +++ b/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt @@ -77,7 +77,7 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget) : AppWidgetP } private fun buildPendingIntent(context: Context): PendingIntent { - val intent = SystemSearchActivity.intent(context, widgetSearch = true) + val intent = SystemSearchActivity.fromWidget(context) return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/res/drawable/ic_empty_drawable.xml b/app/src/main/res/drawable/ic_empty_drawable.xml new file mode 100644 index 000000000000..a9be02d2b60b --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_drawable.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search_black_24dp.xml b/app/src/main/res/drawable/ic_search_black_24dp.xml new file mode 100644 index 000000000000..57b7762c6332 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_black_24dp.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/drawable/notification_logo.xml b/app/src/main/res/drawable/notification_logo.xml new file mode 100644 index 000000000000..03e09a4fae31 --- /dev/null +++ b/app/src/main/res/drawable/notification_logo.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/app/src/main/res/drawable/search_notification_background.xml b/app/src/main/res/drawable/search_notification_background.xml new file mode 100644 index 000000000000..559e2fe6f518 --- /dev/null +++ b/app/src/main/res/drawable/search_notification_background.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_notification_prompt_background.xml b/app/src/main/res/drawable/search_notification_prompt_background.xml new file mode 100644 index 000000000000..21cdc7b131ef --- /dev/null +++ b/app/src/main/res/drawable/search_notification_prompt_background.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/layout/content_settings_general.xml b/app/src/main/res/layout/content_settings_general.xml index ec465b80f293..6a497c5b5267 100644 --- a/app/src/main/res/layout/content_settings_general.xml +++ b/app/src/main/res/layout/content_settings_general.xml @@ -57,6 +57,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/autocompleteToggle" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/search_notification_prompt.xml b/app/src/main/res/layout/search_notification_prompt.xml new file mode 100644 index 000000000000..f11c7fe1d36b --- /dev/null +++ b/app/src/main/res/layout/search_notification_prompt.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 18b2e1b76fd8..a84ca89f7cf6 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -49,4 +49,12 @@ Apply New Icon? The app may close to apply changes. Come on back after you\'ve admired your handsome new icon. + + Quick Search + Sticky Search + Quick Search in Notifications + Keep + Remove + Search +