From 2d794341a5312f19b728d667d494bbb1b9b5b3c5 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Mon, 22 Jan 2024 13:11:03 +0000 Subject: [PATCH 01/26] Remove unused tracker detector instance (#4100) Task/Issue URL: https://app.asana.com/0/488551667048375/1206398717811159/f ### Description Remove unused tracker detector instance ### Steps to test this PR Smoke tests VPN/AppTP working separate and together --- .../tracking/NetpRealAppTrackerDetector.kt | 156 -------- .../app/tracking/RealAppTrackerDetector.kt | 46 ++- .../app/tracking/AppTrackerDetectorTest.kt | 214 ++++++++++ .../tracking/NetpAppTrackerDetectorTest.kt | 368 ------------------ 4 files changed, 255 insertions(+), 529 deletions(-) delete mode 100644 app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/NetpRealAppTrackerDetector.kt delete mode 100644 app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/NetpAppTrackerDetectorTest.kt diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/NetpRealAppTrackerDetector.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/NetpRealAppTrackerDetector.kt deleted file mode 100644 index 14b5db54f7ed..000000000000 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/NetpRealAppTrackerDetector.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) 2022 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.mobile.android.app.tracking - -import android.content.pm.PackageManager -import android.util.LruCache -import com.duckduckgo.di.scopes.VpnScope -import com.duckduckgo.mobile.android.vpn.AppTpVpnFeature -import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry -import com.duckduckgo.mobile.android.vpn.apps.VpnExclusionList -import com.duckduckgo.mobile.android.vpn.apps.isSystemApp -import com.duckduckgo.mobile.android.vpn.dao.VpnAppTrackerBlockingDao -import com.duckduckgo.mobile.android.vpn.model.TrackingApp -import com.duckduckgo.mobile.android.vpn.model.VpnTracker -import com.duckduckgo.mobile.android.vpn.processor.requestingapp.AppNameResolver -import com.duckduckgo.mobile.android.vpn.processor.tcp.tracker.AppTrackerRecorder -import com.duckduckgo.mobile.android.vpn.store.VpnDatabase -import com.duckduckgo.mobile.android.vpn.trackers.AppTrackerRepository -import com.duckduckgo.mobile.android.vpn.trackers.AppTrackerType -import com.squareup.anvil.annotations.ContributesTo -import dagger.Module -import dagger.Provides -import kotlinx.coroutines.runBlocking -import logcat.logcat - -class NetpRealAppTrackerDetector constructor( - private val appTrackerRepository: AppTrackerRepository, - private val appNameResolver: AppNameResolver, - private val appTrackerRecorder: AppTrackerRecorder, - private val vpnAppTrackerBlockingDao: VpnAppTrackerBlockingDao, - private val packageManager: PackageManager, - private val vpnFeaturesRegistry: VpnFeaturesRegistry, -) : AppTrackerDetector { - - private fun isAppTpDisabled(): Boolean { - return runBlocking { !vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN) } - } - - // cache packageId -> app name - private val appNamesCache = LruCache(100) - - override fun evaluate(domain: String, uid: Int): AppTrackerDetector.AppTracker? { - // Check if AppTP is enabled first - if (isAppTpDisabled()) { - logcat { "App tracker detector is DISABLED" } - return null - } - - // `null` package ID means unknown app, return null to not block - val packageId = appNameResolver.getPackageIdForUid(uid) ?: return null - - if (VpnExclusionList.isDdgApp(packageId) || packageId.isInExclusionList()) { - logcat { "shouldAllowDomain: $packageId is excluded, allowing packet" } - return null - } - - when (val type = appTrackerRepository.findTracker(domain, packageId)) { - AppTrackerType.NotTracker -> return null - is AppTrackerType.FirstParty -> return null - is AppTrackerType.ThirdParty -> { - if (isTrackerInExceptionRules(packageId = packageId, hostname = domain)) return null - - val trackingApp = appNamesCache[packageId] ?: appNameResolver.getAppNameForPackageId(packageId) - appNamesCache.put(packageId, trackingApp) - - // if the app name is unknown, do not block - if (trackingApp.isUnknown()) return null - - VpnTracker( - trackerCompanyId = type.tracker.trackerCompanyId, - company = type.tracker.owner.name, - companyDisplayName = type.tracker.owner.displayName, - domain = type.tracker.hostname, - trackingApp = TrackingApp(trackingApp.packageId, trackingApp.appName), - ).run { - appTrackerRecorder.insertTracker(this) - } - return AppTrackerDetector.AppTracker( - domain = type.tracker.hostname, - uid = uid, - trackerCompanyDisplayName = type.tracker.owner.displayName, - trackingAppId = trackingApp.packageId, - trackingAppName = trackingApp.appName, - ) - } - } - } - - private fun isTrackerInExceptionRules( - packageId: String, - hostname: String, - ): Boolean { - return vpnAppTrackerBlockingDao.getRuleByTrackerDomain(hostname)?.let { rule -> - logcat { "isTrackerInExceptionRules: found rule $rule for $hostname" } - return rule.packageNames.contains(packageId) - } ?: false - } - - private fun String.isInExclusionList(): Boolean { - if (packageManager.getApplicationInfo(this, 0).isSystemApp()) { - // if system app is NOT overridden, it means it's in the exclusion list - if (!appTrackerRepository.getSystemAppOverrideList().map { it.packageId }.contains(this)) { - return true - } - } - - return appTrackerRepository.getManualAppExclusionList().firstOrNull { it.packageId == this }?.let { - // if app is defined as "unprotected" by the user, then it is in exclusion list - return !it.isProtected - // else, app is in the exclusion list - } ?: appTrackerRepository.getAppExclusionList().map { it.packageId }.contains(this) - } -} - -// TODO - this is a temporary solution to unblock NetP. We need to spend more time thinking about how to properly share and unify the AppTrackerDetector impl -// Also this file should not be in this module, it's temporarily here until we settle on how exclusion lists should work for NetP -// decide what needs to be extracted to API modules and how AppTrackerDetector should work / be shared -@ContributesTo( - scope = VpnScope::class, - replaces = [AppTrackerDetectorModule::class], -) -@Module -object NetpAppTrackerDetectorModule { - @Provides - fun provideNetpAppTrackerDetectorModule( - appTrackerRepository: AppTrackerRepository, - appNameResolver: AppNameResolver, - appTrackerRecorder: AppTrackerRecorder, - vpnDatabase: VpnDatabase, - packageManager: PackageManager, - vpnFeaturesRegistry: VpnFeaturesRegistry, - ): AppTrackerDetector { - return NetpRealAppTrackerDetector( - appTrackerRepository = appTrackerRepository, - appNameResolver = appNameResolver, - appTrackerRecorder = appTrackerRecorder, - vpnAppTrackerBlockingDao = vpnDatabase.vpnAppTrackerBlockingDao(), - packageManager = packageManager, - vpnFeaturesRegistry = vpnFeaturesRegistry, - ) - } -} diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/RealAppTrackerDetector.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/RealAppTrackerDetector.kt index 17ca9fb096b7..68e3fb946d93 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/RealAppTrackerDetector.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/app/tracking/RealAppTrackerDetector.kt @@ -16,9 +16,13 @@ package com.duckduckgo.mobile.android.app.tracking +import android.content.pm.PackageManager import android.util.LruCache import com.duckduckgo.di.scopes.VpnScope +import com.duckduckgo.mobile.android.vpn.AppTpVpnFeature +import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.mobile.android.vpn.apps.VpnExclusionList +import com.duckduckgo.mobile.android.vpn.apps.isSystemApp import com.duckduckgo.mobile.android.vpn.dao.VpnAppTrackerBlockingDao import com.duckduckgo.mobile.android.vpn.model.TrackingApp import com.duckduckgo.mobile.android.vpn.model.VpnTracker @@ -30,6 +34,7 @@ import com.duckduckgo.mobile.android.vpn.trackers.AppTrackerType import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides +import kotlinx.coroutines.runBlocking import logcat.logcat class RealAppTrackerDetector constructor( @@ -37,17 +42,29 @@ class RealAppTrackerDetector constructor( private val appNameResolver: AppNameResolver, private val appTrackerRecorder: AppTrackerRecorder, private val vpnAppTrackerBlockingDao: VpnAppTrackerBlockingDao, + private val packageManager: PackageManager, + private val vpnFeaturesRegistry: VpnFeaturesRegistry, ) : AppTrackerDetector { + private fun isAppTpDisabled(): Boolean { + return runBlocking { !vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN) } + } + // cache packageId -> app name private val appNamesCache = LruCache(100) override fun evaluate(domain: String, uid: Int): AppTrackerDetector.AppTracker? { - // `null` means unknown package ID, do not block + // Check if AppTP is enabled first + if (isAppTpDisabled()) { + logcat { "App tracker detector is DISABLED" } + return null + } + + // `null` package ID means unknown app, return null to not block val packageId = appNameResolver.getPackageIdForUid(uid) ?: return null - if (VpnExclusionList.isDdgApp(packageId)) { - logcat { "shouldAllowDomain: DDG app is always allowed" } + if (VpnExclusionList.isDdgApp(packageId) || packageId.isInExclusionList()) { + logcat { "shouldAllowDomain: $packageId is excluded, allowing packet" } return null } @@ -92,23 +109,42 @@ class RealAppTrackerDetector constructor( return rule.packageNames.contains(packageId) } ?: false } + + private fun String.isInExclusionList(): Boolean { + if (packageManager.getApplicationInfo(this, 0).isSystemApp()) { + // if system app is NOT overridden, it means it's in the exclusion list + if (!appTrackerRepository.getSystemAppOverrideList().map { it.packageId }.contains(this)) { + return true + } + } + + return appTrackerRepository.getManualAppExclusionList().firstOrNull { it.packageId == this }?.let { + // if app is defined as "unprotected" by the user, then it is in exclusion list + return !it.isProtected + // else, app is in the exclusion list + } ?: appTrackerRepository.getAppExclusionList().map { it.packageId }.contains(this) + } } -@ContributesTo(VpnScope::class) +@ContributesTo(scope = VpnScope::class) @Module object AppTrackerDetectorModule { @Provides - fun provideAppTrackerDetector( + fun provideAppTrackerDetectorModule( appTrackerRepository: AppTrackerRepository, appNameResolver: AppNameResolver, appTrackerRecorder: AppTrackerRecorder, vpnDatabase: VpnDatabase, + packageManager: PackageManager, + vpnFeaturesRegistry: VpnFeaturesRegistry, ): AppTrackerDetector { return RealAppTrackerDetector( appTrackerRepository = appTrackerRepository, appNameResolver = appNameResolver, appTrackerRecorder = appTrackerRecorder, vpnAppTrackerBlockingDao = vpnDatabase.vpnAppTrackerBlockingDao(), + packageManager = packageManager, + vpnFeaturesRegistry = vpnFeaturesRegistry, ) } } diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/AppTrackerDetectorTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/AppTrackerDetectorTest.kt index 6385019fa902..ce6351a7c1b4 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/AppTrackerDetectorTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/AppTrackerDetectorTest.kt @@ -16,17 +16,26 @@ package com.duckduckgo.mobile.android.app.tracking +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.mobile.android.vpn.AppTpVpnFeature +import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.mobile.android.vpn.dao.VpnAppTrackerBlockingDao import com.duckduckgo.mobile.android.vpn.processor.requestingapp.AppNameResolver import com.duckduckgo.mobile.android.vpn.processor.tcp.tracker.AppTrackerRecorder import com.duckduckgo.mobile.android.vpn.trackers.* +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -35,6 +44,8 @@ class AppTrackerDetectorTest { private val appNameResolver: AppNameResolver = mock() private val appTrackerRecorder: AppTrackerRecorder = mock() private val vpnAppTrackerBlockingDao: VpnAppTrackerBlockingDao = mock() + private val packageManager: PackageManager = mock() + private val vpnFeaturesRegistry: VpnFeaturesRegistry = mock() private lateinit var appTrackerDetector: AppTrackerDetector @@ -43,12 +54,16 @@ class AppTrackerDetectorTest { whenever(appNameResolver.getAppNameForPackageId(APP_ORIGINATING_APP.packageId)).thenReturn(APP_ORIGINATING_APP) whenever(appNameResolver.getAppNameForPackageId(AppNameResolver.OriginatingApp.unknown().packageId)) .thenReturn(AppNameResolver.OriginatingApp.unknown()) + whenever(packageManager.getApplicationInfo(any(), eq(0))).thenReturn(ApplicationInfo()) + runBlocking { whenever(vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN)).thenReturn(true) } appTrackerDetector = RealAppTrackerDetector( appTrackerRepository, appNameResolver, appTrackerRecorder, vpnAppTrackerBlockingDao, + packageManager, + vpnFeaturesRegistry, ) } @@ -72,6 +87,186 @@ class AppTrackerDetectorTest { ) } + @Test + fun whenEvaluateThirdPartyTrackerAndSystemAppAndNotInExclusionListAndReturnNull() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) + .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) + + assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndoverriddenSystemAppAndNotInExclusionListAndReturnTracker() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) + .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) + + // overridden system app + whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( + listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), + ) + + assertEquals( + AppTrackerDetector.AppTracker( + TEST_APP_TRACKER.hostname, + APP_UID, + TEST_APP_TRACKER.owner.displayName, + APP_ORIGINATING_APP.packageId, + APP_ORIGINATING_APP.appName, + ), + appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), + ) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndSystemAppAndInExclusionListAndReturnNull() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) + .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) + // in exclusion list + whenever(appTrackerRepository.getAppExclusionList()).thenReturn( + listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), + ) + + assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndOverriddenSystemAppAndInExclusionListAndReturnNull() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) + .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) + // overridden system app + whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( + listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), + ) + // in exclusion list + whenever(appTrackerRepository.getAppExclusionList()).thenReturn( + listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), + ) + + assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndOverriddenSystemAppThenReturnTracker() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) + .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) + whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( + listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), + ) + + assertEquals( + AppTrackerDetector.AppTracker( + TEST_APP_TRACKER.hostname, + APP_UID, + TEST_APP_TRACKER.owner.displayName, + APP_ORIGINATING_APP.packageId, + APP_ORIGINATING_APP.appName, + ), + appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), + ) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndManuallyUnprotectedThenReturnNull() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( + listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, false)), + ) + + assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndManuallyProtectedThenReturnTracker() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( + listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, true)), + ) + + assertEquals( + AppTrackerDetector.AppTracker( + TEST_APP_TRACKER.hostname, + APP_UID, + TEST_APP_TRACKER.owner.displayName, + APP_ORIGINATING_APP.packageId, + APP_ORIGINATING_APP.appName, + ), + appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), + ) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndInExclusionListThenReturnNull() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(appTrackerRepository.getAppExclusionList()).thenReturn( + listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), + ) + + assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + } + + @Test + fun whenEvaluateThirdPartyTrackerAndManuallyProtectedAndInExclusionListThenReturnTracker() { + whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) + .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) + whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) + + whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) + whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( + listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, true)), + ) + whenever(appTrackerRepository.getAppExclusionList()).thenReturn( + listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), + ) + + assertEquals( + AppTrackerDetector.AppTracker( + TEST_APP_TRACKER.hostname, + APP_UID, + TEST_APP_TRACKER.owner.displayName, + APP_ORIGINATING_APP.packageId, + APP_ORIGINATING_APP.appName, + ), + appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), + ) + } + @Test fun whenEvaluateThirdPartyTrackerFromUnknownAppThenReturnNull() { val packageId = AppNameResolver.OriginatingApp.unknown().packageId @@ -136,6 +331,25 @@ class AppTrackerDetectorTest { assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) } + @Test + fun whenAppTpDisabledReturnNull() = runTest { + whenever(vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN)).thenReturn(false) + + val appTrackerDetectorDisabled = RealAppTrackerDetector( + appTrackerRepository, + appNameResolver, + appTrackerRecorder, + vpnAppTrackerBlockingDao, + packageManager, + vpnFeaturesRegistry, + ) + + assertNull(appTrackerDetectorDisabled.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) + + verifyNoInteractions(appNameResolver) + verifyNoInteractions(appTrackerRepository) + } + @Test fun whenNullPackageIdThenEvaluateReturnsNull() { whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(null) diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/NetpAppTrackerDetectorTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/NetpAppTrackerDetectorTest.kt deleted file mode 100644 index f5d72994ac67..000000000000 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/app/tracking/NetpAppTrackerDetectorTest.kt +++ /dev/null @@ -1,368 +0,0 @@ -/* - * Copyright (c) 2022 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.mobile.android.app.tracking - -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.mobile.android.vpn.AppTpVpnFeature -import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry -import com.duckduckgo.mobile.android.vpn.dao.VpnAppTrackerBlockingDao -import com.duckduckgo.mobile.android.vpn.processor.requestingapp.AppNameResolver -import com.duckduckgo.mobile.android.vpn.processor.tcp.tracker.AppTrackerRecorder -import com.duckduckgo.mobile.android.vpn.trackers.* -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.verifyNoInteractions -import org.mockito.kotlin.whenever - -@RunWith(AndroidJUnit4::class) -class NetpAppTrackerDetectorTest { - private val appTrackerRepository: AppTrackerRepository = mock() - private val appNameResolver: AppNameResolver = mock() - private val appTrackerRecorder: AppTrackerRecorder = mock() - private val vpnAppTrackerBlockingDao: VpnAppTrackerBlockingDao = mock() - private val packageManager: PackageManager = mock() - private val vpnFeaturesRegistry: VpnFeaturesRegistry = mock() - - private lateinit var appTrackerDetector: AppTrackerDetector - - @Before - fun setup() { - whenever(appNameResolver.getAppNameForPackageId(APP_ORIGINATING_APP.packageId)).thenReturn(APP_ORIGINATING_APP) - whenever(appNameResolver.getAppNameForPackageId(AppNameResolver.OriginatingApp.unknown().packageId)) - .thenReturn(AppNameResolver.OriginatingApp.unknown()) - whenever(packageManager.getApplicationInfo(any(), eq(0))).thenReturn(ApplicationInfo()) - runBlocking { whenever(vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN)).thenReturn(true) } - - appTrackerDetector = NetpRealAppTrackerDetector( - appTrackerRepository, - appNameResolver, - appTrackerRecorder, - vpnAppTrackerBlockingDao, - packageManager, - vpnFeaturesRegistry, - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerThenReturnTracker() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - - assertEquals( - AppTrackerDetector.AppTracker( - TEST_APP_TRACKER.hostname, - APP_UID, - TEST_APP_TRACKER.owner.displayName, - APP_ORIGINATING_APP.packageId, - APP_ORIGINATING_APP.appName, - ), - appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndSystemAppAndNotInExclusionListAndReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) - .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndoverriddenSystemAppAndNotInExclusionListAndReturnTracker() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) - .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) - - // overridden system app - whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( - listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), - ) - - assertEquals( - AppTrackerDetector.AppTracker( - TEST_APP_TRACKER.hostname, - APP_UID, - TEST_APP_TRACKER.owner.displayName, - APP_ORIGINATING_APP.packageId, - APP_ORIGINATING_APP.appName, - ), - appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndSystemAppAndInExclusionListAndReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) - .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) - // in exclusion list - whenever(appTrackerRepository.getAppExclusionList()).thenReturn( - listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), - ) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndOverriddenSystemAppAndInExclusionListAndReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) - .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) - // overridden system app - whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( - listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), - ) - // in exclusion list - whenever(appTrackerRepository.getAppExclusionList()).thenReturn( - listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), - ) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndOverriddenSystemAppThenReturnTracker() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(packageManager.getApplicationInfo(APP_PACKAGE_ID, 0)) - .thenReturn(ApplicationInfo().apply { flags = ApplicationInfo.FLAG_SYSTEM }) - whenever(appTrackerRepository.getSystemAppOverrideList()).thenReturn( - listOf(AppTrackerSystemAppOverridePackage(APP_PACKAGE_ID)), - ) - - assertEquals( - AppTrackerDetector.AppTracker( - TEST_APP_TRACKER.hostname, - APP_UID, - TEST_APP_TRACKER.owner.displayName, - APP_ORIGINATING_APP.packageId, - APP_ORIGINATING_APP.appName, - ), - appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndManuallyUnprotectedThenReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( - listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, false)), - ) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndManuallyProtectedThenReturnTracker() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( - listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, true)), - ) - - assertEquals( - AppTrackerDetector.AppTracker( - TEST_APP_TRACKER.hostname, - APP_UID, - TEST_APP_TRACKER.owner.displayName, - APP_ORIGINATING_APP.packageId, - APP_ORIGINATING_APP.appName, - ), - appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndInExclusionListThenReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(appTrackerRepository.getAppExclusionList()).thenReturn( - listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), - ) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerAndManuallyProtectedAndInExclusionListThenReturnTracker() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - whenever(appTrackerRepository.getManualAppExclusionList()).thenReturn( - listOf(AppTrackerManualExcludedApp(APP_PACKAGE_ID, true)), - ) - whenever(appTrackerRepository.getAppExclusionList()).thenReturn( - listOf(AppTrackerExcludedPackage(APP_PACKAGE_ID, reason = "")), - ) - - assertEquals( - AppTrackerDetector.AppTracker( - TEST_APP_TRACKER.hostname, - APP_UID, - TEST_APP_TRACKER.owner.displayName, - APP_ORIGINATING_APP.packageId, - APP_ORIGINATING_APP.appName, - ), - appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID), - ) - } - - @Test - fun whenEvaluateThirdPartyTrackerFromUnknownAppThenReturnNull() { - val packageId = AppNameResolver.OriginatingApp.unknown().packageId - - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, packageId)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(packageId) - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerFromDdgAppThenReturnNull() { - // This test case is just in case we pass the DDG traffic through the VPN. Our app doesn't embed trackers but web trackers might be detected - // as app trackers. - - val packageId = "com.duckduckgo.mobile" - - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, packageId)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateThirdPartyTrackerInExclusionListThenReturnTrackerNull() { - val packageId = APP_PACKAGE_ID - - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, packageId)) - .thenReturn(AppTrackerType.ThirdParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn( - AppTrackerExceptionRule(TEST_APP_TRACKER.hostname, listOf(packageId)), - ) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateFirstPartyTrackerThenReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)) - .thenReturn(AppTrackerType.FirstParty(TEST_APP_TRACKER)) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenEvaluateNonTrackerThenReturnNull() { - whenever(appTrackerRepository.findTracker(TEST_APP_TRACKER.hostname, APP_PACKAGE_ID)).thenReturn(AppTrackerType.NotTracker) - whenever(appNameResolver.getPackageIdForUid(APP_UID)).thenReturn(APP_ORIGINATING_APP.packageId) - - whenever(vpnAppTrackerBlockingDao.getRuleByTrackerDomain(TEST_APP_TRACKER.hostname)).thenReturn(null) - - assertNull(appTrackerDetector.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - } - - @Test - fun whenAppTpDisabledReturnNull() = runTest { - whenever(vpnFeaturesRegistry.isFeatureRegistered(AppTpVpnFeature.APPTP_VPN)).thenReturn(false) - - val appTrackerDetectorDisabled = NetpRealAppTrackerDetector( - appTrackerRepository, - appNameResolver, - appTrackerRecorder, - vpnAppTrackerBlockingDao, - packageManager, - vpnFeaturesRegistry, - ) - - assertNull(appTrackerDetectorDisabled.evaluate(TEST_APP_TRACKER.hostname, APP_UID)) - - verifyNoInteractions(appNameResolver) - verifyNoInteractions(appTrackerRepository) - } - - companion object { - private const val APP_UID = 55 - private const val APP_PACKAGE_ID = "com.app.name" - private val APP_ORIGINATING_APP = AppNameResolver.OriginatingApp(APP_PACKAGE_ID, "testApp") - private val TEST_APP_TRACKER = AppTracker( - hostname = "api2.branch.com", - trackerCompanyId = 0, - owner = TrackerOwner( - name = "Branch", - displayName = "Branch", - ), - app = TrackerApp(0, 0.0), - isCdn = false, - ) - } -} From 4e6c4ab1295e273a49369af9476e617161d2262c Mon Sep 17 00:00:00 2001 From: Josh Leibstein Date: Mon, 22 Jan 2024 14:01:46 +0000 Subject: [PATCH 02/26] Remove bookmarks undo test (#4101) --- ...undo.yaml => favorites_bookmarks_add.yaml} | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) rename .maestro/favorites/{favorites_bookmarks_undo.yaml => favorites_bookmarks_add.yaml} (52%) diff --git a/.maestro/favorites/favorites_bookmarks_undo.yaml b/.maestro/favorites/favorites_bookmarks_add.yaml similarity index 52% rename from .maestro/favorites/favorites_bookmarks_undo.yaml rename to .maestro/favorites/favorites_bookmarks_add.yaml index 47c39475abfe..f54cea786d4e 100644 --- a/.maestro/favorites/favorites_bookmarks_undo.yaml +++ b/.maestro/favorites/favorites_bookmarks_add.yaml @@ -1,5 +1,5 @@ appId: com.duckduckgo.mobile.android -name: "ReleaseTest: Can undo a deleted favorite" +name: "ReleaseTest: Can add favorite from bookmark screen" tags: - releaseTest --- @@ -20,40 +20,18 @@ tags: text: "got it" - tapOn: id: "com.duckduckgo.mobile.android:id/browserMenuImageView" -- assertVisible: - text: "add bookmark" - tapOn: text: "add bookmark" -# Add favorite from edit saved site -- tapOn: - id: "com.duckduckgo.mobile.android:id/browserMenuImageView" -- assertVisible: - text: "edit bookmark" -- tapOn: - text: "edit bookmark" -- assertVisible: - text: "add to favorites" -- tapOn: - text: "add to favorites" -- tapOn: - id: "com.duckduckgo.mobile.android:id/action_confirm_changes" # Navigate to bookmarks screen - tapOn: id: "com.duckduckgo.mobile.android:id/browserMenuImageView" -- assertVisible: - text: "bookmarks" - tapOn: text: "bookmarks" -# Remove bookmark from bookmarks screen +# Add favorite from bookmarks screen - tapOn: id: "com.duckduckgo.mobile.android:id/trailingIcon" index: 0 -- assertVisible: - text: "Delete" -- tapOn: - text: "delete" -# Undo remove bookmark from bookmarks screen - tapOn: - id: "com.duckduckgo.mobile.android:id/snackbar_action" -- assertNotVisible: - text: "No bookmarks added yet" + text: "add to favorites" +- assertVisible: + id: "com.duckduckgo.mobile.android:id/favoriteStar" From 622c65fa5fb158e7a497c1cde323d03186a1d941 Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Mon, 22 Jan 2024 15:41:28 +0100 Subject: [PATCH 03/26] Change targetsdk to 14 (#4063) Task/Issue URL: https://app.asana.com/0/488551667048375/1206318340789538/f ### Description See attached task description ### Steps to test this PR Smoke test app --- .../vpn-impl/src/main/AndroidManifest.xml | 4 ++ .../vpn/apps/NewAppBroadcastReceiver.kt | 3 +- .../DeviceShieldNotificationsDebugReceiver.kt | 6 ++- .../vpn/debug/SendTrackerDebugReceiver.kt | 3 +- .../android/vpn/service/RestartReceiver.kt | 3 +- .../vpn/service/VpnReminderReceiverManager.kt | 27 ++++++---- .../service/VpnTrackerNotificationUpdates.kt | 3 +- .../ui/alwayson/AlwaysOnLockDownDetector.kt | 3 +- .../AppTPReminderNotificationScheduler.kt | 7 ++- .../DeviceShieldNotificationScheduler.kt | 54 ++++++++++--------- .../vpn/service/RestartReceiverTest.kt | 8 +-- .../feature/InternalFeatureReceiver.kt | 3 +- .../rules/ExceptionRulesDebugReceiver.kt | 3 +- .../privacy/TrackerDataDevReceiver.kt | 3 +- .../app/notification/AppNotificationSender.kt | 3 +- .../app/widget/WidgetAddedReceiver.kt | 3 +- .../sync/CredentialsSyncFeatureListener.kt | 4 +- build.gradle | 2 +- .../common-utils/src/main/AndroidManifest.xml | 1 + .../utils/extensions/ContextExtensions.kt | 17 ++++++ .../utils/notification/NotificationUtils.kt | 34 ++++++++++++ .../DefaultFileDownloadNotificationManager.kt | 9 ++-- .../FileDownloadNotificationActionReceiver.kt | 3 +- .../NetPDisabledNotificationScheduler.kt | 10 ++-- .../impl/timezone/NetPTimezoneMonitor.kt | 3 +- .../feature/InternalFeatureReceiver.kt | 3 +- .../feature/snooze/VpnCallStateReceiver.kt | 10 +++- .../internal/rekey/DebugRekeyReceiver.kt | 9 +++- .../NetpAccessRevokedNotificationScheduler.kt | 4 +- .../sync/SavedSitesSyncFeatureListener.kt | 4 +- .../duckduckgo/sync/impl/SyncFeatureToggle.kt | 4 +- versions.properties | 2 +- 32 files changed, 182 insertions(+), 73 deletions(-) create mode 100644 common/common-utils/src/main/java/com/duckduckgo/common/utils/notification/NotificationUtils.kt diff --git a/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml b/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml index 789ad0ef9338..c7239be1ca5c 100644 --- a/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml +++ b/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/apps/NewAppBroadcastReceiver.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/apps/NewAppBroadcastReceiver.kt index 92a6a706e805..9f9149b85630 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/apps/NewAppBroadcastReceiver.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/apps/NewAppBroadcastReceiver.kt @@ -23,6 +23,7 @@ import android.content.IntentFilter import androidx.annotation.MainThread import androidx.annotation.WorkerThread import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerExportedReceiver import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.AppTpVpnFeature @@ -81,7 +82,7 @@ class NewAppBroadcastReceiver @Inject constructor( addAction(Intent.ACTION_PACKAGE_ADDED) addDataScheme("package") }.run { - applicationContext.registerReceiver(this@NewAppBroadcastReceiver, this) + applicationContext.registerExportedReceiver(this@NewAppBroadcastReceiver, this) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/DeviceShieldNotificationsDebugReceiver.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/DeviceShieldNotificationsDebugReceiver.kt index 56dc167dcc90..ce9e8bae6d18 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/DeviceShieldNotificationsDebugReceiver.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/DeviceShieldNotificationsDebugReceiver.kt @@ -26,6 +26,8 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.mobile.android.vpn.ui.notification.* import com.squareup.anvil.annotations.ContributesMultibinding @@ -49,7 +51,7 @@ class DeviceShieldNotificationsDebugReceiver( ) : BroadcastReceiver() { init { - context.registerReceiver(this, IntentFilter(intentAction)) + context.registerNotExportedReceiver(this, IntentFilter(intentAction)) } override fun onReceive( @@ -116,7 +118,7 @@ class DeviceShieldNotificationsDebugReceiverRegister @Inject constructor( } notification?.let { - notificationManagerCompat.notify(DeviceShieldNotificationScheduler.VPN_WEEKLY_NOTIFICATION_ID, it) + notificationManagerCompat.checkPermissionAndNotify(context, DeviceShieldNotificationScheduler.VPN_WEEKLY_NOTIFICATION_ID, it) } } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/SendTrackerDebugReceiver.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/SendTrackerDebugReceiver.kt index 76cefe11cc4e..e108cd292870 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/SendTrackerDebugReceiver.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/debug/SendTrackerDebugReceiver.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.content.IntentFilter import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.model.TrackingApp @@ -66,7 +67,7 @@ class SendTrackerDebugReceiver @Inject constructor( } logcat { "Debug receiver SendTrackerDebugReceiver registered" } - context.registerReceiver(this, IntentFilter(INTENT_ACTION)) + context.registerNotExportedReceiver(this, IntentFilter(INTENT_ACTION)) } private fun unregister() { diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiver.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiver.kt index 1bb0c9ba754a..2a71939bede8 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiver.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiver.kt @@ -24,6 +24,7 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor import com.squareup.anvil.annotations.ContributesMultibinding @@ -56,7 +57,7 @@ class RestartReceiver @Inject constructor( if (appBuildConfig.isInternalBuild()) { logcat { "Starting vpn-service receiver" } unregister() - context.registerReceiver(this, IntentFilter("vpn-service")) + context.registerNotExportedReceiver(this, IntentFilter("vpn-service")) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderReceiverManager.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderReceiverManager.kt index 23a79e3f2a6c..292cccbdb3ac 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderReceiverManager.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderReceiverManager.kt @@ -16,8 +16,11 @@ package com.duckduckgo.mobile.android.vpn.service +import android.Manifest.permission import android.content.Context import android.content.SharedPreferences +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.edit import com.duckduckgo.common.utils.plugins.PluginPoint @@ -50,18 +53,20 @@ class AndroidVpnReminderReceiverManager @Inject constructor( logcat { "Vpn is already running, nothing to show" } } else { logcat { "Vpn is not running, showing reminder notification" } - val notification = vpnReminderNotificationContentPluginPoint.getHighestPriorityPluginForType(DISABLED)?.getContent()?.let { content -> - val actualContent = if (wasReminderNotificationShown()) { - content.copy(true) - } else { - notificationWasShown() - content.copy(false) + if (ActivityCompat.checkSelfPermission(context, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + val notification = vpnReminderNotificationContentPluginPoint.getHighestPriorityPluginForType(DISABLED)?.getContent()?.let { content -> + val actualContent = if (wasReminderNotificationShown()) { + content.copy(true) + } else { + notificationWasShown() + content.copy(false) + } + vpnReminderNotificationBuilder.buildReminderNotification(actualContent) + } + if (notification != null) { + deviceShieldPixels.didShowReminderNotification() + notificationManager.notify(TrackerBlockingVpnService.VPN_REMINDER_NOTIFICATION_ID, notification) } - vpnReminderNotificationBuilder.buildReminderNotification(actualContent) - } - if (notification != null) { - deviceShieldPixels.didShowReminderNotification() - notificationManager.notify(TrackerBlockingVpnService.VPN_REMINDER_NOTIFICATION_ID, notification) } } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt index 8b01315bcd13..23ca9d65fde5 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason @@ -71,6 +72,6 @@ class VpnTrackerNotificationUpdates @Inject constructor( vpnNotification: VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent, ) { val notification = VpnEnabledNotificationBuilder.buildVpnEnabledUpdateNotification(context, vpnNotification) - notificationManager.notify(TrackerBlockingVpnService.VPN_FOREGROUND_SERVICE_ID, notification) + notificationManager.checkPermissionAndNotify(context, TrackerBlockingVpnService.VPN_FOREGROUND_SERVICE_ID, notification) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt index 200333b75728..9aa5de8eef0d 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt @@ -22,6 +22,7 @@ import android.text.SpannableStringBuilder import androidx.core.app.NotificationManagerCompat import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams @@ -95,7 +96,7 @@ class AlwaysOnLockDownDetector @Inject constructor( notification, intent, ).also { - notificationManagerCompat.notify(notificationId, it) + notificationManagerCompat.checkPermissionAndNotify(context, notificationId, it) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationScheduler.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationScheduler.kt index 9e5dd969ddd5..945373478826 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationScheduler.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationScheduler.kt @@ -26,6 +26,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection @@ -179,7 +180,8 @@ class AppTPReminderNotificationScheduler @Inject constructor( vpnReminderNotificationContentPluginPoint.getHighestPriorityPluginForType(DISABLED)?.let { it.getContent()?.let { content -> logcat { "Showing disabled notification from $it" } - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, TrackerBlockingVpnService.VPN_REMINDER_NOTIFICATION_ID, vpnReminderNotificationBuilder.buildReminderNotification(content), ) @@ -191,7 +193,8 @@ class AppTPReminderNotificationScheduler @Inject constructor( vpnReminderNotificationContentPluginPoint.getHighestPriorityPluginForType(REVOKED)?.let { it.getContent()?.let { content -> logcat { "Showing revoked notification from $it" } - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, TrackerBlockingVpnService.VPN_REMINDER_NOTIFICATION_ID, vpnReminderNotificationBuilder.buildReminderNotification(content), ) diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationScheduler.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationScheduler.kt index efa48e109449..10a2c4da4ec2 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationScheduler.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationScheduler.kt @@ -16,7 +16,10 @@ package com.duckduckgo.mobile.android.vpn.ui.notification +import android.Manifest.permission import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.LifecycleOwner import androidx.work.* @@ -185,18 +188,20 @@ class DeviceShieldDailyNotificationWorker( } private suspend fun showNotification() { - val deviceShieldNotification = deviceShieldNotificationFactory.createDailyDeviceShieldNotification().also { - notificationPressedHandler.notificationVariant = it.notificationVariant - } + if (ActivityCompat.checkSelfPermission(context, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + val deviceShieldNotification = deviceShieldNotificationFactory.createDailyDeviceShieldNotification().also { + notificationPressedHandler.notificationVariant = it.notificationVariant + } - if (!deviceShieldNotification.hidden) { - val notification = - deviceShieldAlertNotificationBuilder.buildStatusNotification(context, deviceShieldNotification, notificationPressedHandler) - deviceShieldPixels.didShowDailyNotification(deviceShieldNotification.notificationVariant) - notificationManager.notify(DeviceShieldNotificationScheduler.VPN_DAILY_NOTIFICATION_ID, notification) - logcat { "Vpn Daily notification is now shown" } - } else { - logcat { "Vpn Daily notification won't be shown because there is no data to show" } + if (!deviceShieldNotification.hidden) { + val notification = + deviceShieldAlertNotificationBuilder.buildStatusNotification(context, deviceShieldNotification, notificationPressedHandler) + deviceShieldPixels.didShowDailyNotification(deviceShieldNotification.notificationVariant) + notificationManager.notify(DeviceShieldNotificationScheduler.VPN_DAILY_NOTIFICATION_ID, notification) + logcat { "Vpn Daily notification is now shown" } + } else { + logcat { "Vpn Daily notification won't be shown because there is no data to show" } + } } } } @@ -223,20 +228,21 @@ class DeviceShieldWeeklyNotificationWorker( override suspend fun doWork(): Result { logcat { "Vpn Weekly notification worker is now awake" } + if (ActivityCompat.checkSelfPermission(context, permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + val deviceShieldNotification = deviceShieldNotificationFactory.createWeeklyDeviceShieldNotification().also { + notificationPressedHandler.notificationVariant = it.notificationVariant + } - val deviceShieldNotification = deviceShieldNotificationFactory.createWeeklyDeviceShieldNotification().also { - notificationPressedHandler.notificationVariant = it.notificationVariant - } - - if (!deviceShieldNotification.hidden) { - logcat { "Vpn Daily notification won't be shown because there is no data to show" } - val notification = deviceShieldAlertNotificationBuilder.buildStatusNotification( - context, - deviceShieldNotification, - notificationPressedHandler, - ) - deviceShieldPixels.didShowWeeklyNotification(deviceShieldNotification.notificationVariant) - notificationManager.notify(Companion.VPN_WEEKLY_NOTIFICATION_ID, notification) + if (!deviceShieldNotification.hidden) { + logcat { "Vpn Daily notification won't be shown because there is no data to show" } + val notification = deviceShieldAlertNotificationBuilder.buildStatusNotification( + context, + deviceShieldNotification, + notificationPressedHandler, + ) + deviceShieldPixels.didShowWeeklyNotification(deviceShieldNotification.notificationVariant) + notificationManager.notify(Companion.VPN_WEEKLY_NOTIFICATION_ID, notification) + } } return Result.success() diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiverTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiverTest.kt index 328ff2738714..23590a281847 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiverTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/RestartReceiverTest.kt @@ -56,7 +56,7 @@ class RestartReceiverTest { receiver.onVpnStarted(coroutineRule.testScope) verify(context).unregisterReceiver(any()) - verify(context).registerReceiver(any(), any()) + verify(context).registerReceiver(any(), any(), isNull(), isNull(), any()) } @Test @@ -66,7 +66,7 @@ class RestartReceiverTest { receiver.onVpnStarted(coroutineRule.testScope) verify(context, never()).unregisterReceiver(any()) - verify(context, never()).registerReceiver(any(), any()) + verify(context, never()).registerReceiver(any(), any(), isNull(), isNull(), any()) } @Test @@ -76,7 +76,7 @@ class RestartReceiverTest { receiver.onVpnStopped(coroutineRule.testScope, VpnStateMonitor.VpnStopReason.SELF_STOP()) verify(context).unregisterReceiver(any()) - verify(context, never()).registerReceiver(any(), any()) + verify(context, never()).registerReceiver(any(), any(), isNull(), isNull(), any()) } @Test @@ -86,6 +86,6 @@ class RestartReceiverTest { receiver.onVpnStopped(coroutineRule.testScope, VpnStateMonitor.VpnStopReason.SELF_STOP()) verify(context).unregisterReceiver(any()) - verify(context, never()).registerReceiver(any(), any()) + verify(context, never()).registerReceiver(any(), any(), isNull(), isNull(), any()) } } diff --git a/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/InternalFeatureReceiver.kt b/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/InternalFeatureReceiver.kt index 1ff72fb6803a..7f32fdf7231c 100644 --- a/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/InternalFeatureReceiver.kt +++ b/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/InternalFeatureReceiver.kt @@ -20,6 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver /** * Abstract class to create generic receivers for internal features accessible through @@ -44,7 +45,7 @@ abstract class InternalFeatureReceiver( fun register() { unregister() - context.registerReceiver(this, IntentFilter(intentAction())) + context.registerNotExportedReceiver(this, IntentFilter(intentAction())) } fun unregister() { diff --git a/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/rules/ExceptionRulesDebugReceiver.kt b/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/rules/ExceptionRulesDebugReceiver.kt index aa6347101253..bee5be79081c 100644 --- a/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/rules/ExceptionRulesDebugReceiver.kt +++ b/app-tracking-protection/vpn-internal/src/main/java/com/duckduckgo/vpn/internal/feature/rules/ExceptionRulesDebugReceiver.kt @@ -21,6 +21,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason @@ -48,7 +49,7 @@ class ExceptionRulesDebugReceiver( init { kotlin.runCatching { context.unregisterReceiver(this) } - context.registerReceiver(this, IntentFilter(intentAction)) + context.registerNotExportedReceiver(this, IntentFilter(intentAction)) } override fun onReceive( diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/privacy/TrackerDataDevReceiver.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/privacy/TrackerDataDevReceiver.kt index 560d06f2be25..268c762046bc 100644 --- a/app/src/internal/java/com/duckduckgo/app/dev/settings/privacy/TrackerDataDevReceiver.kt +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/privacy/TrackerDataDevReceiver.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.trackerdetection.api.TrackerDataDownloader import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import io.reactivex.android.schedulers.AndroidSchedulers @@ -39,7 +40,7 @@ class TrackerDataDevReceiver( private val receiver: (Intent) -> Unit, ) : BroadcastReceiver() { init { - context.registerReceiver(this, IntentFilter(intentAction)) + context.registerNotExportedReceiver(this, IntentFilter(intentAction)) } override fun onReceive( diff --git a/app/src/main/java/com/duckduckgo/app/notification/AppNotificationSender.kt b/app/src/main/java/com/duckduckgo/app/notification/AppNotificationSender.kt index da067b82346c..6b8caefe42d7 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/AppNotificationSender.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/AppNotificationSender.kt @@ -22,6 +22,7 @@ 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.notification.model.SchedulableNotificationPlugin +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.common.utils.plugins.PluginPoint import timber.log.Timber @@ -54,7 +55,7 @@ class AppNotificationSender( val cancelIntent = NotificationHandlerService.pendingCancelNotificationHandlerIntent(context, notification.javaClass) val systemNotification = factory.createNotification(specification, launchIntent, cancelIntent) notificationDao.insert(Notification(notification.id)) - manager.notify(specification.systemId, systemNotification) + manager.checkPermissionAndNotify(context, specification.systemId, systemNotification) notificationPlugin.onNotificationShown() } diff --git a/app/src/main/java/com/duckduckgo/app/widget/WidgetAddedReceiver.kt b/app/src/main/java/com/duckduckgo/app/widget/WidgetAddedReceiver.kt index 1070a1314c98..3ea7aee196df 100644 --- a/app/src/main/java/com/duckduckgo/app/widget/WidgetAddedReceiver.kt +++ b/app/src/main/java/com/duckduckgo/app/widget/WidgetAddedReceiver.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.browser.R import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.widget.AppWidgetManagerAddWidgetLauncher.Companion.ACTION_ADD_WIDGET +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -46,7 +47,7 @@ class WidgetAddedReceiver @Inject constructor( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - context.registerReceiver(this, IntentFilter(ACTION_ADD_WIDGET)) + context.registerNotExportedReceiver(this, IntentFilter(ACTION_ADD_WIDGET)) } override fun onDestroy(owner: LifecycleOwner) { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt index 5d36ad165bbe..8694138316a3 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/sync/CredentialsSyncFeatureListener.kt @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.sync import android.content.Context import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.engine.FeatureSyncError import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED @@ -65,7 +66,8 @@ class AppCredentialsSyncFeatureListener @Inject constructor( } private fun triggerNotification() { - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, SYNC_PAUSED_CREDENTIALS_NOTIFICATION_ID, notificationBuilder.buildRateLimitNotification(context), ) diff --git a/build.gradle b/build.gradle index 3b38c25c0c43..2acf28e1fd16 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ buildscript { ksp_version = '1.9.20-1.0.14' gradle_plugin = '8.1.2' min_sdk = 23 - target_sdk = 33 + target_sdk = 34 compile_sdk = 34 fladle_version = '0.17.4' kotlinter_version = '3.12.0' diff --git a/common/common-utils/src/main/AndroidManifest.xml b/common/common-utils/src/main/AndroidManifest.xml index e74ca12e7141..efb7d866ea9d 100644 --- a/common/common-utils/src/main/AndroidManifest.xml +++ b/common/common-utils/src/main/AndroidManifest.xml @@ -22,5 +22,6 @@ android:targetPackage="com.duckduckgo.common.utils" android:label="Test for common" /> + \ No newline at end of file diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ContextExtensions.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ContextExtensions.kt index 871171847f9b..bf2ccd135d35 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ContextExtensions.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/extensions/ContextExtensions.kt @@ -16,9 +16,12 @@ package com.duckduckgo.common.utils.extensions +import android.content.BroadcastReceiver import android.content.Context +import android.content.IntentFilter import android.os.PowerManager import android.provider.Settings +import androidx.core.content.ContextCompat import timber.log.Timber fun Context.isPrivateDnsActive(): Boolean { @@ -46,3 +49,17 @@ fun Context.isIgnoringBatteryOptimizations(): Boolean { } ?: false }.getOrDefault(false) } + +fun Context.registerNotExportedReceiver( + receiver: BroadcastReceiver, + intentFilter: IntentFilter, +) { + ContextCompat.registerReceiver(this, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) +} + +fun Context.registerExportedReceiver( + receiver: BroadcastReceiver, + intentFilter: IntentFilter, +) { + ContextCompat.registerReceiver(this, receiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) +} diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/notification/NotificationUtils.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/notification/NotificationUtils.kt new file mode 100644 index 000000000000..1a54e5ae056c --- /dev/null +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/notification/NotificationUtils.kt @@ -0,0 +1,34 @@ +/* + * 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.common.utils.notification + +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_GRANTED +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat + +fun NotificationManagerCompat.checkPermissionAndNotify( + context: Context, + id: Int, + notification: Notification, +) { + if (ActivityCompat.checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + notify(id, notification) + } +} diff --git a/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/DefaultFileDownloadNotificationManager.kt b/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/DefaultFileDownloadNotificationManager.kt index 6d36a9a4a743..f02668c9e35a 100644 --- a/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/DefaultFileDownloadNotificationManager.kt +++ b/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/DefaultFileDownloadNotificationManager.kt @@ -28,6 +28,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.FileProvider import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.browser.api.BrowserLifecycleObserver +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.downloads.api.FileDownloadNotificationManager import com.squareup.anvil.annotations.ContributesBinding @@ -100,8 +101,8 @@ class DefaultFileDownloadNotificationManager @Inject constructor( } notificationManager.apply { - notify(downloadId.toInt(), notification) - notify(SUMMARY_ID, summary) + checkPermissionAndNotify(applicationContext, downloadId.toInt(), notification) + checkPermissionAndNotify(applicationContext, SUMMARY_ID, summary) groupNotificationsCounter.atomicUpdateAndGet { it.plus(downloadId to filename) } } } @@ -128,7 +129,7 @@ class DefaultFileDownloadNotificationManager @Inject constructor( // we don't want to post any notification while the DDG application is closing if (applicationClosing.get()) return - notificationManager.notify(downloadId.toInt(), notification) + notificationManager.checkPermissionAndNotify(applicationContext, downloadId.toInt(), notification) } @AnyThread @@ -158,7 +159,7 @@ class DefaultFileDownloadNotificationManager @Inject constructor( // we don't want to post any notification while the DDG application is closing if (applicationClosing.get()) return - notificationManager.notify(downloadId.toInt(), notification) + notificationManager.checkPermissionAndNotify(applicationContext, downloadId.toInt(), notification) } @AnyThread diff --git a/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/FileDownloadNotificationActionReceiver.kt b/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/FileDownloadNotificationActionReceiver.kt index dce2ec71137f..64206f89639e 100644 --- a/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/FileDownloadNotificationActionReceiver.kt +++ b/downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/FileDownloadNotificationActionReceiver.kt @@ -26,6 +26,7 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.downloads.api.* import com.duckduckgo.downloads.impl.pixels.DownloadsPixelName @@ -55,7 +56,7 @@ class FileDownloadNotificationActionReceiver @Inject constructor( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) Timber.v("Registering file download notification action receiver") - context.registerReceiver(this, IntentFilter(INTENT_DOWNLOADS_NOTIFICATION_ACTION)) + context.registerNotExportedReceiver(this, IntentFilter(INTENT_DOWNLOADS_NOTIFICATION_ACTION)) // When the app process is killed and restarted, this onCreate method is called and we take the opportunity // to clean up the pending downloads that were in progress and will be no longer downloading. diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationScheduler.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationScheduler.kt index 12694ef64104..72394551ea2d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationScheduler.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationScheduler.kt @@ -22,6 +22,7 @@ import android.content.pm.PackageManager import androidx.core.app.NotificationManagerCompat import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason @@ -108,7 +109,8 @@ class NetPDisabledNotificationScheduler @Inject constructor( coroutineScope.launch(dispatcherProvider.io()) { if (triggerAtMillis != 0L) { if (!netPSettingsLocalConfig.vpnNotificationAlerts().isEnabled()) return@launch - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, NETP_REMINDER_NOTIFICATION_ID, netPDisabledNotificationBuilder.buildSnoozeNotification(context, triggerAtMillis), ) @@ -122,7 +124,8 @@ class NetPDisabledNotificationScheduler @Inject constructor( coroutineScope.launch(dispatcherProvider.io()) { logcat { "Showing disabled notification for NetP" } if (!netPSettingsLocalConfig.vpnNotificationAlerts().isEnabled()) return@launch - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, NETP_REMINDER_NOTIFICATION_ID, netPDisabledNotificationBuilder.buildDisabledNotification(context), ) @@ -141,7 +144,8 @@ class NetPDisabledNotificationScheduler @Inject constructor( coroutineScope.launch(dispatcherProvider.io()) { logcat { "Showing disabled by vpn notification for NetP" } if (!netPSettingsLocalConfig.vpnNotificationAlerts().isEnabled()) return@launch - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, NETP_REMINDER_NOTIFICATION_ID, netPDisabledNotificationBuilder.buildDisabledByVpnNotification(context), ) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/timezone/NetPTimezoneMonitor.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/timezone/NetPTimezoneMonitor.kt index 1086926d1a4f..4637bb3db267 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/timezone/NetPTimezoneMonitor.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/timezone/NetPTimezoneMonitor.kt @@ -20,6 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks @@ -76,7 +77,7 @@ class NetPTimezoneMonitor @Inject constructor( IntentFilter().apply { addAction(Intent.ACTION_TIMEZONE_CHANGED) }.run { - context.registerReceiver(this@NetPTimezoneMonitor, this) + context.registerNotExportedReceiver(this@NetPTimezoneMonitor, this) } } diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/InternalFeatureReceiver.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/InternalFeatureReceiver.kt index da8e597bddb6..391112ac52ec 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/InternalFeatureReceiver.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/InternalFeatureReceiver.kt @@ -20,6 +20,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver /** * Abstract class to create generic receivers for internal features accessible through @@ -44,7 +45,7 @@ abstract class InternalFeatureReceiver( fun register() { unregister() - context.registerReceiver(this, IntentFilter(intentAction())) + context.registerNotExportedReceiver(this, IntentFilter(intentAction())) } fun unregister() { diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/snooze/VpnCallStateReceiver.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/snooze/VpnCallStateReceiver.kt index a740c1d73f98..91b7ee115567 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/snooze/VpnCallStateReceiver.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/snooze/VpnCallStateReceiver.kt @@ -27,6 +27,7 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.di.ProcessName import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.di.scopes.ReceiverScope import com.duckduckgo.mobile.android.vpn.Vpn @@ -75,7 +76,10 @@ class VpnCallStateReceiver @Inject constructor( private val _listener: PhoneStateListener = object : PhoneStateListener() { @Deprecated("Deprecated in Java") - override fun onCallStateChanged(state: Int, phoneNumber: String?) { + override fun onCallStateChanged( + state: Int, + phoneNumber: String?, + ) { appCoroutineScope.launch(dispatcherProvider.io()) { logcat { "Call state: $state" } if (state == TelephonyManager.CALL_STATE_IDLE) { @@ -128,12 +132,14 @@ class VpnCallStateReceiver @Inject constructor( registerListener() } } + ACTION_UNREGISTER_STATE_CALL_LISTENER -> { logcat { "ACTION_UNREGISTER_STATE_CALL_LISTENER" } goAsync(pendingResult) { unregisterListener() } } + else -> { logcat { "Unknown action ${intent.action}" } } @@ -155,7 +161,7 @@ class VpnCallStateReceiver @Inject constructor( private fun register() { unregister() logcat { "Registering vpn call state receiver" } - context.registerReceiver( + context.registerNotExportedReceiver( this, IntentFilter().apply { addAction(ACTION_REGISTER_STATE_CALL_LISTENER) diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/rekey/DebugRekeyReceiver.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/rekey/DebugRekeyReceiver.kt index cc24e5972347..62c411aa7e70 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/rekey/DebugRekeyReceiver.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/rekey/DebugRekeyReceiver.kt @@ -23,6 +23,7 @@ import android.content.Intent import android.content.IntentFilter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.di.ProcessName +import com.duckduckgo.common.utils.extensions.registerNotExportedReceiver import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor @@ -61,6 +62,7 @@ class DebugRekeyReceiver @Inject constructor( rekeyer.forceRekey() } } + else -> { logcat(LogPriority.WARN) { "Unknown action" } pendingResult?.finish() @@ -72,7 +74,10 @@ class DebugRekeyReceiver @Inject constructor( register() } - override fun onVpnStopped(coroutineScope: CoroutineScope, vpnStopReason: VpnStateMonitor.VpnStopReason) { + override fun onVpnStopped( + coroutineScope: CoroutineScope, + vpnStopReason: VpnStateMonitor.VpnStopReason, + ) { logcat { "Unregistering debug re-keying receiver" } unregister() } @@ -81,7 +86,7 @@ class DebugRekeyReceiver @Inject constructor( private fun register() { unregister() logcat { "Registering debug re-keying receiver" } - context.registerReceiver( + context.registerNotExportedReceiver( this, IntentFilter().apply { addAction(ACTION_FORCE_REKEY) diff --git a/network-protection/network-protection-subscription-internal/src/main/java/com/duckduckgo/networkprotection/subscription/notification/NetpAccessRevokedNotificationScheduler.kt b/network-protection/network-protection-subscription-internal/src/main/java/com/duckduckgo/networkprotection/subscription/notification/NetpAccessRevokedNotificationScheduler.kt index f6af67db886a..058c4e1f8f4e 100644 --- a/network-protection/network-protection-subscription-internal/src/main/java/com/duckduckgo/networkprotection/subscription/notification/NetpAccessRevokedNotificationScheduler.kt +++ b/network-protection/network-protection-subscription-internal/src/main/java/com/duckduckgo/networkprotection/subscription/notification/NetpAccessRevokedNotificationScheduler.kt @@ -19,6 +19,7 @@ package com.duckduckgo.networkprotection.subscription.notification import android.content.Context import androidx.core.app.NotificationManagerCompat import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason @@ -49,7 +50,8 @@ class NetpAccessRevokedNotificationScheduler @Inject constructor( override fun onVpnStartFailed(coroutineScope: CoroutineScope) { if (networkProtectionRepository.vpnAccessRevoked) { - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, NetPDisabledNotificationScheduler.NETP_REMINDER_NOTIFICATION_ID, netpAccessRevokedNotificationBuilder.buildVpnAccessRevokedNotification(context), ) diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt index 971387214dca..5223d32b71e4 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/sync/SavedSitesSyncFeatureListener.kt @@ -18,6 +18,7 @@ package com.duckduckgo.savedsites.impl.sync import android.content.Context import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.engine.FeatureSyncError import com.duckduckgo.sync.api.engine.FeatureSyncError.COLLECTION_LIMIT_REACHED @@ -65,7 +66,8 @@ class AppSavedSitesSyncFeatureListener @Inject constructor( } private fun triggerNotification() { - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, SYNC_PAUSED_SAVED_SITES_NOTIFICATION_ID, notificationBuilder.buildRateLimitNotification(context), ) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeatureToggle.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeatureToggle.kt index dcbf3e3cf170..f3c8533f69d8 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeatureToggle.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeatureToggle.kt @@ -23,6 +23,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin @@ -135,7 +136,8 @@ class SyncRemoteFeatureToggle @Inject constructor( private fun triggerNotification() { val showSync = showSync() - notificationManager.notify( + notificationManager.checkPermissionAndNotify( + context, SYNC_PAUSED_NOTIFICATION_ID, syncNotificationBuilder.buildSyncPausedNotification(context, addNavigationIntent = showSync), ) diff --git a/versions.properties b/versions.properties index 0ff7a4230e76..ce854160dfe6 100644 --- a/versions.properties +++ b/versions.properties @@ -23,7 +23,7 @@ version.androidx.localbroadcastmanager=1.1.0 version.androidx.recyclerview=1.3.2 -version.androidx.core=1.8.0 +version.androidx.core=1.12.0 version.androidx.fragment=1.5.2 From de0ad9b8025ea557ed886cb86f041c2ee19bd1f8 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Mon, 22 Jan 2024 17:01:39 +0000 Subject: [PATCH 04/26] Split apart AutofillStore, moving what's not needed as public to another interface/class (#4093) Task/Issue URL: https://app.asana.com/0/0/1206383428535278/f ### Description `AutofillStore` was designed as a single place to refer to perform CRUD operations pertaining to autofill (e.g., saving a new credentials, deleting a credential etc...). Over time it's become a large API and, given other work to move more of autofill into `autofill-impl`, most of the functions are now used only by `autofill-impl` and not wider in the codebase, so most of the functions are in the public API and don't have to be. This PR cleans that up. Some functions are called from elsewhere (namely, app module) so can't fully move `AutofillStore` as a whole to autofill-impl. Instead, need to break the API into two. ### Steps to test this PR Mostly just smoke testing needed here. - [x] install from `develop` and save a few logins - [x] install this branch and make sure they're still available and autofill still working ok (e.g., test it out https://fill.dev/form/login-simple) --- .../app/browser/BrowserTabFragment.kt | 6 +- .../api/ExistingCredentialMatchDetector.kt | 20 ++- .../autofill/api/store/AutofillStore.kt | 107 +--------------- .../impl/AutofillGlobalCapabilityChecker.kt | 4 +- .../impl/AutofillJavascriptInterface.kt | 4 +- .../impl/RealDuckAddressLoginCreator.kt | 4 +- .../impl/SecureStoreBackedAutofillStore.kt | 8 +- .../AutofillRuntimeConfigProvider.kt | 4 +- .../impl/sharedcreds/ShareableCredentials.kt | 4 +- .../impl/store/DefaultAutofillStore.kt | 29 +++++ .../impl/store/InternalAutofillStore.kt | 120 ++++++++++++++++++ ...edentialStoreInterrogatingMatchDetector.kt | 7 +- .../management/AutofillSettingsViewModel.kt | 4 +- .../ResultHandlerUseGeneratedPassword.kt | 9 +- .../AutofillSavingCredentialsViewModel.kt | 4 +- ...tHandlerPromptToDisableCredentialSaving.kt | 4 +- .../ResultHandlerSaveLoginCredentials.kt | 4 +- .../AutofillDisablingDeclineCounter.kt | 4 +- .../ResultHandlerUpdateLoginCredentials.kt | 4 +- ...lCapabilityCheckerImplGlobalFeatureTest.kt | 4 +- ...tyCheckerImplSecureStorageAvailableTest.kt | 4 +- ...CapabilityCheckerImplUserPreferenceTest.kt | 4 +- ...tofillStoredBackJavascriptInterfaceTest.kt | 4 +- .../impl/RealDuckAddressLoginCreatorTest.kt | 4 +- .../SecureStoreBackedAutofillStoreTest.kt | 12 +- .../RealAutofillRuntimeConfigProviderTest.kt | 4 +- .../impl/deviceauth}/FakeAuthenticator.kt | 5 +- ...ltHandlerEmailProtectionChooseEmailTest.kt | 9 +- .../AppleShareableCredentialsTest.kt | 4 +- .../AutofillSettingsViewModelTest.kt | 4 +- .../ResultHandlerUseGeneratedPasswordTest.kt | 19 ++- .../AutofillSavingCredentialsViewModelTest.kt | 4 +- ...dlerPromptToDisableCredentialSavingTest.kt | 4 +- .../ResultHandlerSaveLoginCredentialsTest.kt | 9 +- .../AutofillDisablingDeclineCounterTest.kt | 4 +- .../ResultHandlerCredentialSelectionTest.kt | 16 ++- ...ResultHandlerUpdateLoginCredentialsTest.kt | 24 ++-- .../AutofillInternalSettingsActivity.kt | 4 +- 38 files changed, 288 insertions(+), 204 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/DefaultAutofillStore.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth}/FakeAuthenticator.kt (93%) rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email}/ResultHandlerEmailProtectionChooseEmailTest.kt (94%) rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration}/ResultHandlerUseGeneratedPasswordTest.kt (93%) rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving}/ResultHandlerSaveLoginCredentialsTest.kt (94%) rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting}/ResultHandlerCredentialSelectionTest.kt (93%) rename {app/src/test/java/com/duckduckgo/app/browser/autofill => autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating}/ResultHandlerUpdateLoginCredentialsTest.kt (86%) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index c925c338ac2e..68c82d1c1d6f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -203,13 +203,17 @@ import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenNoParams import com.duckduckgo.autofill.api.EmailProtectionInContextSignUpScreenResult import com.duckduckgo.autofill.api.EmailProtectionUserPromptListener import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.ExactMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UrlOnlyMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMissing import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.dialog.AutofillOverlappingDialogDetector import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.emailprotection.EmailInjector -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.* import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.common.ui.DuckDuckGoFragment import com.duckduckgo.common.ui.store.BrowserAppTheme diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/ExistingCredentialMatchDetector.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/ExistingCredentialMatchDetector.kt index b174317bc351..dc5594cfea65 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/ExistingCredentialMatchDetector.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/ExistingCredentialMatchDetector.kt @@ -16,8 +16,6 @@ package com.duckduckgo.autofill.api -import com.duckduckgo.autofill.api.store.AutofillStore - /** * Used to determine if the given credential details exist in the autofill storage * @@ -25,5 +23,21 @@ import com.duckduckgo.autofill.api.store.AutofillStore * We can only show that prompt if we've first determined there is an existing partial match in need of an update. */ interface ExistingCredentialMatchDetector { - suspend fun determine(currentUrl: String, username: String?, password: String?): AutofillStore.ContainsCredentialsResult + + /** + * Determine if the given credential exists in the autofill storage. + * This isn't a binary, as there are different match types that can be returned as captured by [ContainsCredentialsResult] + */ + suspend fun determine(currentUrl: String, username: String?, password: String?): ContainsCredentialsResult + + /** + * Possible match types returned when searching for the presence of credentials + */ + sealed interface ContainsCredentialsResult { + data object ExactMatch : ContainsCredentialsResult + data object UsernameMatch : ContainsCredentialsResult + data object UrlOnlyMatch : ContainsCredentialsResult + data object UsernameMissing : ContainsCredentialsResult + data object NoMatch : ContainsCredentialsResult + } } diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/store/AutofillStore.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/store/AutofillStore.kt index 5eeac7807d49..3e8c7548be7b 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/store/AutofillStore.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/store/AutofillStore.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -16,119 +16,16 @@ package com.duckduckgo.autofill.api.store -import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import kotlinx.coroutines.flow.Flow /** - * APIs for accessing and updating saved autofill data + * Public APIs for querying credentials stored in the autofill store */ interface AutofillStore { - /** - * Global toggle for determining / setting if autofill is enabled - */ - var autofillEnabled: Boolean - - /** - * Determines if the autofill feature is available for the user - */ - val autofillAvailable: Boolean - - /** - * Used to determine if a user has ever been prompted to save a login (note: prompted to save, not necessarily saved) - * Defaults to false, and will be set to true after the user has been shown a prompt to save a login - */ - var hasEverBeenPromptedToSaveLogin: Boolean - - /** - * Whether to monitor autofill decline counts or not - * Used to determine whether we should actively detect when a user new to autofill doesn't appear to want it enabled - */ - var monitorDeclineCounts: Boolean - - /** - * A count of the number of autofill declines the user has made, persisted across all sessions. - * Used to determine whether we should prompt a user new to autofill to disable it if they don't appear to want it enabled - */ - var autofillDeclineCount: Int - /** * Find saved credentials for the given URL, returning an empty list where no matches are found * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) */ suspend fun getCredentials(rawUrl: String): List - - /** - * Find saved credential for the given id - * @param id of the saved credential - */ - suspend fun getCredentialsWithId(id: Long): LoginCredentials? - - /** - * Save the given credentials for the given URL - * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) - * @param credentials The credentials to be saved. The ID can be null. - * @return The saved credential if it saved successfully, otherwise null - */ - suspend fun saveCredentials(rawUrl: String, credentials: LoginCredentials): LoginCredentials? - - /** - * Updates the credentials saved for the given URL - * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) - * @param credentials The credentials to be updated. The ID can be null. - * @param updateType The type of update to perform, whether updating the username or password. - * @return The saved credential if it saved successfully, otherwise null - */ - suspend fun updateCredentials(rawUrl: String, credentials: LoginCredentials, updateType: CredentialUpdateType): LoginCredentials? - - /** - * Returns the full list of stored login credentials - */ - suspend fun getAllCredentials(): Flow> - - /** - * Returns a count of how many credentials are stored - */ - suspend fun getCredentialCount(): Flow - - /** - * Deletes the credential with the given ID - * @return the deleted LoginCredentials, or null if the deletion couldn't be performed - */ - suspend fun deleteCredentials(id: Long): LoginCredentials? - - /** - * Updates the given login credentials, replacing what was saved before for the credentials with the specified ID - * @param credentials The ID of the given credentials must match a saved credential for it to be updated. - * @return The saved credential if it saved successfully, otherwise null - */ - suspend fun updateCredentials(credentials: LoginCredentials): LoginCredentials? - - /** - * Used to reinsert a credential that was previously deleted - * This supports the ability to give user a brief opportunity to 'undo' a deletion - * - * This is similar to a normal save, except it will preserve the original ID and last modified time - */ - suspend fun reinsertCredentials(credentials: LoginCredentials) - - /** - * Searches the saved login credentials for a match to the given URL, username and password - * This can be used to determine if we need to prompt the user to update a saved credential - * - * @return The match type, which might indicate there was an exact match, a partial match etc... - */ - suspend fun containsCredentials(rawUrl: String, username: String?, password: String?): ContainsCredentialsResult - - /** - * Possible match types returned when searching for the presence of credentials - */ - sealed interface ContainsCredentialsResult { - object ExactMatch : ContainsCredentialsResult - object UsernameMatch : ContainsCredentialsResult - object UrlOnlyMatch : ContainsCredentialsResult - object UsernameMissing : ContainsCredentialsResult - object NoMatch : ContainsCredentialsResult - } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityChecker.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityChecker.kt index 15679acf6392..bfe4ee91b61b 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityChecker.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityChecker.kt @@ -18,8 +18,8 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -36,7 +36,7 @@ interface AutofillGlobalCapabilityChecker { class AutofillGlobalCapabilityCheckerImpl @Inject constructor( private val autofillFeature: AutofillFeature, private val internalTestUserChecker: InternalTestUserChecker, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val deviceAuthenticator: DeviceAuthenticator, private val autofill: com.duckduckgo.autofill.api.Autofill, private val dispatcherProvider: DispatcherProvider, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt index 55a082c9fdc6..8ea4811d108e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt @@ -27,7 +27,6 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator import com.duckduckgo.autofill.impl.domain.javascript.JavascriptCredentials import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker @@ -44,6 +43,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerTyp import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions @@ -99,7 +99,7 @@ interface AutofillJavascriptInterface { @ContributesBinding(AppScope::class) class AutofillStoredBackJavascriptInterface @Inject constructor( private val requestParser: AutofillRequestParser, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val shareableCredentials: ShareableCredentials, private val autofillMessagePoster: AutofillMessagePoster, private val autofillResponseWriter: AutofillResponseWriter, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt index b687e10c6e6b..ef5ea4187e8f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreator.kt @@ -21,7 +21,7 @@ import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.credential.saving.DuckAddressLoginCreator import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope @@ -33,7 +33,7 @@ import timber.log.Timber @ContributesBinding(FragmentScope::class) class RealDuckAddressLoginCreator @Inject constructor( - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, private val autofillCapabilityChecker: AutofillCapabilityChecker, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt index d3a67ae41733..de9914d82c8e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStore.kt @@ -19,13 +19,13 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType.Password import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType.Username +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.impl.securestorage.SecureStorage import com.duckduckgo.autofill.impl.securestorage.WebsiteLoginDetails import com.duckduckgo.autofill.impl.securestorage.WebsiteLoginDetailsWithCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher import com.duckduckgo.autofill.store.AutofillPrefsStore import com.duckduckgo.autofill.store.LastUpdatedTimeProvider @@ -51,7 +51,7 @@ class SecureStoreBackedAutofillStore @Inject constructor( private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), private val autofillUrlMatcher: AutofillUrlMatcher, private val syncCredentialsListener: SyncCredentialsListener, -) : AutofillStore { +) : InternalAutofillStore { override val autofillAvailable: Boolean get() = secureStorage.canAccessSecureStorage() diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt index 4c6bf96ca6d1..36446c0b0394 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/configuration/AutofillRuntimeConfigProvider.kt @@ -19,10 +19,10 @@ package com.duckduckgo.autofill.impl.configuration import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -39,7 +39,7 @@ interface AutofillRuntimeConfigProvider { @ContributesBinding(AppScope::class) class RealAutofillRuntimeConfigProvider @Inject constructor( private val emailManager: EmailManager, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val runtimeConfigurationWriter: RuntimeConfigurationWriter, private val autofillCapabilityChecker: AutofillCapabilityChecker, private val shareableCredentials: ShareableCredentials, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/sharedcreds/ShareableCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/sharedcreds/ShareableCredentials.kt index 77b8032c58d7..7cd683dd9081 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/sharedcreds/ShareableCredentials.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/sharedcreds/ShareableCredentials.kt @@ -18,8 +18,8 @@ package com.duckduckgo.autofill.impl.sharedcreds import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.SharedCredentialConfig +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher.ExtractedUrlParts import com.duckduckgo.common.utils.DispatcherProvider @@ -43,7 +43,7 @@ class AppleShareableCredentials @Inject constructor( private val jsonParser: SharedCredentialsParser, private val dispatchers: DispatcherProvider, private val shareableCredentialsUrlGenerator: ShareableCredentialsUrlGenerator, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val autofillUrlMatcher: AutofillUrlMatcher, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : ShareableCredentials { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/DefaultAutofillStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/DefaultAutofillStore.kt new file mode 100644 index 000000000000..1836474b6a8e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/DefaultAutofillStore.kt @@ -0,0 +1,29 @@ +/* + * 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.autofill.impl.store + +import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultAutofillStore @Inject constructor( + private val autofillStore: InternalAutofillStore, +) : AutofillStore by autofillStore diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt new file mode 100644 index 000000000000..305b080e57a9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/store/InternalAutofillStore.kt @@ -0,0 +1,120 @@ +/* + * 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.autofill.impl.store + +import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.store.AutofillStore +import kotlinx.coroutines.flow.Flow + +interface InternalAutofillStore : AutofillStore { + + /** + * Global toggle for determining / setting if autofill is enabled + */ + var autofillEnabled: Boolean + + /** + * Determines if the autofill feature is available for the user + */ + val autofillAvailable: Boolean + + /** + * Used to determine if a user has ever been prompted to save a login (note: prompted to save, not necessarily saved) + * Defaults to false, and will be set to true after the user has been shown a prompt to save a login + */ + var hasEverBeenPromptedToSaveLogin: Boolean + + /** + * Whether to monitor autofill decline counts or not + * Used to determine whether we should actively detect when a user new to autofill doesn't appear to want it enabled + */ + var monitorDeclineCounts: Boolean + + /** + * A count of the number of autofill declines the user has made, persisted across all sessions. + * Used to determine whether we should prompt a user new to autofill to disable it if they don't appear to want it enabled + */ + var autofillDeclineCount: Int + + /** + * Find saved credential for the given id + * @param id of the saved credential + */ + suspend fun getCredentialsWithId(id: Long): LoginCredentials? + + /** + * Save the given credentials for the given URL + * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) + * @param credentials The credentials to be saved. The ID can be null. + * @return The saved credential if it saved successfully, otherwise null + */ + suspend fun saveCredentials(rawUrl: String, credentials: LoginCredentials): LoginCredentials? + + /** + * Updates the credentials saved for the given URL + * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) + * @param credentials The credentials to be updated. The ID can be null. + * @param updateType The type of update to perform, whether updating the username or password. + * @return The saved credential if it saved successfully, otherwise null + */ + suspend fun updateCredentials( + rawUrl: String, + credentials: LoginCredentials, + updateType: CredentialUpdateExistingCredentialsDialog.CredentialUpdateType, + ): LoginCredentials? + + /** + * Returns the full list of stored login credentials + */ + suspend fun getAllCredentials(): Flow> + + /** + * Returns a count of how many credentials are stored + */ + suspend fun getCredentialCount(): Flow + + /** + * Deletes the credential with the given ID + * @return the deleted LoginCredentials, or null if the deletion couldn't be performed + */ + suspend fun deleteCredentials(id: Long): LoginCredentials? + + /** + * Updates the given login credentials, replacing what was saved before for the credentials with the specified ID + * @param credentials The ID of the given credentials must match a saved credential for it to be updated. + * @return The saved credential if it saved successfully, otherwise null + */ + suspend fun updateCredentials(credentials: LoginCredentials): LoginCredentials? + + /** + * Used to reinsert a credential that was previously deleted + * This supports the ability to give user a brief opportunity to 'undo' a deletion + * + * This is similar to a normal save, except it will preserve the original ID and last modified time + */ + suspend fun reinsertCredentials(credentials: LoginCredentials) + + /** + * Searches the saved login credentials for a match to the given URL, username and password + * This can be used to determine if we need to prompt the user to update a saved credential + * + * @return The match type, which might indicate there was an exact match, a partial match etc... + */ + suspend fun containsCredentials(rawUrl: String, username: String?, password: String?): ContainsCredentialsResult +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt index 3c5614914601..cb1610b60c54 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt @@ -17,7 +17,8 @@ package com.duckduckgo.autofill.impl.ui import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -27,12 +28,12 @@ import kotlinx.coroutines.withContext @ContributesBinding(AppScope::class) class ExistingCredentialStoreInterrogatingMatchDetector @Inject constructor( - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), ) : ExistingCredentialMatchDetector { - override suspend fun determine(currentUrl: String, username: String?, password: String?): AutofillStore.ContainsCredentialsResult { + override suspend fun determine(currentUrl: String, username: String?, password: String?): ContainsCredentialsResult { return withContext(dispatcherProvider.io()) { autofillStore.containsCredentials(currentUrl, username, password) } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index b52462778dfe..a164008d13e1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -24,7 +24,6 @@ import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_ENABLED @@ -33,6 +32,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_DISPLAYED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.MENU_ACTION_AUTOFILL_PRESSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.SETTINGS_AUTOFILL_MANAGEMENT_OPENED +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitCredentialMode import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitDisabledMode @@ -89,7 +89,7 @@ import timber.log.Timber @ContributesViewModel(ActivityScope::class) class AutofillSettingsViewModel @Inject constructor( - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val clipboardInteractor: AutofillClipboardInteractor, private val deviceAuthenticator: DeviceAuthenticator, private val pixel: Pixel, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt index dd22894f1e07..cae6c4f70bf1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPassword.kt @@ -23,10 +23,11 @@ import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding @@ -39,7 +40,7 @@ import timber.log.Timber @ContributesMultibinding(AppScope::class) class ResultHandlerUseGeneratedPassword @Inject constructor( private val dispatchers: DispatcherProvider, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor, private val existingCredentialMatchDetector: ExistingCredentialMatchDetector, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, @@ -99,14 +100,14 @@ class ResultHandlerUseGeneratedPassword @Inject constructor( } private suspend fun saveLoginIfNotAlreadySaved( - matchType: AutofillStore.ContainsCredentialsResult, + matchType: ContainsCredentialsResult, originalUrl: String, username: String?, password: String, tabId: String, ) { when (matchType) { - AutofillStore.ContainsCredentialsResult.ExactMatch -> Timber.v("Already got an exact match; nothing to do here") + ContainsCredentialsResult.ExactMatch -> Timber.v("Already got an exact match; nothing to do here") else -> { autofillStore.saveCredentials( originalUrl, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModel.kt index 70a3b84db58a..a9539d305f24 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModel.kt @@ -20,8 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_USER_SELECTED_FROM_SAVE_DIALOG +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope @@ -37,7 +37,7 @@ class AutofillSavingCredentialsViewModel @Inject constructor( ) : ViewModel() { @Inject - lateinit var autofillStore: AutofillStore + lateinit var autofillStore: InternalAutofillStore fun userPromptedToSaveCredentials() { viewModelScope.launch(dispatchers.io()) { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSaving.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSaving.kt index b935a07aa91b..de0e12856e2d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSaving.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSaving.kt @@ -25,10 +25,10 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.CredentialSavePickerDialog -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.autofill.impl.R.string import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.saving.declines.AutofillDeclineCounter import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -45,7 +45,7 @@ class ResultHandlerPromptToDisableCredentialSaving @Inject constructor( private val pixel: Pixel, private val dispatchers: DispatcherProvider, private val declineCounter: AutofillDeclineCounter, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, @com.duckduckgo.app.di.AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AutofillFragmentResultsPlugin { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt index 162ccb71cace..25441bf51617 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentials.kt @@ -28,8 +28,8 @@ import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin import com.duckduckgo.autofill.api.CredentialSavePickerDialog import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.saving.declines.AutofillDeclineCounter import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -45,7 +45,7 @@ class ResultHandlerSaveLoginCredentials @Inject constructor( private val autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor, private val dispatchers: DispatcherProvider, private val declineCounter: AutofillDeclineCounter, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val appBuildConfig: AppBuildConfig, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AutofillFragmentResultsPlugin { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounter.kt index c166e35d291a..822f145348f9 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounter.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounter.kt @@ -18,7 +18,7 @@ package com.duckduckgo.autofill.impl.ui.credential.saving.declines import androidx.annotation.VisibleForTesting import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -63,7 +63,7 @@ interface AutofillDeclineCounter { @ContributesBinding(AppScope::class) @SingleInstanceIn(AppScope::class) class AutofillDisablingDeclineCounter @Inject constructor( - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : AutofillDeclineCounter { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt index 5f7e1922469e..a1b7019fb820 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentials.kt @@ -31,8 +31,8 @@ import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Com import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.Companion.KEY_CREDENTIAL_UPDATE_TYPE import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding @@ -46,7 +46,7 @@ import timber.log.Timber class ResultHandlerUpdateLoginCredentials @Inject constructor( private val autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor, private val dispatchers: DispatcherProvider, - private val autofillStore: AutofillStore, + private val autofillStore: InternalAutofillStore, private val appBuildConfig: AppBuildConfig, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AutofillFragmentResultsPlugin { diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplGlobalFeatureTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplGlobalFeatureTest.kt index d84de301ecf4..30868aa65b54 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplGlobalFeatureTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplGlobalFeatureTest.kt @@ -18,8 +18,8 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest @@ -42,7 +42,7 @@ class AutofillGlobalCapabilityCheckerImplGlobalFeatureTest( private val autofillFeature: AutofillFeature = mock() private val internalTestUserChecker: InternalTestUserChecker = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val deviceAuthenticator: DeviceAuthenticator = mock() private val exceptionChecker: com.duckduckgo.autofill.api.Autofill = mock() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplSecureStorageAvailableTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplSecureStorageAvailableTest.kt index ba8c7881de0f..e3472e30809c 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplSecureStorageAvailableTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplSecureStorageAvailableTest.kt @@ -19,8 +19,8 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.Autofill import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -41,7 +41,7 @@ class AutofillGlobalCapabilityCheckerImplSecureStorageAvailableTest( private val autofillFeature: AutofillFeature = mock() private val internalTestUserChecker: InternalTestUserChecker = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val deviceAuthenticator: DeviceAuthenticator = mock() private val autofill: Autofill = mock() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplUserPreferenceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplUserPreferenceTest.kt index 2b9686469743..8686c6c9479c 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplUserPreferenceTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillGlobalCapabilityCheckerImplUserPreferenceTest.kt @@ -19,8 +19,8 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.Autofill import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.InternalTestUserChecker -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -41,7 +41,7 @@ class AutofillGlobalCapabilityCheckerImplUserPreferenceTest( private val autofillFeature: AutofillFeature = mock() private val internalTestUserChecker: InternalTestUserChecker = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val autofill: Autofill = mock() private val deviceAuthenticator: DeviceAuthenticator = mock() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt index ce455ce58d66..56b0988fce62 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt @@ -25,7 +25,6 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillStoredBackJavascriptInterface.UrlProvider import com.duckduckgo.autofill.impl.deduper.AutofillLoginDeduplicator import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextRecentInstallChecker @@ -41,6 +40,7 @@ import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubTy import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillTriggerType.USER_INITIATED import com.duckduckgo.autofill.impl.jsbridge.response.AutofillResponseWriter import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.autofill.impl.systemautofill.SystemAutofillServiceSuppressor import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.Actions @@ -63,7 +63,7 @@ class AutofillStoredBackJavascriptInterfaceTest { var coroutineRule = CoroutineTestRule() private val requestParser: AutofillRequestParser = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val autofillMessagePoster: AutofillMessagePoster = mock() private val autofillResponseWriter: AutofillResponseWriter = mock() private val currentUrlProvider: UrlProvider = mock() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt index fbf13a9fec95..43bb2b17593e 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/RealDuckAddressLoginCreatorTest.kt @@ -19,7 +19,7 @@ package com.duckduckgo.autofill.impl import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest @@ -32,7 +32,7 @@ class RealDuckAddressLoginCreatorTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val automaticSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt index 0247aabad419..a96a51dd9db1 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/SecureStoreBackedAutofillStoreTest.kt @@ -18,13 +18,13 @@ package com.duckduckgo.autofill.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.ExactMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UrlOnlyMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMatch +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.UsernameMissing import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.ExactMatch -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.NoMatch -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.UrlOnlyMatch -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.UsernameMatch -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.UsernameMissing import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer import com.duckduckgo.autofill.impl.securestorage.SecureStorage import com.duckduckgo.autofill.impl.securestorage.WebsiteLoginDetails diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt index 4fe583452c43..757a04770905 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/configuration/RealAutofillRuntimeConfigProviderTest.kt @@ -19,10 +19,10 @@ package com.duckduckgo.autofill.impl.configuration import com.duckduckgo.autofill.api.AutofillCapabilityChecker import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.email.incontext.availability.EmailProtectionInContextAvailabilityRules import com.duckduckgo.autofill.impl.jsbridge.response.AvailableInputTypeCredentials import com.duckduckgo.autofill.impl.sharedcreds.ShareableCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import kotlinx.coroutines.test.runTest import org.junit.Before @@ -39,7 +39,7 @@ class RealAutofillRuntimeConfigProviderTest { private lateinit var testee: RealAutofillRuntimeConfigProvider private val emailManager: EmailManager = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val runtimeConfigurationWriter: RuntimeConfigurationWriter = mock() private val shareableCredentials: ShareableCredentials = mock() private val autofillCapabilityChecker: AutofillCapabilityChecker = mock() diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/FakeAuthenticator.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/FakeAuthenticator.kt similarity index 93% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/FakeAuthenticator.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/FakeAuthenticator.kt index 04081799f4ad..d8a2c942951a 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/FakeAuthenticator.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/deviceauth/FakeAuthenticator.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 DuckDuckGo + * 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. @@ -14,11 +14,10 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.deviceauth import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator abstract class FakeAuthenticator : DeviceAuthenticator { diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerEmailProtectionChooseEmailTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt similarity index 94% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerEmailProtectionChooseEmailTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt index df0b02d11a94..37dff6d86a6e 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerEmailProtectionChooseEmailTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/email/ResultHandlerEmailProtectionChooseEmailTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.email import android.os.Bundle import androidx.fragment.app.Fragment @@ -26,9 +26,10 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LAST_USED_DAY import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog -import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.* +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.DoNotUseEmailProtection +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePersonalEmailAddress +import com.duckduckgo.autofill.api.EmailProtectionChooseEmailDialog.UseEmailResultType.UsePrivateAliasAddress import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.impl.email.ResultHandlerEmailProtectionChooseEmail import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_TOOLTIP_DISMISSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ADDRESS import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.EMAIL_USE_ALIAS diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/sharedcreds/AppleShareableCredentialsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/sharedcreds/AppleShareableCredentialsTest.kt index ad51cc264395..6be2f830cd1a 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/sharedcreds/AppleShareableCredentialsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/sharedcreds/AppleShareableCredentialsTest.kt @@ -1,9 +1,9 @@ package com.duckduckgo.autofill.impl.sharedcreds import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer import com.duckduckgo.autofill.impl.sharedcreds.SharedCredentialsParser.SharedCredentialConfig +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher.ExtractedUrlParts import com.duckduckgo.common.test.CoroutineTestRule @@ -24,7 +24,7 @@ class AppleShareableCredentialsTest { private val jsonParser: SharedCredentialsParser = mock() private val shareableCredentialsUrlGenerator: ShareableCredentialsUrlGenerator = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val autofillUrlMatcher = AutofillDomainNameUrlMatcher(TestUrlUnicodeNormalizer()) private val testee = AppleShareableCredentials( diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index c2c94ba2b292..7b49f8cfc552 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -22,7 +22,6 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_DISABLED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_ENABLE_AUTOFILL_TOGGLE_MANUALLY_ENABLED @@ -31,6 +30,7 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_DISPLAYED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.MENU_ACTION_AUTOFILL_PRESSED import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.SETTINGS_AUTOFILL_MANAGEMENT_OPENED +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitCredentialMode @@ -73,7 +73,7 @@ class AutofillSettingsViewModelTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private val mockStore: AutofillStore = mock() + private val mockStore: InternalAutofillStore = mock() private val emailManager: EmailManager = mock() private val duckAddressStatusRepository: DuckAddressStatusRepository = mock() private val clipboardInteractor: AutofillClipboardInteractor = mock() diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUseGeneratedPasswordTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt similarity index 93% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUseGeneratedPasswordTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt index c48e15c1776b..e231d6d17b1f 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUseGeneratedPasswordTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/passwordgeneration/ResultHandlerUseGeneratedPasswordTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.ui.credential.passwordgeneration import android.os.Bundle import androidx.fragment.app.Fragment @@ -22,15 +22,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_ACCEPTED import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_PASSWORD import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_URL import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog.Companion.KEY_USERNAME import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonitor -import com.duckduckgo.autofill.api.store.AutofillStore -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.NoMatch -import com.duckduckgo.autofill.impl.ui.credential.passwordgeneration.ResultHandlerUseGeneratedPassword +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Before @@ -46,7 +45,7 @@ class ResultHandlerUseGeneratedPasswordTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val autoSavedLoginsMonitor: AutomaticSavedLoginsMonitor = mock() private val existingCredentialMatchDetector: ExistingCredentialMatchDetector = mock() private val callback: AutofillEventListener = mock() @@ -61,7 +60,13 @@ class ResultHandlerUseGeneratedPasswordTest { @Before fun setup() = runTest { - whenever(existingCredentialMatchDetector.determine(anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(NoMatch) + whenever( + existingCredentialMatchDetector.determine( + anyOrNull(), + anyOrNull(), + anyOrNull(), + ), + ).thenReturn(NoMatch) } @Test diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModelTest.kt index 3973c80b34bf..ed60ef6f455b 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/AutofillSavingCredentialsViewModelTest.kt @@ -17,7 +17,7 @@ package com.duckduckgo.autofill.impl.ui.credential.saving import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest @@ -32,7 +32,7 @@ class AutofillSavingCredentialsViewModelTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private val mockStore: AutofillStore = mock() + private val mockStore: InternalAutofillStore = mock() private val pixel: Pixel = mock() private val neverSavedSiteRepository: NeverSavedSiteRepository = mock() private val testee = AutofillSavingCredentialsViewModel( diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSavingTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSavingTest.kt index 881e0d2c145e..cab176a78f7c 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSavingTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerPromptToDisableCredentialSavingTest.kt @@ -6,8 +6,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autofill.api.AutofillEventListener -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.saving.declines.AutofillDeclineCounter import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.DispatcherProvider @@ -28,7 +28,7 @@ class ResultHandlerPromptToDisableCredentialSavingTest { private val pixel: Pixel = mock() private val dispatchers: DispatcherProvider = coroutineTestRule.testDispatcherProvider private val declineCounter: AutofillDeclineCounter = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val appCoroutineScope: CoroutineScope = coroutineTestRule.testScope private val context = getInstrumentation().targetContext private val callback: AutofillEventListener = mock() diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerSaveLoginCredentialsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt similarity index 94% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerSaveLoginCredentialsTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt index eb7e2f7f27bb..ac353e319d1b 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerSaveLoginCredentialsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/ResultHandlerSaveLoginCredentialsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.ui.credential.saving import android.os.Bundle import androidx.fragment.app.Fragment @@ -24,9 +24,8 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.CredentialSavePickerDialog import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor -import com.duckduckgo.autofill.impl.ui.credential.saving.ResultHandlerSaveLoginCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.ui.credential.saving.declines.AutofillDeclineCounter import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest @@ -45,7 +44,7 @@ class ResultHandlerSaveLoginCredentialsTest { private val autofillFireproofDialogSuppressor: AutofillFireproofDialogSuppressor = mock() private val declineCounter: AutofillDeclineCounter = mock() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val appBuildConfig: AppBuildConfig = mock() private val testee = ResultHandlerSaveLoginCredentials( diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounterTest.kt index bb234a500666..39679d8d4652 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounterTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/saving/declines/AutofillDisablingDeclineCounterTest.kt @@ -16,7 +16,7 @@ package com.duckduckgo.autofill.impl.ui.credential.saving.declines -import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -39,7 +39,7 @@ class AutofillDisablingDeclineCounterTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private lateinit var testee: AutofillDisablingDeclineCounter @Before diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerCredentialSelectionTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt similarity index 93% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerCredentialSelectionTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt index e7aed34ef210..d3622ec1d555 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerCredentialSelectionTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/selecting/ResultHandlerCredentialSelectionTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.ui.credential.selecting import android.os.Bundle import androidx.fragment.app.Fragment @@ -25,9 +25,9 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.api.AutofillEventListener import com.duckduckgo.autofill.api.CredentialAutofillPickerDialog import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector +import com.duckduckgo.autofill.api.ExistingCredentialMatchDetector.ContainsCredentialsResult.NoMatch import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult.NoMatch -import com.duckduckgo.autofill.impl.ui.credential.selecting.ResultHandlerCredentialSelection +import com.duckduckgo.autofill.impl.deviceauth.FakeAuthenticator import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Before @@ -51,7 +51,13 @@ class ResultHandlerCredentialSelectionTest { @Before fun setup() = runTest { - whenever(existingCredentialMatchDetector.determine(any(), any(), any())).thenReturn(NoMatch) + whenever( + existingCredentialMatchDetector.determine( + any(), + any(), + any(), + ), + ).thenReturn(NoMatch) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUpdateLoginCredentialsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt similarity index 86% rename from app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUpdateLoginCredentialsTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt index 4f6c1b498e49..8d9b3c36c206 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/autofill/ResultHandlerUpdateLoginCredentialsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/updating/ResultHandlerUpdateLoginCredentialsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 DuckDuckGo + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.app.browser.autofill +package com.duckduckgo.autofill.impl.ui.credential.updating import android.os.Bundle import androidx.fragment.app.Fragment @@ -26,9 +26,8 @@ import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType import com.duckduckgo.autofill.api.CredentialUpdateExistingCredentialsDialog.CredentialUpdateType.Password import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor -import com.duckduckgo.autofill.impl.ui.credential.updating.ResultHandlerUpdateLoginCredentials +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -43,7 +42,7 @@ class ResultHandlerUpdateLoginCredentialsTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val autofillStore: AutofillStore = mock() + private val autofillStore: InternalAutofillStore = mock() private val autofillDialogSuppressor: AutofillFireproofDialogSuppressor = mock() private val callback: AutofillEventListener = mock() private val appBuildConfig: AppBuildConfig = mock() @@ -58,14 +57,19 @@ class ResultHandlerUpdateLoginCredentialsTest { @Test fun whenUpdateBundleMissingUrlThenNoAttemptToUpdateMade() = runTest { - val bundle = bundleForUpdateDialog(url = null, credentials = someLoginCredentials(), Password) + val bundle = bundleForUpdateDialog( + url = null, + credentials = someLoginCredentials(), + Password, + ) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) verifyUpdateNeverCalled() } @Test fun whenUpdateBundleMissingCredentialsThenNoAttemptToSaveMade() = runTest { - val bundle = bundleForUpdateDialog(url = "example.com", credentials = null, Password) + val bundle = + bundleForUpdateDialog(url = "example.com", credentials = null, Password) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) verifyUpdateNeverCalled() } @@ -75,7 +79,11 @@ class ResultHandlerUpdateLoginCredentialsTest { val loginCredentials = LoginCredentials(domain = "example.com", username = "foo", password = "bar") val bundle = bundleForUpdateDialog("example.com", loginCredentials, Password) testee.processResult(bundle, context, "tab-id-123", Fragment(), callback) - verify(autofillStore).updateCredentials(eq("example.com"), eq(loginCredentials), eq(Password)) + verify(autofillStore).updateCredentials( + eq("example.com"), + eq(loginCredentials), + eq(Password), + ) verifySaveNeverCalled() } diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 4cd034d4d8f1..3122fd9c231a 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -29,9 +29,9 @@ import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreenNoParams import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager -import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore +import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository import com.duckduckgo.autofill.internal.databinding.ActivityAutofillInternalSettingsBinding import com.duckduckgo.browser.api.UserBrowserProperties @@ -67,7 +67,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { lateinit var dispatchers: DispatcherProvider @Inject - lateinit var autofillStore: AutofillStore + lateinit var autofillStore: InternalAutofillStore private val dateFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.MEDIUM) From 2ea8e2209602772cf6e0b01eac6d2858c3301eb2 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 23 Jan 2024 10:25:35 +0000 Subject: [PATCH 05/26] Set metadata when webview is created (#4105) Task/Issue URL: https://app.asana.com/0/488551667048375/1206405649234091/f ### Description Set metadata only when webview is created ### Steps to test this PR See task --- .../duckduckgo/app/browser/BrowserWebViewClientTest.kt | 10 ---------- .../com/duckduckgo/app/browser/BrowserTabFragment.kt | 1 + .../com/duckduckgo/app/browser/BrowserWebViewClient.kt | 3 --- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 16663c974d2f..755ca692e3aa 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -64,7 +64,6 @@ import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.privacy.config.api.AmpLinks -import com.duckduckgo.user.agent.api.UserAgentProvider import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue @@ -118,7 +117,6 @@ class BrowserWebViewClientTest { private val currentTimeProvider: CurrentTimeProvider = mock() private val deviceInfo: DeviceInfo = mock() private val pageLoadedHandler: PageLoadedHandler = mock() - private val userAgentProvider: UserAgentProvider = mock() @UiThreadTest @Before @@ -147,7 +145,6 @@ class BrowserWebViewClientTest { jsPlugins, currentTimeProvider, pageLoadedHandler, - userAgentProvider, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -171,13 +168,6 @@ class BrowserWebViewClientTest { verify(listener).navigationStateChanged(any()) } - @UiThreadTest - @Test - fun whenOnPageStartedCalledThenSetHintHeaderCalled() { - testee.onPageStarted(webView, EXAMPLE_URL, null) - verify(userAgentProvider).setHintHeader(any()) - } - @UiThreadTest @Test fun whenOnPageStartedCalledWithSameUrlAsPreviousThenListenerNotifiedOfRefresh() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 68c82d1c1d6f..c48768b9d8c0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -2137,6 +2137,7 @@ class BrowserTabFragment : it.webChromeClient = webChromeClient it.settings.apply { + userAgentProvider.setHintHeader(this) userAgentString = userAgentProvider.userAgent() javaScriptEnabled = true domStorageEnabled = true diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index a13189e17e3a..4316a6363b05 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -62,7 +62,6 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.cookies.api.CookieManagerProvider import com.duckduckgo.privacy.config.api.AmpLinks -import com.duckduckgo.user.agent.api.UserAgentProvider import java.net.URI import javax.inject.Inject import kotlinx.coroutines.* @@ -93,7 +92,6 @@ class BrowserWebViewClient @Inject constructor( private val jsPlugins: PluginPoint, private val currentTimeProvider: CurrentTimeProvider, private val shouldSendPageLoadedPixel: PageLoadedHandler, - private val userAgentProvider: UserAgentProvider, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -284,7 +282,6 @@ class BrowserWebViewClient @Inject constructor( if (it != "about:blank" && start == null) { start = currentTimeProvider.getTimeInMillis() } - userAgentProvider.setHintHeader(webView.settings) autoconsent.injectAutoconsent(webView, url) adClickManager.detectAdDomain(url) requestInterceptor.onPageStarted(url) From f9f13f68d397d1f199f31458b2fb1ba51d842eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Tue, 23 Jan 2024 15:10:18 +0100 Subject: [PATCH 06/26] Sync: Timestamp and Orphans as daily stats (#4098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/0/1206402944085410/f ### Description Add timestamp and orphans conflict to daily pixel ### Steps to test this PR _Timestamp conflict_ - [ ] Enable sync and force a sync operation that returns timestamp conflict - [ ] You can do this by modifying a Persister and make it return the flag - [ ] Change the SyncStatsRepository to return today’s data - [ ] Delete the sync pixel store, so that the pixel is sent - [ ] Trigger sync - [ ] Verify daily pixel contains timestamp parameter _Orphans present_ - [ ] Enable sync and force a sync operation that returns orphan - [ ] You can do this by modifying a Persister and make it return the flag - [ ] Change the SyncStatsRepository to return today’s data - [ ] Delete the sync pixel store, so that the pixel is sent - [ ] Trigger sync - [ ] Verify daily pixel contains orphans parameter --------- Co-authored-by: Cristian Monforte --- .../sync/impl/engine/RealSyncEngine.kt | 5 +- .../impl/error/SyncOperationErrorRecorder.kt | 5 ++ .../error/SyncOperationErrorRepository.kt | 4 ++ .../duckduckgo/sync/impl/pixels/SyncPixels.kt | 2 + .../sync/impl/engine/SyncEngineTest.kt | 27 +++++++++- .../error/SyncOperationErrorRepositoryTest.kt | 54 +++++++++++++++++++ .../sync/store/model/SyncDatabaseModels.kt | 2 + 7 files changed, 97 insertions(+), 2 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt index 34943d722552..b3211f4d3b7e 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt @@ -45,6 +45,8 @@ import com.duckduckgo.sync.store.model.SyncAttemptState.IN_PROGRESS import com.duckduckgo.sync.store.model.SyncAttemptState.SUCCESS import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_PERSISTER_ERROR import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_PROVIDER_ERROR +import com.duckduckgo.sync.store.model.SyncOperationErrorType.ORPHANS_PRESENT +import com.duckduckgo.sync.store.model.SyncOperationErrorType.TIMESTAMP_CONFLICT import com.squareup.anvil.annotations.ContributesBinding import java.time.Duration import java.time.OffsetDateTime @@ -236,10 +238,11 @@ class RealSyncEngine @Inject constructor( is SyncMergeResult.Success -> { if (result.orphans) { Timber.d("Sync - Orphans present in this sync operation for feature ${remoteChanges.type.field}") + syncOperationErrorRecorder.record(remoteChanges.type.field, ORPHANS_PRESENT) } if (result.timestampConflict) { Timber.d("Sync - Timestamp conflict present in this sync operation for feature ${remoteChanges.type.field}") - syncPixels.fireTimestampConflictPixel(remoteChanges.type.field) + syncOperationErrorRecorder.record(remoteChanges.type.field, TIMESTAMP_CONFLICT) } } is SyncMergeResult.Error -> { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRecorder.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRecorder.kt index e6a24de83a01..d854d3b57771 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRecorder.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRecorder.kt @@ -19,6 +19,7 @@ package com.duckduckgo.sync.impl.error import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.store.model.SyncOperationErrorType +import com.duckduckgo.sync.store.model.SyncOperationErrorType.TIMESTAMP_CONFLICT import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import timber.log.Timber @@ -52,6 +53,10 @@ class RealSyncOperationErrorRecorder @Inject constructor( errorType: SyncOperationErrorType, ) { Timber.d("Sync-Error: Recording Operation Error $errorType for $feature") + if (errorType == TIMESTAMP_CONFLICT) { + syncPixels.fireTimestampConflictPixel(feature) + } + repository.addError(feature, errorType) } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepository.kt index fd7ae7b98c24..86600180221e 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepository.kt @@ -26,6 +26,8 @@ import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_DECRYPT import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_ENCRYPT import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_PERSISTER_ERROR import com.duckduckgo.sync.store.model.SyncOperationErrorType.DATA_PROVIDER_ERROR +import com.duckduckgo.sync.store.model.SyncOperationErrorType.ORPHANS_PRESENT +import com.duckduckgo.sync.store.model.SyncOperationErrorType.TIMESTAMP_CONFLICT import java.util.Locale import javax.inject.Inject @@ -81,6 +83,8 @@ class RealSyncOperationErrorRepository @Inject constructor(private val dao: Sync DATA_ENCRYPT -> SyncPixelParameters.DATA_ENCRYPT_ERROR DATA_PROVIDER_ERROR -> SyncPixelParameters.DATA_PROVIDER_ERROR_PARAM DATA_PERSISTER_ERROR -> SyncPixelParameters.DATA_PERSISTER_ERROR_PARAM + TIMESTAMP_CONFLICT -> SyncPixelParameters.TIMESTAMP_CONFLICT + ORPHANS_PRESENT -> SyncPixelParameters.ORPHANS_PRESENT } val errorName = String.format(Locale.US, errorType, it.feature) SyncOperationErrorPixelData(errorName, it.count.toString()) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt index 10d2b2021709..95e0ebb711fa 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt @@ -163,6 +163,8 @@ object SyncPixelParameters { const val DATA_DECRYPT_ERROR = "decrypt_error_count" const val DATA_PERSISTER_ERROR_PARAM = "%s_persister_error_count" const val DATA_PROVIDER_ERROR_PARAM = "%s_provider_error_count" + const val TIMESTAMP_CONFLICT = "%s_local_timestamp_resolution_triggered" + const val ORPHANS_PRESENT = "%s_orphans_present" const val ERROR_CODE = "code" const val ERROR_REASON = "reason" } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt index 62b349b42d7d..b5a3304febe8 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt @@ -40,6 +40,8 @@ import com.duckduckgo.sync.store.model.SyncAttempt import com.duckduckgo.sync.store.model.SyncAttemptState.FAIL import com.duckduckgo.sync.store.model.SyncAttemptState.IN_PROGRESS import com.duckduckgo.sync.store.model.SyncAttemptState.SUCCESS +import com.duckduckgo.sync.store.model.SyncOperationErrorType.ORPHANS_PRESENT +import com.duckduckgo.sync.store.model.SyncOperationErrorType.TIMESTAMP_CONFLICT import org.junit.Before import org.junit.Ignore import org.junit.Test @@ -495,7 +497,20 @@ internal class SyncEngineTest { verify(syncApiClient).patch(any()) verify(syncPixels).fireDailyPixel() - verify(syncPixels).fireTimestampConflictPixel(any()) + verify(syncOperationErrorRecorder).record(BOOKMARKS.field, TIMESTAMP_CONFLICT) + verify(syncStateRepository).updateSyncState(SUCCESS) + } + + @Test + fun whenSyncTriggeredWithChangesAndPatchRemoteSucceedsWithOrphansThenStateIsUpdatedAndPixelIsFired() { + givenLocalChangesWithOrphansPresent() + givenPatchSuccess() + + syncEngine.triggerSync(APP_OPEN) + + verify(syncApiClient).patch(any()) + verify(syncPixels).fireDailyPixel() + verify(syncOperationErrorRecorder).record(BOOKMARKS.field, ORPHANS_PRESENT) verify(syncStateRepository).updateSyncState(SUCCESS) } @@ -526,6 +541,16 @@ internal class SyncEngineTest { .thenReturn(listOf(FakeSyncableDataProvider(fakeChanges = SyncChangesRequest.empty()))) } + private fun givenLocalChangesWithOrphansPresent() { + val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "data_sync_sent_bookmarks.json") + val localChanges = SyncChangesRequest(BOOKMARKS, updatesJSON, ModifiedSince.Timestamp("2021-01-01T00:00:00.000Z")) + val fakeProviderPlugin = FakeSyncableDataProvider(fakeChanges = localChanges) + whenever(persisterPlugins.getPlugins()).thenReturn(listOf(FakeSyncableDataPersister(orphans = true))) + .thenReturn(listOf(FakeSyncableDataPersister(orphans = true))) + whenever(providerPlugins.getPlugins()).thenReturn(listOf(fakeProviderPlugin)) + .thenReturn(listOf(FakeSyncableDataProvider(fakeChanges = SyncChangesRequest.empty()))) + } + private fun givenFirstSyncLocalChanges() { val updatesJSON = FileUtilities.loadText(javaClass.classLoader!!, "data_sync_sent_bookmarks.json") val firstSyncLocalChanges = SyncChangesRequest(BOOKMARKS, updatesJSON, FirstSync) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepositoryTest.kt index be4944e53a50..7fac160f7f1a 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/error/SyncOperationErrorRepositoryTest.kt @@ -24,6 +24,9 @@ import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter import com.duckduckgo.sync.api.engine.SyncableType import com.duckduckgo.sync.impl.pixels.SyncPixelParameters import com.duckduckgo.sync.impl.pixels.SyncPixelParameters.DATA_PERSISTER_ERROR_PARAM +import com.duckduckgo.sync.impl.pixels.SyncPixelParameters.DATA_PROVIDER_ERROR_PARAM +import com.duckduckgo.sync.impl.pixels.SyncPixelParameters.ORPHANS_PRESENT +import com.duckduckgo.sync.impl.pixels.SyncPixelParameters.TIMESTAMP_CONFLICT import com.duckduckgo.sync.store.SyncDatabase import com.duckduckgo.sync.store.model.GENERIC_FEATURE import com.duckduckgo.sync.store.model.SyncOperationErrorType @@ -151,4 +154,55 @@ class SyncOperationErrorRepositoryTest { Assert.assertTrue(error.name == expectedErrorName) Assert.assertTrue(error.count == "1") } + + @Test + fun whenProviderErrorsStoredThenGettingErrorsReturnsData() { + val feature = SyncableType.BOOKMARKS + val errorType = SyncOperationErrorType.DATA_PROVIDER_ERROR + val today = DatabaseDateFormatter.getUtcIsoLocalDate() + + testee.addError(feature.field, errorType) + + val errors = testee.getErrorsByDate(today) + Assert.assertTrue(errors.isNotEmpty()) + + val error = errors.first() + val expectedErrorName = String.format(Locale.US, DATA_PROVIDER_ERROR_PARAM, feature.field) + Assert.assertTrue(error.name == expectedErrorName) + Assert.assertTrue(error.count == "1") + } + + @Test + fun whenTimestampErrorsStoredThenGettingErrorsReturnsData() { + val feature = SyncableType.BOOKMARKS + val errorType = SyncOperationErrorType.TIMESTAMP_CONFLICT + val today = DatabaseDateFormatter.getUtcIsoLocalDate() + + testee.addError(feature.field, errorType) + + val errors = testee.getErrorsByDate(today) + Assert.assertTrue(errors.isNotEmpty()) + + val error = errors.first() + val expectedErrorName = String.format(Locale.US, TIMESTAMP_CONFLICT, feature.field) + Assert.assertTrue(error.name == expectedErrorName) + Assert.assertTrue(error.count == "1") + } + + @Test + fun whenOrphansErrorsStoredThenGettingErrorsReturnsData() { + val feature = SyncableType.BOOKMARKS + val errorType = SyncOperationErrorType.ORPHANS_PRESENT + val today = DatabaseDateFormatter.getUtcIsoLocalDate() + + testee.addError(feature.field, errorType) + + val errors = testee.getErrorsByDate(today) + Assert.assertTrue(errors.isNotEmpty()) + + val error = errors.first() + val expectedErrorName = String.format(Locale.US, ORPHANS_PRESENT, feature.field) + Assert.assertTrue(error.name == expectedErrorName) + Assert.assertTrue(error.count == "1") + } } diff --git a/sync/sync-store/src/main/java/com/duckduckgo/sync/store/model/SyncDatabaseModels.kt b/sync/sync-store/src/main/java/com/duckduckgo/sync/store/model/SyncDatabaseModels.kt index 3db60c52df63..88400c8c8cd7 100644 --- a/sync/sync-store/src/main/java/com/duckduckgo/sync/store/model/SyncDatabaseModels.kt +++ b/sync/sync-store/src/main/java/com/duckduckgo/sync/store/model/SyncDatabaseModels.kt @@ -78,4 +78,6 @@ enum class SyncOperationErrorType { DATA_PERSISTER_ERROR, DATA_ENCRYPT, DATA_DECRYPT, + TIMESTAMP_CONFLICT, + ORPHANS_PRESENT, } From ab0d1ca687457bf9541ba05b0ca738a54a1e77a0 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Wed, 24 Jan 2024 10:53:49 +0000 Subject: [PATCH 07/26] Handle UndeliverableException in the :vpn process (#4109) Task/Issue URL: https://app.asana.com/0/488551667048375/1206413338208880/f ### Description Provided the same handling to `UndeliverableException` in the `vpn` process as well ### Steps to test this PR NA, just code review --- .../com/duckduckgo/app/global/DuckDuckGoApplication.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 2945556a27ff..eaa4abc63da5 100644 --- a/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt +++ b/app/src/main/java/com/duckduckgo/app/global/DuckDuckGoApplication.kt @@ -75,7 +75,7 @@ open class DuckDuckGoApplication : HasDaggerInjector, MultiProcessApplication() configureDependencyInjection() setupActivityLifecycleCallbacks() - configureUncaughtExceptionHandlerBrowser() + configureUncaughtExceptionHandler() // Deprecated, we need to move all these into AppLifecycleEventObserver ProcessLifecycleOwner.get().lifecycle.apply { @@ -95,7 +95,7 @@ open class DuckDuckGoApplication : HasDaggerInjector, MultiProcessApplication() configureLogging() Timber.d("Init for secondary process $shortProcessName with pid=${android.os.Process.myPid()}") configureDependencyInjection() - configureUncaughtExceptionHandlerVpn() + configureUncaughtExceptionHandler() // ProcessLifecycleOwner doesn't know about secondary processes, so the callbacks are our own callbacks and limited to onCreate which // is good enough. @@ -112,7 +112,7 @@ open class DuckDuckGoApplication : HasDaggerInjector, MultiProcessApplication() activityLifecycleCallbacks.getPlugins().forEach { registerActivityLifecycleCallbacks(it) } } - private fun configureUncaughtExceptionHandlerBrowser() { + private fun configureUncaughtExceptionHandler() { Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler) RxJavaPlugins.setErrorHandler { throwable -> if (throwable is UndeliverableException) { @@ -123,10 +123,6 @@ open class DuckDuckGoApplication : HasDaggerInjector, MultiProcessApplication() } } - private fun configureUncaughtExceptionHandlerVpn() { - Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler) - } - private fun configureLogging() { if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) } From 0d8436716a91297e76b87a83af7322b5364571d3 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 24 Jan 2024 12:09:59 +0100 Subject: [PATCH 08/26] use large android runner for ci.yml (#4107) Task/Issue URL: https://app.asana.com/0/488551667048375/1206419665418067/f ### Description Uses large runner to run ci jobs ### Steps to test this PR N/A ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e31a8b25db5..4d2417e94b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: unit_tests: name: Unit tests - runs-on: ubuntu-20.04 #https://github.com/actions/runner-images/issues/6709 + runs-on: android-large-runner steps: - name: Checkout repository @@ -73,7 +73,7 @@ jobs: lint: name: Lint - runs-on: ubuntu-20.04 #https://github.com/actions/runner-images/issues/6709 + runs-on: android-large-runner steps: - name: Checkout repository @@ -109,7 +109,7 @@ jobs: path: lint-report.zip android_tests: - runs-on: ubuntu-20.04 #https://github.com/actions/runner-images/issues/6709 + runs-on: android-large-runner name: Android CI tests steps: From 3748685ef7772bc3b7d691d2f7d3ac82fe0f9bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Wed, 24 Jan 2024 12:21:29 +0100 Subject: [PATCH 09/26] GHA: Prevent duplicate tasks (#4097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1174433894299346/1205930380564097 ### Description This PR updates the GHA action to use the latest version 1.1 for creating tasks This solves the issue when creating a task it will check for a duplication, and if the task doesn’t exist it will create it, but it will not create the task if it already exists. --- .github/workflows/action-issue-opened.yaml | 2 +- .github/workflows/action-pr-approved.yaml | 2 +- .github/workflows/action-pr-merged.yaml | 2 +- .github/workflows/action-pr-opened.yaml | 2 +- .github/workflows/sync-critical-path.yml | 4 ++-- .github/workflows/sync-end-to-end.yml | 4 ++-- .github/workflows/update-content-scope.yml | 14 +++++++------- .github/workflows/update-ref-tests.yml | 12 ++++++------ 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/action-issue-opened.yaml b/.github/workflows/action-issue-opened.yaml index 67629d9ed3c2..ff2d9e3e9df3 100644 --- a/.github/workflows/action-issue-opened.yaml +++ b/.github/workflows/action-issue-opened.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Create Asana task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: '414730916066338' diff --git a/.github/workflows/action-pr-approved.yaml b/.github/workflows/action-pr-approved.yaml index 25a660abb54e..d9289e66cad8 100644 --- a/.github/workflows/action-pr-approved.yaml +++ b/.github/workflows/action-pr-approved.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Update Asana task -> PR approved - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} trigger-phrase: "Task/Issue URL:" diff --git a/.github/workflows/action-pr-merged.yaml b/.github/workflows/action-pr-merged.yaml index f5846727d65d..3c34ee782873 100644 --- a/.github/workflows/action-pr-merged.yaml +++ b/.github/workflows/action-pr-merged.yaml @@ -8,7 +8,7 @@ jobs: add-pr-merged-comment: runs-on: ubuntu-latest steps: - - uses: duckduckgo/native-github-asana-sync@v1.0 + - uses: duckduckgo/native-github-asana-sync@v1.1 if: github.event.pull_request.merged with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} diff --git a/.github/workflows/action-pr-opened.yaml b/.github/workflows/action-pr-opened.yaml index fe58a02b2e20..6f381aa4d857 100644 --- a/.github/workflows/action-pr-opened.yaml +++ b/.github/workflows/action-pr-opened.yaml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Add comment in Asana task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} trigger-phrase: "Task/Issue URL:" diff --git a/.github/workflows/sync-critical-path.yml b/.github/workflows/sync-critical-path.yml index dd918b90720b..6c2a6e2ae116 100644 --- a/.github/workflows/sync-critical-path.yml +++ b/.github/workflows/sync-critical-path.yml @@ -75,7 +75,7 @@ jobs: - name: Create Asana task when workflow failed if: ${{ failure() }} id: create-failure-task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} @@ -86,7 +86,7 @@ jobs: - name: Add Asana task to Browser Sync & Backup project if: ${{ failure() }} - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_BROWSER_SYNC_BACKUP_PROJECT_ID }} diff --git a/.github/workflows/sync-end-to-end.yml b/.github/workflows/sync-end-to-end.yml index 6451f96d79be..0b014ad43e65 100644 --- a/.github/workflows/sync-end-to-end.yml +++ b/.github/workflows/sync-end-to-end.yml @@ -66,7 +66,7 @@ jobs: - name: Create Asana task when workflow failed if: ${{ failure() }} id: create-failure-task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} @@ -77,7 +77,7 @@ jobs: - name: Add Asana task to Browser Sync & Backup project if: ${{ failure() }} - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_BROWSER_SYNC_BACKUP_PROJECT_ID }} diff --git a/.github/workflows/update-content-scope.yml b/.github/workflows/update-content-scope.yml index 5d1f92577063..556dd186d163 100644 --- a/.github/workflows/update-content-scope.yml +++ b/.github/workflows/update-content-scope.yml @@ -77,7 +77,7 @@ jobs: - name: Create Asana task in Android App project if: ${{ steps.update-check.outcome == 'failure' }} id: create-task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} @@ -87,13 +87,13 @@ jobs: Content scope scripts have been updated and a PR created. If tests failed check out https://app.asana.com/0/1202561462274611/1203986899650836/f for further information on what to do next. - + d See ${{ steps.create-pr.outputs.pull-request-url }} action: 'create-asana-task' - name: Add Asana task to Release Board project - if: ${{ steps.update-check.outcome == 'failure' }} - uses: duckduckgo/native-github-asana-sync@v1.0 + if: ${{ steps.create-task.outputs.duplicate == 'false' }} + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_RELEASE_BOARD_PROJECT_ID }} @@ -102,8 +102,8 @@ jobs: action: 'add-task-asana-project' - name: Update PR description with Asana task - if: ${{ steps.update-check.outcome == 'failure' }} - uses: duckduckgo/native-github-asana-sync@v1.0 + if: ${{ steps.create-task.outputs.duplicate == 'false' }} + uses: duckduckgo/native-github-asana-sync@v1.1 with: github-pat: ${{ secrets.GT_DAXMOBILE }} github-pr: ${{ steps.create-pr.outputs.pull-request-number }} @@ -115,7 +115,7 @@ jobs: - name: Create Asana task when workflow failed if: ${{ failure() }} - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} diff --git a/.github/workflows/update-ref-tests.yml b/.github/workflows/update-ref-tests.yml index 2de1639477f1..42894bc1f23d 100644 --- a/.github/workflows/update-ref-tests.yml +++ b/.github/workflows/update-ref-tests.yml @@ -84,7 +84,7 @@ jobs: - name: Create Asana task in Android App project if: ${{ steps.update-check.outcome == 'failure' }} id: create-task - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} @@ -99,8 +99,8 @@ jobs: action: 'create-asana-task' - name: Add Asana task to Release Board project - if: ${{ steps.update-check.outcome == 'failure' }} - uses: duckduckgo/native-github-asana-sync@v1.0 + if: ${{ steps.create-task.outputs.duplicate == 'false' }} + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_RELEASE_BOARD_PROJECT_ID }} @@ -109,8 +109,8 @@ jobs: action: 'add-task-asana-project' - name: Update PR description with Asana task - if: ${{ steps.update-check.outcome == 'failure' }} - uses: duckduckgo/native-github-asana-sync@v1.0 + if: ${{ steps.create-task.outputs.duplicate == 'false' }} + uses: duckduckgo/native-github-asana-sync@v1.1 with: github-pat: ${{ secrets.GT_DAXMOBILE }} github-pr: ${{ steps.create-pr.outputs.pull-request-number }} @@ -122,7 +122,7 @@ jobs: - name: Create Asana task when workflow failed if: ${{ failure() }} - uses: duckduckgo/native-github-asana-sync@v1.0 + uses: duckduckgo/native-github-asana-sync@v1.1 with: asana-pat: ${{ secrets.GH_ASANA_SECRET }} asana-project: ${{ vars.GH_ANDROID_APP_PROJECT_ID }} From 4f76ad6cf2e8ae28925ee57c3ccda606557fb50d Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Wed, 24 Jan 2024 13:39:16 +0000 Subject: [PATCH 10/26] ADS: onDialogCancelled callback added to alert dialogs (#4088) Task/Issue URL: https://app.asana.com/0/488551667048375/1204448904099019/f ### Description Add cancellable callback to Dax Alert dialogs - [Android Component: TextAlertDialog](https://app.asana.com/0/0/1202943892847396/f) [Android Component: StackedAlertDialog](https://app.asana.com/0/0/1202943892847397/f) [Android Component: RadioListAlertDialog](https://app.asana.com/0/0/1202943892847398/f) [Android Component: CustomAlertDialog](https://app.asana.com/0/0/1203508448986202/f) ### Steps to test this PR - Install from branch - Go to Settings > Design Preview - Tap on DIALOGS tab - Tap on "Text Alert Dialog Cancellable" - [ ] Check you see "Dialog cancelled" when tapping outside the dialog - [ ] Check you see "Dialog cancelled" when tapping back key - [ ] Check you don't see "Dialog cancelled" when tapping any button ### No UI changes --- .../common/ui/view/dialog/CustomAlertDialogBuilder.kt | 2 ++ .../common/ui/view/dialog/RadioListAlertDialogBuilder.kt | 2 ++ .../common/ui/view/dialog/StackedAlertDialogBuilder.kt | 2 ++ .../common/ui/view/dialog/TextAlertDialogBuilder.kt | 7 ++----- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/CustomAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/CustomAlertDialogBuilder.kt index fd14eccb0ab6..511059455d32 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/CustomAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/CustomAlertDialogBuilder.kt @@ -32,6 +32,7 @@ class CustomAlertDialogBuilder(val context: Context) : DaxAlertDialog { open fun onDialogDismissed() {} open fun onPositiveButtonClicked() {} open fun onNegativeButtonClicked() {} + open fun onDialogCancelled() {} } internal class DefaultEventListener : EventListener() @@ -99,6 +100,7 @@ class CustomAlertDialogBuilder(val context: Context) : DaxAlertDialog { .apply { setCancelable(false) setOnDismissListener { listener.onDialogDismissed() } + setOnCancelListener { listener.onDialogCancelled() } } dialog = dialogBuilder.create() setViews(binding, dialog!!) diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/RadioListAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/RadioListAlertDialogBuilder.kt index 9798685df644..187d901952ca 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/RadioListAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/RadioListAlertDialogBuilder.kt @@ -36,6 +36,7 @@ class RadioListAlertDialogBuilder(val context: Context) : DaxAlertDialog { abstract class EventListener { open fun onDialogShown() {} open fun onDialogDismissed() {} + open fun onDialogCancelled() {} open fun onRadioItemSelected(selectedItem: Int) {} open fun onPositiveButtonClicked(selectedItem: Int) {} open fun onNegativeButtonClicked() {} @@ -127,6 +128,7 @@ class RadioListAlertDialogBuilder(val context: Context) : DaxAlertDialog { .apply { setCancelable(false) setOnDismissListener { listener.onDialogDismissed() } + setOnCancelListener { listener.onDialogCancelled() } } dialog = dialogBuilder.create() diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt index b35921660d91..a8a74275fc47 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt @@ -34,6 +34,7 @@ class StackedAlertDialogBuilder(val context: Context) : DaxAlertDialog { abstract class EventListener { open fun onDialogShown() {} open fun onDialogDismissed() {} + open fun onDialogCancelled() {} open fun onButtonClicked(position: Int) {} } @@ -99,6 +100,7 @@ class StackedAlertDialogBuilder(val context: Context) : DaxAlertDialog { .apply { setCancelable(false) setOnDismissListener { listener.onDialogDismissed() } + setOnCancelListener { listener.onDialogCancelled() } } dialog = dialogBuilder.create() diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt index 5458f9e972d1..785258297f5b 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/TextAlertDialogBuilder.kt @@ -110,11 +110,8 @@ class TextAlertDialogBuilder(val context: Context) : DaxAlertDialog { .setView(binding.root) .setCancelable(isCancellable) .apply { - if (isCancellable) { - setOnCancelListener { - listener.onDialogCancelled() - } - } + setOnDismissListener { listener.onDialogDismissed() } + setOnCancelListener { listener.onDialogCancelled() } } dialog = dialogBuilder.create() setViews(binding, dialog!!) From 98a43a914ca06f03c96cbb08e1fcc399130e86c4 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 24 Jan 2024 14:40:08 +0000 Subject: [PATCH 11/26] Handle subs across platforms (#4085) Task/Issue URL: https://app.asana.com/0/488551667048375/1205557379404217/f ### Description See task ### Steps to test this PR See task --- .../src/main/AndroidManifest.xml | 7 + .../impl/SubscriptionsManager.kt | 58 +++++--- .../impl/repository/AuthRepository.kt | 77 ++++++++++ .../impl/services/SubscriptionsService.kt | 5 + .../settings/views/ItrSettingViewModel.kt | 2 + .../settings/views/PirSettingViewModel.kt | 2 + .../impl/ui/ChangePlanActivity.kt | 47 +++++++ .../impl/ui/SubscriptionSettingsActivity.kt | 42 ++++-- .../impl/ui/SubscriptionSettingsViewModel.kt | 12 +- .../src/main/res/drawable/ic_apple_logo.xml | 44 ++++++ .../main/res/layout/activity_change_plan.xml | 61 ++++++++ .../layout/activity_subscription_settings.xml | 2 +- .../src/main/res/values/donottranslate.xml | 4 + .../impl/RealSubscriptionsManagerTest.kt | 131 +++++++++++++++--- .../impl/repository/FakeAuthDataStore.kt | 30 ++++ .../impl/repository/RealAuthRepositoryTest.kt | 107 ++++++++++++++ .../ui/SubscriptionSettingsViewModelTest.kt | 27 ++++ .../subscriptions/store/AuthDataStore.kt | 56 ++++++++ 18 files changed, 663 insertions(+), 51 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/ChangePlanActivity.kt create mode 100644 subscriptions/subscriptions-impl/src/main/res/drawable/ic_apple_logo.xml create mode 100644 subscriptions/subscriptions-impl/src/main/res/layout/activity_change_plan.xml create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeAuthDataStore.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt diff --git a/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml b/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml index 3c4e4bdf9b1c..821eb08d138f 100644 --- a/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml +++ b/subscriptions/subscriptions-impl/src/main/AndroidManifest.xml @@ -43,5 +43,12 @@ android:label="Activate Subscription" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" android:screenOrientation="portrait" /> + + \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index a1a346e0143a..fd2fd1bc2fdd 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -32,13 +32,13 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown import com.duckduckgo.subscriptions.impl.SubscriptionsData.* import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.services.AuthService import com.duckduckgo.subscriptions.impl.services.CreateAccountResponse import com.duckduckgo.subscriptions.impl.services.Entitlement import com.duckduckgo.subscriptions.impl.services.ResponseError import com.duckduckgo.subscriptions.impl.services.StoreLoginBody import com.duckduckgo.subscriptions.impl.services.SubscriptionsService -import com.duckduckgo.subscriptions.store.AuthDataStore import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import dagger.SingleInstanceIn @@ -124,6 +124,11 @@ interface SubscriptionsManager { * Deletes the current account */ suspend fun deleteAccount(): Boolean + + /** + * Returns a [String] with the URL of the portal or null otherwise + */ + suspend fun getPortalUrl(): String? } @SingleInstanceIn(AppScope::class) @@ -131,7 +136,7 @@ interface SubscriptionsManager { class RealSubscriptionsManager @Inject constructor( private val authService: AuthService, private val subscriptionsService: SubscriptionsService, - private val authDataStore: AuthDataStore, + private val authRepository: AuthRepository, private val billingClientWrapper: BillingClientWrapper, private val emailManager: EmailManager, private val context: Context, @@ -151,7 +156,7 @@ class RealSubscriptionsManager @Inject constructor( override val hasSubscription = _hasSubscription.asStateFlow().onSubscription { emitHasSubscriptionsValues() } private var purchaseStateJob: Job? = null - private fun isUserAuthenticated(): Boolean = !authDataStore.accessToken.isNullOrBlank() && !authDataStore.authToken.isNullOrBlank() + private fun isUserAuthenticated(): Boolean = authRepository.isUserAuthenticated() private suspend fun emitHasSubscriptionsValues() { coroutineScope.launch(dispatcherProvider.io()) { @@ -175,17 +180,28 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun deleteAccount(): Boolean { return try { - val state = authService.delete("Bearer ${authDataStore.authToken}") + val state = authService.delete("Bearer ${authRepository.tokens().authToken}") (state.status == "deleted") } catch (e: Exception) { false } } + override suspend fun getPortalUrl(): String? { + return try { + if (isUserAuthenticated()) { + return subscriptionsService.portal("Bearer ${authRepository.tokens().accessToken}").customerPortalUrl + } + null + } catch (e: Exception) { + null + } + } + override suspend fun getSubscription(): Subscription { return try { if (isUserAuthenticated()) { - val response = subscriptionsService.subscription("Bearer ${authDataStore.accessToken}") + val response = subscriptionsService.subscription("Bearer ${authRepository.tokens().accessToken}") val state = when (response.status) { "Auto-Renewable" -> AutoRenewable "Not Auto-Renewable" -> NotAutoRenewable @@ -194,11 +210,13 @@ class RealSubscriptionsManager @Inject constructor( "Expired" -> Expired else -> Unknown } + authRepository.saveSubscriptionData(response.platform, response.expiresOrRenewsAt) return Subscription.Success( productId = response.productId, startedAt = response.startedAt, expiresOrRenewsAt = response.expiresOrRenewsAt, status = state, + platform = response.platform, ) } else { Subscription.Failure("Subscription not found") @@ -212,8 +230,7 @@ class RealSubscriptionsManager @Inject constructor( } override suspend fun signOut() { - authDataStore.authToken = "" - authDataStore.accessToken = "" + authRepository.signOut() _isSignedIn.emit(false) _hasSubscription.emit(false) } @@ -252,9 +269,10 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun authenticate(authToken: String): SubscriptionsData { return try { val response = authService.accessToken("Bearer $authToken") - authDataStore.accessToken = response.accessToken - authDataStore.authToken = authToken val subscriptionData = getSubscriptionDataFromToken(response.accessToken) + if (subscriptionData is Success) { + authRepository.authenticate(authToken, response.accessToken, subscriptionData.externalId, subscriptionData.email) + } _isSignedIn.emit(isUserAuthenticated()) _hasSubscription.emit(hasSubscription()) return subscriptionData @@ -290,7 +308,7 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun getSubscriptionData(): SubscriptionsData { return try { if (isUserAuthenticated()) { - getSubscriptionDataFromToken(authDataStore.accessToken!!) + getSubscriptionDataFromToken(authRepository.tokens().accessToken!!) } else { Failure("Subscription data not found") } @@ -337,7 +355,7 @@ class RealSubscriptionsManager @Inject constructor( private suspend fun prePurchaseFlow(): SubscriptionsData { return try { val subscriptionData = if (isUserAuthenticated()) { - getSubscriptionDataFromToken(authDataStore.accessToken!!) + getSubscriptionDataFromToken(authRepository.tokens().accessToken!!) } else { recoverSubscriptionFromStore() } @@ -358,13 +376,13 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun getAuthToken(): AuthToken { return if (isUserAuthenticated()) { - logcat { "Subs auth token is ${authDataStore.authToken}" } - when (val response = getSubscriptionDataFromToken(authDataStore.authToken!!)) { + logcat { "Subs auth token is ${authRepository.tokens().authToken}" } + when (val response = getSubscriptionDataFromToken(authRepository.tokens().authToken!!)) { is Success -> { return if (response.entitlements.isEmpty()) { AuthToken.Failure("") } else { - AuthToken.Success(authDataStore.authToken!!) + AuthToken.Success(authRepository.tokens().authToken!!) } } is Failure -> { @@ -376,7 +394,7 @@ class RealSubscriptionsManager @Inject constructor( return if (subscriptionsData.entitlements.isEmpty()) { AuthToken.Failure("") } else { - AuthToken.Success(authDataStore.authToken!!) + AuthToken.Success(authRepository.tokens().authToken!!) } } else { AuthToken.Failure(response.message) @@ -396,7 +414,7 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun getAccessToken(): AccessToken { return withContext(dispatcherProvider.io()) { if (isUserAuthenticated()) { - AccessToken.Success(authDataStore.accessToken!!) + AccessToken.Success(authRepository.tokens().accessToken!!) } else { AccessToken.Failure("Token not found") } @@ -451,7 +469,13 @@ sealed class SubscriptionsData { } sealed class Subscription { - data class Success(val productId: String, val startedAt: Long, val expiresOrRenewsAt: Long, val status: SubscriptionStatus) : Subscription() + data class Success( + val productId: String, + val startedAt: Long, + val expiresOrRenewsAt: Long, + val status: SubscriptionStatus, + val platform: String, + ) : Subscription() data class Failure(val message: String) : Subscription() } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt new file mode 100644 index 000000000000..f0452e58e3b6 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -0,0 +1,77 @@ +/* + * 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.subscriptions.impl.repository + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.store.AuthDataStore +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface AuthRepository { + + fun isUserAuthenticated(): Boolean + + suspend fun signOut() + + fun tokens(): SubscriptionsTokens + + suspend fun authenticate(authToken: String?, accessToken: String?, externalId: String?, email: String?) + + suspend fun saveSubscriptionData(platform: String, expiresOrRenewsAt: Long) + + suspend fun clearSubscriptionData() +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealAuthRepository @Inject constructor( + private val authDataStore: AuthDataStore, +) : AuthRepository { + + override fun isUserAuthenticated(): Boolean = !authDataStore.accessToken.isNullOrBlank() && !authDataStore.authToken.isNullOrBlank() + + override suspend fun signOut() { + authDataStore.authToken = null + authDataStore.accessToken = null + authDataStore.platform = null + authDataStore.email = null + authDataStore.externalId = null + authDataStore.expiresOrRenewsAt = 0 + } + + override suspend fun clearSubscriptionData() { + authDataStore.platform = null + authDataStore.expiresOrRenewsAt = 0 + } + + override suspend fun authenticate(authToken: String?, accessToken: String?, externalId: String?, email: String?) { + authDataStore.authToken = authToken + authDataStore.accessToken = accessToken + authDataStore.externalId = externalId + authDataStore.email = email + } + + override fun tokens(): SubscriptionsTokens = SubscriptionsTokens(authToken = authDataStore.authToken, accessToken = authDataStore.accessToken) + + override suspend fun saveSubscriptionData(platform: String, expiresOrRenewsAt: Long) { + authDataStore.platform = platform + authDataStore.expiresOrRenewsAt = expiresOrRenewsAt + } +} + +data class SubscriptionsTokens(val authToken: String?, val accessToken: String?) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt index 17cdde6b1e79..f627abe12049 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/services/SubscriptionsService.kt @@ -25,8 +25,13 @@ import retrofit2.http.Header interface SubscriptionsService { @GET("https://subscriptions-dev.duckduckgo.com/api/subscription") suspend fun subscription(@Header("Authorization") authorization: String?): SubscriptionResponse + + @GET("https://subscriptions-dev.duckduckgo.com/api/checkout/portal") + suspend fun portal(@Header("Authorization") authorization: String?): PortalResponse } +data class PortalResponse(val customerPortalUrl: String) + data class SubscriptionResponse( val productId: String, val startedAt: Long, diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt index 0c2f1ec9238e..2fa9d59b0c66 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt @@ -63,6 +63,8 @@ class ItrSettingViewModel( subscriptions.getEntitlementStatus(ITR_PRODUCT_NAME).also { if (it.isSuccess) { _viewState.emit(viewState.value.copy(hasSubscription = it.getOrDefault(NotFound) == Found)) + } else { + _viewState.emit(viewState.value.copy(hasSubscription = false)) } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt index 250b8016dce7..bfc7a957e330 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt @@ -63,6 +63,8 @@ class PirSettingViewModel( subscriptions.getEntitlementStatus(PIR_PRODUCT_NAME).also { if (it.isSuccess) { _viewState.emit(viewState.value.copy(hasSubscription = it.getOrDefault(NotFound) == Found)) + } else { + _viewState.emit(viewState.value.copy(hasSubscription = false)) } } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/ChangePlanActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/ChangePlanActivity.kt new file mode 100644 index 000000000000..81ed1651c067 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/ChangePlanActivity.kt @@ -0,0 +1,47 @@ +/* + * 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.subscriptions.impl.ui + +import android.os.Bundle +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.subscriptions.impl.databinding.ActivityChangePlanBinding +import com.duckduckgo.subscriptions.impl.ui.ChangePlanActivity.Companion.ChangePlanScreenWithEmptyParams + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(ChangePlanScreenWithEmptyParams::class) +class ChangePlanActivity : DuckDuckGoActivity() { + + private val binding: ActivityChangePlanBinding by viewBinding() + + private val toolbar + get() = binding.includeToolbar.toolbar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupToolbar(toolbar) + } + + companion object { + data object ChangePlanScreenWithEmptyParams : GlobalActivityStarter.ActivityParams + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index c7af361d6dda..d3ac909005a6 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -35,9 +35,11 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionSettingsBinding import com.duckduckgo.subscriptions.impl.ui.AddDeviceActivity.Companion.AddDeviceScreenWithEmptyParams +import com.duckduckgo.subscriptions.impl.ui.ChangePlanActivity.Companion.ChangePlanScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut +import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToPortal import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.ViewState import javax.inject.Inject @@ -98,13 +100,6 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { .show() } - binding.changePlan.setClickListener { - val url = String.format(URL, BASIC_SUBSCRIPTION, applicationContext.packageName) - val intent = Intent(Intent.ACTION_VIEW) - intent.setData(Uri.parse(url)) - startActivity(intent) - } - binding.faq.setClickListener { Toast.makeText(this, "This will take you to FAQs", Toast.LENGTH_SHORT).show() } @@ -116,18 +111,32 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { } private fun renderView(viewState: ViewState) { - val duration = if (viewState.duration is Monthly) { - getString(string.monthly) - } else { - getString(string.yearly) - } - + val duration = if (viewState.duration is Monthly) { getString(string.monthly) } else { getString(string.yearly) } val status = when (viewState.status) { is AutoRenewable -> getString(string.renews) else -> getString(string.expires) } - binding.description.text = getString(string.subscriptionsData, duration, status, viewState.date) + + when (viewState.platform?.lowercase()) { + "apple", "ios" -> + binding.changePlan.setClickListener { + globalActivityStarter.start(this, ChangePlanScreenWithEmptyParams) + } + "stripe" -> { + binding.changePlan.setClickListener { + viewModel.goToStripe() + } + } + else -> { + binding.changePlan.setClickListener { + val url = String.format(URL, BASIC_SUBSCRIPTION, applicationContext.packageName) + val intent = Intent(Intent.ACTION_VIEW) + intent.setData(Uri.parse(url)) + startActivity(intent) + } + } + } } private fun processCommand(command: Command) { @@ -136,10 +145,13 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this, string.subscriptionRemoved, Toast.LENGTH_SHORT).show() finish() } + is GoToPortal -> { + globalActivityStarter.start(this, SubscriptionsWebViewActivityWithParams(url = command.url, getString(string.changePlanTitle))) + } } } companion object { const val URL = "https://play.google.com/store/account/subscriptions?sku=%s&package=%s" - object SubscriptionsSettingsScreenWithEmptyParams : GlobalActivityStarter.ActivityParams + data object SubscriptionsSettingsScreenWithEmptyParams : GlobalActivityStarter.ActivityParams } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt index 5cd4ad684a52..e7b6cfffbfd5 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt @@ -29,6 +29,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut +import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToPortal import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Yearly import java.text.SimpleDateFormat @@ -58,6 +59,7 @@ class SubscriptionSettingsViewModel @Inject constructor( val date: String? = null, val duration: SubscriptionDuration? = null, val status: SubscriptionStatus? = null, + val platform: String? = null, ) override fun onResume(owner: LifecycleOwner) { @@ -67,11 +69,18 @@ class SubscriptionSettingsViewModel @Inject constructor( val formatter = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) val date = formatter.format(Date(subs.expiresOrRenewsAt)) val type = if (subs.productId == MONTHLY_PLAN) Monthly else Yearly - _viewState.emit(viewState.value.copy(date = date, duration = type, status = subs.status)) + _viewState.emit(viewState.value.copy(date = date, duration = type, status = subs.status, platform = subs.platform)) } } } + fun goToStripe() { + viewModelScope.launch(dispatcherProvider.io()) { + val url = subscriptionsManager.getPortalUrl() ?: return@launch + command.send(GoToPortal(url)) + } + } + fun removeFromDevice() { viewModelScope.launch { subscriptionsManager.signOut() @@ -86,5 +95,6 @@ class SubscriptionSettingsViewModel @Inject constructor( sealed class Command { data object FinishSignOut : Command() + data class GoToPortal(val url: String) : Command() } } diff --git a/subscriptions/subscriptions-impl/src/main/res/drawable/ic_apple_logo.xml b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_apple_logo.xml new file mode 100644 index 000000000000..1d502981731c --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/res/drawable/ic_apple_logo.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/activity_change_plan.xml b/subscriptions/subscriptions-impl/src/main/res/layout/activity_change_plan.xml new file mode 100644 index 000000000000..62c1ac86ef03 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/res/layout/activity_change_plan.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + diff --git a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml index aff61c5b35ad..098eb1850994 100644 --- a/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml +++ b/subscriptions/subscriptions-impl/src/main/res/layout/activity_subscription_settings.xml @@ -77,7 +77,7 @@ android:id="@+id/changePlan" android:layout_width="match_parent" android:layout_height="wrap_content" - app:primaryText="Change Plan or Billing" /> + app:primaryText="@string/changePlanTitle" /> Identity Theft Restoration If your identity is stolen, we\'ll help restore it + + + Change Plan or Billing + Your subscription was purchased through the Apple App Store. To change your plan or billing settings, please go to Settings > Apple ID > Subscriptions on a device signed in to the same Apple ID used to purchase your subscription. \ No newline at end of file diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 92f335e11327..4b7c8df23a2e 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -18,11 +18,15 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsData.Failure import com.duckduckgo.subscriptions.impl.SubscriptionsData.Success import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.duckduckgo.subscriptions.impl.repository.FakeAuthDataStore +import com.duckduckgo.subscriptions.impl.repository.RealAuthRepository import com.duckduckgo.subscriptions.impl.services.AccessTokenResponse import com.duckduckgo.subscriptions.impl.services.AccountResponse import com.duckduckgo.subscriptions.impl.services.AuthService import com.duckduckgo.subscriptions.impl.services.CreateAccountResponse import com.duckduckgo.subscriptions.impl.services.Entitlement +import com.duckduckgo.subscriptions.impl.services.PortalResponse import com.duckduckgo.subscriptions.impl.services.StoreLoginResponse import com.duckduckgo.subscriptions.impl.services.SubscriptionResponse import com.duckduckgo.subscriptions.impl.services.SubscriptionsService @@ -55,7 +59,8 @@ class RealSubscriptionsManagerTest { private val authService: AuthService = mock() private val subscriptionsService: SubscriptionsService = mock() - private val authDataStore: AuthDataStore = FakeDataStore() + private val authDataStore: AuthDataStore = FakeAuthDataStore() + private val authRepository = RealAuthRepository(authDataStore) private val emailManager: EmailManager = mock() private val billingClient: BillingClientWrapper = mock() private val billingBuilder: BillingFlowParams.Builder = mock() @@ -71,7 +76,7 @@ class RealSubscriptionsManagerTest { subscriptionsManager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -156,6 +161,7 @@ class RealSubscriptionsManagerTest { givenUserIsNotAuthenticated() givenPurchaseStored() givenPurchaseStoredIsValid() + givenValidateTokenSucceedsNoEntitlements() givenAuthenticateSucceeds() subscriptionsManager.recoverSubscriptionFromStore() @@ -329,6 +335,7 @@ class RealSubscriptionsManagerTest { fun whenPurchaseFlowIfAccountCreatedThenSignInUserAndSetToken() = runTest { givenUserIsNotAuthenticated() givenCreateAccountSucceeds() + givenValidateTokenSucceedsNoEntitlements() givenAuthenticateSucceeds() subscriptionsManager.purchase(mock(), mock(), "", false) @@ -345,6 +352,7 @@ class RealSubscriptionsManagerTest { givenUserIsNotAuthenticated() givenPurchaseStored() givenPurchaseStoredIsValid() + givenValidateTokenSucceedsNoEntitlements() givenAuthenticateSucceeds() subscriptionsManager.purchase(mock(), mock(), "", false) @@ -399,7 +407,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -421,7 +429,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -443,7 +451,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -464,7 +472,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -485,7 +493,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -510,7 +518,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -537,7 +545,7 @@ class RealSubscriptionsManagerTest { val manager = RealSubscriptionsManager( authService, subscriptionsService, - authDataStore, + authRepository, billingClient, emailManager, context, @@ -697,6 +705,102 @@ class RealSubscriptionsManagerTest { assertTrue((subscriptionsManager.getSubscription() as Subscription.Success).status is Unknown) } + @Test + fun whenGetSubscriptionThenStorePlatformValue() = runTest { + givenUserIsAuthenticated() + givenSubscriptionSucceeds("Auto-Renewable") + + assertNull(authDataStore.platform) + subscriptionsManager.getSubscription() + assertEquals("android", authDataStore.platform) + } + + @Test + fun whenGetPortalAndUserAuthenticatedReturnUrl() = runTest { + givenUserIsAuthenticated() + givenUrlPortalSucceeds() + + assertEquals("example.com", subscriptionsManager.getPortalUrl()) + } + + @Test + fun whenGetPortalAndUserIsNotAuthenticatedReturnNull() = runTest { + givenUserIsNotAuthenticated() + + assertNull(subscriptionsManager.getPortalUrl()) + } + + @Test + fun whenGetPortalFailsReturnNull() = runTest { + givenUserIsAuthenticated() + givenUrlPortalFails() + + assertNull(subscriptionsManager.getPortalUrl()) + } + + @Test + fun whenSignOutThenCallRepositorySignOut() = runTest { + val mockRepo: AuthRepository = mock() + val manager = RealSubscriptionsManager( + authService, + subscriptionsService, + mockRepo, + billingClient, + emailManager, + context, + TestScope(), + coroutineRule.testDispatcherProvider, + ) + manager.signOut() + verify(mockRepo).signOut() + } + + @Test + fun whenSignOutEmitFalseForIsSignedIn() = runTest { + givenAuthenticateSucceeds() + givenValidateTokenSucceedsWithEntitlements() + + subscriptionsManager.authenticate("authToken") + subscriptionsManager.isSignedIn.test { + assertTrue(awaitItem()) + subscriptionsManager.signOut() + assertFalse(awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSignOutEmitFalseForHasSubscription() = runTest { + givenUserIsAuthenticated() + givenValidateTokenSucceedsWithEntitlements() + val manager = RealSubscriptionsManager( + authService, + subscriptionsService, + authRepository, + billingClient, + emailManager, + context, + TestScope(), + coroutineRule.testDispatcherProvider, + ) + + manager.hasSubscription.test { + assertTrue(awaitItem()) + manager.signOut() + assertFalse(awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + private suspend fun givenUrlPortalSucceeds() { + whenever(subscriptionsService.portal(any())).thenReturn(PortalResponse("example.com")) + } + + private suspend fun givenUrlPortalFails() { + val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) + whenever(subscriptionsService.portal(any())).thenThrow(HttpException(Response.error(400, exception))) + } + private suspend fun givenSubscriptionFails() { val exception = "failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(subscriptionsService.subscription(any())).thenThrow(HttpException(Response.error(400, exception))) @@ -708,7 +812,7 @@ class RealSubscriptionsManagerTest { productId = MONTHLY_PLAN, startedAt = 1234, expiresOrRenewsAt = 1234, - platform = "google", + platform = "android", status = status, ), ) @@ -837,11 +941,4 @@ class RealSubscriptionsManagerTest { val exception = "account_failure".toResponseBody("text/json".toMediaTypeOrNull()) whenever(authService.accessToken(any())).thenThrow(HttpException(Response.error(400, exception))) } - - internal class FakeDataStore : AuthDataStore { - - override var accessToken: String? = null - override var authToken: String? = null - override fun canUseEncryption(): Boolean = true - } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeAuthDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeAuthDataStore.kt new file mode 100644 index 000000000000..775c68e3f34d --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeAuthDataStore.kt @@ -0,0 +1,30 @@ +/* + * 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.subscriptions.impl.repository + +import com.duckduckgo.subscriptions.store.AuthDataStore + +class FakeAuthDataStore : AuthDataStore { + + override var accessToken: String? = null + override var authToken: String? = null + override var email: String? = null + override var externalId: String? = null + override var expiresOrRenewsAt: Long? = 0L + override var platform: String? = null + override fun canUseEncryption(): Boolean = true +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt new file mode 100644 index 000000000000..6077a5f7d4dd --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -0,0 +1,107 @@ +package com.duckduckgo.subscriptions.impl.repository + +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class RealAuthRepositoryTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val authStore = FakeAuthDataStore() + private val authRepository: AuthRepository = RealAuthRepository(authStore) + + @Test + fun whenIsAuthenticatedAndNoAccessTokenThenReturnFalse() { + authStore.authToken = "authToken" + assertFalse(authRepository.isUserAuthenticated()) + } + + @Test + fun whenIsAuthenticatedAndNoAuthTokenThenReturnFalse() { + authStore.accessToken = "accessToken" + assertFalse(authRepository.isUserAuthenticated()) + } + + @Test + fun whenIsAuthenticatedAndNoAuthTokenAndAccessTokenThenReturnFalse() { + assertFalse(authRepository.isUserAuthenticated()) + } + + @Test + fun whenIsAuthenticatedThenReturnTrue() { + authStore.authToken = "authToken" + authStore.accessToken = "accessToken" + assertTrue(authRepository.isUserAuthenticated()) + } + + @Test + fun whenSignOutThenClearData() = runTest { + authStore.accessToken = "accessToken" + authStore.authToken = "accessToken" + authStore.platform = "android" + authStore.email = "email@duck.com" + authStore.externalId = "externalId" + authStore.expiresOrRenewsAt = 1234L + + authRepository.signOut() + + assertNull(authStore.accessToken) + assertNull(authStore.authToken) + assertNull(authStore.platform) + assertNull(authStore.email) + assertNull(authStore.externalId) + assertEquals(0L, authStore.expiresOrRenewsAt) + } + + @Test + fun whenClearSubscriptionDataThenClearData() = runTest { + authStore.platform = "android" + authStore.expiresOrRenewsAt = 1234L + + authRepository.clearSubscriptionData() + + assertNull(authStore.platform) + assertEquals(0L, authStore.expiresOrRenewsAt) + } + + @Test + fun whenAuthenticateThenSetData() = runTest { + assertNull(authStore.accessToken) + assertNull(authStore.authToken) + assertNull(authStore.email) + assertNull(authStore.externalId) + + authRepository.authenticate(authToken = "authToken", accessToken = "accessToken", email = "email", externalId = "externalId") + + assertEquals("authToken", authStore.authToken) + assertEquals("accessToken", authStore.accessToken) + assertEquals("email", authStore.email) + assertEquals("externalId", authStore.externalId) + } + + @Test + fun whenTokensThenReturnTokens() { + var tokens = authRepository.tokens() + assertNull(tokens.authToken) + assertNull(tokens.accessToken) + + authStore.accessToken = "accessToken" + authStore.authToken = "authToken" + + tokens = authRepository.tokens() + assertEquals("authToken", tokens.authToken) + assertEquals("accessToken", tokens.accessToken) + } + + @Test + fun whenSaveSubscriptionDataThenStoreSubscriptionValues() = runTest { + assertNull(authStore.platform) + assertEquals(0L, authStore.expiresOrRenewsAt) + authRepository.saveSubscriptionData("android", 1234L) + assertEquals("android", authStore.platform) + assertEquals(1234L, authStore.expiresOrRenewsAt) + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt index ece21d69a455..0ad056f63f93 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt @@ -7,6 +7,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut +import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToPortal import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Yearly import kotlinx.coroutines.test.runTest @@ -46,6 +47,7 @@ class SubscriptionSettingsViewModelTest { startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AutoRenewable, + platform = "android", ), ) @@ -63,6 +65,7 @@ class SubscriptionSettingsViewModelTest { startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AutoRenewable, + platform = "android", ), ) @@ -80,6 +83,7 @@ class SubscriptionSettingsViewModelTest { startedAt = 1234, expiresOrRenewsAt = 1701694623000, status = AutoRenewable, + platform = "android", ), ) @@ -88,4 +92,27 @@ class SubscriptionSettingsViewModelTest { assertEquals(Yearly, awaitItem().duration) } } + + @Test + fun whenGoToStripeIfNoUrlThenDoNothing() = runTest { + whenever(subscriptionsManager.getPortalUrl()).thenReturn(null) + + viewModel.commands().test { + viewModel.goToStripe() + expectNoEvents() + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenGoToStripeIfNoUrlThenDoSendCommandWithUrl() = runTest { + whenever(subscriptionsManager.getPortalUrl()).thenReturn("example.com") + + viewModel.commands().test { + viewModel.goToStripe() + val value = awaitItem() as GoToPortal + assertEquals("example.com", value.url) + cancelAndConsumeRemainingEvents() + } + } } diff --git a/subscriptions/subscriptions-store/src/main/java/com/duckduckgo/subscriptions/store/AuthDataStore.kt b/subscriptions/subscriptions-store/src/main/java/com/duckduckgo/subscriptions/store/AuthDataStore.kt index 19b83881c664..ef1ccc939b45 100644 --- a/subscriptions/subscriptions-store/src/main/java/com/duckduckgo/subscriptions/store/AuthDataStore.kt +++ b/subscriptions/subscriptions-store/src/main/java/com/duckduckgo/subscriptions/store/AuthDataStore.kt @@ -22,6 +22,10 @@ import androidx.core.content.edit interface AuthDataStore { var accessToken: String? var authToken: String? + var email: String? + var externalId: String? + var expiresOrRenewsAt: Long? + var platform: String? fun canUseEncryption(): Boolean } @@ -60,11 +64,63 @@ class AuthEncryptedDataStore( } } + override var email: String? + get() = encryptedPreferences?.getString(KEY_EMAIL, null) + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_EMAIL) + } else { + putString(KEY_EMAIL, value) + } + } + } + + override var platform: String? + get() = encryptedPreferences?.getString(KEY_PLATFORM, null) + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_PLATFORM) + } else { + putString(KEY_PLATFORM, value) + } + } + } + + override var externalId: String? + get() = encryptedPreferences?.getString(KEY_EXTERNAL_ID, null) + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_EXTERNAL_ID) + } else { + putString(KEY_EXTERNAL_ID, value) + } + } + } + + override var expiresOrRenewsAt: Long? + get() = encryptedPreferences?.getLong(KEY_EXPIRES_OR_RENEWS_AT, 0L) + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_EXPIRES_OR_RENEWS_AT) + } else { + putLong(KEY_EXPIRES_OR_RENEWS_AT, value) + } + } + } + override fun canUseEncryption(): Boolean = encryptedPreferences != null companion object { const val FILENAME = "com.duckduckgo.subscriptions.store" const val KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" + const val KEY_PLATFORM = "KEY_PLATFORM" + const val KEY_EMAIL = "KEY_EMAIL" + const val KEY_EXTERNAL_ID = "KEY_EXTERNAL_ID" + const val KEY_EXPIRES_OR_RENEWS_AT = "KEY_EXPIRES_OR_RENEWS_AT" } } From 465aae85f66579bbbf7ce443491b2e7d1c436505 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 24 Jan 2024 15:48:55 +0000 Subject: [PATCH 12/26] Fix css workflow (#4112) Task/Issue URL: https://app.asana.com/0/488551667048375/1206418414402174/f ### Description Fixes the css workflow ### Steps to test this PR - [x] Workflow doesn't fail --- .github/workflows/update-content-scope.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-content-scope.yml b/.github/workflows/update-content-scope.yml index 556dd186d163..ce20b3a57e4c 100644 --- a/.github/workflows/update-content-scope.yml +++ b/.github/workflows/update-content-scope.yml @@ -87,7 +87,7 @@ jobs: Content scope scripts have been updated and a PR created. If tests failed check out https://app.asana.com/0/1202561462274611/1203986899650836/f for further information on what to do next. - d + See ${{ steps.create-pr.outputs.pull-request-url }} action: 'create-asana-task' From f0010210963427a7529de5993ff11c7e05f82df8 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 25 Jan 2024 10:13:16 +0000 Subject: [PATCH 13/26] ADS: Update PopupMenuItem with a new property (#4106) Task/Issue URL: https://app.asana.com/0/488551667048375/1206364374762056/f ### Description Added labelType property to PopupMenuItem ### Steps to test this PR - [ ] Install from branch - [ ] Go to Settings > Components Design Preview - [ ] Select List items tab - [ ] Check Popup Menu Items look according to specs --- .../bookmarks/ui/BookmarkScreenViewHolders.kt | 4 +- .../ui/themepreview/ui/component/Component.kt | 1 + .../ui/component/ComponentViewHolder.kt | 5 +++ .../ComponentListItemsElementsFragment.kt | 3 +- .../common/ui/view/PopupMenuItemView.kt | 37 +++++++++++++---- .../view/dialog/StackedAlertDialogBuilder.kt | 2 +- ...ml => destructive_text_color_selector.xml} | 0 .../res/layout/component_one_line_item.xml | 2 +- .../res/layout/component_popup_menu_item.xml | 41 +++++++++++++++++++ .../res/layout/component_two_line_item.xml | 4 +- .../layout/fragment_components_typography.xml | 2 +- .../main/res/layout/view_popup_menu_item.xml | 28 ++++++------- .../src/main/res/values/attrs-lists.xml | 6 +++ .../src/main/res/values/attrs-menu-item.xml | 5 +-- 14 files changed, 108 insertions(+), 32 deletions(-) rename common/common-ui/src/main/res/color/{red_text_color_selector.xml => destructive_text_color_selector.xml} (100%) create mode 100644 common/common-ui/src/main/res/layout/component_popup_menu_item.xml diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarkScreenViewHolders.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarkScreenViewHolders.kt index 162c675552a3..8b725ed654fa 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarkScreenViewHolders.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarkScreenViewHolders.kt @@ -151,9 +151,9 @@ sealed class BookmarkScreenViewHolders(itemView: View) : RecyclerView.ViewHolder } } if (isFavorite) { - view.findViewById(R.id.addRemoveFavorite).label(context.getString(R.string.removeFromFavorites)) + view.findViewById(R.id.addRemoveFavorite).setPrimaryText(context.getString(R.string.removeFromFavorites)) } else { - view.findViewById(R.id.addRemoveFavorite).label(context.getString(R.string.addToFavoritesMenu)) + view.findViewById(R.id.addRemoveFavorite).setPrimaryText(context.getString(R.string.addToFavoritesMenu)) } popupMenu.show(binding.root, anchor) } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt index c0919d6c8189..15801bef6c26 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/Component.kt @@ -40,6 +40,7 @@ enum class Component { IMAGE, SEARCH_BAR, MENU_ITEM, + POPUP_MENU_ITEM, SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt index ddb76f210f2f..987ebebae37e 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/ComponentViewHolder.kt @@ -142,6 +142,10 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) parent: ViewGroup, ) : ComponentViewHolder(inflate(parent, R.layout.component_menu_item)) + class PopupMenuItemComponentViewHolder( + parent: ViewGroup, + ) : ComponentViewHolder(inflate(parent, R.layout.component_popup_menu_item)) + class HeaderSectionComponentViewHolder( parent: ViewGroup, ) : ComponentViewHolder(inflate(parent, R.layout.component_section_header_item)) { @@ -377,6 +381,7 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) Component.REMOTE_MESSAGE -> RemoteMessageComponentViewHolder(parent) Component.SEARCH_BAR -> SearchBarComponentViewHolder(parent) Component.MENU_ITEM -> MenuItemComponentViewHolder(parent) + Component.POPUP_MENU_ITEM -> PopupMenuItemComponentViewHolder(parent) Component.SECTION_HEADER_LIST_ITEM -> HeaderSectionComponentViewHolder(parent) Component.SINGLE_LINE_LIST_ITEM -> OneLineListItemComponentViewHolder(parent) Component.TWO_LINE_LIST_ITEM -> TwoLineItemComponentViewHolder(parent) diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt index a44c1b622d6b..e7797aab898f 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/component/listitems/ComponentListItemsElementsFragment.kt @@ -18,6 +18,7 @@ package com.duckduckgo.common.ui.themepreview.ui.component.listitems import com.duckduckgo.common.ui.themepreview.ui.component.Component import com.duckduckgo.common.ui.themepreview.ui.component.Component.MENU_ITEM +import com.duckduckgo.common.ui.themepreview.ui.component.Component.POPUP_MENU_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.SECTION_HEADER_LIST_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.SINGLE_LINE_LIST_ITEM import com.duckduckgo.common.ui.themepreview.ui.component.Component.TWO_LINE_LIST_ITEM @@ -25,6 +26,6 @@ import com.duckduckgo.common.ui.themepreview.ui.component.ComponentFragment class ComponentListItemsElementsFragment : ComponentFragment() { override fun getComponents(): List { - return listOf(SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, MENU_ITEM) + return listOf(SECTION_HEADER_LIST_ITEM, SINGLE_LINE_LIST_ITEM, TWO_LINE_LIST_ITEM, MENU_ITEM, POPUP_MENU_ITEM) } } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/PopupMenuItemView.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/PopupMenuItemView.kt index 53ff6088eecd..2dff2fa7f0e7 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/PopupMenuItemView.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/PopupMenuItemView.kt @@ -19,6 +19,9 @@ package com.duckduckgo.common.ui.view import android.content.Context import android.util.AttributeSet import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import com.duckduckgo.common.ui.view.PopupMenuItemView.PopupMenuItemType.DESTRUCTIVE +import com.duckduckgo.common.ui.view.PopupMenuItemView.PopupMenuItemType.PRIMARY import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.mobile.android.R import com.duckduckgo.mobile.android.databinding.ViewPopupMenuItemBinding @@ -39,28 +42,48 @@ constructor( } private fun initAttr(attrs: AttributeSet?) { - val attributes = context.obtainStyledAttributes( + context.obtainStyledAttributes( attrs, R.styleable.PopupMenuItemView, 0, 0, - ) - binding.label.text = attributes.getString(R.styleable.PopupMenuItemView_primaryText) ?: "" - updateContentDescription() - attributes.recycle() + ).apply { + if (hasValue(R.styleable.PopupMenuItemView_primaryTextType)) { + val primaryTextType = when (getInt(R.styleable.PopupMenuItemView_primaryTextType, 0)) { + 1 -> DESTRUCTIVE + else -> PRIMARY + } + setPrimaryTextType(primaryTextType) + } + binding.label.text = getString(R.styleable.PopupMenuItemView_primaryText) ?: "" + updateContentDescription() + recycle() + } } - fun label(label: String) { + fun setPrimaryText(label: String) { binding.label.text = label updateContentDescription() } - fun label(label: () -> String) { + fun setPrimaryText(label: () -> String) { binding.label.text = label() updateContentDescription() } + fun setPrimaryTextType(type: PopupMenuItemType) { + when (type) { + PRIMARY -> binding.label.setTextColor(ContextCompat.getColorStateList(context, R.color.primary_text_color_selector)) + DESTRUCTIVE -> binding.label.setTextColor(ContextCompat.getColorStateList(context, R.color.destructive_text_color_selector)) + } + } + private fun updateContentDescription() { binding.root.contentDescription = binding.label.text } + + enum class PopupMenuItemType { + PRIMARY, + DESTRUCTIVE, + } } diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt index a8a74275fc47..0c3fc8de36a1 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/dialog/StackedAlertDialogBuilder.kt @@ -159,7 +159,7 @@ class StackedAlertDialogBuilder(val context: Context) : DaxAlertDialog { ghostButton.setTextColor( ContextCompat.getColorStateList( context, - R.color.red_text_color_selector, + R.color.destructive_text_color_selector, ), ) ghostButton diff --git a/common/common-ui/src/main/res/color/red_text_color_selector.xml b/common/common-ui/src/main/res/color/destructive_text_color_selector.xml similarity index 100% rename from common/common-ui/src/main/res/color/red_text_color_selector.xml rename to common/common-ui/src/main/res/color/destructive_text_color_selector.xml diff --git a/common/common-ui/src/main/res/layout/component_one_line_item.xml b/common/common-ui/src/main/res/layout/component_one_line_item.xml index 0823c812e994..dfc4c7fde9c5 100644 --- a/common/common-ui/src/main/res/layout/component_one_line_item.xml +++ b/common/common-ui/src/main/res/layout/component_one_line_item.xml @@ -152,7 +152,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/oneLineListItemDisabled" app:primaryText="Item with custom text color" - app:primaryTextColorOverlay="@color/red_text_color_selector" /> + app:primaryTextColorOverlay="@color/destructive_text_color_selector" /> + + + + + + + + + + \ No newline at end of file diff --git a/common/common-ui/src/main/res/layout/component_two_line_item.xml b/common/common-ui/src/main/res/layout/component_two_line_item.xml index 799e364595ac..723fc87f8aa7 100644 --- a/common/common-ui/src/main/res/layout/component_two_line_item.xml +++ b/common/common-ui/src/main/res/layout/component_two_line_item.xml @@ -234,7 +234,7 @@ app:primaryText="Two Line Item" app:secondaryText="With custom Primary Text color" app:leadingIcon="@drawable/ic_globe_gray_16dp" - app:primaryTextColorOverlay="@color/red_text_color_selector" + app:primaryTextColorOverlay="@color/destructive_text_color_selector" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/twoLineSwitchListItemWithSwitchDisabledChecked"/> @@ -246,7 +246,7 @@ app:primaryText="Two Line Item" app:secondaryText="With custom Secondary Text color" app:leadingIcon="@drawable/ic_globe_gray_16dp" - app:secondaryTextColorOverlay="@color/red_text_color_selector" + app:secondaryTextColorOverlay="@color/destructive_text_color_selector" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/twoLineSwitchListItemWithPrimaryTextColorOverlay"/> diff --git a/common/common-ui/src/main/res/layout/fragment_components_typography.xml b/common/common-ui/src/main/res/layout/fragment_components_typography.xml index a7a56c8f281e..d330e16a3dca 100644 --- a/common/common-ui/src/main/res/layout/fragment_components_typography.xml +++ b/common/common-ui/src/main/res/layout/fragment_components_typography.xml @@ -328,7 +328,7 @@ android:layout_marginTop="@dimen/keyline_2" android:layout_marginBottom="@dimen/keyline_2" android:text="Text Appearance Body 1 set manually" - android:textColor="@color/red_text_color_selector" + android:textColor="@color/destructive_text_color_selector" tools:ignore="HardcodedText"/> diff --git a/common/common-ui/src/main/res/layout/view_popup_menu_item.xml b/common/common-ui/src/main/res/layout/view_popup_menu_item.xml index 89a1d514f818..d0cbdc363640 100644 --- a/common/common-ui/src/main/res/layout/view_popup_menu_item.xml +++ b/common/common-ui/src/main/res/layout/view_popup_menu_item.xml @@ -15,20 +15,20 @@ --> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + tools:parentTag="android.widget.LinearLayout"> + android:id="@+id/label" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:gravity="center_vertical" + android:minWidth="@dimen/popupMenuItemWidth" + android:minHeight="@dimen/popupMenuItemHeight" + android:paddingStart="@dimen/keyline_4" + android:paddingEnd="@dimen/keyline_4" + app:typography="body1" + tools:text="Text label" /> \ No newline at end of file diff --git a/common/common-ui/src/main/res/values/attrs-lists.xml b/common/common-ui/src/main/res/values/attrs-lists.xml index bd43f107750a..51a5aef2f5d9 100644 --- a/common/common-ui/src/main/res/values/attrs-lists.xml +++ b/common/common-ui/src/main/res/values/attrs-lists.xml @@ -23,6 +23,12 @@ + + + + + + diff --git a/common/common-ui/src/main/res/values/attrs-menu-item.xml b/common/common-ui/src/main/res/values/attrs-menu-item.xml index 047b861e0016..048811e680a1 100644 --- a/common/common-ui/src/main/res/values/attrs-menu-item.xml +++ b/common/common-ui/src/main/res/values/attrs-menu-item.xml @@ -1,5 +1,4 @@ - - Task/Issue URL: https://app.asana.com/0/72649045549333/1206301192455389 ### Description Optimize tracker evaluation by avoiding back and forth conversion of String to Uri and unneeded precautions for edge cases. Note: The methods I optimized are used in several places, where we can't be sure the URL is correctly formed (or can only be sure for some parameters but not others), and therefore we need to keep the old implementation as well as create several variants to make sure nothing breaks while optimizing whenever possible. Further optimizations might be possible but they would require a larger investment A follow-up PR will setup an A/B test ### Steps to test this PR _Feature 1_ - [ ] Smoke test ### UI changes No UI changes --- .../app/browser/BrowserWebViewClientTest.kt | 22 + .../browser/WebViewRequestInterceptorTest.kt | 6 +- .../pageloadpixel/PageLoadedHandlerTest.kt | 8 + .../app/browser/BrowserTabViewModel.kt | 7 + .../app/browser/BrowserWebViewClient.kt | 8 +- .../app/browser/WebViewClientListener.kt | 1 + .../app/browser/WebViewRequestInterceptor.kt | 130 +++++- .../pageloadpixel/PageLoadedHandler.kt | 3 + .../PageLoadedOfflinePixelSender.kt | 2 + .../pageloadpixel/PageLoadedPixelEntity.kt | 1 + .../duckduckgo/app/global/db/AppDatabase.kt | 9 +- .../AndroidBrowserConfigFeature.kt | 8 + .../OptimizeTrackerEvaluationRCWrapper.kt | 32 ++ .../app/privacy/model/TrustedSites.kt | 5 + .../duckduckgo/app/trackerdetection/Client.kt | 20 + .../app/trackerdetection/TdsClient.kt | 113 +++++ .../app/trackerdetection/TdsEntityLookup.kt | 14 + .../app/trackerdetection/TrackerDetector.kt | 192 ++++++++ .../duckduckgo/app/global/UriStringTest.kt | 76 ++++ .../app/trackerdetection/TdsClientTest.kt | 423 ++++++++++++++++++ .../TrackerDetectorClientTypeTest.kt | 7 +- .../trackerdetection/TrackerDetectorTest.kt | 297 ++++++++++++ .../com/duckduckgo/app/global/model/Site.kt | 5 + .../app/trackerdetection/EntityLookup.kt | 4 + .../com/duckduckgo/common/utils/UriString.kt | 27 ++ 25 files changed, 1412 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/OptimizeTrackerEvaluationRCWrapper.kt diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 755ca692e3aa..845933932644 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -52,6 +52,7 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler import com.duckduckgo.app.browser.print.PrintInjector import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.pixels.remoteconfig.OptimizeTrackerEvaluationRCWrapper import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.BrowserAutofill @@ -67,6 +68,7 @@ import com.duckduckgo.privacy.config.api.AmpLinks import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Before @@ -117,6 +119,7 @@ class BrowserWebViewClientTest { private val currentTimeProvider: CurrentTimeProvider = mock() private val deviceInfo: DeviceInfo = mock() private val pageLoadedHandler: PageLoadedHandler = mock() + private val optimizeTrackerEvaluationRCWrapper = TestOptimizeTrackerEvaluationRCWrapper() @UiThreadTest @Before @@ -145,6 +148,7 @@ class BrowserWebViewClientTest { jsPlugins, currentTimeProvider, pageLoadedHandler, + optimizeTrackerEvaluationRCWrapper, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -247,6 +251,17 @@ class BrowserWebViewClientTest { verify(loginDetector).onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) } + @UiThreadTest + @Test + fun whenShouldInterceptRequestAndOptimizeEnabledThenShouldInterceptWithUri() { + TestScope().launch { + val webResourceRequest = mock() + optimizeTrackerEvaluationRCWrapper.value = true + testee.shouldInterceptRequest(webView, webResourceRequest) + verify(requestInterceptor).shouldIntercept(any(), any(), any(), any()) + } + } + @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) fun whenRenderProcessGoneDueToCrashThenCrashDataStoreEntryIsIncremented() { @@ -870,6 +885,13 @@ class BrowserWebViewClientTest { override fun clone(): WebHistoryItem = throw NotImplementedError() } + private class TestOptimizeTrackerEvaluationRCWrapper : OptimizeTrackerEvaluationRCWrapper { + + var value = false + override val enabled: Boolean + get() = value + } + companion object { const val EXAMPLE_URL = "example.com" } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt index 2e94f374cc16..be83e41f3927 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt @@ -699,12 +699,12 @@ class WebViewRequestInterceptorTest { surrogateId = "testId", ) whenever(mockRequest.isForMainFrame).thenReturn(false) - whenever(mockTrackerDetector.evaluate(any(), any(), eq(true), anyMap())).thenReturn(trackingEvent) + whenever(mockTrackerDetector.evaluate(anyString(), anyString(), eq(true), anyMap())).thenReturn(trackingEvent) } private fun configureNull() { whenever(mockRequest.isForMainFrame).thenReturn(false) - whenever(mockTrackerDetector.evaluate(any(), any(), eq(true), anyMap())).thenReturn(null) + whenever(mockTrackerDetector.evaluate(anyString(), anyString(), eq(true), anyMap())).thenReturn(null) } private fun configureBlockedCnameTrackingEvent() { @@ -726,7 +726,7 @@ class WebViewRequestInterceptorTest { surrogateId = null, ) whenever(mockRequest.isForMainFrame).thenReturn(false) - whenever(mockTrackerDetector.evaluate(any(), any(), eq(false), anyMap())).thenReturn(trackingEvent) + whenever(mockTrackerDetector.evaluate(anyString(), anyString(), eq(false), anyMap())).thenReturn(trackingEvent) } private fun configureUrlExistsInTheStack(uri: Uri = validUri()) { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandlerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandlerTest.kt index 5d4edb9a04ab..2caed318ba4d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandlerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandlerTest.kt @@ -1,5 +1,6 @@ package com.duckduckgo.app.browser.pageloadpixel +import com.duckduckgo.app.pixels.remoteconfig.OptimizeTrackerEvaluationRCWrapper import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.device.DeviceInfo @@ -25,12 +26,18 @@ class PageLoadedHandlerTest { private val deviceInfo: DeviceInfo = mock() private val webViewVersionProvider: WebViewVersionProvider = mock() private val pageLoadedPixelDao: PageLoadedPixelDao = mock() + private val optimizeTrackerEvaluationRCWrapper = object : OptimizeTrackerEvaluationRCWrapper { + override val enabled: Boolean + get() = true + } + private val testee = RealPageLoadedHandler( deviceInfo, webViewVersionProvider, pageLoadedPixelDao, TestScope(), coroutinesTestRule.testDispatcherProvider, + optimizeTrackerEvaluationRCWrapper, ) @Before @@ -45,6 +52,7 @@ class PageLoadedHandlerTest { val argumentCaptor = argumentCaptor() verify(pageLoadedPixelDao).add(argumentCaptor.capture()) Assert.assertEquals(10L, argumentCaptor.firstValue.elapsedTime) + Assert.assertEquals(true, argumentCaptor.firstValue.trackerOptimizationEnabled) } @Test 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 393844454182..468d15b87d11 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -1844,6 +1844,13 @@ class BrowserTabViewModel @Inject constructor( } } + override fun pageHasHttpResources(page: Uri) { + if (site?.domainMatchesUrl(page) == true) { + site?.hasHttpResources = true + onSiteChanged() + } + } + override fun onCertificateReceived(certificate: SslCertificate?) { site?.certificate = certificate } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 4316a6363b05..6b6340f1fa8d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -52,6 +52,7 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler import com.duckduckgo.app.browser.print.PrintInjector import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.pixels.remoteconfig.OptimizeTrackerEvaluationRCWrapper import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.autoconsent.api.Autoconsent import com.duckduckgo.autofill.api.BrowserAutofill @@ -92,6 +93,7 @@ class BrowserWebViewClient @Inject constructor( private val jsPlugins: PluginPoint, private val currentTimeProvider: CurrentTimeProvider, private val shouldSendPageLoadedPixel: PageLoadedHandler, + private val optimizeTrackerEvaluationRCWrapper: OptimizeTrackerEvaluationRCWrapper, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -352,7 +354,11 @@ class BrowserWebViewClient @Inject constructor( loginDetector.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, request)) } Timber.v("Intercepting resource ${request.url} type:${request.method} on page $documentUrl") - requestInterceptor.shouldIntercept(request, webView, documentUrl, webViewClientListener) + if (optimizeTrackerEvaluationRCWrapper.enabled) { + requestInterceptor.shouldIntercept(request, webView, documentUrl?.toUri(), webViewClientListener) + } else { + requestInterceptor.shouldIntercept(request, webView, documentUrl, webViewClientListener) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index d7d69731e130..b224d835ed5a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -52,6 +52,7 @@ interface WebViewClientListener { fun titleReceived(newTitle: String) fun trackerDetected(event: TrackingEvent) fun pageHasHttpResources(page: String) + fun pageHasHttpResources(page: Uri) fun onCertificateReceived(certificate: SslCertificate?) fun sendEmailRequested(emailAddress: String) diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt index be61010d36d6..fc28a7d59e3d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -50,6 +50,14 @@ interface RequestInterceptor { webViewClientListener: WebViewClientListener?, ): WebResourceResponse? + @WorkerThread + suspend fun shouldIntercept( + request: WebResourceRequest, + webView: WebView, + documentUri: Uri?, + webViewClientListener: WebViewClientListener?, + ): WebResourceResponse? + @WorkerThread suspend fun shouldInterceptFromServiceWorker( request: WebResourceRequest?, @@ -141,6 +149,71 @@ class WebViewRequestInterceptor( return getWebResourceResponse(request, documentUrl, webViewClientListener) } + /** + * Notify the application of a resource request and allow the application to return the data. + * + * If the return value is null, the WebView will continue to load the resource as usual. + * Otherwise, the return response and data will be used. + * + * NOTE: This method is called on a thread other than the UI thread so clients should exercise + * caution when accessing private data or the view system. + */ + @WorkerThread + override suspend fun shouldIntercept( + request: WebResourceRequest, + webView: WebView, + documentUri: Uri?, + webViewClientListener: WebViewClientListener?, + ): WebResourceResponse? { + val url = request.url + + if (requestFilterer.shouldFilterOutRequest(request, documentUri.toString())) return WebResourceResponse(null, null, null) + + adClickManager.detectAdClick(url?.toString(), request.isForMainFrame) + + newUserAgent(request, webView, webViewClientListener)?.let { + withContext(dispatchers.main()) { + webView.settings?.userAgentString = it + webView.loadUrl(url.toString(), getHeaders(request)) + } + return WebResourceResponse(null, null, null) + } + + if (appUrlPixel(url)) return null + + if (shouldUpgrade(request)) { + val newUri = httpsUpgrader.upgrade(url) + + withContext(dispatchers.main()) { + webView.loadUrl(newUri.toString(), getHeaders(request)) + } + + webViewClientListener?.upgradedToHttps() + privacyProtectionCountDao.incrementUpgradeCount() + return WebResourceResponse(null, null, null) + } + + if (shouldAddGcpHeaders(request) && !requestWasInTheStack(url, webView)) { + withContext(dispatchers.main()) { + webViewClientListener?.redirectTriggeredByGpc() + webView.loadUrl(url.toString(), getHeaders(request)) + } + return WebResourceResponse(null, null, null) + } + + if (documentUri == null) return null + + if (TrustedSites.isTrusted(documentUri)) { + return null + } + + if (url != null && url.isHttp) { + webViewClientListener?.pageHasHttpResources(documentUri) + } + + return getWebResourceResponse(request, documentUri, webViewClientListener) + } + override suspend fun shouldInterceptFromServiceWorker( request: WebResourceRequest?, documentUrl: String?, @@ -157,7 +230,7 @@ class WebViewRequestInterceptor( private fun getWebResourceResponse( request: WebResourceRequest, - documentUrl: String?, + documentUrl: String, webViewClientListener: WebViewClientListener?, ): WebResourceResponse? { val trackingEvent = trackingEvent(request, documentUrl, webViewClientListener) @@ -178,6 +251,29 @@ class WebViewRequestInterceptor( return null } + private fun getWebResourceResponse( + request: WebResourceRequest, + documentUrl: Uri, + webViewClientListener: WebViewClientListener?, + ): WebResourceResponse? { + val trackingEvent = trackingEvent(request, documentUrl, webViewClientListener) + if (trackingEvent?.status == TrackerStatus.BLOCKED) { + return blockRequest(trackingEvent, request, webViewClientListener) + } else if (trackingEvent == null || + trackingEvent.status == TrackerStatus.ALLOWED || + trackingEvent.status == TrackerStatus.SAME_ENTITY_ALLOWED + ) { + cloakedCnameDetector.detectCnameCloakedHost(documentUrl.toString(), request.url)?.let { uncloakedHost -> + trackingEvent(request, documentUrl, webViewClientListener, false, uncloakedHost)?.let { cloakedTrackingEvent -> + if (cloakedTrackingEvent.status == TrackerStatus.BLOCKED) { + return blockRequest(cloakedTrackingEvent, request, webViewClientListener) + } + } + } + } + return null + } + private fun blockRequest( trackingEvent: TrackingEvent, request: WebResourceRequest, @@ -242,6 +338,22 @@ class WebViewRequestInterceptor( private fun shouldUpgrade(request: WebResourceRequest) = request.isForMainFrame && request.url != null && httpsUpgrader.shouldUpgrade(request.url) + private fun trackingEvent( + request: WebResourceRequest, + documentUrl: Uri?, + webViewClientListener: WebViewClientListener?, + checkFirstParty: Boolean = true, + ): TrackingEvent? { + val url = request.url + if (request.isForMainFrame || documentUrl == null) { + return null + } + + val trackingEvent = trackerDetector.evaluate(url, documentUrl, checkFirstParty, request.requestHeaders) ?: return null + webViewClientListener?.trackerDetected(trackingEvent) + return trackingEvent + } + private fun trackingEvent( request: WebResourceRequest, documentUrl: String?, @@ -258,6 +370,22 @@ class WebViewRequestInterceptor( return trackingEvent } + private fun trackingEvent( + request: WebResourceRequest, + documentUrl: Uri?, + webViewClientListener: WebViewClientListener?, + checkFirstParty: Boolean = true, + url: String = request.url.toString(), + ): TrackingEvent? { + if (request.isForMainFrame || documentUrl == null) { + return null + } + + val trackingEvent = trackerDetector.evaluate(url, documentUrl, checkFirstParty, request.requestHeaders) ?: return null + webViewClientListener?.trackerDetected(trackingEvent) + return trackingEvent + } + private fun appUrlPixel(url: Uri?): Boolean = url?.toString()?.startsWith(AppUrl.Url.PIXEL) == true } diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandler.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandler.kt index fcdea4f34042..3357875d81f5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandler.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedHandler.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.browser.pageloadpixel import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.pixels.remoteconfig.OptimizeTrackerEvaluationRCWrapper import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.UriString @@ -50,6 +51,7 @@ class RealPageLoadedHandler @Inject constructor( private val pageLoadedPixelDao: PageLoadedPixelDao, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val optimizeTrackerEvaluationRCWrapper: OptimizeTrackerEvaluationRCWrapper, ) : PageLoadedHandler { override operator fun invoke(url: String, start: Long, end: Long) { @@ -60,6 +62,7 @@ class RealPageLoadedHandler @Inject constructor( elapsedTime = end - start, webviewVersion = webViewVersionProvider.getMajorVersion(), appVersion = deviceInfo.appVersion, + trackerOptimizationEnabled = optimizeTrackerEvaluationRCWrapper.enabled, ), ) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt index ccedf97905d4..3b02e3f9d22c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt @@ -26,6 +26,7 @@ import javax.inject.Inject private const val ELAPSED_TIME = "elapsed_time" private const val WEBVIEW_VERSION = "webview_version" +private const val TRACKER_OPTIMIZATION_ENABLED = "tracker_optimization_enabled" // This is used to ensure the app version we send is the one from the moment the page was loaded, and not then the pixel is fired later on private const val APP_VERSION = "app_version_when_page_loaded" @@ -45,6 +46,7 @@ class PageLoadedOfflinePixelSender @Inject constructor( APP_VERSION to it.appVersion, ELAPSED_TIME to it.elapsedTime.toString(), WEBVIEW_VERSION to it.webviewVersion, + TRACKER_OPTIMIZATION_ENABLED to it.trackerOptimizationEnabled.toString(), ), mapOf(), ).doOnComplete { diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedPixelEntity.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedPixelEntity.kt index 36a1e6cd6bc9..22ac4f395c24 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedPixelEntity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedPixelEntity.kt @@ -25,4 +25,5 @@ class PageLoadedPixelEntity( val appVersion: String, val elapsedTime: Long, val webviewVersion: String, + val trackerOptimizationEnabled: Boolean, ) 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 2f9c584f17b6..28e20e7608a3 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 @@ -68,7 +68,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao @Database( exportSchema = true, - version = 50, + version = 51, entities = [ TdsTracker::class, TdsEntity::class, @@ -629,6 +629,12 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa } } + private val MIGRATION_50_TO_51: Migration = object : Migration(50, 51) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `page_loaded_pixel_entity` ADD COLUMN `trackerOptimizationEnabled` INTEGER NOT NULL DEFAULT 0") + } + } + val BOOKMARKS_DB_ON_CREATE = object : RoomDatabase.Callback() { override fun onCreate(database: SupportSQLiteDatabase) { database.execSQL( @@ -704,6 +710,7 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa MIGRATION_47_TO_48, MIGRATION_48_TO_49, MIGRATION_49_TO_50, + MIGRATION_50_TO_51, ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt index 2982cae37f59..5c9a0af71446 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt @@ -51,4 +51,12 @@ interface AndroidBrowserConfigFeature { */ @Toggle.DefaultValue(false) fun screenLock(): Toggle + + /** + * @return `true` when the remote config has the global "optimizeTrackerEvaluation" androidBrowserConfig + * sub-feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun optimizeTrackerEvaluation(): Toggle } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/OptimizeTrackerEvaluationRCWrapper.kt b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/OptimizeTrackerEvaluationRCWrapper.kt new file mode 100644 index 000000000000..1b0781d32ff9 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/OptimizeTrackerEvaluationRCWrapper.kt @@ -0,0 +1,32 @@ +/* + * 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.app.pixels.remoteconfig + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface OptimizeTrackerEvaluationRCWrapper { + val enabled: Boolean +} + +@ContributesBinding(AppScope::class) +class RealOptimizeTrackerEvaluationRCWrapper @Inject constructor( + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, +) : OptimizeTrackerEvaluationRCWrapper { + override val enabled by lazy { androidBrowserConfigFeature.optimizeTrackerEvaluation().isEnabled() } +} diff --git a/app/src/main/java/com/duckduckgo/app/privacy/model/TrustedSites.kt b/app/src/main/java/com/duckduckgo/app/privacy/model/TrustedSites.kt index 0f28559f876d..b4e9c62c6bc3 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/model/TrustedSites.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/model/TrustedSites.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.privacy.model +import android.net.Uri import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.UriString @@ -36,5 +37,9 @@ class TrustedSites { fun isTrusted(url: String): Boolean { return trusted.any { UriString.sameOrSubdomain(url, it) } } + + fun isTrusted(url: Uri): Boolean { + return trusted.any { UriString.sameOrSubdomain(url, it) } + } } } diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/Client.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/Client.kt index c46ae63c4dd2..56bc57d5fc6f 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/Client.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/Client.kt @@ -16,6 +16,8 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri + interface Client { enum class ClientType { @@ -48,4 +50,22 @@ interface Client { documentUrl: String, requestHeaders: Map, ): Result + + fun matches( + url: String, + documentUrl: Uri, + requestHeaders: Map, + ): Result + + fun matches( + url: Uri, + documentUrl: String, + requestHeaders: Map, + ): Result + + fun matches( + url: Uri, + documentUrl: Uri, + requestHeaders: Map, + ): Result } diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsClient.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsClient.kt index 181633278c54..3c4f58bb4d8e 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsClient.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsClient.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.core.net.toUri import com.duckduckgo.app.trackerdetection.model.Action.BLOCK import com.duckduckgo.app.trackerdetection.model.Action.IGNORE @@ -46,6 +47,54 @@ class TdsClient( ) } + override fun matches( + url: String, + documentUrl: Uri, + requestHeaders: Map, + ): Client.Result { + val tracker = trackers.firstOrNull { sameOrSubdomain(url, it.domain) } ?: return Client.Result(matches = false, isATracker = false) + val matches = matchesTrackerEntry(tracker, url, documentUrl, requestHeaders) + return Client.Result( + matches = matches.shouldBlock, + entityName = tracker.ownerName, + categories = tracker.categories, + surrogate = matches.surrogate, + isATracker = matches.isATracker, + ) + } + + override fun matches( + url: Uri, + documentUrl: String, + requestHeaders: Map, + ): Client.Result { + val tracker = trackers.firstOrNull { sameOrSubdomain(url, it.domain) } ?: return Client.Result(matches = false, isATracker = false) + val matches = matchesTrackerEntry(tracker, url.toString(), documentUrl, requestHeaders) + return Client.Result( + matches = matches.shouldBlock, + entityName = tracker.ownerName, + categories = tracker.categories, + surrogate = matches.surrogate, + isATracker = matches.isATracker, + ) + } + + override fun matches( + url: Uri, + documentUrl: Uri, + requestHeaders: Map, + ): Client.Result { + val tracker = trackers.firstOrNull { sameOrSubdomain(url, it.domain) } ?: return Client.Result(matches = false, isATracker = false) + val matches = matchesTrackerEntry(tracker, url.toString(), documentUrl, requestHeaders) + return Client.Result( + matches = matches.shouldBlock, + entityName = tracker.ownerName, + categories = tracker.categories, + surrogate = matches.surrogate, + isATracker = matches.isATracker, + ) + } + private fun matchesTrackerEntry( tracker: TdsTracker, url: String, @@ -87,6 +136,47 @@ class TdsClient( return MatchedResult(shouldBlock = (tracker.defaultAction == BLOCK), isATracker = true) } + private fun matchesTrackerEntry( + tracker: TdsTracker, + url: String, + documentUrl: Uri, + requestHeaders: Map, + ): MatchedResult { + tracker.rules.forEach { rule -> + val regex = ".*${rule.rule}.*".toRegex() + if (url.matches(regex)) { + val type = urlToTypeMapper.map(url, requestHeaders) + + if (rule.options != null) { + if (!matchedDomainAndTypes(rule.options.domains, rule.options.types, documentUrl, type)) { + // Continue to the next rule instead + return@forEach + } + } + + if (rule.exceptions != null) { + if (matchedDomainAndTypes(rule.exceptions.domains, rule.exceptions.types, documentUrl, type)) { + return MatchedResult(shouldBlock = false, isATracker = true) + } + } + + if (rule.action == IGNORE) { + return MatchedResult(shouldBlock = false, isATracker = true) + } + + if (rule.surrogate?.isNotEmpty() == true) { + return MatchedResult(shouldBlock = true, surrogate = rule.surrogate, isATracker = true) + } + // Null means no action which we should default to block + if (rule.action == BLOCK || rule.action == null) { + return MatchedResult(shouldBlock = true, isATracker = true) + } + } + } + + return MatchedResult(shouldBlock = (tracker.defaultAction == BLOCK), isATracker = true) + } + private fun matchedDomainAndTypes( ruleDomains: List?, ruleTypes: List?, @@ -110,6 +200,29 @@ class TdsClient( } } + private fun matchedDomainAndTypes( + ruleDomains: List?, + ruleTypes: List?, + documentUrl: Uri, + type: String?, + ): Boolean { + val matchesDomain = ruleDomains?.any { domain -> sameOrSubdomain(documentUrl, domain) } + val matchesType = ruleTypes?.contains(type) + + return when { + ruleTypes.isNullOrEmpty() && matchesDomain == true -> { + true + } + ruleDomains.isNullOrEmpty() && matchesType == true -> { + true + } + matchesDomain == true && matchesType == true -> { + true + } + else -> false + } + } + private fun removePortFromUrl(url: String): String { return try { val uri = url.toUri() diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsEntityLookup.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsEntityLookup.kt index 6eb8058e0117..29daa12a78dc 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsEntityLookup.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/TdsEntityLookup.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.annotation.WorkerThread import androidx.core.net.toUri import com.duckduckgo.app.global.uri.removeSubdomain @@ -50,6 +51,19 @@ class TdsEntityLookup @Inject constructor( return entityForUrl(parentDomain) } + @WorkerThread + override fun entityForUrl(uri: Uri): Entity? { + val host = uri.host ?: return null + + // try searching for exact domain + val direct = lookUpEntityInDatabase(host) + if (direct != null) return direct + + // remove the first subdomain, and try again + val parentDomain = uri.removeSubdomain() ?: return null + return entityForUrl(parentDomain.toUri()) + } + @WorkerThread override fun entityForName(name: String): Entity? { return entityDao.get(name) diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt index 014c0bbaddc0..563adc194d60 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import com.duckduckgo.adclick.api.AdClickManager @@ -23,27 +24,51 @@ import com.duckduckgo.app.privacy.db.UserAllowListDao import com.duckduckgo.app.trackerdetection.Client.ClientType.BLOCKING import com.duckduckgo.app.trackerdetection.db.WebTrackerBlocked import com.duckduckgo.app.trackerdetection.db.WebTrackersBlockedDao +import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.common.utils.UriString.Companion.sameOrSubdomain +import com.duckduckgo.common.utils.UriString.Companion.sameOrSubdomainPair import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.TrackerAllowlist import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn +import java.net.URI import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import timber.log.Timber interface TrackerDetector { fun addClient(client: Client) + fun evaluate( + url: Uri, + documentUrl: String, + checkFirstParty: Boolean = true, + requestHeaders: Map, + ): TrackingEvent? + + fun evaluate( + url: Uri, + documentUrl: Uri, + checkFirstParty: Boolean = true, + requestHeaders: Map, + ): TrackingEvent? + fun evaluate( url: String, documentUrl: String, checkFirstParty: Boolean = true, requestHeaders: Map, ): TrackingEvent? + + fun evaluate( + url: String, + documentUrl: Uri, + checkFirstParty: Boolean = true, + requestHeaders: Map, + ): TrackingEvent? } @ContributesBinding(AppScope::class) @@ -67,6 +92,82 @@ class TrackerDetectorImpl @Inject constructor( clients.add(client) } + override fun evaluate( + url: Uri, + documentUrl: String, + checkFirstParty: Boolean, + requestHeaders: Map, + ): TrackingEvent? { + val cleanedUrl = removePortFromUrl(url) + val urlString = url.toString() + + if (checkFirstParty && firstParty(cleanedUrl, documentUrl)) { + Timber.v("$url is a first party url") + return null + } + + val result = clients + .filter { it.name.type == BLOCKING } + .firstNotNullOfOrNull { it.matches(cleanedUrl, documentUrl, requestHeaders) } ?: Client.Result(matches = false, isATracker = false) + + val sameEntity = sameNetworkName(url, documentUrl) + val entity = if (result.entityName != null) entityLookup.entityForName(result.entityName) else entityLookup.entityForUrl(url) + val isDocumentInAllowedList = userAllowListDao.isDocumentAllowListed(documentUrl) + + return evaluate(documentUrl, urlString, result, sameEntity, isDocumentInAllowedList, entity) + } + + override fun evaluate( + url: Uri, + documentUrl: Uri, + checkFirstParty: Boolean, + requestHeaders: Map, + ): TrackingEvent? { + val cleanedUrl = removePortFromUrl(url) + val urlString = url.toString() + val documentUrlString = documentUrl.toString() + + if (checkFirstParty && firstParty(cleanedUrl, documentUrl)) { + Timber.v("$url is a first party url") + return null + } + + val result = clients + .filter { it.name.type == BLOCKING } + .firstNotNullOfOrNull { it.matches(cleanedUrl, documentUrl, requestHeaders) } ?: Client.Result(matches = false, isATracker = false) + + val sameEntity = sameNetworkName(url, documentUrl) + val entity = if (result.entityName != null) entityLookup.entityForName(result.entityName) else entityLookup.entityForUrl(url) + val isDocumentInAllowedList = userAllowListDao.isDocumentAllowListed(documentUrl) + + return evaluate(documentUrlString, urlString, result, sameEntity, isDocumentInAllowedList, entity) + } + + override fun evaluate( + url: String, + documentUrl: Uri, + checkFirstParty: Boolean, + requestHeaders: Map, + ): TrackingEvent? { + val cleanedUrl = removePortFromUrl(url) + val documentUrlString = documentUrl.toString() + + if (checkFirstParty && firstParty(documentUrl, cleanedUrl)) { + Timber.v("$url is a first party url") + return null + } + + val result = clients + .filter { it.name.type == BLOCKING } + .firstNotNullOfOrNull { it.matches(cleanedUrl, documentUrl, requestHeaders) } ?: Client.Result(matches = false, isATracker = false) + + val sameEntity = sameNetworkName(documentUrl, url) + val entity = if (result.entityName != null) entityLookup.entityForName(result.entityName) else entityLookup.entityForUrl(url) + val isDocumentInAllowedList = userAllowListDao.isDocumentAllowListed(documentUrl) + + return evaluate(documentUrlString, url, result, sameEntity, isDocumentInAllowedList, entity) + } + override fun evaluate( url: String, documentUrl: String, @@ -112,12 +213,78 @@ class TrackerDetectorImpl @Inject constructor( return TrackingEvent(documentUrl, url, result.categories, entity, result.surrogate, status, type) } + private fun evaluate( + documentUrlString: String, + urlString: String, + result: Client.Result, + sameEntity: Boolean, + isDocumentInAllowedList: Boolean, + entity: Entity?, + ): TrackingEvent { + val isSiteAContentBlockingException = contentBlocking.isAnException(documentUrlString) + val isInAdClickAllowList = adClickManager.isExemption(documentUrlString, urlString) + val isInTrackerAllowList = trackerAllowlist.isAnException(documentUrlString, urlString) + val isATrackerAllowed = result.isATracker && !result.matches + val shouldBlock = result.matches && !isSiteAContentBlockingException && !isInTrackerAllowList && !isInAdClickAllowList && !sameEntity + + val status = when { + sameEntity -> TrackerStatus.SAME_ENTITY_ALLOWED + isDocumentInAllowedList -> TrackerStatus.USER_ALLOWED + shouldBlock -> TrackerStatus.BLOCKED + isInAdClickAllowList -> TrackerStatus.AD_ALLOWED + isInTrackerAllowList || isATrackerAllowed -> TrackerStatus.SITE_BREAKAGE_ALLOWED + else -> TrackerStatus.ALLOWED + } + + val type = if (isInAdClickAllowList) TrackerType.AD else TrackerType.OTHER + + if (status == TrackerStatus.BLOCKED) { + val trackerCompany = entity?.displayName ?: "Undefined" + webTrackersBlockedDao.insert(WebTrackerBlocked(trackerUrl = urlString, trackerCompany = trackerCompany)) + } + + Timber.v("$documentUrlString resource $urlString WAS identified as a tracker and status=$status") + + return TrackingEvent(documentUrlString, urlString, result.categories, entity, result.surrogate, status, type) + } + + private fun removePortFromUrl(uri: Uri): Uri { + return if (uri.port != -1) { + uri.buildUpon() + .authority(uri.host) + .build() + } else { + uri + } + } + + private fun removePortFromUrl(url: String): String { + return try { + val uri = Uri.parse(url) + URI(uri.scheme, uri.host, uri.path, uri.fragment).toString() + } catch (e: Exception) { + url + } + } + private fun firstParty( firstUrl: String, secondUrl: String, ): Boolean = sameOrSubdomain(firstUrl, secondUrl) || sameOrSubdomain(secondUrl, firstUrl) + private fun firstParty( + firstUrl: Uri, + secondUrl: String, + ): Boolean = + sameOrSubdomainPair(firstUrl, secondUrl) + + private fun firstParty( + firstUrl: Uri, + secondUrl: Uri, + ): Boolean = + sameOrSubdomainPair(firstUrl, secondUrl) + private fun sameNetworkName( url: String, documentUrl: String, @@ -127,6 +294,24 @@ class TrackerDetectorImpl @Inject constructor( return firstNetwork.name == secondNetwork.name } + private fun sameNetworkName( + first: Uri, + second: String, + ): Boolean { + val firstNetwork = entityLookup.entityForUrl(first) ?: return false + val secondNetwork = entityLookup.entityForUrl(second) ?: return false + return firstNetwork.name == secondNetwork.name + } + + private fun sameNetworkName( + url: Uri, + documentUrl: Uri, + ): Boolean { + val firstNetwork = entityLookup.entityForUrl(url) ?: return false + val secondNetwork = entityLookup.entityForUrl(documentUrl) ?: return false + return firstNetwork.name == secondNetwork.name + } + @VisibleForTesting val clientCount get() = clients.count() @@ -138,3 +323,10 @@ private fun UserAllowListDao.isDocumentAllowListed(document: String?): Boolean { } return false } + +private fun UserAllowListDao.isDocumentAllowListed(document: Uri?): Boolean { + document?.host?.let { + return contains(it) + } + return false +} diff --git a/app/src/test/java/com/duckduckgo/app/global/UriStringTest.kt b/app/src/test/java/com/duckduckgo/app/global/UriStringTest.kt index 6afdda0ca3aa..7d527552bd6e 100644 --- a/app/src/test/java/com/duckduckgo/app/global/UriStringTest.kt +++ b/app/src/test/java/com/duckduckgo/app/global/UriStringTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.global +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.utils.UriString.Companion.isWebUrl import com.duckduckgo.common.utils.UriString.Companion.sameOrSubdomain @@ -32,26 +33,101 @@ class UriStringTest { assertTrue(sameOrSubdomain("http://example.com/index.html", "http://example.com/home.html")) } + @Test + fun whenUrlsHaveSameDomainThenSameOrSubdomainIsTrue2() { + assertTrue(sameOrSubdomain("http://example.com/index.html".toUri(), "http://example.com/home.html")) + } + @Test fun whenUrlIsSubdomainThenSameOrSubdomainIsTrue() { assertTrue(sameOrSubdomain("http://subdomain.example.com/index.html", "http://example.com/home.html")) } + @Test + fun whenUrlIsSubdomainThenSameOrSubdomainIsTrue2() { + assertTrue(sameOrSubdomain("http://subdomain.example.com/index.html".toUri(), "http://example.com/home.html")) + } + @Test fun whenUrlIsAParentDomainThenSameOrSubdomainIsFalse() { assertFalse(sameOrSubdomain("http://example.com/index.html", "http://parent.example.com/home.html")) } + @Test + fun whenUrlIsAParentDomainThenSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("http://example.com/index.html".toUri(), "http://parent.example.com/home.html")) + } + @Test fun whenChildUrlIsMalformedThenSameOrSubdomainIsFalse() { assertFalse(sameOrSubdomain("??.example.com/index.html", "http://example.com/home.html")) } + @Test + fun whenChildUrlIsMalformedThenSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("??.example.com/index.html".toUri(), "http://example.com/home.html")) + } + @Test fun whenParentUrlIsMalformedThenSameOrSubdomainIsFalse() { assertFalse(sameOrSubdomain("http://example.com/index.html", "??.example.com/home.html")) } + @Test + fun whenParentUrlIsMalformedThenSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("http://example.com/index.html".toUri(), "??.example.com/home.html")) + } + + @Test + fun whenUrlsHaveSameDomainThenSafeSameOrSubdomainIsTrue() { + assertTrue(sameOrSubdomain("http://example.com/index.html", "http://example.com/home.html")) + } + + @Test + fun whenUrlsHaveSameDomainThenSafeSameOrSubdomainIsTrue2() { + assertTrue(sameOrSubdomain("http://example.com/index.html".toUri(), "http://example.com/home.html")) + } + + @Test + fun whenUrlIsSubdomainThenSafeSameOrSubdomainIsTrue() { + assertTrue(sameOrSubdomain("http://subdomain.example.com/index.html", "http://example.com/home.html")) + } + + @Test + fun whenUrlIsSubdomainThenSafeSameOrSubdomainIsTrue2() { + assertTrue(sameOrSubdomain("http://subdomain.example.com/index.html".toUri(), "http://example.com/home.html")) + } + + @Test + fun whenUrlIsAParentDomainThenSafeSameOrSubdomainIsFalse() { + assertFalse(sameOrSubdomain("http://example.com/index.html", "http://parent.example.com/home.html")) + } + + @Test + fun whenUrlIsAParentDomainThenSafeSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("http://example.com/index.html".toUri(), "http://parent.example.com/home.html")) + } + + @Test + fun whenChildUrlIsMalformedThenSafeSameOrSubdomainIsFalse() { + assertFalse(sameOrSubdomain("??.example.com/index.html", "http://example.com/home.html")) + } + + @Test + fun whenChildUrlIsMalformedThenSafeSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("??.example.com/index.html".toUri(), "http://example.com/home.html")) + } + + @Test + fun whenParentUrlIsMalformedThenSafeSameOrSubdomainIsFalse() { + assertFalse(sameOrSubdomain("http://example.com/index.html", "??.example.com/home.html")) + } + + @Test + fun whenParentUrlIsMalformedThenSafeSameOrSubdomainIsFalse2() { + assertFalse(sameOrSubdomain("http://example.com/index.html".toUri(), "??.example.com/home.html")) + } + @Test fun whenUserIsPresentThenIsWebUrlIsFalse() { val input = "http://example.com@sample.com" diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/TdsClientTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/TdsClientTest.kt index fab27c46230a..f1877f346702 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/TdsClientTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/TdsClientTest.kt @@ -16,6 +16,8 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.trackerdetection.Client.ClientName.TDS import com.duckduckgo.app.trackerdetection.model.Action.BLOCK @@ -47,6 +49,14 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlHasSameDomainAsTrackerEntryAndDefaultActionBlockThenMatchesIsTrue2() { + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://tracker.com/script.js"), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlHasSameDomainAsTrackerEntryAndDefaultActionIgnoreThenMatchesIsFalse() { val data = listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, emptyList())) @@ -55,6 +65,14 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlHasSameDomainAsTrackerEntryAndDefaultActionIgnoreThenMatchesIsFalse2() { + val data = listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, emptyList())) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://tracker.com/script.js"), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlIsSubdomainOfTrackerEntryAndDefaultActionBlockThenMatchesIsTrue() { val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) @@ -71,6 +89,14 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlIsNotDomainOrSubDomainOfTrackerEntryThenMatchesIsFalse2() { + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://nontracker.com/script.js"), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlIsAParentDomainOfATrackerEntryThenMatchesIsFalse() { val data = listOf(TdsTracker("subdomain.tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) @@ -79,6 +105,14 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlIsAParentDomainOfATrackerEntryThenMatchesIsFalse2() { + val data = listOf(TdsTracker("subdomain.tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://tracker.com/script.js"), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlContainsButIsNotSubdomainOfATrackerEntryThenMatchesIsFalse() { val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) @@ -87,6 +121,14 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlContainsButIsNotSubdomainOfATrackerEntryThenMatchesIsFalse2() { + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, emptyList())) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://notsubdomainoftracker.com"), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithNoExceptionsAndRuleActionBlockThenMatchesIsTrue() { val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, null, null, null) @@ -96,6 +138,15 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesRuleWithNoExceptionsAndRuleActionBlockThenMatchesIsTrue2() { + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, null, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://api.tracker.com/auth/script.js"), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesRuleWithNoExceptionsAndRuleActionIgnoreThenMatchesIsFalse() { val rule = Rule("api\\.tracker\\.com\\/auth", IGNORE, null, null, null) @@ -105,6 +156,15 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithNoExceptionsAndRuleActionIgnoreThenMatchesIsFalse2() { + val rule = Rule("api\\.tracker\\.com\\/auth", IGNORE, null, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://api.tracker.com/auth/script.js"), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesDomainWithDefaultBlockAndRuleWithNoExceptionsAndNoActionThenMatchesIsTrue() { val rule = Rule("api\\.tracker\\.com\\/auth", null, null, null, null) @@ -114,6 +174,15 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesDomainWithDefaultBlockAndRuleWithNoExceptionsAndNoActionThenMatchesIsTrue2() { + val rule = Rule("api\\.tracker\\.com\\/auth", null, null, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://api.tracker.com/auth/script.js"), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesDomainWithDefaultIgnoreAndRuleWithNoExceptionsAndNoActionThenMatchesIsTrue() { val rule = Rule("api\\.tracker\\.com\\/auth", null, null, null, null) @@ -123,6 +192,15 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesDomainWithDefaultIgnoreAndRuleWithNoExceptionsAndNoActionThenMatchesIsTrue2() { + val rule = Rule("api\\.tracker\\.com\\/auth", null, null, null, null) + val data = listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches(Uri.parse("http://api.tracker.com/auth/script.js"), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesRuleWithExceptionsAndExceptionDomainMatchesDocumentThenMatchesIsFalseIrrespectiveOfAction() { val exceptions = RuleExceptions(listOf("example.com"), null) @@ -170,6 +248,53 @@ class TdsClientTest { assertFalse(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js", DOCUMENT_URL, mapOf()).matches) } + @Test + fun whenUrlMatchesRuleWithExceptionsAndExceptionDomainMatchesDocumentThenMatchesIsFalseIrrespectiveOfAction2() { + val exceptions = RuleExceptions(listOf("example.com"), null) + + val ruleBlock = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val ruleIgnore = Rule("api\\.tracker\\.com\\/auth", IGNORE, exceptions, null, null) + val ruleNone = Rule("api\\.tracker\\.com\\/auth", null, exceptions, null, null) + + val testeeBlockRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + + assertFalse(testeeBlockRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + } + @Test fun whenUrlMatchesRuleWithExceptionsAndExceptionDomainDoesNotMatchDocumentThenMatchesBehaviorIsStandard() { val exceptions = RuleExceptions(listOf("nonmatching.com"), null) @@ -217,6 +342,53 @@ class TdsClientTest { assertTrue(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js", DOCUMENT_URL, mapOf()).matches) } + @Test + fun whenUrlMatchesRuleWithExceptionsAndExceptionDomainDoesNotMatchDocumentThenMatchesBehaviorIsStandard2() { + val exceptions = RuleExceptions(listOf("nonmatching.com"), null) + + val ruleBlock = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val ruleIgnore = Rule("api\\.tracker\\.com\\/auth", IGNORE, exceptions, null, null) + val ruleNone = Rule("api\\.tracker\\.com\\/auth", null, exceptions, null, null) + + val testeeBlockRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + + assertTrue(testeeBlockRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeIgnoreRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeBlockRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + } + @Test fun whenUrlMatchesRuleWithExceptionsWithNoDomainsAndTypeMatchesExceptionThenMatchesIsFalseIrrespectiveOfAction() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("something") @@ -266,6 +438,55 @@ class TdsClientTest { assertFalse(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js", DOCUMENT_URL, mapOf()).matches) } + @Test + fun whenUrlMatchesRuleWithExceptionsWithNoDomainsAndTypeMatchesExceptionThenMatchesIsFalseIrrespectiveOfAction2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("something") + + val exceptions = RuleExceptions(null, listOf("something")) + + val ruleBlock = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val ruleIgnore = Rule("api\\.tracker\\.com\\/auth", IGNORE, exceptions, null, null) + val ruleNone = Rule("api\\.tracker\\.com\\/auth", null, exceptions, null, null) + + val testeeBlockRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + + assertFalse(testeeBlockRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + } + @Test fun whenUrlMatchesRuleWithSurrogateThenMatchesIsTrueIrrespectiveOfActionExceptIgnore() { val exceptions = RuleExceptions(null, null) @@ -313,6 +534,53 @@ class TdsClientTest { assertTrue(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js", DOCUMENT_URL, mapOf()).matches) } + @Test + fun whenUrlMatchesRuleWithSurrogateThenMatchesIsTrueIrrespectiveOfActionExceptIgnore2() { + val exceptions = RuleExceptions(null, null) + + val ruleBlock = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, "testId", null) + val ruleIgnore = Rule("api\\.tracker\\.com\\/auth", IGNORE, exceptions, "testId", null) + val ruleNone = Rule("api\\.tracker\\.com\\/auth", null, exceptions, "testId", null) + + val testeeBlockRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleBlock = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleBlock))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleIgnore = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleIgnore))), + mockUrlToTypeMapper, + ) + val testeeBlockRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + val testeeIgnoreRuleNone = TdsClient( + TDS, + listOf(TdsTracker("tracker.com", IGNORE, OWNER, CATEGORY, listOf(ruleNone))), + mockUrlToTypeMapper, + ) + + assertTrue(testeeBlockRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeIgnoreRuleBlock.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeBlockRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertFalse(testeeIgnoreRuleIgnore.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeBlockRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + assertTrue(testeeIgnoreRuleNone.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).matches) + } + @Test fun whenUrlMatchesRuleWithSurrogateThenSurrogateScriptIdReturned() { val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, null, "script.js", null) @@ -322,6 +590,15 @@ class TdsClientTest { assertEquals("script.js", testee.matches("http://api.tracker.com/auth/script.js", DOCUMENT_URL, mapOf()).surrogate) } + @Test + fun whenUrlMatchesRuleWithSurrogateThenSurrogateScriptIdReturned2() { + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, null, "script.js", null) + + val testee = TdsClient(TDS, listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))), mockUrlToTypeMapper) + + assertEquals("script.js", testee.matches("http://api.tracker.com/auth/script.js".toUri(), DOCUMENT_URL, mapOf()).surrogate) + } + @Test fun whenUrlMatchesRuleWithTypeExceptionAndDomainsIsNullThenMatchesIsFalse() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -333,6 +610,17 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithTypeExceptionAndDomainsIsNullThenMatchesIsFalse2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(null, listOf("image")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithTypeExceptionAndDomainsIsEmptyThenMatchesIsFalse() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -344,6 +632,17 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithTypeExceptionAndDomainsIsEmptyThenMatchesIsFalse2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(emptyList(), listOf("image")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithDomainExceptionAndTypesIsNullThenMatchesIsFalse() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -355,6 +654,17 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithDomainExceptionAndTypesIsNullThenMatchesIsFalse2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("example.com"), null) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithDomainExceptionAndTypesIsEmptyThenMatchesIsFalse() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -366,6 +676,17 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithDomainExceptionAndTypesIsEmptyThenMatchesIsFalse2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("example.com"), emptyList()) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithDomainAndTypeExceptionThenMatchesIsFalse() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -377,6 +698,17 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleWithDomainAndTypeExceptionThenMatchesIsFalse2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("example.com"), listOf("image")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleWithDomainExceptionButNotTypeThenMatchesIsTrue() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -388,6 +720,17 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesRuleWithDomainExceptionButNotTypeThenMatchesIsTrue2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("example.com"), listOf("script")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesRuleWithTypeExceptionButNotDomainThenMatchesIsTrue() { whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") @@ -399,6 +742,17 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesRuleWithTypeExceptionButNotDomainThenMatchesIsTrue2() { + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("foo.com"), listOf("image")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, null) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionTypeAndEmptyOptionDomainThenMatchesFalse() { // If option domain is empty and type is matching, should block would be false since exception is matching. @@ -413,6 +767,20 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionTypeAndEmptyOptionDomainThenMatchesFalse2() { + // If option domain is empty and type is matching, should block would be false since exception is matching. + val matchingType = "image" + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn(matchingType) + val exceptions = RuleExceptions(listOf("example.com"), listOf(matchingType)) + val options = Options(domains = null, types = listOf(matchingType)) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, options) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionDomainAndEmptyOptionTypeThenMatchesFalse() { // If option type is empty and domain is matching, should block would be false since exception is matching. @@ -427,6 +795,20 @@ class TdsClientTest { assertFalse(result.matches) } + @Test + fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionDomainAndEmptyOptionTypeThenMatchesFalse2() { + // If option type is empty and domain is matching, should block would be false since exception is matching. + val matchingDomain = "example.com" + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf(matchingDomain), listOf("image")) + val options = Options(domains = listOf(matchingDomain), types = null) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, options) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertFalse(result.matches) + } + @Test fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionDomainButNotOptionTypeThenMatchesTrue() { // If option type is not null and not matching, should block would be true since we will use the tracker's default action. @@ -441,6 +823,20 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionAndOptionDomainButNotOptionTypeThenMatchesTrue2() { + // If option type is not null and not matching, should block would be true since we will use the tracker's default action. + val matchingDomain = "example.com" + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf(matchingDomain), listOf("image")) + val options = Options(domains = listOf(matchingDomain), types = listOf("not-matching-type")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, options) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionButNotOptionDomainAndOptionTypeThenMatchesTrue() { // If option domain is not null and not matching, should block would be true since we will use the tracker's default action. @@ -455,6 +851,20 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenUrlMatchesRuleForBlockedTrackerWithMatchingExceptionButNotOptionDomainAndOptionTypeThenMatchesTrue2() { + // If option domain is not null and not matching, should block would be true since we will use the tracker's default action. + val matchingType = "image" + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn(matchingType) + val exceptions = RuleExceptions(listOf("example.com"), listOf(matchingType)) + val options = Options(domains = listOf("not-matching-domain.com"), types = listOf(matchingType)) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, options) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + @Test fun whenHasOptionsButDoesntMatchDomainNorTypeThenMatchesTrue() { // If option type and domain are both not null and not matching, should block would be true since we will use the tracker's default action. @@ -468,6 +878,19 @@ class TdsClientTest { assertTrue(result.matches) } + @Test + fun whenHasOptionsButDoesntMatchDomainNorTypeThenMatchesTrue2() { + // If option type and domain are both not null and not matching, should block would be true since we will use the tracker's default action. + whenever(mockUrlToTypeMapper.map(anyString(), anyMap())).thenReturn("image") + val exceptions = RuleExceptions(listOf("example.com"), listOf("image")) + val options = Options(domains = listOf("not-matching-domain.com"), types = listOf("not-matching-type")) + val rule = Rule("api\\.tracker\\.com\\/auth", BLOCK, exceptions, null, options) + val data = listOf(TdsTracker("tracker.com", BLOCK, OWNER, CATEGORY, listOf(rule))) + val testee = TdsClient(TDS, data, mockUrlToTypeMapper) + val result = testee.matches("http://api.tracker.com/auth/image.png".toUri(), DOCUMENT_URL, mapOf()) + assertTrue(result.matches) + } + companion object { private const val OWNER = "A Network Owner" private const val DOCUMENT_URL = "http://example.com/index.htm" diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt index 660926aad68c..26ad1d4f7568 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.privacy.db.UserAllowListDao @@ -59,8 +60,10 @@ class TrackerDetectorClientTypeTest { fun before() { whenever(mockUserAllowListDao.contains(any())).thenReturn(false) - whenever(mockBlockingClient.matches(eq(Url.BLOCKED), any(), anyMap())).thenReturn(Client.Result(matches = true, isATracker = true)) - whenever(mockBlockingClient.matches(eq(Url.UNLISTED), any(), anyMap())).thenReturn(Client.Result(matches = false, isATracker = false)) + whenever(mockBlockingClient.matches(eq(Url.BLOCKED), any(), anyMap())).thenReturn(Client.Result(matches = true, isATracker = true)) + whenever(mockBlockingClient.matches(eq(Url.BLOCKED), any(), anyMap())).thenReturn(Client.Result(matches = true, isATracker = true)) + whenever(mockBlockingClient.matches(eq(Url.UNLISTED), any(), anyMap())).thenReturn(Client.Result(matches = false, isATracker = false)) + whenever(mockBlockingClient.matches(eq(Url.UNLISTED), any(), anyMap())).thenReturn(Client.Result(matches = false, isATracker = false)) whenever(mockBlockingClient.name).thenReturn(Client.ClientName.TDS) testee.addClient(mockBlockingClient) } diff --git a/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt b/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt index 29783bba0c70..a483659d126c 100644 --- a/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt +++ b/app/src/test/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.privacy.db.UserAllowListDao @@ -34,6 +35,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyMap import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -97,6 +99,29 @@ class TrackerDetectorTest { ) } + @Test + fun whenTwoClientsWithSameNameAddedThenClientIsReplacedAndCountIsStillOne2() { + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + assertEquals(1, trackerDetector.clientCount) + assertNotNull( + trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ), + ) + + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + assertEquals(1, trackerDetector.clientCount) + assertNotNull( + trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ), + ) + } + @Test fun whenThereAreNoClientsAndIsThirdPartyThenEvaluateReturnsNonTrackingEvent() { trackerDetector.addClient(nonMatchingClientNoTracker(CLIENT_A)) @@ -118,6 +143,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenThereAreNoClientsAndIsThirdPartyThenEvaluateReturnsNonTrackingEvent2() { + trackerDetector.addClient(nonMatchingClientNoTracker(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.ALLOWED, + type = TrackerType.OTHER, + ) + + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenThereAreNoClientsAndIsThirdPartyFromSameEntityThenEvaluateReturnsSameEntityNonTrackingEvent() { val entity = TdsEntity("example", "example", 0.0) @@ -141,6 +187,30 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenThereAreNoClientsAndIsThirdPartyFromSameEntityThenEvaluateReturnsSameEntityNonTrackingEvent2() { + val entity = TdsEntity("example", "example", 0.0) + whenever(mockEntityLookup.entityForUrl(anyString())).thenReturn(entity) + whenever(mockEntityLookup.entityForUrl(any())).thenReturn(entity) + trackerDetector.addClient(nonMatchingClientNoTracker(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = entity, + surrogateId = null, + status = TrackerStatus.SAME_ENTITY_ALLOWED, + type = TrackerType.OTHER, + ) + + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenThereAreClientsAndIsThirdPartyButIgnoredThenEvaluateReturnsNonTrackingEvent() { val entity = TdsEntity("example", "example", 0.0) @@ -164,6 +234,30 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenThereAreClientsAndIsThirdPartyButIgnoredThenEvaluateReturnsNonTrackingEvent2() { + val entity = TdsEntity("example", "example", 0.0) + whenever(mockEntityLookup.entityForUrl(anyString())).thenReturn(entity) + whenever(mockEntityLookup.entityForUrl(any())).thenReturn(entity) + trackerDetector.addClient(matchingClientTrackerIgnored(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = entity, + surrogateId = null, + status = TrackerStatus.SAME_ENTITY_ALLOWED, + type = TrackerType.OTHER, + ) + + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsNotUserAllowListedAndAllClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) @@ -186,6 +280,28 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsNotUserAllowListedAndAllClientsMatchThenEvaluateReturnsBlockedTrackingEvent2() { + whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.BLOCKED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsUserAllowListedAndAllClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { whenever(mockUserAllowListDao.contains("example.com")).thenReturn(true) @@ -208,6 +324,28 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsUserAllowListedAndAllClientsMatchThenEvaluateReturnsUnblockedTrackingEvent2() { + whenever(mockUserAllowListDao.contains("example.com")).thenReturn(true) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.USER_ALLOWED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsNotUserAllowListedAndSomeClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) @@ -229,6 +367,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsNotUserAllowListedAndSomeClientsMatchThenEvaluateReturnsBlockedTrackingEvent2() { + whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.BLOCKED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsUserAllowListedAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { whenever(mockUserAllowListDao.contains("example.com")).thenReturn(true) @@ -250,6 +409,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsUserAllowListedAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent2() { + whenever(mockUserAllowListDao.contains("example.com")).thenReturn(true) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.USER_ALLOWED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsInContentBlockingExceptionsListAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { whenever(mockContentBlocking.isAnException(anyString())).thenReturn(true) @@ -271,6 +451,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsInContentBlockingExceptionsListAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent2() { + whenever(mockContentBlocking.isAnException(anyString())).thenReturn(true) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.ALLOWED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenSiteIsNotUserAllowListedAndSomeClientsMatchWithSurrogateThenEvaluateReturnsBlockedTrackingEventWithSurrogate() { whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) @@ -292,6 +493,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenSiteIsNotUserAllowListedAndSomeClientsMatchWithSurrogateThenEvaluateReturnsBlockedTrackingEventWithSurrogate2() { + whenever(mockUserAllowListDao.contains("example.com")).thenReturn(false) + trackerDetector.addClient(alwaysMatchingClientWithSurrogate(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = "testId", + status = TrackerStatus.BLOCKED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenRequestIsInAllowlistAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { whenever(mockTrackerAllowlist.isAnException(anyString(), anyString())).thenReturn(true) @@ -313,6 +535,27 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenRequestIsInAllowlistAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent2() { + whenever(mockTrackerAllowlist.isAnException(anyString(), anyString())).thenReturn(true) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.SITE_BREAKAGE_ALLOWED, + type = TrackerType.OTHER, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenRequestIsInAdClickAllowListAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { whenever(mockAdClickManager.isExemption(anyString(), anyString())).thenReturn(true) @@ -334,12 +577,39 @@ class TrackerDetectorTest { assertEquals(expected, actual) } + @Test + fun whenRequestIsInAdClickAllowListAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent2() { + whenever(mockAdClickManager.isExemption(anyString(), anyString())).thenReturn(true) + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + val expected = TrackingEvent( + documentUrl = "http://example.com/index.com", + trackerUrl = "http://thirdparty.com/update.js", + categories = null, + entity = null, + surrogateId = null, + status = TrackerStatus.AD_ALLOWED, + type = TrackerType.AD, + ) + val actual = trackerDetector.evaluate( + Uri.parse("http://thirdparty.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ) + assertEquals(expected, actual) + } + @Test fun whenUrlHasSameDomainAsDocumentThenEvaluateReturnsNull() { trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) assertNull(trackerDetector.evaluate("http://example.com/update.js", "http://example.com/index.com", requestHeaders = mapOf())) } + @Test + fun whenUrlHasSameDomainAsDocumentThenEvaluateReturnsNull2() { + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + assertNull(trackerDetector.evaluate(Uri.parse("http://example.com/update.js"), "http://example.com/index.com", requestHeaders = mapOf())) + } + @Test fun whenUrlIsSubdomainOfDocumentThenEvaluateReturnsNull() { trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) @@ -352,6 +622,18 @@ class TrackerDetectorTest { ) } + @Test + fun whenUrlIsSubdomainOfDocumentThenEvaluateReturnsNull2() { + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + assertNull( + trackerDetector.evaluate( + Uri.parse("http://mobile.example.com/update.js"), + "http://example.com/index.com", + requestHeaders = mapOf(), + ), + ) + } + @Test fun whenUrlIsParentOfDocumentThenEvaluateReturnsNull() { trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) @@ -364,10 +646,23 @@ class TrackerDetectorTest { ) } + @Test + fun whenUrlIsParentOfDocumentThenEvaluateReturnsNull2() { + trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) + assertNull( + trackerDetector.evaluate( + Uri.parse("http://example.com/update.js"), + "http://mobile.example.com/index.com", + requestHeaders = mapOf(), + ), + ) + } + private fun alwaysMatchingClient(name: ClientName): Client { val client: Client = mock() whenever(client.name).thenReturn(name) whenever(client.matches(anyString(), anyString(), anyMap())).thenReturn(Client.Result(matches = true, isATracker = true)) + whenever(client.matches(any(), anyString(), anyMap())).thenReturn(Client.Result(matches = true, isATracker = true)) return client } @@ -376,6 +671,8 @@ class TrackerDetectorTest { whenever(client.name).thenReturn(name) whenever(client.matches(anyString(), anyString(), anyMap())) .thenReturn(Client.Result(matches = true, surrogate = "testId", isATracker = true)) + whenever(client.matches(any(), anyString(), anyMap())) + .thenReturn(Client.Result(matches = true, surrogate = "testId", isATracker = true)) return client } diff --git a/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt b/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt index cb52fe0ff848..de32fca98260 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/global/model/Site.kt @@ -85,5 +85,10 @@ fun Site.domainMatchesUrl(matchingUrl: String): Boolean { return uri?.baseHost == matchingUrl.toUri().baseHost } +fun Site.domainMatchesUrl(matchingUrl: Uri): Boolean { + // TODO (cbarreiro) can we get rid of baseHost for the Uri as well? + return uri?.baseHost == matchingUrl.host +} + val Site.domain get() = uri?.domain() val Site.baseHost get() = uri?.baseHost diff --git a/browser-api/src/main/java/com/duckduckgo/app/trackerdetection/EntityLookup.kt b/browser-api/src/main/java/com/duckduckgo/app/trackerdetection/EntityLookup.kt index 2acbd3abd762..1b1cd8a35e1b 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/trackerdetection/EntityLookup.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/trackerdetection/EntityLookup.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.trackerdetection +import android.net.Uri import androidx.annotation.WorkerThread import com.duckduckgo.app.trackerdetection.model.Entity @@ -23,6 +24,9 @@ interface EntityLookup { @WorkerThread fun entityForUrl(url: String): Entity? + @WorkerThread + fun entityForUrl(url: Uri): Entity? + @WorkerThread fun entityForName(name: String): Entity? } diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriString.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriString.kt index 2c167e491464..cad0edb15269 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriString.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/UriString.kt @@ -44,6 +44,33 @@ class UriString { return parentHost == childHost || childHost.endsWith(".$parentHost") } + fun sameOrSubdomain( + child: Uri, + parent: String, + ): Boolean { + val childHost = child.host ?: return false + val parentHost = host(parent) ?: return false + return parentHost == childHost || childHost.endsWith(".$parentHost") + } + + fun sameOrSubdomainPair( + first: Uri, + second: String, + ): Boolean { + val childHost = first.host ?: return false + val parentHost = host(second) ?: return false + return parentHost == childHost || (childHost.endsWith(".$parentHost") || parentHost.endsWith(".$childHost")) + } + + fun sameOrSubdomainPair( + first: Uri, + second: Uri, + ): Boolean { + val childHost = first.host ?: return false + val parentHost = second.host ?: return false + return parentHost == childHost || (childHost.endsWith(".$parentHost") || parentHost.endsWith(".$childHost")) + } + fun isWebUrl(inputQuery: String): Boolean { if (inputQuery.contains(space)) return false val rawUri = Uri.parse(inputQuery) From fad758d851164c5eceaad419806148c4be4c8399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Fri, 26 Jan 2024 14:59:49 +0100 Subject: [PATCH 17/26] Sync: Fire Daily Sync Pixel (#4110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/488551667048375/1206421064288087/f ### Description ### Steps to test this PR _Daily Pixel_ - [ ] Enable sync - [ ] Ensure sync operation is triggered - [ ] Verify “m_sync_daily” pixel is sent - [ ] Close the app and open it again - [ ] Verify “m_sync_daily” pixel is not sent --- .../sync/impl/engine/RealSyncEngine.kt | 1 + .../impl/pixels/SyncDailyReportingWorker.kt | 2 +- .../duckduckgo/sync/impl/pixels/SyncPixels.kt | 21 ++++++++++++++----- .../sync/impl/engine/SyncEngineTest.kt | 3 +++ .../sync/impl/pixels/SyncPixelsTest.kt | 10 ++++----- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt index b3211f4d3b7e..89cb233dbed9 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/engine/RealSyncEngine.kt @@ -124,6 +124,7 @@ class RealSyncEngine @Inject constructor( Timber.d("Sync-Engine: sync is not in progress, starting to sync") syncStateRepository.store(SyncAttempt(state = IN_PROGRESS, meta = trigger.toString())) + syncPixels.fireDailySuccessRatePixel() syncPixels.fireDailyPixel() Timber.i("Sync-Engine: getChanges - performSync") diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncDailyReportingWorker.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncDailyReportingWorker.kt index f1a52d025811..3c073aaf2e3d 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncDailyReportingWorker.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncDailyReportingWorker.kt @@ -54,7 +54,7 @@ class SyncDailyReportingWorker( override suspend fun doWork(): Result { return withContext(dispatchers.io()) { if (syncAccountRepository.isSignedIn()) { - syncPixels.fireDailyPixel() + syncPixels.fireDailySuccessRatePixel() } return@withContext Result.success() } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt index 95e0ebb711fa..8e2ff003c977 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt @@ -21,7 +21,8 @@ import androidx.core.content.edit import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.impl.Result.Error -import com.duckduckgo.sync.impl.pixels.SyncPixelName.SYNC_DAILY_PIXEL +import com.duckduckgo.sync.impl.pixels.SyncPixelName.SYNC_DAILY +import com.duckduckgo.sync.impl.pixels.SyncPixelName.SYNC_DAILY_SUCCESS_RATE_PIXEL import com.duckduckgo.sync.impl.stats.SyncStatsRepository import com.duckduckgo.sync.store.SharedPrefsProvider import com.squareup.anvil.annotations.ContributesBinding @@ -35,9 +36,16 @@ interface SyncPixels { /** * Fired once per day, for all users with sync enabled + * Sent during the first sync of the day */ fun fireDailyPixel() + /** + * Fired once per day, for all users with sync enabled + * It carries the daily stats for errors and sync count + */ + fun fireDailySuccessRatePixel() + /** * Fired after a sync operation has found timestamp conflict */ @@ -76,12 +84,16 @@ class RealSyncPixels @Inject constructor( } override fun fireDailyPixel() { + tryToFireDailyPixel(SYNC_DAILY) + } + + override fun fireDailySuccessRatePixel() { val dailyStats = statsRepository.getYesterdayDailyStats() val payload = mapOf( SyncPixelParameters.COUNT to dailyStats.attempts, SyncPixelParameters.DATE to dailyStats.date, ).plus(dailyStats.apiErrorStats).plus(dailyStats.operationErrorStats) - tryToFireDailyPixel(SYNC_DAILY_PIXEL, payload) + tryToFireDailyPixel(SYNC_DAILY_SUCCESS_RATE_PIXEL, payload) } override fun fireTimestampConflictPixel(feature: String) { @@ -142,14 +154,13 @@ class RealSyncPixels @Inject constructor( // https://app.asana.com/0/72649045549333/1205649300615861 enum class SyncPixelName(override val pixelName: String) : Pixel.PixelName { - SYNC_DAILY_PIXEL("m_sync_success_rate_daily"), + SYNC_DAILY("m_sync_daily"), + SYNC_DAILY_SUCCESS_RATE_PIXEL("m_sync_success_rate_daily"), SYNC_TIMESTAMP_RESOLUTION_TRIGGERED("m_sync_%s_local_timestamp_resolution_triggered"), SYNC_LOGIN("m_sync_login"), SYNC_SIGNUP_DIRECT("m_sync_signup_direct"), SYNC_SIGNUP_CONNECT("m_sync_signup_connect"), SYNC_ACCOUNT_FAILURE("m_sync_account_failure"), - SYNC_ENCRYPT_FAILURE("m_sync_encrypt_failure"), - SYNC_DECRYPT_FAILURE("m_sync_decrypt_failure"), } object SyncPixelParameters { diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt index b5a3304febe8..41b6187dbc31 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/engine/SyncEngineTest.kt @@ -484,6 +484,7 @@ internal class SyncEngineTest { syncEngine.triggerSync(APP_OPEN) verify(syncApiClient).patch(any()) + verify(syncPixels).fireDailySuccessRatePixel() verify(syncPixels).fireDailyPixel() verify(syncStateRepository).updateSyncState(SUCCESS) } @@ -496,6 +497,7 @@ internal class SyncEngineTest { syncEngine.triggerSync(APP_OPEN) verify(syncApiClient).patch(any()) + verify(syncPixels).fireDailySuccessRatePixel() verify(syncPixels).fireDailyPixel() verify(syncOperationErrorRecorder).record(BOOKMARKS.field, TIMESTAMP_CONFLICT) verify(syncStateRepository).updateSyncState(SUCCESS) @@ -509,6 +511,7 @@ internal class SyncEngineTest { syncEngine.triggerSync(APP_OPEN) verify(syncApiClient).patch(any()) + verify(syncPixels).fireDailySuccessRatePixel() verify(syncPixels).fireDailyPixel() verify(syncOperationErrorRecorder).record(BOOKMARKS.field, ORPHANS_PRESENT) verify(syncStateRepository).updateSyncState(SUCCESS) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt index 2e8712eac06e..46e642156903 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/pixels/SyncPixelsTest.kt @@ -58,29 +58,29 @@ class SyncPixelsTest { fun whenDailyPixelCalledThenPixelFired() { val dailyStats = givenSomeDailyStats() - testee.fireDailyPixel() + testee.fireDailySuccessRatePixel() val payload = mapOf( SyncPixelParameters.COUNT to dailyStats.attempts, SyncPixelParameters.DATE to dailyStats.date, ).plus(dailyStats.apiErrorStats) - verify(pixel).fire(SyncPixelName.SYNC_DAILY_PIXEL, payload) + verify(pixel).fire(SyncPixelName.SYNC_DAILY_SUCCESS_RATE_PIXEL, payload) } @Test fun whenDailyPixelCalledTwiceThenPixelFiredOnce() { val dailyStats = givenSomeDailyStats() - testee.fireDailyPixel() - testee.fireDailyPixel() + testee.fireDailySuccessRatePixel() + testee.fireDailySuccessRatePixel() val payload = mapOf( SyncPixelParameters.COUNT to dailyStats.attempts, SyncPixelParameters.DATE to dailyStats.date, ).plus(dailyStats.apiErrorStats).plus(dailyStats.operationErrorStats) - verify(pixel, times(1)).fire(SyncPixelName.SYNC_DAILY_PIXEL, payload) + verify(pixel, times(1)).fire(SyncPixelName.SYNC_DAILY_SUCCESS_RATE_PIXEL, payload) } @Test From b77ee2caaec09f135b2679b8f18e9cb10cc831da Mon Sep 17 00:00:00 2001 From: Karl Dimla Date: Mon, 29 Jan 2024 12:29:18 +0100 Subject: [PATCH 18/26] New persistent notif behavior for VPN (#4092) Task/Issue URL: https://app.asana.com/0/488551667048375/1206278886478367/f ### Description See attached task description ### Steps to test this PR https://app.asana.com/0/0/1206381420683431/f --- ...FakeVpnEnabledNotificationContentPlugin.kt | 2 + .../VpnEnabledNotificationContentPlugin.kt | 9 ++- .../VpnReminderNotificationContentPlugin.kt | 5 +- .../vpn-impl/src/main/AndroidManifest.xml | 9 +++ .../service/VpnTrackerNotificationUpdates.kt | 75 +++++++++++++++---- ...AndNetPEnabledNotificationContentPlugin.kt | 13 +++- .../AppTpDisabledContentPlugin.kt | 2 +- .../AppTpEnabledNotificationContentPlugin.kt | 27 ++++++- .../notification/AppTpRevokedContentPlugin.kt | 2 +- ...PersistentNotificationDismissedReceiver.kt | 58 ++++++++++++++ .../notification/VpnNotificationStore.kt | 48 ++++++++++++ .../ui/alwayson/AlwaysOnLockDownDetector.kt | 4 +- .../DeviceShieldAlertNotificationBuilder.kt | 52 +++---------- .../DeviceShieldNotificationFactory.kt | 4 +- .../VpnEnabledNotificationBuilder.kt | 23 +++--- .../VpnReminderNotificationBuilder.kt | 6 +- .../notification_device_shield_disabled.xml | 43 ----------- .../notification_device_shield_report.xml | 44 ----------- .../notification_device_shield_revoked.xml | 43 ----------- .../res/layout/notification_vpn_enabled.xml | 44 ----------- ...etPEnabledNotificationContentPluginTest.kt | 31 +++++--- ...pTpEnabledNotificationContentPluginTest.kt | 35 +++++---- .../AppTPReminderNotificationSchedulerTest.kt | 5 +- ...eviceShieldDailyNotificationFactoryTest.kt | 2 +- ...viceShieldWeeklyNotificationFactoryTest.kt | 2 +- .../NetPDisabledNotificationBuilder.kt | 9 +-- .../NetPEnabledNotificationContentPlugin.kt | 13 +++- .../src/main/res/drawable/ic_netp_warning.xml | 73 ------------------ .../res/layout/notification_netp_disabled.xml | 42 ----------- .../notification_netp_disabled_by_vpn.xml | 42 ----------- .../src/main/res/values/donottranslate.xml | 1 + ...etPEnabledNotificationContentPluginTest.kt | 2 +- 32 files changed, 314 insertions(+), 456 deletions(-) create mode 100644 app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/PersistentNotificationDismissedReceiver.kt create mode 100644 app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/VpnNotificationStore.kt delete mode 100644 app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_disabled.xml delete mode 100644 app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_report.xml delete mode 100644 app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_revoked.xml delete mode 100644 app-tracking-protection/vpn-impl/src/main/res/layout/notification_vpn_enabled.xml delete mode 100644 network-protection/network-protection-impl/src/main/res/drawable/ic_netp_warning.xml delete mode 100644 network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled.xml delete mode 100644 network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled_by_vpn.xml diff --git a/app-tracking-protection/vpn-api-test/src/main/java/com/duckduckgo/mobile/android/vpn/service/FakeVpnEnabledNotificationContentPlugin.kt b/app-tracking-protection/vpn-api-test/src/main/java/com/duckduckgo/mobile/android/vpn/service/FakeVpnEnabledNotificationContentPlugin.kt index 60f71d8827d1..3ac9a8768e04 100644 --- a/app-tracking-protection/vpn-api-test/src/main/java/com/duckduckgo/mobile/android/vpn/service/FakeVpnEnabledNotificationContentPlugin.kt +++ b/app-tracking-protection/vpn-api-test/src/main/java/com/duckduckgo/mobile/android/vpn/service/FakeVpnEnabledNotificationContentPlugin.kt @@ -25,6 +25,8 @@ class FakeVpnEnabledNotificationContentPlugin constructor( private val priority: VpnEnabledNotificationPriority = VpnEnabledNotificationPriority.NORMAL, ) : VpnEnabledNotificationContentPlugin { + override val uuid: String = "1234" + override fun getInitialContent(): VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent? { return null } diff --git a/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnEnabledNotificationContentPlugin.kt b/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnEnabledNotificationContentPlugin.kt index 5543d2b9303e..8e6c383cd0a5 100644 --- a/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnEnabledNotificationContentPlugin.kt +++ b/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnEnabledNotificationContentPlugin.kt @@ -23,6 +23,7 @@ import com.duckduckgo.mobile.android.vpn.service.VpnEnabledNotificationContentPl import kotlinx.coroutines.flow.Flow interface VpnEnabledNotificationContentPlugin { + val uuid: String /** * This method will be called to show the first notification when the VPN is enabled. @@ -58,15 +59,19 @@ interface VpnEnabledNotificationContentPlugin { fun isActive(): Boolean data class VpnEnabledNotificationContent( - val title: SpannableStringBuilder, + val title: String?, + val text: SpannableStringBuilder, val onNotificationPressIntent: PendingIntent?, val notificationActions: NotificationActions, + val deleteIntent: PendingIntent?, ) { companion object { val EMPTY = VpnEnabledNotificationContent( - title = SpannableStringBuilder(), + title = null, + text = SpannableStringBuilder(), onNotificationPressIntent = null, notificationActions = VPNFeatureActions(emptyList()), + deleteIntent = null, ) } } diff --git a/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderNotificationContentPlugin.kt b/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderNotificationContentPlugin.kt index 63eac51a493d..c097409ca13f 100644 --- a/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderNotificationContentPlugin.kt +++ b/app-tracking-protection/vpn-api/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnReminderNotificationContentPlugin.kt @@ -17,7 +17,6 @@ package com.duckduckgo.mobile.android.vpn.service import android.app.PendingIntent -import androidx.annotation.LayoutRes import androidx.core.app.NotificationCompat interface VpnReminderNotificationContentPlugin { @@ -48,7 +47,7 @@ interface VpnReminderNotificationContentPlugin { data class NotificationContent( val isSilent: Boolean, val shouldAutoCancel: Boolean?, - @LayoutRes val customViewLayout: Int, + val title: String, val onNotificationPressIntent: PendingIntent?, val notificationAction: List, ) { @@ -56,7 +55,7 @@ interface VpnReminderNotificationContentPlugin { val EMPTY = NotificationContent( isSilent = false, shouldAutoCancel = null, - customViewLayout = -1, + title = "", onNotificationPressIntent = null, notificationAction = emptyList(), ) diff --git a/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml b/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml index c7239be1ca5c..ed85fa681ee7 100644 --- a/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml +++ b/app-tracking-protection/vpn-impl/src/main/AndroidManifest.xml @@ -152,6 +152,15 @@ + + + + + + \ No newline at end of file diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt index 23ca9d65fde5..e2b4712b7ddc 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/VpnTrackerNotificationUpdates.kt @@ -16,17 +16,21 @@ package com.duckduckgo.mobile.android.vpn.service +import android.app.NotificationManager import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE import androidx.core.app.NotificationManagerCompat import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.VpnScope +import com.duckduckgo.mobile.android.vpn.service.notification.VpnNotificationStore import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason import com.duckduckgo.mobile.android.vpn.ui.notification.VpnEnabledNotificationBuilder import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -37,41 +41,84 @@ import logcat.logcat class VpnTrackerNotificationUpdates @Inject constructor( private val context: Context, private val dispatcherProvider: DispatcherProvider, - private val notificationManager: NotificationManagerCompat, + private val notificationManagerCompat: NotificationManagerCompat, private val vpnEnabledNotificationContentPluginPoint: PluginPoint, + private val vpnNotificationStore: VpnNotificationStore, ) : VpnServiceCallbacks { + private val systemNotificationManager: NotificationManager? by lazy { context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager } private val job = ConflatedJob() + private var hasUpdatedInitialState = false + private var currentPlugin: VpnEnabledNotificationContentPlugin? = null override fun onVpnStarted(coroutineScope: CoroutineScope) { - job += coroutineScope.launch(dispatcherProvider.io()) { - val notificationContentFlow = vpnEnabledNotificationContentPluginPoint.getHighestPriorityPlugin()?.getUpdatedContent() - notificationContentFlow?.let { - it.collectLatest { content -> - val vpnNotification = content ?: VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent.EMPTY - - updateNotification(vpnNotification) - } - } - } + logcat { "VpnTrackerNotificationUpdates: onVpnStarted called" } + handleNotificationUpdates(coroutineScope) } override fun onVpnReconfigured(coroutineScope: CoroutineScope) { - logcat { "Notification updates: onVpnReconfigured" } - onVpnStarted(coroutineScope) + logcat { "VpnTrackerNotificationUpdates: onVpnReconfigured called" } + handleNotificationUpdates(coroutineScope) } override fun onVpnStopped( coroutineScope: CoroutineScope, vpnStopReason: VpnStopReason, ) { + logcat { "VpnTrackerNotificationUpdates: onVpnStopped called" } job.cancel() + currentPlugin = null + hasUpdatedInitialState = false + vpnNotificationStore.persistentNotifDimissedTimestamp = 0L + } + + private fun handleNotificationUpdates(coroutineScope: CoroutineScope) { + job += coroutineScope.launch(dispatcherProvider.io()) { + val newPlugin = vpnEnabledNotificationContentPluginPoint.getHighestPriorityPlugin() + + // If there is a change in plugin, we want to show the updated content + if (currentPlugin?.uuid != newPlugin?.uuid) { + currentPlugin = newPlugin + hasUpdatedInitialState = false + vpnNotificationStore.persistentNotifDimissedTimestamp = 0L + } + + currentPlugin?.getUpdatedContent()?.let { + it.collectLatest { content -> + val vpnNotification = content ?: VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent.EMPTY + // We want to update the notification once every state change to be able to update the initial content. Else + // we only update if the notification is still active / shown + if (!hasUpdatedInitialState || shouldUpdateNotification()) { + logcat { "VpnTrackerNotificationUpdates: updating notification" } + hasUpdatedInitialState = true + updateNotification(vpnNotification) + vpnNotificationStore.persistentNotifDimissedTimestamp = 0L + } else { + logcat { "VpnTrackerNotificationUpdates: Ignoring notification update" } + } + } + } + } } private fun updateNotification( vpnNotification: VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent, ) { val notification = VpnEnabledNotificationBuilder.buildVpnEnabledUpdateNotification(context, vpnNotification) - notificationManager.checkPermissionAndNotify(context, TrackerBlockingVpnService.VPN_FOREGROUND_SERVICE_ID, notification) + notificationManagerCompat.checkPermissionAndNotify(context, TrackerBlockingVpnService.VPN_FOREGROUND_SERVICE_ID, notification) + } + + private fun isNotificationVisible(): Boolean { + val notificationToUpdate = + systemNotificationManager?.activeNotifications?.filter { it.id == TrackerBlockingVpnService.VPN_FOREGROUND_SERVICE_ID } + return !notificationToUpdate.isNullOrEmpty() + } + + private fun hasRequiredTimeElapsed(): Boolean = + vpnNotificationStore.persistentNotifDimissedTimestamp != 0L && + System.currentTimeMillis() - vpnNotificationStore.persistentNotifDimissedTimestamp >= TimeUnit.DAYS.toMillis(1) + + private fun shouldUpdateNotification(): Boolean { + return !hasUpdatedInitialState || isNotificationVisible() || hasRequiredTimeElapsed() } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPlugin.kt index fe7514a45ab5..1b86a102aec6 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPlugin.kt @@ -47,19 +47,24 @@ class AppTPAndNetPEnabledNotificationContentPlugin @Inject constructor( ) : VpnEnabledNotificationContentPlugin { private val notificationPendingIntent by lazy { appTpEnabledNotificationIntentProvider.getOnPressNotificationIntent() } + private val deletePendingIntent by lazy { appTpEnabledNotificationIntentProvider.getDeleteNotificationIntent() } + + override val uuid: String = "1cc717cf-f046-40de-948c-fd8bc26300d4" override fun getInitialContent(): VpnEnabledNotificationContent? { return if (isActive()) { - val title = networkProtectionState.serverLocation()?.run { + val text = networkProtectionState.serverLocation()?.run { HtmlCompat.fromHtml( resources.getString(R.string.vpn_SilentNotificationTitleAppTPAndNetpEnabledNoneBlocked, this), HtmlCompat.FROM_HTML_MODE_LEGACY, ) } ?: resources.getString(R.string.vpn_SilentNotificationTitleAppTPAndNetpEnabledNoneBlockedNoLocation) return VpnEnabledNotificationContent( - title = SpannableStringBuilder(title), + title = null, + text = SpannableStringBuilder(text), onNotificationPressIntent = notificationPendingIntent, notificationActions = NotificationActions.VPNActions, + deleteIntent = deletePendingIntent, ) } else { null @@ -107,9 +112,11 @@ class AppTPAndNetPEnabledNotificationContentPlugin @Inject constructor( } VpnEnabledNotificationContent( - title = SpannableStringBuilder(notificationText), + title = null, + text = SpannableStringBuilder(notificationText), onNotificationPressIntent = notificationPendingIntent, notificationActions = NotificationActions.VPNActions, + deleteIntent = deletePendingIntent, ) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpDisabledContentPlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpDisabledContentPlugin.kt index e52fe9e1f0e3..f9aaafd77997 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpDisabledContentPlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpDisabledContentPlugin.kt @@ -41,7 +41,7 @@ class AppTpDisabledContentPlugin @Inject constructor( return NotificationContent( isSilent = true, shouldAutoCancel = null, - customViewLayout = R.layout.notification_device_shield_disabled, + title = context.getString(R.string.atp_DisabledNotification), onNotificationPressIntent = notificationPendingIntent, notificationAction = listOf( NotificationCompat.Action( diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPlugin.kt index bc50784e209d..c950113120b7 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPlugin.kt @@ -18,6 +18,7 @@ package com.duckduckgo.mobile.android.vpn.service.notification import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.res.Resources import android.text.SpannableStringBuilder import androidx.core.app.TaskStackBuilder @@ -54,13 +55,18 @@ class AppTpEnabledNotificationContentPlugin @Inject constructor( ) : VpnEnabledNotificationContentPlugin { private val notificationPendingIntent by lazy { appTpEnabledNotificationIntentProvider.getOnPressNotificationIntent() } + private val deletePendingIntent by lazy { appTpEnabledNotificationIntentProvider.getDeleteNotificationIntent() } + + override val uuid: String = "1e2a9c9f-2ccd-425a-b454-4ea30d62c0cc" override fun getInitialContent(): VpnEnabledNotificationContent? { return if (isActive()) { return VpnEnabledNotificationContent( - title = SpannableStringBuilder(resources.getString(R.string.atp_OnInitialNotification)), + title = context.getString(R.string.atp_name), + text = SpannableStringBuilder(resources.getString(R.string.atp_OnInitialNotification)), onNotificationPressIntent = notificationPendingIntent, notificationActions = NotificationActions.VPNFeatureActions(emptyList()), + deleteIntent = deletePendingIntent, ) } else { null @@ -84,13 +90,15 @@ class AppTpEnabledNotificationContentPlugin @Inject constructor( resources.getQuantityString(R.plurals.atp_OnNotification, trackingApps.size, trackingApps.size) } VpnEnabledNotificationContent( - title = SpannableStringBuilder(HtmlCompat.fromHtml(notificationText, HtmlCompat.FROM_HTML_MODE_LEGACY)), + title = context.getString(R.string.atp_name), + text = SpannableStringBuilder(HtmlCompat.fromHtml(notificationText, HtmlCompat.FROM_HTML_MODE_LEGACY)), notificationActions = NotificationActions.VPNFeatureActions( listOf( NotificationActionReportIssue.mangeRecentAppsNotificationAction(context), ), ), onNotificationPressIntent = if (isEnabled) notificationPendingIntent else null, + deleteIntent = deletePendingIntent, ) } } @@ -104,8 +112,10 @@ class AppTpEnabledNotificationContentPlugin @Inject constructor( } // This fun interface is provided just for testing purposes - fun interface IntentProvider { + interface IntentProvider { fun getOnPressNotificationIntent(): PendingIntent? + + fun getDeleteNotificationIntent(): PendingIntent? } } @@ -122,4 +132,15 @@ class AppTpEnabledNotificationIntentProvider @Inject constructor( getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } } + + override fun getDeleteNotificationIntent(): PendingIntent? { + return PendingIntent.getBroadcast( + context, + 0, + Intent(context, PersistentNotificationDismissedReceiver::class.java).apply { + action = PersistentNotificationDismissedReceiver.ACTION_VPN_PERSISTENT_NOTIF_DISMISSED + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpRevokedContentPlugin.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpRevokedContentPlugin.kt index 5ac3c56ad859..8cbef328611f 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpRevokedContentPlugin.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpRevokedContentPlugin.kt @@ -43,7 +43,7 @@ class AppTpRevokedContentPlugin @Inject constructor( return NotificationContent( isSilent = true, shouldAutoCancel = null, - customViewLayout = R.layout.notification_device_shield_revoked, + title = context.getString(R.string.atp_RevokedNotification), onNotificationPressIntent = notificationPendingIntent, notificationAction = listOf( Action( diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/PersistentNotificationDismissedReceiver.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/PersistentNotificationDismissedReceiver.kt new file mode 100644 index 000000000000..24d94c0257eb --- /dev/null +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/PersistentNotificationDismissedReceiver.kt @@ -0,0 +1,58 @@ +/* + * 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.mobile.android.vpn.service.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.di.scopes.ReceiverScope +import dagger.android.AndroidInjection +import javax.inject.Inject +import logcat.LogPriority +import logcat.logcat + +@InjectWith(ReceiverScope::class) +class PersistentNotificationDismissedReceiver : BroadcastReceiver() { + @Inject + lateinit var vpnNotificationStore: VpnNotificationStore + + override fun onReceive( + context: Context, + intent: Intent, + ) { + AndroidInjection.inject(this, context) + + logcat { "PersistentNotificationDismissedReceiver onReceive ${intent.action}" } + val pendingResult = goAsync() + + if (intent.action == ACTION_VPN_PERSISTENT_NOTIF_DISMISSED) { + // handle dismissed notif + com.duckduckgo.mobile.android.vpn.service.goAsync(pendingResult) { + logcat { "PersistentNotificationDismissedReceiver dismissed notification received" } + vpnNotificationStore.persistentNotifDimissedTimestamp = System.currentTimeMillis() + } + } else { + logcat(LogPriority.WARN) { "PersistentNotificationDismissedReceiver: unknown action ${intent.action}" } + pendingResult?.finish() + } + } + + companion object { + internal const val ACTION_VPN_PERSISTENT_NOTIF_DISMISSED = "com.duckduckgo.vpn.ACTION_VPN_PERSISTENT_NOTIF_DISMISSED" + } +} diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/VpnNotificationStore.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/VpnNotificationStore.kt new file mode 100644 index 000000000000..8c6c54068b64 --- /dev/null +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/service/notification/VpnNotificationStore.kt @@ -0,0 +1,48 @@ +/* + * 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.mobile.android.vpn.service.notification + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.mobile.android.vpn.prefs.VpnSharedPreferencesProvider +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface VpnNotificationStore { + var persistentNotifDimissedTimestamp: Long +} + +@ContributesBinding(AppScope::class) +class RealVpnNotificationStore @Inject constructor( + private val vpnSharedPreferencesProvider: VpnSharedPreferencesProvider, +) : VpnNotificationStore { + private val prefs: SharedPreferences by lazy { + vpnSharedPreferencesProvider.getSharedPreferences(FILENAME, multiprocess = true, migrate = false) + } + + override var persistentNotifDimissedTimestamp: Long + get() = prefs.getLong(KEY_PERSISTENT_NOTIF_DISMISSED_TIMESTAMP, 0L) + set(value) { + prefs.edit { putLong(KEY_PERSISTENT_NOTIF_DISMISSED_TIMESTAMP, value) } + } + + companion object { + private const val FILENAME = "com.duckduckgo.mobile.android.vpn.service.notification.v1" + private const val KEY_PERSISTENT_NOTIF_DISMISSED_TIMESTAMP = "key_persistent_notif_dismissed_timestamp" + } +} diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt index 9aa5de8eef0d..fa4f0bcb9390 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/alwayson/AlwaysOnLockDownDetector.kt @@ -87,10 +87,10 @@ class AlwaysOnLockDownDetector @Inject constructor( } private suspend fun showNotification() { - val title = SpannableStringBuilder(getNotificationText()) + val text = SpannableStringBuilder(getNotificationText()) val intent = getNotificationIntent() - val notification = DeviceShieldNotificationFactory.DeviceShieldNotification(title = title) + val notification = DeviceShieldNotificationFactory.DeviceShieldNotification(text = text) deviceShieldAlertNotificationBuilder.buildAlwaysOnLockdownNotification( context, notification, diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldAlertNotificationBuilder.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldAlertNotificationBuilder.kt index bdafd4abda8a..96c96a0a9326 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldAlertNotificationBuilder.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldAlertNotificationBuilder.kt @@ -24,13 +24,13 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.ResultReceiver -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.TaskStackBuilder import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.mobile.android.vpn.R +import com.duckduckgo.mobile.android.vpn.ui.notification.DeviceShieldNotificationFactory.DeviceShieldNotification import com.duckduckgo.mobile.android.vpn.ui.tracker_activity.DeviceShieldTrackerActivity import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -52,13 +52,13 @@ interface DeviceShieldAlertNotificationBuilder { fun buildStatusNotification( context: Context, - deviceShieldNotification: DeviceShieldNotificationFactory.DeviceShieldNotification, + deviceShieldNotification: DeviceShieldNotification, onNotificationPressedCallback: ResultReceiver, ): Notification fun buildAlwaysOnLockdownNotification( context: Context, - deviceShieldNotification: DeviceShieldNotificationFactory.DeviceShieldNotification, + deviceShieldNotification: DeviceShieldNotification, contentNextIntent: Intent, ): Notification } @@ -81,56 +81,29 @@ class AndroidDeviceShieldAlertNotificationBuilder constructor( override fun buildStatusNotification( context: Context, - deviceShieldNotification: DeviceShieldNotificationFactory.DeviceShieldNotification, + deviceShieldNotification: DeviceShieldNotification, onNotificationPressedCallback: ResultReceiver, ): Notification { registerAlertChannel(context) - val notificationLayout = RemoteViews(context.packageName, R.layout.notification_device_shield_report) - - val notificationImage = getNotificationImage(deviceShieldNotification) - notificationLayout.setImageViewResource(R.id.deviceShieldNotificationStatusIcon, notificationImage) - notificationLayout.setTextViewText(R.id.deviceShieldNotificationText, deviceShieldNotification.title) val vpnControllerIntent = DeviceShieldTrackerActivity.intent(context = context, onLaunchCallback = onNotificationPressedCallback) - return buildNotification(context, notificationLayout, addReportIssueAction = true, contentNextIntent = vpnControllerIntent) + return buildNotification(context, deviceShieldNotification, addReportIssueAction = true, contentNextIntent = vpnControllerIntent) } override fun buildAlwaysOnLockdownNotification( context: Context, - deviceShieldNotification: DeviceShieldNotificationFactory.DeviceShieldNotification, + deviceShieldNotification: DeviceShieldNotification, contentNextIntent: Intent, ): Notification { registerAlertChannel(context) - val notificationLayout = RemoteViews(context.packageName, R.layout.notification_device_shield_report) - - val notificationImage = getNotificationImage(deviceShieldNotification) - notificationLayout.setImageViewResource(R.id.deviceShieldNotificationStatusIcon, notificationImage) - notificationLayout.setTextViewText(R.id.deviceShieldNotificationText, deviceShieldNotification.title) - - return buildNotification(context, notificationLayout, addReportIssueAction = false, contentNextIntent = contentNextIntent) - } - - private fun getNotificationImage(deviceShieldNotification: DeviceShieldNotificationFactory.DeviceShieldNotification): Int { - if (deviceShieldNotification.title.contains(TRACKER_COMPANY_GOOGLE)) { - return R.drawable.ic_apptb_google - } - - if (deviceShieldNotification.title.contains(TRACKER_COMPANY_AMAZON)) { - return R.drawable.ic_apptb_amazon - } - - if (deviceShieldNotification.title.contains(TRACKER_COMPANY_FACEBOOK)) { - return R.drawable.ic_apptb_facebook - } - - return R.drawable.ic_apptb_default + return buildNotification(context, deviceShieldNotification, addReportIssueAction = false, contentNextIntent = contentNextIntent) } private fun buildNotification( context: Context, - content: RemoteViews, + content: DeviceShieldNotification, addReportIssueAction: Boolean, contentNextIntent: Intent, ): Notification { @@ -143,10 +116,10 @@ class AndroidDeviceShieldAlertNotificationBuilder constructor( return NotificationCompat.Builder(context, VPN_ALERTS_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setStyle(NotificationCompat.BigTextStyle().bigText(content.text)) + .setContentTitle(context.getString(R.string.atp_name)) .setContentIntent(vpnControllerPendingIntent) .setSilent(true) - .setCustomContentView(content) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_STATUS) @@ -159,13 +132,8 @@ class AndroidDeviceShieldAlertNotificationBuilder constructor( } companion object { - const val VPN_ALERTS_CHANNEL_ID = "com.duckduckgo.mobile.android.vpn.notification.alerts" private const val VPN_ALERTS_CHANNEL_NAME = "App Tracking Protection Alerts" private const val VPN_ALERTS_CHANNEL_DESCRIPTION = "Alerts from App Tracking Protection" - - private const val TRACKER_COMPANY_GOOGLE = "Google" - private const val TRACKER_COMPANY_FACEBOOK = "Facebook" - private const val TRACKER_COMPANY_AMAZON = "Amazon" } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationFactory.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationFactory.kt index e29a199c10aa..a1e47a2f548f 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationFactory.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldNotificationFactory.kt @@ -317,7 +317,7 @@ class DeviceShieldNotificationFactory @Inject constructor( } data class DeviceShieldNotification( - val title: SpannableStringBuilder = SpannableStringBuilder(), + val text: SpannableStringBuilder = SpannableStringBuilder(), val silent: Boolean = false, val hidden: Boolean = false, val notificationVariant: Int = -1, // default no variant @@ -325,7 +325,7 @@ class DeviceShieldNotificationFactory @Inject constructor( companion object { fun from(content: VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent): DeviceShieldNotification { return DeviceShieldNotification( - title = SpannableStringBuilder(content.title), + text = SpannableStringBuilder(content.text), ) } } diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnEnabledNotificationBuilder.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnEnabledNotificationBuilder.kt index cd7d1587ef9e..0c1f4ac00a57 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnEnabledNotificationBuilder.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnEnabledNotificationBuilder.kt @@ -23,7 +23,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.duckduckgo.mobile.android.vpn.R @@ -61,14 +60,11 @@ class VpnEnabledNotificationBuilder { ): Notification { registerOngoingNotificationChannel(context) - val notificationLayout = RemoteViews(context.packageName, R.layout.notification_vpn_enabled) - notificationLayout.setTextViewText(R.id.deviceShieldNotificationHeader, vpnEnabledNotificationContent.title) - return NotificationCompat.Builder(context, VPN_FOREGROUND_SERVICE_NOTIFICATION_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setContentTitle(context.getString(R.string.atp_name)) + .setStyle(NotificationCompat.BigTextStyle().bigText(vpnEnabledNotificationContent.title)) .setContentIntent(vpnEnabledNotificationContent.onNotificationPressIntent) - .setCustomContentView(notificationLayout) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setChannelId(VPN_FOREGROUND_SERVICE_NOTIFICATION_CHANNEL_ID) @@ -82,23 +78,28 @@ class VpnEnabledNotificationBuilder { ): Notification { registerOngoingNotificationChannel(context) - val notificationLayout = RemoteViews(context.packageName, R.layout.notification_vpn_enabled) - notificationLayout.setTextViewText(R.id.deviceShieldNotificationHeader, vpnNotification.title) - return NotificationCompat.Builder(context, VPN_FOREGROUND_SERVICE_NOTIFICATION_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setStyle(NotificationCompat.BigTextStyle().bigText(vpnNotification.text)) + .setContentTitle(vpnNotification.title) .setContentIntent(vpnNotification.onNotificationPressIntent) - .setCustomContentView(notificationLayout) .setPriority(NotificationCompat.PRIORITY_LOW) .setSilent(true) .setOngoing(true) .addNotificationActions(context, vpnNotification.notificationActions) .setChannelId(VPN_FOREGROUND_SERVICE_NOTIFICATION_CHANNEL_ID) .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setDeleteIntentIfValid(vpnNotification.deleteIntent) .build() } + private fun NotificationCompat.Builder.setDeleteIntentIfValid(deleteIntent: PendingIntent?): NotificationCompat.Builder { + if (deleteIntent != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.setDeleteIntent(deleteIntent) + } + return this + } + private fun NotificationCompat.Builder.addNotificationActions( context: Context, notificationActions: NotificationActions, diff --git a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnReminderNotificationBuilder.kt b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnReminderNotificationBuilder.kt index 416a9d1bfde4..8e2a728877a4 100644 --- a/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnReminderNotificationBuilder.kt +++ b/app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/ui/notification/VpnReminderNotificationBuilder.kt @@ -21,7 +21,6 @@ import android.app.NotificationChannel import android.app.NotificationManager.IMPORTANCE_DEFAULT import android.content.Context import android.os.Build -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.duckduckgo.di.scopes.AppScope @@ -60,13 +59,12 @@ class RealVpnReminderNotificationBuilder @Inject constructor( vpnNotification: VpnReminderNotificationContentPlugin.NotificationContent, ): Notification { registerAlertChannel(context) - val notificationLayout = RemoteViews(context.packageName, vpnNotification.customViewLayout) val builder = NotificationCompat.Builder(context, VPN_ALERTS_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setStyle(NotificationCompat.BigTextStyle().bigText(vpnNotification.title)) + .setContentTitle(context.getString(R.string.atp_name)) .setContentIntent(vpnNotification.onNotificationPressIntent) - .setCustomContentView(notificationLayout) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_STATUS) .setSilent(vpnNotification.isSilent) diff --git a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_disabled.xml b/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_disabled.xml deleted file mode 100644 index 655702281454..000000000000 --- a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_disabled.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_report.xml b/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_report.xml deleted file mode 100644 index 21003c6e93f9..000000000000 --- a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_report.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_revoked.xml b/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_revoked.xml deleted file mode 100644 index d877e1846009..000000000000 --- a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_device_shield_revoked.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_vpn_enabled.xml b/app-tracking-protection/vpn-impl/src/main/res/layout/notification_vpn_enabled.xml deleted file mode 100644 index c357c49de638..000000000000 --- a/app-tracking-protection/vpn-impl/src/main/res/layout/notification_vpn_enabled.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPluginTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPluginTest.kt index 577bdbd1a153..f2f4c9d1b171 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPluginTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTPAndNetPEnabledNotificationContentPluginTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.mobile.android.vpn.service.notification +import android.app.PendingIntent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -61,6 +62,13 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { @Mock private lateinit var networkProtectionState: NetworkProtectionState + + private val intentProvider = object : AppTpEnabledNotificationContentPlugin.IntentProvider { + override fun getOnPressNotificationIntent(): PendingIntent? = null + + override fun getDeleteNotificationIntent(): PendingIntent? = null + } + private val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources private lateinit var plugin: AppTPAndNetPEnabledNotificationContentPlugin @@ -77,7 +85,8 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { appTrackerBlockingStatsRepository, appTrackingProtection, networkProtectionState, - ) { null } + intentProvider, + ) } @After @@ -93,7 +102,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val content = plugin.getInitialContent() - content!!.assertTitleEquals("Device traffic routing through the VPN. No tracking attempts blocked in apps (past hour).") + content!!.assertTextEquals("Device traffic routing through the VPN. No tracking attempts blocked in apps (past hour).") assertEquals(NotificationActions.VPNActions, content.notificationActions) } @@ -105,7 +114,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val content = plugin.getInitialContent() - content!!.assertTitleEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") + content!!.assertTextEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") assertEquals(NotificationActions.VPNActions, content.notificationActions) } @@ -132,7 +141,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { plugin.getUpdatedContent().test { val item = awaitItem() - item.assertTitleEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") + item.assertTextEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") assertTrue(item.notificationActions is NotificationActions.VPNActions) cancelAndConsumeRemainingEvents() @@ -151,7 +160,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { skipItems(1) val item = awaitItem() - item.assertTitleEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked in 1 app (past hour).") + item.assertTextEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked in 1 app (past hour).") cancelAndConsumeRemainingEvents() } @@ -175,7 +184,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked across 2 apps (past hour).") + item.assertTextEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked across 2 apps (past hour).") cancelAndConsumeRemainingEvents() } @@ -199,7 +208,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through the VPN. Tracking attempts blocked across 2 apps (past hour).") + item.assertTextEquals("Device traffic routing through the VPN. Tracking attempts blocked across 2 apps (past hour).") cancelAndConsumeRemainingEvents() } @@ -214,7 +223,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") + item.assertTextEquals("Device traffic routing through Stockholm, SE. No tracking attempts blocked in apps (past hour).") cancelAndConsumeRemainingEvents() } @@ -229,7 +238,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through the VPN. No tracking attempts blocked in apps (past hour).") + item.assertTextEquals("Device traffic routing through the VPN. No tracking attempts blocked in apps (past hour).") cancelAndConsumeRemainingEvents() } @@ -247,7 +256,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked in 1 app (past hour).") + item.assertTextEquals("Device traffic routing through Stockholm, SE. Tracking attempts blocked in 1 app (past hour).") cancelAndConsumeRemainingEvents() } @@ -265,7 +274,7 @@ class AppTPAndNetPEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Device traffic routing through the VPN. Tracking attempts blocked in 1 app (past hour).") + item.assertTextEquals("Device traffic routing through the VPN. Tracking attempts blocked in 1 app (past hour).") cancelAndConsumeRemainingEvents() } diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPluginTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPluginTest.kt index 45aaa961d779..96ca650cdc9a 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPluginTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/service/notification/AppTpEnabledNotificationContentPluginTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.mobile.android.vpn.service.notification +import android.app.PendingIntent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -62,6 +63,13 @@ class AppTpEnabledNotificationContentPluginTest { @Mock private lateinit var networkProtectionState: NetworkProtectionState + + private val intentProvider = object : AppTpEnabledNotificationContentPlugin.IntentProvider { + override fun getOnPressNotificationIntent(): PendingIntent? = null + + override fun getDeleteNotificationIntent(): PendingIntent? = null + } + private val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources private lateinit var plugin: AppTpEnabledNotificationContentPlugin @@ -79,7 +87,8 @@ class AppTpEnabledNotificationContentPluginTest { appTrackerBlockingStatsRepository, appTrackingProtection, networkProtectionState, - ) { null } + intentProvider, + ) } @After @@ -94,7 +103,7 @@ class AppTpEnabledNotificationContentPluginTest { val content = plugin.getInitialContent() - content!!.assertTitleEquals("App Tracking Protection is enabled and blocking tracking attempts across your apps") + content!!.assertTextEquals("App Tracking Protection is enabled and blocking tracking attempts across your apps") assertEquals(NotificationActions.VPNFeatureActions(emptyList()), content.notificationActions) } @@ -112,7 +121,7 @@ class AppTpEnabledNotificationContentPluginTest { plugin.getUpdatedContent().test { val item = awaitItem() - item.assertTitleEquals("Scanning for tracking activity… beep… boop") + item.assertTextEquals("Scanning for tracking activity… beep… boop") assertTrue(item.notificationActions is NotificationActions.VPNFeatureActions) @@ -127,7 +136,7 @@ class AppTpEnabledNotificationContentPluginTest { plugin.getUpdatedContent().test { val item = awaitItem() - item.assertTitleEquals("") + item.assertTextEquals("") cancelAndConsumeRemainingEvents() } @@ -146,7 +155,7 @@ class AppTpEnabledNotificationContentPluginTest { skipItems(1) val item = awaitItem() - item.assertTitleEquals("Tracking attempts blocked in 1 app (past hour).") + item.assertTextEquals("Tracking attempts blocked in 1 app (past hour).") cancelAndConsumeRemainingEvents() } @@ -164,7 +173,7 @@ class AppTpEnabledNotificationContentPluginTest { skipItems(1) val item = awaitItem() - item.assertTitleEquals("") + item.assertTextEquals("") cancelAndConsumeRemainingEvents() } @@ -189,7 +198,7 @@ class AppTpEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Tracking attempts blocked across 2 apps (past hour).") + item.assertTextEquals("Tracking attempts blocked across 2 apps (past hour).") cancelAndConsumeRemainingEvents() } @@ -214,7 +223,7 @@ class AppTpEnabledNotificationContentPluginTest { skipItems(1) val item = awaitItem() - item.assertTitleEquals("") + item.assertTextEquals("") cancelAndConsumeRemainingEvents() } @@ -230,7 +239,7 @@ class AppTpEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Scanning for tracking activity… beep… boop") + item.assertTextEquals("Scanning for tracking activity… beep… boop") cancelAndConsumeRemainingEvents() } @@ -249,7 +258,7 @@ class AppTpEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("Tracking attempts blocked in 1 app (past hour).") + item.assertTextEquals("Tracking attempts blocked in 1 app (past hour).") cancelAndConsumeRemainingEvents() } @@ -263,7 +272,7 @@ class AppTpEnabledNotificationContentPluginTest { val item = expectMostRecentItem() - item.assertTitleEquals("") + item.assertTextEquals("") cancelAndConsumeRemainingEvents() } @@ -321,6 +330,6 @@ class AppTpEnabledNotificationContentPluginTest { } } -internal fun VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent.assertTitleEquals(expected: String) { - assertEquals(expected, this.title.toString()) +internal fun VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent.assertTextEquals(expected: String) { + assertEquals(expected, this.text.toString()) } diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationSchedulerTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationSchedulerTest.kt index 0d9941bce561..602609642f99 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationSchedulerTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/AppTPReminderNotificationSchedulerTest.kt @@ -27,7 +27,6 @@ import androidx.work.testing.WorkManagerTestInitHelper import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection -import com.duckduckgo.mobile.android.vpn.R import com.duckduckgo.mobile.android.vpn.feature.removal.VpnFeatureRemover import com.duckduckgo.mobile.android.vpn.service.VpnReminderNotificationContentPlugin import com.duckduckgo.mobile.android.vpn.service.VpnReminderNotificationContentPlugin.NotificationContent @@ -433,7 +432,7 @@ class AppTPReminderNotificationSchedulerTest { override fun getContent(): NotificationContent = NotificationContent( isSilent = false, shouldAutoCancel = false, - customViewLayout = R.layout.notification_device_shield_revoked, + title = "", onNotificationPressIntent = null, notificationAction = emptyList(), ) @@ -446,7 +445,7 @@ class AppTPReminderNotificationSchedulerTest { override fun getContent(): NotificationContent = NotificationContent( isSilent = false, shouldAutoCancel = false, - customViewLayout = R.layout.notification_device_shield_disabled, + title = "", onNotificationPressIntent = null, notificationAction = emptyList(), ) diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldDailyNotificationFactoryTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldDailyNotificationFactoryTest.kt index 4373f0acc3f5..295b323ec6cd 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldDailyNotificationFactoryTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldDailyNotificationFactoryTest.kt @@ -250,5 +250,5 @@ class DeviceShieldDailyNotificationFactoryTest { } private fun DeviceShieldNotification.assertTitleEquals(expected: String) { - assertEquals("Given notification titles do not match", expected, this.title.toString()) + assertEquals("Given notification titles do not match", expected, this.text.toString()) } diff --git a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldWeeklyNotificationFactoryTest.kt b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldWeeklyNotificationFactoryTest.kt index 437ce9fdccb5..1dcf73f2e2e7 100644 --- a/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldWeeklyNotificationFactoryTest.kt +++ b/app-tracking-protection/vpn-impl/src/test/java/com/duckduckgo/mobile/android/vpn/ui/notification/DeviceShieldWeeklyNotificationFactoryTest.kt @@ -205,5 +205,5 @@ class DeviceShieldWeeklyNotificationFactoryTest { } private fun DeviceShieldNotification.assertTitleEquals(expected: String) { - assertEquals("Given notification titles do not match", expected, this.title.toString()) + assertEquals("Given notification titles do not match", expected, this.text.toString()) } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationBuilder.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationBuilder.kt index 1ab2ab2cb027..948a87378bf9 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationBuilder.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPDisabledNotificationBuilder.kt @@ -23,7 +23,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.TaskStackBuilder @@ -72,9 +71,9 @@ class RealNetPDisabledNotificationBuilder @Inject constructor( return NotificationCompat.Builder(context, NETP_ALERTS_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.netpNotificationDisabled))) + .setContentTitle(context.getString(R.string.netp_name)) .setContentIntent(getPendingIntent(context)) - .setCustomContentView(RemoteViews(context.packageName, R.layout.notification_netp_disabled)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_STATUS) .addAction(netPNotificationActions.getEnableNetpNotificationAction(context)) @@ -131,9 +130,9 @@ class RealNetPDisabledNotificationBuilder @Inject constructor( return NotificationCompat.Builder(context, NETP_ALERTS_CHANNEL_ID) .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.netpNotificationDisabledByVpn))) + .setContentTitle(context.getString(R.string.netp_name)) .setContentIntent(getPendingIntent(context)) - .setCustomContentView(RemoteViews(context.packageName, R.layout.notification_netp_disabled_by_vpn)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_STATUS) .addAction(netPNotificationActions.getEnableNetpNotificationAction(context)) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPlugin.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPlugin.kt index 78e12fe883e3..22abd3190c00 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPlugin.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPlugin.kt @@ -51,16 +51,25 @@ class NetPEnabledNotificationContentPlugin @Inject constructor( ) : VpnEnabledNotificationContentPlugin { private val onPressIntent by lazy { netPIntentProvider.getOnPressNotificationIntent() } + + override val uuid: String = "d0c6aa7b-16dc-4d35-a4c6-35c6dfcb8309" + override fun getInitialContent(): VpnEnabledNotificationContent? { return if (isActive()) { - val title = networkProtectionState.serverLocation()?.run { + val text = networkProtectionState.serverLocation()?.run { HtmlCompat.fromHtml(resources.getString(R.string.netpEnabledNotificationTitle, this), FROM_HTML_MODE_LEGACY) } ?: resources.getString(R.string.netpEnabledNotificationInitialTitle) + /** + * deleteIntent is set to null here since we decided that we don't need to reshow the notification for NetP on the event that + * the user dismissed it. This is applicable to Android 14 and up. More info: https://app.asana.com/0/0/1206344475728481/f + */ return VpnEnabledNotificationContent( - title = SpannableStringBuilder(title), + title = resources.getString(R.string.netp_name), + text = SpannableStringBuilder(text), onNotificationPressIntent = onPressIntent, notificationActions = NotificationActions.VPNActions, + deleteIntent = null, ) } else { null diff --git a/network-protection/network-protection-impl/src/main/res/drawable/ic_netp_warning.xml b/network-protection/network-protection-impl/src/main/res/drawable/ic_netp_warning.xml deleted file mode 100644 index 8afca8fb030b..000000000000 --- a/network-protection/network-protection-impl/src/main/res/drawable/ic_netp_warning.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled.xml b/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled.xml deleted file mode 100644 index 6b8931df7181..000000000000 --- a/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled_by_vpn.xml b/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled_by_vpn.xml deleted file mode 100644 index a4850f43ccd1..000000000000 --- a/network-protection/network-protection-impl/src/main/res/layout/notification_netp_disabled_by_vpn.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml b/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml index e4599401ed0b..92a81b1ac4ba 100644 --- a/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml +++ b/network-protection/network-protection-impl/src/main/res/values/donottranslate.xml @@ -15,6 +15,7 @@ --> + Network Protection Network Protection Network Protection is On Network Protection is Off diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPluginTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPluginTest.kt index e147416b8d0c..9a61c5cd1d64 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPluginTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/notification/NetPEnabledNotificationContentPluginTest.kt @@ -153,5 +153,5 @@ class NetPEnabledNotificationContentPluginTest { } private fun VpnEnabledNotificationContentPlugin.VpnEnabledNotificationContent.assertTitleEquals(expected: String) { - Assert.assertEquals(expected, this.title.toString()) + Assert.assertEquals(expected, this.text.toString()) } From 359ef426fbd05bd9a6aec75bb63fbfba123fc898 Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 29 Jan 2024 11:44:35 +0000 Subject: [PATCH 19/26] Fix welcome animation (#4125) Task/Issue URL: https://app.asana.com/0/488551667048375/1206462881489060/f ### Description Fix welcome animation from getting stuck ### Steps to test this PR _Android <13_ - [ ] Fresh install from branch - [ ] Check notifications prompt doesn't appear - [ ] Check animation works as expected _Android >=13_ - [ ] Fresh install from branch - [ ] Check notifications prompt appears - [ ] Check animation works as expected ### No UI changes --- .../java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt index ee8a1d8a7a6e..b8f8e7b64057 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt @@ -111,7 +111,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) if (appBuildConfig.sdkInt >= android.os.Build.VERSION_CODES.TIRAMISU) { event(WelcomePageView.Event.OnNotificationPermissionsRequested) } else { - scheduleWelcomeAnimation() + scheduleTypingAnimation() } } From eed5373bbfcce85a93b975edb07ba496f8d072d6 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Mon, 29 Jan 2024 12:31:50 +0000 Subject: [PATCH 20/26] Convert null usernames to empty strings in getAutofillData response (#4128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/488551667048375/1206230587783134/f ### Description Converts null usernames to empty strings in response to autofill `getAutofillData` requests from the JS layer. This is to keep the response aligned with the expected schema. ### Steps to test this PR **QA optional**. If you want to test more... follow the below: - [ ] Open `Settings->Passwords` - [ ] Manually add the following passwords using the ➕ button `username=`, `password=bar`, `URL=fill.dev` - [ ] Visit https://fill.dev/form/login-simple - [ ] Verify there is **no** key icon on the username field - [ ] Verify the key **is** visible on the password field - [ ] Tap on the password field, verify you are prompted to autofill the saved password --- .../impl/AutofillJavascriptInterface.kt | 15 +++++++++++++-- ...AutofillStoredBackJavascriptInterfaceTest.kt | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt index 8ea4811d108e..c5ef7fdb189e 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/AutofillJavascriptInterface.kt @@ -223,13 +223,24 @@ class AutofillStoredBackJavascriptInterface @Inject constructor( val dedupedCredentials = loginDeduplicator.deduplicate(url, credentials) Timber.v("Original autofill credentials list size: %d, after de-duping: %d", credentials.size, dedupedCredentials.size) - if (dedupedCredentials.isEmpty()) { + val finalCredentialList = ensureUsernamesNotNull(dedupedCredentials) + + if (finalCredentialList.isEmpty()) { callback?.noCredentialsAvailable(url) } else { - callback?.onCredentialsAvailableToInject(url, dedupedCredentials, triggerType) + callback?.onCredentialsAvailableToInject(url, finalCredentialList, triggerType) } } + private fun ensureUsernamesNotNull(credentials: List) = + credentials.map { + if (it.username == null) { + it.copy(username = "") + } else { + it + } + } + private fun convertTriggerType(trigger: SupportedAutofillTriggerType): LoginTriggerType { return when (trigger) { USER_INITIATED -> LoginTriggerType.USER_INITIATED diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt index 56b0988fce62..172112766201 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillStoredBackJavascriptInterfaceTest.kt @@ -150,6 +150,23 @@ class AutofillStoredBackJavascriptInterfaceTest { assertCredentialsAvailable() } + @Test + fun whenGetAutofillDataCalledWithCredentialsAvailableWithNullUsernameUsernameConvertedToEmptyString() = runTest { + setupRequestForSubTypePassword() + whenever(autofillStore.getCredentials(any())).thenReturn( + listOf( + loginCredential(username = null, password = "foo"), + loginCredential(username = null, password = "bar"), + loginCredential(username = "foo", password = "bar"), + ), + ) + initiateGetAutofillDataRequest() + assertCredentialsAvailable() + + // ensure the list of credentials now has two entries with empty string username (one for each null username) + assertCredentialsContains({ it.username }, "", "") + } + @Test fun whenRequestSpecifiesSubtypeUsernameAndNoEntriesThenNoCredentialsCallbackInvoked() = runTest { setupRequestForSubTypeUsername() From 1b1342e05202c9fc800dd0313f7097de27f9f4d5 Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Mon, 29 Jan 2024 13:39:31 +0000 Subject: [PATCH 21/26] Fix re-key (#4123) Task/Issue URL: https://app.asana.com/0/488551667048375/1206444028232342/f ### Description Re-key was not working as expected because when we restart the tunnel we always re-register. Note: This PR also reduces the amount of models to represent the WG configuration to basically one. There are now two interfaces related to the tunnel, ie `WgTunnel` and `WgTunnelConfig` * `WgTunnel` exposes APIs to create the tunnel config and (optionally) save it * `WgTunnelConfig` allows to read the saved tunnel config and also clear it. This is mainly done because the UI (browser process) needs read access to tunnel config, while the VPN process is the one that needs access to create the tunnel. ### Steps to test this PR _1_ - [x] enable VPN - [x] smoke tests VPN and AppTP in their different configurations - [x] verify VPN management screen displays right information - [x] verify geo-switching works as expected - [x] verify 'netp dev settings' display right information - [x] verify 'exclusion list' feature works as expected - [x] verify 'exclude local networks' setting works as expected _2_ - [x] Filter logcat by `Wireguard configuration` - [x] enable only the VPN (AppTP disabled) and note the configuration (config 1) - [x] force close the app and re-open - [x] verify the configuration (config 2) is identical to the previous one - [x] disable the VPN and re-enable - [x] verify the configuration (config 3) is different than previous ones above (config 1 and 2) - [x] enable both VPN and AppTP and take note of the configuration (config 4) - [x] snooze the VPN and reconnect - [x] verify the configuration (config 4) is different than config 3 above - [ ] go to NetP dev settings and force rekey - [ ] verify the configuration (config 5) is different than config 4 above (you may need to force rekey 1+ times) _3_ - [x] Filter logcat by `Wireguard configuration` - [x] enable only VPN and take note of the configuration (config 1) - [x] go to NetP dev settings - [x] tap on `Exclude all System apps` - [ ] verify the configuration (config 2) is the same as config 1 above (rinse and repeat 1+ times) - [ ] Enable AppTP - [ ] Repeat from step 3 above _4_ - [x] Enable VPN - [x] Change location using geo-switching feature - [x] verify VPN re-connects to different server _5_ - [x] Install app from develop - [x] Enable VPN - [x] Build this PR branch, update the app and re-launch - [x] Verify VPN is re-enabled automatically - [x] Verify VPN works as expected - [x] Repeat from step 1 and in step 2 enable both AppTP and VPN --- .../api/NetworkProtectionState.kt | 6 + .../impl/WgVpnNetworkStack.kt | 59 ++-- .../impl/configuration/DeviceKeys.kt | 58 ---- .../impl/configuration/ServerDetails.kt | 36 +++ .../impl/configuration/WgServerApi.kt | 3 +- .../impl/configuration/WgTunnel.kt | 254 +++++++++++++++--- .../impl/connectionclass/VpnLatencySampler.kt | 7 +- .../impl/integration/NetPStateCollector.kt | 10 +- .../NetworkProtectionManagementViewModel.kt | 31 +-- .../impl/rekey/NetPRekeyer.kt | 27 +- .../geoswitching/NetpGeoSwitchingViewModel.kt | 2 +- .../impl/state/NetworkProtectionStateImpl.kt | 15 +- .../impl/store/NetworkProtectionRepository.kt | 80 ------ .../wireguard/config/BadConfigException.kt | 1 + .../main/java/com/wireguard/config/Config.kt | 4 +- .../com/wireguard/config/InetAddresses.kt | 4 +- .../java/com/wireguard/config/Interface.kt | 45 +++- .../main/java/com/wireguard/config/Peer.kt | 30 ++- .../main/java/com/wireguard/crypto/KeyPair.kt | 13 + .../impl/WgVpnNetworkStackTest.kt | 114 ++++---- .../FakeWgVpnControllerService.kt | 42 ++- .../impl/configuration/RealDeviceKeysTest.kt | 91 ------- .../impl/configuration/RealWgServerApiTest.kt | 21 +- .../impl/configuration/WgTunnelTest.kt | 57 ++-- .../fakes/FakeNetworkProtectionRepository.kt | 50 ---- ...etworkProtectionManagementViewModelTest.kt | 42 +-- .../impl/rekey/RealNetPRekeyerTest.kt | 92 ++++--- .../NetpGeoSwitchingViewModelTest.kt | 4 +- .../RealNetworkProtectionRepositoryTest.kt | 155 ++--------- .../feature/NetPInternalSettingsActivity.kt | 19 +- 30 files changed, 685 insertions(+), 687 deletions(-) delete mode 100644 network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/DeviceKeys.kt create mode 100644 network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/ServerDetails.kt delete mode 100644 network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealDeviceKeysTest.kt delete mode 100644 network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/fakes/FakeNetworkProtectionRepository.kt diff --git a/network-protection/network-protection-api/src/main/java/com/duckduckgo/networkprotection/api/NetworkProtectionState.kt b/network-protection/network-protection-api/src/main/java/com/duckduckgo/networkprotection/api/NetworkProtectionState.kt index 2a0728788f35..db72dad95403 100644 --- a/network-protection/network-protection-api/src/main/java/com/duckduckgo/networkprotection/api/NetworkProtectionState.kt +++ b/network-protection/network-protection-api/src/main/java/com/duckduckgo/networkprotection/api/NetworkProtectionState.kt @@ -45,6 +45,12 @@ interface NetworkProtectionState { */ fun restart() + /** + * This method is the same as [restart] but it also clears the existing VPN reconfiguration, forcing a new registration + * process with the VPN backend + */ + fun clearVPNConfigurationAndRestart() + /** * This method will stop the Network Protection feature by disabling it */ diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStack.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStack.kt index 5e65ac9bc3e0..d7e5e813829a 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStack.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStack.kt @@ -24,19 +24,16 @@ import com.duckduckgo.mobile.android.vpn.network.DnsProvider import com.duckduckgo.mobile.android.vpn.network.VpnNetworkStack import com.duckduckgo.mobile.android.vpn.network.VpnNetworkStack.VpnTunnelConfig import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason +import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.RESTART import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.SELF_STOP -import com.duckduckgo.networkprotection.impl.config.NetPDefaultConfigProvider import com.duckduckgo.networkprotection.impl.configuration.WgTunnel -import com.duckduckgo.networkprotection.impl.configuration.WgTunnel.WgTunnelData -import com.duckduckgo.networkprotection.impl.configuration.toCidrString +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ClientInterface -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails import com.squareup.anvil.annotations.ContributesMultibinding +import com.wireguard.config.Config import dagger.Lazy import dagger.SingleInstanceIn -import java.net.InetAddress import javax.inject.Inject import logcat.LogPriority import logcat.asLog @@ -50,59 +47,39 @@ import logcat.logcat class WgVpnNetworkStack @Inject constructor( private val wgProtocol: Lazy, private val wgTunnelLazy: Lazy, + private val wgTunnelConfigLazy: Lazy, private val networkProtectionRepository: Lazy, private val currentTimeProvider: CurrentTimeProvider, private val netpPixels: Lazy, - private val netPDefaultConfigProvider: NetPDefaultConfigProvider, private val dnsProvider: DnsProvider, private val crashLogger: CrashLogger, ) : VpnNetworkStack { - private var wgTunnelData: WgTunnelData? = null + private var wgConfig: Config? = null override val name: String = NetPVpnFeature.NETP_VPN.featureName override fun onCreateVpn(): Result = Result.success(Unit) override suspend fun onPrepareVpn(): Result { - fun WgTunnelData.allDns(): Set { - return mutableSetOf().apply { - runCatching { InetAddress.getByName(gateway) }.getOrNull()?.let { - add(it) - } - addAll(netPDefaultConfigProvider.fallbackDns()) - }.toSet().also { logcat { "DNS to be configured $it" } } - } - return try { netpPixels.get().reportEnableAttempt() - wgTunnelData = wgTunnelLazy.get().establish() + wgConfig = wgTunnelLazy.get().createAndSetWgConfig() .onFailure { netpPixels.get().reportErrorInRegistration() } .getOrThrow() - logcat { "Received config from BE: $wgTunnelData" } - - networkProtectionRepository.get().run { - serverDetails = ServerDetails( - serverName = wgTunnelData!!.serverName, - ipAddress = wgTunnelData!!.serverIP, - location = wgTunnelData!!.serverLocation, - ) - clientInterface = ClientInterface( - tunnelCidrSet = wgTunnelData!!.tunnelAddress.toCidrString(), - ) - } + logcat { "Wireguard configuration:\n$wgConfig" } val privateDns = dnsProvider.getPrivateDns() Result.success( VpnTunnelConfig( - mtu = netPDefaultConfigProvider.mtu(), - addresses = wgTunnelData?.tunnelAddress ?: emptyMap(), + mtu = wgConfig?.`interface`?.mtu ?: 1280, + addresses = wgConfig!!.`interface`.addresses.associate { Pair(it.address, it.mask) }, // when Android private DNS are set, we return DO NOT configure any DNS. // why? no use intercepting encrypted DNS traffic, plus we can't configure any DNS that doesn't support DoT, otherwise Android // will enforce DoT and will stop passing any DNS traffic, resulting in no DNS resolution == connectivity is killed - dns = if (privateDns.isEmpty()) wgTunnelData!!.allDns() else emptySet(), - routes = netPDefaultConfigProvider.routes(), - appExclusionList = netPDefaultConfigProvider.exclusionList(), + dns = if (privateDns.isEmpty()) wgConfig!!.`interface`.dnsServers else emptySet(), + routes = wgConfig!!.`interface`.routes.associate { it.address.hostAddress!! to it.mask }, + appExclusionList = wgConfig!!.`interface`.excludedApplications, ), ).also { logcat { "Returning VPN configuration: ${it.getOrNull()}" } } } catch (e: Throwable) { @@ -132,17 +109,18 @@ class WgVpnNetworkStack @Inject constructor( } private fun turnOnNative(tunfd: Int): Result { - if (wgTunnelData == null) { + if (wgConfig == null) { netpPixels.get().reportErrorWgInvalidState() return Result.failure(java.lang.IllegalStateException("Tunnel data not available when attempting to start wg.")) } val result = wgProtocol.get().startWg( tunfd, - wgTunnelData!!.userSpaceConfig.also { + wgConfig!!.toWgUserspaceString().also { logcat { "WgUserspace config: $it" } }, - pcapConfig = netPDefaultConfigProvider.pcapConfig(), + // pcapConfig = netPDefaultConfigProvider.pcapConfig(), + pcapConfig = null, ) return if (result.isFailure) { logcat(LogPriority.ERROR) { "Failed to turnOnNative due to ${result.exceptionOrNull()}" } @@ -170,7 +148,10 @@ class WgVpnNetworkStack @Inject constructor( } logcat { "Completed turnOffNative" } - networkProtectionRepository.get().serverDetails = null + if (reason != RESTART) { + logcat { "Deleting wireguard config..." } + wgTunnelConfigLazy.get().clearWgConfig() + } // Only update if enabledTimeInMillis stop has been initiated by the user if (reason is SELF_STOP && reason.snoozedTriggerAtMillis == 0L) { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/DeviceKeys.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/DeviceKeys.kt deleted file mode 100644 index f65bef786a66..000000000000 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/DeviceKeys.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2022 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.networkprotection.impl.configuration - -import com.duckduckgo.di.scopes.VpnScope -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import com.squareup.anvil.annotations.ContributesBinding -import com.wireguard.crypto.Key -import com.wireguard.crypto.KeyPair -import javax.inject.Inject - -interface DeviceKeys { - val publicKey: String - val privateKey: String -} - -@ContributesBinding(VpnScope::class) -class RealDeviceKeys @Inject constructor( - private val networkProtectionRepository: NetworkProtectionRepository, - private val keyPairGenerator: KeyPairGenerator, -) : DeviceKeys { - override val publicKey: String - get() = keyPairGenerator.generatePublicKey(privateKey) - override val privateKey: String - get() = if (networkProtectionRepository.privateKey.isNullOrEmpty()) { - keyPairGenerator.generatePrivateKey().also { - networkProtectionRepository.privateKey = it - } - } else { - networkProtectionRepository.privateKey!! - } -} - -interface KeyPairGenerator { - fun generatePrivateKey(): String - fun generatePublicKey(privateKey: String): String -} - -@ContributesBinding(VpnScope::class) -class WgKeyPairGenerator @Inject constructor() : KeyPairGenerator { - override fun generatePrivateKey(): String = KeyPair().privateKey.toBase64() - - override fun generatePublicKey(privateKey: String): String = KeyPair(Key.fromBase64(privateKey)).publicKey.toBase64() -} diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/ServerDetails.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/ServerDetails.kt new file mode 100644 index 000000000000..a936d88a8ef5 --- /dev/null +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/ServerDetails.kt @@ -0,0 +1,36 @@ +/* + * 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.networkprotection.impl.configuration + +import com.wireguard.config.Config + +/** + * View of the WG config with only relevant information about the egress server + */ +internal data class ServerDetails( + val serverName: String?, + val ipAddress: String?, + val location: String?, +) + +internal fun Config.asServerDetails(): ServerDetails { + return ServerDetails( + serverName = this.peers[0].name, + ipAddress = this.peers[0].endpoint?.getResolved()?.host, + location = this.peers[0].location, + ) +} diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt index 274e025adcf9..40807916ccab 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgServerApi.kt @@ -33,7 +33,6 @@ interface WgServerApi { val address: String, val location: String?, val gateway: String, - val allowedIPs: String = "0.0.0.0/0,::0/0", ) suspend fun registerPublicKey(publicKey: String): WgServerData? @@ -88,7 +87,7 @@ class RealWgServerApi @Inject constructor( serverName = server.name, publicKey = server.publicKey, publicEndpoint = server.extractPublicEndpoint(), - address = allowedIPs.joinToString(","), + address = allowedIPs.first(), gateway = server.internalIp, location = server.attributes.extractLocation(), ) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt index 4540e104ab3b..d9d84e8211fa 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnel.kt @@ -16,72 +16,242 @@ package com.duckduckgo.networkprotection.impl.configuration +import android.content.SharedPreferences +import androidx.core.content.edit +import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.di.scopes.VpnScope -import com.duckduckgo.networkprotection.impl.configuration.WgTunnel.WgTunnelData +import com.duckduckgo.mobile.android.vpn.prefs.VpnSharedPreferencesProvider +import com.duckduckgo.networkprotection.impl.config.NetPDefaultConfigProvider import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesTo import com.wireguard.config.Config +import com.wireguard.config.InetNetwork import com.wireguard.config.Interface import com.wireguard.config.Peer +import com.wireguard.crypto.KeyPair +import dagger.Module +import dagger.Provides +import java.io.BufferedReader +import java.io.StringReader import java.net.InetAddress import javax.inject.Inject +import javax.inject.Qualifier import logcat.LogPriority import logcat.asLog import logcat.logcat +/** + * This class exposes a read-write version of the WG config + */ interface WgTunnel { - suspend fun establish(): Result - data class WgTunnelData( - val serverName: String, - val userSpaceConfig: String, - val serverLocation: String?, - val serverIP: String?, - val gateway: String, - val tunnelAddress: Map, - ) + /** + * Creates a new wireguard [Config] and returns it + * If one exists, updates it and returns it but doesn't store it internally. + * + * @param keyPair is the private/public key [KeyPair] to be used in the wireguard [Config] + */ + suspend fun createWgConfig(keyPair: KeyPair? = null): Result + + /** + * Creates a new wireguard [Config], returns it and stores it internally. + * If one exists, updates it and returns it and also stores it internally. + * + * @param keyPair is the private/public key [KeyPair] to be used in the wireguard [Config] + */ + suspend fun createAndSetWgConfig(keyPair: KeyPair? = null): Result } -fun Map.toCidrString(): Set { - return this.map { "${it.key.hostAddress}/${it.value}" }.toSet() +/** + * This class exposes a read-only version of the WG config + */ +interface WgTunnelConfig { + /** + * @return the currently stored [Config] or null + */ + suspend fun getWgConfig(): Config? + + /** + * @return returns the [Config] creation timestamp in milliseconds + */ + suspend fun getWgConfigCreatedAt(): Long + + /** + * Clear the current wireguard [Config] + */ + fun clearWgConfig() } -@ContributesBinding(VpnScope::class) +@ContributesBinding( + scope = AppScope::class, + boundType = WgTunnelConfig::class, +) +class RealWgTunnelConfig @Inject constructor( + @InternalApi private val wgTunnelStore: WgTunnelStore, +) : WgTunnelConfig { + override suspend fun getWgConfig(): Config? { + return wgTunnelStore.wireguardConfig + } + + override suspend fun getWgConfigCreatedAt(): Long { + return wgTunnelStore.lastPrivateKeyUpdateTimeInMillis + } + + override fun clearWgConfig() { + wgTunnelStore.wireguardConfig = null + } +} + +@ContributesBinding( + scope = VpnScope::class, + boundType = WgTunnel::class, +) class RealWgTunnel @Inject constructor( - private val deviceKeys: DeviceKeys, private val wgServerApi: WgServerApi, + private val netPDefaultConfigProvider: NetPDefaultConfigProvider, + @InternalApi private val wgTunnelStore: WgTunnelStore, ) : WgTunnel { - override suspend fun establish(): Result { - return try { - // ensure we always return null on error - val serverData = wgServerApi.registerPublicKey(deviceKeys.publicKey) ?: return Result.failure(NullPointerException("serverData = null")) - val data = Config.Builder() - .setInterface( - Interface.Builder() - .parsePrivateKey(deviceKeys.privateKey) - .parseAddresses(serverData.address) - .build(), - ) - .addPeer( - Peer.Builder() - .parsePublicKey(serverData.publicKey) - .parseAllowedIPs(serverData.allowedIPs) - .parseEndpoint(serverData.publicEndpoint) - .build(), - ) - .build().run { - WgTunnelData( - serverName = serverData.serverName, - userSpaceConfig = this.toWgUserspaceString(), - serverLocation = serverData.location, - serverIP = kotlin.runCatching { InetAddress.getByName(peers[0].endpoint?.host).hostAddress }.getOrNull(), - gateway = serverData.gateway, - tunnelAddress = getInterface().addresses.associate { Pair(it.address, it.mask) }, - ) + override suspend fun createWgConfig(keyPair: KeyPair?): Result { + try { + // return updated existing config or new one + val config = wgTunnelStore.wireguardConfig?.let outerLet@{ wgConfig -> + keyPair?.let { newKeys -> + if (wgConfig.`interface`.keyPair != newKeys) { + logcat { "Different keys, fetching new config" } + return@outerLet fetchNewConfig(keyPair) + } } - Result.success(data) + + logcat { "Updating existing WG config" } + + val newConfigBuilder = wgConfig.builder + val oldInterfaceBuilder = wgConfig.`interface`.builder + + // update exclusion list and routes + wgConfig.builder.apply { + setInterface( + oldInterfaceBuilder.apply { + // update excluded applications + includedApplications.clear() + excludedApplications.clear() + excludeApplications(netPDefaultConfigProvider.exclusionList()) + + routes.clear() + addRoutes( + netPDefaultConfigProvider.routes().map { + InetNetwork.parse("${it.key}/${it.value}") + }, + ) + }.build(), + ) + }.build() + + newConfigBuilder.build() + } ?: fetchNewConfig(keyPair) + + return Result.success(config) } catch (e: Throwable) { logcat(LogPriority.ERROR) { "Error getting WgTunnelData: ${e.asLog()}" } return Result.failure(e) } } + + override suspend fun createAndSetWgConfig(keyPair: KeyPair?): Result { + val result = createWgConfig(keyPair) + if (result.isFailure) { + return result + } + wgTunnelStore.wireguardConfig = result.getOrThrow() + return result + } + + private suspend fun fetchNewConfig(keyPair: KeyPair?): Config { + @Suppress("NAME_SHADOWING") + val keyPair = keyPair ?: KeyPair() + val publicKey = keyPair.publicKey.toBase64() + val privateKey = keyPair.privateKey.toBase64() + + // throw on error + val serverData = wgServerApi.registerPublicKey(publicKey) ?: throw NullPointerException("serverData = null") + + return Config.Builder() + .setInterface( + Interface.Builder() + .parsePrivateKey(privateKey) + .addAddress(InetNetwork.parse(serverData.address)) + .apply { + addDnsServer(InetAddress.getByName(serverData.gateway)) + addDnsServers(netPDefaultConfigProvider.fallbackDns()) + } + .excludeApplications(netPDefaultConfigProvider.exclusionList()) + .setMtu(netPDefaultConfigProvider.mtu()) + .build(), + ) + .addPeer( + Peer.Builder() + .parsePublicKey(serverData.publicKey) + // peer is a relay server that bounces all internet & VPN traffic (like a proxy), including IPv6 + .parseAllowedIPs("0.0.0.0/0,::/0") + .parseEndpoint(serverData.publicEndpoint) + .setName(serverData.serverName) + .setLocation(serverData.location.orEmpty()) + .build(), + ) + .build() + } +} + +@Retention(AnnotationRetention.BINARY) +@Qualifier +private annotation class InternalApi + +class WgTunnelStore constructor( + private val vpnSharedPreferencesProvider: VpnSharedPreferencesProvider, +) { + private val prefs: SharedPreferences by lazy { + vpnSharedPreferencesProvider.getSharedPreferences(FILENAME, multiprocess = true, migrate = false) + } + + var wireguardConfig: Config? + get() { + val wgQuickString = prefs.getString(KEY_WG_CONFIG, null) + return wgQuickString?.let { + Config.parse(BufferedReader(StringReader(it))) + } + } + set(value) { + val oldConfig = prefs.getString(KEY_WG_CONFIG, null)?.let { + Config.parse(BufferedReader(StringReader(it))) + } + // nothing to update + if (oldConfig == value) return + + prefs.edit(commit = true) { putString(KEY_WG_CONFIG, value?.toWgQuickString()) } + if (value == null) { + lastPrivateKeyUpdateTimeInMillis = -1 + } else if (oldConfig?.`interface`?.keyPair != value.`interface`.keyPair) { + lastPrivateKeyUpdateTimeInMillis = System.currentTimeMillis() + } + } + + var lastPrivateKeyUpdateTimeInMillis: Long + get() = prefs.getLong(KEY_WG_PRIVATE_KEY_LAST_UPDATE, -1L) + set(value) { + prefs.edit(commit = true) { putLong(KEY_WG_PRIVATE_KEY_LAST_UPDATE, value) } + } + companion object { + private const val FILENAME = "com.duckduckgo.vpn.tunnel.config.v1" + private const val KEY_WG_CONFIG = "wg_config_key" + private const val KEY_WG_PRIVATE_KEY_LAST_UPDATE = "wg_private_key_last_update" + } +} + +@Module +@ContributesTo(AppScope::class) +object WgTunnelStoreModule { + @Provides + @InternalApi + fun provideWgTunnelStore(preferencesProvider: VpnSharedPreferencesProvider): WgTunnelStore { + return WgTunnelStore(preferencesProvider) + } } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/connectionclass/VpnLatencySampler.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/connectionclass/VpnLatencySampler.kt index cd145e8ab4b6..3f21a5cf9baa 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/connectionclass/VpnLatencySampler.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/connectionclass/VpnLatencySampler.kt @@ -22,6 +22,8 @@ import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.service.VpnServiceCallbacks import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig +import com.duckduckgo.networkprotection.impl.configuration.asServerDetails import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.EXCELLENT import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.GOOD import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.MODERATE @@ -29,7 +31,6 @@ import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.P import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.TERRIBLE import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQuality.UNKNOWN import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository import com.squareup.anvil.annotations.ContributesMultibinding import java.time.Instant import java.util.concurrent.atomic.AtomicLong @@ -42,7 +43,7 @@ import logcat.logcat class VpnLatencySampler @Inject constructor( private val connectionClassManager: ConnectionClassManager, private val latencyMeasurer: LatencyMeasurer, - private val networkProtectionRepository: NetworkProtectionRepository, + private val wgTunnelConfig: WgTunnelConfig, private val networkProtectionState: NetworkProtectionState, private val dispatcherProvider: DispatcherProvider, private val networkProtectionPixels: NetworkProtectionPixels, @@ -84,7 +85,7 @@ class VpnLatencySampler @Inject constructor( } private suspend fun sampleLatency(): ConnectionQuality? = withContext(dispatcherProvider.io()) { - return@withContext networkProtectionRepository.serverDetails?.ipAddress?.let { server -> + return@withContext wgTunnelConfig.getWgConfig()?.asServerDetails()?.ipAddress?.let { server -> val latency = latencyMeasurer.measureLatency(server) if (latency >= 0) { connectionClassManager.addLatency(latency.toDouble()) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/integration/NetPStateCollector.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/integration/NetPStateCollector.kt index 211d0874d9dd..264d14673e64 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/integration/NetPStateCollector.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/integration/NetPStateCollector.kt @@ -20,10 +20,11 @@ import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.mobile.android.vpn.state.VpnStateCollectorPlugin import com.duckduckgo.networkprotection.impl.NetPVpnFeature +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig +import com.duckduckgo.networkprotection.impl.configuration.asServerDetails import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQualityStore import com.duckduckgo.networkprotection.impl.connectionclass.asConnectionQuality import com.duckduckgo.networkprotection.impl.settings.NetPSettingsLocalConfig -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository import com.duckduckgo.networkprotection.store.NetPExclusionListRepository import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository import com.squareup.anvil.annotations.ContributesMultibinding @@ -33,7 +34,7 @@ import org.json.JSONObject @ContributesMultibinding(ActivityScope::class) class NetPStateCollector @Inject constructor( private val vpnFeaturesRegistry: VpnFeaturesRegistry, - private val netpRepository: NetworkProtectionRepository, + private val wgTunnelConfig: WgTunnelConfig, private val netPExclusionListRepository: NetPExclusionListRepository, private val connectionQualityStore: ConnectionQualityStore, private val netPSettingsLocalConfig: NetPSettingsLocalConfig, @@ -47,8 +48,9 @@ class NetPStateCollector @Inject constructor( appPackageId?.let { put("reportedAppProtected", !netPExclusionListRepository.getExcludedAppPackages().contains(it)) } - put("connectedServer", netpRepository.serverDetails?.location ?: "Unknown") - put("connectedServerIP", netpRepository.serverDetails?.ipAddress ?: "Unknown") + val serverDetails = wgTunnelConfig.getWgConfig()?.asServerDetails() + put("connectedServer", serverDetails?.location ?: "Unknown") + put("connectedServerIP", serverDetails?.ipAddress ?: "Unknown") put("connectionQuality", connectionQualityStore.getConnectionLatency().asConnectionQuality()) put("customServerSelection", netPGeoswitchingRepository.getUserPreferredLocation().countryCode != null) put("excludeLocalNetworks", netPSettingsLocalConfig.vpnExcludeLocalNetworkRoutes().isEnabled()) diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt index 0ec7dd80f14a..aa3e927e0485 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModel.kt @@ -42,6 +42,8 @@ import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.REV import com.duckduckgo.mobile.android.vpn.ui.AppBreakageCategory import com.duckduckgo.mobile.android.vpn.ui.OpenVpnBreakageCategoryWithBrokenApp import com.duckduckgo.networkprotection.impl.NetPVpnFeature +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig +import com.duckduckgo.networkprotection.impl.configuration.asServerDetails import com.duckduckgo.networkprotection.impl.di.NetpBreakageCategories import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.AlertState.None import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.AlertState.ShowAlwaysOnLockdownEnabled @@ -71,6 +73,7 @@ class NetworkProtectionManagementViewModel @Inject constructor( private val vpnStateMonitor: VpnStateMonitor, private val featuresRegistry: VpnFeaturesRegistry, private val networkProtectionRepository: NetworkProtectionRepository, + private val wgTunnelConfig: WgTunnelConfig, private val dispatcherProvider: DispatcherProvider, private val externalVpnDetector: ExternalVpnDetector, private val networkProtectionPixels: NetworkProtectionPixels, @@ -150,25 +153,23 @@ class NetworkProtectionManagementViewModel @Inject constructor( } } - private fun loadConnectionDetails() { - networkProtectionRepository.serverDetails.run { - this?.let { serverDetails -> - connectionDetailsFlow.value = if (connectionDetailsFlow.value == null) { - ConnectionDetails( - location = serverDetails.location, - ipAddress = serverDetails.ipAddress, - ) - } else { - connectionDetailsFlow.value!!.copy( - location = serverDetails.location, - ipAddress = serverDetails.ipAddress, - ) - } + private suspend fun loadConnectionDetails() { + wgTunnelConfig.getWgConfig()?.asServerDetails()?.let { serverDetails -> + connectionDetailsFlow.value = if (connectionDetailsFlow.value == null) { + ConnectionDetails( + location = serverDetails.location, + ipAddress = serverDetails.ipAddress, + ) + } else { + connectionDetailsFlow.value!!.copy( + location = serverDetails.location, + ipAddress = serverDetails.ipAddress, + ) } } } - private fun startElapsedTimeTimer() { + private suspend fun startElapsedTimeTimer() { if (!isTimerTickRunning) { isTimerTickRunning = true loadConnectionDetails() diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/rekey/NetPRekeyer.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/rekey/NetPRekeyer.kt index 99cd078125b4..ff1916611b89 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/rekey/NetPRekeyer.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/rekey/NetPRekeyer.kt @@ -25,9 +25,9 @@ import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.di.scopes.VpnScope import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.networkprotection.impl.NetPVpnFeature -import com.duckduckgo.networkprotection.impl.configuration.WgServerApi +import com.duckduckgo.networkprotection.impl.configuration.WgTunnel +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesTo import com.wireguard.crypto.KeyPair @@ -47,11 +47,11 @@ interface NetPRekeyer { @ContributesBinding(VpnScope::class) class RealNetPRekeyer @Inject constructor( - private val networkProtectionRepository: NetworkProtectionRepository, private val vpnFeaturesRegistry: VpnFeaturesRegistry, private val networkProtectionPixels: NetworkProtectionPixels, @ProcessName private val processName: String, - private val wgServerApi: WgServerApi, + private val wgTunnel: WgTunnel, + private val wgTunnelConfig: WgTunnelConfig, private val appBuildConfig: AppBuildConfig, @InternalApi private val deviceLockedChecker: DeviceLockedChecker, ) : NetPRekeyer { @@ -67,28 +67,23 @@ class RealNetPRekeyer @Inject constructor( logcat { "Rekeying client on $processName" } val forceOrFalseInProductionBuilds = forceRekey.getAndResetValue() - val millisSinceLastKeyUpdate = System.currentTimeMillis() - networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis + val millisSinceLastKeyUpdate = System.currentTimeMillis() - wgTunnelConfig.getWgConfigCreatedAt() if (!forceOrFalseInProductionBuilds && millisSinceLastKeyUpdate < TimeUnit.DAYS.toMillis(1)) { logcat { "Less than 24h passed, skip re-keying" } return } if (deviceLockedChecker.invoke() || forceOrFalseInProductionBuilds) { - val newKey = runCatching { - val newKeys = KeyPair() - wgServerApi.registerPublicKey(newKeys.publicKey.toBase64()) - - newKeys - }.onFailure { - logcat(LogPriority.ERROR) { "Failed registering the new key during re-keying: ${it.asLog()}" } - }.getOrNull() ?: return + if (vpnFeaturesRegistry.isFeatureRegistered(NetPVpnFeature.NETP_VPN)) { + val config = wgTunnel.createAndSetWgConfig(KeyPair()) + .onFailure { + logcat(LogPriority.ERROR) { "Failed registering the new key during re-keying: ${it.asLog()}" } + }.getOrNull() ?: return - logcat { "Re-keying with public key: ${newKey.publicKey.toBase64()}" } + logcat { "Re-keying with public key: ${config.`interface`.keyPair.publicKey.toBase64()}" } - if (vpnFeaturesRegistry.isFeatureRegistered(NetPVpnFeature.NETP_VPN)) { logcat { "Restarting VPN after clearing client keys" } networkProtectionPixels.reportRekeyCompleted() - networkProtectionRepository.privateKey = newKey.privateKey.toBase64() vpnFeaturesRegistry.refreshFeature(NetPVpnFeature.NETP_VPN) } else { logcat(LogPriority.ERROR) { "Re-key work should not happen" } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModel.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModel.kt index 6add43b6ce63..20674346646a 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModel.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModel.kt @@ -107,7 +107,7 @@ class NetpGeoSwitchingViewModel @Inject constructor( val newPreferredLocation = netPGeoswitchingRepository.getUserPreferredLocation() if (networkProtectionState.isEnabled()) { if (initialPreferredLocation != newPreferredLocation) { - networkProtectionState.restart() + networkProtectionState.clearVPNConfigurationAndRestart() } } diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/state/NetworkProtectionStateImpl.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/state/NetworkProtectionStateImpl.kt index 8042f9602ade..0a10f0374c3d 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/state/NetworkProtectionStateImpl.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/state/NetworkProtectionStateImpl.kt @@ -30,13 +30,15 @@ import com.duckduckgo.networkprotection.api.NetworkProtectionState.ConnectionSta import com.duckduckgo.networkprotection.api.NetworkProtectionState.ConnectionState.DISCONNECTED import com.duckduckgo.networkprotection.impl.NetPVpnFeature import com.duckduckgo.networkprotection.impl.cohort.NetpCohortStore -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig +import com.duckduckgo.networkprotection.impl.configuration.asServerDetails import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @ContributesBinding(AppScope::class) @@ -45,7 +47,7 @@ class NetworkProtectionStateImpl @Inject constructor( @AppCoroutineScope private val coroutineScope: CoroutineScope, private val cohortStore: NetpCohortStore, private val dispatcherProvider: DispatcherProvider, - private val networkProtectionRepository: NetworkProtectionRepository, + private val wgTunnelConfig: WgTunnelConfig, private val vpnStateMonitor: VpnStateMonitor, ) : NetworkProtectionState { override suspend fun isOnboarded(): Boolean = withContext(dispatcherProvider.io()) { @@ -66,12 +68,19 @@ class NetworkProtectionStateImpl @Inject constructor( } } + override fun clearVPNConfigurationAndRestart() { + coroutineScope.launch(dispatcherProvider.io()) { + wgTunnelConfig.clearWgConfig() + restart() + } + } + override suspend fun stop() { vpnFeaturesRegistry.unregisterFeature(NetPVpnFeature.NETP_VPN) } override fun serverLocation(): String? { - return networkProtectionRepository.serverDetails?.location + return runBlocking { wgTunnelConfig.getWgConfig() }?.asServerDetails()?.location } override fun getConnectionStateFlow(): Flow { diff --git a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/store/NetworkProtectionRepository.kt b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/store/NetworkProtectionRepository.kt index aa5fcf351c89..84c734cce798 100644 --- a/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/store/NetworkProtectionRepository.kt +++ b/network-protection/network-protection-impl/src/main/java/com/duckduckgo/networkprotection/impl/store/NetworkProtectionRepository.kt @@ -18,36 +18,14 @@ package com.duckduckgo.networkprotection.impl.store import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.networkprotection.impl.state.NetPFeatureRemover -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ClientInterface -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails import com.duckduckgo.networkprotection.store.NetworkProtectionPrefs import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject interface NetworkProtectionRepository { - var privateKey: String? - val lastPrivateKeyUpdateTimeInMillis: Long var enabledTimeInMillis: Long - var serverDetails: ServerDetails? - var clientInterface: ClientInterface? var vpnAccessRevoked: Boolean - - enum class ReconnectStatus { - NotReconnecting, - Reconnecting, - ReconnectingFailed, - } - - data class ServerDetails( - val serverName: String?, - val ipAddress: String?, - val location: String?, - ) - - data class ClientInterface( - val tunnelCidrSet: Set, - ) } @ContributesBinding( @@ -62,64 +40,12 @@ class RealNetworkProtectionRepository @Inject constructor( private val networkProtectionPrefs: NetworkProtectionPrefs, ) : NetworkProtectionRepository, NetPFeatureRemover.NetPStoreRemovalPlugin { - override var privateKey: String? - get() = networkProtectionPrefs.getString(KEY_WG_PRIVATE_KEY, null) - set(value) { - networkProtectionPrefs.putString(KEY_WG_PRIVATE_KEY, value) - if (value == null) { - networkProtectionPrefs.putLong(KEY_WG_PRIVATE_KEY_LAST_UPDATE, -1L) - } else { - networkProtectionPrefs.putLong(KEY_WG_PRIVATE_KEY_LAST_UPDATE, System.currentTimeMillis()) - } - } - - override val lastPrivateKeyUpdateTimeInMillis: Long - get() = networkProtectionPrefs.getLong(KEY_WG_PRIVATE_KEY_LAST_UPDATE, -1L) - override var enabledTimeInMillis: Long get() = networkProtectionPrefs.getLong(KEY_WG_SERVER_ENABLE_TIME, -1) set(value) { networkProtectionPrefs.putLong(KEY_WG_SERVER_ENABLE_TIME, value) } - override var serverDetails: ServerDetails? - get() { - val name = networkProtectionPrefs.getString(KEY_WG_SERVER_NAME, null) - val ip = networkProtectionPrefs.getString(KEY_WG_SERVER_IP, null) - val location = networkProtectionPrefs.getString(KEY_WG_SERVER_LOCATION, null) - - return if (ip.isNullOrBlank() && location.isNullOrBlank()) { - null - } else { - ServerDetails( - serverName = name, - ipAddress = ip, - location = location, - ) - } - } - set(value) { - if (value == null) { - networkProtectionPrefs.putString(KEY_WG_SERVER_NAME, null) - networkProtectionPrefs.putString(KEY_WG_SERVER_IP, null) - networkProtectionPrefs.putString(KEY_WG_SERVER_LOCATION, null) - } else { - networkProtectionPrefs.putString(KEY_WG_SERVER_NAME, value.serverName) - networkProtectionPrefs.putString(KEY_WG_SERVER_IP, value.ipAddress) - networkProtectionPrefs.putString(KEY_WG_SERVER_LOCATION, value.location) - } - } - - override var clientInterface: ClientInterface? - get() { - val tunnelIp = networkProtectionPrefs.getStringSet(KEY_WG_CLIENT_IFACE_TUNNEL_IP) - - return ClientInterface(tunnelIp) - } - set(value) { - networkProtectionPrefs.setStringSet(KEY_WG_CLIENT_IFACE_TUNNEL_IP, value?.tunnelCidrSet ?: emptySet()) - } - override fun clearStore() { networkProtectionPrefs.clear() } @@ -131,13 +57,7 @@ class RealNetworkProtectionRepository @Inject constructor( } companion object { - private const val KEY_WG_PRIVATE_KEY = "wg_private_key" - private const val KEY_WG_PRIVATE_KEY_LAST_UPDATE = "wg_private_key_last_update" - private const val KEY_WG_SERVER_NAME = "wg_server_name" - private const val KEY_WG_SERVER_IP = "wg_server_ip" - private const val KEY_WG_SERVER_LOCATION = "wg_server_location" private const val KEY_WG_SERVER_ENABLE_TIME = "wg_server_enable_time" - private const val KEY_WG_CLIENT_IFACE_TUNNEL_IP = "wg_client_iface_tunnel_ip" private const val KEY_VPN_ACCESS_REVOKED = "key_vpn_access_revoked" } } diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/BadConfigException.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/BadConfigException.kt index a0219655c1b9..62d3c82cf86c 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/BadConfigException.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/BadConfigException.kt @@ -45,6 +45,7 @@ class BadConfigException private constructor( enum class Location(val locationName: String) { TOP_LEVEL(""), ADDRESS("Address"), + ROUTE("Route"), ALLOWED_IPS("AllowedIPs"), DNS("DNS"), ENDPOINT("Endpoint"), diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Config.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Config.kt index d1ccf8c44e21..542dce8eabac 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Config.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Config.kt @@ -18,7 +18,7 @@ import java.util.* * * Instances of this class are immutable. */ -class Config private constructor(builder: Builder) { +class Config private constructor(val builder: Builder) { /** * Returns the interface section of the configuration. * @@ -55,7 +55,7 @@ class Config private constructor(builder: Builder) { * @return a concise single-line identifier for the `Config` */ override fun toString(): String { - return "(Config " + `interface` + " (" + peers.size + " peers))" + return toWgQuickString() } /** diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/InetAddresses.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/InetAddresses.kt index 965dc3165573..7369a514c8f8 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/InetAddresses.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/InetAddresses.kt @@ -37,8 +37,8 @@ object InetAddresses { |2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d) (\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d |1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d - |[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?)|((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9] - [0-9]?))${'$'} + |[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))) + (%.+)?)|((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))${'$'} """.trimIndent(), ) private val VALID_HOSTNAME = diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Interface.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Interface.kt index 6f088d8b3d0d..f1bcf11f0099 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Interface.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Interface.kt @@ -23,7 +23,7 @@ import java.net.InetAddress * * Instances of this class are immutable. */ -class Interface private constructor(builder: Builder) { // The collection is already immutable. +class Interface private constructor(val builder: Builder) { // The collection is already immutable. /** * Returns the set of IP addresses assigned to the interface. * @@ -34,6 +34,16 @@ class Interface private constructor(builder: Builder) { // The collection is alr this.addAll(builder.addresses) } + /** + * Returns the set of routes to be configured in the interface + * + * @return a set of [InetNetwork]es + */ + val routes: Set = mutableSetOf().apply { + // Defensively copy to ensure immutability even if the Builder is reused. + this.addAll(builder.routes) + } + /** * Returns the set of DNS servers associated with the interface. * @@ -135,6 +145,9 @@ class Interface private constructor(builder: Builder) { // The collection is alr fun toWgQuickString(): String { val sb = StringBuilder() if (addresses.isNotEmpty()) sb.append("Address = ").append(join(addresses)).append('\n') + if (routes.isNotEmpty()) { + sb.append("Routes = ").append(join(routes)).append('\n') + } if (dnsServers.isNotEmpty()) { val dnsServerStrings = dnsServers.map { obj: InetAddress -> obj.hostAddress }.toMutableList() dnsServerStrings.addAll(dnsSearchDomains) @@ -171,6 +184,9 @@ class Interface private constructor(builder: Builder) { // The collection is alr // Defaults to an empty set. val addresses: MutableSet = linkedSetOf() + // Defaults to an empty set. + val routes: MutableSet = linkedSetOf() + // Defaults to an empty set. val dnsServers: MutableSet = linkedSetOf() @@ -201,6 +217,16 @@ class Interface private constructor(builder: Builder) { // The collection is alr return this } + fun addRoute(route: InetNetwork): Builder { + routes.add(route) + return this + } + + fun addRoutes(routes: Collection): Builder { + this.routes.addAll(routes) + return this + } + fun addDnsServer(dnsServer: InetAddress): Builder { dnsServers.add(dnsServer) return this @@ -280,6 +306,22 @@ class Interface private constructor(builder: Builder) { // The collection is alr } } + @Throws(BadConfigException::class) + fun parseRoutes(routes: CharSequence): Builder { + return try { + for (route in split(routes)) addRoute( + InetNetwork.parse(route), + ) + this + } catch (e: ParseException) { + throw BadConfigException( + BadConfigException.Section.INTERFACE, + BadConfigException.Location.ROUTE, + e, + ) + } + } + @Throws(BadConfigException::class) fun parseDnsServers(dnsServers: CharSequence): Builder { return try { @@ -426,6 +468,7 @@ class Interface private constructor(builder: Builder) { // The collection is alr ) when (attribute.key.lowercase()) { "address" -> builder.parseAddresses(attribute.value) + "routes" -> builder.parseRoutes(attribute.value) "dns" -> builder.parseDnsServers(attribute.value) "excludedapplications" -> builder.parseExcludedApplications(attribute.value) "includedapplications" -> builder.parseIncludedApplications(attribute.value) diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Peer.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Peer.kt index d976f81ace01..42e45244ffcc 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Peer.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/config/Peer.kt @@ -19,7 +19,7 @@ import java.lang.StringBuilder * * Instances of this class are immutable. */ -class Peer private constructor(builder: Builder) { // The collection is already immutable. +class Peer private constructor(val builder: Builder) { // The collection is already immutable. /** * Returns the peer's set of allowed IPs. * @@ -37,6 +37,16 @@ class Peer private constructor(builder: Builder) { // The collection is already */ val endpoint: InetEndpoint? = builder.endpoint + /** + * @return the peers name, null if not configured + */ + val name: String? = builder.name + + /** + * @return the peers location, null if not configured + */ + val location: String? = builder.location + /** * Returns the peer's persistent keepalive. * @@ -97,6 +107,8 @@ class Peer private constructor(builder: Builder) { // The collection is already val sb = StringBuilder() if (allowedIps.isNotEmpty()) sb.append("AllowedIPs = ").append(join(allowedIps)).append('\n') endpoint?.let { ep: InetEndpoint? -> sb.append("Endpoint = ").append(ep).append('\n') } + name?.let { ep: String? -> sb.append("Name = ").append(ep).append('\n') } + location?.let { ep: String? -> sb.append("Location = ").append(ep).append('\n') } persistentKeepalive?.let { pk: Int? -> sb.append("PersistentKeepalive = ").append(pk).append('\n') } @@ -137,6 +149,10 @@ class Peer private constructor(builder: Builder) { // The collection is already // Defaults to not present. var endpoint: InetEndpoint? = null + var name: String? = null + + var location: String? = null + // Defaults to not present. var persistentKeepalive: Int? = null @@ -245,6 +261,16 @@ class Peer private constructor(builder: Builder) { // The collection is already return this } + fun setName(name: String): Builder { + this.name = name + return this + } + + fun setLocation(location: String): Builder { + this.location = location + return this + } + @Throws(BadConfigException::class) fun setPersistentKeepalive(persistentKeepalive: Int): Builder { if (persistentKeepalive < 0 || persistentKeepalive > MAX_PERSISTENT_KEEPALIVE) { @@ -299,6 +325,8 @@ class Peer private constructor(builder: Builder) { // The collection is already when (attribute.key.lowercase()) { "allowedips" -> builder.parseAllowedIPs(attribute.value) "endpoint" -> builder.parseEndpoint(attribute.value) + "name" -> builder.setName(attribute.value) + "location" -> builder.setLocation(attribute.value) "persistentkeepalive" -> builder.parsePersistentKeepalive(attribute.value) "presharedkey" -> builder.parsePreSharedKey(attribute.value) "publickey" -> builder.parsePublicKey(attribute.value) diff --git a/network-protection/network-protection-impl/src/main/java/com/wireguard/crypto/KeyPair.kt b/network-protection/network-protection-impl/src/main/java/com/wireguard/crypto/KeyPair.kt index 368a116e974d..fb17314ba5f0 100644 --- a/network-protection/network-protection-impl/src/main/java/com/wireguard/crypto/KeyPair.kt +++ b/network-protection/network-protection-impl/src/main/java/com/wireguard/crypto/KeyPair.kt @@ -26,4 +26,17 @@ class KeyPair @JvmOverloads constructor( * @return the public key */ val publicKey: Key = Key.generatePublicKey(privateKey) + + override fun equals(obj: Any?): Boolean { + if (obj === this) return true + if (obj == null || obj.javaClass != javaClass) return false + val other = obj as KeyPair + return other.privateKey == this.privateKey + } + + override fun hashCode(): Int { + var result = privateKey.hashCode() + result = 31 * result + publicKey.hashCode() + return result + } } diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt index 3b485620c0b7..ca96e413ac99 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/WgVpnNetworkStackTest.kt @@ -17,15 +17,21 @@ package com.duckduckgo.networkprotection.impl import com.duckduckgo.mobile.android.vpn.network.FakeDnsProvider +import com.duckduckgo.mobile.android.vpn.network.VpnNetworkStack.VpnTunnelConfig +import com.duckduckgo.mobile.android.vpn.prefs.FakeVpnSharedPreferencesProvider import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.RESTART import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.SELF_STOP import com.duckduckgo.networkprotection.impl.config.NetPDefaultConfigProvider +import com.duckduckgo.networkprotection.impl.configuration.ServerDetails import com.duckduckgo.networkprotection.impl.configuration.WgTunnel -import com.duckduckgo.networkprotection.impl.configuration.WgTunnel.WgTunnelData +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ClientInterface -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails +import com.duckduckgo.networkprotection.impl.store.RealNetworkProtectionRepository +import com.duckduckgo.networkprotection.store.RealNetworkProtectionPrefs +import com.wireguard.config.Config +import java.io.BufferedReader +import java.io.StringReader import java.net.InetAddress import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -44,6 +50,8 @@ class WgVpnNetworkStackTest { private lateinit var wgTunnel: WgTunnel @Mock + private lateinit var wgTunnelConfig: WgTunnelConfig + private lateinit var networkProtectionRepository: NetworkProtectionRepository @Mock @@ -52,9 +60,7 @@ class WgVpnNetworkStackTest { @Mock private lateinit var netpPixels: NetworkProtectionPixels - private lateinit var wgTunnelData: WgTunnelData - - private fun WgTunnelData.success(): Result { + private fun Config.success(): Result { return Result.success(this) } @@ -74,6 +80,22 @@ class WgVpnNetworkStackTest { } } + private val wgQuickConfig = """ + [Interface] + Address = 10.237.97.63/32 + DNS = 1.2.3.4 + MTU = 1280 + PrivateKey = yD1fKxCG/HFbxOy4YfR6zG86YQ1nOswlsv8n7uypb14= + + [Peer] + AllowedIPs = 0.0.0.0/0 + Endpoint = 10.10.10.10:443 + Name = euw.1 + Location = Stockholm, Sweden + PublicKey = u4geRTVQHaZYwsQzb/LsJqEDpxU8Fqzb5VjxGeIHslM= + """.trimIndent() + private lateinit var wgConfig: Config + private lateinit var wgVpnNetworkStack: WgVpnNetworkStack @Before @@ -81,23 +103,19 @@ class WgVpnNetworkStackTest { MockitoAnnotations.openMocks(this) privateDnsProvider = FakeDnsProvider() - - wgTunnelData = WgTunnelData( - serverName = "euw.1", - userSpaceConfig = "testuserspaceconfig", - serverIP = "10.10.10.10", - serverLocation = "Stockholm, Sweden", - tunnelAddress = emptyMap(), - gateway = "1.2.3.4", + networkProtectionRepository = RealNetworkProtectionRepository( + RealNetworkProtectionPrefs(FakeVpnSharedPreferencesProvider()), ) + wgConfig = Config.parse(BufferedReader(StringReader(wgQuickConfig))) + wgVpnNetworkStack = WgVpnNetworkStack( { wgProtocol }, { wgTunnel }, + { wgTunnelConfig }, { networkProtectionRepository }, currentTimeProvider, { netpPixels }, - netPDefaultConfigProvider, privateDnsProvider, mock(), ) @@ -105,32 +123,24 @@ class WgVpnNetworkStackTest { @Test fun whenOnPrepareVpnThenReturnVpnTunnelConfigAndStoreServerDetails() = runTest { - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) val actual = wgVpnNetworkStack.onPrepareVpn().getOrNull() - val expectedDns = (netPDefaultConfigProvider.fallbackDns() + InetAddress.getByName(wgTunnelData.gateway)) assertNotNull(actual) - assertEquals(1280, actual!!.mtu) - assertEquals(emptyMap(), actual.addresses) - assertEquals(setOf("com.example.app"), actual.appExclusionList) - assertEquals(mapOf("10.11.12.1" to 32), actual.routes) - assertEquals(expectedDns.size, actual.dns.size) - assertTrue(actual.dns.any { it.hostAddress == "1.2.3.4" }) - assertTrue(actual.dns.any { it.hostAddress == "127.0.0.1" }) - - verify(networkProtectionRepository).serverDetails = ServerDetails( + assertEquals(wgConfig.toTunnelConfig(), actual) + + val expectedServerDetails = ServerDetails( serverName = "euw.1", ipAddress = "10.10.10.10", location = "Stockholm, Sweden", ) - verify(networkProtectionRepository).clientInterface = ClientInterface(emptySet()) verify(netpPixels).reportEnableAttempt() } @Test fun whenOnPrepareVpnAndPrivateDnsConfiguredThenReturnEmptyDnsList() = runTest { - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) privateDnsProvider.mutablePrivateDns.add(InetAddress.getByName("1.1.1.1")) val actual = wgVpnNetworkStack.onPrepareVpn().getOrThrow() @@ -143,8 +153,7 @@ class WgVpnNetworkStackTest { @Test fun whenOnStartVpnAndEnabledTimeHasBeenResetThenSetEnabledTimeInMillis() = runTest { - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) - whenever(networkProtectionRepository.enabledTimeInMillis).thenReturn(-1L) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) whenever(currentTimeProvider.getTimeInMillis()).thenReturn(1672229650358L) wgVpnNetworkStack.onPrepareVpn() @@ -154,7 +163,7 @@ class WgVpnNetworkStackTest { wgVpnNetworkStack.onStartVpn(mock()), ) - verify(networkProtectionRepository).enabledTimeInMillis = 1672229650358L + assertEquals(1672229650358L, networkProtectionRepository.enabledTimeInMillis) verify(netpPixels).reportEnableAttempt() verify(netpPixels).reportEnableAttemptSuccess() verifyNoMoreInteractions(netpPixels) @@ -162,8 +171,7 @@ class WgVpnNetworkStackTest { @Test fun whenOnStartVpnAndEnabledTimeHasBeenSetThenDoNotUpdateEnabledTime() = runTest { - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) - whenever(networkProtectionRepository.enabledTimeInMillis).thenReturn(16722296505000L) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(Result.success(wgConfig)) whenever(currentTimeProvider.getTimeInMillis()).thenReturn(1672229650358L) wgVpnNetworkStack.onPrepareVpn() @@ -173,16 +181,14 @@ class WgVpnNetworkStackTest { wgVpnNetworkStack.onStartVpn(mock()), ) - verify(networkProtectionRepository).serverDetails = ServerDetails( + val expectedServerDetails = ServerDetails( serverName = "euw.1", ipAddress = "10.10.10.10", location = "Stockholm, Sweden", ) - verify(networkProtectionRepository).clientInterface = ClientInterface( - emptySet(), - ) - verify(networkProtectionRepository).enabledTimeInMillis - verifyNoMoreInteractions(networkProtectionRepository) + // assertEquals(expectedServerDetails, networkProtectionRepository.serverDetails) + + assertEquals(1672229650358L, networkProtectionRepository.enabledTimeInMillis) verify(netpPixels).reportEnableAttempt() verify(netpPixels).reportEnableAttemptSuccess() @@ -194,7 +200,7 @@ class WgVpnNetworkStackTest { val result = wgVpnNetworkStack.onStartVpn(mock()) assertTrue(result.isFailure) - verifyNoInteractions(networkProtectionRepository) + assertEquals(-1, networkProtectionRepository.enabledTimeInMillis) verify(netpPixels).reportErrorWgInvalidState() verify(netpPixels).reportEnableAttemptFailure() verifyNoMoreInteractions(netpPixels) @@ -207,8 +213,9 @@ class WgVpnNetworkStackTest { wgVpnNetworkStack.onStopVpn(SELF_STOP()), ) - verify(networkProtectionRepository).enabledTimeInMillis = -1 - verify(networkProtectionRepository).serverDetails = null + assertEquals(-1, networkProtectionRepository.enabledTimeInMillis) + verify(wgTunnelConfig).clearWgConfig() + // assertNull(networkProtectionRepository.serverDetails) } @Test @@ -217,14 +224,12 @@ class WgVpnNetworkStackTest { Result.success(Unit), wgVpnNetworkStack.onStopVpn(RESTART), ) - - verify(networkProtectionRepository).serverDetails = null - verifyNoMoreInteractions(networkProtectionRepository) + verify(wgTunnelConfig, never()).clearWgConfig() } @Test fun whenWgTunnelDataProviderThrowsExceptionThenOnPrepareShouldReturnFailure() = runTest { - whenever(wgTunnel.establish()).thenReturn(Result.failure(NullPointerException("null"))) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(Result.failure(NullPointerException("null"))) assertTrue(wgVpnNetworkStack.onPrepareVpn().isFailure) verify(netpPixels).reportErrorInRegistration() @@ -236,7 +241,7 @@ class WgVpnNetworkStackTest { @Test fun whenWgProtocolStartWgReturnsFailureThenOnStartVpnShouldReturnFailure() = runTest { whenever(wgProtocol.startWg(any(), any(), eq(null))).thenReturn(Result.failure(java.lang.IllegalStateException())) - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) wgVpnNetworkStack.onPrepareVpn() @@ -250,7 +255,7 @@ class WgVpnNetworkStackTest { @Test fun whenWgProtocolStartWgReturnsSuccessThenOnStartVpnShouldReturnSuccess() = runTest { whenever(wgProtocol.startWg(any(), any(), eq(null))).thenReturn(Result.success(Unit)) - whenever(wgTunnel.establish()).thenReturn(wgTunnelData.success()) + whenever(wgTunnel.createAndSetWgConfig()).thenReturn(wgConfig.success()) wgVpnNetworkStack.onPrepareVpn() @@ -260,4 +265,17 @@ class WgVpnNetworkStackTest { verify(netpPixels).reportEnableAttemptSuccess() verifyNoMoreInteractions(netpPixels) } + + private fun Config.toTunnelConfig(): VpnTunnelConfig { + return VpnTunnelConfig( + mtu = this.`interface`?.mtu ?: 1280, + addresses = this.`interface`.addresses.associate { Pair(it.address, it.mask) }, + // when Android private DNS are set, we return DO NOT configure any DNS. + // why? no use intercepting encrypted DNS traffic, plus we can't configure any DNS that doesn't support DoT, otherwise Android + // will enforce DoT and will stop passing any DNS traffic, resulting in no DNS resolution == connectivity is killed + dns = this.`interface`.dnsServers, + routes = this.`interface`.routes.associate { it.address.hostAddress!! to it.mask }, + appExclusionList = this.`interface`.excludedApplications, + ) + } } diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/FakeWgVpnControllerService.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/FakeWgVpnControllerService.kt index 67353f6d9e83..59d5bc270488 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/FakeWgVpnControllerService.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/FakeWgVpnControllerService.kt @@ -62,7 +62,7 @@ class FakeWgVpnControllerService : WgVpnControllerService { private fun RegisteredServerInfo.toEligibleServerInfo(): EligibleServerInfo { return EligibleServerInfo( publicKey = "key", - allowedIPs = emptyList(), + allowedIPs = listOf("10.64.169.158/32"), server = this.server, ) } @@ -112,7 +112,10 @@ private val SERVER_LOCATIONS_JSON = """ private val SERVERS_JSON = """ [ { - "registeredAt": "2023-01-30T14:02:51.056245702-05:00", + "expiresAt": "2023-01-30T14:02:51.056245702-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.usw.1", "attributes": { @@ -134,7 +137,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:51.109695295-05:00", + "expiresAt": "2023-01-30T14:02:51.109695295-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.euw", "attributes": { @@ -155,7 +161,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:52.602419176-05:00", + "expiresAt": "2023-01-30T14:02:52.602419176-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.euw.2", "attributes": { @@ -177,7 +186,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:52.130321638-05:00", + "expiresAt": "2023-01-30T14:02:52.130321638-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.euw.1", "attributes": { @@ -199,7 +211,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:51.301505435-05:00", + "expiresAt": "2023-01-30T14:02:51.301505435-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.use.2", "attributes": { @@ -221,7 +236,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:50.945553924-05:00", + "expiresAt": "2023-01-30T14:02:50.945553924-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.use.1", "attributes": { @@ -243,7 +261,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:52.648854078-05:00", + "expiresAt": "2023-01-30T14:02:52.648854078-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.usc", "attributes": { @@ -267,7 +288,10 @@ private val SERVERS_JSON = """ } }, { - "registeredAt": "2023-01-30T14:02:52.745221926-05:00", + "expiresAt": "2023-01-30T14:02:52.745221926-05:00", + "allowedIPs": [ + "10.64.169.158/32" + ], "server": { "name": "egress.usw.2", "attributes": { diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealDeviceKeysTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealDeviceKeysTest.kt deleted file mode 100644 index 90cb080eca21..000000000000 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealDeviceKeysTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2023 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.networkprotection.impl.configuration - -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class RealDeviceKeysTest { - @Mock - private lateinit var networkProtectionRepository: NetworkProtectionRepository - - @Mock - private lateinit var keyPairGenerator: KeyPairGenerator - - private lateinit var testee: RealDeviceKeys - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - testee = RealDeviceKeys(networkProtectionRepository, keyPairGenerator) - } - - @Test - fun whenNoPrivateKeyInRepositoryThenGeneratePrivateKeyAndStoreIt() { - val expectedPrivateKey = "testprivatekey123" - whenever(networkProtectionRepository.privateKey).thenReturn(null) - whenever(keyPairGenerator.generatePrivateKey()).thenReturn(expectedPrivateKey) - - assertEquals(expectedPrivateKey, testee.privateKey) - verify(networkProtectionRepository).privateKey = expectedPrivateKey - } - - @Test - fun whenEmptyPrivateKeyInRepositoryThenGeneratePrivateKeyAndStoreIt() { - val expectedPrivateKey = "testprivatekey123" - whenever(networkProtectionRepository.privateKey).thenReturn("") - whenever(keyPairGenerator.generatePrivateKey()).thenReturn(expectedPrivateKey) - - assertEquals(expectedPrivateKey, testee.privateKey) - verify(networkProtectionRepository).privateKey = expectedPrivateKey - } - - @Test - fun whenExistingPrivateKeyInRepositoryThenReturnPrivateKey() { - val expectedPrivateKey = "testprivatekey123" - whenever(networkProtectionRepository.privateKey).thenReturn(expectedPrivateKey) - - assertEquals(expectedPrivateKey, testee.privateKey) - } - - @Test - fun whenExistingPrivateKeyThenReturnPublicKeyUsingStoredPrivateKey() { - val expectedPrivateKey = "testprivatekey123" - val expectedPublicKey = "testpublickey123" - whenever(networkProtectionRepository.privateKey).thenReturn(expectedPrivateKey) - whenever(keyPairGenerator.generatePublicKey(expectedPrivateKey)).thenReturn(expectedPublicKey) - - assertEquals(expectedPublicKey, testee.publicKey) - } - - @Test - fun whenNoPrivateKeyThenReturnPublicKeyUsingGeneratedPrivateKey() { - val expectedPrivateKey = "testprivatekey123" - val expectedPublicKey = "testpublickey123" - whenever(networkProtectionRepository.privateKey).thenReturn(null) - whenever(keyPairGenerator.generatePrivateKey()).thenReturn(expectedPrivateKey) - whenever(keyPairGenerator.generatePublicKey(expectedPrivateKey)).thenReturn(expectedPublicKey) - - assertEquals(expectedPublicKey, testee.publicKey) - } -} diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt index bbbb8294dc41..bbecd81ed737 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/RealWgServerApiTest.kt @@ -65,10 +65,9 @@ class RealWgServerApiTest { serverName = "egress.usw.1", publicKey = "R/BMR6Rr5rzvp7vSIWdAtgAmOLK9m7CqTcDynblM3Us=", publicEndpoint = "162.245.204.100:443", - address = "", + address = "10.64.169.158/32", location = "Newark, US", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), productionApi.registerPublicKey("testpublickey"), ) @@ -83,10 +82,9 @@ class RealWgServerApiTest { serverName = "egress.euw.2", publicKey = "4PnM/V0CodegK44rd9fKTxxS9QDVTw13j8fxKsVud3s=", publicEndpoint = "31.204.129.39:443", - address = "", + address = "10.64.169.158/32", location = "Rotterdam, NL", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), internalApi.registerPublicKey("testpublickey"), ) @@ -101,10 +99,9 @@ class RealWgServerApiTest { serverName = "egress.euw", publicKey = "CLQMP4SFzpyvAzMj3rXwShm+3n6Yt68hGHBF67At+x0=", publicEndpoint = "euw.egress.np.duck.com:443", - address = "", + address = "10.64.169.158/32", location = null, gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), internalApi.registerPublicKey("testpublickey"), ) @@ -119,10 +116,9 @@ class RealWgServerApiTest { serverName = "egress.usw.1", publicKey = "R/BMR6Rr5rzvp7vSIWdAtgAmOLK9m7CqTcDynblM3Us=", publicEndpoint = "162.245.204.100:443", - address = "", + address = "10.64.169.158/32", location = "Newark, US", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), internalApi.registerPublicKey("testpublickey"), ) @@ -165,10 +161,9 @@ class RealWgServerApiTest { serverName = "egress.euw.2", publicKey = "4PnM/V0CodegK44rd9fKTxxS9QDVTw13j8fxKsVud3s=", publicEndpoint = "31.204.129.39:443", - address = "", + address = "10.64.169.158/32", location = "Rotterdam, NL", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), productionApi.registerPublicKey("testpublickey"), ) @@ -185,10 +180,9 @@ class RealWgServerApiTest { serverName = "egress.usc", publicKey = "ovn9RpzUuvQ4XLQt6B3RKuEXGIxa5QpTnehjduZlcSE=", publicEndpoint = "109.200.208.196:443", - address = "", + address = "10.64.169.158/32", location = "Des Moines, US", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), productionApi.registerPublicKey("testpublickey"), ) @@ -206,10 +200,9 @@ class RealWgServerApiTest { serverName = "egress.euw.2", publicKey = "4PnM/V0CodegK44rd9fKTxxS9QDVTw13j8fxKsVud3s=", publicEndpoint = "31.204.129.39:443", - address = "", + address = "10.64.169.158/32", location = "Rotterdam, NL", gateway = "1.2.3.4", - allowedIPs = "0.0.0.0/0,::0/0", ), internalApi.registerPublicKey("testpublickey"), ) diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnelTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnelTest.kt index 010b82dd100d..8a80873763e1 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnelTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/configuration/WgTunnelTest.kt @@ -3,9 +3,11 @@ package com.duckduckgo.networkprotection.impl.configuration import android.os.Build.VERSION import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.mobile.android.vpn.prefs.FakeVpnSharedPreferencesProvider -import com.duckduckgo.networkprotection.impl.store.RealNetworkProtectionRepository -import com.duckduckgo.networkprotection.store.RealNetworkProtectionPrefs -import com.wireguard.config.InetAddresses +import com.duckduckgo.networkprotection.impl.config.NetPDefaultConfigProvider +import com.wireguard.config.Config +import com.wireguard.crypto.KeyPair +import java.io.BufferedReader +import java.io.StringReader import java.lang.reflect.Field import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -20,6 +22,8 @@ import org.mockito.kotlin.* class WgTunnelTest { private val wgServerApi: WgServerApi = mock() + private val netPDefaultConfigProvider: NetPDefaultConfigProvider = object : NetPDefaultConfigProvider {} + private val keys = KeyPair() private val serverData = WgServerApi.WgServerData( serverName = "name", publicKey = "public key", @@ -28,36 +32,40 @@ class WgTunnelTest { location = "Furadouro", gateway = "10.1.1.1", ) + + private val wgQuickConfig = """ + [Interface] + Address = ${serverData.address} + DNS = ${serverData.gateway} + MTU = 1280 + PrivateKey = ${keys.privateKey.toBase64()} + + [Peer] + AllowedIPs = 0.0.0.0/0, ::/0 + Endpoint = ${serverData.publicEndpoint} + Name = ${serverData.serverName} + Location = ${serverData.location} + PublicKey = ${keys.publicKey.toBase64()} + """.trimIndent() + private lateinit var wgTunnel: WgTunnel @Before fun setup() { - val networkProtectionPrefs = RealNetworkProtectionPrefs(FakeVpnSharedPreferencesProvider()) - val networkProtectionRepository = RealNetworkProtectionRepository(networkProtectionPrefs) - val deviceKeys = RealDeviceKeys(networkProtectionRepository, WgKeyPairGenerator()) setFinalStatic(VERSION::class.java.getField("SDK_INT"), 29) runBlocking { - whenever(wgServerApi.registerPublicKey(eq(deviceKeys.publicKey))) - .thenReturn(serverData.copy(publicKey = deviceKeys.publicKey)) + whenever(wgServerApi.registerPublicKey(eq(keys.publicKey.toBase64()))) + .thenReturn(serverData.copy(publicKey = keys.publicKey.toBase64())) } - wgTunnel = RealWgTunnel(deviceKeys, wgServerApi) + wgTunnel = RealWgTunnel(wgServerApi, netPDefaultConfigProvider, WgTunnelStore(FakeVpnSharedPreferencesProvider())) } @Test fun establishThenReturnWgTunnelData() = runTest { - val actual = wgTunnel.establish().getOrThrow().copy(userSpaceConfig = "") - val expected = WgTunnel.WgTunnelData( - serverName = serverData.serverName, - userSpaceConfig = "", - serverLocation = serverData.location, - serverIP = serverData.publicEndpoint.substringBefore(":"), - gateway = serverData.gateway, - tunnelAddress = mapOf( - InetAddresses.parse(serverData.address.substringBefore("/")) to serverData.address.substringAfter("/").toInt(), - ), - ) + val actual = wgTunnel.createWgConfig(keys).getOrThrow() + val expected = Config.parse(BufferedReader(StringReader(wgQuickConfig))) assertEquals(expected, actual) } @@ -66,7 +74,14 @@ class WgTunnelTest { fun establishErrorThenLogError() = runTest { whenever(wgServerApi.registerPublicKey(any())).thenReturn(serverData) - assertNull(wgTunnel.establish().getOrNull()) + assertNull(wgTunnel.createWgConfig(keys).getOrNull()) + } + + @Test + fun withNoKeysEstablishErrorThenLogError() = runTest { + whenever(wgServerApi.registerPublicKey(any())).thenReturn(serverData) + + assertNull(wgTunnel.createWgConfig().getOrNull()) } @Throws(Exception::class) diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/fakes/FakeNetworkProtectionRepository.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/fakes/FakeNetworkProtectionRepository.kt deleted file mode 100644 index 84caefd1e88e..000000000000 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/fakes/FakeNetworkProtectionRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2023 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.networkprotection.impl.fakes - -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails - -class FakeNetworkProtectionRepository : NetworkProtectionRepository { - private var _serverDetails: ServerDetails? = null - private var _vpnAccessRevoked: Boolean = false - - override var privateKey: String? - get() = null - set(_) {} - - override val lastPrivateKeyUpdateTimeInMillis: Long - get() = -1L - - override var enabledTimeInMillis: Long - get() = -1L - set(_) {} - override var serverDetails: ServerDetails? - get() = _serverDetails - set(value) { - _serverDetails = value - } - override var clientInterface: NetworkProtectionRepository.ClientInterface? - get() = null - set(_) {} - - override var vpnAccessRevoked: Boolean - get() = _vpnAccessRevoked - set(value) { - _vpnAccessRevoked = value - } -} diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt index 2f6b73fbffe9..345de5e8a1bc 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/management/NetworkProtectionManagementViewModelTest.kt @@ -33,6 +33,7 @@ import com.duckduckgo.mobile.android.vpn.state.VpnStateMonitor.VpnStopReason.UNK import com.duckduckgo.mobile.android.vpn.ui.AppBreakageCategory import com.duckduckgo.mobile.android.vpn.ui.OpenVpnBreakageCategoryWithBrokenApp import com.duckduckgo.networkprotection.impl.NetPVpnFeature +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.AlertState.None import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.AlertState.ShowAlwaysOnLockdownEnabled import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.AlertState.ShowRevoked @@ -52,7 +53,9 @@ import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagem import com.duckduckgo.networkprotection.impl.management.NetworkProtectionManagementViewModel.ViewState import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails +import com.wireguard.config.Config +import java.io.BufferedReader +import java.io.StringReader import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -80,11 +83,31 @@ class NetworkProtectionManagementViewModelTest { @Mock private lateinit var networkProtectionRepository: NetworkProtectionRepository + @Mock + private lateinit var wgTunnelConfig: WgTunnelConfig + @Mock private lateinit var externalVpnDetector: ExternalVpnDetector @Mock private lateinit var networkProtectionPixels: NetworkProtectionPixels + + private val wgQuickConfig = """ + [Interface] + Address = 10.237.97.63/32 + DNS = 1.2.3.4 + MTU = 1280 + PrivateKey = yD1fKxCG/HFbxOy4YfR6zG86YQ1nOswlsv8n7uypb14= + + [Peer] + AllowedIPs = 0.0.0.0/0 + Endpoint = 10.10.10.10:443 + Name = euw.1 + Location = Stockholm, Sweden + PublicKey = u4geRTVQHaZYwsQzb/LsJqEDpxU8Fqzb5VjxGeIHslM= + """.trimIndent() + private val wgConfig: Config = Config.parse(BufferedReader(StringReader(wgQuickConfig))) + private lateinit var testee: NetworkProtectionManagementViewModel private val testbreakageCategories = listOf(AppBreakageCategory("test", "test description")) @@ -102,6 +125,7 @@ class NetworkProtectionManagementViewModelTest { vpnStateMonitor, vpnFeaturesRegistry, networkProtectionRepository, + wgTunnelConfig, coroutineRule.testDispatcherProvider, externalVpnDetector, networkProtectionPixels, @@ -226,13 +250,7 @@ class NetworkProtectionManagementViewModelTest { ), ), ) - whenever(networkProtectionRepository.serverDetails).thenReturn( - ServerDetails( - serverName = "euw.1", - ipAddress = "10.10.10.10", - location = "Stockholm, Sweden", - ), - ) + whenever(wgTunnelConfig.getWgConfig()).thenReturn(wgConfig) testee.viewState().test { assertEquals( @@ -248,13 +266,7 @@ class NetworkProtectionManagementViewModelTest { @Test fun whenEnabledAndServerDetailsAvailableThenEmitViewStateConnectedWithDetails() = runTest { whenever(networkProtectionRepository.enabledTimeInMillis).thenReturn(-1) - whenever(networkProtectionRepository.serverDetails).thenReturn( - ServerDetails( - serverName = "euw.1", - ipAddress = "10.10.10.10", - location = "Stockholm, Sweden", - ), - ) + whenever(wgTunnelConfig.getWgConfig()).thenReturn(wgConfig) whenever(vpnStateMonitor.getStateFlow(NetPVpnFeature.NETP_VPN)).thenReturn( flowOf( VpnState( diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/rekey/RealNetPRekeyerTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/rekey/RealNetPRekeyerTest.kt index 3c2c525efc42..f19cd30d309a 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/rekey/RealNetPRekeyerTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/rekey/RealNetPRekeyerTest.kt @@ -21,10 +21,13 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor import com.duckduckgo.mobile.android.vpn.VpnFeaturesRegistry import com.duckduckgo.networkprotection.impl.NetPVpnFeature.NETP_VPN -import com.duckduckgo.networkprotection.impl.configuration.WgServerApi +import com.duckduckgo.networkprotection.impl.configuration.WgTunnel +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixels -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository -import java.lang.RuntimeException +import com.wireguard.config.Config +import com.wireguard.crypto.KeyPair +import java.io.BufferedReader +import java.io.StringReader import java.util.concurrent.TimeUnit import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -40,9 +43,6 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class RealNetPRekeyerTest { - @Mock - private lateinit var networkProtectionRepository: NetworkProtectionRepository - @Mock private lateinit var vpnFeaturesRegistry: VpnFeaturesRegistry @@ -50,12 +50,32 @@ class RealNetPRekeyerTest { private lateinit var networkProtectionPixels: NetworkProtectionPixels @Mock - private lateinit var wgServerApi: WgServerApi + private lateinit var appBuildConfig: AppBuildConfig @Mock - private lateinit var appBuildConfig: AppBuildConfig + private lateinit var wgTunnel: WgTunnel + + @Mock + private lateinit var wgTunnelConfig: WgTunnelConfig private var isDeviceLocked = false + private val keys = KeyPair() + + private val wgQuickConfig = """ + [Interface] + Address = 1.1.1.2 + DNS = 1.1.1.1 + MTU = 1280 + PrivateKey = ${keys.privateKey.toBase64()} + + [Peer] + AllowedIPs = 0.0.0.0/0 + Endpoint = 12.12.12.12:443 + Name = name + Location = Furadouro + PublicKey = ${keys.publicKey.toBase64()} + """.trimIndent() + private val config = Config.parse(BufferedReader(StringReader(wgQuickConfig))) private lateinit var testee: RealNetPRekeyer @@ -65,32 +85,41 @@ class RealNetPRekeyerTest { whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) runBlocking { - whenever(wgServerApi.registerPublicKey(any())).thenReturn( - WgServerApi.WgServerData( - serverName = "", - publicKey = "key", - publicEndpoint = "endpoint", - address = "1.1.1.2", - location = null, - gateway = "1.1.1.1", - ), - ) + whenever(wgTunnel.createWgConfig(any())).thenReturn(Result.success(config)) + whenever(wgTunnel.createAndSetWgConfig(any())).thenReturn(Result.success(config)) } testee = RealNetPRekeyer( - networkProtectionRepository, vpnFeaturesRegistry, networkProtectionPixels, "name", - wgServerApi, + wgTunnel, + wgTunnelConfig, appBuildConfig, { isDeviceLocked }, ) } @Test - fun `do not rekey if time since last rekey is less than 24h`() = runTest { + fun `do not rekey in production if time since last rekey is less than 24h`() = runTest { + whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) + .thenReturn(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(23)) + isDeviceLocked = true + whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) + + testee.doRekey() + + assertNoRekey() + } + + @Test + fun `do not rekey in internal if time since last rekey is less than 24h`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) + .thenReturn(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(23)) + isDeviceLocked = true + whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.INTERNAL) testee.doRekey() @@ -100,9 +129,8 @@ class RealNetPRekeyerTest { @Test fun `do not rekey if registering new key fails`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - whenever(wgServerApi.registerPublicKey(any())).thenThrow(RuntimeException("")) testee.doRekey() @@ -112,7 +140,7 @@ class RealNetPRekeyerTest { @Test fun `do not rekey device is not locked`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = false @@ -124,7 +152,7 @@ class RealNetPRekeyerTest { @Test fun `do not rekey if not internal build and forced rekey`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = true whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) @@ -137,7 +165,7 @@ class RealNetPRekeyerTest { @Test fun `do rekey if internal build and forced rekey`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = true whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.INTERNAL) @@ -150,7 +178,7 @@ class RealNetPRekeyerTest { @Test fun `do not rekey if internal build and forced rekey but vpn disabled`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(false) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = true whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.INTERNAL) @@ -163,7 +191,7 @@ class RealNetPRekeyerTest { @Test fun `do rekey if production build`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(true) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = true whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) @@ -176,7 +204,7 @@ class RealNetPRekeyerTest { @Test fun `do not rekey if production build but vpn disabled`() = runTest { whenever(vpnFeaturesRegistry.isFeatureRegistered(NETP_VPN)).thenReturn(false) - whenever(networkProtectionRepository.lastPrivateKeyUpdateTimeInMillis) + whenever(wgTunnelConfig.getWgConfigCreatedAt()) .thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) isDeviceLocked = true whenever(appBuildConfig.flavor).thenReturn(BuildFlavor.PLAY) @@ -187,13 +215,15 @@ class RealNetPRekeyerTest { } private suspend fun assertNoRekey() { - verify(networkProtectionRepository, never()).privateKey = any() + verify(wgTunnel, never()).createWgConfig(any()) + verify(wgTunnel, never()).createAndSetWgConfig(any()) verify(vpnFeaturesRegistry, never()).refreshFeature(NETP_VPN) verify(networkProtectionPixels, never()).reportRekeyCompleted() } private suspend fun assertRekey() { - verify(networkProtectionRepository).privateKey = any() + verify(wgTunnel, never()).createWgConfig(any()) + verify(wgTunnel).createAndSetWgConfig(any()) verify(vpnFeaturesRegistry).refreshFeature(NETP_VPN) verify(networkProtectionPixels).reportRekeyCompleted() } diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModelTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModelTest.kt index 60b49620c789..ff619bee42e7 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModelTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/settings/geoswitching/NetpGeoSwitchingViewModelTest.kt @@ -206,7 +206,7 @@ class NetpGeoSwitchingViewModelTest { testee.onCountrySelected("us") testee.onStop(mockLifecycleOwner) - verify(networkProtectionState).restart() + verify(networkProtectionState).clearVPNConfigurationAndRestart() } @Test @@ -218,7 +218,7 @@ class NetpGeoSwitchingViewModelTest { fakeRepository.setUserPreferredLocation(UserPreferredLocation(countryCode = "us", cityName = "Newark")) testee.onStop(mockLifecycleOwner) - verify(networkProtectionState).restart() + verify(networkProtectionState).clearVPNConfigurationAndRestart() } @Test diff --git a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/store/RealNetworkProtectionRepositoryTest.kt b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/store/RealNetworkProtectionRepositoryTest.kt index 41bf9c9f32cc..560bff68870a 100644 --- a/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/store/RealNetworkProtectionRepositoryTest.kt +++ b/network-protection/network-protection-impl/src/test/java/com/duckduckgo/networkprotection/impl/store/RealNetworkProtectionRepositoryTest.kt @@ -16,155 +16,56 @@ package com.duckduckgo.networkprotection.impl.store -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository.ServerDetails +import com.duckduckgo.mobile.android.vpn.prefs.FakeVpnSharedPreferencesProvider import com.duckduckgo.networkprotection.store.NetworkProtectionPrefs +import com.duckduckgo.networkprotection.store.RealNetworkProtectionPrefs +import com.wireguard.config.Config +import com.wireguard.crypto.KeyPair +import java.io.BufferedReader +import java.io.StringReader import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test -import org.mockito.ArgumentMatchers.anyLong -import org.mockito.Mock import org.mockito.MockitoAnnotations -import org.mockito.kotlin.eq -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever class RealNetworkProtectionRepositoryTest { - @Mock private lateinit var networkProtectionPrefs: NetworkProtectionPrefs + private val keys = KeyPair() + + private val wgQuickConfig = """ + [Interface] + Address = 1.1.1.2/32 + DNS = 1.1.1.1 + MTU = 1280 + PrivateKey = ${keys.privateKey.toBase64()} + + [Peer] + AllowedIPs = 0.0.0.0/0, 0.0.0.0/5, 8.0.0.0/7 + Endpoint = 1.1.1.1:443 + Name = expected_server_name + Location = expected_location + PublicKey = ${keys.publicKey.toBase64()} + """.trimIndent() + private val config = Config.parse(BufferedReader(StringReader(wgQuickConfig))) + private lateinit var testee: RealNetworkProtectionRepository @Before fun setUp() { MockitoAnnotations.openMocks(this) + networkProtectionPrefs = RealNetworkProtectionPrefs(FakeVpnSharedPreferencesProvider()) testee = RealNetworkProtectionRepository(networkProtectionPrefs) - - whenever(networkProtectionPrefs.getString("wg_server_name", null)).thenReturn("expected_server_name") - whenever(networkProtectionPrefs.getString("wg_server_ip", null)).thenReturn("expected_ip") - whenever(networkProtectionPrefs.getString("wg_server_location", null)).thenReturn("expected_location") - whenever(networkProtectionPrefs.getString("wg_private_key", null)).thenReturn("expected_private_key") - whenever(networkProtectionPrefs.getLong("wg_server_enable_time", -1L)).thenReturn(123124312312L) - } - - @Test - fun whenGettingPrivateKeyThenGetStringFromPrefs() { - assertEquals("expected_private_key", testee.privateKey) - } - - @Test - fun whenSettingPrivateKeyWithValueThenPutStringInPrefsAndSetLastUpdate() { - testee.privateKey = "privateKey" - - verify(networkProtectionPrefs).putString("wg_private_key", "privateKey") - verify(networkProtectionPrefs).putLong(eq("wg_private_key_last_update"), anyLong()) - } - - @Test - fun whenSettingPrivateKeyNullThenClearPrivateKeyAndLastUpdate() { - testee.privateKey = null - - verify(networkProtectionPrefs).putString("wg_private_key", null) - verify(networkProtectionPrefs).putLong("wg_private_key_last_update", -1L) } @Test - fun whenGettingEnabledTimeMillisThenGetLongFromPrefs() { - assertEquals(123124312312L, testee.enabledTimeInMillis) + fun whenNoEnabledTimeMillisThenReturnDefaultValue() { + assertEquals(-1, testee.enabledTimeInMillis) } @Test fun whenSettingEnabledTimeMillisThenPutLongInPrefs() { testee.enabledTimeInMillis = 12243235423453L - verify(networkProtectionPrefs).putLong("wg_server_enable_time", 12243235423453L) - } - - @Test - fun whenGettingServerDetailsThenGetServerDetailsFromPrefs() { - assertEquals( - ServerDetails( - serverName = "expected_server_name", - ipAddress = "expected_ip", - location = "expected_location", - ), - testee.serverDetails, - ) - } - - @Test - fun whenSettingServerDetailsThenPutLongInPrefs() { - testee.serverDetails = ServerDetails( - serverName = "expected_server_name", - ipAddress = "expected_ip", - location = "expected_location", - ) - - verify(networkProtectionPrefs).putString("wg_server_ip", "expected_ip") - verify(networkProtectionPrefs).putString("wg_server_location", "expected_location") - } - - @Test - fun whenServerDetailsIsSetToNullThenSetIpAndLocationToNull() { - testee.serverDetails = null - - verify(networkProtectionPrefs).putString("wg_server_ip", null) - verify(networkProtectionPrefs).putString("wg_server_location", null) - } - - @Test - fun whenBothIpAndLocationAreNullThenServerDetailsReturnNull() { - whenever(networkProtectionPrefs.getString("wg_server_ip", null)).thenReturn(null) - whenever(networkProtectionPrefs.getString("wg_server_location", null)).thenReturn(null) - - assertNull(testee.serverDetails) - } - - @Test - fun whenIpIsNullThenServerDetailsIpIsNull() { - whenever(networkProtectionPrefs.getString("wg_server_ip", null)).thenReturn(null) - - assertEquals( - ServerDetails( - serverName = "expected_server_name", - ipAddress = null, - location = "expected_location", - ), - testee.serverDetails, - ) - } - - @Test - fun whenLocationIsNullThenServerDetailsLocationIsNull() { - whenever(networkProtectionPrefs.getString("wg_server_location", null)).thenReturn(null) - - assertEquals( - ServerDetails( - serverName = "expected_server_name", - ipAddress = "expected_ip", - location = null, - ), - testee.serverDetails, - ) - } - - @Test - fun whenServerNameIsNullThenServerDetailsNameIsNull() { - whenever(networkProtectionPrefs.getString("wg_server_name", null)).thenReturn(null) - - assertEquals( - ServerDetails( - serverName = null, - ipAddress = "expected_ip", - location = "expected_location", - ), - testee.serverDetails, - ) - } - - @Test - fun whenClearStoreThenClearStore() { - networkProtectionPrefs.clear() - - verify(networkProtectionPrefs).clear() + assertEquals(12243235423453L, networkProtectionPrefs.getLong("wg_server_enable_time", -1)) } } diff --git a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt index 423046b513f4..b27455345eb1 100644 --- a/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt +++ b/network-protection/network-protection-internal/src/main/java/com/duckduckgo/networkprotection/internal/feature/NetPInternalSettingsActivity.kt @@ -39,9 +39,9 @@ import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.networkprotection.api.NetworkProtectionState +import com.duckduckgo.networkprotection.impl.configuration.WgTunnelConfig import com.duckduckgo.networkprotection.impl.connectionclass.ConnectionQualityStore import com.duckduckgo.networkprotection.impl.connectionclass.asConnectionQuality -import com.duckduckgo.networkprotection.impl.store.NetworkProtectionRepository import com.duckduckgo.networkprotection.internal.databinding.ActivityNetpInternalSettingsBinding import com.duckduckgo.networkprotection.internal.feature.NetPEnvironmentSettingActivity.Companion.NetPEnvironmentSettingScreen import com.duckduckgo.networkprotection.internal.feature.snooze.VpnDisableOnCall @@ -55,8 +55,6 @@ import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository import com.duckduckgo.networkprotection.store.NetPGeoswitchingRepository.UserPreferredLocation import com.duckduckgo.networkprotection.store.remote_config.NetPServerRepository import com.google.android.material.snackbar.Snackbar -import com.wireguard.crypto.Key -import com.wireguard.crypto.KeyPair import java.io.FileInputStream import java.text.SimpleDateFormat import java.util.* @@ -79,7 +77,7 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var serverRepository: NetPServerRepository - @Inject lateinit var netpRepository: NetworkProtectionRepository + @Inject lateinit var wgTunnelConfig: WgTunnelConfig @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -147,17 +145,18 @@ class NetPInternalSettingsActivity : DuckDuckGoActivity() { binding.overrideMtuSelector.isEnabled = isEnabled binding.overrideMtuSelector.setSecondaryText("MTU size: ${netPInternalMtuProvider.getMtu()}") binding.overrideServerBackendSelector.isEnabled = isEnabled - binding.overrideServerBackendSelector.setSecondaryText("${serverRepository.getSelectedServer()?.name ?: AUTOMATIC}") + binding.overrideServerBackendSelector.setSecondaryText(serverRepository.getSelectedServer()?.name ?: AUTOMATIC) binding.forceRekey.isEnabled = isEnabled if (isEnabled) { - netpRepository.clientInterface?.tunnelCidrSet?.joinToString(", ")?.let { + val wgConfig = wgTunnelConfig.getWgConfig() + wgConfig?.`interface`?.addresses?.joinToString(", ") { it.toString() }?.let { binding.internalIp.show() binding.internalIp.setSecondaryText(it) } ?: binding.internalIp.gone() - netpRepository.privateKey?.let { - "Device Public key: ${KeyPair(Key.fromBase64(it)).publicKey.toBase64()}".run { - if (netpRepository.lastPrivateKeyUpdateTimeInMillis != -1L) { - this + "\nLast updated ${formatter.format(netpRepository.lastPrivateKeyUpdateTimeInMillis)}" + wgConfig?.`interface`?.keyPair?.let { keys -> + "Device Public key: ${keys.publicKey.toBase64()}".run { + if (wgTunnelConfig.getWgConfigCreatedAt() != -1L) { + this + "\nLast updated ${formatter.format(wgTunnelConfig.getWgConfigCreatedAt())}" } else { this } From 2fe7a7458a81f08f5acdee647f7dc45d99a868f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez?= Date: Mon, 29 Jan 2024 15:52:37 +0100 Subject: [PATCH 22/26] Sync: Critical Path Tests (#4031) Task/Issue URL: https://app.asana.com/0/1174433894299346/1205433252919607 ### Description This PR adds the e2e test to verify that data can be synced across accounts. ### Steps to test this PR Run maestro test locally, replacing `${CODE}` in `action_add_new_device` with a valid recovery code from the test account https://github.com/duckduckgo/Android/actions/runs/7646940140/job/20836834057 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1206229586525308 --- .github/workflows/sync-critical-path.yml | 2 +- .maestro/shared/open_bookmarks.yaml | 5 ++ .maestro/shared/open_sync_screen.yaml | 6 +- ...reate_account.yaml => create_account.yaml} | 3 +- .maestro/sync_flows/delete_server_data.yaml | 18 +++++ .maestro/sync_flows/recover_account.yaml | 18 +++++ .../action_add_bookmarks_and_folders.yaml | 69 +++++++++++++++++++ .../action_add_new_device.yaml} | 14 ++-- .../sync_flows/steps/action_disable_sync.yaml | 14 ++++ .../action_enable_unified_favourites.yaml | 11 +++ .../action_recover_account.yaml} | 13 +--- .../action_verify_bookmarks_and_folders.yaml | 13 ++++ .../steps/script_add_bookmark_domains.js | 4 ++ .../steps/script_verify_bookmark_domains.js | 4 ++ .maestro/sync_flows/sync_data.yaml | 17 +++++ 15 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 .maestro/shared/open_bookmarks.yaml rename .maestro/sync_flows/{1_-_create_account.yaml => create_account.yaml} (80%) create mode 100644 .maestro/sync_flows/delete_server_data.yaml create mode 100644 .maestro/sync_flows/recover_account.yaml create mode 100644 .maestro/sync_flows/steps/action_add_bookmarks_and_folders.yaml rename .maestro/sync_flows/{critical_path_recover_account.yaml => steps/action_add_new_device.yaml} (53%) create mode 100644 .maestro/sync_flows/steps/action_disable_sync.yaml create mode 100644 .maestro/sync_flows/steps/action_enable_unified_favourites.yaml rename .maestro/sync_flows/{2_-_recover_account.yaml => steps/action_recover_account.yaml} (56%) create mode 100644 .maestro/sync_flows/steps/action_verify_bookmarks_and_folders.yaml create mode 100644 .maestro/sync_flows/steps/script_add_bookmark_domains.js create mode 100644 .maestro/sync_flows/steps/script_verify_bookmark_domains.js create mode 100644 .maestro/sync_flows/sync_data.yaml diff --git a/.github/workflows/sync-critical-path.yml b/.github/workflows/sync-critical-path.yml index 6c2a6e2ae116..0869f576534c 100644 --- a/.github/workflows/sync-critical-path.yml +++ b/.github/workflows/sync-critical-path.yml @@ -12,7 +12,7 @@ concurrency: jobs: instrumentation_tests: runs-on: ubuntu-latest - name: End-to-End tests + name: Sync Feature Critical Path End-to-End tests steps: - name: Checkout repository diff --git a/.maestro/shared/open_bookmarks.yaml b/.maestro/shared/open_bookmarks.yaml new file mode 100644 index 000000000000..a08c7917124a --- /dev/null +++ b/.maestro/shared/open_bookmarks.yaml @@ -0,0 +1,5 @@ +appId: com.duckduckgo.mobile.android +--- +- tapOn: + id: "com.duckduckgo.mobile.android:id/browserMenuImageView" +- tapOn: "Bookmarks" \ No newline at end of file diff --git a/.maestro/shared/open_sync_screen.yaml b/.maestro/shared/open_sync_screen.yaml index f7c10773f7df..7df91b96358d 100644 --- a/.maestro/shared/open_sync_screen.yaml +++ b/.maestro/shared/open_sync_screen.yaml @@ -1,8 +1,6 @@ appId: com.duckduckgo.mobile.android --- - tapOn: - id: "com.duckduckgo.mobile.android:id/browserMenuImageView" + id: "com.duckduckgo.mobile.android:id/browserMenuImageView" - tapOn: "Settings" -- tapOn: - id: "com.duckduckgo.mobile.android:id/item_container" - index: 8 +- tapOn: "Sync & Backup" \ No newline at end of file diff --git a/.maestro/sync_flows/1_-_create_account.yaml b/.maestro/sync_flows/create_account.yaml similarity index 80% rename from .maestro/sync_flows/1_-_create_account.yaml rename to .maestro/sync_flows/create_account.yaml index 2b4ffbea7cb7..28434497ae9c 100644 --- a/.maestro/sync_flows/1_-_create_account.yaml +++ b/.maestro/sync_flows/create_account.yaml @@ -1,5 +1,6 @@ +# Test Definition: https://app.asana.com/0/1205017362573508/1205017364481021 appId: com.duckduckgo.mobile.android -name: "ReleaseTest: Sync can create an account" +name: "ReleaseTest: Users can create an account" tags: - syncTest --- diff --git a/.maestro/sync_flows/delete_server_data.yaml b/.maestro/sync_flows/delete_server_data.yaml new file mode 100644 index 000000000000..95fc3009eff7 --- /dev/null +++ b/.maestro/sync_flows/delete_server_data.yaml @@ -0,0 +1,18 @@ +# Test Definition: https://app.asana.com/0/1205017362573508/1206195332210606 +appId: com.duckduckgo.mobile.android +name: "ReleaseTest: Users can remove all data" +tags: + - syncTest +--- +- launchApp: + clearState: true + stopApp: true +- runFlow: create_account.yaml +- scrollUntilVisible: + element: + text: "Turn Off and Delete Server Data…" + direction: DOWN +- assertVisible: "Turn Off and Delete Server Data…" +- tapOn: "Turn Off and Delete Server Data…" +- tapOn: "Turn Off" +- assertVisible: "Sync and Back Up This Device" \ No newline at end of file diff --git a/.maestro/sync_flows/recover_account.yaml b/.maestro/sync_flows/recover_account.yaml new file mode 100644 index 000000000000..29e1ad2451a3 --- /dev/null +++ b/.maestro/sync_flows/recover_account.yaml @@ -0,0 +1,18 @@ +# Test Definition: https://app.asana.com/0/1205017362573508/1205044961533553/f +# Test Definition: https://app.asana.com/0/1205017362573508/1205044961533551/f +appId: com.duckduckgo.mobile.android +name: "ReleaseTest: Users can recover an account / Device can be added to an account" +tags: + - syncTest +--- +- launchApp: + clearState: true + stopApp: true +- runFlow: create_account.yaml +- tapOn: "Show Text Code" +- copyTextFrom: + id: "com.duckduckgo.mobile.android:id/recoveryCode" +- tapOn: "Navigate up" +- tapOn: "Turn Off Sync & Backup…" +- tapOn: "Turn Off" +- runFlow: ../sync_flows/steps/action_recover_account.yaml \ No newline at end of file diff --git a/.maestro/sync_flows/steps/action_add_bookmarks_and_folders.yaml b/.maestro/sync_flows/steps/action_add_bookmarks_and_folders.yaml new file mode 100644 index 000000000000..70b4d4a99cfc --- /dev/null +++ b/.maestro/sync_flows/steps/action_add_bookmarks_and_folders.yaml @@ -0,0 +1,69 @@ +# Expected state: Browser screen +appId: com.duckduckgo.mobile.android +--- +- runScript: script_add_bookmark_domains.js +# Until here we've joined an account that has predefined data +# Now, we add a couple bookmarks +- tapOn: "Search or type URL" +- inputText: "${output.bookmarks.domains[0]}" +# We need this because Dax Dialogs sometimes appears before pressing Enter +- runFlow: + when: + visible: + id: "com.duckduckgo.mobile.android:id/primaryCta" + commands: + - tapOn: + id: "com.duckduckgo.mobile.android:id/primaryCta" +- pressKey: Enter +- runFlow: + when: + visible: + id: "com.duckduckgo.mobile.android:id/primaryCta" + commands: + - tapOn: + id: "com.duckduckgo.mobile.android:id/primaryCta" +- tapOn: + id: "com.duckduckgo.mobile.android:id/browserMenuImageView" +- tapOn: "Add Bookmark" +- tapOn: "Search or type URL" +# Now, we add a favourite +- inputText: "${output.bookmarks.domains[1]}" +- pressKey: Enter +- tapOn: + id: "com.duckduckgo.mobile.android:id/browserMenuImageView" +- tapOn: "Add Bookmark" +- tapOn: + id: "com.duckduckgo.mobile.android:id/bookmarksBottomSheetSwitch" +- runFlow: + when: + visible: + id: "com.duckduckgo.mobile.android:id/dialogTextCta" + commands: + - tapOn: + id: "com.duckduckgo.mobile.android:id/item_container" + index: 1 +- tapOn: + id: "com.duckduckgo.mobile.android:id/touch_outside" +# Now, we create a new folder +- runFlow: ../../shared/open_bookmarks.yaml +- tapOn: "Add Folder" +- tapOn: "Title" +- inputText: "${output.bookmarks.folders[0]}" +- tapOn: "Confirm" +# Move first item to newly created folder +- tapOn: + id: "com.duckduckgo.mobile.android:id/trailingIcon" + index: 1 +- tapOn: "Edit" +- tapOn: "Bookmarks" +- tapOn: "${output.bookmarks.folders[0]}" +- tapOn: "Confirm" +- tapOn: "Navigate up" +- runFlow: + when: + visible: + id: "com.duckduckgo.mobile.android:id/dialogTextCta" + commands: + - tapOn: + id: "com.duckduckgo.mobile.android:id/item_container" + index: 1 \ No newline at end of file diff --git a/.maestro/sync_flows/critical_path_recover_account.yaml b/.maestro/sync_flows/steps/action_add_new_device.yaml similarity index 53% rename from .maestro/sync_flows/critical_path_recover_account.yaml rename to .maestro/sync_flows/steps/action_add_new_device.yaml index 496b1be810a9..19be03e81082 100644 --- a/.maestro/sync_flows/critical_path_recover_account.yaml +++ b/.maestro/sync_flows/steps/action_add_new_device.yaml @@ -1,13 +1,13 @@ +# This test uses an input generated by a Github Action. The action creates an account with predefined data +# Expected state: Test start appId: com.duckduckgo.mobile.android -name: "Sync Critical Path: Account can be recovered" -tags: - - syncCriticalPathTest +name: "Sync Critical Path: Devices can be added to an existing account" --- - launchApp: clearState: true stopApp: true -- runFlow: ../shared/onboarding.yaml -- runFlow: ../shared/open_sync_dev_settings_screen.yaml +- runFlow: ../../shared/onboarding.yaml +- runFlow: ../../shared/open_sync_dev_settings_screen.yaml - tapOn: id: "com.duckduckgo.mobile.android:id/trailingSwitch" - tapOn: @@ -22,4 +22,6 @@ tags: direction: UP - tapOn: "Sync & Backup" - assertVisible: - id: "com.duckduckgo.mobile.android:id/qrCodeImageView" \ No newline at end of file + id: "com.duckduckgo.mobile.android:id/qrCodeImageView" +- tapOn: "Navigate up" +- tapOn: "Navigate up" \ No newline at end of file diff --git a/.maestro/sync_flows/steps/action_disable_sync.yaml b/.maestro/sync_flows/steps/action_disable_sync.yaml new file mode 100644 index 000000000000..bcb954dba66d --- /dev/null +++ b/.maestro/sync_flows/steps/action_disable_sync.yaml @@ -0,0 +1,14 @@ +# Expected state: Sync Settings screen +appId: com.duckduckgo.mobile.android +--- +# Open Sync screen and enable unified favourites +- scrollUntilVisible: + element: + text: "Turn Off Sync & Backup…" + direction: UP +- tapOn: "Turn Off Sync & Backup…" +- tapOn: "Turn Off" +- pressKey: Home +- launchApp: + clearState: true + stopApp: true \ No newline at end of file diff --git a/.maestro/sync_flows/steps/action_enable_unified_favourites.yaml b/.maestro/sync_flows/steps/action_enable_unified_favourites.yaml new file mode 100644 index 000000000000..5a395a3e9abb --- /dev/null +++ b/.maestro/sync_flows/steps/action_enable_unified_favourites.yaml @@ -0,0 +1,11 @@ +# Expected state: Browser screen +appId: com.duckduckgo.mobile.android +--- +# Open Sync screen and enable unified favourites +- runFlow: ../../shared/open_sync_screen.yaml +- scrollUntilVisible: + element: + text: "Save Recovery PDF" + direction: DOWN +- tapOn: + id: "com.duckduckgo.mobile.android:id/trailingSwitch" \ No newline at end of file diff --git a/.maestro/sync_flows/2_-_recover_account.yaml b/.maestro/sync_flows/steps/action_recover_account.yaml similarity index 56% rename from .maestro/sync_flows/2_-_recover_account.yaml rename to .maestro/sync_flows/steps/action_recover_account.yaml index 24c47a2a9053..65cfd231b307 100644 --- a/.maestro/sync_flows/2_-_recover_account.yaml +++ b/.maestro/sync_flows/steps/action_recover_account.yaml @@ -1,17 +1,6 @@ +# Expected state: Sync Settings screen appId: com.duckduckgo.mobile.android -name: "ReleaseTest: Sync can recover a created account" -tags: - - syncTest --- -- launchApp: - clearState: true - stopApp: true -- runFlow: ../sync_flows/1_-_create_account.yaml -- tapOn: "Show Text Code" -- tapOn: "Copy Code" -- tapOn: "Navigate up" -- tapOn: "Turn Off Sync & Backup…" -- tapOn: "Turn Off" - tapOn: "Recover Synced Data" - tapOn: "Get Started" - tapOn: "Or Manually Enter Code" diff --git a/.maestro/sync_flows/steps/action_verify_bookmarks_and_folders.yaml b/.maestro/sync_flows/steps/action_verify_bookmarks_and_folders.yaml new file mode 100644 index 000000000000..95bfed198a72 --- /dev/null +++ b/.maestro/sync_flows/steps/action_verify_bookmarks_and_folders.yaml @@ -0,0 +1,13 @@ +# Expected state: Browser screen +appId: com.duckduckgo.mobile.android +--- +- runScript: script_verify_bookmark_domains.js +# Verify newly created bookmarks and folders have been added +- runFlow: ../../shared/open_bookmarks.yaml +- assertVisible: "${output.bookmarks.titles[0]}" +- assertVisible: "${output.bookmarks.titles[1]}" +- assertVisible: "${output.bookmarks.folders[0]}" +- tapOn: "${output.bookmarks.folders[0]}" +- assertVisible: "${output.bookmarks.titles[2]}" +- tapOn: "Navigate up" +- tapOn: "Navigate up" \ No newline at end of file diff --git a/.maestro/sync_flows/steps/script_add_bookmark_domains.js b/.maestro/sync_flows/steps/script_add_bookmark_domains.js new file mode 100644 index 000000000000..6bea70975a2f --- /dev/null +++ b/.maestro/sync_flows/steps/script_add_bookmark_domains.js @@ -0,0 +1,4 @@ +output.bookmarks = { + domains: ["https://www.example.com", "fill.dev"], + folders: ["sync"], +} diff --git a/.maestro/sync_flows/steps/script_verify_bookmark_domains.js b/.maestro/sync_flows/steps/script_verify_bookmark_domains.js new file mode 100644 index 000000000000..63413350deb2 --- /dev/null +++ b/.maestro/sync_flows/steps/script_verify_bookmark_domains.js @@ -0,0 +1,4 @@ +output.bookmarks = { + titles: ["Example Domain", "Test Autofill", "DuckDuckGo — Privacy, simplified."], + folders: ["sync"], +} diff --git a/.maestro/sync_flows/sync_data.yaml b/.maestro/sync_flows/sync_data.yaml new file mode 100644 index 000000000000..a2020c45cbd4 --- /dev/null +++ b/.maestro/sync_flows/sync_data.yaml @@ -0,0 +1,17 @@ +# Test Definition: https://app.asana.com/0/1205017362573508/1205044961533557 +appId: com.duckduckgo.mobile.android +name: "ReleaseTest: Data can be synced" +tags: + - syncCriticalPathTest +--- +- runFlow: ../sync_flows/steps/action_add_new_device.yaml +- runFlow: ../sync_flows/steps/action_add_bookmarks_and_folders.yaml +# Enable Unified Favourites +- runFlow: ../sync_flows/steps/action_enable_unified_favourites.yaml +# Disable account +- runFlow: ../sync_flows/steps/action_disable_sync.yaml +# Recover account +- runFlow: ../sync_flows/steps/action_add_new_device.yaml +# Verify newly created bookmarks and folders have been added +# It also verifies that Unified Favourites is turned on +- runFlow: ../sync_flows/steps/action_verify_bookmarks_and_folders.yaml \ No newline at end of file From 7da853b7b25a4315550b4a6513df486eca84d58e Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Mon, 29 Jan 2024 16:18:40 +0100 Subject: [PATCH 23/26] Privacy Protections Popup (#3979) Task/Issue URL: https://app.asana.com/0/1205648422731273/1206122343139850/f ### Description This feature adds popup prompting user to try disabling protections when they refresh the page (which may be a result of observed site breakage). In this PR it is enabled only for debug builds, a feature flag and experiment will be set up in a following / stacked PR. ### Steps to test this PR The feature persists some data to local database - I recommend using the fire button before testing each scenario. #### Shows popup on user-generated page refresh - [x] Navigate to a webpage, eg. www.wikipedia.org - [x] Refresh the page (either from options menu or by pull-to-refresh) - [x] Verify that the popup appears on screen - [x] Dismiss popup (either by touch outside or the dismiss button) - [x] Refresh again - [x] Verify that the popup does not appear this time - [x] Open the same page in new tab - [x] Refresh - [x] Verify that the popup does not appear this time - [x] Navigate to a different site in the same tab - [x] Refresh - [x] Verify that the popup appears on screen #### Protections can be disabled directly from popup - [x] Navigate to a webpage, eg. www.wikipedia.org - [x] Refresh the page (either from options menu or by pull-to-refresh) - [x] Tap "Turn Off Protections" on the popup - [x] Verify that the popup disappeared and website reloaded with protections disabled #### Popup is not shown when protections are already disabled - [x] Navigate to a webpage that has protections disabled e.g. strava.com - [x] Refresh - [x] Verify that the popup does not appear #### Popup is not shown for sites under DDG domain - [x] Type something into search, e.g. "weather" - [x] Refresh - [x] Verify that the popup does not appear #### Using protections toggle suppresses popup on all sites for 2 weeks - [x] Navigate to a webpage, eg. www.wikipedia.org - [x] Disable protections in any of the following ways: - Use the toggle on the Privacy Dashboard - Use the toggle on the Broken Site screen - Tap "Disable Protections" in the options menu - [x] Refresh - [x] Verify that popup does not appear - [x] Navigate to a different site - [x] Refresh - [x] Verify that the popup does not appear - [x] Change date in system settings and move it at least 14 days forward - [x] Navigate to the same site as before - [x] Refresh - [x] Verify that the popup appears on screen #### Popup is shown again for the same domain after 24 hours - [x] Navigate to a webpage, eg. www.wikipedia.org - [x] Refresh the page - [x] Dismiss popup - [x] Refresh - [x] Verify that popup does not appear - [x] Change date in system settings and move it at least 1 day forward - [x] Navigate to the same site as before - [x] Refresh - [x] Verify that the popup appears on screen #### Popup is not shown when page loaded with HTTP error - [x] Navigate to a page that returns 4xx/5xx HTTP status, e.g. www.wikipedia.org/404 - [x] Refresh - [x] Verify that the popup does not appear #### Popup is not shown when host name can't be resolved - [x] (Try to) navigate to a page that doesn't exist, e.g. www.nonexistentpage.com - [x] Verify that page loading error with the "bigfoot" illustration is shown - [x] Refresh (you may need to clear the address bar to make options menu appear) - [x] Verify that popup does not appear ### UI changes | light | dark | |---|---| | ![Screenshot_20231206_115230](https://github.com/duckduckgo/Android/assets/4212474/8dba1026-fd60-4544-98c3-f748c874d38a) | ![Screenshot_20231206_115208](https://github.com/duckduckgo/Android/assets/4212474/50f8d8ea-96b5-4f3f-afc4-7496001f8eb0) | --------- Co-authored-by: Josh Leibstein Co-authored-by: root --- .../impl/pixels/RealAdClickPixelsTest.kt | 5 + .../app/anr/AnrOfflinePixelSender.kt | 4 +- .../app/anr/CrashOfflinePixelSender.kt | 3 +- app/build.gradle | 3 + .../app/browser/BrowserTabViewModelTest.kt | 117 ++- .../duckduckgo/app/cta/ui/CtaViewModelTest.kt | 14 +- .../duckduckgo/app/di/StubStatisticsModule.kt | 11 +- .../view/ClearPersonalDataActionTest.kt | 9 + .../notification/NotificationRegistrarTest.kt | 9 +- .../app/brokensite/BrokenSiteViewModel.kt | 12 +- .../app/brokensite/api/BrokenSiteSender.kt | 4 + .../app/browser/BrowserTabFragment.kt | 32 +- .../app/browser/BrowserTabViewModel.kt | 41 +- .../PageLoadedOfflinePixelSender.kt | 4 +- .../com/duckduckgo/app/di/PrivacyModule.kt | 3 + .../global/view/ClearPersonalDataAction.kt | 4 + .../app/pixels/EnqueuedPixelWorker.kt | 17 +- .../brokensite/api/BrokenSiteSubmitterTest.kt | 43 +- .../DefaultBrowserObserverTest.kt | 5 +- .../app/feedback/BrokenSiteViewModelTest.kt | 64 ++ .../app/pixels/EnqueuedPixelWorkerTest.kt | 43 ++ .../BrokenSitesMultipleReportReferenceTest.kt | 10 +- .../brokensites/BrokenSitesReferenceTest.kt | 11 +- ...ltAutofillOverlappingDialogDetectorTest.kt | 3 +- .../AutofillSettingsViewModelTest.kt | 9 +- .../main/res/values/design-system-colors.xml | 1 + .../InstallSourceLifecycleObserverTest.kt | 5 +- .../privacy-dashboard-impl/build.gradle | 1 + .../ui/PrivacyDashboardHybridViewModel.kt | 23 +- .../ui/PrivacyDashboardHybridViewModelTest.kt | 43 +- .../privacy-protections-popup-api/.gitignore | 0 .../build.gradle | 32 + .../api/PrivacyProtectionsPopup.kt | 46 ++ .../api/PrivacyProtectionsPopupDataClearer.kt | 29 + ...rotectionsPopupExperimentExternalPixels.kt | 52 ++ .../api/PrivacyProtectionsPopupFactory.kt | 29 + .../api/PrivacyProtectionsPopupManager.kt | 60 ++ .../api/PrivacyProtectionsPopupUiEvent.kt | 53 ++ .../api/PrivacyProtectionsPopupViewState.kt | 33 + .../PrivacyProtectionsToggleUsageListener.kt | 26 + .../build.gradle | 86 +++ .../lint-baseline.xml | 4 + ...acyProtectionsPopupDomainsCleanupWorker.kt | 83 +++ ...ctionsPopupExperimentExternalPixelsImpl.kt | 90 +++ ...rivacyProtectionsPopupExperimentVariant.kt | 23 + ...ectionsPopupExperimentVariantRandomizer.kt | 42 ++ .../PrivacyProtectionsPopupFactoryImpl.kt | 32 + .../impl/PrivacyProtectionsPopupFeature.kt | 35 + .../impl/PrivacyProtectionsPopupImpl.kt | 304 ++++++++ ...vacyProtectionsPopupManagerDataProvider.kt | 63 ++ .../PrivacyProtectionsPopupManagerImpl.kt | 296 ++++++++ .../impl/PrivacyProtectionsPopupPixelName.kt | 106 +++ .../impl/PrivacyProtectionsPopupPixels.kt | 122 +++ ...ivacyProtectionsToggleUsageListenerImpl.kt | 34 + .../impl/ProtectionsStateProvider.kt | 57 ++ .../impl/ShieldIconHighlightAnimation.kt | 49 ++ .../impl/TimeProvider.kt | 32 + .../impl/db/PopupDismissDomain.kt | 27 + .../impl/db/PopupDismissDomainRepository.kt | 55 ++ .../impl/db/PopupDismissDomainsDao.kt | 40 + .../PrivacyProtectionsPopupDataClearerImpl.kt | 32 + .../db/PrivacyProtectionsPopupDatabase.kt | 45 ++ .../PrivacyProtectionsPopupDatabaseModule.kt | 47 ++ .../store/PrivacyProtectionsPopupDataStore.kt | 128 ++++ .../PrivacyProtectionsPopupDataStoreModule.kt | 43 ++ .../main/res/drawable/ic_highlight_blue.xml | 24 + .../res/layout/popup_buttons_horizontal.xml | 48 ++ .../res/layout/popup_buttons_vertical.xml | 48 ++ .../res/layout/popup_privacy_dashboard.xml | 104 +++ .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 28 + .../strings-privacy-protections-popup.xml | 27 + .../FakePrivacyProtectionsPopupDataStore.kt | 64 ++ .../impl/FakeTimeProvider.kt | 26 + .../impl/FakeUserAllowlistRepository.kt | 43 ++ ...rotectionsPopupDomainsCleanupWorkerTest.kt | 60 ++ .../PrivacyProtectionsPopupManagerImplTest.kt | 702 ++++++++++++++++++ .../PrivacyProtectionsPopupPixelNameTest.kt | 63 ++ .../impl/PrivacyProtectionsPopupPixelsTest.kt | 184 +++++ .../impl/ProtectionsStateProviderTest.kt | 103 +++ .../db/PopupDismissDomainRepositoryTest.kt | 128 ++++ .../PrivacyProtectionsPopupDataStoreTest.kt | 114 +++ privacy-protections-popup/readme.md | 8 + statistics/build.gradle | 18 +- .../app/statistics/api/PixelSender.kt | 65 +- .../app/statistics/model/DailyPixelFired.kt | 27 + .../app/statistics/model/UniquePixelFired.kt | 25 + .../duckduckgo/app/statistics/pixels/Pixel.kt | 37 +- .../statistics/store/DailyPixelFiredDao.kt | 34 + .../statistics/store/PixelFiredRepository.kt | 57 ++ .../statistics/store/StatisticsDatabase.kt | 53 ++ .../store/StatisticsDatabaseModule.kt | 51 ++ .../app/statistics/store/TimeProvider.kt | 31 + .../statistics/store/UniquePixelFiredDao.kt | 33 + .../app/statistics/AtbInitializerTest.kt | 31 +- .../app/statistics/api/RxPixelSenderTest.kt | 145 +++- .../statistics/api/StatisticsRequesterTest.kt | 0 .../app/statistics/model/AtbJsonTest.kt | 0 .../app/statistics/pixels/RxBasedPixelTest.kt | 13 +- .../store/PixelFiredRepositoryTest.kt | 110 +++ .../resources/json/atb_response_valid.json | 6 + versions.properties | 4 +- 124 files changed, 5843 insertions(+), 103 deletions(-) create mode 100644 privacy-protections-popup/privacy-protections-popup-api/.gitignore create mode 100644 privacy-protections-popup/privacy-protections-popup-api/build.gradle create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopup.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupDataClearer.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupExperimentExternalPixels.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupFactory.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupUiEvent.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsToggleUsageListener.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/build.gradle create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorker.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentExternalPixelsImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariant.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariantRandomizer.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFactoryImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerDataProvider.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelName.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixels.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsToggleUsageListenerImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProvider.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ShieldIconHighlightAnimation.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/TimeProvider.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomain.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepository.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainsDao.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDataClearerImpl.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabase.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabaseModule.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStore.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreModule.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/drawable/ic_highlight_blue.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_horizontal.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_vertical.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-bg/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-cs/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-da/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-de/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-el/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-es/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-et/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fi/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fr/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hr/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hu/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-it/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lt/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lv/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nb/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nl/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pl/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pt/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ro/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ru/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sk/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sl/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sv/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-tr/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values/strings-privacy-protections-popup.xml create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakePrivacyProtectionsPopupDataStore.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeTimeProvider.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeUserAllowlistRepository.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorkerTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelsTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProviderTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepositoryTest.kt create mode 100644 privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreTest.kt create mode 100644 privacy-protections-popup/readme.md create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/model/DailyPixelFired.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/model/UniquePixelFired.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/DailyPixelFiredDao.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/PixelFiredRepository.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabase.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabaseModule.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/TimeProvider.kt create mode 100644 statistics/src/main/java/com/duckduckgo/app/statistics/store/UniquePixelFiredDao.kt rename {app => statistics}/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt (84%) rename {app/src/androidTest => statistics/src/test}/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt (67%) rename {app => statistics}/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt (100%) rename {app => statistics}/src/test/java/com/duckduckgo/app/statistics/model/AtbJsonTest.kt (100%) rename {app => statistics}/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt (89%) create mode 100644 statistics/src/test/java/com/duckduckgo/app/statistics/store/PixelFiredRepositoryTest.kt create mode 100644 statistics/src/test/resources/json/atb_response_valid.json diff --git a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt index 16298a938c99..6b70570a0e50 100644 --- a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt +++ b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/pixels/RealAdClickPixelsTest.kt @@ -22,6 +22,7 @@ import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adclick.impl.Exemption import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.common.test.api.InMemorySharedPreferences import java.time.Instant import java.util.concurrent.TimeUnit @@ -247,6 +248,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = any(), encodedParameters = any(), + type = eq(COUNT), ) } @@ -262,6 +264,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = eq(mapOf(AdClickPixelParameters.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION_COUNT to "1")), encodedParameters = any(), + type = eq(COUNT), ) } @@ -280,6 +283,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = any(), encodedParameters = any(), + type = eq(COUNT), ) } @@ -298,6 +302,7 @@ class RealAdClickPixelsTest { pixel = eq(AdClickPixelName.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION), parameters = eq(mapOf(AdClickPixelParameters.AD_CLICK_PAGELOADS_WITH_AD_ATTRIBUTION_COUNT to "1")), encodedParameters = any(), + type = eq(COUNT), ) } } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt index 91bd60c53961..1096288ed25a 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/AnrOfflinePixelSender.kt @@ -21,6 +21,7 @@ import com.duckduckgo.anrs.api.AnrRepository import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import io.reactivex.Completable @@ -45,7 +46,8 @@ class AnrOfflinePixelSender @Inject constructor( ANR_WEBVIEW_VERSION to it.webView, ), mapOf(), - ).doOnComplete { + COUNT, + ).ignoreElement().doOnComplete { anrRepository.removeMostRecentAnr() } } diff --git a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt index b0c9b876bc86..8ed90dcffb39 100644 --- a/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt +++ b/anrs/anrs-impl/src/main/java/com/duckduckgo/app/anr/CrashOfflinePixelSender.kt @@ -20,6 +20,7 @@ import android.util.Base64 import com.duckduckgo.app.anrs.store.UncaughtExceptionDao import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.pixels.Pixel.StatisticsPixelName.APPLICATION_CRASH_GLOBAL import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.di.scopes.AppScope @@ -54,7 +55,7 @@ class CrashOfflinePixelSender @Inject constructor( ) val pixel = - pixelSender.sendPixel(APPLICATION_CRASH_GLOBAL.pixelName, params, emptyMap()).doOnComplete { + pixelSender.sendPixel(APPLICATION_CRASH_GLOBAL.pixelName, params, emptyMap(), COUNT).ignoreElement().doOnComplete { logcat { "Sent pixel with params: $params containing exception; deleting exception with id=${exception.hash}" } uncaughtExceptionDao.delete(exception) } diff --git a/app/build.gradle b/app/build.gradle index 7f800f1f9bac..096cbeaef8f9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -218,6 +218,9 @@ dependencies { implementation project(':privacy-dashboard-api') implementation project(':privacy-dashboard-impl') + implementation project(":privacy-protections-popup-api") + implementation project(":privacy-protections-popup-impl") + implementation project(':remote-messaging-api') implementation project(':remote-messaging-impl') implementation project(':remote-messaging-store') 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 27ab739e1bf0..fbf24c264df3 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -110,6 +110,7 @@ import com.duckduckgo.app.privacy.model.TestEntity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.survey.api.SurveyRepository import com.duckduckgo.app.survey.model.Survey @@ -142,6 +143,11 @@ import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE import com.duckduckgo.privacy.config.store.features.gpc.GpcRepository +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.duckduckgo.remote.messaging.api.Content import com.duckduckgo.remote.messaging.api.RemoteMessage import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository @@ -170,6 +176,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest @@ -407,6 +414,14 @@ class BrowserTabViewModelTest { private val mockToggle: Toggle = mock() + private val mockPrivacyProtectionsPopupManager: PrivacyProtectionsPopupManager = mock() + + private val mockPrivacyProtectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener = mock() + + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -476,6 +491,7 @@ class BrowserTabViewModelTest { whenever(mockContentBlocking.isAnException(anyString())).thenReturn(false) whenever(fireproofDialogsEventHandler.event).thenReturn(fireproofDialogsEventHandlerLiveData) whenever(cameraHardwareChecker.hasCameraHardware()).thenReturn(true) + whenever(mockPrivacyProtectionsPopupManager.viewState).thenReturn(flowOf(PrivacyProtectionsPopupViewState.Gone)) testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -531,6 +547,9 @@ class BrowserTabViewModelTest { sitePermissionsManager = mockSitePermissionsManager, cameraHardwareChecker = cameraHardwareChecker, androidBrowserConfig = androidBrowserConfig, + privacyProtectionsPopupManager = mockPrivacyProtectionsPopupManager, + privacyProtectionsToggleUsageListener = mockPrivacyProtectionsToggleUsageListener, + privacyProtectionsPopupExperimentExternalPixels = privacyProtectionsPopupExperimentExternalPixels, ) testee.loadData("abc", null, false, false) @@ -1493,7 +1512,7 @@ class BrowserTabViewModelTest { givenOneActiveTabSelected() givenInvalidatedGlobalLayout() - testee.onRefreshRequested() + testee.onRefreshRequested(triggeredByUser = true) assertCommandIssued { assertNull(sourceTabId) @@ -1505,7 +1524,7 @@ class BrowserTabViewModelTest { givenOneActiveTabSelected() givenInvalidatedGlobalLayout() - testee.onRefreshRequested() + testee.onRefreshRequested(triggeredByUser = true) runTest { verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) @@ -1514,7 +1533,7 @@ class BrowserTabViewModelTest { @Test fun whenRefreshRequestedWithBrowserGlobalLayoutThenRefresh() { - testee.onRefreshRequested() + testee.onRefreshRequested(triggeredByUser = true) assertCommandIssued() } @@ -1522,7 +1541,7 @@ class BrowserTabViewModelTest { fun whenRefreshRequestedWithQuerySearchThenFireQueryChangePixelZero() { loadUrl("query") - testee.onRefreshRequested() + testee.onRefreshRequested(triggeredByUser = true) verify(mockPixel).fire("rq_0") } @@ -1531,7 +1550,7 @@ class BrowserTabViewModelTest { fun whenRefreshRequestedWithUrlThenDoNotFireQueryChangePixel() { loadUrl("https://example.com") - testee.onRefreshRequested() + testee.onRefreshRequested(triggeredByUser = true) verify(mockPixel, never()).fire("rq_0") } @@ -4677,6 +4696,94 @@ class BrowserTabViewModelTest { assertCommandIssued() } + @Test + fun whenPrivacyProtectionMenuClickedThenListenerIsInvoked() = runTest { + loadUrl("http://www.example.com/home.html") + testee.onPrivacyProtectionMenuClicked() + verify(mockPrivacyProtectionsToggleUsageListener).onPrivacyProtectionsToggleUsed() + } + + @Test + fun whenPageIsChangedThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + updateUrl( + originalUrl = "example.com", + currentUrl = "example2.com", + isBrowserShowing = true, + ) + + verify(mockPrivacyProtectionsPopupManager).onPageLoaded( + url = "example2.com", + httpErrorCodes = emptyList(), + hasBrowserError = false, + ) + } + + @Test + fun whenPageIsChangedWithWebViewErrorResponseThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + testee.onReceivedError(WebViewErrorResponse.BAD_URL, "example2.com") + + updateUrl( + originalUrl = "example.com", + currentUrl = "example2.com", + isBrowserShowing = true, + ) + + verify(mockPrivacyProtectionsPopupManager).onPageLoaded( + url = "example2.com", + httpErrorCodes = emptyList(), + hasBrowserError = true, + ) + } + + @Test + fun whenPageIsChangedWithHttpErrorThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + testee.recordHttpErrorCode(statusCode = 404, url = "example2.com") + + updateUrl( + originalUrl = "example.com", + currentUrl = "example2.com", + isBrowserShowing = true, + ) + + verify(mockPrivacyProtectionsPopupManager).onPageLoaded( + url = "example2.com", + httpErrorCodes = listOf(404), + hasBrowserError = false, + ) + } + + @Test + fun whenPrivacyProtectionsPopupUiEventIsReceivedThenItIsPassedToPrivacyProtectionsPopupManager() = runTest { + PrivacyProtectionsPopupUiEvent.entries.forEach { event -> + testee.onPrivacyProtectionsPopupUiEvent(event) + verify(mockPrivacyProtectionsPopupManager).onUiEvent(event) + } + } + + @Test + fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + testee.onRefreshRequested(triggeredByUser = false) + verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser() + testee.onRefreshRequested(triggeredByUser = true) + verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser() + } + + @Test + fun whenPrivacyProtectionsAreToggledThenCorrectPixelsAreSent() = runTest { + val params = mapOf("test_key" to "test_value") + whenever(privacyProtectionsPopupExperimentExternalPixels.getPixelParams()).thenReturn(params) + whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.example.com")).thenReturn(false) + loadUrl("http://www.example.com/home.html") + testee.onPrivacyProtectionMenuClicked() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.example.com")).thenReturn(true) + testee.onPrivacyProtectionMenuClicked() + + verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, params, type = COUNT) + verify(mockPixel).fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) + } + private fun aCredential(): LoginCredentials { return LoginCredentials(domain = null, username = null, password = null) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index b4e0af5e197d..51e2637ef656 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -38,6 +38,7 @@ import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.TestEntity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.survey.api.SurveyRepository import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.survey.model.Survey.Status.SCHEDULED @@ -51,7 +52,6 @@ import com.duckduckgo.app.widget.ui.WidgetCapabilities import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.ui.store.AppTheme -import java.util.* import java.util.concurrent.TimeUnit import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.drop @@ -202,32 +202,32 @@ class CtaViewModelTest { @Test fun whenCtaShownAndCtaIsDaxAndCanNotSendPixelThenPixelIsNotFired() { testee.onCtaShown(DaxBubbleCta.DaxIntroCta(mockOnboardingStore, mockAppInstallStore)) - verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any()) + verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(COUNT)) } @Test fun whenCtaShownAndCtaIsDaxAndCanSendPixelThenPixelIsFired() { whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn("s:0") testee.onCtaShown(DaxBubbleCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore)) - verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any()) + verify(mockPixel, never()).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(COUNT)) } @Test fun whenCtaShownAndCtaIsNotDaxThenPixelIsFired() { testee.onCtaShown(HomePanelCta.Survey(Survey("abc", "http://example.com", 1, SCHEDULED))) - verify(mockPixel).fire(eq(SURVEY_CTA_SHOWN), any(), any()) + verify(mockPixel).fire(eq(SURVEY_CTA_SHOWN), any(), any(), eq(COUNT)) } @Test fun whenCtaLaunchedPixelIsFired() { testee.onUserClickCtaOkButton(HomePanelCta.Survey(Survey("abc", "http://example.com", 1, SCHEDULED))) - verify(mockPixel).fire(eq(SURVEY_CTA_LAUNCHED), any(), any()) + verify(mockPixel).fire(eq(SURVEY_CTA_LAUNCHED), any(), any(), eq(COUNT)) } @Test fun whenCtaDismissedPixelIsFired() = runTest { testee.onUserDismissedCta(HomePanelCta.Survey(Survey("abc", "http://example.com", 1, SCHEDULED))) - verify(mockPixel).fire(eq(SURVEY_CTA_DISMISSED), any(), any()) + verify(mockPixel).fire(eq(SURVEY_CTA_DISMISSED), any(), any(), eq(COUNT)) } @Test @@ -262,7 +262,7 @@ class CtaViewModelTest { @Test fun whenHideTipsForeverThenPixelIsFired() = runTest { testee.hideTipsForever(HomePanelCta.AddWidgetAuto) - verify(mockPixel).fire(eq(ONBOARDING_DAX_ALL_CTA_HIDDEN), any(), any()) + verify(mockPixel).fire(eq(ONBOARDING_DAX_ALL_CTA_HIDDEN), any(), any(), eq(COUNT)) } @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt b/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt index 4ff77061bf05..e86800fba459 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt @@ -21,9 +21,12 @@ import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.AtbInitializer import com.duckduckgo.app.statistics.AtbInitializerListener import com.duckduckgo.app.statistics.api.PixelSender +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_SENT import com.duckduckgo.app.statistics.api.StatisticsService import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.device.ContextDeviceInfo @@ -36,6 +39,7 @@ import dagger.Provides import dagger.SingleInstanceIn import dagger.multibindings.IntoSet import io.reactivex.Completable +import io.reactivex.Single import kotlinx.coroutines.CoroutineScope import retrofit2.Retrofit @@ -73,6 +77,7 @@ class StubStatisticsModule { pixel: Pixel.PixelName, parameters: Map, encodedParameters: Map, + type: PixelType, ) { } @@ -80,6 +85,7 @@ class StubStatisticsModule { pixelName: String, parameters: Map, encodedParameters: Map, + type: PixelType, ) { } @@ -122,8 +128,9 @@ class StubStatisticsModule { pixelName: String, parameters: Map, encodedParameters: Map, - ): Completable { - return Completable.fromAction {} + type: PixelType, + ): Single { + return Single.just(PIXEL_SENT) } override fun enqueuePixel( diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt index 56af3aa0aac5..7e4a5b101ec4 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.location.GeoLocationPermissions import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.cookies.api.DuckDuckGoCookieManager +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupDataClearer import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.sync.api.DeviceSyncState @@ -61,6 +62,7 @@ class ClearPersonalDataActionTest { private val mockDeviceSyncState: DeviceSyncState = mock() private val mockSavedSitesRepository: SavedSitesRepository = mock() private val mockSitePermissionsManager: SitePermissionsManager = mock() + private val mockPrivacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer = mock() private val fireproofWebsites: LiveData> = MutableLiveData() @@ -80,6 +82,7 @@ class ClearPersonalDataActionTest { fireproofWebsiteRepository = mockFireproofWebsiteRepository, deviceSyncState = mockDeviceSyncState, savedSitesRepository = mockSavedSitesRepository, + privacyProtectionsPopupDataClearer = mockPrivacyProtectionsPopupDataClearer, sitePermissionsManager = mockSitePermissionsManager, ) whenever(mockFireproofWebsiteRepository.getFireproofWebsites()).thenReturn(fireproofWebsites) @@ -146,4 +149,10 @@ class ClearPersonalDataActionTest { testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) verify(mockSavedSitesRepository).pruneDeleted() } + + @Test + fun whenClearCalledThenPrivacyProtectionsPopupDataClearerIsInvoked() = runTest { + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verify(mockPrivacyProtectionsPopupDataClearer).clearPersonalData() + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt index 6e1d5736f6e3..decd1f0b78e0 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint @@ -75,7 +76,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOffAndNowOnThenPixelIsFiredAndSettingsUpdated() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(false) testee.updateStatus(true) - verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_ENABLED), any(), any()) + verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_ENABLED), any(), any(), eq(COUNT)) verify(mockSettingsDataStore).appNotificationsEnabled = true } @@ -83,7 +84,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOffAndStillOffThenNoPixelIsFiredAndSettingsUnchanged() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(false) testee.updateStatus(false) - verify(mockPixel, never()).fire(any(), any(), any()) + verify(mockPixel, never()).fire(any(), any(), any(), eq(COUNT)) verify(mockSettingsDataStore, never()).appNotificationsEnabled = true } @@ -91,7 +92,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOnAndStillOnThenNoPixelIsFiredAndSettingsUnchanged() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(true) testee.updateStatus(true) - verify(mockPixel, never()).fire(any(), any(), any()) + verify(mockPixel, never()).fire(any(), any(), any(), eq(COUNT)) verify(mockSettingsDataStore, never()).appNotificationsEnabled = false } @@ -99,7 +100,7 @@ class NotificationRegistrarTest { fun whenNotificationsPreviouslyOnAndNowOffPixelIsFiredAndSettingsUpdated() { whenever(mockSettingsDataStore.appNotificationsEnabled).thenReturn(true) testee.updateStatus(false) - verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_DISABLED), any(), any()) + verify(mockPixel).fire(eq(AppPixelName.NOTIFICATIONS_DISABLED), any(), any(), eq(COUNT)) verify(mockSettingsDataStore).appNotificationsEnabled = false } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt index 12b59bedcdd7..60db88d7fc6a 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteViewModel.kt @@ -35,6 +35,7 @@ import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.DASHBOARD import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU @@ -45,6 +46,8 @@ import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.squareup.moshi.Moshi import com.squareup.moshi.Types import javax.inject.Inject @@ -63,6 +66,8 @@ class BrokenSiteViewModel @Inject constructor( private val contentBlocking: ContentBlocking, private val unprotectedTemporary: UnprotectedTemporary, private val userAllowListRepository: UserAllowListRepository, + private val protectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener, + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, moshi: Moshi, ) : ViewModel() { private val jsonStringListAdapter = moshi.adapter>( @@ -171,13 +176,16 @@ class BrokenSiteViewModel @Inject constructor( val domain = getDomain() ?: return viewModelScope.launch { + val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() if (protectionsEnabled) { userAllowListRepository.removeDomainFromUserAllowList(domain) - pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE) + pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, pixelParams, type = COUNT) } else { userAllowListRepository.addDomainToUserAllowList(domain) - pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD) + pixel.fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, pixelParams, type = COUNT) } + privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled) + protectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt index 22045019b3f9..627b5d9b4dac 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt @@ -41,6 +41,7 @@ import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.squareup.anvil.annotations.ContributesBinding import java.util.* import javax.inject.Inject @@ -68,6 +69,7 @@ class BrokenSiteSubmitter @Inject constructor( private val unprotectedTemporary: UnprotectedTemporary, private val contentBlocking: ContentBlocking, private val brokenSiteLastSentReport: BrokenSiteLastSentReport, + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, ) : BrokenSiteSender { override fun submitBrokenSiteFeedback(brokenSite: BrokenSite) { @@ -119,6 +121,8 @@ class BrokenSiteSubmitter @Inject constructor( params[LOGIN_SITE] = brokenSite.loginSite.orEmpty() } + params += privacyProtectionsPopupExperimentExternalPixels.getPixelParams() + val encodedParams = mapOf( BLOCKED_TRACKERS_KEY to brokenSite.blockedTrackers, SURROGATES_KEY to brokenSite.surrogates, diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index c48768b9d8c0..6d8cd743a799 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -253,6 +253,9 @@ import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.JsMessaging import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupFactory +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState import com.duckduckgo.remote.messaging.api.RemoteMessage import com.duckduckgo.savedsites.api.models.BookmarkFolder import com.duckduckgo.savedsites.api.models.SavedSite @@ -274,6 +277,8 @@ import javax.inject.Provider import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.json.JSONObject import timber.log.Timber @@ -444,6 +449,9 @@ class BrowserTabFragment : @Inject lateinit var autofillOverlappingDialogDetector: AutofillOverlappingDialogDetector + @Inject + lateinit var privacyProtectionsPopupFactory: PrivacyProtectionsPopupFactory + /** * We use this to monitor whether the user was seeing the in-context Email Protection signup prompt * This is needed because the activity stack will be cleared if an external link is opened in our browser @@ -732,6 +740,8 @@ class BrowserTabFragment : // see discussion in https://github.com/duckduckgo/Android/pull/4027#discussion_r1433373625 private val jsOrientationHandler = JsOrientationHandler() + private lateinit var privacyProtectionsPopup: PrivacyProtectionsPopup + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) removeDaxDialogFromActivity() @@ -789,6 +799,7 @@ class BrowserTabFragment : configureAutoComplete() configureOmnibarQuickAccessGrid() configureHomeTabQuickAccessGrid() + initPrivacyProtectionsPopup() decorator.decorateWithFeatures() @@ -820,6 +831,15 @@ class BrowserTabFragment : } } + private fun initPrivacyProtectionsPopup() { + privacyProtectionsPopup = privacyProtectionsPopupFactory.createPopup( + anchor = omnibar.shieldIcon, + ) + privacyProtectionsPopup.events + .onEach(viewModel::onPrivacyProtectionsPopupUiEvent) + .launchIn(lifecycleScope) + } + private fun getDaxDialogFromActivity(): Fragment? = activity?.supportFragmentManager?.findFragmentByTag(DAX_DIALOG_DIALOG_TAG) private fun removeDaxDialogFromActivity() { @@ -1093,11 +1113,11 @@ class BrowserTabFragment : } private fun onRefreshRequested() { - viewModel.onRefreshRequested() + viewModel.onRefreshRequested(triggeredByUser = true) } override fun onAutofillStateChange() { - viewModel.onRefreshRequested() + viewModel.onRefreshRequested(triggeredByUser = false) } override fun onRejectGeneratedPassword(originalUrl: String) { @@ -2724,6 +2744,7 @@ class BrowserTabFragment : configureQuickAccessGridLayout(quickAccessItems.quickAccessRecyclerView) configureQuickAccessGridLayout(binding.quickAccessSuggestionsRecyclerView) decorator.recreatePopupMenu() + privacyProtectionsPopup.onConfigurationChanged() viewModel.onConfigurationChanged() } @@ -3178,7 +3199,7 @@ class BrowserTabFragment : viewModel.onUserLongPressedBack() } onMenuItemClicked(menuBinding.refreshMenuItem) { - viewModel.onRefreshRequested() + viewModel.onRefreshRequested(triggeredByUser = true) pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED.pixelName) } onMenuItemClicked(menuBinding.newTabMenuItem) { @@ -3401,7 +3422,9 @@ class BrowserTabFragment : if (isHidden) { return@launch } - if (lastSeenOmnibarViewState?.isEditing != true) { + val privacyProtectionsPopupVisible = lastSeenBrowserViewState + ?.privacyProtectionsPopupViewState is PrivacyProtectionsPopupViewState.Visible + if (lastSeenOmnibarViewState?.isEditing != true && !privacyProtectionsPopupVisible) { val site = viewModel.siteLiveData.value val events = site?.orderedTrackerBlockedEntities() activity?.let { activity -> @@ -3476,6 +3499,7 @@ class BrowserTabFragment : renderFullscreenMode(viewState) renderVoiceSearch(viewState) omnibar.spacer.isVisible = viewState.showVoiceSearch && lastSeenBrowserViewState?.showClearButton ?: false + privacyProtectionsPopup.setViewState(viewState.privacyProtectionsPopupViewState) bookmarksBottomSheetDialog?.dialog?.toggleSwitch(viewState.favorite != null) val bookmark = viewModel.browserViewState.value?.bookmark?.copy(isFavorite = viewState.favorite != null) 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 468d15b87d11..75e4f67cb760 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -106,6 +106,7 @@ import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FAVORITE_MENU_ITEM_STATE +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.survey.notification.SurveyNotificationScheduler @@ -135,6 +136,11 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.privacy.config.api.* +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.duckduckgo.remote.messaging.api.RemoteMessage import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.savedsites.api.models.BookmarkFolder @@ -216,6 +222,9 @@ class BrowserTabViewModel @Inject constructor( private val syncEngine: SyncEngine, private val cameraHardwareChecker: CameraHardwareChecker, private val androidBrowserConfig: AndroidBrowserConfigFeature, + private val privacyProtectionsPopupManager: PrivacyProtectionsPopupManager, + private val privacyProtectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener, + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -275,6 +284,7 @@ class BrowserTabViewModel @Inject constructor( val canPrintPage: Boolean = false, val showAutofill: Boolean = false, val browserError: WebViewErrorResponse = OMITTED, + val privacyProtectionsPopupViewState: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone, ) sealed class HighlightableButton { @@ -754,6 +764,12 @@ class BrowserTabViewModel @Inject constructor( .flowOn(dispatchers.io()) .launchIn(viewModelScope) } + + privacyProtectionsPopupManager.viewState + .onEach { popupViewState -> + browserViewState.value = currentBrowserViewState().copy(privacyProtectionsPopupViewState = popupViewState) + } + .launchIn(viewModelScope) } fun loadData( @@ -1151,7 +1167,7 @@ class BrowserTabViewModel @Inject constructor( } } - fun onRefreshRequested() { + fun onRefreshRequested(triggeredByUser: Boolean) { val omnibarContent = currentOmnibarViewState().omnibarText if (!Patterns.WEB_URL.matcher(omnibarContent).matches()) { fireQueryChangedPixel(currentOmnibarViewState().omnibarText) @@ -1162,6 +1178,10 @@ class BrowserTabViewModel @Inject constructor( } else { command.value = NavigationCommand.Refresh } + + if (triggeredByUser) { + privacyProtectionsPopupManager.onPageRefreshTriggeredByUser() + } } /** @@ -1371,6 +1391,11 @@ class BrowserTabViewModel @Inject constructor( isLinkOpenedInNewTab = false automaticSavedLoginsMonitor.clearAutoSavedLoginId(tabId) + + site?.run { + val hasBrowserError = currentBrowserViewState().browserError != OMITTED + privacyProtectionsPopupManager.onPageLoaded(url, httpErrorCodeEvents, hasBrowserError) + } } private fun setAdClickActiveTabData(url: String?) { @@ -2221,11 +2246,15 @@ class BrowserTabViewModel @Inject constructor( } else { addToAllowList(domain) } + + privacyProtectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() } } private suspend fun addToAllowList(domain: String) { - pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD) + val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() + pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_ADD, pixelParams, type = COUNT) + privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = false) userAllowListRepository.addDomainToUserAllowList(domain) withContext(dispatchers.main()) { command.value = ShowPrivacyProtectionDisabledConfirmation(domain) @@ -2234,7 +2263,9 @@ class BrowserTabViewModel @Inject constructor( } private suspend fun removeFromAllowList(domain: String) { - pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE) + val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() + pixel.fire(AppPixelName.BROWSER_MENU_ALLOWLIST_REMOVE, pixelParams, type = COUNT) + privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled = true) userAllowListRepository.removeDomainFromUserAllowList(domain) withContext(dispatchers.main()) { command.value = ShowPrivacyProtectionEnabledConfirmation(domain) @@ -2362,6 +2393,10 @@ class BrowserTabViewModel @Inject constructor( } } + fun onPrivacyProtectionsPopupUiEvent(event: PrivacyProtectionsPopupUiEvent) { + privacyProtectionsPopupManager.onUiEvent(event) + } + private fun initializeViewStates() { initializeDefaultViewStates() viewModelScope.launch { diff --git a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt index 3b02e3f9d22c..ddfef5f6e0f6 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/pageloadpixel/PageLoadedOfflinePixelSender.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.pageloadpixel import com.duckduckgo.app.browser.WebViewPixelName import com.duckduckgo.app.statistics.api.OfflinePixel import com.duckduckgo.app.statistics.api.PixelSender +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import io.reactivex.Completable @@ -49,7 +50,8 @@ class PageLoadedOfflinePixelSender @Inject constructor( TRACKER_OPTIMIZATION_ENABLED to it.trackerOptimizationEnabled.toString(), ), mapOf(), - ).doOnComplete { + COUNT, + ).ignoreElement().doOnComplete { pageLoadedPixelDao.deleteAll() } } diff --git a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt index e1ac798f0584..d349a8ab591f 100644 --- a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt @@ -46,6 +46,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsEntityDao import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.cookies.api.DuckDuckGoCookieManager import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupDataClearer import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.sync.api.DeviceSyncState @@ -82,6 +83,7 @@ object PrivacyModule { sitePermissionsManager: SitePermissionsManager, deviceSyncState: DeviceSyncState, savedSitesRepository: SavedSitesRepository, + privacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer, dispatcherProvider: DispatcherProvider, ): ClearDataAction { return ClearPersonalDataAction( @@ -99,6 +101,7 @@ object PrivacyModule { sitePermissionsManager, deviceSyncState, savedSitesRepository, + privacyProtectionsPopupDataClearer, dispatcherProvider, ) } diff --git a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt index 5cf7a5138adb..b4f66f3a9a94 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt @@ -34,6 +34,7 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.cookies.api.DuckDuckGoCookieManager +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupDataClearer import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.site.permissions.api.SitePermissionsManager import com.duckduckgo.sync.api.DeviceSyncState @@ -70,6 +71,7 @@ class ClearPersonalDataAction( private val sitePermissionsManager: SitePermissionsManager, private val deviceSyncState: DeviceSyncState, private val savedSitesRepository: SavedSitesRepository, + private val privacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : ClearDataAction { @@ -99,6 +101,8 @@ class ClearPersonalDataAction( savedSitesRepository.pruneDeleted() } + privacyProtectionsPopupDataClearer.clearPersonalData() + clearTabsAsync(appInForeground) } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt b/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt index cdcc3de7d4b3..69b6b3716529 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/EnqueuedPixelWorker.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.work.* import com.duckduckgo.anvil.annotations.ContributesWorker import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.fire.UnsentForgetAllPixelStore import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature @@ -30,11 +31,14 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.WEBVIEW_FULL_VE import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.WEBVIEW_VERSION import com.duckduckgo.browser.api.WebViewVersionProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import timber.log.Timber @ContributesMultibinding( @@ -49,6 +53,8 @@ class EnqueuedPixelWorker @Inject constructor( private val webViewVersionProvider: WebViewVersionProvider, private val defaultBrowserDetector: DefaultBrowserDetector, private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : MainProcessLifecycleObserver { private var launchedByFireAction: Boolean = false @@ -75,10 +81,13 @@ class EnqueuedPixelWorker @Inject constructor( put(WEBVIEW_FULL_VERSION, webViewVersionProvider.getFullVersion()) } }.toMap() - pixel.get().fire( - pixel = AppPixelName.APP_LAUNCH, - parameters = paramsMap, - ) + appCoroutineScope.launch { + val popupExperimentParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() + pixel.get().fire( + pixel = AppPixelName.APP_LAUNCH, + parameters = paramsMap + popupExperimentParams, + ) + } } private fun isLaunchByFireAction(): Boolean { diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt index 389d22ab20f6..393d02ba60de 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt @@ -10,6 +10,7 @@ import com.duckduckgo.app.pixels.AppPixelName.BROKEN_SITE_REPORT import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -23,7 +24,9 @@ import com.duckduckgo.privacy.config.api.PrivacyConfig import com.duckduckgo.privacy.config.api.PrivacyConfigData import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import java.util.* +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -67,6 +70,10 @@ class BrokenSiteSubmitterTest { private val mockBrokenSiteLastSentReport: BrokenSiteLastSentReport = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } + private lateinit var testee: BrokenSiteSubmitter @Before @@ -97,6 +104,7 @@ class BrokenSiteSubmitterTest { mockUnprotectedTemporary, mockContentBlocking, mockBrokenSiteLastSentReport, + privacyProtectionsPopupExperimentExternalPixels, ) } @@ -111,7 +119,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -128,7 +136,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -145,7 +153,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -164,7 +172,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("false", params["protectionsState"]) @@ -181,7 +189,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("true", params["protectionsState"]) @@ -197,7 +205,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals(lastSentDay, params["lastSentDay"]) @@ -216,7 +224,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("", params["loginSite"]) @@ -234,7 +242,7 @@ class BrokenSiteSubmitterTest { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue assertFalse(params.containsKey("loginSite")) @@ -248,7 +256,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("menu", params["reportFlow"]) @@ -262,7 +270,7 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) val params = paramsCaptor.firstValue assertEquals("dashboard", params["reportFlow"]) @@ -276,12 +284,25 @@ class BrokenSiteSubmitterTest { testee.submitBrokenSiteFeedback(brokenSite) val paramsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any()) + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) val params = paramsCaptor.firstValue assertFalse("reportFlow" in params) } + @Test + fun whenPrivacyProtectionsPopupExperimentParamsArePresentThenTheyAreIncludedInPixel() = runTest { + val params = mapOf("test_key" to "test_value") + whenever(privacyProtectionsPopupExperimentExternalPixels.getPixelParams()).thenReturn(params) + + testee.submitBrokenSiteFeedback(getBrokenSite()) + + val paramsCaptor = argumentCaptor>() + verify(mockPixel).fire(eq(BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), any(), eq(COUNT)) + + assertEquals("test_value", paramsCaptor.firstValue["test_key"]) + } + private fun getBrokenSite(): BrokenSite { return BrokenSite( category = "category", diff --git a/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt index 5dbdff3e8b99..c158c8165db4 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/DefaultBrowserObserverTest.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -67,7 +68,7 @@ class DefaultBrowserObserverTest { testee.onResume(mockOwner) - verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any()) + verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(COUNT)) } @Test @@ -77,7 +78,7 @@ class DefaultBrowserObserverTest { testee.onResume(mockOwner) - verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any()) + verify(mockPixel, never()).fire(eq(AppPixelName.DEFAULT_BROWSER_SET), any(), any(), eq(COUNT)) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt index 7eb672c25f85..a34603af2838 100644 --- a/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/feedback/BrokenSiteViewModelTest.kt @@ -29,6 +29,7 @@ import com.duckduckgo.app.brokensite.model.SiteProtectionsState import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.feature.toggles.api.FeatureToggle @@ -38,8 +39,11 @@ import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.privacy.config.impl.network.JSONObjectAdapter +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.* @@ -52,6 +56,7 @@ import org.mockito.Mockito.never import org.mockito.MockitoAnnotations import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -82,6 +87,12 @@ class BrokenSiteViewModelTest { private val mockUserAllowListRepository: UserAllowListRepository = mock() + private val mockPrivacyProtectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener = mock() + + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } + private lateinit var testee: BrokenSiteViewModel private val viewState: BrokenSiteViewModel.ViewState @@ -98,6 +109,8 @@ class BrokenSiteViewModelTest { mockContentBlocking, mockUnprotectedTemporary, mockUserAllowListRepository, + mockPrivacyProtectionsToggleUsageListener, + privacyProtectionsPopupExperimentExternalPixels, Moshi.Builder().add(JSONObjectAdapter()).build(), ) testee.command.observeForever(mockCommandObserver) @@ -751,6 +764,57 @@ class BrokenSiteViewModelTest { verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE) } + @Test + fun whenProtectionsAreToggledThenPrivacyProtectionsPopupListenerIsInvoked() = runTest { + testee.setInitialBrokenSite( + url = url, + blockedTrackers = "", + surrogates = "", + upgradedHttps = false, + urlParametersRemoved = false, + consentManaged = false, + consentOptOutFailed = false, + consentSelfTestFailed = false, + errorCodes = emptyArray(), + httpErrorCodes = "", + isDesktopMode = false, + reportFlow = MENU, + ) + + testee.onProtectionsToggled(protectionsEnabled = false) + verify(mockPrivacyProtectionsToggleUsageListener).onPrivacyProtectionsToggleUsed() + testee.onProtectionsToggled(protectionsEnabled = true) + verify(mockPrivacyProtectionsToggleUsageListener, times(2)).onPrivacyProtectionsToggleUsed() + } + + @Test + fun whenPrivacyProtectionsAreToggledThenCorrectPixelsAreSent() = runTest { + val params = mapOf("test_key" to "test_value") + whenever(privacyProtectionsPopupExperimentExternalPixels.getPixelParams()).thenReturn(params) + testee.setInitialBrokenSite( + url = url, + blockedTrackers = "", + surrogates = "", + upgradedHttps = false, + urlParametersRemoved = false, + consentManaged = false, + consentOptOutFailed = false, + consentSelfTestFailed = false, + errorCodes = emptyArray(), + httpErrorCodes = "", + isDesktopMode = false, + reportFlow = MENU, + ) + + testee.onProtectionsToggled(protectionsEnabled = false) + verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_ADD, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled = false) + + testee.onProtectionsToggled(protectionsEnabled = true) + verify(mockPixel).fire(AppPixelName.BROKEN_SITE_ALLOWLIST_REMOVE, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled = true) + } + private fun selectAndAcceptCategory(indexSelected: Int = 0) { testee.onCategoryIndexChanged(indexSelected) testee.onCategoryAccepted() diff --git a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt index dc2889d5a0bb..4c60547a1ba4 100644 --- a/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/pixels/EnqueuedPixelWorkerTest.kt @@ -24,12 +24,18 @@ import com.duckduckgo.app.fire.UnsentForgetAllPixelStore import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.browser.api.WebViewVersionProvider +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import org.junit.Before +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.* class EnqueuedPixelWorkerTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + private val workManager: WorkManager = mock() private val pixel: Pixel = mock() private val unsentForgetAllPixelStore: UnsentForgetAllPixelStore = mock() @@ -37,6 +43,7 @@ class EnqueuedPixelWorkerTest { private val webViewVersionProvider: WebViewVersionProvider = mock() private val defaultBrowserDetector: DefaultBrowserDetector = mock() private val androidBrowserConfigFeature: AndroidBrowserConfigFeature = mock() + private val privacyProtectionsPopupExperimentExternalPixels = FakePrivacyProtectionsPopupExperimentExternalPixels() private lateinit var enqueuedPixelWorker: EnqueuedPixelWorker @@ -49,6 +56,8 @@ class EnqueuedPixelWorkerTest { webViewVersionProvider, defaultBrowserDetector, androidBrowserConfigFeature, + privacyProtectionsPopupExperimentExternalPixels, + coroutineRule.testScope, ) setupRemoteConfig(browserEnabled = false, collectFullWebViewVersionEnabled = false) } @@ -164,6 +173,26 @@ class EnqueuedPixelWorkerTest { ) } + @Test + fun whenSendingAppLaunchPixelThenIncludePrivacyProtectionsPopupExperimentParams() { + whenever(unsentForgetAllPixelStore.pendingPixelCountClearData).thenReturn(1) + whenever(webViewVersionProvider.getMajorVersion()).thenReturn("91") + whenever(defaultBrowserDetector.isDefaultBrowser()).thenReturn(false) + privacyProtectionsPopupExperimentExternalPixels.params = mapOf("test_key" to "test_value") + + enqueuedPixelWorker.onCreate(lifecycleOwner) + enqueuedPixelWorker.onStart(lifecycleOwner) + + verify(pixel).fire( + AppPixelName.APP_LAUNCH, + mapOf( + Pixel.PixelParameter.WEBVIEW_VERSION to "91", + Pixel.PixelParameter.DEFAULT_BROWSER to "false", + "test_key" to "test_value", + ), + ) + } + private fun setupRemoteConfig(browserEnabled: Boolean, collectFullWebViewVersionEnabled: Boolean) { whenever(androidBrowserConfigFeature.self()).thenReturn( object : Toggle { @@ -198,3 +227,17 @@ class EnqueuedPixelWorkerTest { ) } } + +private class FakePrivacyProtectionsPopupExperimentExternalPixels : PrivacyProtectionsPopupExperimentExternalPixels { + var params: Map = emptyMap() + + override suspend fun getPixelParams(): Map = params + + override fun tryReportPrivacyDashboardOpened() = throw UnsupportedOperationException() + + override fun tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled: Boolean) = throw UnsupportedOperationException() + + override fun tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled: Boolean) = throw UnsupportedOperationException() + + override fun tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled: Boolean) = throw UnsupportedOperationException() +} diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt index 3a6dad4be3dc..010d2b65ed2c 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.brokensite.model.ReportFlow import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -35,11 +36,13 @@ import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig import com.duckduckgo.privacy.config.api.PrivacyConfigData import com.duckduckgo.privacy.config.impl.network.JSONObjectAdapter +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import java.net.URLEncoder import java.util.* import java.util.regex.Pattern +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -81,6 +84,10 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor private val mockBrokenSiteLastSentReport: BrokenSiteLastSentReport = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } + private lateinit var testee: BrokenSiteSubmitter companion object { @@ -121,6 +128,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor mock(), mock(), mockBrokenSiteLastSentReport, + privacyProtectionsPopupExperimentExternalPixels, ) } @@ -174,7 +182,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue val encodedParams = encodedParamsCaptor.firstValue diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt index 0fc15fb09ff8..320d8f25fa3d 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt @@ -25,10 +25,10 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.MENU import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.experiments.api.VariantManager @@ -37,10 +37,12 @@ import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig import com.duckduckgo.privacy.config.api.PrivacyConfigData import com.duckduckgo.privacy.config.impl.network.JSONObjectAdapter +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import java.net.URLEncoder import java.util.* +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import org.junit.Assert.* import org.junit.Before @@ -80,6 +82,10 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { private val mockUserAllowListRepository: UserAllowListRepository = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } + private lateinit var testee: BrokenSiteSubmitter companion object { @@ -119,6 +125,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { mock(), mock(), mock(), + privacyProtectionsPopupExperimentExternalPixels, ) } @@ -162,7 +169,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { val paramsCaptor = argumentCaptor>() val encodedParamsCaptor = argumentCaptor>() - verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture()) + verify(mockPixel).fire(eq(AppPixelName.BROKEN_SITE_REPORT.pixelName), paramsCaptor.capture(), encodedParamsCaptor.capture(), eq(COUNT)) val params = paramsCaptor.firstValue val encodedParams = encodedParamsCaptor.firstValue diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/DefaultAutofillOverlappingDialogDetectorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/DefaultAutofillOverlappingDialogDetectorTest.kt index c9449fbb99d3..2905ed73894d 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/DefaultAutofillOverlappingDialogDetectorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/DefaultAutofillOverlappingDialogDetectorTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.autofill.impl.ui import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.autofill.api.CredentialSavePickerDialog import com.duckduckgo.autofill.api.UseGeneratedPasswordDialog import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_OVERLAPPING_DIALOG @@ -84,7 +85,7 @@ class DefaultAutofillOverlappingDialogDetectorTest { "newDialogTag" to newDialogTag, "existingDialogTags" to existingTags, ) - verify(pixel).fire(eq(AUTOFILL_OVERLAPPING_DIALOG), eq(expectedMap), any()) + verify(pixel).fire(eq(AUTOFILL_OVERLAPPING_DIALOG), eq(expectedMap), any(), eq(COUNT)) } private fun configureFragmentShowing(tags: List) { diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt index 7b49f8cfc552..4d5a5ade089e 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModelTest.kt @@ -20,6 +20,7 @@ import app.cash.turbine.test import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator @@ -611,7 +612,7 @@ class AutofillSettingsViewModelTest { val launchedFromBrowser = false val directLinkToCredentials = true testee.sendLaunchPixel(launchedFromBrowser, directLinkToCredentials) - verify(pixel, never()).fire(any(), any(), any()) + verify(pixel, never()).fire(any(), any(), any(), eq(COUNT)) } @Test @@ -619,7 +620,7 @@ class AutofillSettingsViewModelTest { val launchedFromBrowser = true val directLinkToCredentials = true testee.sendLaunchPixel(launchedFromBrowser, directLinkToCredentials) - verify(pixel, never()).fire(any(), any(), any()) + verify(pixel, never()).fire(any(), any(), any(), eq(COUNT)) } @Test @@ -627,7 +628,7 @@ class AutofillSettingsViewModelTest { val launchedFromBrowser = true val directLinkToCredentials = false testee.sendLaunchPixel(launchedFromBrowser, directLinkToCredentials) - verify(pixel).fire(eq(MENU_ACTION_AUTOFILL_PRESSED), any(), any()) + verify(pixel).fire(eq(MENU_ACTION_AUTOFILL_PRESSED), any(), any(), eq(COUNT)) } @Test @@ -635,7 +636,7 @@ class AutofillSettingsViewModelTest { val launchedFromBrowser = false val directLinkToCredentials = false testee.sendLaunchPixel(launchedFromBrowser, directLinkToCredentials) - verify(pixel).fire(eq(SETTINGS_AUTOFILL_MANAGEMENT_OPENED), any(), any()) + verify(pixel).fire(eq(SETTINGS_AUTOFILL_MANAGEMENT_OPENED), any(), any(), eq(COUNT)) } @Test diff --git a/common/common-ui/src/main/res/values/design-system-colors.xml b/common/common-ui/src/main/res/values/design-system-colors.xml index 6e7d666d0b4e..6cdc152c4f0f 100644 --- a/common/common-ui/src/main/res/values/design-system-colors.xml +++ b/common/common-ui/src/main/res/values/design-system-colors.xml @@ -196,6 +196,7 @@ #d0d0d0 #666666 #678fff + #1A678fff #5078e9 #b000 #F2F5F9 diff --git a/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt b/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt index 31b5479c9782..01dd92185057 100644 --- a/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt +++ b/installation/installation-impl/src/test/java/InstallSourceLifecycleObserverTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.installation.impl.installer import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.installation.impl.installer.InstallationPixelName.APP_INSTALLER_PACKAGE_NAME import kotlinx.coroutines.test.runTest @@ -54,13 +55,13 @@ class InstallSourceLifecycleObserverTest { @Test fun whenNotPreviouslyProcessedThenPixelSent() = runTest { testee.onCreate(mockLifecycleOwner) - verify(mockPixel).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any()) + verify(mockPixel).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(COUNT)) } @Test fun whenPreviouslyProcessedThenPixelNotSent() = runTest { testee.recordInstallSourceProcessed() testee.onCreate(mockLifecycleOwner) - verify(mockPixel, never()).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any()) + verify(mockPixel, never()).fire(eq(APP_INSTALLER_PACKAGE_NAME), any(), any(), eq(COUNT)) } } diff --git a/privacy-dashboard/privacy-dashboard-impl/build.gradle b/privacy-dashboard/privacy-dashboard-impl/build.gradle index fb39da30cad6..ec40c3af3d52 100644 --- a/privacy-dashboard/privacy-dashboard-impl/build.gradle +++ b/privacy-dashboard/privacy-dashboard-impl/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation project(':app-build-config-api') implementation project(':navigation-api') implementation project(':feature-toggles-api') + implementation project(':privacy-protections-popup-api') implementation Google.dagger implementation AndroidX.appCompat diff --git a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt index 104eb0e39f97..58439e4c3389 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/main/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModel.kt @@ -23,16 +23,17 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.browser.api.brokensite.BrokenSiteData import com.duckduckgo.browser.api.brokensite.BrokenSiteData.ReportFlow.DASHBOARD import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.PRIVACY_DASHBOARD_ALLOWLIST_ADD -import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.PRIVACY_DASHBOARD_ALLOWLIST_REMOVE -import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.PRIVACY_DASHBOARD_OPENED +import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.* import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenSettings import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.OpenURL +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import java.util.* import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -63,6 +64,8 @@ class PrivacyDashboardHybridViewModel @Inject constructor( private val protectionStatusViewStateMapper: ProtectionStatusViewStateMapper, private val privacyDashboardPayloadAdapter: PrivacyDashboardPayloadAdapter, private val autoconsentStatusViewStateMapper: AutoconsentStatusViewStateMapper, + private val protectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener, + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -184,7 +187,11 @@ class PrivacyDashboardHybridViewModel @Inject constructor( private val site = MutableStateFlow(null) init { - pixel.fire(PRIVACY_DASHBOARD_OPENED) + viewModelScope.launch { + val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() + pixel.fire(PRIVACY_DASHBOARD_OPENED, pixelParams, type = COUNT) + } + privacyProtectionsPopupExperimentExternalPixels.tryReportPrivacyDashboardOpened() site.filterNotNull() .onEach(::updateSite) @@ -239,15 +246,19 @@ class PrivacyDashboardHybridViewModel @Inject constructor( Timber.i("PrivacyDashboard: onPrivacyProtectionsClicked $enabled") viewModelScope.launch(dispatcher.io()) { + protectionsToggleUsageListener.onPrivacyProtectionsToggleUsed() + delay(CLOSE_DASHBOARD_ON_INTERACTION_DELAY) currentViewState().siteViewState.domain?.let { domain -> + val pixelParams = privacyProtectionsPopupExperimentExternalPixels.getPixelParams() if (enabled) { userAllowListRepository.removeDomainFromUserAllowList(domain) - pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE) + pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, pixelParams, type = COUNT) } else { userAllowListRepository.addDomainToUserAllowList(domain) - pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD) + pixel.fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, pixelParams, type = COUNT) } + privacyProtectionsPopupExperimentExternalPixels.tryReportProtectionsToggledFromPrivacyDashboard(enabled) } } } diff --git a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt index 2a051487f058..3a1588fb843e 100644 --- a/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt +++ b/privacy-dashboard/privacy-dashboard-impl/src/test/java/com/duckduckgo/privacy/dashboard/impl/ui/PrivacyDashboardHybridViewModelTest.kt @@ -25,17 +25,23 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.UnprotectedTemporary -import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.PRIVACY_DASHBOARD_ALLOWLIST_ADD +import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels.* import com.duckduckgo.privacy.dashboard.impl.ui.PrivacyDashboardHybridViewModel.Command.LaunchReportBrokenSite +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Assert.assertEquals @@ -48,6 +54,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) class PrivacyDashboardHybridViewModelTest { @@ -64,6 +71,10 @@ class PrivacyDashboardHybridViewModelTest { private val unprotectedTemporary = mock() private val pixel = mock() + private val privacyProtectionsToggleUsageListener: PrivacyProtectionsToggleUsageListener = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { + runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } + } private val testee: PrivacyDashboardHybridViewModel by lazy { PrivacyDashboardHybridViewModel( @@ -75,6 +86,8 @@ class PrivacyDashboardHybridViewModelTest { protectionStatusViewStateMapper = AppProtectionStatusViewStateMapper(contentBlocking, unprotectedTemporary), privacyDashboardPayloadAdapter = mock(), autoconsentStatusViewStateMapper = CookiePromptManagementStatusViewStateMapper(), + protectionsToggleUsageListener = privacyProtectionsToggleUsageListener, + privacyProtectionsPopupExperimentExternalPixels = privacyProtectionsPopupExperimentExternalPixels, ) } @@ -138,6 +151,34 @@ class PrivacyDashboardHybridViewModelTest { } } + @Test + fun whenOnPrivacyProtectionClickedThenListenerIsNotified() = runTest { + val site = site(siteAllowed = false) + testee.onSiteChanged(site) + + testee.onPrivacyProtectionsClicked(enabled = false) + + verify(privacyProtectionsToggleUsageListener).onPrivacyProtectionsToggleUsed() + } + + @Test + fun whenPrivacyProtectionsPopupExperimentParamsArePresentThenTheyShouldBeIncludedInPixels() = runTest { + val params = mapOf("test_key" to "test_value") + whenever(privacyProtectionsPopupExperimentExternalPixels.getPixelParams()).thenReturn(params) + val site = site(siteAllowed = false) + testee.onSiteChanged(site) + testee.onPrivacyProtectionsClicked(enabled = false) + testee.onPrivacyProtectionsClicked(enabled = true) + coroutineRule.testScope.advanceUntilIdle() + + verify(pixel).fire(PRIVACY_DASHBOARD_OPENED, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportPrivacyDashboardOpened() + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_ADD, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = false) + verify(pixel).fire(PRIVACY_DASHBOARD_ALLOWLIST_REMOVE, params, type = COUNT) + verify(privacyProtectionsPopupExperimentExternalPixels).tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled = true) + } + private fun site( url: String = "https://example.com", siteAllowed: Boolean = false, diff --git a/privacy-protections-popup/privacy-protections-popup-api/.gitignore b/privacy-protections-popup/privacy-protections-popup-api/.gitignore new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/privacy-protections-popup/privacy-protections-popup-api/build.gradle b/privacy-protections-popup/privacy-protections-popup-api/build.gradle new file mode 100644 index 000000000000..d57b047c7e64 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/build.gradle @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +android { + namespace "com.duckduckgo.privacyprotectionspopup.api" +} + +dependencies { + implementation KotlinX.coroutines.core +} + + diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopup.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopup.kt new file mode 100644 index 000000000000..55ffa000115f --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopup.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for managing the UI layer of the Privacy Protections Popup. + */ +interface PrivacyProtectionsPopup { + + /** + * A flow of UI events for the Privacy Protections Popup. + * + * This property emits [PrivacyProtectionsPopupUiEvent] objects representing + * various UI interactions or state changes that occur within the popup. + * Those events should be consumed by [PrivacyProtectionsPopupManager] + */ + val events: Flow + + /** + * Updates the view state of the popup. + * + * @param viewState The new view state to be set for the popup. + */ + fun setViewState(viewState: PrivacyProtectionsPopupViewState) + + /** + * Notifies the popup UI about configuration change and re-creates UI if necessary. + */ + fun onConfigurationChanged() +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupDataClearer.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupDataClearer.kt new file mode 100644 index 000000000000..5a36c72caf16 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupDataClearer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +/** + * Interface for clearing any personal data that may be managed by Privacy Protections Popup. + * Should be used whenever the "fire" action is invoked. + */ +interface PrivacyProtectionsPopupDataClearer { + + /** + * Deletes any personal data managed by this module (e.g., domains, for which the popup was shown). + */ + suspend fun clearPersonalData() +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupExperimentExternalPixels.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupExperimentExternalPixels.kt new file mode 100644 index 000000000000..b94547421c8f --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupExperimentExternalPixels.kt @@ -0,0 +1,52 @@ +/* + * 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.privacyprotectionspopup.api + +interface PrivacyProtectionsPopupExperimentExternalPixels { + /** + * Returns parameters to annotate pixels with the popup experiment variant. + */ + suspend fun getPixelParams(): Map + + /** + * This method should be invoked whenever the user enters the Privacy Dashboard screen. + * + * If the user is enrolled in the popup experiment, calling this method will fire a unique pixel. + */ + fun tryReportPrivacyDashboardOpened() + + /** + * This method should be invoked whenever the user toggles privacy protections on the Privacy Dashboard screen. + * + * If the user is enrolled in the popup experiment, calling this method will fire a unique pixel. + */ + fun tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled: Boolean) + + /** + * This method should be invoked whenever the user toggles privacy protections using the options menu on the Browser screen. + * + * If the user is enrolled in the popup experiment, calling this method will fire a unique pixel. + */ + fun tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled: Boolean) + + /** + * This method should be invoked whenever the user toggles privacy protections on the Broken Site screen. + * + * If the user is enrolled in the popup experiment, calling this method will fire a unique pixel. + */ + fun tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled: Boolean) +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupFactory.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupFactory.kt new file mode 100644 index 000000000000..5b4fb5197438 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +import android.view.View + +interface PrivacyProtectionsPopupFactory { + + /** + * Creates instance of [PrivacyProtectionsPopup] that manages UI of the popup. + * + * @param anchor The view to which the popup should be anchored. + */ + fun createPopup(anchor: View): PrivacyProtectionsPopup +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt new file mode 100644 index 000000000000..b0587ec29fcf --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for managing the business logic associated with the Privacy Protections Popup. + * + * It is intended to be used within a ViewModel. + */ +interface PrivacyProtectionsPopupManager { + + /** + * A flow representing the current view state of the [PrivacyProtectionsPopup]. + */ + val viewState: Flow + + /** + * Handles UI events emitted by the [PrivacyProtectionsPopup]. + * + * @param event The [PrivacyProtectionsPopupUiEvent] to be handled. + */ + fun onUiEvent(event: PrivacyProtectionsPopupUiEvent) + + /** + * Invoked when a page refresh is triggered by the user. + * + * This function should be called whenever the user triggers page refresh, + * either by the pull-to-refresh gesture or the button in the menu. + */ + fun onPageRefreshTriggeredByUser() + + /** + * Handles the event of a page being fully loaded. + * + * @param url The URL of the loaded page. + * @param httpErrorCodes A list of HTTP error codes encountered during the page load. + * @param hasBrowserError Boolean indicating whether a browser error occurred. + */ + fun onPageLoaded( + url: String, + httpErrorCodes: List, + hasBrowserError: Boolean, + ) +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupUiEvent.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupUiEvent.kt new file mode 100644 index 000000000000..1147932a4130 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupUiEvent.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +/** + * Enum class representing different UI events emitted by [PrivacyProtectionsPopup] + * and consumed by [PrivacyProtectionsPopupManager]. + */ +enum class PrivacyProtectionsPopupUiEvent { + /** + * Event indicating that the popup was dismissed by the user without explicit + * interaction with the popup UI, e.g., by clicking outside of the popup. + */ + DISMISSED, + + /** + * Event indicating that the dismiss button within the popup was clicked. + */ + DISMISS_CLICKED, + + /** + * Event indicating that the 'Disable Protections' button was clicked. + */ + DISABLE_PROTECTIONS_CLICKED, + + /** + * Event indicating that the 'Don't show again' button was clicked. + */ + DONT_SHOW_AGAIN_CLICKED, + + /** + * Event indicating that the privacy dashboard icon (popup anchor view) was clicked. + * + * The click event is passed to the popup anchor view, so no extra handling is necessary + * to open the privacy dashboard. This is emitted to ensure the [PrivacyProtectionsPopupManager] + * state is updated and for measurement purposes. + */ + PRIVACY_DASHBOARD_CLICKED, +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt new file mode 100644 index 000000000000..1ecbfca4f094 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +/** + * Represents the view state of the Privacy Protections Popup + * Should be emitted by [PrivacyProtectionsPopupManager] and consumed by [PrivacyProtectionsPopup] + */ +sealed class PrivacyProtectionsPopupViewState { + + data class Visible( + /** + * Indicates whether the popup should show the "Don't show again" button. + */ + val doNotShowAgainOptionAvailable: Boolean, + ) : PrivacyProtectionsPopupViewState() + + data object Gone : PrivacyProtectionsPopupViewState() +} diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsToggleUsageListener.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsToggleUsageListener.kt new file mode 100644 index 000000000000..8a5a30f24681 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsToggleUsageListener.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.api + +interface PrivacyProtectionsToggleUsageListener { + + /** + * Should be invoked whenever user manually toggles privacy protections for a site. + * Popup trigger heuristic is impacted by the use of privacy protections toggle. + */ + suspend fun onPrivacyProtectionsToggleUsed() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/build.gradle b/privacy-protections-popup/privacy-protections-popup-impl/build.gradle new file mode 100644 index 000000000000..d1bca190ca5a --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/build.gradle @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.google.devtools.ksp' version "$ksp_version" + id 'com.squareup.anvil' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + anvil project(':anvil-compiler') + + implementation project(':privacy-protections-popup-api') + implementation project(':anvil-annotations') + implementation project(':di') + implementation project(':common-ui') + implementation project(':common-utils') + implementation project(':browser-api') + implementation project(':privacy-config-api') + implementation project(':feature-toggles-api') + implementation project(':app-build-config-api') + implementation project(':statistics') + + implementation KotlinX.coroutines.android + implementation AndroidX.core.ktx + implementation Google.android.material + implementation Google.dagger + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler + api AndroidX.dataStore.preferences + + implementation "com.squareup.logcat:logcat:_" + implementation Square.moshi + + implementation "org.apache.commons:commons-math3:_" + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation AndroidX.test.ext.junit + + implementation AndroidX.work.runtimeKtx + testImplementation AndroidX.work.testing +} + +android { + namespace "com.duckduckgo.privacyprotectionspopup.impl" + anvil { + generateDaggerFactories = true // default is false + } + lint { + baseline file("lint-baseline.xml") + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + compileOptions { + coreLibraryDesugaringEnabled = true + } +} + diff --git a/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml b/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml new file mode 100644 index 000000000000..f32fed49aac4 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorker.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorker.kt new file mode 100644 index 000000000000..c1d55ec439cf --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorker.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupManagerImpl.Companion.TOGGLE_USAGE_REMEMBER_DURATION +import com.duckduckgo.privacyprotectionspopup.impl.db.PopupDismissDomainRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@ContributesWorker(AppScope::class) +class PrivacyProtectionsPopupDomainsCleanupWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + @Inject + lateinit var popupDismissDomainRepository: PopupDismissDomainRepository + + @Inject + lateinit var timeProvider: TimeProvider + + override suspend fun doWork(): Result { + popupDismissDomainRepository.removeEntriesOlderThan( + time = timeProvider.getCurrentTime() - TOGGLE_USAGE_REMEMBER_DURATION, + ) + return Result.success() + } +} + +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +class PrivacyProtectionsPopupDomainsCleanupWorkerScheduler @Inject constructor( + private val workManager: WorkManager, +) : MainProcessLifecycleObserver { + + override fun onCreate(owner: LifecycleOwner) { + val workRequest = PeriodicWorkRequestBuilder( + repeatInterval = 1, + repeatIntervalTimeUnit = TimeUnit.DAYS, + ) + .addTag(WORK_REQUEST_TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES) + .build() + + workManager.enqueueUniquePeriodicWork( + WORK_REQUEST_TAG, + ExistingPeriodicWorkPolicy.KEEP, + workRequest, + ) + } + + private companion object { + const val WORK_REQUEST_TAG = "PRIVACY_PROTECTIONS_POPUP_DOMAINS_CLEANUP_TAG" + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentExternalPixelsImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentExternalPixelsImpl.kt new file mode 100644 index 000000000000..15a99dfbe963 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentExternalPixelsImpl.kt @@ -0,0 +1,90 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.CONTROL +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.TEST +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.* +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesBinding(AppScope::class) +class PrivacyProtectionsPopupExperimentExternalPixelsImpl @Inject constructor( + private val dataStore: PrivacyProtectionsPopupDataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val pixelSender: Pixel, +) : PrivacyProtectionsPopupExperimentExternalPixels { + + override suspend fun getPixelParams(): Map { + val experimentVariant = dataStore.getExperimentVariant() + + return if (experimentVariant != null) { + val paramValue = when (experimentVariant) { + CONTROL -> "control" + TEST -> "test" + } + mapOf(PARAM_EXPERIMENT_VARIANT to paramValue) + } else { + emptyMap() + } + } + + override fun tryReportPrivacyDashboardOpened() { + fireIfInExperiment(PRIVACY_DASHBOARD_LAUNCHED_UNIQUE) + } + + override fun tryReportProtectionsToggledFromPrivacyDashboard(protectionsEnabled: Boolean) { + fireIfInExperiment( + if (protectionsEnabled) PRIVACY_DASHBOARD_ALLOWLIST_REMOVE_UNIQUE else PRIVACY_DASHBOARD_ALLOWLIST_ADD_UNIQUE, + ) + } + + override fun tryReportProtectionsToggledFromBrowserMenu(protectionsEnabled: Boolean) { + fireIfInExperiment( + if (protectionsEnabled) BROWSER_MENU_ALLOWLIST_REMOVE_UNIQUE else BROWSER_MENU_ALLOWLIST_ADD_UNIQUE, + ) + } + + override fun tryReportProtectionsToggledFromBrokenSiteReport(protectionsEnabled: Boolean) { + fireIfInExperiment( + if (protectionsEnabled) BROKEN_SITE_ALLOWLIST_REMOVE_UNIQUE else BROKEN_SITE_ALLOWLIST_ADD_UNIQUE, + ) + } + + private fun fireIfInExperiment(pixel: PrivacyProtectionsPopupPixelName) { + appCoroutineScope.launch { + if (dataStore.getExperimentVariant() != null) { + pixelSender.fire( + pixel = pixel, + parameters = getPixelParams(), + type = pixel.type, + ) + } + } + } + + private companion object { + const val PARAM_EXPERIMENT_VARIANT = "privacy_protections_popup_experiment_variant" + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariant.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariant.kt new file mode 100644 index 000000000000..c5bfee34317d --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariant.kt @@ -0,0 +1,23 @@ +/* + * 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.privacyprotectionspopup.impl + +enum class PrivacyProtectionsPopupExperimentVariant { + CONTROL, + TEST, + ; +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariantRandomizer.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariantRandomizer.kt new file mode 100644 index 000000000000..b8276ebd19f5 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupExperimentVariantRandomizer.kt @@ -0,0 +1,42 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.CONTROL +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import org.apache.commons.math3.random.RandomDataGenerator + +interface PrivacyProtectionsPopupExperimentVariantRandomizer { + fun getRandomVariant(): PrivacyProtectionsPopupExperimentVariant +} + +@ContributesBinding(FragmentScope::class) +class PrivacyProtectionsPopupExperimentVariantRandomizerImpl @Inject constructor( + private val buildConfig: AppBuildConfig, +) : PrivacyProtectionsPopupExperimentVariantRandomizer { + + override fun getRandomVariant(): PrivacyProtectionsPopupExperimentVariant { + if (buildConfig.isDefaultVariantForced) return CONTROL + + val variants = PrivacyProtectionsPopupExperimentVariant.entries + val randomIndex = RandomDataGenerator().nextInt(0, variants.lastIndex) + return variants[randomIndex] + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFactoryImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFactoryImpl.kt new file mode 100644 index 000000000000..4aff7377b777 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFactoryImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import android.view.View +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupFactory +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(FragmentScope::class) +class PrivacyProtectionsPopupFactoryImpl @Inject constructor() : PrivacyProtectionsPopupFactory { + + override fun createPopup(anchor: View): PrivacyProtectionsPopup { + return PrivacyProtectionsPopupImpl(anchor) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt new file mode 100644 index 000000000000..65afb6bf016b --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupFeature.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue +import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "privacyProtectionsPopup", +) +interface PrivacyProtectionsPopupFeature { + @DefaultValue(false) + @InternalAlwaysEnabled + fun self(): Toggle +} + +fun PrivacyProtectionsPopupFeature.isEnabled() = self().isEnabled() diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt new file mode 100644 index 000000000000..6e058ce26d0f --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import android.content.Context +import android.graphics.Point +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.PopupWindow +import androidx.core.view.doOnDetach +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible +import androidx.core.view.updatePaddingRelative +import com.duckduckgo.common.ui.view.shape.DaxBubbleEdgeTreatment +import com.duckduckgo.common.ui.view.toPx +import com.duckduckgo.mobile.android.R +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISABLE_PROTECTIONS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISSED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DONT_SHOW_AGAIN_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.PRIVACY_DASHBOARD_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState +import com.duckduckgo.privacyprotectionspopup.impl.R.* +import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsHorizontalBinding +import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsVerticalBinding +import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupPrivacyDashboardBinding +import com.google.android.material.shape.ShapeAppearanceModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class PrivacyProtectionsPopupImpl( + private val anchor: View, +) : PrivacyProtectionsPopup { + + private val context: Context get() = anchor.context + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + private var state: PrivacyProtectionsPopupViewState = PrivacyProtectionsPopupViewState.Gone + private var popupWindow: PopupWindow? = null + + override val events: Flow = _events.asSharedFlow() + + override fun setViewState(viewState: PrivacyProtectionsPopupViewState) { + if (viewState != state) { + state = viewState + + when (viewState) { + is PrivacyProtectionsPopupViewState.Visible -> { + showPopup(viewState) + } + + PrivacyProtectionsPopupViewState.Gone -> { + dismissPopup() + } + } + } + } + + override fun onConfigurationChanged() { + when (val state = state) { + is PrivacyProtectionsPopupViewState.Visible -> { + dismissPopup() + showPopup(state) + } + + PrivacyProtectionsPopupViewState.Gone -> { + // no-op + } + } + } + + private fun showPopup(viewState: PrivacyProtectionsPopupViewState.Visible) = anchor.doOnLayout { + val popupContent = createPopupContentView(viewState.doNotShowAgainOptionAvailable) + val popupWindowSpec = createPopupWindowSpec(popupContent = popupContent.root) + + popupWindowSpec.overrideContentPaddingStartPx?.let { contentPaddingStartPx -> + popupContent.root.updatePaddingRelative(start = contentPaddingStartPx) + } + + popupContent.buttons.dismiss.setOnClickListener { _events.tryEmit(DISMISS_CLICKED) } + popupContent.buttons.doNotShowAgain.setOnClickListener { _events.tryEmit(DONT_SHOW_AGAIN_CLICKED) } + popupContent.buttons.disableProtections.setOnClickListener { _events.tryEmit(DISABLE_PROTECTIONS_CLICKED) } + popupContent.anchorOverlay.setOnClickListener { + anchor.performClick() + _events.tryEmit(PRIVACY_DASHBOARD_CLICKED) + } + popupContent.omnibarOverlay.setOnClickListener { _events.tryEmit(DISMISSED) } + + popupContent.anchorOverlay.layoutParams = popupContent.anchorOverlay.layoutParams.apply { + height = anchor.measuredHeight + if (this is MarginLayoutParams) { + marginStart = anchor.locationInWindow.x - popupContent.root.paddingStart + } + } + + popupWindow = PopupWindow( + popupContent.root, + popupWindowSpec.width, + LayoutParams.WRAP_CONTENT, + true, + ).apply { + setOnDismissListener { + _events.tryEmit(DISMISSED) + popupWindow = null + } + showAsDropDown(anchor, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + } + + anchor.doOnDetach { dismissPopup() } + } + + private fun dismissPopup() { + popupWindow?.setOnDismissListener(null) + popupWindow?.dismiss() + popupWindow = null + } + + private fun createPopupContentView(doNotShowAgainAvailable: Boolean): PopupViewHolder { + val popupContent = PopupPrivacyDashboardBinding.inflate(LayoutInflater.from(context)) + val buttonsViewHolder = inflateButtons(popupContent, doNotShowAgainAvailable) + adjustBodyTextToAvailableWidth(popupContent) + + // Override CardView's default elevation with popup/dialog elevation + popupContent.cardView.cardElevation = POPUP_DEFAULT_ELEVATION_DP.toPx() + + val cornerRadius = context.resources.getDimension(R.dimen.mediumShapeCornerRadius) + val cornerSize = context.resources.getDimension(R.dimen.daxBubbleDialogEdge) + val distanceFromEdge = EDGE_TREATMENT_DISTANCE_FROM_EDGE.toPx() - POPUP_HORIZONTAL_OFFSET_DP.toPx() + val edgeTreatment = DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge) + + popupContent.cardView.shapeAppearanceModel = ShapeAppearanceModel.builder() + .setAllCornerSizes(cornerRadius) + .setTopEdge(edgeTreatment) + .build() + + popupContent.shieldIconHighlight.startAnimation(buildShieldIconHighlightAnimation()) + + return PopupViewHolder( + root = popupContent.root, + anchorOverlay = popupContent.anchorOverlay, + omnibarOverlay = popupContent.omnibarOverlay, + buttons = buttonsViewHolder, + ) + } + + private fun inflateButtons(popupContent: PopupPrivacyDashboardBinding, doNotShowAgainAvailable: Boolean): PopupButtonsViewHolder { + val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + + val horizontalButtons = PopupButtonsHorizontalBinding + .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, false) + .apply { + dontShowAgainButton.isVisible = doNotShowAgainAvailable + dismissButton.isVisible = !doNotShowAgainAvailable + } + + val horizontalButtonsWidth = horizontalButtons.root + .apply { measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) } + .measuredWidth + + return if (horizontalButtonsWidth <= availableWidth) { + popupContent.buttonsContainer.addView(horizontalButtons.root) + PopupButtonsViewHolder( + dismiss = horizontalButtons.dismissButton, + doNotShowAgain = horizontalButtons.dontShowAgainButton, + disableProtections = horizontalButtons.disableButton, + ) + } else { + val verticalButtons = PopupButtonsVerticalBinding + .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, true) + .apply { + dontShowAgainButton.isVisible = doNotShowAgainAvailable + dismissButton.isVisible = !doNotShowAgainAvailable + } + popupContent.buttonsContainer.layoutParams = popupContent.buttonsContainer.layoutParams.apply { width = 0 } + PopupButtonsViewHolder( + dismiss = verticalButtons.dismissButton, + doNotShowAgain = verticalButtons.dontShowAgainButton, + disableProtections = verticalButtons.disableButton, + ) + } + } + + private fun adjustBodyTextToAvailableWidth(popupContent: PopupPrivacyDashboardBinding) { + val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + + val defaultText = context.getString(string.privacy_protections_popup_body) + val shortText = context.getString(string.privacy_protections_popup_body_short) + + popupContent.bodyText.post { + val textPaint = popupContent.bodyText.paint + + popupContent.bodyText.text = when { + textPaint.measureText(defaultText) <= availableWidth -> defaultText + textPaint.measureText(shortText) <= availableWidth -> shortText + else -> defaultText // No need to use the shorter text if it wraps anyway + } + } + } + + private fun createPopupWindowSpec(popupContent: View): PopupWindowSpec { + val distanceFromStartEdgeOfTheScreenPx = anchor.locationInWindow.x + POPUP_HORIZONTAL_OFFSET_DP.toPx() + + val overrideContentPaddingStartPx = if (distanceFromStartEdgeOfTheScreenPx - popupContent.paddingStart < 0) { + distanceFromStartEdgeOfTheScreenPx + } else { + null + } + + // Adjust anchor position for extra margin that CardView needs in order draw its shadow + val horizontalOffsetPx = POPUP_HORIZONTAL_OFFSET_DP.toPx() - popupContent.paddingStart + + // Align top of the popup layout with the top of the anchor + val verticalOffsetPx = -anchor.measuredHeight - popupContent.paddingTop + + // Calculate width because PopupWindow doesn't handle WRAP_CONTENT as expected + val popupContentWidth = popupContent + .apply { measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) } + .measuredWidth + + // If we reduce the start padding, then the max width is increased so that paddings appear symmetrical + val maxPopupWindowWidth = if (overrideContentPaddingStartPx == null) { + context.screenWidth + } else { + context.screenWidth + popupContent.paddingEnd - overrideContentPaddingStartPx + } + + // Stretch the popup to the entire width when the screen is small + val popupWidth = if (popupContentWidth > 0.7 * maxPopupWindowWidth) { + maxPopupWindowWidth + } else { + popupContentWidth + } + + return PopupWindowSpec( + width = popupWidth, + horizontalOffsetPx = horizontalOffsetPx, + verticalOffsetPx = verticalOffsetPx, + overrideContentPaddingStartPx = overrideContentPaddingStartPx, + ) + } + + private fun getAvailablePopupCardViewContentWidthPx(popupContent: PopupPrivacyDashboardBinding): Int { + val popupExternalMarginsWidth = 2 * anchor.locationInWindow.x + POPUP_HORIZONTAL_OFFSET_DP.toPx() + val popupInternalPaddingWidth = popupContent.cardViewContent.paddingStart + popupContent.cardViewContent.paddingEnd + return context.screenWidth - popupExternalMarginsWidth - popupInternalPaddingWidth + } + + private class PopupViewHolder( + val root: View, + val anchorOverlay: View, + val omnibarOverlay: View, + val buttons: PopupButtonsViewHolder, + ) + + private class PopupButtonsViewHolder( + val dismiss: Button, + val doNotShowAgain: Button, + val disableProtections: Button, + ) + + private data class PopupWindowSpec( + val width: Int, + val horizontalOffsetPx: Int, + val verticalOffsetPx: Int, + val overrideContentPaddingStartPx: Int?, + ) + + private companion object { + const val POPUP_DEFAULT_ELEVATION_DP = 8f + const val EDGE_TREATMENT_DISTANCE_FROM_EDGE = 10f + + // Alignment of popup left edge vs. anchor left edge + const val POPUP_HORIZONTAL_OFFSET_DP = -4 + } +} + +private val View.locationInWindow: Point + get() { + val location = IntArray(2) + getLocationInWindow(location) + return Point(location[0], location[1]) + } + +private val Context.screenWidth: Int + get() = resources.displayMetrics.widthPixels diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerDataProvider.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerDataProvider.kt new file mode 100644 index 000000000000..7d6634b542e2 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerDataProvider.kt @@ -0,0 +1,63 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.privacyprotectionspopup.impl.db.PopupDismissDomainRepository +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +interface PrivacyProtectionsPopupManagerDataProvider { + fun getData(domain: String): Flow +} + +data class PrivacyProtectionsPopupManagerData( + val protectionsEnabled: Boolean, + val popupDismissedAt: Instant?, + val toggleUsedAt: Instant?, + val popupTriggerCount: Int, + val doNotShowAgainClicked: Boolean, + val experimentVariant: PrivacyProtectionsPopupExperimentVariant?, +) + +@ContributesBinding(FragmentScope::class) +class PrivacyProtectionsPopupManagerDataProviderImpl @Inject constructor( + private val protectionsStateProvider: ProtectionsStateProvider, + private val popupDismissDomainRepository: PopupDismissDomainRepository, + private val dataStore: PrivacyProtectionsPopupDataStore, +) : PrivacyProtectionsPopupManagerDataProvider { + + override fun getData(domain: String): Flow = + combine( + protectionsStateProvider.areProtectionsEnabled(domain), + popupDismissDomainRepository.getPopupDismissTime(domain), + dataStore.data, + ) { protectionsEnabled, popupDismissedAt, popupData -> + PrivacyProtectionsPopupManagerData( + protectionsEnabled = protectionsEnabled, + popupDismissedAt = popupDismissedAt, + toggleUsedAt = popupData.toggleUsedAt, + popupTriggerCount = popupData.popupTriggerCount, + doNotShowAgainClicked = popupData.doNotShowAgainClicked, + experimentVariant = popupData.experimentVariant, + ) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt new file mode 100644 index 000000000000..9a37a3c92f1c --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.extractDomain +import com.duckduckgo.common.utils.normalizeScheme +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISABLE_PROTECTIONS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISSED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DONT_SHOW_AGAIN_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.PRIVACY_DASHBOARD_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.TEST +import com.duckduckgo.privacyprotectionspopup.impl.db.PopupDismissDomainRepository +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Duration +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import logcat.logcat + +@ContributesBinding(FragmentScope::class) +class PrivacyProtectionsPopupManagerImpl @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val featureFlag: PrivacyProtectionsPopupFeature, + private val dataProvider: PrivacyProtectionsPopupManagerDataProvider, + private val timeProvider: TimeProvider, + private val popupDismissDomainRepository: PopupDismissDomainRepository, + private val dataStore: PrivacyProtectionsPopupDataStore, + private val userAllowListRepository: UserAllowListRepository, + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, + private val variantRandomizer: PrivacyProtectionsPopupExperimentVariantRandomizer, + private val pixels: PrivacyProtectionsPopupPixels, +) : PrivacyProtectionsPopupManager { + + private val state = MutableStateFlow( + State( + featureAvailable = false, + popupData = null, + domain = null, + hasHttpErrorCodes = false, + hasBrowserError = false, + viewState = PrivacyProtectionsPopupViewState.Gone, + ), + ) + + private var dataLoadingJob: Job? = null + + override val viewState = state + .map { state -> state.viewState } + .onStart { startDataLoading() } + .onCompletion { stopDataLoading() } + .stateIn( + scope = appCoroutineScope, + started = SharingStarted.WhileSubscribed(), + initialValue = PrivacyProtectionsPopupViewState.Gone, + ) + + override fun onUiEvent(event: PrivacyProtectionsPopupUiEvent) { + when (event) { + DISMISSED -> { + dismissPopup() + pixels.reportPopupDismissedViaClickOutside() + } + + DISMISS_CLICKED -> { + dismissPopup() + pixels.reportPopupDismissedViaButton() + } + + DISABLE_PROTECTIONS_CLICKED -> { + state.value.domain?.let { domain -> + appCoroutineScope.launch { + userAllowListRepository.addDomainToUserAllowList(domain) + } + } + dismissPopup() + pixels.reportProtectionsDisabled() + } + + PRIVACY_DASHBOARD_CLICKED -> { + dismissPopup() + pixels.reportPrivacyDashboardOpened() + } + + DONT_SHOW_AGAIN_CLICKED -> { + appCoroutineScope.launch { + dataStore.setDoNotShowAgainClicked(clicked = true) + } + dismissPopup() + pixels.reportDoNotShowAgainClicked() + } + } + } + + override fun onPageRefreshTriggeredByUser() { + var popupTriggered = false + var experimentVariantToStore: PrivacyProtectionsPopupExperimentVariant? = null + + val updatedState = state.updateAndGet { oldState -> + if (oldState.popupData == null) return@updateAndGet oldState + + val popupConditionsMet = arePopupConditionsMet(state = oldState) + + var experimentVariant = oldState.popupData.experimentVariant + + if (experimentVariant == null && popupConditionsMet) { + experimentVariant = variantRandomizer.getRandomVariant() + experimentVariantToStore = experimentVariant + } else { + experimentVariantToStore = null + } + + val shouldShowPopup = popupConditionsMet && experimentVariant == TEST + + popupTriggered = shouldShowPopup && oldState.viewState is PrivacyProtectionsPopupViewState.Gone + + oldState.copy( + viewState = if (shouldShowPopup) { + PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = oldState.popupData.popupTriggerCount > 0) + } else { + PrivacyProtectionsPopupViewState.Gone + }, + ) + } + + appCoroutineScope.launch { + experimentVariantToStore?.let { variant -> + dataStore.setExperimentVariant(variant) + pixels.reportExperimentVariantAssigned() + logcat(tag = PrivacyProtectionsPopupManagerImpl::class.simpleName) { + "Experiment variant assigned: $variant" + } + } + + if (popupTriggered) { + val count = dataStore.getPopupTriggerCount() + dataStore.setPopupTriggerCount(count + 1) + pixels.reportPopupTriggered() + } + + tryReportPageRefreshOnPossibleBreakage(updatedState) + } + } + + override fun onPageLoaded( + url: String, + httpErrorCodes: List, + hasBrowserError: Boolean, + ) { + state.update { oldState -> + val newDomain = url.extractDomain().takeUnless { it.isNullOrBlank() } + + if (newDomain != oldState.domain) { + oldState.copy( + popupData = null, + domain = newDomain, + hasHttpErrorCodes = httpErrorCodes.isNotEmpty(), + hasBrowserError = hasBrowserError, + ) + } else { + oldState.copy( + hasHttpErrorCodes = httpErrorCodes.isNotEmpty(), + hasBrowserError = hasBrowserError, + ) + } + } + } + + private fun dismissPopup() { + state.update { it.copy(viewState = PrivacyProtectionsPopupViewState.Gone) } + + val popupDismissedAt = timeProvider.getCurrentTime() + + state.value.domain?.let { domain -> + appCoroutineScope.launch { + popupDismissDomainRepository.setPopupDismissTime(domain, popupDismissedAt) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun startDataLoading() { + dataLoadingJob = appCoroutineScope.launch { + state.update { it.copy(featureAvailable = featureFlag.isEnabled()) } + + state.map { it.domain } + .distinctUntilChanged() + .flatMapLatest { domain -> + if (domain != null) { + dataProvider.getData(domain) + } else { + flowOf(null) + } + } + .onEach { popupData -> + state.update { it.copy(popupData = popupData) } + } + .launchIn(this) + } + } + + private suspend fun stopDataLoading() { + dataLoadingJob?.cancelAndJoin() + dataLoadingJob = null + } + + private data class State( + val featureAvailable: Boolean, + val popupData: PrivacyProtectionsPopupManagerData?, + val domain: String?, + val hasHttpErrorCodes: Boolean, + val hasBrowserError: Boolean, + val viewState: PrivacyProtectionsPopupViewState, + ) + + private fun arePopupConditionsMet(state: State): Boolean = with(state) { + if (popupData == null) return@with false + + val popupDismissed = popupData.popupDismissedAt != null && + popupData.popupDismissedAt + DISMISS_REMEMBER_DURATION > timeProvider.getCurrentTime() + + val toggleUsed = popupData.toggleUsedAt != null && + popupData.toggleUsedAt + TOGGLE_USAGE_REMEMBER_DURATION > timeProvider.getCurrentTime() + + val isDuckDuckGoDomain = domain?.let { duckDuckGoUrlDetector.isDuckDuckGoUrl(it.normalizeScheme()) } + + featureAvailable && + popupData.protectionsEnabled && + domain != null && + isDuckDuckGoDomain == false && + !hasHttpErrorCodes && + !hasBrowserError && + !popupDismissed && + !toggleUsed && + !popupData.doNotShowAgainClicked + } + + private fun tryReportPageRefreshOnPossibleBreakage(state: State) = with(state) { + if (popupData == null) return + + val isDuckDuckGoDomain = domain?.let { duckDuckGoUrlDetector.isDuckDuckGoUrl(it.normalizeScheme()) } + + if ( + popupData.protectionsEnabled && + domain != null && + isDuckDuckGoDomain == false && + !hasHttpErrorCodes && + !hasBrowserError + ) { + pixels.reportPageRefreshOnPossibleBreakage() + } + } + + companion object { + private val DISMISS_REMEMBER_DURATION: Duration = Duration.ofDays(2) + val TOGGLE_USAGE_REMEMBER_DURATION: Duration = Duration.ofDays(30) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelName.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelName.kt new file mode 100644 index 000000000000..e778a1c8f71d --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelName.kt @@ -0,0 +1,106 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE + +enum class PrivacyProtectionsPopupPixelName( + override val pixelName: String, + val type: PixelType, +) : PixelName { + EXPERIMENT_VARIANT_ASSIGNED( + pixelName = "m_privacy_protections_popup_experiment_variant_assigned_u", + type = UNIQUE, + ), + POPUP_TRIGGERED( + pixelName = "m_privacy_protections_popup_triggered_c", + type = COUNT, + ), + PROTECTIONS_DISABLED( + pixelName = "m_privacy_protections_popup_protections_disabled_c", + type = COUNT, + ), + PROTECTIONS_DISABLED_UNIQUE( + pixelName = "m_privacy_protections_popup_protections_disabled_u", + type = UNIQUE, + ), + PRIVACY_DASHBOARD_OPENED( + pixelName = "m_privacy_protections_popup_dashboard_opened_c", + type = COUNT, + ), + PRIVACY_DASHBOARD_OPENED_UNIQUE( + pixelName = "m_privacy_protections_popup_dashboard_opened_u", + type = UNIQUE, + ), + POPUP_DISMISSED_VIA_BUTTON( + pixelName = "m_privacy_protections_popup_dismissed_via_button_c", + type = COUNT, + ), + POPUP_DISMISSED_VIA_CLICK_OUTSIDE( + pixelName = "m_privacy_protections_popup_dismissed_via_click_outside_c", + type = COUNT, + ), + DO_NOT_SHOW_AGAIN_CLICKED( + pixelName = "m_privacy_protections_popup_do_not_show_again_clicked_u", + type = UNIQUE, + ), + PAGE_REFRESH_ON_POSSIBLE_BREAKAGE( + pixelName = "m_privacy_protections_popup_page_refresh_on_possible_breakage_c", + type = COUNT, + ), + PAGE_REFRESH_ON_POSSIBLE_BREAKAGE_DAILY( + pixelName = "m_privacy_protections_popup_page_refresh_on_possible_breakage_d", + type = DAILY, + ), + PRIVACY_DASHBOARD_LAUNCHED_UNIQUE( + pixelName = "m_privacy_protections_popup_dashboard_launched_u", + type = UNIQUE, + ), + PRIVACY_DASHBOARD_ALLOWLIST_ADD_UNIQUE( + pixelName = "m_privacy_protections_popup_dashboard_allowlist_add_u", + type = UNIQUE, + ), + PRIVACY_DASHBOARD_ALLOWLIST_REMOVE_UNIQUE( + pixelName = "m_privacy_protections_popup_dashboard_allowlist_remove_u", + type = UNIQUE, + ), + BROWSER_MENU_ALLOWLIST_ADD_UNIQUE( + pixelName = "m_privacy_protections_popup_browser_menu_allowlist_add_u", + type = UNIQUE, + ), + BROWSER_MENU_ALLOWLIST_REMOVE_UNIQUE( + pixelName = "m_privacy_protections_popup_browser_menu_allowlist_remove_u", + type = UNIQUE, + ), + BROKEN_SITE_ALLOWLIST_ADD_UNIQUE( + pixelName = "m_privacy_protections_popup_broken_site_allowlist_add_u", + type = UNIQUE, + ), + BROKEN_SITE_ALLOWLIST_REMOVE_UNIQUE( + pixelName = "m_privacy_protections_popup_broken_site_allowlist_remove_u", + type = UNIQUE, + ), + ; + + object Params { + const val PARAM_POPUP_TRIGGER_COUNT = "privacy_protections_popup_trigger_count" + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixels.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixels.kt new file mode 100644 index 000000000000..7688da6245ca --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixels.kt @@ -0,0 +1,122 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.DO_NOT_SHOW_AGAIN_CLICKED +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.EXPERIMENT_VARIANT_ASSIGNED +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PAGE_REFRESH_ON_POSSIBLE_BREAKAGE +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PAGE_REFRESH_ON_POSSIBLE_BREAKAGE_DAILY +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.POPUP_DISMISSED_VIA_BUTTON +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.POPUP_DISMISSED_VIA_CLICK_OUTSIDE +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.POPUP_TRIGGERED +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PRIVACY_DASHBOARD_OPENED +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PRIVACY_DASHBOARD_OPENED_UNIQUE +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PROTECTIONS_DISABLED +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.PROTECTIONS_DISABLED_UNIQUE +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.Params.PARAM_POPUP_TRIGGER_COUNT +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface PrivacyProtectionsPopupPixels { + fun reportExperimentVariantAssigned() + fun reportPopupTriggered() + fun reportProtectionsDisabled() + fun reportPrivacyDashboardOpened() + fun reportPopupDismissedViaButton() + fun reportPopupDismissedViaClickOutside() + fun reportDoNotShowAgainClicked() + fun reportPageRefreshOnPossibleBreakage() +} + +@ContributesBinding(FragmentScope::class) +class PrivacyProtectionsPopupPixelsImpl @Inject constructor( + private val pixelSender: Pixel, + private val paramsProvider: PrivacyProtectionsPopupExperimentExternalPixels, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dataStore: PrivacyProtectionsPopupDataStore, +) : PrivacyProtectionsPopupPixels { + + override fun reportExperimentVariantAssigned() { + appCoroutineScope.launch { + fire(EXPERIMENT_VARIANT_ASSIGNED) + } + } + + override fun reportPopupTriggered() { + appCoroutineScope.launch { + fire(POPUP_TRIGGERED) + } + } + + override fun reportProtectionsDisabled() { + appCoroutineScope.launch { + fire(PROTECTIONS_DISABLED) + fire(PROTECTIONS_DISABLED_UNIQUE) + } + } + + override fun reportPrivacyDashboardOpened() { + appCoroutineScope.launch { + fire(PRIVACY_DASHBOARD_OPENED) + fire(PRIVACY_DASHBOARD_OPENED_UNIQUE) + } + } + + override fun reportPopupDismissedViaButton() { + appCoroutineScope.launch { + fire(POPUP_DISMISSED_VIA_BUTTON) + } + } + + override fun reportPopupDismissedViaClickOutside() { + appCoroutineScope.launch { + fire(POPUP_DISMISSED_VIA_CLICK_OUTSIDE) + } + } + + override fun reportDoNotShowAgainClicked() { + appCoroutineScope.launch { + val params = mapOf(PARAM_POPUP_TRIGGER_COUNT to dataStore.getPopupTriggerCount().toString()) + fire(DO_NOT_SHOW_AGAIN_CLICKED, params) + } + } + + override fun reportPageRefreshOnPossibleBreakage() { + appCoroutineScope.launch { + fire(PAGE_REFRESH_ON_POSSIBLE_BREAKAGE) + fire(PAGE_REFRESH_ON_POSSIBLE_BREAKAGE_DAILY) + } + } + + private suspend fun fire( + pixel: PrivacyProtectionsPopupPixelName, + params: Map = emptyMap(), + ) { + pixelSender.fire( + pixel = pixel, + parameters = params + paramsProvider.getPixelParams(), + type = pixel.type, + ) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsToggleUsageListenerImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsToggleUsageListenerImpl.kt new file mode 100644 index 000000000000..459724e69ebd --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsToggleUsageListenerImpl.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsToggleUsageListener +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PrivacyProtectionsToggleUsageListenerImpl @Inject constructor( + private val timeProvider: TimeProvider, + private val dataStore: PrivacyProtectionsPopupDataStore, +) : PrivacyProtectionsToggleUsageListener { + + override suspend fun onPrivacyProtectionsToggleUsed() { + dataStore.setToggleUsageTimestamp(timeProvider.getCurrentTime()) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProvider.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProvider.kt new file mode 100644 index 000000000000..3aa8d70eb08e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.PrivacyFeatureName +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +interface ProtectionsStateProvider { + fun areProtectionsEnabled(domain: String): Flow +} + +@ContributesBinding(FragmentScope::class) +class ProtectionsStateProviderImpl @Inject constructor( + private val featureToggle: FeatureToggle, + private val contentBlocking: ContentBlocking, + private val unprotectedTemporary: UnprotectedTemporary, + private val userAllowListRepository: UserAllowListRepository, +) : ProtectionsStateProvider { + + override fun areProtectionsEnabled(domain: String): Flow { + if ( + !featureToggle.isFeatureEnabled(PrivacyFeatureName.ContentBlockingFeatureName.value) || + contentBlocking.isAnException(domain) || + unprotectedTemporary.isAnException(domain) + ) { + return flowOf(false) + } + + return userAllowListRepository.domainsInUserAllowListFlow() + .map { allowlistedDomains -> domain !in allowlistedDomains } + .distinctUntilChanged() + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ShieldIconHighlightAnimation.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ShieldIconHighlightAnimation.kt new file mode 100644 index 000000000000..cbd4d8fd667f --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/ShieldIconHighlightAnimation.kt @@ -0,0 +1,49 @@ +/* + * 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.privacyprotectionspopup.impl + +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationSet +import android.view.animation.ScaleAnimation + +fun buildShieldIconHighlightAnimation(): Animation { + val fadeInAnimation = AlphaAnimation(0.0f, 1.0f).apply { + duration = 500 + startOffset = 500 + } + + val scaleAnimation = ScaleAnimation( + 1.0f, // Start scale for X + 1.2f, // End scale for X + 1.0f, // Start scale for Y + 1.2f, // End scale for Y + Animation.RELATIVE_TO_SELF, + 0.5f, // Pivot X at the center + Animation.RELATIVE_TO_SELF, + 0.5f, // Pivot Y at the center + ).apply { + duration = 800 + repeatCount = Animation.INFINITE + repeatMode = Animation.REVERSE + } + + return AnimationSet(false).apply { + addAnimation(fadeInAnimation) + addAnimation(scaleAnimation) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/TimeProvider.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/TimeProvider.kt new file mode 100644 index 000000000000..6d8678798e57 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/TimeProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import javax.inject.Inject + +interface TimeProvider { + fun getCurrentTime(): Instant +} + +@ContributesBinding(AppScope::class) +class TimeProviderImpl @Inject constructor() : TimeProvider { + + override fun getCurrentTime(): Instant = Instant.now() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomain.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomain.kt new file mode 100644 index 000000000000..ab70c0a866d3 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomain.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "popup_dismiss_domains") +data class PopupDismissDomain( + @PrimaryKey val domain: String, + val dismissedAt: Instant, +) diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepository.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepository.kt new file mode 100644 index 000000000000..7153f02e0aff --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface PopupDismissDomainRepository { + fun getPopupDismissTime(domain: String): Flow + suspend fun setPopupDismissTime(domain: String, time: Instant) + suspend fun removeEntriesOlderThan(time: Instant) + suspend fun removeAllEntries() +} + +@ContributesBinding(AppScope::class) +class PopupDismissDomainRepositoryImpl @Inject constructor( + private val dao: PopupDismissDomainsDao, +) : PopupDismissDomainRepository { + + override fun getPopupDismissTime(domain: String): Flow = + dao.query(domain).map { it?.dismissedAt } + + override suspend fun setPopupDismissTime( + domain: String, + time: Instant, + ) { + dao.insert(PopupDismissDomain(domain = domain, dismissedAt = time)) + } + + override suspend fun removeEntriesOlderThan(time: Instant) { + dao.removeEntriesOlderThan(time) + } + + override suspend fun removeAllEntries() { + dao.removeAllEntries() + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainsDao.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainsDao.kt new file mode 100644 index 000000000000..a159d075fb23 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainsDao.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import java.time.Instant +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class PopupDismissDomainsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entity: PopupDismissDomain) + + @Query("SELECT * FROM popup_dismiss_domains WHERE domain = :domain") + abstract fun query(domain: String): Flow + + @Query("DELETE FROM popup_dismiss_domains WHERE dismissedAt < :time") + abstract suspend fun removeEntriesOlderThan(time: Instant) + + @Query("DELETE FROM popup_dismiss_domains") + abstract suspend fun removeAllEntries() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDataClearerImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDataClearerImpl.kt new file mode 100644 index 000000000000..0096dbdf9037 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDataClearerImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupDataClearer +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PrivacyProtectionsPopupDataClearerImpl @Inject constructor( + private val popupDismissDomainsRepository: PopupDismissDomainRepository, +) : PrivacyProtectionsPopupDataClearer { + + override suspend fun clearPersonalData() { + popupDismissDomainsRepository.removeAllEntries() + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabase.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabase.kt new file mode 100644 index 000000000000..9d54d18cc87e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import java.time.Instant + +@Database( + exportSchema = true, + version = 1, + entities = [ + PopupDismissDomain::class, + ], +) +@TypeConverters(Converters::class) +abstract class PrivacyProtectionsPopupDatabase : RoomDatabase() { + abstract fun popupDismissDomainDao(): PopupDismissDomainsDao +} + +class Converters { + @TypeConverter + fun epochMilliToInstant(millis: Long?): Instant? = + millis?.let { Instant.ofEpochMilli(it) } + + @TypeConverter + fun instantToEpochMilli(instant: Instant?): Long? = + instant?.toEpochMilli() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabaseModule.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabaseModule.kt new file mode 100644 index 000000000000..526cdf60cc6b --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/db/PrivacyProtectionsPopupDatabaseModule.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import android.content.Context +import androidx.room.Room +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +class PrivacyProtectionsPopupDatabaseModule { + + @Provides + @SingleInstanceIn(AppScope::class) + fun providesPrivacyProtectionsPopupDatabase(context: Context): PrivacyProtectionsPopupDatabase = + Room + .databaseBuilder( + context = context, + klass = PrivacyProtectionsPopupDatabase::class.java, + name = "privacy_protections_popup.db", + ) + .fallbackToDestructiveMigration() + .build() + + @Provides + @SingleInstanceIn(AppScope::class) + fun providePopupDismissDomainsDao(db: PrivacyProtectionsPopupDatabase): PopupDismissDomainsDao = + db.popupDismissDomainDao() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStore.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStore.kt new file mode 100644 index 000000000000..824c8034e671 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStore.kt @@ -0,0 +1,128 @@ +/* + * 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.privacyprotectionspopup.impl.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Keys.DO_NOT_SHOW_AGAIN_CLICKED +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Keys.EXPERIMENT_VARIANT +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Keys.POPUP_TRIGGER_COUNT +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Keys.TOGGLE_USAGE_TIMESTAMP +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Values.EXPERIMENT_VARIANT_CONTROL +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStoreImpl.Values.EXPERIMENT_VARIANT_TEST +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface PrivacyProtectionsPopupDataStore { + val data: Flow + + suspend fun getToggleUsageTimestamp(): Instant? + suspend fun setToggleUsageTimestamp(timestamp: Instant) + suspend fun getPopupTriggerCount(): Int + suspend fun setPopupTriggerCount(count: Int) + suspend fun getDoNotShowAgainClicked(): Boolean + suspend fun setDoNotShowAgainClicked(clicked: Boolean) + suspend fun getExperimentVariant(): PrivacyProtectionsPopupExperimentVariant? + suspend fun setExperimentVariant(variant: PrivacyProtectionsPopupExperimentVariant) +} + +data class PrivacyProtectionsPopupData( + val toggleUsedAt: Instant?, + val popupTriggerCount: Int, + val doNotShowAgainClicked: Boolean, + val experimentVariant: PrivacyProtectionsPopupExperimentVariant?, +) + +@ContributesBinding(AppScope::class) +class PrivacyProtectionsPopupDataStoreImpl @Inject constructor( + @PrivacyProtectionsPopup private val store: DataStore, +) : PrivacyProtectionsPopupDataStore { + + override val data: Flow + get() = store.data + .map { prefs -> + PrivacyProtectionsPopupData( + toggleUsedAt = prefs[TOGGLE_USAGE_TIMESTAMP]?.let { Instant.ofEpochMilli(it) }, + popupTriggerCount = prefs[POPUP_TRIGGER_COUNT] ?: 0, + doNotShowAgainClicked = prefs[DO_NOT_SHOW_AGAIN_CLICKED] == true, + experimentVariant = when (val variant = prefs[EXPERIMENT_VARIANT]) { + EXPERIMENT_VARIANT_TEST -> PrivacyProtectionsPopupExperimentVariant.TEST + EXPERIMENT_VARIANT_CONTROL -> PrivacyProtectionsPopupExperimentVariant.CONTROL + null -> null + else -> throw IllegalStateException(variant) + }, + ) + } + .distinctUntilChanged() + + override suspend fun getToggleUsageTimestamp(): Instant? = + data.first().toggleUsedAt + + override suspend fun setToggleUsageTimestamp(timestamp: Instant) { + store.edit { prefs -> prefs[TOGGLE_USAGE_TIMESTAMP] = timestamp.toEpochMilli() } + } + + override suspend fun getPopupTriggerCount(): Int = + data.first().popupTriggerCount + + override suspend fun setPopupTriggerCount(count: Int) { + store.edit { prefs -> prefs[POPUP_TRIGGER_COUNT] = count } + } + + override suspend fun getDoNotShowAgainClicked(): Boolean = + data.first().doNotShowAgainClicked + + override suspend fun setDoNotShowAgainClicked(clicked: Boolean) { + store.edit { prefs -> prefs[DO_NOT_SHOW_AGAIN_CLICKED] = clicked } + } + + override suspend fun getExperimentVariant(): PrivacyProtectionsPopupExperimentVariant? = + data.first().experimentVariant + + override suspend fun setExperimentVariant(variant: PrivacyProtectionsPopupExperimentVariant) { + store.edit { prefs -> + prefs[EXPERIMENT_VARIANT] = when (variant) { + PrivacyProtectionsPopupExperimentVariant.CONTROL -> EXPERIMENT_VARIANT_CONTROL + PrivacyProtectionsPopupExperimentVariant.TEST -> EXPERIMENT_VARIANT_TEST + } + } + } + + private object Keys { + val TOGGLE_USAGE_TIMESTAMP = longPreferencesKey(name = "toggle_usage_timestamp") + val POPUP_TRIGGER_COUNT = intPreferencesKey(name = "popup_trigger_count") + val DO_NOT_SHOW_AGAIN_CLICKED = booleanPreferencesKey(name = "dont_show_again_clicked") + val EXPERIMENT_VARIANT = stringPreferencesKey(name = "experiment_variant") + } + + private object Values { + const val EXPERIMENT_VARIANT_TEST = "test" + const val EXPERIMENT_VARIANT_CONTROL = "control" + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreModule.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreModule.kt new file mode 100644 index 000000000000..9ef77fcc2a03 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreModule.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.privacyprotectionspopup.impl.store + +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 PrivacyProtectionsPopupDataStoreModule { + + private val Context.privacyProtectionsPopupDataStore: DataStore by preferencesDataStore( + name = "privacy_protections_popup", + ) + + @Provides + @PrivacyProtectionsPopup + fun providePrivacyProtectionsPopupDataStore(context: Context): DataStore = context.privacyProtectionsPopupDataStore +} + +@Qualifier +annotation class PrivacyProtectionsPopup diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/drawable/ic_highlight_blue.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/drawable/ic_highlight_blue.xml new file mode 100644 index 000000000000..2a47c39dfca2 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/drawable/ic_highlight_blue.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_horizontal.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_horizontal.xml new file mode 100644 index 000000000000..4ab604eae070 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_horizontal.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_vertical.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_vertical.xml new file mode 100644 index 000000000000..3e39536f46b4 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_buttons_vertical.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard.xml new file mode 100644 index 000000000000..95e187b75117 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-bg/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-bg/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..87a2b3164014 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-bg/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Уебсайтът не работи според очакванията? + Изключването на защитите за този сайт може да помогне. + Изключването на защитите може да помогне. + Отказване + Изключване на защитите + Не показвай отново + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-cs/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-cs/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..574c5d8b5b79 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-cs/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Nefunguje web podle očekávání? + Možná by pomohlo vypnout ochranu pro tento web. + Možná zabere vypnutí ochrany. + Odmítnout + Vypnout ochranu + Už neukazovat + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-da/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-da/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..3aa739c01846 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-da/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Fungerer webstedet ikke som forventet? + Det kan hjælpe at slå beskyttelsen fra for dette websted. + Det kan hjælpe at slå beskyttelserne FRA. + Afvis + Slå beskyttelse fra + Vis ikke igen + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-de/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-de/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..89d3cd4ac504 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-de/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Die Website funktioniert nicht wie erwartet? + Vielleicht hilft es, den Schutz für diese Website zu deaktivieren. + Das AUSschalten der Schutzfunktionen könnte helfen. + Verwerfen + Schutzmaßnahmen deaktivieren + Nicht erneut anzeigen + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-el/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-el/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..25fe62acd9a7 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-el/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Δεν λειτουργεί ο ιστότοπος όπως αναμενόταν; + Η απενεργοποίηση της προστασίας γι\' αυτόν τον ιστότοπο μπορεί να βοηθήσει. + Η απενεργοποίηση της προστασίας μπορεί να βοηθήσει. + Απόρριψη + Απενεργοποιήστε τις προστασίες + Να μην εμφανιστεί ξανά + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-es/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-es/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..b8bf21ca2737 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-es/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + ¿El sitio web no funciona como esperabas? + Puede que, si desactivas las protecciones para este sitio, funcione. + Puede que, si desactivas las protecciones, funcione. + Descartar + Desactivar protecciones + No volver a mostrar + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-et/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-et/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..6eb705a17775 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-et/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Kas veebisait ei tööta ootuspäraselt? + Abi võib olla selle saidi kaitsefunktsioonide väljalülitamisest. + Abi võib olla kaitsefunktsioonide väljalülitamisest. + Loobu + Lülita kaitsefunktsioonid välja + Ära enam näita + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fi/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fi/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..76179f6edc8e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fi/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Eikö verkkosivusto toimi odotetulla tavalla? + Tämän sivuston suojausten poistaminen käytöstä saattaa auttaa. + Suojausten kytkeminen pois päältä voi auttaa. + Hylkää + Poista suojaukset käytöstä + Älä näytä enää + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fr/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fr/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..f28c922fb9af --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-fr/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Le site Web ne fonctionne pas correctement ? + La désactivation des protections sur ce site pourrait aider. + La désactivation des protections pourrait aider. + Ignorer + Désactiver les protections + Ne plus afficher + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hr/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hr/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..c8cbd5fb3ff2 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hr/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Web-mjesto ne radi u skladu s očekivanjima? + Isključivanje zaštite za ovu web lokaciju moglo bi pomoći. + ISKLJUČIVANJE zaštite može pomoći. + Odbaci + Isključi zaštitu + Nemoj ponovno prikazivati + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hu/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hu/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..1afe6c6b8bc8 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-hu/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + A weboldal nem az elvártak szerint működik? + A webhely védelmeinek kikapcsolása segíthet. + A védelem kikapcsolása segíthet. + Elutasítás + Védelmek kikapcsolása + Ne jelenjen meg újra + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-it/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-it/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..e83f9711d870 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-it/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Il sito web non funziona come dovrebbe? + Disattivare le protezioni per questo sito potrebbe essere d\'aiuto. + Disattivare le protezioni potrebbe essere utile. + Ignora + Disattiva le protezioni + Non mostrare più + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lt/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lt/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..b894b74a6235 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lt/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Svetainė neveikia taip, kaip tikėtasi? + Gali padėti šios svetainės apsaugos priemonių išjungimas. + Gali padėti apsaugos priemonių išjungimas. + Atmesti + Išjungti apsaugos priemones + Daugiau nerodyti + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lv/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lv/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..195b440f09a6 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-lv/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Tīmekļa vietne nedarbojas, kā paredzēts? + Varētu palīdzēt šīs vietnes aizsardzības izslēgšana. + Aizsardzības IZSLĒGŠANA var palīdzēt. + Nerādīt + Izslēgt aizsardzību + Turpmāk nerādīt + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nb/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nb/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..3188a9ffd3a1 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nb/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Virker ikke nettsiden som forventet? + Det kan hjelpe å slå av beskyttelser for dette nettstedet. + Det kan hjelpe å slå beskyttelsen AV. + Avvis + Slå av beskyttelser + Ikke vis igjen + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nl/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nl/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..93bd2b02d8fb --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-nl/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Werkt de website niet zoals verwacht? + Het kan helpen om de beveiliging voor deze site uit te schakelen. + Het kan helpen om de beveiliging uit te schakelen. + Negeren + Beveiligingen uitschakelen + Niet meer weergeven + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pl/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pl/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..f918dfe78f0a --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pl/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Witryna internetowa nie działa zgodnie z oczekiwaniami? + Wyłączenie zabezpieczeń dla tej witryny może pomóc. + WYŁĄCZENIE zabezpieczeń może pomóc. + Odrzuć + Wyłącz zabezpieczenia + Nie pokazuj więcej + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pt/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pt/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..7878c93a6031 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-pt/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + O website não funciona como esperado? + A desativação das proteções deste site pode ajudar. + A desativação das proteções pode ajudar. + Ignorar + Desativar proteções + Não mostrar novamente + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ro/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ro/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..2f8317b057d1 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ro/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Site-ul nu funcționează așa cum era de așteptat? + Dezactivarea protecțiilor pentru acest site ar putea fi utilă. + Dezactivarea protecțiilor ar putea fi de ajutor. + Renunță + Dezactivează protecțiile + Nu mai afișa + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ru/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ru/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..f1828cff0b81 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-ru/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Сайт плохо работает? + Попробуйте отключить защиту на этом сайте. + Попробуйте отключить защиту. + Отклонить + Отключить защиту + Больше не показывать + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sk/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sk/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..14a32921916e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sk/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Webová lokalita nefunguje podľa očakávania? + Vypnutie ochrany pre tento web môže pomôcť. + Pomôcť by mohlo VYPNUTIE ochranných prvkov. + Odmietnuť + Vypnúť ochranu + Nabudúce už neukazovať + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sl/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sl/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..5a182cd7d716 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sl/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Spletno mesto ne deluje po pričakovanjih? + Morda bo pomagalo, če izklopite zaščite za to spletno mesto. + IZKLOP zaščit lahko pomaga. + Opusti + Izklop zaščit + Ne prikaži več + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sv/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sv/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..affd7176307b --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-sv/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Fungerar inte webbplatsen som förväntat? + Det kan hjälpa om du stänger av skydd för den här webbplatsen. + Det kan hjälpa att stänga av skyddet. + Avvisa + Stäng av skydd + Visa inte igen + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-tr/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-tr/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..306a237988ed --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values-tr/strings-privacy-protections-popup.xml @@ -0,0 +1,28 @@ + + + + + + + Web sitesi beklendiği gibi çalışmıyor mu? + Bu site için korumaları kapatmak yardımcı olabilir. + Korumaları KAPALI duruma getirmek yardımcı olabilir. + Reddet + Korumaları Kapat + Bir Daha Gösterme + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values/strings-privacy-protections-popup.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values/strings-privacy-protections-popup.xml new file mode 100644 index 000000000000..383818cf0b03 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/values/strings-privacy-protections-popup.xml @@ -0,0 +1,27 @@ + + + + + + Website not working as expected? + Turning protections off for this site might help. + Turning protections off might help. + Dismiss + Turn Off Protections + Don\'t Show Again + + \ No newline at end of file diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakePrivacyProtectionsPopupDataStore.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakePrivacyProtectionsPopupDataStore.kt new file mode 100644 index 000000000000..4c3f5bf0bfc6 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakePrivacyProtectionsPopupDataStore.kt @@ -0,0 +1,64 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupData +import com.duckduckgo.privacyprotectionspopup.impl.store.PrivacyProtectionsPopupDataStore +import java.time.Instant +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update + +class FakePrivacyProtectionsPopupDataStore : PrivacyProtectionsPopupDataStore { + + override val data = MutableStateFlow( + PrivacyProtectionsPopupData( + toggleUsedAt = null, + popupTriggerCount = 0, + doNotShowAgainClicked = false, + experimentVariant = null, + ), + ) + + override suspend fun getToggleUsageTimestamp(): Instant? = + data.first().toggleUsedAt + + override suspend fun setToggleUsageTimestamp(timestamp: Instant) { + data.update { it.copy(toggleUsedAt = timestamp) } + } + + override suspend fun getPopupTriggerCount(): Int = + data.first().popupTriggerCount + + override suspend fun setPopupTriggerCount(count: Int) { + data.update { it.copy(popupTriggerCount = count) } + } + + override suspend fun getDoNotShowAgainClicked(): Boolean = + data.first().doNotShowAgainClicked + + override suspend fun setDoNotShowAgainClicked(clicked: Boolean) { + data.update { it.copy(doNotShowAgainClicked = clicked) } + } + + override suspend fun getExperimentVariant(): PrivacyProtectionsPopupExperimentVariant? = + data.first().experimentVariant + + override suspend fun setExperimentVariant(variant: PrivacyProtectionsPopupExperimentVariant) { + data.update { it.copy(experimentVariant = variant) } + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeTimeProvider.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeTimeProvider.kt new file mode 100644 index 000000000000..c019f1a778c6 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeTimeProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import java.time.Instant + +class FakeTimeProvider : TimeProvider { + + var time: Instant = Instant.parse("2023-11-29T10:15:30.000Z") + + override fun getCurrentTime(): Instant = time +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeUserAllowlistRepository.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeUserAllowlistRepository.kt new file mode 100644 index 000000000000..994a2435bde6 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/FakeUserAllowlistRepository.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import android.net.Uri +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.extractDomain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class FakeUserAllowlistRepository : UserAllowListRepository { + + private val domains = MutableStateFlow>(emptyList()) + + override fun isUrlInUserAllowList(url: String): Boolean = isDomainInUserAllowList(url.extractDomain()) + + override fun isUriInUserAllowList(uri: Uri): Boolean = throw UnsupportedOperationException() + + override fun isDomainInUserAllowList(domain: String?): Boolean = domain in domains.value + + override fun domainsInUserAllowList(): List = domains.value + + override fun domainsInUserAllowListFlow(): Flow> = domains + + override suspend fun addDomainToUserAllowList(domain: String) = domains.update { it + domain } + + override suspend fun removeDomainFromUserAllowList(domain: String) = domains.update { it - domain } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorkerTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorkerTest.kt new file mode 100644 index 000000000000..a7941fd474a5 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupDomainsCleanupWorkerTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import androidx.work.ListenableWorker.Result +import androidx.work.testing.TestListenableWorkerBuilder +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.privacyprotectionspopup.impl.db.PopupDismissDomainRepository +import java.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.only +import org.mockito.kotlin.verify + +@ExperimentalCoroutinesApi +class PrivacyProtectionsPopupDomainsCleanupWorkerTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val popupDismissDomainRepository: PopupDismissDomainRepository = mock() + private val timeProvider = FakeTimeProvider() + + private val subject = TestListenableWorkerBuilder(context = mock()) + .build() + .also { worker -> + worker.popupDismissDomainRepository = popupDismissDomainRepository + worker.timeProvider = timeProvider + } + + @Test + fun whenDoWorkThenCleanUpOldEntriesFromPopupDismissDomainRepository() = runTest { + timeProvider.time = Instant.parse("2023-11-29T10:15:30.000Z") + + val result = subject.doWork() + + verify(popupDismissDomainRepository, only()) + .removeEntriesOlderThan(Instant.parse("2023-10-30T10:15:30.000Z")) + + assertEquals(Result.success(), result) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt new file mode 100644 index 000000000000..162eb3ee8e50 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt @@ -0,0 +1,702 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.common.utils.extractDomain +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISABLE_PROTECTIONS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISSED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DISMISS_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.DONT_SHOW_AGAIN_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupUiEvent.PRIVACY_DASHBOARD_CLICKED +import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupViewState +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.CONTROL +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.TEST +import com.duckduckgo.privacyprotectionspopup.impl.db.PopupDismissDomainRepository +import java.time.Duration +import java.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class PrivacyProtectionsPopupManagerImplTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val featureFlag = FakePrivacyProtectionsPopupFeature() + + private val protectionsStateProvider = FakeProtectionsStateProvider() + + private val timeProvider = FakeTimeProvider() + + private val popupDismissDomainRepository = FakePopupDismissDomainRepository() + + private val userAllowListRepository = FakeUserAllowlistRepository() + + private val dataStore = FakePrivacyProtectionsPopupDataStore() + + private val duckDuckGoUrlDetector = FakeDuckDuckGoUrlDetector() + + private val variantRandomizer = FakePrivacyProtectionsPopupExperimentVariantRandomizer() + + private val pixels: PrivacyProtectionsPopupPixels = mock() + + private val subject = PrivacyProtectionsPopupManagerImpl( + appCoroutineScope = coroutineRule.testScope, + featureFlag = featureFlag, + dataProvider = PrivacyProtectionsPopupManagerDataProviderImpl( + protectionsStateProvider = protectionsStateProvider, + popupDismissDomainRepository = popupDismissDomainRepository, + dataStore = dataStore, + ), + timeProvider = timeProvider, + popupDismissDomainRepository = popupDismissDomainRepository, + userAllowListRepository = userAllowListRepository, + dataStore = dataStore, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, + variantRandomizer = variantRandomizer, + pixels = pixels, + ) + + @Test + fun whenRefreshIsTriggeredThenEmitsUpdateToShowPopup() = runTest { + val toggleUsedAt = timeProvider.time - Duration.ofDays(32) + dataStore.setToggleUsageTimestamp(toggleUsedAt) + + subject.viewState.test { + assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + expectNoEvents() + subject.onPageRefreshTriggeredByUser() + assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) + expectNoEvents() + } + } + + @Test + fun whenRefreshIsTriggeredThenPopupIsShown() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + } + } + + @Test + fun whenUrlIsDuckDuckGoThenPopupIsNotShown() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://duckduckgo.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenFeatureIsDisabledThenPopupIsNotShown() = runTest { + featureFlag.enabled = false + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenProtectionsAreDisabledThenPopupIsNotShown() = runTest { + protectionsStateProvider.protectionsEnabled = false + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenUrlIsMissingThenPopupIsNotShown() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenPageLoadedWithHttpErrorThenPopupIsNotShown() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = listOf(500), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenPageLoadedWithBrowserErrorThenPopupIsNotShown() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = true) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenPageIsChangedThenPopupIsNotDismissed() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) + + expectNoEvents() + } + } + + @Test + fun whenDismissEventIsHandledThenViewStateIsUpdated() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + subject.onUiEvent(DISMISSED) + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenDismissButtonClickedEventIsHandledThenPopupIsDismissed() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + subject.onUiEvent(DISMISS_CLICKED) + + assertPopupVisible(visible = false) + assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) + } + } + + @Test + fun whenDisableProtectionsClickedEventIsHandledThenPopupIsDismissed() = runTest { + subject.viewState.test { + assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) + + subject.onUiEvent(DISABLE_PROTECTIONS_CLICKED) + + assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) + assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) + } + } + + @Test + fun whenDisableProtectionsClickedEventIsHandledThenDomainIsAddedToUserAllowlist() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertFalse(userAllowListRepository.isUrlInUserAllowList("https://www.example.com")) + assertPopupVisible(visible = true) + + subject.onUiEvent(DISABLE_PROTECTIONS_CLICKED) + + assertPopupVisible(visible = false) + assertTrue(userAllowListRepository.isUrlInUserAllowList("https://www.example.com")) + } + } + + @Test + fun whenPopupWasDismissedRecentlyForTheSameDomainThenItWontBeShownOnRefresh() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + subject.onUiEvent(DISMISSED) + assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) + + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) + } + } + + @Test + fun whenPopupWasDismissedMoreThan24HoursAgoForTheSameDomainThenItIsShownAgainOnRefresh() = runTest { + subject.viewState.test { + timeProvider.time = Instant.parse("2023-11-29T10:15:30.000Z") + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + subject.onUiEvent(DISMISSED) + assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) + timeProvider.time += Duration.ofDays(2) + + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + } + } + + @Test + fun whenPopupWasDismissedRecentlyThenItWontBeShownOnForTheSameDomainButWillBeForOtherDomains() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + subject.onUiEvent(DISMISSED) + + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + + subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + subject.onUiEvent(DISMISSED) + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenRefreshIsTriggeredBeforeDataIsLoadedThenPopupIsNotShown() = runTest { + val protectionsEnabledFlow = MutableSharedFlow() + protectionsStateProvider.overrideProtectionsEnabledFlow(protectionsEnabledFlow) + + subject.viewState.test { + assertPopupVisible(visible = false) + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + protectionsEnabledFlow.emit(true) + expectNoEvents() + } + } + + @Test + fun whenRefreshIsTriggeredThenPopupIsNotShownEvenIfOtherConditionsAreMetAfterAFewSeconds() = runTest { + val protectionsEnabledFlow = MutableSharedFlow() + protectionsStateProvider.overrideProtectionsEnabledFlow(protectionsEnabledFlow) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + timeProvider.time += Duration.ofSeconds(5) + protectionsEnabledFlow.emit(true) + assertPopupVisible(visible = false) + } + } + + @Test + fun whenToggleWasUsedInLast2WeeksThenPopupIsNotShownOnRefresh() = runTest { + val toggleUsedAt = timeProvider.time - Duration.ofDays(10) + dataStore.setToggleUsageTimestamp(toggleUsedAt) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenToggleWasNotUsedInLast2WeeksThenPopupIsShownOnRefresh() = runTest { + val toggleUsedAt = timeProvider.time - Duration.ofDays(32) + dataStore.setToggleUsageTimestamp(toggleUsedAt) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + } + } + + @Test + fun whenPageReloadsOnRefreshWithHttpErrorThenPopupIsNotDismissed() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + timeProvider.time += Duration.ofSeconds(2) + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = listOf(500), hasBrowserError = false) + + expectNoEvents() + } + } + + @Test + fun whenPopupIsShownThenTriggerCountIsIncremented() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + assertEquals(1, dataStore.getPopupTriggerCount()) + + subject.onUiEvent(DISMISSED) + + assertPopupVisible(visible = false) + + subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + assertEquals(2, dataStore.getPopupTriggerCount()) + } + } + + @Test + fun whenPopupTriggerCountIsZeroThenDoNotShowAgainOptionIsNotAvailable() = runTest { + dataStore.setPopupTriggerCount(0) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = false), expectMostRecentItem()) + } + } + + @Test + fun whenPopupTriggerCountIsGreaterThanZeroThenDoNotShowAgainOptionIsAvailable() = runTest { + dataStore.setPopupTriggerCount(1) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + } + } + + @Test + fun whenDoNotShowAgainIsClickedThenPopupIsNotShownAgain() = runTest { + dataStore.setPopupTriggerCount(1) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + + subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) + + assertPopupVisible(visible = false) + assertTrue(dataStore.getDoNotShowAgainClicked()) + + subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + expectNoEvents() + } + } + + @Test + fun whenPopupConditionsAreMetAndExperimentVariantIsControlThenPopupIsNotShown() = runTest { + dataStore.setExperimentVariant(CONTROL) + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + } + } + + @Test + fun whenPopupConditionsAreMetAndExperimentVariantIsNullThenInitializesVariantWithRandomValue() = runTest { + variantRandomizer.variant = CONTROL + assertNull(dataStore.getExperimentVariant()) + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = false) + assertEquals(CONTROL, dataStore.getExperimentVariant()) + } + } + + @Test + fun whenExperimentVariantIsAssignedThenPixelIsSent() = runTest { + variantRandomizer.variant = CONTROL + assertNull(dataStore.getExperimentVariant()) + var variantIncludedInPixel: PrivacyProtectionsPopupExperimentVariant? = null + whenever(pixels.reportExperimentVariantAssigned()) doAnswer { + variantIncludedInPixel = runBlocking { dataStore.getExperimentVariant() } + } + + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + cancelAndIgnoreRemainingEvents() + + verify(pixels).reportExperimentVariantAssigned() + assertEquals(CONTROL, variantIncludedInPixel) // Verify that pixel is sent AFTER assigned variant is stored. + } + } + + @Test + fun whenVariantIsAlreadyAssignedThenPixelIsNotSent() = runTest { + dataStore.setExperimentVariant(TEST) + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + assertPopupVisible(visible = true) + + verify(pixels, never()).reportExperimentVariantAssigned() + } + } + + @Test + fun whenPopupIsTriggeredThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + verify(pixels).reportPopupTriggered() + } + } + + @Test + fun whenPrivacyProtectionsDisableButtonIsClickedThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + subject.onUiEvent(DISABLE_PROTECTIONS_CLICKED) + + verify(pixels).reportProtectionsDisabled() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDismissButtonIsClickedThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + subject.onUiEvent(DISMISS_CLICKED) + + verify(pixels).reportPopupDismissedViaButton() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenPopupIsDismissedViaClickOutsideThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + subject.onUiEvent(DISMISSED) + + verify(pixels).reportPopupDismissedViaClickOutside() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDoNotShowAgainButtonIsClickedThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) + + verify(pixels).reportDoNotShowAgainClicked() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenPrivacyDashboardIsOpenedThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + assertPopupVisible(visible = true) + + subject.onUiEvent(PRIVACY_DASHBOARD_CLICKED) + + verify(pixels).reportPrivacyDashboardOpened() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenPageIsRefreshedAndConditionsAreMetThenPixelIsSent() = runTest { + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + verify(pixels).reportPageRefreshOnPossibleBreakage() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenPageIsRefreshedAndFeatureIsDisabledAndThereIsNoExperimentVariantThenPixelIsNotSent() = runTest { + featureFlag.enabled = false + subject.viewState.test { + subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) + subject.onPageRefreshTriggeredByUser() + + verify(pixels).reportPageRefreshOnPossibleBreakage() + cancelAndIgnoreRemainingEvents() + } + } + + private fun ReceiveTurbine.assertPopupVisible(visible: Boolean) { + if (visible) { + assertTrue(expectMostRecentItem() is PrivacyProtectionsPopupViewState.Visible) + } else { + assertEquals(PrivacyProtectionsPopupViewState.Gone, expectMostRecentItem()) + } + } + + private suspend fun assertStoredPopupDismissTimestamp(url: String, expectedTimestamp: Instant?) { + val dismissedAt = popupDismissDomainRepository.getPopupDismissTime(url.extractDomain()!!).first() + assertEquals(expectedTimestamp, dismissedAt) + } +} + +private class FakePrivacyProtectionsPopupFeature : PrivacyProtectionsPopupFeature { + + var enabled = true + + override fun self(): Toggle = object : Toggle { + override fun isEnabled(): Boolean = enabled + override fun setEnabled(state: State) = throw UnsupportedOperationException() + override fun getRawStoredState(): State? = throw UnsupportedOperationException() + } +} + +private class FakeProtectionsStateProvider : ProtectionsStateProvider { + + private var _protectionsEnabled = MutableStateFlow(true) + + private var protectionsEnabledOverride: Flow? = null + + var protectionsEnabled: Boolean + set(value) { + check(protectionsEnabledOverride == null) + _protectionsEnabled.value = value + } + get() = _protectionsEnabled.value + + fun overrideProtectionsEnabledFlow(flow: Flow) { + protectionsEnabledOverride = flow + } + + override fun areProtectionsEnabled(domain: String): Flow = + protectionsEnabledOverride ?: _protectionsEnabled.asStateFlow() +} + +private class FakePopupDismissDomainRepository : PopupDismissDomainRepository { + + private val data = MutableStateFlow(emptyMap()) + + override fun getPopupDismissTime(domain: String): Flow = + data.map { it[domain] }.distinctUntilChanged() + + override suspend fun setPopupDismissTime( + domain: String, + time: Instant, + ) { + data.update { it + (domain to time) } + } + + override suspend fun removeEntriesOlderThan(time: Instant) = + throw UnsupportedOperationException() + + override suspend fun removeAllEntries() = + throw UnsupportedOperationException() +} + +private class FakeDuckDuckGoUrlDetector : DuckDuckGoUrlDetector { + override fun isDuckDuckGoUrl(url: String): Boolean = AppUrl.Url.HOST == Uri.parse(url).host + + override fun isDuckDuckGoEmailUrl(url: String): Boolean = throw UnsupportedOperationException() + override fun isDuckDuckGoQueryUrl(uri: String): Boolean = throw UnsupportedOperationException() + override fun isDuckDuckGoStaticUrl(uri: String): Boolean = throw UnsupportedOperationException() + override fun extractQuery(uriString: String): String? = throw UnsupportedOperationException() + override fun isDuckDuckGoVerticalUrl(uri: String): Boolean = throw UnsupportedOperationException() + override fun extractVertical(uriString: String): String? = throw UnsupportedOperationException() +} + +private class FakePrivacyProtectionsPopupExperimentVariantRandomizer : PrivacyProtectionsPopupExperimentVariantRandomizer { + var variant = TEST + + override fun getRandomVariant(): PrivacyProtectionsPopupExperimentVariant = variant +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt new file mode 100644 index 000000000000..3d4f1ebc9601 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelNameTest.kt @@ -0,0 +1,63 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +class PrivacyProtectionsPopupPixelNameTest( + private val pixel: PrivacyProtectionsPopupPixelName, +) { + + @Test + fun pixelNameShouldHaveCorrectPrefix() { + val pixelName = pixel.pixelName + val requiredPrefix = "m_privacy_protections_popup_" + assertTrue( + "Pixel name should start with '$requiredPrefix': $pixelName", + pixelName.startsWith(requiredPrefix), + ) + } + + @Test + fun pixelNameSuffixShouldMatchPixelType() { + val pixelName = pixel.pixelName + val requiredSuffix = when (pixel.type) { + COUNT -> "_c" + DAILY -> "_d" + UNIQUE -> "_u" + } + assertTrue( + "Pixel name should end with '$requiredSuffix': $pixelName", + pixelName.endsWith(requiredSuffix), + ) + } + + companion object { + @JvmStatic + @Parameters(name = "{0}") + fun data(): Collection> = + PrivacyProtectionsPopupPixelName.entries.map { arrayOf(it) } + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelsTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelsTest.kt new file mode 100644 index 000000000000..eaa289cdd6ae --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupPixelsTest.kt @@ -0,0 +1,184 @@ +/* + * 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.privacyprotectionspopup.impl + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.TEST +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupPixelName.Params.PARAM_POPUP_TRIGGER_COUNT +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions + +class PrivacyProtectionsPopupPixelsTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val pixel: Pixel = mock() + + private val dataStore = FakePrivacyProtectionsPopupDataStore() + + private val subject = PrivacyProtectionsPopupPixelsImpl( + pixelSender = pixel, + paramsProvider = PrivacyProtectionsPopupExperimentExternalPixelsImpl(dataStore, coroutineRule.testScope, pixel), + appCoroutineScope = coroutineRule.testScope, + dataStore = dataStore, + ) + + @Before + fun setUp() { + runBlocking { dataStore.setExperimentVariant(TEST) } + } + + @Test + fun whenExperimentVariantIsAssignedThenPixelIsSent() = runTest { + subject.reportExperimentVariantAssigned() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.EXPERIMENT_VARIANT_ASSIGNED, + parameters = DEFAULT_PARAMS, + type = UNIQUE, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenPopupIsTriggeredThenPixelIsSent() = runTest { + subject.reportPopupTriggered() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.POPUP_TRIGGERED, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenProtectionsAreDisabledThenPixelsAreSent() = runTest { + subject.reportProtectionsDisabled() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PROTECTIONS_DISABLED, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PROTECTIONS_DISABLED_UNIQUE, + parameters = DEFAULT_PARAMS, + type = UNIQUE, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenPrivacyDashboardIsOpenedThenPixelsAreSent() = runTest { + subject.reportPrivacyDashboardOpened() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PRIVACY_DASHBOARD_OPENED, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PRIVACY_DASHBOARD_OPENED_UNIQUE, + parameters = DEFAULT_PARAMS, + type = UNIQUE, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenDismissButtonsIsClickedThenPixelIsSent() = runTest { + subject.reportPopupDismissedViaButton() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.POPUP_DISMISSED_VIA_BUTTON, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenDoNotShowAgainIsClickedThenPixelIsSentWithTriggerCountParam() = runTest { + val popupTriggerCount = 4 + dataStore.setPopupTriggerCount(popupTriggerCount) + subject.reportDoNotShowAgainClicked() + val extraParams = mapOf(PARAM_POPUP_TRIGGER_COUNT to popupTriggerCount.toString()) + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.DO_NOT_SHOW_AGAIN_CLICKED, + parameters = DEFAULT_PARAMS + extraParams, + type = UNIQUE, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenPopupIsDismissedViaClickOutsideThenPixelIsSent() = runTest { + subject.reportPopupDismissedViaClickOutside() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.POPUP_DISMISSED_VIA_CLICK_OUTSIDE, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verifyNoMoreInteractions(pixel) + } + + @Test + fun whenPageIsRefreshedThenPixelIsSent() = runTest { + subject.reportPageRefreshOnPossibleBreakage() + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PAGE_REFRESH_ON_POSSIBLE_BREAKAGE, + parameters = DEFAULT_PARAMS, + type = COUNT, + ) + + verify(pixel).fire( + pixel = PrivacyProtectionsPopupPixelName.PAGE_REFRESH_ON_POSSIBLE_BREAKAGE_DAILY, + parameters = DEFAULT_PARAMS, + type = DAILY, + ) + + verifyNoMoreInteractions(pixel) + } + + private companion object { + val DEFAULT_PARAMS = mapOf("privacy_protections_popup_experiment_variant" to "test") + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProviderTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProviderTest.kt new file mode 100644 index 000000000000..4a547b453813 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/ProtectionsStateProviderTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FeatureToggle +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.PrivacyFeatureName +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ProtectionsStateProviderTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val featureToggle: FeatureToggle = mock() + private val contentBlocking: ContentBlocking = mock() + private val unprotectedTemporary: UnprotectedTemporary = mock() + private val userAllowListRepository = FakeUserAllowlistRepository() + + private val subject = ProtectionsStateProviderImpl( + featureToggle = featureToggle, + contentBlocking = contentBlocking, + unprotectedTemporary = unprotectedTemporary, + userAllowListRepository = userAllowListRepository, + ) + + @Before + fun setUp() { + whenever(featureToggle.isFeatureEnabled(PrivacyFeatureName.ContentBlockingFeatureName.value)).thenReturn(true) + whenever(contentBlocking.isAnException(any())).thenReturn(false) + whenever(unprotectedTemporary.isAnException(any())).thenReturn(false) + } + + @Test + fun whenContentBlockingIsEnabledAndDomainIsNotAnExceptionThenProtectionsAreEnabled() = runTest { + assertTrue(areProtectionsEnabled(domain = "www.example.com")) + } + + @Test + fun whenContentBlockingFeatureIsDisabledThenProtectionsAreDisabled() = runTest { + val domain = "www.example.com" + whenever(featureToggle.isFeatureEnabled(PrivacyFeatureName.ContentBlockingFeatureName.value)).thenReturn(false) + + assertFalse(areProtectionsEnabled(domain)) + } + + @Test + fun whenDomainIsOnContentBlockingExceptionListThenProtectionsAreDisabled() = runTest { + val domain = "www.example.com" + whenever(contentBlocking.isAnException(domain)).thenReturn(true) + + assertFalse(areProtectionsEnabled(domain)) + } + + @Test + fun whenDomainIsOnUnprotectedTemporaryExceptionListThenProtectionsAreDisabled() = runTest { + val domain = "www.example.com" + whenever(unprotectedTemporary.isAnException(domain)).thenReturn(true) + + assertFalse(areProtectionsEnabled(domain)) + } + + @Test + fun whenDomainIsInUserAllowlistThenProtectionsAreDisabled() = runTest { + val domain = "www.example.com" + userAllowListRepository.addDomainToUserAllowList(domain) + + assertFalse(areProtectionsEnabled(domain)) + } + + private suspend fun areProtectionsEnabled(domain: String): Boolean = + subject.areProtectionsEnabled(domain).first() +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepositoryTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepositoryTest.kt new file mode 100644 index 000000000000..e7c236f410e5 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/db/PopupDismissDomainRepositoryTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 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.privacyprotectionspopup.impl.db + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import java.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class PopupDismissDomainRepositoryTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private lateinit var database: PrivacyProtectionsPopupDatabase + private lateinit var subject: PopupDismissDomainRepository + + @Before + fun setUp() { + database = Room + .inMemoryDatabaseBuilder( + context = ApplicationProvider.getApplicationContext(), + PrivacyProtectionsPopupDatabase::class.java, + ) + .build() + + subject = PopupDismissDomainRepositoryImpl(database.popupDismissDomainDao()) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun whenDatabaseIsEmptyThenReturnsNullDismissTimestamp() = runTest { + val dismissedAt = subject.getPopupDismissTime("www.example.com").first() + assertNull(dismissedAt) + } + + @Test + fun whenDismissTimeIsStoredThenQueryReturnsCorrectValue() = runTest { + val domain = "www.example.com" + val dismissAt = Instant.parse("2023-11-29T10:15:30.000Z") + + subject.setPopupDismissTime(domain, dismissAt) + val storedDismissAt = subject.getPopupDismissTime(domain).first() + assertEquals(dismissAt, storedDismissAt) + } + + @Test + fun whenDismissTimeIsSetMultipleTimesThenReturnsMostRecentlyStoredValue() = runTest { + val domain = "www.example.com" + subject.setPopupDismissTime(domain, Instant.parse("2023-11-28T10:15:30.000Z")) + subject.setPopupDismissTime(domain, Instant.parse("2023-11-29T10:15:30.000Z")) + subject.setPopupDismissTime(domain, Instant.parse("2023-11-10T10:15:30.000Z")) + + val storedDismissAt = subject.getPopupDismissTime(domain).first() + assertEquals(Instant.parse("2023-11-10T10:15:30.000Z"), storedDismissAt) + } + + @Test + fun whenDismissTimeIsSetForDifferentDomainsThenCorrectValueIsReturned() = runTest { + val domain = "www.example.com" + subject.setPopupDismissTime(domain, Instant.parse("2023-11-28T10:15:30.000Z")) + subject.setPopupDismissTime("www.example2.com", Instant.parse("2023-11-29T10:15:30.000Z")) + subject.setPopupDismissTime("www.example1.com", Instant.parse("2023-11-30T10:15:30.000Z")) + + val storedDismissAt = subject.getPopupDismissTime(domain).first() + assertEquals(Instant.parse("2023-11-28T10:15:30.000Z"), storedDismissAt) + } + + @Test + fun whenRemoveEntriesInvokedThenCorrectDataIsDeleted() = runTest { + subject.setPopupDismissTime("www.example1.com", Instant.parse("2023-11-28T10:15:30.000Z")) + subject.setPopupDismissTime("www.example2.com", Instant.parse("2023-11-29T10:15:30.000Z")) + subject.setPopupDismissTime("www.example3.com", Instant.parse("2023-12-01T10:00:00.000Z")) + subject.setPopupDismissTime("www.example4.com", Instant.parse("2023-12-01T10:30:00.000Z")) + + subject.removeEntriesOlderThan(Instant.parse("2023-12-01T10:15:00.000Z")) + + assertNull(subject.getPopupDismissTime("www.example1.com").first()) + assertNull(subject.getPopupDismissTime("www.example2.com").first()) + assertNull(subject.getPopupDismissTime("www.example3.com").first()) + assertEquals(Instant.parse("2023-12-01T10:30:00.000Z"), subject.getPopupDismissTime("www.example4.com").first()) + } + + @Test + fun whenRemoreAllEntriesIsInvokedThenAllDataIsDeleted() = runTest { + subject.setPopupDismissTime("www.example1.com", Instant.parse("2023-11-28T10:15:30.000Z")) + subject.setPopupDismissTime("www.example2.com", Instant.parse("2023-11-29T10:15:30.000Z")) + subject.setPopupDismissTime("www.example3.com", Instant.parse("2023-12-01T10:00:00.000Z")) + subject.setPopupDismissTime("www.example4.com", Instant.parse("2023-12-01T10:30:00.000Z")) + + subject.removeAllEntries() + + assertNull(subject.getPopupDismissTime("www.example1.com").first()) + assertNull(subject.getPopupDismissTime("www.example2.com").first()) + assertNull(subject.getPopupDismissTime("www.example3.com").first()) + assertNull(subject.getPopupDismissTime("www.example4.com").first()) + } +} diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreTest.kt new file mode 100644 index 000000000000..5b46ddaf9867 --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/store/PrivacyProtectionsPopupDataStoreTest.kt @@ -0,0 +1,114 @@ +/* + * 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.privacyprotectionspopup.impl.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.privacyprotectionspopup.impl.PrivacyProtectionsPopupExperimentVariant.CONTROL +import java.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class PrivacyProtectionsPopupDataStoreTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineRule.testScope, + produceFile = { context.preferencesDataStoreFile("privacy_protections_popup") }, + ) + + private val subject: PrivacyProtectionsPopupDataStore = PrivacyProtectionsPopupDataStoreImpl(testDataStore) + + @Test + fun whenDatabaseIsEmptyThenReturnsNullDismissTimestamp() = runTest { + assertNull(subject.getToggleUsageTimestamp()) + } + + @Test + fun whenDismissTimeIsStoredThenQueryReturnsCorrectValue() = runTest { + val timestamp = Instant.parse("2023-11-29T10:15:30.000Z") + + subject.setToggleUsageTimestamp(timestamp) + val storedTimestamp = subject.getToggleUsageTimestamp() + assertEquals(timestamp, storedTimestamp) + } + + @Test + fun whenDismissTimeIsSetMultipleTimesThenReturnsMostRecentlyStoredValue() = runTest { + subject.setToggleUsageTimestamp(Instant.parse("2023-11-28T10:15:30.000Z")) + subject.setToggleUsageTimestamp(Instant.parse("2023-11-29T10:15:30.000Z")) + subject.setToggleUsageTimestamp(Instant.parse("2023-11-10T10:15:30.000Z")) + + val storedTimestamp = subject.getToggleUsageTimestamp() + assertEquals(Instant.parse("2023-11-10T10:15:30.000Z"), storedTimestamp) + } + + @Test + fun whenPopupTriggerCountIsNotInitializedThenReturnsZero() = runTest { + assertEquals(0, subject.getPopupTriggerCount()) + } + + @Test + fun whenPopupTriggerCountIsStoredThenReturnsCorrectValue() = runTest { + val count = 123 + subject.setPopupTriggerCount(count) + val storedCount = subject.getPopupTriggerCount() + assertEquals(count, storedCount) + } + + @Test + fun whenDoNotShowAgainIsNotInitializedThenReturnsFalse() = runTest { + assertFalse(subject.getDoNotShowAgainClicked()) + } + + @Test + fun whenDoNotShowAgainIsStoredThenReturnsCorrectValue() = runTest { + subject.setDoNotShowAgainClicked(clicked = true) + assertTrue(subject.getDoNotShowAgainClicked()) + } + + @Test + fun whenExperimentVariantIsNotInitializedThenReturnsNull() = runTest { + assertNull(subject.getExperimentVariant()) + } + + @Test + fun whenExperimentVariantIsStoredThenReturnsCorrectValue() = runTest { + subject.setExperimentVariant(CONTROL) + assertEquals(CONTROL, subject.getExperimentVariant()) + } +} diff --git a/privacy-protections-popup/readme.md b/privacy-protections-popup/readme.md new file mode 100644 index 000000000000..7edc76c988c4 --- /dev/null +++ b/privacy-protections-popup/readme.md @@ -0,0 +1,8 @@ +# Feature Name + +This module encapsulates a feature that shows a popup suggesting to toggle privacy protections when +user refreshes the page. + +## Who can help you better understand this feature? +- Łukasz Macionczyk +- Marcos Holgado \ No newline at end of file diff --git a/statistics/build.gradle b/statistics/build.gradle index 6889cf2cb7b8..9a5f7ffd3963 100644 --- a/statistics/build.gradle +++ b/statistics/build.gradle @@ -17,6 +17,7 @@ plugins { id 'com.android.library' id 'kotlin-android' + id 'com.google.devtools.ksp' version "$ksp_version" id 'com.squareup.anvil' } @@ -49,7 +50,8 @@ dependencies { implementation Google.dagger // Room - implementation AndroidX.room.runtime + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler // WorkManager implementation AndroidX.work.runtimeKtx @@ -61,6 +63,20 @@ dependencies { implementation AndroidX.core.ktx testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation project(path: ':common-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation AndroidX.test.ext.junit + testImplementation AndroidX.archCore.testing + testImplementation AndroidX.room.testing + testImplementation AndroidX.room.rxJava2 + androidTestImplementation AndroidX.test.runner androidTestImplementation AndroidX.test.rules } diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt index f0b6a1405f70..cd597ac293ba 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt @@ -18,10 +18,16 @@ package com.duckduckgo.app.statistics.api import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult import com.duckduckgo.app.statistics.config.StatisticsLibraryConfig import com.duckduckgo.app.statistics.model.PixelEntity import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE import com.duckduckgo.app.statistics.store.PendingPixelDao +import com.duckduckgo.app.statistics.store.PixelFiredRepository import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.di.scopes.AppScope @@ -30,9 +36,11 @@ import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import io.reactivex.Completable +import io.reactivex.Single import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject +import kotlinx.coroutines.runBlocking import timber.log.Timber interface PixelSender : MainProcessLifecycleObserver { @@ -40,13 +48,19 @@ interface PixelSender : MainProcessLifecycleObserver { pixelName: String, parameters: Map, encodedParameters: Map, - ): Completable + type: PixelType, + ): Single fun enqueuePixel( pixelName: String, parameters: Map, encodedParameters: Map, ): Completable + + enum class SendPixelResult { + PIXEL_SENT, + PIXEL_IGNORED, // Daily or unique pixels may be ignored. + } } @ContributesBinding( @@ -65,6 +79,7 @@ class RxPixelSender @Inject constructor( private val variantManager: VariantManager, private val deviceInfo: DeviceInfo, private val statisticsLibraryConfig: StatisticsLibraryConfig?, + private val pixelFiredRepository: PixelFiredRepository, ) : PixelSender { private val compositeDisposable = CompositeDisposable() @@ -109,15 +124,24 @@ class RxPixelSender @Inject constructor( pixelName: String, parameters: Map, encodedParameters: Map, - ): Completable { - return api.fire( - pixelName, - getDeviceFactor(), - getAtbInfo(), - addDeviceParametersTo(parameters), - encodedParameters, - devMode = shouldFirePixelsAsDev, - ) + type: PixelType, + ): Single = Single.fromCallable { + runBlocking { + if (shouldFirePixel(pixelName, type)) { + api.fire( + pixelName, + getDeviceFactor(), + getAtbInfo(), + addDeviceParametersTo(parameters), + encodedParameters, + devMode = shouldFirePixelsAsDev, + ).blockingAwait() + storePixelFired(pixelName, type) + SendPixelResult.PIXEL_SENT + } else { + SendPixelResult.PIXEL_IGNORED + } + } } override fun enqueuePixel( @@ -163,4 +187,25 @@ class RxPixelSender @Inject constructor( private fun getAtbInfo() = statisticsDataStore.atb?.formatWithVariant(variantManager.getVariantKey()) ?: "" private fun getDeviceFactor() = deviceInfo.formFactor().description + + private suspend fun shouldFirePixel( + pixelName: String, + type: PixelType, + ): Boolean = + when (type) { + COUNT -> true + DAILY -> !pixelFiredRepository.hasDailyPixelFiredToday(pixelName) + UNIQUE -> !pixelFiredRepository.hasUniquePixelFired(pixelName) + } + + private suspend fun storePixelFired( + pixelName: String, + type: PixelType, + ) { + when (type) { + COUNT -> {} // no-op + DAILY -> pixelFiredRepository.storeDailyPixelFiredToday(pixelName) + UNIQUE -> pixelFiredRepository.storeUniquePixelFired(pixelName) + } + } } diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/model/DailyPixelFired.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/model/DailyPixelFired.kt new file mode 100644 index 000000000000..ef3b086825a7 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/model/DailyPixelFired.kt @@ -0,0 +1,27 @@ +/* + * 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.app.statistics.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDate + +@Entity(tableName = "daily_pixels_fired") +data class DailyPixelFired( + @PrimaryKey val name: String, + val date: LocalDate, +) diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/model/UniquePixelFired.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/model/UniquePixelFired.kt new file mode 100644 index 000000000000..1d0da9dcd21e --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/model/UniquePixelFired.kt @@ -0,0 +1,25 @@ +/* + * 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.app.statistics.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "unique_pixels_fired") +data class UniquePixelFired( + @PrimaryKey val name: String, +) diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index d21055f7d9ff..e801a8e3259f 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -18,6 +18,10 @@ package com.duckduckgo.app.statistics.pixels import android.annotation.SuppressLint import com.duckduckgo.app.statistics.api.PixelSender +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_IGNORED +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_SENT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import io.reactivex.schedulers.Schedulers @@ -84,16 +88,36 @@ interface Pixel { const val FIRE_ANIMATION_NONE = "fann" } + enum class PixelType { + + /** + * Pixel is a every-occurrence pixel. Sent every time fire() is invoked. + */ + COUNT, + + /** + * Pixel is a first-in-day pixel. Subsequent attempts to fire such pixel on a given calendar day (UTC) will be ignored. + */ + DAILY, + + /** + * Pixel is a once-ever pixel. Subsequent attempts to fire such pixel will be ignored. + */ + UNIQUE, + } + fun fire( pixel: PixelName, parameters: Map = emptyMap(), encodedParameters: Map = emptyMap(), + type: PixelType = COUNT, ) fun fire( pixelName: String, parameters: Map = emptyMap(), encodedParameters: Map = emptyMap(), + type: PixelType = COUNT, ) fun enqueueFire( @@ -117,8 +141,9 @@ class RxBasedPixel @Inject constructor( pixel: Pixel.PixelName, parameters: Map, encodedParameters: Map, + type: PixelType, ) { - fire(pixel.pixelName, parameters, encodedParameters) + fire(pixel.pixelName, parameters, encodedParameters, type) } @SuppressLint("CheckResult") @@ -126,12 +151,18 @@ class RxBasedPixel @Inject constructor( pixelName: String, parameters: Map, encodedParameters: Map, + type: PixelType, ) { pixelSender - .sendPixel(pixelName, parameters, encodedParameters) + .sendPixel(pixelName, parameters, encodedParameters, type) .subscribeOn(Schedulers.io()) .subscribe( - { Timber.v("Pixel sent: $pixelName with params: $parameters $encodedParameters") }, + { result -> + when (result) { + PIXEL_SENT -> Timber.v("Pixel sent: $pixelName with params: $parameters $encodedParameters") + PIXEL_IGNORED -> Timber.v("Pixel ignored: $pixelName with params: $parameters $encodedParameters") + } + }, { Timber.w( it, diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/DailyPixelFiredDao.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/DailyPixelFiredDao.kt new file mode 100644 index 000000000000..dd933c97a9d5 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/DailyPixelFiredDao.kt @@ -0,0 +1,34 @@ +/* + * 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.app.statistics.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.duckduckgo.app.statistics.model.DailyPixelFired +import java.time.LocalDate + +@Dao +interface DailyPixelFiredDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(value: DailyPixelFired) + + @Query("SELECT COUNT(1) > 0 FROM daily_pixels_fired WHERE name = :name AND date = :date") + suspend fun hasDailyPixelFired(name: String, date: LocalDate): Boolean +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/PixelFiredRepository.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/PixelFiredRepository.kt new file mode 100644 index 000000000000..90e64f11fb55 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/PixelFiredRepository.kt @@ -0,0 +1,57 @@ +/* + * 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.app.statistics.store + +import com.duckduckgo.app.statistics.model.DailyPixelFired +import com.duckduckgo.app.statistics.model.UniquePixelFired +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.LocalDate +import java.time.ZoneOffset +import javax.inject.Inject + +interface PixelFiredRepository { + suspend fun storeDailyPixelFiredToday(name: String) + suspend fun hasDailyPixelFiredToday(name: String): Boolean + suspend fun storeUniquePixelFired(name: String) + suspend fun hasUniquePixelFired(name: String): Boolean +} + +@ContributesBinding(AppScope::class) +class PixelFiredRepositoryImpl @Inject constructor( + private val dailyPixelFiredDao: DailyPixelFiredDao, + private val uniquePixelFiredDao: UniquePixelFiredDao, + private val timeProvider: TimeProvider, +) : PixelFiredRepository { + + private val currentDate: LocalDate + get() = LocalDate.ofInstant(timeProvider.getCurrentTime(), ZoneOffset.UTC) + + override suspend fun storeDailyPixelFiredToday(name: String) { + dailyPixelFiredDao.insert(DailyPixelFired(name, currentDate)) + } + + override suspend fun hasDailyPixelFiredToday(name: String): Boolean = + dailyPixelFiredDao.hasDailyPixelFired(name, currentDate) + + override suspend fun storeUniquePixelFired(name: String) { + uniquePixelFiredDao.insert(UniquePixelFired(name)) + } + + override suspend fun hasUniquePixelFired(name: String): Boolean = + uniquePixelFiredDao.hasUniquePixelFired(name) +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabase.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabase.kt new file mode 100644 index 000000000000..42bb186b6f79 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabase.kt @@ -0,0 +1,53 @@ +/* + * 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.app.statistics.store + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import com.duckduckgo.app.statistics.model.DailyPixelFired +import com.duckduckgo.app.statistics.model.UniquePixelFired +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Database( + exportSchema = true, + version = 1, + entities = [ + DailyPixelFired::class, + UniquePixelFired::class, + ], +) +@TypeConverters(LocalDateConverter::class) +abstract class StatisticsDatabase : RoomDatabase() { + abstract fun dailyPixelFiredDao(): DailyPixelFiredDao + abstract fun uniquePixelFiredDao(): UniquePixelFiredDao +} + +object LocalDateConverter { + private val formatter: DateTimeFormatter + get() = DateTimeFormatter.ISO_LOCAL_DATE + + @TypeConverter + fun fromLocalDate(localDate: LocalDate?): String? = + localDate?.format(formatter) + + @TypeConverter + fun toLocalDate(dateString: String?): LocalDate? = + dateString?.let { LocalDate.parse(it, formatter) } +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabaseModule.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabaseModule.kt new file mode 100644 index 000000000000..df4cd16b221a --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/StatisticsDatabaseModule.kt @@ -0,0 +1,51 @@ +/* + * 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.app.statistics.store + +import android.content.Context +import androidx.room.Room +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +class StatisticsDatabaseModule { + @Provides + @SingleInstanceIn(AppScope::class) + fun provideStatisticsDatabase(context: Context): StatisticsDatabase = + Room + .databaseBuilder( + context = context, + klass = StatisticsDatabase::class.java, + name = "pixels.db", + ) + .fallbackToDestructiveMigration() + .build() + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideDailyPixelFiredDao(db: StatisticsDatabase): DailyPixelFiredDao = + db.dailyPixelFiredDao() + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideUniquePixelFiredDao(db: StatisticsDatabase): UniquePixelFiredDao = + db.uniquePixelFiredDao() +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/TimeProvider.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/TimeProvider.kt new file mode 100644 index 000000000000..c3ed4370cf50 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/TimeProvider.kt @@ -0,0 +1,31 @@ +/* + * 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.app.statistics.store + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import javax.inject.Inject + +interface TimeProvider { + fun getCurrentTime(): Instant +} + +@ContributesBinding(AppScope::class) +class TimeProviderImpl @Inject constructor() : TimeProvider { + override fun getCurrentTime(): Instant = Instant.now() +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/store/UniquePixelFiredDao.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/store/UniquePixelFiredDao.kt new file mode 100644 index 000000000000..04b63acb12c3 --- /dev/null +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/store/UniquePixelFiredDao.kt @@ -0,0 +1,33 @@ +/* + * 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.app.statistics.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.duckduckgo.app.statistics.model.UniquePixelFired + +@Dao +interface UniquePixelFiredDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(value: UniquePixelFired) + + @Query("SELECT COUNT(1) > 0 FROM unique_pixels_fired WHERE name = :name") + suspend fun hasUniquePixelFired(name: String): Boolean +} diff --git a/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt similarity index 84% rename from app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt rename to statistics/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt index 2365a088f6fd..a346fcb8f985 100644 --- a/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt +++ b/statistics/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt @@ -16,10 +16,12 @@ package com.duckduckgo.app.statistics -import com.duckduckgo.app.referral.StubAppReferrerFoundStateListener import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.test.CoroutineTestRule +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -36,7 +38,7 @@ class AtbInitializerTest { private val statisticsDataStore: StatisticsDataStore = mock() private val statisticsUpdater: StatisticsUpdater = mock() - private lateinit var appReferrerStateListener: AtbInitializerListener + private var atbInitializerListener = FakeAtbInitializerListener() @Test fun whenReferrerInformationInstantlyAvailableThenAtbInitialized() = runTest { @@ -50,12 +52,12 @@ class AtbInitializerTest { @Test fun whenReferrerInformationQuicklyAvailableThenAtbInitialized() = runTest { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) - appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx", mockDelayMs = 1000L) + atbInitializerListener.delay = 1.seconds testee = AtbInitializer( coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(appReferrerStateListener), + setOf(atbInitializerListener), coroutineRule.testDispatcherProvider, ) @@ -67,12 +69,12 @@ class AtbInitializerTest { @Test fun whenReferrerInformationTimesOutThenRefreshAtbNotCalled() = runTest { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) - appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx", mockDelayMs = Long.MAX_VALUE) + atbInitializerListener.delay = Duration.INFINITE testee = AtbInitializer( coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(appReferrerStateListener), + setOf(atbInitializerListener), coroutineRule.testDispatcherProvider, ) @@ -110,25 +112,34 @@ class AtbInitializerTest { private fun configureNeverInitialized() { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) - appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx") testee = AtbInitializer( coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(appReferrerStateListener), + setOf(atbInitializerListener), coroutineRule.testDispatcherProvider, ) } private fun configureAlreadyInitialized() { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(true) - appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx") testee = AtbInitializer( coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(appReferrerStateListener), + setOf(atbInitializerListener), coroutineRule.testDispatcherProvider, ) } } + +class FakeAtbInitializerListener : AtbInitializerListener { + + var delay: Duration = Duration.ZERO + + override suspend fun beforeAtbInit() { + delay(delay) + } + + override fun beforeAtbInitTimeoutMillis(): Long = delay.inWholeMilliseconds +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt similarity index 67% rename from app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt rename to statistics/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt index 739cf93d8667..80c304da37ac 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt +++ b/statistics/src/test/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 DuckDuckGo + * 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. @@ -18,30 +18,45 @@ package com.duckduckgo.app.statistics.api import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.LifecycleOwner +import androidx.room.Database import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_IGNORED +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_SENT import com.duckduckgo.app.statistics.api.RxPixelSenderTest.TestPixels.TEST import com.duckduckgo.app.statistics.config.StatisticsLibraryConfig import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.model.PixelEntity +import com.duckduckgo.app.statistics.model.QueryParamsTypeConverter import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE import com.duckduckgo.app.statistics.store.PendingPixelDao +import com.duckduckgo.app.statistics.store.PixelFiredRepository import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.experiments.api.VariantManager import io.reactivex.Completable import java.util.concurrent.TimeoutException +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.kotlin.* +@RunWith(AndroidJUnit4::class) class RxPixelSenderTest { @get:Rule @@ -50,6 +65,9 @@ class RxPixelSenderTest { @get:Rule val schedulers = InstantSchedulersRule() + @get:Rule + val coroutineRule = CoroutineTestRule() + @Mock val api: PixelService = mock() @@ -62,14 +80,15 @@ class RxPixelSenderTest { @Mock val mockDeviceInfo: DeviceInfo = mock() - private lateinit var db: AppDatabase + private lateinit var db: TestAppDatabase private lateinit var pendingPixelDao: PendingPixelDao private lateinit var testee: RxPixelSender private val mockLifecycleOwner: LifecycleOwner = mock() + private val pixelFiredRepository = FakePixelFiredRepository() @Before fun before() { - db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, TestAppDatabase::class.java) .allowMainThreadQueries() .build() pendingPixelDao = db.pixelDao() @@ -83,6 +102,7 @@ class RxPixelSenderTest { object : StatisticsLibraryConfig { override fun shouldFirePixelsAsDev() = true }, + pixelFiredRepository, ) } @@ -98,7 +118,8 @@ class RxPixelSenderTest { givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap()) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("phone"), eq("atbvariant"), any(), any(), any()) } @@ -108,7 +129,8 @@ class RxPixelSenderTest { givenApiSendPixelSucceeds() givenFormFactor(DeviceInfo.FormFactor.TABLET) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap()) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("tablet"), eq(""), any(), any(), any()) } @@ -118,7 +140,8 @@ class RxPixelSenderTest { givenApiSendPixelSucceeds() givenFormFactor(DeviceInfo.FormFactor.PHONE) - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap()) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + .test().assertValue(PIXEL_SENT) verify(api).fire(eq("test"), eq("phone"), eq(""), any(), any(), any()) } @@ -133,7 +156,8 @@ class RxPixelSenderTest { val params = mapOf("param1" to "value1", "param2" to "value2") val expectedParams = mapOf("param1" to "value1", "param2" to "value2", "appVersion" to "1.0.0") - testee.sendPixel(TEST.pixelName, params, emptyMap()) + testee.sendPixel(TEST.pixelName, params, emptyMap(), COUNT) + .test().assertValue(PIXEL_SENT) verify(api).fire("test", "phone", "atbvariant", expectedParams, emptyMap()) } @@ -146,7 +170,8 @@ class RxPixelSenderTest { givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") - testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap()) + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), COUNT) + .test().assertValue(PIXEL_SENT) val expectedParams = mapOf("appVersion" to "1.0.0") verify(api).fire("test", "phone", "atbvariant", expectedParams, emptyMap()) @@ -282,6 +307,76 @@ class RxPixelSenderTest { ) } + @Test + fun whenDailyPixelIsFiredThenPixelNameIsStored() = runTest { + givenPixelApiSucceeds() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + .test().assertValue(PIXEL_SENT) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertTrue(TEST.pixelName in pixelFiredRepository.dailyPixelsFiredToday) + } + + @Test + fun whenDailyPixelFireFailsThenPixelNameIsNotStored() = runTest { + givenPixelApiFails() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + .test().assertError(RuntimeException::class.java) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertFalse(TEST.pixelName in pixelFiredRepository.dailyPixelsFiredToday) + } + + @Test + fun whenDailyPixelHasAlreadyBeenFiredTodayThenItIsNotFiredAgain() = runTest { + pixelFiredRepository.dailyPixelsFiredToday += TEST.pixelName + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), DAILY) + .test().assertValue(PIXEL_IGNORED) + + verifyNoInteractions(api) + assertTrue(TEST.pixelName in pixelFiredRepository.dailyPixelsFiredToday) + } + + @Test + fun whenUniquePixelIsFiredThenPixelNameIsStored() = runTest { + givenPixelApiSucceeds() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + .test().assertValue(PIXEL_SENT) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertTrue(TEST.pixelName in pixelFiredRepository.uniquePixelsFired) + } + + @Test + fun whenUniquePixelFireFailsThenPixelNameIsNotStored() = runTest { + givenPixelApiFails() + givenFormFactor(DeviceInfo.FormFactor.PHONE) + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + .test().assertError(RuntimeException::class.java) + + verify(api).fire(eq(TEST.pixelName), any(), any(), any(), any(), any()) + assertFalse(TEST.pixelName in pixelFiredRepository.uniquePixelsFired) + } + + @Test + fun whenUniquePixelHasAlreadyBeenFiredThenItIsNotFiredAgain() = runTest { + pixelFiredRepository.uniquePixelsFired += TEST.pixelName + + testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap(), UNIQUE) + .test().assertValue(PIXEL_IGNORED) + + verifyNoInteractions(api) + assertTrue(TEST.pixelName in pixelFiredRepository.uniquePixelsFired) + } + private fun assertPixelEntity( expectedEntity: PixelEntity, pixelEntity: PixelEntity, @@ -334,3 +429,35 @@ class RxPixelSenderTest { TEST("test"), } } + +@Database( + exportSchema = false, + version = 1, + entities = [PixelEntity::class], +) +@TypeConverters( + QueryParamsTypeConverter::class, +) +private abstract class TestAppDatabase : RoomDatabase() { + abstract fun pixelDao(): PendingPixelDao +} + +private class FakePixelFiredRepository : PixelFiredRepository { + + val dailyPixelsFiredToday = mutableSetOf() + val uniquePixelsFired = mutableSetOf() + + override suspend fun storeDailyPixelFiredToday(name: String) { + dailyPixelsFiredToday += name + } + + override suspend fun hasDailyPixelFiredToday(name: String): Boolean = + name in dailyPixelsFiredToday + + override suspend fun storeUniquePixelFired(name: String) { + uniquePixelsFired += name + } + + override suspend fun hasUniquePixelFired(name: String): Boolean = + name in uniquePixelsFired +} diff --git a/app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt similarity index 100% rename from app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt rename to statistics/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt diff --git a/app/src/test/java/com/duckduckgo/app/statistics/model/AtbJsonTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/model/AtbJsonTest.kt similarity index 100% rename from app/src/test/java/com/duckduckgo/app/statistics/model/AtbJsonTest.kt rename to statistics/src/test/java/com/duckduckgo/app/statistics/model/AtbJsonTest.kt diff --git a/app/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt similarity index 89% rename from app/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt rename to statistics/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt index 1135161e4d4f..8e92da95b36e 100644 --- a/app/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt +++ b/statistics/src/test/java/com/duckduckgo/app/statistics/pixels/RxBasedPixelTest.kt @@ -17,9 +17,12 @@ package com.duckduckgo.app.statistics.pixels import com.duckduckgo.app.statistics.api.PixelSender +import com.duckduckgo.app.statistics.api.PixelSender.SendPixelResult.PIXEL_SENT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT import com.duckduckgo.app.statistics.pixels.RxBasedPixelTest.TestPixels.TEST import com.duckduckgo.common.test.InstantSchedulersRule import io.reactivex.Completable +import io.reactivex.Single import java.util.concurrent.TimeoutException import org.junit.Rule import org.junit.Test @@ -45,7 +48,7 @@ class RxBasedPixelTest { val pixel = RxBasedPixel(mockPixelSender) pixel.fire(TEST) - verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap()) + verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), COUNT) } @Test @@ -55,7 +58,7 @@ class RxBasedPixelTest { val pixel = RxBasedPixel(mockPixelSender) pixel.fire(TEST) - verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap()) + verify(mockPixelSender).sendPixel("test", emptyMap(), emptyMap(), COUNT) } @Test @@ -66,7 +69,7 @@ class RxBasedPixelTest { val params = mapOf("param1" to "value1", "param2" to "value2") pixel.fire(TEST, params) - verify(mockPixelSender).sendPixel("test", params, emptyMap()) + verify(mockPixelSender).sendPixel("test", params, emptyMap(), COUNT) } @Test @@ -109,11 +112,11 @@ class RxBasedPixelTest { } private fun givenSendPixelSucceeds() { - whenever(mockPixelSender.sendPixel(any(), any(), any())).thenReturn(Completable.complete()) + whenever(mockPixelSender.sendPixel(any(), any(), any(), any())).thenReturn(Single.just(PIXEL_SENT)) } private fun givenSendPixelFails() { - whenever(mockPixelSender.sendPixel(any(), any(), any())).thenReturn(Completable.error(TimeoutException())) + whenever(mockPixelSender.sendPixel(any(), any(), any(), any())).thenReturn(Single.error(TimeoutException())) } enum class TestPixels(override val pixelName: String, val enqueue: Boolean = false) : Pixel.PixelName { diff --git a/statistics/src/test/java/com/duckduckgo/app/statistics/store/PixelFiredRepositoryTest.kt b/statistics/src/test/java/com/duckduckgo/app/statistics/store/PixelFiredRepositoryTest.kt new file mode 100644 index 000000000000..bc7dbc4b773d --- /dev/null +++ b/statistics/src/test/java/com/duckduckgo/app/statistics/store/PixelFiredRepositoryTest.kt @@ -0,0 +1,110 @@ +/* + * 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.app.statistics.store + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import java.time.Instant +import java.time.ZonedDateTime +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class PixelFiredRepositoryTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val timeProvider = FakeTimeProvider() + private lateinit var database: StatisticsDatabase + private lateinit var subject: PixelFiredRepository + + @Before + fun setUp() { + database = Room + .inMemoryDatabaseBuilder( + context = ApplicationProvider.getApplicationContext(), + StatisticsDatabase::class.java, + ) + .build() + + subject = PixelFiredRepositoryImpl( + dailyPixelFiredDao = database.dailyPixelFiredDao(), + uniquePixelFiredDao = database.uniquePixelFiredDao(), + timeProvider = timeProvider, + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun whenDatabaseIsEmptyThenPixelFiredIsFalse() = runTest { + assertFalse(subject.hasDailyPixelFiredToday("pixel_d")) + assertFalse(subject.hasUniquePixelFired("pixel_u")) + } + + @Test + fun whenPixelWasFiredThenPixelFiredIsTrue() = runTest { + subject.storeDailyPixelFiredToday("pixel_d") + subject.storeUniquePixelFired("pixel_u") + + assertTrue(subject.hasDailyPixelFiredToday("pixel_d")) + assertTrue(subject.hasUniquePixelFired("pixel_u")) + } + + @Test + fun whenADayHasPassedThenDailyPixelFiredIsFalse() = runTest { + subject.storeDailyPixelFiredToday("pixel_d") + subject.storeUniquePixelFired("pixel_u") + + timeProvider.time += 1.days.toJavaDuration() + + assertFalse(subject.hasDailyPixelFiredToday("pixel_d")) + assertTrue(subject.hasUniquePixelFired("pixel_u")) + } + + @Test + fun whenDailyPixelWasFiredAgainThenDateIsUpdated() = runTest { + subject.storeDailyPixelFiredToday("pixel_d") + assertTrue(subject.hasDailyPixelFiredToday("pixel_d")) + timeProvider.time += 1.days.toJavaDuration() + assertFalse(subject.hasDailyPixelFiredToday("pixel_d")) + subject.storeDailyPixelFiredToday("pixel_d") + assertTrue(subject.hasDailyPixelFiredToday("pixel_d")) + } +} + +private class FakeTimeProvider : TimeProvider { + var time: ZonedDateTime = ZonedDateTime.parse("2024-01-16T10:15:30Z") + + override fun getCurrentTime(): Instant = time.toInstant() +} diff --git a/statistics/src/test/resources/json/atb_response_valid.json b/statistics/src/test/resources/json/atb_response_valid.json new file mode 100644 index 000000000000..247b1ad29413 --- /dev/null +++ b/statistics/src/test/resources/json/atb_response_valid.json @@ -0,0 +1,6 @@ +{ + "for_more_info": "https://duck.co/help/privacy/atb", + "minorVersion": 3, + "majorVersion": 105, + "version": "v105-3" +} diff --git a/versions.properties b/versions.properties index ce854160dfe6..9fc85014b80b 100644 --- a/versions.properties +++ b/versions.properties @@ -19,6 +19,8 @@ version.androidx.constraintlayout=2.1.4 version.androidx.biometric=1.1.0 +version.androidx.datastore=1.0.0 + version.androidx.localbroadcastmanager=1.1.0 version.androidx.recyclerview=1.3.2 @@ -133,4 +135,4 @@ version.com.google.zxing..core=3.3.0 version.androidx.room.testing=2.4.3 -version.android.tools.desugar_jdk_libs=2.0.4 \ No newline at end of file +version.android.tools.desugar_jdk_libs=2.0.4 From 4080f8fa322fce332f029863bb696fb8b9f0fd35 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Mon, 29 Jan 2024 16:29:06 +0100 Subject: [PATCH 24/26] Change result type to Int instead of Long (#4131) Task/Issue URL: https://app.asana.com/0/488551667048375/1206450345591185/f ### Description Changes result type from Long to Int to avoid conversions. ### Steps to test this PR _Feature 1_ - [ ] smoke testing create account and ensuring sync works - [ ] ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)| --- .../duckduckgo/sync/impl/RealSyncCrypto.kt | 4 +-- .../sync/impl/SyncAccountRepository.kt | 12 +++---- .../com/duckduckgo/sync/TestSyncFixtures.kt | 14 ++++---- .../sync/impl/AppSyncAccountRepositoryTest.kt | 4 +-- .../duckduckgo/sync/impl/SyncCryptoTest.kt | 4 +-- .../sync/crypto/SyncNativeLibTest.kt | 2 +- .../duckduckgo/sync/crypto/SyncNativeLib.kt | 32 +++++++++---------- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCrypto.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCrypto.kt index e89ebc9fe76e..3756ce92e509 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCrypto.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCrypto.kt @@ -39,7 +39,7 @@ class RealSyncCrypto @Inject constructor( throw it } - return if (encryptResult.result != 0L) { + return if (encryptResult.result != 0) { syncOperationErrorRecorder.record(SyncOperationErrorType.DATA_ENCRYPT) throw Exception("Failed to encrypt data") } else { @@ -56,7 +56,7 @@ class RealSyncCrypto @Inject constructor( throw it } - return if (decryptResult.result != 0L) { + return if (decryptResult.result != 0) { syncOperationErrorRecorder.record(SyncOperationErrorType.DATA_DECRYPT) throw Exception("Failed to decrypt data") } else { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index fef27b119402..6baeb892aa9b 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -77,8 +77,8 @@ class AppSyncAccountRepository @Inject constructor( val userId = syncDeviceIds.userId() val account: AccountKeys = nativeLib.generateAccountKeys(userId = userId) - if (account.result != 0L) { - return Error(code = account.result.toInt(), reason = "Create Account: keys failed").also { + if (account.result != 0) { + return Error(code = account.result, reason = "Create Account: keys failed").also { syncPixels.fireSyncAccountErrorPixel(it) } } @@ -323,8 +323,8 @@ class AppSyncAccountRepository @Inject constructor( primaryKey: String, ): Result { val preLogin: LoginKeys = nativeLib.prepareForLogin(primaryKey) - if (preLogin.result != 0L) { - return Error(code = preLogin.result.toInt(), reason = "Login account keys failed").also { + if (preLogin.result != 0) { + return Error(code = preLogin.result, reason = "Login account keys failed").also { syncPixels.fireSyncAccountErrorPixel(it) } } @@ -348,8 +348,8 @@ class AppSyncAccountRepository @Inject constructor( is Result.Success -> { val decryptResult = nativeLib.decrypt(result.data.protected_encryption_key, preLogin.stretchedPrimaryKey) - if (decryptResult.result != 0L) { - return Error(code = decryptResult.result.toInt(), reason = "Decrypt failed").also { + if (decryptResult.result != 0) { + return Error(code = decryptResult.result, reason = "Decrypt failed").also { syncPixels.fireSyncAccountErrorPixel(it) } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt index 1fc8f34614be..52313e8fe8b5 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt @@ -54,7 +54,7 @@ object TestSyncFixtures { const val protectedEncryptionKey = "protectedEncryptionKey" const val encryptedRecoveryCode = "encrypted_recovery_code" val accountKeys = AccountKeys( - result = 0L, + result = 0, userId = userId, password = password, primaryKey = primaryKey, @@ -63,7 +63,7 @@ object TestSyncFixtures { passwordHash = hashedPassword, ) val accountKeysFailed = AccountKeys( - result = 9L, + result = 9, userId = userId, password = password, primaryKey = "", @@ -114,11 +114,11 @@ object TestSyncFixtures { val jsonConnectKey = "{\"connect\":{\"device_id\":\"$deviceId\",\"secret_key\":\"$primaryKey\"}}" val jsonRecoveryKeyEncoded = jsonRecoveryKey.encodeB64() val jsonConnectKeyEncoded = jsonConnectKey.encodeB64() - val connectKeys = ConnectKeys(0L, publicKey = primaryKey, secretKey = secretKey) - val validLoginKeys = LoginKeys(result = 0L, passwordHash = hashedPassword, stretchedPrimaryKey = stretchedPrimaryKey, primaryKey = primaryKey) - val failedLoginKeys = LoginKeys(result = 9L, passwordHash = "", stretchedPrimaryKey = "", primaryKey = "") - val decryptedSecretKey = DecryptResult(result = 0L, decryptedData = secretKey) - val invalidDecryptedSecretKey = DecryptResult(result = 9L, decryptedData = "") + val connectKeys = ConnectKeys(0, publicKey = primaryKey, secretKey = secretKey) + val validLoginKeys = LoginKeys(result = 0, passwordHash = hashedPassword, stretchedPrimaryKey = stretchedPrimaryKey, primaryKey = primaryKey) + val failedLoginKeys = LoginKeys(result = 9, passwordHash = "", stretchedPrimaryKey = "", primaryKey = "") + val decryptedSecretKey = DecryptResult(result = 0, decryptedData = secretKey) + val invalidDecryptedSecretKey = DecryptResult(result = 9, decryptedData = "") val loginResponseBody = LoginResponse( token = token, protected_encryption_key = protectedEncryptionKey, diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index 6785b2374ecc..5892bc9d448b 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -473,10 +473,10 @@ class AppSyncAccountRepositoryTest { private fun prepareForEncryption() { whenever(nativeLib.decrypt(encryptedData = protectedEncryptionKey, secretKey = stretchedPrimaryKey)).thenReturn(decryptedSecretKey) whenever(nativeLib.decryptData(anyString(), primaryKey = eq(primaryKey))).thenAnswer { - DecryptResult(0L, it.arguments.first() as String) + DecryptResult(0, it.arguments.first() as String) } whenever(nativeLib.encryptData(anyString(), primaryKey = eq(primaryKey))).thenAnswer { - EncryptResult(0L, it.arguments.first() as String) + EncryptResult(0, it.arguments.first() as String) } } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncCryptoTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncCryptoTest.kt index ecc270dbd070..197e6d1a0751 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncCryptoTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncCryptoTest.kt @@ -60,7 +60,7 @@ class SyncCryptoTest { @Test fun whenEncryptSucceedsThenResultIsEncrypted() { - whenever(nativeLib.encryptData(any(), any())).thenReturn(EncryptResult(0L, "not encrypted")) + whenever(nativeLib.encryptData(any(), any())).thenReturn(EncryptResult(0, "not encrypted")) val result = syncCrypto.encrypt("something") @@ -82,7 +82,7 @@ class SyncCryptoTest { @Test fun whenDecryptSucceedsThenResultIsDecrypted() { - whenever(nativeLib.decryptData(any(), any())).thenReturn(DecryptResult(0L, "not decrypted")) + whenever(nativeLib.decryptData(any(), any())).thenReturn(DecryptResult(0, "not decrypted")) val result = syncCrypto.decrypt("something") diff --git a/sync/sync-lib/src/androidTest/java/com/duckduckgo/sync/crypto/SyncNativeLibTest.kt b/sync/sync-lib/src/androidTest/java/com/duckduckgo/sync/crypto/SyncNativeLibTest.kt index 6b8da5b549ed..4959ca59c1dc 100644 --- a/sync/sync-lib/src/androidTest/java/com/duckduckgo/sync/crypto/SyncNativeLibTest.kt +++ b/sync/sync-lib/src/androidTest/java/com/duckduckgo/sync/crypto/SyncNativeLibTest.kt @@ -121,7 +121,7 @@ class SyncNativeLibTest { fun whenPrepareForConnectThenResultSuccess() { val syncNativeLib = SyncNativeLib(InstrumentationRegistry.getInstrumentation().targetContext) val prepareForConnect = syncNativeLib.prepareForConnect() - assertEquals(0L, prepareForConnect.result) + assertEquals(0, prepareForConnect.result) } @Test diff --git a/sync/sync-lib/src/main/java/com/duckduckgo/sync/crypto/SyncNativeLib.kt b/sync/sync-lib/src/main/java/com/duckduckgo/sync/crypto/SyncNativeLib.kt index f4a761edb713..510ef1c2660c 100644 --- a/sync/sync-lib/src/main/java/com/duckduckgo/sync/crypto/SyncNativeLib.kt +++ b/sync/sync-lib/src/main/java/com/duckduckgo/sync/crypto/SyncNativeLib.kt @@ -77,7 +77,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val protectedSecretKey = ByteArray(getProtectedSecretKeySize()) val passwordHash = ByteArray(getPasswordHashSize()) - val result: Long = + val result: Int = generateAccountKeys( primaryKey, secretKey, @@ -103,7 +103,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val passwordHash = ByteArray(getPasswordHashSize()) val stretchedPrimaryKey = ByteArray(getStretchedPrimaryKeySize()) - val result: Long = prepareForLogin(passwordHash, stretchedPrimaryKey, primarKeyByteArray) + val result: Int = prepareForLogin(passwordHash, stretchedPrimaryKey, primarKeyByteArray) return LoginKeys( result = result, @@ -121,7 +121,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val secretKeyByteArray = secretKey.decodeKey() val decryptedData = ByteArray(encryptedDataByteArray.size - getEncryptedExtraBytes()) - val result: Long = decrypt(decryptedData, encryptedDataByteArray, secretKeyByteArray) + val result: Int = decrypt(decryptedData, encryptedDataByteArray, secretKeyByteArray) return DecryptResult( result = result, @@ -133,7 +133,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val publicKey = ByteArray(getPublicKeyBytes()) val privateKey = ByteArray(getPrivateKeyBytes()) - val result: Long = + val result: Int = prepareForConnect( publicKey, privateKey, @@ -154,7 +154,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val secretKeyByteArray = primaryKey.decodeKey() val encryptedDataByteArray = ByteArray(rawDataByteArray.size + getEncryptedExtraBytes()) - val result: Long = encrypt(encryptedDataByteArray, rawDataByteArray, secretKeyByteArray) + val result: Int = encrypt(encryptedDataByteArray, rawDataByteArray, secretKeyByteArray) return EncryptResult( result = result, @@ -170,7 +170,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { val secretKeyByteArray = primaryKey.decodeKey() val decryptedData = ByteArray(encryptedDataByteArray.size - getEncryptedExtraBytes()) - val result: Long = decrypt(decryptedData, encryptedDataByteArray, secretKeyByteArray) + val result: Int = decrypt(decryptedData, encryptedDataByteArray, secretKeyByteArray) return DecryptResult( result = result, @@ -228,30 +228,30 @@ class SyncNativeLib constructor(context: Context) : SyncLib { passwordHash: ByteArray, userId: String, password: String, - ): Long + ): Int private external fun prepareForConnect( publicKey: ByteArray, secretKey: ByteArray, - ): Long + ): Int private external fun prepareForLogin( passwordHash: ByteArray, stretchedPrimaryKey: ByteArray, primaryKey: ByteArray, - ): Long + ): Int private external fun encrypt( encryptedBytes: ByteArray, rawBytes: ByteArray, secretKey: ByteArray, - ): Long + ): Int private external fun decrypt( rawBytes: ByteArray, encryptedBytes: ByteArray, secretKey: ByteArray, - ): Long + ): Int private external fun seal( sealedBytes: ByteArray, @@ -278,7 +278,7 @@ class SyncNativeLib constructor(context: Context) : SyncLib { } class AccountKeys( - val result: Long, + val result: Int, val primaryKey: String, val secretKey: String, val protectedSecretKey: String, @@ -288,24 +288,24 @@ class AccountKeys( ) class ConnectKeys( - val result: Long, + val result: Int, val publicKey: String, val secretKey: String, ) class LoginKeys( - val result: Long, + val result: Int, val passwordHash: String, val stretchedPrimaryKey: String, val primaryKey: String, ) class DecryptResult( - val result: Long, + val result: Int, val decryptedData: String, ) class EncryptResult( - val result: Long, + val result: Int, val encryptedData: String, ) From 24bfd6ead8926a2454b03198cc611fdf4da18acf Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Mon, 29 Jan 2024 16:38:58 +0000 Subject: [PATCH 25/26] Bundle notifications experiment in privacy_config.json (#4129) Task/Issue URL: https://app.asana.com/0/488551667048375/1206462881489059/f ### Description Load experiment variants from local `privacy_config` to remove the welcome animation delay ### Steps to test this PR _Pre Steps_ - [x] Change const val `PRIVACY_REMOTE_CONFIG_URL` to `https://www.jsonblob.com/api/1168612786158034944` - [ ] There is a log in `VariantManagerImpl` _Line 86_ when a new variant allocation is made. This way, you can check if the variant has been allocated correctly - [ ] Before every test you need to delete the DuckDuckGo folder from downloads _Experimental variant_ - [ ] Change `privacy_config.json` to the one below in the variant `mg` and make sure you have `androidVersion` filter set to 33 and 34. ``` { "desc": "No notifications permissions prompt experimental group", "variantKey": "mg", "weight": 1, "filters": { "androidVersion": [ "34", "33" ] } } ``` _Android >= 13_ - [ ] Delete the DuckDuckGo folder from Downloads - [ ] Fresh install - [ ] Check experimental variant is assigned - [ ] Check you don't see notifications permissions prompt when first install _Android < 13_ - [ ] Delete the DuckDuckGo folder from Downloads - [ ] Fresh install - [ ] Check experimental variant is assigned - [ ] Check you don't see notifications permissions prompt when first install _Control variant_ - [ ] Change `privacy_config.json` to the one below in the variant `mf` and make sure you have `androidVersion` filter set to 33 and 34. ``` { "desc": "No notifications permissions prompt experimental group", "variantKey": "mf", "weight": 1, "filters": { "androidVersion": [ "34", "33" ] } } ``` _Returning users_ - [ ] Make sure this is not a fresh install so you have DuckDuckGo directory in Downloads folder - [ ] Install from branch - [ ] Check `ru` variant is assigned to the user ### No UI changes --- .../app/onboarding/ui/page/WelcomePage.kt | 57 ++++++++----------- .../impl/ExperimentVariantRepository.kt | 10 +++- .../experiments/impl/VariantManagerImpl.kt | 4 +- .../src/main/res/raw/privacy_config.json | 45 ++++++++++++++- 4 files changed, 80 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt index b8f8e7b64057..233f7f616ef6 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt @@ -61,7 +61,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) // displayed on top of the onboarding. if (view?.windowVisibility == View.VISIBLE) { // Nothing to do at this point with the result. Proceed with the welcome animation. - scheduleTypingAnimation() + scheduleWelcomeAnimation(ANIMATION_DELAY_AFTER_NOTIFICATIONS_PERMISSIONS_HANDLED) } } @@ -74,7 +74,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) private val events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) private val welcomePageViewModel: WelcomePageViewModel by lazy { - ViewModelProvider(this, viewModelFactory)[WelcomePageViewModel::class.java] + ViewModelProvider(this, viewModelFactory).get(WelcomePageViewModel::class.java) } private val binding: ContentOnboardingWelcomeBinding by viewBinding() @@ -86,7 +86,6 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) super.onViewCreated(view, savedInstanceState) configureDaxCta() - scheduleWelcomeAnimation() setSkipAnimationListener() lifecycleScope.launch { @@ -95,30 +94,15 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) .flowOn(dispatcherProvider.io()) .collect(::render) } - } - private fun scheduleWelcomeAnimation() { - welcomeAnimation = ViewCompat.animate(binding.welcomeContent as View) - .alpha(MIN_ALPHA) - .setDuration(ANIMATION_DURATION) - .setStartDelay(ANIMATION_DELAY) - .withEndAction { - requestNotificationsPermissions() - } + requestNotificationsPermissions() } private fun requestNotificationsPermissions() { if (appBuildConfig.sdkInt >= android.os.Build.VERSION_CODES.TIRAMISU) { event(WelcomePageView.Event.OnNotificationPermissionsRequested) } else { - scheduleTypingAnimation() - } - } - - @SuppressLint("InlinedApi") - private fun showNotificationsPermissionsPrompt() { - if (appBuildConfig.sdkInt >= android.os.Build.VERSION_CODES.TIRAMISU) { - requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + scheduleWelcomeAnimation() } } @@ -128,16 +112,19 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) is WelcomePageView.State.ShowDefaultBrowserDialog -> { showDefaultBrowserDialog(state.intent) } - WelcomePageView.State.Finish -> { onContinuePressed() } - - WelcomePageView.State.ShowWelcomeAnimation -> scheduleTypingAnimation() + WelcomePageView.State.ShowWelcomeAnimation -> scheduleWelcomeAnimation() WelcomePageView.State.ShowNotificationsPermissionsPrompt -> showNotificationsPermissionsPrompt() } } + @SuppressLint("InlinedApi") + private fun showNotificationsPermissionsPrompt() { + requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + private fun event(event: WelcomePageView.Event) { lifecycleScope.launch(dispatcherProvider.io()) { events.emit(event) @@ -201,20 +188,26 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) finishTypingAnimation() } else if (!welcomeAnimationFinished) { welcomeAnimation?.cancel() - scheduleTypingAnimation() + scheduleWelcomeAnimation(0L) } welcomeAnimationFinished = true } } - private fun scheduleTypingAnimation() { - typingAnimation = ViewCompat.animate(binding.daxDialogCta.daxCtaContainer) - .alpha(MAX_ALPHA) + private fun scheduleWelcomeAnimation(startDelay: Long = ANIMATION_DELAY) { + welcomeAnimation = ViewCompat.animate(binding.welcomeContent as View) + .alpha(MIN_ALPHA) .setDuration(ANIMATION_DURATION) + .setStartDelay(startDelay) .withEndAction { - welcomeAnimationFinished = true - binding.daxDialogCta.dialogTextCta.startTypingAnimation(ctaText) - setPrimaryCtaListenerAfterWelcomeAlphaAnimation() + typingAnimation = ViewCompat.animate(binding.daxDialogCta.daxCtaContainer) + .alpha(MAX_ALPHA) + .setDuration(ANIMATION_DURATION) + .withEndAction { + welcomeAnimationFinished = true + binding.daxDialogCta.dialogTextCta.startTypingAnimation(ctaText) + setPrimaryCtaListenerAfterWelcomeAlphaAnimation() + } } } @@ -231,8 +224,8 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome) companion object { private const val MIN_ALPHA = 0f private const val MAX_ALPHA = 1f - private const val ANIMATION_DURATION = 1200L - private const val ANIMATION_DELAY = 1800L + private const val ANIMATION_DURATION = 400L + private const val ANIMATION_DELAY = 1400L private const val ANIMATION_DELAY_AFTER_NOTIFICATIONS_PERMISSIONS_HANDLED = 800L private const val DEFAULT_BROWSER_ROLE_MANAGER_DIALOG = 101 diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt index de430ac703fd..c0eb553cc9d0 100644 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt @@ -19,6 +19,8 @@ package com.duckduckgo.experiments.impl import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.impl.VariantManagerImpl.Companion.DEFAULT_VARIANT +import com.duckduckgo.experiments.impl.VariantManagerImpl.Companion.REINSTALL_VARIANT import com.duckduckgo.experiments.impl.store.ExperimentVariantDao import com.duckduckgo.experiments.impl.store.ExperimentVariantEntity import com.squareup.anvil.annotations.ContributesBinding @@ -64,7 +66,13 @@ class ExperimentVariantRepositoryImpl @Inject constructor( override fun updateVariant(variantKey: String) { Timber.i("Updating variant for user: $variantKey") - store.variant = variantKey + if (updateVariantIsAllowed()) { + store.variant = variantKey + } + } + + private fun updateVariantIsAllowed(): Boolean { + return store.variant != DEFAULT_VARIANT.key && store.variant != REINSTALL_VARIANT } override fun getAppReferrerVariant(): String? = store.referrerVariant diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt index c89a82d9bec8..ab01adb4eb88 100644 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt @@ -131,9 +131,9 @@ class VariantManagerImpl @Inject constructor( const val RESERVED_EU_AUCTION_VARIANT = "ml" // this will be returned when there are no other active experiments - private val DEFAULT_VARIANT = Variant(key = "", filterBy = { noFilter() }) + val DEFAULT_VARIANT = Variant(key = "", filterBy = { noFilter() }) - private const val REINSTALL_VARIANT = "ru" + const val REINSTALL_VARIANT = "ru" private fun noFilter(): Boolean = true } diff --git a/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json b/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json index f59a95cf6476..623ec139ef31 100644 --- a/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json +++ b/privacy-config/privacy-config-impl/src/main/res/raw/privacy_config.json @@ -947,7 +947,50 @@ "reason": "Site breakage" } ] + }, + "notificationPermissions": { + "state": "enabled", + "features": { + "noPermissionsPrompt": { + "state": "enabled", + "targets": [ + { + "variantKey": "mg" + } + ] + } + } } }, - "unprotectedTemporary": [] + "unprotectedTemporary": [], + "experimentalVariants": { + "variants": [ + { + "desc": "this is SERP don't remove", + "variantKey": "sc", + "weight": 0.0 + }, + { + "desc": "this is SERP don't remove", + "variantKey": "se", + "weight": 0.0 + }, + { + "desc": "Control group for notification permissions experiment", + "variantKey": "mf", + "weight": 1.0, + "filters": { + "androidVersion": ["34", "33"] + } + }, + { + "desc": "No notifications permissions prompt experimental group", + "variantKey": "mg", + "weight": 1.0, + "filters": { + "androidVersion": ["34", "33"] + } + } + ] + } } \ No newline at end of file From 17624241a326f3d7113053d66ac170ce554d1726 Mon Sep 17 00:00:00 2001 From: Dax the Deployer Date: Mon, 29 Jan 2024 12:15:21 -0500 Subject: [PATCH 26/26] Updated release notes and version number for new release - 5.186.0 --- app/version/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version/version.properties b/app/version/version.properties index 2c7793f19390..ab4559d70e53 100644 --- a/app/version/version.properties +++ b/app/version/version.properties @@ -1 +1 @@ -VERSION=5.185.2 \ No newline at end of file +VERSION=5.186.0 \ No newline at end of file