From ac879839f3686567665e4f340e02f064276df115 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Tue, 4 Nov 2025 13:20:46 +0100 Subject: [PATCH 1/6] Handle db encryption related errors for PIR --- .../java/com/duckduckgo/pir/api/PirFeature.kt | 7 + .../duckduckgo/pir/impl/PirRemoteFeatures.kt | 6 + .../pir/impl/brokers/BrokerJsonUpdater.kt | 4 + .../pir/impl/checker/PirWorkHandler.kt | 4 +- .../com/duckduckgo/pir/impl/di/PirModule.kt | 90 +-------- .../duckduckgo/pir/impl/pixels/PirPixel.kt | 5 + .../pir/impl/pixels/PirPixelSender.kt | 10 + .../pir/impl/store/PirEventsRepository.kt | 133 +++++++++----- .../pir/impl/store/PirRepository.kt | 172 +++++++++++------- .../pir/impl/store/PirSchedulingRepository.kt | 110 +++++++---- .../secure/PirSecureStorageDatabaseFactory.kt | 42 +++-- .../internal/settings/PirDevOptOutActivity.kt | 23 +-- .../internal/settings/PirDevScanActivity.kt | 26 +-- .../settings/PirDevSettingsFeatures.kt | 20 +- .../internal/settings/PirResultsActivity.kt | 111 +++++------ .../store/secure/PirDatabaseExporter.kt | 8 +- .../src/main/res/values/donottranslate.xml | 1 + .../impl/settings/views/PirSettingView.kt | 10 + .../settings/views/PirSettingViewModel.kt | 17 +- .../src/main/res/values/donottranslate.xml | 6 +- 20 files changed, 477 insertions(+), 328 deletions(-) diff --git a/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt index 1767639ab063..9766936d0e96 100644 --- a/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt +++ b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt @@ -24,4 +24,11 @@ interface PirFeature { * @return true if the PIR beta is enabled, false otherwise */ suspend fun isPirBetaEnabled(): Boolean + + /** + * Runs on the IO thread by default. + * + * @return true if PIR storage is available, false otherwise + */ + suspend fun isPirStorageAvailable(): Boolean } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt index 5ebd381fce14..d44db5d06fac 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt @@ -23,6 +23,7 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue import com.duckduckgo.pir.api.PirFeature +import com.duckduckgo.pir.impl.store.PirRepository import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import kotlinx.coroutines.withContext @@ -48,9 +49,14 @@ interface PirRemoteFeatures { class PirRemoteFeatureImpl @Inject constructor( private val pirRemoteFeatures: PirRemoteFeatures, private val dispatcherProvider: DispatcherProvider, + private val pirRepository: PirRepository, ) : PirFeature { override suspend fun isPirBetaEnabled(): Boolean = withContext(dispatcherProvider.io()) { pirRemoteFeatures.pirBeta().isEnabled() } + + override suspend fun isPirStorageAvailable(): Boolean = withContext(dispatcherProvider.io()) { + pirRepository.isRepositoryAvailable() + } } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/brokers/BrokerJsonUpdater.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/brokers/BrokerJsonUpdater.kt index 92ab2ee3636d..7a2ee1012fcb 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/brokers/BrokerJsonUpdater.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/brokers/BrokerJsonUpdater.kt @@ -56,6 +56,10 @@ class RealBrokerJsonUpdater @Inject constructor( */ override suspend fun update(): Boolean = withContext(dispatcherProvider.io()) { return@withContext kotlin.runCatching { + if (!pirRepository.isRepositoryAvailable()) { + return@withContext false + } + confirmEtagIntegrity() dbpService.getMainConfig(pirRepository.getCurrentMainEtag()).also { logcat { "PIR-update: Main config result $it." } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/checker/PirWorkHandler.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/checker/PirWorkHandler.kt index d04540145ac7..46e8f6430c39 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/checker/PirWorkHandler.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/checker/PirWorkHandler.kt @@ -24,6 +24,7 @@ import com.duckduckgo.pir.impl.PirRemoteFeatures import com.duckduckgo.pir.impl.optout.PirForegroundOptOutService import com.duckduckgo.pir.impl.scan.PirForegroundScanService import com.duckduckgo.pir.impl.scan.PirScanScheduler +import com.duckduckgo.pir.impl.store.PirRepository import com.duckduckgo.subscriptions.api.Product.PIR import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.Subscriptions @@ -65,6 +66,7 @@ class RealPirWorkHandler @Inject constructor( private val subscriptions: Subscriptions, private val context: Context, private val pirScanScheduler: PirScanScheduler, + private val pirRepository: PirRepository, ) : PirWorkHandler { override suspend fun canRunPir(): Flow { @@ -81,7 +83,7 @@ class RealPirWorkHandler @Inject constructor( } .distinctUntilChanged(), ) { subscriptionStatus, hasValidEntitlement -> - isPirEnabled(hasValidEntitlement, subscriptionStatus) + isPirEnabled(hasValidEntitlement, subscriptionStatus) && pirRepository.isRepositoryAvailable() } } else { flowOf(false) diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt index ef01b1a7cf5f..f5ece58ea96d 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/di/PirModule.kt @@ -31,6 +31,7 @@ import com.duckduckgo.pir.impl.common.RealNativeBrokerActionHandler import com.duckduckgo.pir.impl.common.actions.EventHandler import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngineFactory import com.duckduckgo.pir.impl.common.actions.RealPirActionsRunnerStateEngineFactory +import com.duckduckgo.pir.impl.pixels.PirPixelSender import com.duckduckgo.pir.impl.scripts.BrokerActionProcessor import com.duckduckgo.pir.impl.scripts.PirMessagingInterface import com.duckduckgo.pir.impl.scripts.RealBrokerActionProcessor @@ -48,19 +49,9 @@ import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.GetCaptchaInfoR import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.NavigateResponse import com.duckduckgo.pir.impl.scripts.models.PirSuccessResponse.SolveCaptchaResponse import com.duckduckgo.pir.impl.service.DbpService -import com.duckduckgo.pir.impl.store.PirDatabase import com.duckduckgo.pir.impl.store.PirRepository import com.duckduckgo.pir.impl.store.RealPirDataStore import com.duckduckgo.pir.impl.store.RealPirRepository -import com.duckduckgo.pir.impl.store.db.BrokerDao -import com.duckduckgo.pir.impl.store.db.BrokerJsonDao -import com.duckduckgo.pir.impl.store.db.EmailConfirmationLogDao -import com.duckduckgo.pir.impl.store.db.ExtractedProfileDao -import com.duckduckgo.pir.impl.store.db.JobSchedulingDao -import com.duckduckgo.pir.impl.store.db.OptOutResultsDao -import com.duckduckgo.pir.impl.store.db.ScanLogDao -import com.duckduckgo.pir.impl.store.db.ScanResultsDao -import com.duckduckgo.pir.impl.store.db.UserProfileDao import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory import com.squareup.anvil.annotations.ContributesTo import com.squareup.moshi.Moshi @@ -70,97 +61,30 @@ import dagger.Module import dagger.Provides import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.runBlocking import javax.inject.Named @Module @ContributesTo(AppScope::class) class PirModule { - @SingleInstanceIn(AppScope::class) - @Provides - fun bindPirDatabase( - databaseFactory: PirSecureStorageDatabaseFactory, - ): PirDatabase { - return runBlocking { - databaseFactory.getDatabase() - } ?: throw IllegalStateException("Failed to create PIR encrypted database") - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideBrokerJsonDao(database: PirDatabase): BrokerJsonDao { - return database.brokerJsonDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideBrokerDao(database: PirDatabase): BrokerDao { - return database.brokerDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideScanResultsDao(database: PirDatabase): ScanResultsDao { - return database.scanResultsDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideUserProfileDao(database: PirDatabase): UserProfileDao { - return database.userProfileDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideScanLogDao(database: PirDatabase): ScanLogDao { - return database.scanLogDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideOptOutResultsDao(database: PirDatabase): OptOutResultsDao { - return database.optOutResultsDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideJobSchedulingDao(database: PirDatabase): JobSchedulingDao { - return database.jobSchedulingDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideExtractedProfileDao(database: PirDatabase): ExtractedProfileDao { - return database.extractedProfileDao() - } - - @SingleInstanceIn(AppScope::class) - @Provides - fun provideEmailConfirmationLogDao(database: PirDatabase): EmailConfirmationLogDao { - return database.emailConfirmationLogDao() - } - @Provides @SingleInstanceIn(AppScope::class) fun providePirRepository( sharedPreferencesProvider: SharedPreferencesProvider, dispatcherProvider: DispatcherProvider, - brokerJsonDao: BrokerJsonDao, - brokerDao: BrokerDao, currentTimeProvider: CurrentTimeProvider, - userProfileDao: UserProfileDao, dbpService: DbpService, - extractedProfileDao: ExtractedProfileDao, + @AppCoroutineScope appCoroutineScope: CoroutineScope, + databaseFactory: PirSecureStorageDatabaseFactory, + pixelSender: PirPixelSender, ): PirRepository = RealPirRepository( dispatcherProvider, RealPirDataStore(sharedPreferencesProvider), currentTimeProvider, - brokerJsonDao, - brokerDao, - userProfileDao, + databaseFactory, dbpService, - extractedProfileDao, + pixelSender, + appCoroutineScope, ) @Provides diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt index 5da0272a0eda..8cc331ad8c39 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt @@ -125,6 +125,11 @@ enum class PirPixel( PIR_EMAIL_CONFIRMATION_RUN_COMPLETED( baseName = "pir_email-confirmation_completed", type = Count, + ), + + PIR_INTERNAL_SECURE_STORAGE_UNAVAILABLE( + baseName = "pir_internal_secure-storage_unavailable", + type = Count, ), ; constructor( diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixelSender.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixelSender.kt index 47eb6e91af65..406270938c69 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixelSender.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixelSender.kt @@ -39,6 +39,7 @@ import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_INTERNAL_SCAN_STATS import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_COMPLETED import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_SCHEDULED import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_STARTED +import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_INTERNAL_SECURE_STORAGE_UNAVAILABLE import com.duckduckgo.pir.impl.pixels.PirPixel.PIR_OPTOUT_STAGE_PENDING_EMAIL_CONFIRMATION import com.squareup.anvil.annotations.ContributesBinding import logcat.logcat @@ -282,6 +283,11 @@ interface PirPixelSender { totalFetchAttempts: Int, totalEmailConfirmationJobs: Int, ) + + /** + * Emits a pixel to signal that PIR encrypted database is unavailable. + */ + fun reportSecureStorageUnavailable() } @ContributesBinding(AppScope::class) @@ -526,6 +532,10 @@ class RealPirPixelSender @Inject constructor( fire(PIR_EMAIL_CONFIRMATION_RUN_COMPLETED, params) } + override fun reportSecureStorageUnavailable() { + fire(PIR_INTERNAL_SECURE_STORAGE_UNAVAILABLE) + } + private fun fire( pixel: PirPixel, params: Map = emptyMap(), diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt index 315dd58f2b7b..636ee5422b82 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt @@ -16,6 +16,7 @@ package com.duckduckgo.pir.impl.store +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.pir.impl.models.ExtractedProfile @@ -32,16 +33,25 @@ import com.duckduckgo.pir.impl.store.db.PirEventLog import com.duckduckgo.pir.impl.store.db.ScanCompletedBroker import com.duckduckgo.pir.impl.store.db.ScanLogDao import com.duckduckgo.pir.impl.store.db.ScanResultsDao +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat import javax.inject.Inject interface PirEventsRepository { - fun getAllEventLogsFlow(): Flow> + suspend fun getAllEventLogsFlow(): Flow> suspend fun saveEventLog(pirEventLog: PirEventLog) @@ -49,7 +59,7 @@ interface PirEventsRepository { suspend fun deleteEventLogs() - fun getScannedBrokersFlow(): Flow> + suspend fun getScannedBrokersFlow(): Flow> suspend fun getScanErrorResultsCount(): Int @@ -57,13 +67,13 @@ interface PirEventsRepository { suspend fun deleteAllScanResults() - fun getTotalScannedBrokersFlow(): Flow + suspend fun getTotalScannedBrokersFlow(): Flow - fun getTotalOptOutCompletedFlow(): Flow + suspend fun getTotalOptOutCompletedFlow(): Flow - fun getAllOptOutActionLogFlow(): Flow> + suspend fun getAllOptOutActionLogFlow(): Flow> - fun getAllSuccessfullySubmittedOptOutFlow(): Flow> + suspend fun getAllSuccessfullySubmittedOptOutFlow(): Flow> suspend fun saveScanCompletedBroker( brokerName: String, @@ -98,7 +108,7 @@ interface PirEventsRepository { detail: String, ) - fun getAllEmailConfirmationLogFlow(): Flow> + suspend fun getAllEmailConfirmationLogFlow(): Flow> suspend fun deleteAllEmailConfirmationsLogs() } @@ -111,64 +121,66 @@ interface PirEventsRepository { class RealPirEventsRepository @Inject constructor( val moshi: Moshi, private val dispatcherProvider: DispatcherProvider, - private val scanResultsDao: ScanResultsDao, - private val scanLogDao: ScanLogDao, - private val optOutResultsDao: OptOutResultsDao, - private val emailConfirmationLogDao: EmailConfirmationLogDao, + private val databaseFactory: PirSecureStorageDatabaseFactory, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : PirEventsRepository { private val extractedProfileAdapter by lazy { moshi.adapter(ExtractedProfile::class.java) } + private val database: Deferred = appCoroutineScope.async(start = CoroutineStart.LAZY) { + prepareDatabase() + } + override suspend fun deleteAllScanResults() { withContext(dispatcherProvider.io()) { - scanResultsDao.deleteAllScanCompletedBroker() - scanLogDao.deleteAllBrokerScanEvents() + scanResultsDao()?.deleteAllScanCompletedBroker() + scanLogDao()?.deleteAllBrokerScanEvents() } } override suspend fun getScanErrorResultsCount(): Int = withContext(dispatcherProvider.io()) { - scanLogDao.getAllBrokerScanEvents().filter { it.eventType == BROKER_ERROR }.size + scanLogDao()?.getAllBrokerScanEvents()?.filter { it.eventType == BROKER_ERROR }?.size ?: 0 } override suspend fun getScanSuccessResultsCount(): Int = withContext(dispatcherProvider.io()) { - scanLogDao.getAllBrokerScanEvents().filter { it.eventType == BROKER_SUCCESS }.size + scanLogDao()?.getAllBrokerScanEvents()?.filter { it.eventType == BROKER_SUCCESS }?.size ?: 0 } - override fun getAllEventLogsFlow(): Flow> { - return scanLogDao.getAllEventLogsFlow() + override suspend fun getAllEventLogsFlow(): Flow> { + return scanLogDao()?.getAllEventLogsFlow() ?: emptyFlow() } override suspend fun saveEventLog(pirEventLog: PirEventLog) { withContext(dispatcherProvider.io()) { - scanLogDao.insertEventLog(pirEventLog) + scanLogDao()?.insertEventLog(pirEventLog) } } override suspend fun saveBrokerScanLog(pirBrokerScanLog: PirBrokerScanLog) { withContext(dispatcherProvider.io()) { - scanLogDao.insertBrokerScanEvent(pirBrokerScanLog) + scanLogDao()?.insertBrokerScanEvent(pirBrokerScanLog) } } override suspend fun deleteEventLogs() { withContext(dispatcherProvider.io()) { - scanLogDao.deleteAllEventLogs() + scanLogDao()?.deleteAllEventLogs() } } - override fun getScannedBrokersFlow(): Flow> { - return scanResultsDao.getScanCompletedBrokerFlow() + override suspend fun getScannedBrokersFlow(): Flow> { + return scanResultsDao()?.getScanCompletedBrokerFlow() ?: flowOf(emptyList()) } - override fun getTotalScannedBrokersFlow(): Flow { - return scanResultsDao.getScanCompletedBrokerFlow().map { it.size } + override suspend fun getTotalScannedBrokersFlow(): Flow { + return scanResultsDao()?.getScanCompletedBrokerFlow()?.map { it.size } ?: flowOf(0) } - override fun getTotalOptOutCompletedFlow(): Flow { - return optOutResultsDao.getOptOutCompletedBrokerFlow().map { it.size } + override suspend fun getTotalOptOutCompletedFlow(): Flow { + return optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { it.size } ?: flowOf(0) } - override fun getAllSuccessfullySubmittedOptOutFlow(): Flow> { - return optOutResultsDao.getOptOutCompletedBrokerFlow().map { + override suspend fun getAllSuccessfullySubmittedOptOutFlow(): Flow> { + return optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { it.filter { it.isSubmitSuccess }.map { @@ -177,11 +189,11 @@ class RealPirEventsRepository @Inject constructor( ?: "Unknown" ) to it.brokerName }.distinct().toMap() - } + } ?: flowOf(emptyMap()) } - override fun getAllOptOutActionLogFlow(): Flow> { - return optOutResultsDao.getOptOutActionLogFlow() + override suspend fun getAllOptOutActionLogFlow(): Flow> { + return optOutResultsDao()?.getOptOutActionLogFlow() ?: flowOf(emptyList()) } override suspend fun saveScanCompletedBroker( @@ -190,8 +202,8 @@ class RealPirEventsRepository @Inject constructor( startTimeInMillis: Long, endTimeInMillis: Long, isSuccess: Boolean, - ) = withContext(dispatcherProvider.io()) { - scanResultsDao.insertScanCompletedBroker( + ): Unit = withContext(dispatcherProvider.io()) { + scanResultsDao()?.insertScanCompletedBroker( ScanCompletedBroker( brokerName = brokerName, profileQueryId = profileQueryId, @@ -208,8 +220,8 @@ class RealPirEventsRepository @Inject constructor( startTimeInMillis: Long, endTimeInMillis: Long, isSubmitSuccess: Boolean, - ) = withContext(dispatcherProvider.io()) { - optOutResultsDao.insertOptOutCompletedBroker( + ): Unit = withContext(dispatcherProvider.io()) { + optOutResultsDao()?.insertOptOutCompletedBroker( OptOutCompletedBroker( brokerName = brokerName, extractedProfile = extractedProfileAdapter.toJson(extractedProfile), @@ -227,8 +239,8 @@ class RealPirEventsRepository @Inject constructor( actionType: String, isError: Boolean, result: String, - ) = withContext(dispatcherProvider.io()) { - optOutResultsDao.insertOptOutActionLog( + ): Unit = withContext(dispatcherProvider.io()) { + optOutResultsDao()?.insertOptOutActionLog( OptOutActionLog( brokerName = brokerName, extractedProfile = extractedProfileAdapter.toJson(extractedProfile), @@ -240,17 +252,17 @@ class RealPirEventsRepository @Inject constructor( ) } - override suspend fun deleteAllOptOutData() = withContext(dispatcherProvider.io()) { - optOutResultsDao.deleteAllOptOutActionLog() - optOutResultsDao.deleteAllOptOutCompletedBroker() + override suspend fun deleteAllOptOutData(): Unit = withContext(dispatcherProvider.io()) { + optOutResultsDao()?.deleteAllOptOutActionLog() + optOutResultsDao()?.deleteAllOptOutCompletedBroker() } override suspend fun saveEmailConfirmationLog( eventTimeInMillis: Long, type: EmailConfirmationEventType, detail: String, - ) = withContext(dispatcherProvider.io()) { - emailConfirmationLogDao.insertEmailConfirmationLog( + ): Unit = withContext(dispatcherProvider.io()) { + emailConfirmationLogDao()?.insertEmailConfirmationLog( PirEmailConfirmationLog( eventTimeInMillis = eventTimeInMillis, eventType = type, @@ -259,11 +271,40 @@ class RealPirEventsRepository @Inject constructor( ) } - override fun getAllEmailConfirmationLogFlow(): Flow> { - return emailConfirmationLogDao.getAllEmailConfirmationLogsFlow() + override suspend fun getAllEmailConfirmationLogFlow(): Flow> { + return emailConfirmationLogDao()?.getAllEmailConfirmationLogsFlow() ?: flowOf(emptyList()) } - override suspend fun deleteAllEmailConfirmationsLogs() = withContext(dispatcherProvider.io()) { - emailConfirmationLogDao.deleteAllEmailConfirmationLogs() + override suspend fun deleteAllEmailConfirmationsLogs(): Unit = withContext(dispatcherProvider.io()) { + emailConfirmationLogDao()?.deleteAllEmailConfirmationLogs() } + + private suspend fun prepareDatabase(): PirDatabase? { + val database = databaseFactory.getDatabase() + return if (database != null && database.databaseContentsAreReadable()) { + database + } else { + logcat(ERROR) { "PIR-DB: PIR events repository is not readable" } + null + } + } + + private fun PirDatabase.databaseContentsAreReadable(): Boolean { + return kotlin.runCatching { + // Try to read from the database to verify it's accessible + brokerJsonDao().getAllBrokersCount() + true + }.getOrElse { + logcat(ERROR) { "PIR-DB: Error reading from PIR events repository: ${it.message}" } + false + } + } + + private suspend fun scanResultsDao(): ScanResultsDao? = database.await()?.scanResultsDao() + + private suspend fun scanLogDao(): ScanLogDao? = database.await()?.scanLogDao() + + private suspend fun optOutResultsDao(): OptOutResultsDao? = database.await()?.optOutResultsDao() + + private suspend fun emailConfirmationLogDao(): EmailConfirmationLogDao? = database.await()?.emailConfirmationLogDao() } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt index 8627076aa4a6..29424b29935a 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt @@ -26,6 +26,7 @@ import com.duckduckgo.pir.impl.models.MirrorSite import com.duckduckgo.pir.impl.models.ProfileQuery import com.duckduckgo.pir.impl.models.scheduling.BrokerSchedulingConfig import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.EmailData +import com.duckduckgo.pir.impl.pixels.PirPixelSender import com.duckduckgo.pir.impl.service.DbpService import com.duckduckgo.pir.impl.service.DbpService.PirEmailConfirmationDataRequest import com.duckduckgo.pir.impl.service.DbpService.PirJsonBroker @@ -44,16 +45,27 @@ import com.duckduckgo.pir.impl.store.db.StoredExtractedProfile import com.duckduckgo.pir.impl.store.db.UserName import com.duckduckgo.pir.impl.store.db.UserProfile import com.duckduckgo.pir.impl.store.db.UserProfileDao +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR import logcat.logcat import java.util.concurrent.TimeUnit interface PirRepository { + /** + * @return Returns `true` if the repository and underlying database is available for use, `false` otherwise. + */ + suspend fun isRepositoryAvailable(): Boolean + suspend fun getCurrentMainEtag(): String? suspend fun updateMainEtag(etag: String?) @@ -124,7 +136,7 @@ interface PirRepository { extractedProfileId: Long, ): ExtractedProfile? - fun getAllExtractedProfilesFlow(): Flow> + suspend fun getAllExtractedProfilesFlow(): Flow> suspend fun getAllExtractedProfiles(): List @@ -213,18 +225,24 @@ interface PirRepository { } } -internal class RealPirRepository( +class RealPirRepository( private val dispatcherProvider: DispatcherProvider, private val pirDataStore: PirDataStore, private val currentTimeProvider: CurrentTimeProvider, - private val brokerJsonDao: BrokerJsonDao, - private val brokerDao: BrokerDao, - private val userProfileDao: UserProfileDao, + private val databaseFactory: PirSecureStorageDatabaseFactory, private val dbpService: DbpService, - private val extractedProfileDao: ExtractedProfileDao, + private val pixelSender: PirPixelSender, + appCoroutineScope: CoroutineScope, ) : PirRepository { + + private val database: Deferred = appCoroutineScope.async(start = CoroutineStart.LAZY) { + prepareDatabase() + } + private val addressCityStateAdapter by lazy { Moshi.Builder().build().adapter(AddressCityState::class.java) } + override suspend fun isRepositoryAvailable(): Boolean = database.await() != null + override suspend fun getCurrentMainEtag(): String? = pirDataStore.mainConfigEtag override suspend fun updateMainEtag(etag: String?) { @@ -240,36 +258,36 @@ internal class RealPirRepository( etag = it.etag, ) }.also { - brokerJsonDao.insertBrokerJsonEtags(it) + brokerJsonDao()?.insertBrokerJsonEtags(it) } } } override suspend fun getAllLocalBrokerJsons(): List = withContext(dispatcherProvider.io()) { - return@withContext brokerJsonDao.getAllBrokers().map { + return@withContext brokerJsonDao()?.getAllBrokers()?.map { BrokerJson( fileName = it.fileName, etag = it.etag, ) - } + }.orEmpty() } override suspend fun getStoredBrokersCount(): Int = withContext(dispatcherProvider.io()) { - return@withContext brokerJsonDao.getAllBrokersCount() + return@withContext brokerJsonDao()?.getAllBrokersCount() ?: 0 } override suspend fun getAllActiveBrokers(): List = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getAllActiveBrokers().map { + return@withContext brokerDao()?.getAllActiveBrokers()?.map { it.name - } + }.orEmpty() } override suspend fun getAllActiveBrokerObjects(): List = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getAllActiveBrokers().map { + return@withContext brokerDao()?.getAllActiveBrokers()?.map { Broker( name = it.name, fileName = it.fileName, @@ -279,12 +297,12 @@ internal class RealPirRepository( addedDatetime = it.addedDatetime, removedAt = it.removedAt, ) - } + }.orEmpty() } override suspend fun getBrokerForName(name: String): Broker? = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getBrokerDetails(name)?.let { + return@withContext brokerDao()?.getBrokerDetails(name)?.let { Broker( name = it.name, fileName = it.fileName, @@ -298,7 +316,7 @@ internal class RealPirRepository( } override suspend fun getAllMirrorSitesForBroker(brokerName: String): List = - brokerDao.getAllMirrorSitesForBroker(brokerName).map { + brokerDao()?.getAllMirrorSitesForBroker(brokerName)?.map { MirrorSite( name = it.name, url = it.url, @@ -307,10 +325,10 @@ internal class RealPirRepository( optOutUrl = it.optOutUrl, parentSite = it.parentSite, ) - } + }.orEmpty() override suspend fun getAllMirrorSites(): List = - brokerDao.getAllMirrorSites().map { + brokerDao()?.getAllMirrorSites()?.map { MirrorSite( name = it.name, url = it.url, @@ -319,22 +337,22 @@ internal class RealPirRepository( optOutUrl = it.optOutUrl, parentSite = it.parentSite, ) - } + }.orEmpty() override suspend fun getAllBrokersForScan(): List = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getAllBrokersNamesWithScanSteps() + return@withContext brokerDao()?.getAllBrokersNamesWithScanSteps().orEmpty() } override suspend fun getAllBrokerOptOutUrls(): Map = withContext(dispatcherProvider.io()) { - val brokerOptOuts = brokerDao.getAllBrokerOptOuts() - return@withContext brokerOptOuts.associate { it.brokerName to it.optOutUrl } + val brokerOptOuts = brokerDao()?.getAllBrokerOptOuts() + return@withContext brokerOptOuts?.associate { it.brokerName to it.optOutUrl }.orEmpty() } override suspend fun getEtagForFilename(fileName: String): String = withContext(dispatcherProvider.io()) { - return@withContext brokerJsonDao.getEtag(fileName) + return@withContext brokerJsonDao()?.getEtag(fileName).orEmpty() } override suspend fun updateBrokerData( @@ -342,7 +360,7 @@ internal class RealPirRepository( broker: PirJsonBroker, ) { withContext(dispatcherProvider.io()) { - brokerDao.upsert( + brokerDao()?.upsert( broker = BrokerEntity( name = broker.name, @@ -389,7 +407,7 @@ internal class RealPirRepository( override suspend fun getBrokerSchedulingConfig(brokerName: String): BrokerSchedulingConfig? = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getSchedulingConfig(brokerName)?.run { + return@withContext brokerDao()?.getSchedulingConfig(brokerName)?.run { BrokerSchedulingConfig( brokerName = this.brokerName, retryErrorInMillis = TimeUnit.HOURS.toMillis(this.retryError.toLong()), @@ -402,7 +420,7 @@ internal class RealPirRepository( override suspend fun getAllBrokerSchedulingConfigs(): List = withContext(dispatcherProvider.io()) { - return@withContext brokerDao.getAllSchedulingConfigs().map { + return@withContext brokerDao()?.getAllSchedulingConfigs()?.map { BrokerSchedulingConfig( brokerName = it.brokerName, retryErrorInMillis = TimeUnit.HOURS.toMillis(it.retryError.toLong()), @@ -410,37 +428,37 @@ internal class RealPirRepository( maintenanceScanInMillis = TimeUnit.HOURS.toMillis(it.maintenanceScan.toLong()), maxAttempts = it.maxAttempts ?: -1, ) - } + }.orEmpty() } override suspend fun getBrokerScanSteps(name: String): String? = withContext(dispatcherProvider.io()) { - brokerDao.getScanJson(name) + brokerDao()?.getScanJson(name) } override suspend fun getBrokerOptOutSteps(name: String): String? = withContext(dispatcherProvider.io()) { - brokerDao.getOptOutJson(name) + brokerDao()?.getOptOutJson(name) } override suspend fun getBrokersForOptOut(formOptOutOnly: Boolean): List = withContext(dispatcherProvider.io()) { - extractedProfileDao - .getAllExtractedProfiles() - .map { + extractedProfileDao() + ?.getAllExtractedProfiles() + ?.map { it.brokerName - }.distinct() - .run { + }?.distinct() + ?.run { if (formOptOutOnly) { this.filter { - brokerDao - .getOptOutJson(it) + brokerDao() + ?.getOptOutJson(it) ?.contains("\"optOutType\":\"formOptOut\"") == true } } else { this } - } + }.orEmpty() } override suspend fun saveNewExtractedProfiles(extractedProfiles: List) { @@ -450,7 +468,7 @@ internal class RealPirRepository( } val profileQueryId = extractedProfiles.first().profileQueryId - val profileQuery = userProfileDao.getUserProfile(profileQueryId) + val profileQuery = userProfileDao()?.getUserProfile(profileQueryId) if (profileQuery?.deprecated == true) { // we should not store any new extracted profiles for a deprecated user profile // also don't mark them as deprecated as we still want to show them on the UI @@ -461,7 +479,7 @@ internal class RealPirRepository( .map { it.toStoredExtractedProfile() }.also { - extractedProfileDao.insertNewExtractedProfiles(it) + extractedProfileDao()?.insertNewExtractedProfiles(it) } } } @@ -471,66 +489,66 @@ internal class RealPirRepository( profileQueryId: Long, ): List = withContext(dispatcherProvider.io()) { - return@withContext extractedProfileDao - .getExtractedProfilesForBrokerAndProfile( + return@withContext extractedProfileDao() + ?.getExtractedProfilesForBrokerAndProfile( brokerName, profileQueryId, - ).map { + )?.map { it.toExtractedProfile() - } + }.orEmpty() } override suspend fun getExtractedProfile( extractedProfileId: Long, ): ExtractedProfile? = withContext(dispatcherProvider.io()) { - return@withContext extractedProfileDao.getExtractedProfile(extractedProfileId)?.toExtractedProfile() + return@withContext extractedProfileDao()?.getExtractedProfile(extractedProfileId)?.toExtractedProfile() } - override fun getAllExtractedProfilesFlow(): Flow> = - extractedProfileDao.getAllExtractedProfileFlow().map { list -> + override suspend fun getAllExtractedProfilesFlow(): Flow> = + extractedProfileDao()?.getAllExtractedProfileFlow()?.map { list -> list.map { it.toExtractedProfile() } - } + } ?: flowOf(emptyList()) override suspend fun getAllExtractedProfiles(): List = withContext(dispatcherProvider.io()) { - return@withContext extractedProfileDao.getAllExtractedProfiles().map { + return@withContext extractedProfileDao()?.getAllExtractedProfiles()?.map { it.toExtractedProfile() - } + }.orEmpty() } override suspend fun getUserProfileQuery(id: Long): ProfileQuery? = withContext(dispatcherProvider.io()) { - userProfileDao.getUserProfile(id)?.toProfileQuery() + userProfileDao()?.getUserProfile(id)?.toProfileQuery() } override suspend fun getAllUserProfileQueries(): List = withContext(dispatcherProvider.io()) { - userProfileDao.getAllUserProfiles().map { + userProfileDao()?.getAllUserProfiles()?.map { it.toProfileQuery() - } + }.orEmpty() } override suspend fun getValidUserProfileQueries(): List = withContext(dispatcherProvider.io()) { - userProfileDao.getValidUserProfiles().map { + userProfileDao()?.getValidUserProfiles()?.map { it.toProfileQuery() - } + }.orEmpty() } override suspend fun getUserProfileQueriesWithIds(ids: List): List = withContext(dispatcherProvider.io()) { - userProfileDao.getUserProfilesWithIds(ids).map { + userProfileDao()?.getUserProfilesWithIds(ids)?.map { it.toProfileQuery() - } + }.orEmpty() } override suspend fun deleteAllUserProfilesQueries() { withContext(dispatcherProvider.io()) { - userProfileDao.deleteAllProfiles() - extractedProfileDao.deleteAllExtractedProfiles() + userProfileDao()?.deleteAllProfiles() + extractedProfileDao()?.deleteAllExtractedProfiles() } } @@ -559,8 +577,8 @@ internal class RealPirRepository( override suspend fun replaceUserProfile(profileQuery: ProfileQuery) { withContext(dispatcherProvider.io()) { - userProfileDao.deleteAllProfiles() - userProfileDao.insertUserProfile(profileQuery.toUserProfile()) + userProfileDao()?.deleteAllProfiles() + userProfileDao()?.insertUserProfile(profileQuery.toUserProfile()) } } @@ -570,7 +588,7 @@ internal class RealPirRepository( profileQueryIdsToDelete: List, ): Boolean = withContext(dispatcherProvider.io()) { try { - userProfileDao.updateUserProfiles( + userProfileDao()?.updateUserProfiles( profilesToAdd = profileQueriesToAdd.map { query -> query.toUserProfile() }, profilesToUpdate = profileQueriesToUpdate.map { query -> query.toUserProfile() }, profileIdsToDelete = profileQueryIdsToDelete, @@ -583,7 +601,7 @@ internal class RealPirRepository( override suspend fun getEmailForBroker(dataBroker: String): String = withContext(dispatcherProvider.io()) { - return@withContext dbpService.getEmail(brokerDao.getBrokerDetails(dataBroker)!!.url).emailAddress + return@withContext dbpService.getEmail(brokerDao()?.getBrokerDetails(dataBroker)!!.url).emailAddress } override suspend fun getEmailConfirmationLinkStatus(emailData: List): Map = @@ -707,6 +725,36 @@ internal class RealPirRepository( deprecated = this.deprecated, ) + private suspend fun prepareDatabase(): PirDatabase? { + val database = databaseFactory.getDatabase() + return if (database != null && database.databaseContentsAreReadable()) { + database + } else { + pixelSender.reportSecureStorageUnavailable() + logcat(ERROR) { "PIR-DB: PIR database is not readable" } + null + } + } + + private fun PirDatabase.databaseContentsAreReadable(): Boolean { + return kotlin.runCatching { + // Try to read from the database to verify it's accessible + brokerJsonDao().getAllBrokersCount() + true + }.getOrElse { + logcat(ERROR) { "PIR-DB: Error reading from PIR database: ${it.message}" } + false + } + } + + private suspend fun brokerJsonDao(): BrokerJsonDao? = database.await()?.brokerJsonDao() + + private suspend fun brokerDao(): BrokerDao? = database.await()?.brokerDao() + + private suspend fun extractedProfileDao(): ExtractedProfileDao? = database.await()?.extractedProfileDao() + + private suspend fun userProfileDao(): UserProfileDao? = database.await()?.userProfileDao() + companion object { private const val EMAIL_DATA_BATCH_SIZE = 100 } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirSchedulingRepository.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirSchedulingRepository.kt index b2c6ded6fb32..311db495677e 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirSchedulingRepository.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirSchedulingRepository.kt @@ -16,6 +16,7 @@ package com.duckduckgo.pir.impl.store +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -28,9 +29,16 @@ import com.duckduckgo.pir.impl.store.db.EmailConfirmationJobRecordEntity import com.duckduckgo.pir.impl.store.db.JobSchedulingDao import com.duckduckgo.pir.impl.store.db.OptOutJobRecordEntity import com.duckduckgo.pir.impl.store.db.ScanJobRecordEntity +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat import javax.inject.Inject interface PirSchedulingRepository { @@ -57,7 +65,10 @@ interface PirSchedulingRepository { * * @param includeDeprecated If true, will also return deprecated jobs (used to run opt-out jobs on profiles that have been removed) */ - suspend fun getValidOptOutJobRecord(extractedProfileId: Long, includeDeprecated: Boolean = false): OptOutJobRecord? + suspend fun getValidOptOutJobRecord( + extractedProfileId: Long, + includeDeprecated: Boolean = false, + ): OptOutJobRecord? suspend fun updateScanJobRecordStatus( newStatus: ScanJobStatus, @@ -117,16 +128,23 @@ interface PirSchedulingRepository { @SingleInstanceIn(AppScope::class) class RealPirSchedulingRepository @Inject constructor( private val dispatcherProvider: DispatcherProvider, - private val jobSchedulingDao: JobSchedulingDao, private val currentTimeProvider: CurrentTimeProvider, + private val databaseFactory: PirSecureStorageDatabaseFactory, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : PirSchedulingRepository { + + private val database: Deferred = appCoroutineScope.async(start = CoroutineStart.LAZY) { + prepareDatabase() + } + override suspend fun getAllValidScanJobRecords(): List = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao - .getAllScanJobRecords() - .map { it.toRecord() } + return@withContext jobSchedulingDao() + ?.getAllScanJobRecords() + ?.map { it.toRecord() } // do not pick-up deprecated jobs as they belong to removed profiles - .filter { !it.deprecated } + ?.filter { !it.deprecated } + .orEmpty() } override suspend fun getValidScanJobRecord( @@ -134,8 +152,8 @@ class RealPirSchedulingRepository @Inject constructor( userProfileId: Long, ): ScanJobRecord? = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao - .getScanJobRecord(brokerName, userProfileId) + return@withContext jobSchedulingDao() + ?.getScanJobRecord(brokerName, userProfileId) ?.run { this.toRecord() } // do not pick-up deprecated jobs as they belong to removed profiles ?.takeIf { !it.deprecated } @@ -146,8 +164,8 @@ class RealPirSchedulingRepository @Inject constructor( includeDeprecated: Boolean, ): OptOutJobRecord? = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao - .getOptOutJobRecord(extractedProfileId) + return@withContext jobSchedulingDao() + ?.getOptOutJobRecord(extractedProfileId) ?.run { this.toRecord() } // do not pick-up deprecated jobs as they belong to removed profiles ?.takeIf { includeDeprecated || !it.deprecated } @@ -155,16 +173,17 @@ class RealPirSchedulingRepository @Inject constructor( override suspend fun getAllValidOptOutJobRecords(): List = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao - .getAllOptOutJobRecords() - .map { record -> record.toRecord() } + return@withContext jobSchedulingDao() + ?.getAllOptOutJobRecords() + ?.map { record -> record.toRecord() } // do not pick-up deprecated jobs as they belong to removed profiles - .filter { !it.deprecated } + ?.filter { !it.deprecated } + .orEmpty() } override suspend fun saveScanJobRecord(scanJobRecord: ScanJobRecord) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.saveScanJobRecord(scanJobRecord.toEntity()) + jobSchedulingDao()?.saveScanJobRecord(scanJobRecord.toEntity()) } } @@ -173,7 +192,7 @@ class RealPirSchedulingRepository @Inject constructor( scanJobRecords .map { it.toEntity() } .also { - jobSchedulingDao.saveScanJobRecords(it) + jobSchedulingDao()?.saveScanJobRecords(it) } } } @@ -186,7 +205,7 @@ class RealPirSchedulingRepository @Inject constructor( deprecated: Boolean, ) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.updateScanJobRecordStatus( + jobSchedulingDao()?.updateScanJobRecordStatus( brokerName = brokerName, profileQueryId = profileQueryId, newStatus = newStatus.name, @@ -201,7 +220,7 @@ class RealPirSchedulingRepository @Inject constructor( optOutJobRecord .toEntity() .also { - jobSchedulingDao.saveOptOutJobRecord(it) + jobSchedulingDao()?.saveOptOutJobRecord(it) } } } @@ -211,77 +230,77 @@ class RealPirSchedulingRepository @Inject constructor( optOutJobRecords .map { it.toEntity() } .also { - jobSchedulingDao.saveOptOutJobRecords(it) + jobSchedulingDao()?.saveOptOutJobRecords(it) } } } override suspend fun deleteAllJobRecords() { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteAllScanJobRecords() - jobSchedulingDao.deleteAllOptOutJobRecords() - jobSchedulingDao.deleteAllEmailConfirmationJobRecords() + jobSchedulingDao()?.deleteAllScanJobRecords() + jobSchedulingDao()?.deleteAllOptOutJobRecords() + jobSchedulingDao()?.deleteAllEmailConfirmationJobRecords() } } override suspend fun deleteAllScanJobRecords() { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteAllScanJobRecords() + jobSchedulingDao()?.deleteAllScanJobRecords() } } override suspend fun deleteJobRecordsForProfiles(profileQueryIds: List) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteJobRecordsForProfiles(profileQueryIds) + jobSchedulingDao()?.deleteJobRecordsForProfiles(profileQueryIds) } } override suspend fun deleteScanJobRecordsWithoutMatchesForProfiles(profileQueryIds: List) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteScanJobRecordsWithoutMatchesForProfiles(profileQueryIds) + jobSchedulingDao()?.deleteScanJobRecordsWithoutMatchesForProfiles(profileQueryIds) } } override suspend fun deleteAllOptOutJobRecords() { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteAllOptOutJobRecords() + jobSchedulingDao()?.deleteAllOptOutJobRecords() } } override suspend fun saveEmailConfirmationJobRecord(emailConfirmationJobRecord: EmailConfirmationJobRecord) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.saveEmailConfirmationJobRecord(emailConfirmationJobRecord.toEntity()) + jobSchedulingDao()?.saveEmailConfirmationJobRecord(emailConfirmationJobRecord.toEntity()) } } override suspend fun getEmailConfirmationJobsWithNoLink(): List = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao.getAllActiveEmailConfirmationJobRecordsWithNoLink().map { + return@withContext jobSchedulingDao()?.getAllActiveEmailConfirmationJobRecordsWithNoLink()?.map { it.toRecord() - } + }.orEmpty() } override suspend fun getEmailConfirmationJobsWithLink(): List = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao.getAllActiveEmailConfirmationJobRecordsWithLink().map { + return@withContext jobSchedulingDao()?.getAllActiveEmailConfirmationJobRecordsWithLink()?.map { it.toRecord() - } + }.orEmpty() } override suspend fun getEmailConfirmationJob(extractedProfileId: Long): EmailConfirmationJobRecord? = withContext(dispatcherProvider.io()) { - return@withContext jobSchedulingDao.getEmailConfirmationJobRecord(extractedProfileId)?.toRecord() + return@withContext jobSchedulingDao()?.getEmailConfirmationJobRecord(extractedProfileId)?.toRecord() } override suspend fun deleteEmailConfirmationJobRecord(extractedProfileId: Long) { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteEmailConfirmationJobRecord(extractedProfileId) + jobSchedulingDao()?.deleteEmailConfirmationJobRecord(extractedProfileId) } } override suspend fun deleteAllEmailConfirmationJobRecords() { withContext(dispatcherProvider.io()) { - jobSchedulingDao.deleteAllEmailConfirmationJobRecords() + jobSchedulingDao()?.deleteAllEmailConfirmationJobRecords() } } @@ -375,4 +394,27 @@ class RealPirSchedulingRepository @Inject constructor( dateCreatedInMillis = this.dateCreatedInMillis, deprecated = this.deprecated, ) + + private suspend fun prepareDatabase(): PirDatabase? { + val database = databaseFactory.getDatabase() + return if (database != null && database.databaseContentsAreReadable()) { + database + } else { + logcat(ERROR) { "PIR-DB: PIR scheduling repository is not readable" } + null + } + } + + private fun PirDatabase.databaseContentsAreReadable(): Boolean { + return kotlin.runCatching { + // Try to read from the database to verify it's accessible + jobSchedulingDao().getAllScanJobRecords() + true + }.getOrElse { + logcat(ERROR) { "PIR-DB: Error reading from PIR scheduling repository: ${it.message}" } + false + } + } + + private suspend fun jobSchedulingDao(): JobSchedulingDao? = database.await()?.jobSchedulingDao() } diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/secure/PirSecureStorageDatabaseFactory.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/secure/PirSecureStorageDatabaseFactory.kt index 0b4d30f1b78a..a14e3bcf883b 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/secure/PirSecureStorageDatabaseFactory.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/secure/PirSecureStorageDatabaseFactory.kt @@ -73,25 +73,31 @@ class RealPirSecureStorageDatabaseFactory @Inject constructor( return _database } - // If we can't access the keystore, it means that L1Key will be null. We don't want to encrypt the db with a null key. - return if (keyProvider.canAccessKeyStore()) { - // At this point, we are guaranteed that if L1key is null, it's because it hasn't been generated yet. Else, we always use the one stored. - _database = Room.databaseBuilder( - context, - PirDatabase::class.java, - "pir_encrypted.db", - ) - .openHelperFactory( - SupportOpenHelperFactory( - keyProvider.getl1Key(), - ), + return runCatching { + // If we can't access the keystore, it means that L1Key will be null. We don't want to encrypt the db with a null key. + if (keyProvider.canAccessKeyStore()) { + // At this point, we are guaranteed that if L1key is null, it's because it hasn't been generated yet. Else, we always use the one stored. + _database = Room.databaseBuilder( + context, + PirDatabase::class.java, + "pir_encrypted.db", ) - .enableMultiInstanceInvalidation() - .fallbackToDestructiveMigration() - .build() - _database - } else { - logcat(ERROR) { "PIR-DB: Cannot access key store!" } + .openHelperFactory( + SupportOpenHelperFactory( + keyProvider.getl1Key(), + ), + ) + .enableMultiInstanceInvalidation() + .fallbackToDestructiveMigration() + .build() + logcat { "PIR-DB: Ready to use!" } + _database + } else { + logcat(ERROR) { "PIR-DB: Cannot access key store!" } + null + } + }.getOrElse { + logcat(ERROR) { "PIR-DB: Cannot instantiate the database due to ${it.message}!" } null } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt index c9d1a6cf99a0..90579eb4af4e 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt @@ -133,18 +133,19 @@ class PirDevOptOutActivity : DuckDuckGoActivity() { dropDownAdapter.clear() dropDownAdapter.addAll(brokerOptions) } + + eventsRepository.getAllSuccessfullySubmittedOptOutFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { optOuts -> + optOutAdapter.clear() + optOutAdapter.addAll( + optOuts.map { + "${it.value} - ${it.key}" + }, + ) + } + .launchIn(lifecycleScope) } - eventsRepository.getAllSuccessfullySubmittedOptOutFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { optOuts -> - optOutAdapter.clear() - optOutAdapter.addAll( - optOuts.map { - "${it.value} - ${it.key}" - }, - ) - } - .launchIn(lifecycleScope) } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt index b8929c5e81b3..00d6cc8c5b76 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt @@ -93,19 +93,21 @@ class PirDevScanActivity : DuckDuckGoActivity() { } private fun bindViews() { - repository.getAllExtractedProfilesFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - render(it) - } - .launchIn(lifecycleScope) + lifecycleScope.launch { + repository.getAllExtractedProfilesFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + render(it) + } + .launchIn(lifecycleScope) - eventsRepository.getTotalScannedBrokersFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - binding.statusSitesScanned.text = getString(R.string.pirStatsStatusScanned, it) - } - .launchIn(lifecycleScope) + eventsRepository.getTotalScannedBrokersFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + binding.statusSitesScanned.text = getString(R.string.pirStatsStatusScanned, it) + } + .launchIn(lifecycleScope) + } } private fun render(extractedProfiles: List) { diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt index 775669ab7ac0..f26503b62c62 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt @@ -17,17 +17,24 @@ package com.duckduckgo.pir.internal.settings import android.content.Context +import android.widget.Toast import com.duckduckgo.anvil.annotations.PriorityKey import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.internal.features.api.InternalFeaturePlugin import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.pir.impl.store.PirRepository +import com.duckduckgo.pir.internal.R import com.squareup.anvil.annotations.ContributesMultibinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject @ContributesMultibinding(AppScope::class) @PriorityKey(InternalFeaturePlugin.PIR_SETTINGS_PRIO_KEY) class PirDevSettingsFeatures @Inject constructor( private val globalActivityStarter: GlobalActivityStarter, + private val pirRepository: PirRepository, ) : InternalFeaturePlugin { override fun internalFeatureTitle(): String { return "PIR dev settings" @@ -38,6 +45,17 @@ class PirDevSettingsFeatures @Inject constructor( } override fun onInternalFeatureClicked(activityContext: Context) { - globalActivityStarter.start(activityContext, PirSettingsScreenNoParams) + // Check if PIR database is available before launching settings + CoroutineScope(Dispatchers.Main).launch { + if (pirRepository.isRepositoryAvailable()) { + globalActivityStarter.start(activityContext, PirSettingsScreenNoParams) + } else { + Toast.makeText( + activityContext, + R.string.pirDevSettingNotAvailableMessage, + Toast.LENGTH_LONG, + ).show() + } + } } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt index 6dbfe5f4db3f..a64842f2c06a 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt @@ -38,6 +38,7 @@ import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirOptOutResu import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirScanResultsScreen import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -97,70 +98,78 @@ class PirResultsActivity : DuckDuckGoActivity() { } private fun showEmailResults() { - eventsRepository.getAllEmailConfirmationLogFlow().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { emailEvents -> - emailEvents.map { result -> - val stringBuilder = StringBuilder() - stringBuilder.append("Time: ${formatter.format(Date(result.eventTimeInMillis))}\n") - stringBuilder.append("EVENT: ${result.eventType}\n") - stringBuilder.append("RESULT: ${result.value}\n") - stringBuilder.toString() - }.also { - render(it) + lifecycleScope.launch { + eventsRepository.getAllEmailConfirmationLogFlow().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { emailEvents -> + emailEvents.map { result -> + val stringBuilder = StringBuilder() + stringBuilder.append("Time: ${formatter.format(Date(result.eventTimeInMillis))}\n") + stringBuilder.append("EVENT: ${result.eventType}\n") + stringBuilder.append("RESULT: ${result.value}\n") + stringBuilder.toString() + }.also { + render(it) + } } - } - .launchIn(lifecycleScope) + .launchIn(lifecycleScope) + } } private fun showOptOutResults() { - eventsRepository.getAllOptOutActionLogFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { optOutEvents -> - optOutEvents.map { result -> - val stringBuilder = StringBuilder() - stringBuilder.append("Time: ${formatter.format(Date(result.completionTimeInMillis))}\n") - stringBuilder.append("BROKER NAME: ${result.brokerName}\n") - stringBuilder.append("EXTRACTED PROFILE: ${result.extractedProfile}\n") - stringBuilder.append("ACTION EXECUTED: ${result.actionType}\n") - stringBuilder.append("IS ERROR: ${result.isError}\n") - stringBuilder.append("RAW RESULT: ${result.result}\n") - stringBuilder.toString() - }.also { - render(it) + lifecycleScope.launch { + eventsRepository.getAllOptOutActionLogFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { optOutEvents -> + optOutEvents.map { result -> + val stringBuilder = StringBuilder() + stringBuilder.append("Time: ${formatter.format(Date(result.completionTimeInMillis))}\n") + stringBuilder.append("BROKER NAME: ${result.brokerName}\n") + stringBuilder.append("EXTRACTED PROFILE: ${result.extractedProfile}\n") + stringBuilder.append("ACTION EXECUTED: ${result.actionType}\n") + stringBuilder.append("IS ERROR: ${result.isError}\n") + stringBuilder.append("RAW RESULT: ${result.result}\n") + stringBuilder.toString() + }.also { + render(it) + } } - } - .launchIn(lifecycleScope) + .launchIn(lifecycleScope) + } } private fun showScanResults() { - eventsRepository.getScannedBrokersFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { scanResults -> - scanResults.map { - val stringBuilder = StringBuilder() - stringBuilder.append("BROKER NAME: ${it.brokerName}\n") - stringBuilder.append("PROFILE ID: ${it.profileQueryId}\n") - stringBuilder.append("COMPLETED WITH NO ERROR: ${it.isSuccess}\n") - stringBuilder.append("DURATION: ${it.endTimeInMillis - it.startTimeInMillis}\n") - stringBuilder.toString() - }.also { - render(it) + lifecycleScope.launch { + eventsRepository.getScannedBrokersFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanResults -> + scanResults.map { + val stringBuilder = StringBuilder() + stringBuilder.append("BROKER NAME: ${it.brokerName}\n") + stringBuilder.append("PROFILE ID: ${it.profileQueryId}\n") + stringBuilder.append("COMPLETED WITH NO ERROR: ${it.isSuccess}\n") + stringBuilder.append("DURATION: ${it.endTimeInMillis - it.startTimeInMillis}\n") + stringBuilder.toString() + }.also { + render(it) + } } - } - .launchIn(lifecycleScope) + .launchIn(lifecycleScope) + } } private fun showAllEvents() { - eventsRepository.getAllEventLogsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { scanEvents -> - scanEvents.map { result -> - "Time: ${formatter.format(Date(result.eventTimeInMillis))}\nEVENT: ${result.eventType}\n" - }.also { - render(it) + lifecycleScope.launch { + eventsRepository.getAllEventLogsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanEvents -> + scanEvents.map { result -> + "Time: ${formatter.format(Date(result.eventTimeInMillis))}\nEVENT: ${result.eventType}\n" + }.also { + render(it) + } } - } - .launchIn(lifecycleScope) + .launchIn(lifecycleScope) + } } private fun render(results: List) { diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/store/secure/PirDatabaseExporter.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/store/secure/PirDatabaseExporter.kt index 990ff77ceb00..abe644400931 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/store/secure/PirDatabaseExporter.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/store/secure/PirDatabaseExporter.kt @@ -19,7 +19,7 @@ package com.duckduckgo.pir.internal.settings.store.secure import android.content.Context import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.pir.impl.store.PirDatabase +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import kotlinx.coroutines.withContext @@ -50,7 +50,7 @@ interface PirDatabaseExporter { boundType = PirDatabaseExporter::class, ) class PirRealDatabaseExporter @Inject constructor( - private val pirDatabase: PirDatabase, + private val pirDatabaseFactory: PirSecureStorageDatabaseFactory, private val dispatcherProvider: DispatcherProvider, private val context: Context, ) : PirDatabaseExporter { @@ -59,10 +59,10 @@ class PirRealDatabaseExporter @Inject constructor( exportDecryptedDatabase() } - private fun exportDecryptedDatabase() { + private suspend fun exportDecryptedDatabase() { try { // Get the writable database instance - val db = pirDatabase.openHelper.writableDatabase + val db = pirDatabaseFactory.getDatabase()?.openHelper?.writableDatabase ?: return // Define output path - using external files directory for accessibility val outputFile = File(context.getExternalFilesDir(null), "pir_decrypted.db") diff --git a/pir/pir-internal/src/main/res/values/donottranslate.xml b/pir/pir-internal/src/main/res/values/donottranslate.xml index 8d7150974927..fd0703ad5fec 100644 --- a/pir/pir-internal/src/main/res/values/donottranslate.xml +++ b/pir/pir-internal/src/main/res/values/donottranslate.xml @@ -47,4 +47,5 @@ Debug opt out Run opt out Note: Only brokers with formOptOut are supported and will appear as an option for optout. + PIR database is not available. This may be due to keystore access issues. diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt index 0d8c68d653f0..792b4df07691 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt @@ -26,6 +26,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.view.button.ButtonType.GHOST +import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider @@ -128,6 +130,14 @@ class PirSettingView @JvmOverloads constructor( OpenPirDashboard -> { globalActivityStarter.start(context, PirDashboardWebViewScreen) } + + Command.ShowPirStorageUnavailableDialog -> { + TextAlertDialogBuilder(context) + .setTitle(R.string.pirStorageUnavailableDialogTitle) + .setMessage(R.string.pirStorageUnavailableDialogMessage) + .setPositiveButton(R.string.pirStorageUnavailableDialogButton, GHOST) + .show() + } } } } 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 42fc037ddcab..3c963808a91e 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 @@ -57,6 +57,7 @@ class PirSettingViewModel @Inject constructor( sealed class Command { data object OpenPirDesktop : Command() data object OpenPirDashboard : Command() + data object ShowPirStorageUnavailableDialog : Command() } private val command = Channel(1, BufferOverflow.DROP_OLDEST) @@ -83,11 +84,19 @@ class PirSettingViewModel @Inject constructor( fun onPir(type: Type) { pixelSender.reportAppSettingsPirClick() - val command = when (type) { - DESKTOP -> OpenPirDesktop - DASHBOARD -> Command.OpenPirDashboard + viewModelScope.launch { + val command = when (type) { + DESKTOP -> OpenPirDesktop + DASHBOARD -> { + if (pirFeature.isPirStorageAvailable()) { + Command.OpenPirDashboard + } else { + Command.ShowPirStorageUnavailableDialog + } + } + } + sendCommand(command) } - sendCommand(command) } override fun onCreate(owner: LifecycleOwner) { diff --git a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml index 4c597b155e21..cfe1fda30517 100644 --- a/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml +++ b/subscriptions/subscriptions-impl/src/main/res/values/donottranslate.xml @@ -32,4 +32,8 @@ Switch Plan Manage Plan and Payment Options - \ No newline at end of file + + Something went wrong + Personal Information Removal is not available at this moment. Please restart the app and try again. + OK + From 9ebfc572fd9bbe50cdb05bbc6efa829053fa8844 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Tue, 4 Nov 2025 13:53:19 +0100 Subject: [PATCH 2/6] Update tests --- .../impl/checker/RealPirWorkHandlerTest.kt | 19 ++++++- .../impl/brokers/RealBrokerJsonUpdaterTest.kt | 21 +++++++- .../pir/impl/store/RealPirRepositoryTest.kt | 49 +++++++++++++++++-- .../store/RealPirSchedulingRepositoryTest.kt | 16 +++++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/pir/pir-impl/src/test/java/com/duckduckgo/pir/impl/checker/RealPirWorkHandlerTest.kt b/pir/pir-impl/src/test/java/com/duckduckgo/pir/impl/checker/RealPirWorkHandlerTest.kt index e2c426d87127..86ce4466e13d 100644 --- a/pir/pir-impl/src/test/java/com/duckduckgo/pir/impl/checker/RealPirWorkHandlerTest.kt +++ b/pir/pir-impl/src/test/java/com/duckduckgo/pir/impl/checker/RealPirWorkHandlerTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.pir.impl.PirRemoteFeatures import com.duckduckgo.pir.impl.scan.PirScanScheduler +import com.duckduckgo.pir.impl.store.PirRepository import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.Subscriptions @@ -50,12 +51,14 @@ class RealPirWorkHandlerTest { private val pirScanScheduler: PirScanScheduler = mock() private val context: Context = mock() private val pirBetaToggle: Toggle = mock() + private val pirRepository: PirRepository = mock() private lateinit var pirWorkHandler: RealPirWorkHandler @Before - fun setUp() { + fun setUp() = runTest { whenever(pirRemoteFeatures.pirBeta()).thenReturn(pirBetaToggle) + whenever(pirRepository.isRepositoryAvailable()).thenReturn(true) pirWorkHandler = RealPirWorkHandler( pirRemoteFeatures = pirRemoteFeatures, @@ -63,6 +66,7 @@ class RealPirWorkHandlerTest { subscriptions = subscriptions, context = context, pirScanScheduler = pirScanScheduler, + pirRepository = pirRepository, ) } @@ -254,6 +258,19 @@ class RealPirWorkHandlerTest { } } + @Test + fun whenRepositoryNotAvailableThenCanRunPirReturnsFalse() = runTest { + whenever(pirBetaToggle.isEnabled()).thenReturn(true) + whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) + whenever(subscriptions.getSubscriptionStatusFlow()).thenReturn(flowOf(SubscriptionStatus.AUTO_RENEWABLE)) + whenever(pirRepository.isRepositoryAvailable()).thenReturn(false) + + pirWorkHandler.canRunPir().test { + assertFalse(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenCancelWorkThenStopsForegroundServicesAndCancelsWorkManager() { pirWorkHandler.cancelWork() diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/brokers/RealBrokerJsonUpdaterTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/brokers/RealBrokerJsonUpdaterTest.kt index 03e5244f9dc9..b72c1c16fa44 100644 --- a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/brokers/RealBrokerJsonUpdaterTest.kt +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/brokers/RealBrokerJsonUpdaterTest.kt @@ -49,7 +49,9 @@ class RealBrokerJsonUpdaterTest { private val mockBrokerDataDownloader: BrokerDataDownloader = mock() @Before - fun setUp() { + fun setUp() = runTest { + whenever(mockPirRepository.isRepositoryAvailable()).thenReturn(true) + testee = RealBrokerJsonUpdater( dbpService = mockDbpService, dispatcherProvider = coroutineRule.testDispatcherProvider, @@ -251,4 +253,21 @@ class RealBrokerJsonUpdaterTest { verifyNoInteractions(mockDbpService) verifyNoInteractions(mockBrokerDataDownloader) } + + @Test + fun whenRepositoryNotAvailableThenReturnsFalse() = runTest { + // Given + whenever(mockPirRepository.isRepositoryAvailable()).thenReturn(false) + + // When + val result = testee.update() + + // Then + assertFalse(result) + verifyNoInteractions(mockDbpService) + verifyNoInteractions(mockBrokerDataDownloader) + verify(mockPirRepository, never()).getCurrentMainEtag() + verify(mockPirRepository, never()).updateMainEtag(any()) + verify(mockPirRepository, never()).updateBrokerJsons(any()) + } } diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirRepositoryTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirRepositoryTest.kt index 930ef3a99b8a..cd58a6e1d66a 100644 --- a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirRepositoryTest.kt +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirRepositoryTest.kt @@ -22,6 +22,7 @@ import com.duckduckgo.pir.impl.models.Address import com.duckduckgo.pir.impl.models.AddressCityState import com.duckduckgo.pir.impl.models.ExtractedProfile import com.duckduckgo.pir.impl.models.scheduling.JobRecord.EmailConfirmationJobRecord.EmailData +import com.duckduckgo.pir.impl.pixels.PirPixelSender import com.duckduckgo.pir.impl.service.DbpService import com.duckduckgo.pir.impl.service.DbpService.PirEmailConfirmationDataRequest import com.duckduckgo.pir.impl.service.DbpService.PirGetEmailConfirmationLinkResponse @@ -32,6 +33,8 @@ import com.duckduckgo.pir.impl.store.db.ExtractedProfileDao import com.duckduckgo.pir.impl.store.db.UserName import com.duckduckgo.pir.impl.store.db.UserProfile import com.duckduckgo.pir.impl.store.db.UserProfileDao +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -60,18 +63,29 @@ class RealPirRepositoryTest { private val mockUserProfileDao: UserProfileDao = mock() private val mockDbpService: DbpService = mock() private val mockExtractedProfileDao: ExtractedProfileDao = mock() + private val mockDatabaseFactory: PirSecureStorageDatabaseFactory = mock() + private val mockDatabase: PirDatabase = mock() + private val mockPixelSender: PirPixelSender = mock() @Before fun setUp() { + runBlocking { + whenever(mockDatabaseFactory.getDatabase()).thenReturn(mockDatabase) + } + whenever(mockDatabase.brokerJsonDao()).thenReturn(mockBrokerJsonDao) + whenever(mockDatabase.brokerDao()).thenReturn(mockBrokerDao) + whenever(mockDatabase.userProfileDao()).thenReturn(mockUserProfileDao) + whenever(mockDatabase.extractedProfileDao()).thenReturn(mockExtractedProfileDao) + whenever(mockBrokerJsonDao.getAllBrokersCount()).thenReturn(0) + testee = RealPirRepository( dispatcherProvider = coroutineRule.testDispatcherProvider, pirDataStore = mockPirDataStore, currentTimeProvider = mockCurrentTimeProvider, - brokerJsonDao = mockBrokerJsonDao, - brokerDao = mockBrokerDao, - userProfileDao = mockUserProfileDao, + databaseFactory = mockDatabaseFactory, dbpService = mockDbpService, - extractedProfileDao = mockExtractedProfileDao, + pixelSender = mockPixelSender, + appCoroutineScope = coroutineRule.testScope, ) } @@ -80,6 +94,33 @@ class RealPirRepositoryTest { private val testEmailData2 = EmailData(email = "test2@example.com", attemptId = "attempt-456") private val testEmailData3 = EmailData(email = "test3@example.com", attemptId = "attempt-789") + @Test + fun whenIsRepositoryAvailableAndDatabaseReadableThenReturnTrue() = runTest { + // Given - Database is readable (set up in setUp method) + + // When + val result = testee.isRepositoryAvailable() + + // Then + assertTrue(result) + verify(mockDatabaseFactory).getDatabase() + verify(mockBrokerJsonDao).getAllBrokersCount() + } + + @Test + fun whenIsRepositoryAvailableAndDatabaseNotReadableThenReturnFalse() = runTest { + // Given - Set up the database to throw an exception when accessed + whenever(mockBrokerJsonDao.getAllBrokersCount()).thenThrow(RuntimeException("Database not readable")) + + // When + val result = testee.isRepositoryAvailable() + + // Then + assertEquals(false, result) + verify(mockDatabaseFactory).getDatabase() + verify(mockBrokerJsonDao).getAllBrokersCount() + } + @Test fun whenGetEmailConfirmationLinkStatusWithReadyStatusThenReturnReadyWithData() = runTest { // Given diff --git a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirSchedulingRepositoryTest.kt b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirSchedulingRepositoryTest.kt index f02b21a5bdce..ad93c1383d0e 100644 --- a/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirSchedulingRepositoryTest.kt +++ b/pir/pir-impl/src/test/kotlin/com/duckduckgo/pir/impl/store/RealPirSchedulingRepositoryTest.kt @@ -23,10 +23,13 @@ import com.duckduckgo.pir.impl.models.scheduling.JobRecord.OptOutJobRecord import com.duckduckgo.pir.impl.models.scheduling.JobRecord.OptOutJobRecord.OptOutJobStatus import com.duckduckgo.pir.impl.models.scheduling.JobRecord.ScanJobRecord import com.duckduckgo.pir.impl.models.scheduling.JobRecord.ScanJobRecord.ScanJobStatus +import com.duckduckgo.pir.impl.store.db.BrokerJsonDao import com.duckduckgo.pir.impl.store.db.EmailConfirmationJobRecordEntity import com.duckduckgo.pir.impl.store.db.JobSchedulingDao import com.duckduckgo.pir.impl.store.db.OptOutJobRecordEntity import com.duckduckgo.pir.impl.store.db.ScanJobRecordEntity +import com.duckduckgo.pir.impl.store.secure.PirSecureStorageDatabaseFactory +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -45,14 +48,25 @@ class RealPirSchedulingRepositoryTest { private val mockJobSchedulingDao: JobSchedulingDao = mock() private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + private val mockDatabaseFactory: PirSecureStorageDatabaseFactory = mock() + private val mockDatabase: PirDatabase = mock() + private val mockBrokerJsonDao: BrokerJsonDao = mock() @Before fun setUp() { + runBlocking { + whenever(mockDatabaseFactory.getDatabase()).thenReturn(mockDatabase) + } + whenever(mockDatabase.jobSchedulingDao()).thenReturn(mockJobSchedulingDao) + whenever(mockDatabase.brokerJsonDao()).thenReturn(mockBrokerJsonDao) + whenever(mockBrokerJsonDao.getAllBrokersCount()).thenReturn(0) + testee = RealPirSchedulingRepository( dispatcherProvider = coroutineRule.testDispatcherProvider, - jobSchedulingDao = mockJobSchedulingDao, currentTimeProvider = mockCurrentTimeProvider, + databaseFactory = mockDatabaseFactory, + appCoroutineScope = coroutineRule.testScope, ) whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(9000L) From 88e04f8aa0fb34823fcf3e6e1a6c9cf50b574d9f Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Tue, 4 Nov 2025 14:42:28 +0100 Subject: [PATCH 3/6] Fix dispatchers lint --- .../pir/internal/settings/PirDevSettingsFeatures.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt index f26503b62c62..72d47f2d5132 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsFeatures.kt @@ -19,6 +19,7 @@ package com.duckduckgo.pir.internal.settings import android.content.Context import android.widget.Toast import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.internal.features.api.InternalFeaturePlugin import com.duckduckgo.navigation.api.GlobalActivityStarter @@ -26,7 +27,6 @@ import com.duckduckgo.pir.impl.store.PirRepository import com.duckduckgo.pir.internal.R import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,6 +35,7 @@ import javax.inject.Inject class PirDevSettingsFeatures @Inject constructor( private val globalActivityStarter: GlobalActivityStarter, private val pirRepository: PirRepository, + private val dispatcherProvider: DispatcherProvider, ) : InternalFeaturePlugin { override fun internalFeatureTitle(): String { return "PIR dev settings" @@ -46,7 +47,7 @@ class PirDevSettingsFeatures @Inject constructor( override fun onInternalFeatureClicked(activityContext: Context) { // Check if PIR database is available before launching settings - CoroutineScope(Dispatchers.Main).launch { + CoroutineScope(dispatcherProvider.main()).launch { if (pirRepository.isRepositoryAvailable()) { globalActivityStarter.start(activityContext, PirSettingsScreenNoParams) } else { From 7aeb21bab0bd7740b98364325f9dd2e3bee9b02d Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Wed, 5 Nov 2025 13:23:45 +0100 Subject: [PATCH 4/6] Use flow instead of suspend --- .../pir/impl/store/PirEventsRepository.kt | 65 +++++----- .../pir/impl/store/PirRepository.kt | 21 ++-- .../internal/settings/PirDevOptOutActivity.kt | 24 ++-- .../internal/settings/PirDevScanActivity.kt | 26 ++-- .../internal/settings/PirResultsActivity.kt | 111 ++++++++---------- 5 files changed, 123 insertions(+), 124 deletions(-) diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt index 636ee5422b82..4b46b640a992 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirEventsRepository.kt @@ -42,7 +42,8 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -51,7 +52,7 @@ import logcat.logcat import javax.inject.Inject interface PirEventsRepository { - suspend fun getAllEventLogsFlow(): Flow> + fun getAllEventLogsFlow(): Flow> suspend fun saveEventLog(pirEventLog: PirEventLog) @@ -59,7 +60,7 @@ interface PirEventsRepository { suspend fun deleteEventLogs() - suspend fun getScannedBrokersFlow(): Flow> + fun getScannedBrokersFlow(): Flow> suspend fun getScanErrorResultsCount(): Int @@ -67,13 +68,13 @@ interface PirEventsRepository { suspend fun deleteAllScanResults() - suspend fun getTotalScannedBrokersFlow(): Flow + fun getTotalScannedBrokersFlow(): Flow - suspend fun getTotalOptOutCompletedFlow(): Flow + fun getTotalOptOutCompletedFlow(): Flow - suspend fun getAllOptOutActionLogFlow(): Flow> + fun getAllOptOutActionLogFlow(): Flow> - suspend fun getAllSuccessfullySubmittedOptOutFlow(): Flow> + fun getAllSuccessfullySubmittedOptOutFlow(): Flow> suspend fun saveScanCompletedBroker( brokerName: String, @@ -108,7 +109,7 @@ interface PirEventsRepository { detail: String, ) - suspend fun getAllEmailConfirmationLogFlow(): Flow> + fun getAllEmailConfirmationLogFlow(): Flow> suspend fun deleteAllEmailConfirmationsLogs() } @@ -145,8 +146,8 @@ class RealPirEventsRepository @Inject constructor( scanLogDao()?.getAllBrokerScanEvents()?.filter { it.eventType == BROKER_SUCCESS }?.size ?: 0 } - override suspend fun getAllEventLogsFlow(): Flow> { - return scanLogDao()?.getAllEventLogsFlow() ?: emptyFlow() + override fun getAllEventLogsFlow(): Flow> = flow { + emitAll(scanLogDao()?.getAllEventLogsFlow() ?: flowOf(listOf())) } override suspend fun saveEventLog(pirEventLog: PirEventLog) { @@ -167,33 +168,35 @@ class RealPirEventsRepository @Inject constructor( } } - override suspend fun getScannedBrokersFlow(): Flow> { - return scanResultsDao()?.getScanCompletedBrokerFlow() ?: flowOf(emptyList()) + override fun getScannedBrokersFlow(): Flow> = flow { + emitAll(scanResultsDao()?.getScanCompletedBrokerFlow() ?: flowOf(emptyList())) } - override suspend fun getTotalScannedBrokersFlow(): Flow { - return scanResultsDao()?.getScanCompletedBrokerFlow()?.map { it.size } ?: flowOf(0) + override fun getTotalScannedBrokersFlow(): Flow = flow { + emitAll(scanResultsDao()?.getScanCompletedBrokerFlow()?.map { it.size } ?: flowOf(0)) } - override suspend fun getTotalOptOutCompletedFlow(): Flow { - return optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { it.size } ?: flowOf(0) + override fun getTotalOptOutCompletedFlow(): Flow = flow { + emitAll(optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { it.size } ?: flowOf(0)) } - override suspend fun getAllSuccessfullySubmittedOptOutFlow(): Flow> { - return optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { - it.filter { - it.isSubmitSuccess - }.map { - ( - extractedProfileAdapter.fromJson(it.extractedProfile)?.identifier - ?: "Unknown" - ) to it.brokerName - }.distinct().toMap() - } ?: flowOf(emptyMap()) + override fun getAllSuccessfullySubmittedOptOutFlow(): Flow> = flow { + emitAll( + optOutResultsDao()?.getOptOutCompletedBrokerFlow()?.map { + it.filter { + it.isSubmitSuccess + }.map { + ( + extractedProfileAdapter.fromJson(it.extractedProfile)?.identifier + ?: "Unknown" + ) to it.brokerName + }.distinct().toMap() + } ?: flowOf(emptyMap()), + ) } - override suspend fun getAllOptOutActionLogFlow(): Flow> { - return optOutResultsDao()?.getOptOutActionLogFlow() ?: flowOf(emptyList()) + override fun getAllOptOutActionLogFlow(): Flow> = flow { + emitAll(optOutResultsDao()?.getOptOutActionLogFlow() ?: flowOf(emptyList())) } override suspend fun saveScanCompletedBroker( @@ -271,8 +274,8 @@ class RealPirEventsRepository @Inject constructor( ) } - override suspend fun getAllEmailConfirmationLogFlow(): Flow> { - return emailConfirmationLogDao()?.getAllEmailConfirmationLogsFlow() ?: flowOf(emptyList()) + override fun getAllEmailConfirmationLogFlow(): Flow> = flow { + emitAll(emailConfirmationLogDao()?.getAllEmailConfirmationLogsFlow() ?: flowOf(emptyList())) } override suspend fun deleteAllEmailConfirmationsLogs(): Unit = withContext(dispatcherProvider.io()) { diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt index 29424b29935a..1399d26996ba 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt @@ -53,6 +53,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -136,7 +138,7 @@ interface PirRepository { extractedProfileId: Long, ): ExtractedProfile? - suspend fun getAllExtractedProfilesFlow(): Flow> + fun getAllExtractedProfilesFlow(): Flow> suspend fun getAllExtractedProfiles(): List @@ -505,12 +507,17 @@ class RealPirRepository( return@withContext extractedProfileDao()?.getExtractedProfile(extractedProfileId)?.toExtractedProfile() } - override suspend fun getAllExtractedProfilesFlow(): Flow> = - extractedProfileDao()?.getAllExtractedProfileFlow()?.map { list -> - list.map { - it.toExtractedProfile() - } - } ?: flowOf(emptyList()) + override fun getAllExtractedProfilesFlow(): Flow> { + return flow { + emitAll( + extractedProfileDao()?.getAllExtractedProfileFlow()?.map { list -> + list.map { + it.toExtractedProfile() + } + } ?: flowOf(emptyList()), + ) + } + } override suspend fun getAllExtractedProfiles(): List = withContext(dispatcherProvider.io()) { diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt index 90579eb4af4e..a70d574163f3 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt @@ -133,19 +133,19 @@ class PirDevOptOutActivity : DuckDuckGoActivity() { dropDownAdapter.clear() dropDownAdapter.addAll(brokerOptions) } - - eventsRepository.getAllSuccessfullySubmittedOptOutFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { optOuts -> - optOutAdapter.clear() - optOutAdapter.addAll( - optOuts.map { - "${it.value} - ${it.key}" - }, - ) - } - .launchIn(lifecycleScope) } + + eventsRepository.getAllSuccessfullySubmittedOptOutFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { optOuts -> + optOutAdapter.clear() + optOutAdapter.addAll( + optOuts.map { + "${it.value} - ${it.key}" + }, + ) + } + .launchIn(lifecycleScope) } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt index 00d6cc8c5b76..b8929c5e81b3 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt @@ -93,21 +93,19 @@ class PirDevScanActivity : DuckDuckGoActivity() { } private fun bindViews() { - lifecycleScope.launch { - repository.getAllExtractedProfilesFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - render(it) - } - .launchIn(lifecycleScope) + repository.getAllExtractedProfilesFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + render(it) + } + .launchIn(lifecycleScope) - eventsRepository.getTotalScannedBrokersFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - binding.statusSitesScanned.text = getString(R.string.pirStatsStatusScanned, it) - } - .launchIn(lifecycleScope) - } + eventsRepository.getTotalScannedBrokersFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + binding.statusSitesScanned.text = getString(R.string.pirStatsStatusScanned, it) + } + .launchIn(lifecycleScope) } private fun render(extractedProfiles: List) { diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt index a64842f2c06a..6dbfe5f4db3f 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirResultsActivity.kt @@ -38,7 +38,6 @@ import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirOptOutResu import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirScanResultsScreen import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -98,78 +97,70 @@ class PirResultsActivity : DuckDuckGoActivity() { } private fun showEmailResults() { - lifecycleScope.launch { - eventsRepository.getAllEmailConfirmationLogFlow().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { emailEvents -> - emailEvents.map { result -> - val stringBuilder = StringBuilder() - stringBuilder.append("Time: ${formatter.format(Date(result.eventTimeInMillis))}\n") - stringBuilder.append("EVENT: ${result.eventType}\n") - stringBuilder.append("RESULT: ${result.value}\n") - stringBuilder.toString() - }.also { - render(it) - } + eventsRepository.getAllEmailConfirmationLogFlow().flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { emailEvents -> + emailEvents.map { result -> + val stringBuilder = StringBuilder() + stringBuilder.append("Time: ${formatter.format(Date(result.eventTimeInMillis))}\n") + stringBuilder.append("EVENT: ${result.eventType}\n") + stringBuilder.append("RESULT: ${result.value}\n") + stringBuilder.toString() + }.also { + render(it) } - .launchIn(lifecycleScope) - } + } + .launchIn(lifecycleScope) } private fun showOptOutResults() { - lifecycleScope.launch { - eventsRepository.getAllOptOutActionLogFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { optOutEvents -> - optOutEvents.map { result -> - val stringBuilder = StringBuilder() - stringBuilder.append("Time: ${formatter.format(Date(result.completionTimeInMillis))}\n") - stringBuilder.append("BROKER NAME: ${result.brokerName}\n") - stringBuilder.append("EXTRACTED PROFILE: ${result.extractedProfile}\n") - stringBuilder.append("ACTION EXECUTED: ${result.actionType}\n") - stringBuilder.append("IS ERROR: ${result.isError}\n") - stringBuilder.append("RAW RESULT: ${result.result}\n") - stringBuilder.toString() - }.also { - render(it) - } + eventsRepository.getAllOptOutActionLogFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { optOutEvents -> + optOutEvents.map { result -> + val stringBuilder = StringBuilder() + stringBuilder.append("Time: ${formatter.format(Date(result.completionTimeInMillis))}\n") + stringBuilder.append("BROKER NAME: ${result.brokerName}\n") + stringBuilder.append("EXTRACTED PROFILE: ${result.extractedProfile}\n") + stringBuilder.append("ACTION EXECUTED: ${result.actionType}\n") + stringBuilder.append("IS ERROR: ${result.isError}\n") + stringBuilder.append("RAW RESULT: ${result.result}\n") + stringBuilder.toString() + }.also { + render(it) } - .launchIn(lifecycleScope) - } + } + .launchIn(lifecycleScope) } private fun showScanResults() { - lifecycleScope.launch { - eventsRepository.getScannedBrokersFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { scanResults -> - scanResults.map { - val stringBuilder = StringBuilder() - stringBuilder.append("BROKER NAME: ${it.brokerName}\n") - stringBuilder.append("PROFILE ID: ${it.profileQueryId}\n") - stringBuilder.append("COMPLETED WITH NO ERROR: ${it.isSuccess}\n") - stringBuilder.append("DURATION: ${it.endTimeInMillis - it.startTimeInMillis}\n") - stringBuilder.toString() - }.also { - render(it) - } + eventsRepository.getScannedBrokersFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanResults -> + scanResults.map { + val stringBuilder = StringBuilder() + stringBuilder.append("BROKER NAME: ${it.brokerName}\n") + stringBuilder.append("PROFILE ID: ${it.profileQueryId}\n") + stringBuilder.append("COMPLETED WITH NO ERROR: ${it.isSuccess}\n") + stringBuilder.append("DURATION: ${it.endTimeInMillis - it.startTimeInMillis}\n") + stringBuilder.toString() + }.also { + render(it) } - .launchIn(lifecycleScope) - } + } + .launchIn(lifecycleScope) } private fun showAllEvents() { - lifecycleScope.launch { - eventsRepository.getAllEventLogsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { scanEvents -> - scanEvents.map { result -> - "Time: ${formatter.format(Date(result.eventTimeInMillis))}\nEVENT: ${result.eventType}\n" - }.also { - render(it) - } + eventsRepository.getAllEventLogsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanEvents -> + scanEvents.map { result -> + "Time: ${formatter.format(Date(result.eventTimeInMillis))}\nEVENT: ${result.eventType}\n" + }.also { + render(it) } - .launchIn(lifecycleScope) - } + } + .launchIn(lifecycleScope) } private fun render(results: List) { From 4d164d8669bae83be6f74922cea78b47d1f0ebd3 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Wed, 5 Nov 2025 13:24:23 +0100 Subject: [PATCH 5/6] Introduce PirFeatureState --- .../java/com/duckduckgo/pir/api/PirFeature.kt | 13 ++----- .../pir/api/dashboard/PirFeatureState.kt | 37 +++++++++++++++++++ .../duckduckgo/pir/impl/PirRemoteFeatures.kt | 17 ++++++--- .../impl/settings/views/PirSettingView.kt | 2 +- .../settings/views/PirSettingViewModel.kt | 21 ++++++----- .../settings/views/PirSettingViewModelTest.kt | 13 ++++--- 6 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 pir/pir-api/src/main/java/com/duckduckgo/pir/api/dashboard/PirFeatureState.kt diff --git a/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt index 9766936d0e96..1c647a2fc484 100644 --- a/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt +++ b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/PirFeature.kt @@ -16,19 +16,14 @@ package com.duckduckgo.pir.api -interface PirFeature { +import com.duckduckgo.pir.api.dashboard.PirFeatureState - /** - * Runs on the IO thread by default. - * - * @return true if the PIR beta is enabled, false otherwise - */ - suspend fun isPirBetaEnabled(): Boolean +interface PirFeature { /** * Runs on the IO thread by default. * - * @return true if PIR storage is available, false otherwise + * @return [PirFeatureState] that represents the current state of the feature */ - suspend fun isPirStorageAvailable(): Boolean + suspend fun getPirFeatureState(): PirFeatureState } diff --git a/pir/pir-api/src/main/java/com/duckduckgo/pir/api/dashboard/PirFeatureState.kt b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/dashboard/PirFeatureState.kt new file mode 100644 index 000000000000..b2c54304fe0e --- /dev/null +++ b/pir/pir-api/src/main/java/com/duckduckgo/pir/api/dashboard/PirFeatureState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 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.pir.api.dashboard + +/** + * Represents the state of the PIR (Private Information Retrieval) feature. + */ +enum class PirFeatureState { + /** + * The PIR feature is enabled and available for use. + */ + ENABLED, + + /** + * The PIR feature is disabled and cannot be used. + */ + DISABLED, + + /** + * The PIR feature is enabled but not available. + */ + NOT_AVAILABLE, +} diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt index d44db5d06fac..8fbedf4e9c3c 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/PirRemoteFeatures.kt @@ -23,6 +23,7 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue import com.duckduckgo.pir.api.PirFeature +import com.duckduckgo.pir.api.dashboard.PirFeatureState import com.duckduckgo.pir.impl.store.PirRepository import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn @@ -52,11 +53,17 @@ class PirRemoteFeatureImpl @Inject constructor( private val pirRepository: PirRepository, ) : PirFeature { - override suspend fun isPirBetaEnabled(): Boolean = withContext(dispatcherProvider.io()) { - pirRemoteFeatures.pirBeta().isEnabled() - } + override suspend fun getPirFeatureState(): PirFeatureState = withContext(dispatcherProvider.io()) { + val isEnabled = pirRemoteFeatures.pirBeta().isEnabled() + if (!isEnabled) { + return@withContext PirFeatureState.DISABLED + } + + val isRepositoryAvailable = pirRepository.isRepositoryAvailable() + if (!isRepositoryAvailable) { + return@withContext PirFeatureState.NOT_AVAILABLE + } - override suspend fun isPirStorageAvailable(): Boolean = withContext(dispatcherProvider.io()) { - pirRepository.isRepositoryAvailable() + return@withContext PirFeatureState.ENABLED } } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt index 792b4df07691..61ff9cb32e3c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingView.kt @@ -131,7 +131,7 @@ class PirSettingView @JvmOverloads constructor( globalActivityStarter.start(context, PirDashboardWebViewScreen) } - Command.ShowPirStorageUnavailableDialog -> { + Command.ShowPirUnavailableDialog -> { TextAlertDialogBuilder(context) .setTitle(R.string.pirStorageUnavailableDialogTitle) .setMessage(R.string.pirStorageUnavailableDialogMessage) 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 3c963808a91e..c260ecbc9795 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 @@ -24,6 +24,7 @@ import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.pir.api.PirFeature +import com.duckduckgo.pir.api.dashboard.PirFeatureState import com.duckduckgo.subscriptions.api.Product.PIR import com.duckduckgo.subscriptions.api.SubscriptionStatus import com.duckduckgo.subscriptions.api.Subscriptions @@ -57,7 +58,7 @@ class PirSettingViewModel @Inject constructor( sealed class Command { data object OpenPirDesktop : Command() data object OpenPirDashboard : Command() - data object ShowPirStorageUnavailableDialog : Command() + data object ShowPirUnavailableDialog : Command() } private val command = Channel(1, BufferOverflow.DROP_OLDEST) @@ -88,10 +89,10 @@ class PirSettingViewModel @Inject constructor( val command = when (type) { DESKTOP -> OpenPirDesktop DASHBOARD -> { - if (pirFeature.isPirStorageAvailable()) { - Command.OpenPirDashboard - } else { - Command.ShowPirStorageUnavailableDialog + when (pirFeature.getPirFeatureState()) { + PirFeatureState.ENABLED -> Command.OpenPirDashboard + PirFeatureState.DISABLED -> OpenPirDesktop + PirFeatureState.NOT_AVAILABLE -> Command.ShowPirUnavailableDialog } } } @@ -137,10 +138,12 @@ class PirSettingViewModel @Inject constructor( SubscriptionStatus.GRACE_PERIOD, -> { if (hasValidEntitlement) { - val type = if (pirFeature.isPirBetaEnabled()) { - DASHBOARD - } else { - DESKTOP + val type = when (pirFeature.getPirFeatureState()) { + PirFeatureState.ENABLED, + PirFeatureState.NOT_AVAILABLE, + -> DASHBOARD + + PirFeatureState.DISABLED -> DESKTOP } PirState.Enabled(type) } else { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt index cf882eb2a8af..db8f907de705 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.subscriptions.impl.settings.views import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.pir.api.PirFeature +import com.duckduckgo.pir.api.dashboard.PirFeatureState import com.duckduckgo.subscriptions.api.Product import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED @@ -183,7 +184,7 @@ class PirSettingViewModelTest { fun `when subscription state is auto renewable and entitled then PirState is enabled and beta FF is false`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(AUTO_RENEWABLE) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(false) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.DISABLED) pirSettingsViewModel.onCreate(mock()) @@ -199,7 +200,7 @@ class PirSettingViewModelTest { fun `when subscription state is auto renewable and entitled then PirState is enabled and beta FF is true`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(AUTO_RENEWABLE) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(true) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.ENABLED) pirSettingsViewModel.onCreate(mock()) @@ -230,7 +231,7 @@ class PirSettingViewModelTest { fun `when subscription state is not auto renewable and entitled then PirState is enabled and beta FF is false`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(NOT_AUTO_RENEWABLE) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(false) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.DISABLED) pirSettingsViewModel.onCreate(mock()) @@ -246,7 +247,7 @@ class PirSettingViewModelTest { fun `when subscription state is not auto renewable and entitled then PirState is enabled and beta FF is true`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(NOT_AUTO_RENEWABLE) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(true) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.ENABLED) pirSettingsViewModel.onCreate(mock()) @@ -277,7 +278,7 @@ class PirSettingViewModelTest { fun `when subscription state is grace period and entitled then PirState is enabled and beta FF is false`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(GRACE_PERIOD) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(false) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.DISABLED) pirSettingsViewModel.onCreate(mock()) @@ -293,7 +294,7 @@ class PirSettingViewModelTest { fun `when subscription state is grace period and entitled then PirState is enabled and beta FF is true`() = runTest { whenever(subscriptions.getEntitlementStatus()).thenReturn(flowOf(listOf(Product.PIR))) whenever(subscriptions.getSubscriptionStatus()).thenReturn(GRACE_PERIOD) - whenever(pirFeature.isPirBetaEnabled()).thenReturn(true) + whenever(pirFeature.getPirFeatureState()).thenReturn(PirFeatureState.ENABLED) pirSettingsViewModel.onCreate(mock()) From b79948d1ebf24ea085cb3399f728585eb11ae4c3 Mon Sep 17 00:00:00 2001 From: Domen Lanisnik Date: Wed, 5 Nov 2025 15:21:55 +0100 Subject: [PATCH 6/6] Add Daily pixel --- .../src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt index 8cc331ad8c39..4c9f96301f08 100644 --- a/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt +++ b/pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/pixels/PirPixel.kt @@ -129,7 +129,7 @@ enum class PirPixel( PIR_INTERNAL_SECURE_STORAGE_UNAVAILABLE( baseName = "pir_internal_secure-storage_unavailable", - type = Count, + types = setOf(Count, Daily()), ), ; constructor(