diff --git a/broken-site/broken-site-impl/build.gradle b/broken-site/broken-site-impl/build.gradle index 2241531fbc4b..44429ebc4884 100644 --- a/broken-site/broken-site-impl/build.gradle +++ b/broken-site/broken-site-impl/build.gradle @@ -74,6 +74,8 @@ dependencies { implementation Square.retrofit2.converter.moshi implementation Square.okHttp3.okHttp + implementation AndroidX.dataStore.preferences + // Testing dependencies testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation Testing.junit4 diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt new file mode 100644 index 000000000000..8ae89da44629 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2024 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.brokensite.impl + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.COOL_DOWN_DAYS +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_STREAK_RESET_DAYS +import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.MAX_DISMISS_STREAK +import com.duckduckgo.brokensite.impl.di.BrokenSitePrompt +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +interface BrokenSitePomptDataStore { + suspend fun setMaxDismissStreak(maxDismissStreak: Int) + fun getMaxDismissStreak(): Int + + suspend fun setDismissStreakResetDays(days: Int) + fun getDismissStreakResetDays(): Int + + suspend fun setCoolDownDays(days: Int) + fun getCoolDownDays(): Int +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesDuckPlayerDataStore @Inject constructor( + @BrokenSitePrompt private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : BrokenSitePomptDataStore { + + private object Keys { + val MAX_DISMISS_STREAK = intPreferencesKey(name = "MAX_DISMISS_STREAK") + val DISMISS_STREAK_RESET_DAYS = intPreferencesKey(name = "DISMISS_STREAK_RESET_DAYS") + val COOL_DOWN_DAYS = intPreferencesKey(name = "COOL_DOWN_DAYS") + } + + private val maxDismissStreak: StateFlow = store.data + .map { prefs -> + prefs[MAX_DISMISS_STREAK] ?: 3 + } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, 3) + + private val dismissStreakResetDays: StateFlow = store.data + .map { prefs -> + prefs[DISMISS_STREAK_RESET_DAYS] ?: 30 + } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, 30) + + private val coolDownDays: StateFlow = store.data + .map { prefs -> + prefs[COOL_DOWN_DAYS] ?: 7 + } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, 7) + + override suspend fun setMaxDismissStreak(maxDismissStreak: Int) { + store.edit { prefs -> prefs[MAX_DISMISS_STREAK] = maxDismissStreak } + } + + override fun getMaxDismissStreak(): Int = maxDismissStreak.value + + override suspend fun setDismissStreakResetDays(days: Int) { + store.edit { prefs -> prefs[DISMISS_STREAK_RESET_DAYS] = days } + } + + override fun getDismissStreakResetDays(): Int = dismissStreakResetDays.value + + override suspend fun setCoolDownDays(days: Int) { + store.edit { prefs -> prefs[COOL_DOWN_DAYS] = days } + } + + override fun getCoolDownDays(): Int = coolDownDays.value +} diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt index fb2e591b5f05..92a421d77d34 100644 --- a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePromptRCFeature.kt @@ -16,6 +16,61 @@ package com.duckduckgo.brokensite.impl +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureSettings +import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "brokenSitePrompt", + settingsStore = BrokenSitePromptRCFeatureStore::class, +) interface BrokenSitePromptRCFeature { // TODO (cbarreiro) Implement broken site prompt + @DefaultValue(false) + fun self(): Toggle +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +@RemoteFeatureStoreNamed(BrokenSitePromptRCFeature::class) +class BrokenSitePromptRCFeatureStore @Inject constructor( + private val repository: BrokenSiteReportRepository, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : FeatureSettings.Store { + + private val jsonAdapter by lazy { buildJsonAdapter() } + + override fun store(jsonString: String) { + appCoroutineScope.launch(dispatcherProvider.io()) { + jsonAdapter.fromJson(jsonString)?.let { + repository.setBrokenSitePromptRCSettings(it.maxDismissStreak, it.dismissStreakResetDays, it.coolDownDays) + } + } + } + + private fun buildJsonAdapter(): JsonAdapter { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter(BrokenSitePromptSettings::class.java) + } + + data class BrokenSitePromptSettings( + val maxDismissStreak: Int, + val dismissStreakResetDays: Int, + val coolDownDays: Int, + ) } diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt index 473ef80e90c4..a117c702e493 100644 --- a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt @@ -36,12 +36,24 @@ interface BrokenSiteReportRepository { fun setLastSentDay(hostname: String) fun cleanupOldEntries() + + suspend fun setMaxDismissStreak(maxDismissStreak: Int) + fun getMaxDismissStreak(): Int + + suspend fun setDismissStreakResetDays(days: Int) + fun getDismissStreakResetDays(): Int + + suspend fun setCoolDownDays(days: Int) + fun getCoolDownDays(): Int + + suspend fun setBrokenSitePromptRCSettings(maxDismissStreak: Int, dismissStreakResetDays: Int, coolDownDays: Int) } -class RealBrokenSiteReportRepository constructor( +class RealBrokenSiteReportRepository( private val database: BrokenSiteDatabase, @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val brokenSitePromptDataStore: BrokenSitePomptDataStore, ) : BrokenSiteReportRepository { override suspend fun getLastSentDay(hostname: String): String? { @@ -74,6 +86,37 @@ class RealBrokenSiteReportRepository constructor( } } + override suspend fun setMaxDismissStreak(maxDismissStreak: Int) { + brokenSitePromptDataStore.setMaxDismissStreak(maxDismissStreak) + } + + override fun getMaxDismissStreak(): Int = + brokenSitePromptDataStore.getMaxDismissStreak() + + override suspend fun setDismissStreakResetDays(days: Int) { + brokenSitePromptDataStore.setDismissStreakResetDays(days) + } + + override fun getDismissStreakResetDays(): Int = + brokenSitePromptDataStore.getDismissStreakResetDays() + + override suspend fun setCoolDownDays(days: Int) { + brokenSitePromptDataStore.setCoolDownDays(days) + } + + override fun getCoolDownDays(): Int = + brokenSitePromptDataStore.getCoolDownDays() + + override suspend fun setBrokenSitePromptRCSettings( + maxDismissStreak: Int, + dismissStreakResetDays: Int, + coolDownDays: Int, + ) { + setMaxDismissStreak(maxDismissStreak) + setDismissStreakResetDays(dismissStreakResetDays) + setCoolDownDays(coolDownDays) + } + private fun convertToShortDate(dateString: String): String { val inputFormatter = DateTimeFormatter.ISO_INSTANT val outputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt index bf116e4f4bbe..0ff18b9396a6 100644 --- a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt @@ -19,6 +19,7 @@ package com.duckduckgo.brokensite.impl.di import android.content.Context import androidx.room.Room import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.brokensite.impl.BrokenSitePomptDataStore import com.duckduckgo.brokensite.impl.BrokenSiteReportRepository import com.duckduckgo.brokensite.impl.RealBrokenSiteReportRepository import com.duckduckgo.brokensite.store.ALL_MIGRATIONS @@ -41,8 +42,9 @@ class BrokenSiteModule { database: BrokenSiteDatabase, @AppCoroutineScope coroutineScope: CoroutineScope, dispatcherProvider: DispatcherProvider, + brokenSitePomptDataStore: BrokenSitePomptDataStore, ): BrokenSiteReportRepository { - return RealBrokenSiteReportRepository(database, coroutineScope, dispatcherProvider) + return RealBrokenSiteReportRepository(database, coroutineScope, dispatcherProvider, brokenSitePomptDataStore) } @Provides diff --git a/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt new file mode 100644 index 000000000000..a3e8d65c11a1 --- /dev/null +++ b/broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSitePromptDataStoreModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 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.brokensite.impl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object DuckPlayerDataStoreModule { + + private val Context.brokenSitePromptDataStore: DataStore by preferencesDataStore( + name = "broken_site_prompt", + ) + + @Provides + @BrokenSitePrompt + fun provideBrokenSitePromptDataStore(context: Context): DataStore = context.brokenSitePromptDataStore +} + +@Qualifier +internal annotation class BrokenSitePrompt diff --git a/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt index 84b7220505c4..53730549794a 100644 --- a/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt +++ b/broken-site/broken-site-impl/src/test/java/com/duckduckgo/brokensite/impl/RealBrokenSiteReportRepositoryTest.kt @@ -38,6 +38,7 @@ class RealBrokenSiteReportRepositoryTest { private val mockDatabase: BrokenSiteDatabase = mock() private val mockBrokenSiteDao: BrokenSiteDao = mock() + private val mockDataStore: BrokenSitePomptDataStore = mock() lateinit var testee: RealBrokenSiteReportRepository @Before @@ -48,6 +49,7 @@ class RealBrokenSiteReportRepositoryTest { database = mockDatabase, coroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, + brokenSitePromptDataStore = mockDataStore, ) } @@ -109,4 +111,31 @@ class RealBrokenSiteReportRepositoryTest { verify(mockDatabase.brokenSiteDao()).deleteAllExpiredReports(any()) } + + @Test + fun whenSetMaxDismissStreakCalledThenSetMaxDismissStreakIsCalled() = runTest { + val maxDismissStreak = 3 + + testee.setMaxDismissStreak(maxDismissStreak) + + verify(mockDataStore).setMaxDismissStreak(maxDismissStreak) + } + + @Test + fun whenDismissStreakResetDaysCalledThenDismissStreakResetDaysIsCalled() = runTest { + val days = 30 + + testee.setDismissStreakResetDays(days) + + verify(mockDataStore).setDismissStreakResetDays(days) + } + + @Test + fun whenCoolDownDaysCalledThenCoolDownDaysIsCalled() = runTest { + val days = 7 + + testee.setCoolDownDays(days) + + verify(mockDataStore).setCoolDownDays(days) + } }