diff --git a/pir/pir-internal/schemas/com.duckduckgo.pir.internal.store.PirDatabase/3.json b/pir/pir-internal/schemas/com.duckduckgo.pir.internal.store.PirDatabase/3.json index c78984e25d99..9c2224d4ed4d 100644 --- a/pir/pir-internal/schemas/com.duckduckgo.pir.internal.store.PirDatabase/3.json +++ b/pir/pir-internal/schemas/com.duckduckgo.pir.internal.store.PirDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "4fda73a442b6411886706c42bedc64bc", + "identityHash": "a82b3c3fcdfd42d3736c96fd4b979451", "entities": [ { "tableName": "pir_broker_json_etag", @@ -364,7 +364,7 @@ }, { "tableName": "pir_user_profile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `birthYear` INTEGER NOT NULL, `phone` TEXT, `age` INTEGER NOT NULL, `user_firstName` TEXT NOT NULL, `user_lastName` TEXT NOT NULL, `user_middleName` TEXT, `user_suffix` TEXT, `address_city` TEXT NOT NULL, `address_state` TEXT NOT NULL, `address_street` TEXT, `address_zip` TEXT)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `birthYear` INTEGER NOT NULL, `phone` TEXT, `user_firstName` TEXT NOT NULL, `user_lastName` TEXT NOT NULL, `user_middleName` TEXT, `user_suffix` TEXT, `address_city` TEXT NOT NULL, `address_state` TEXT NOT NULL, `address_street` TEXT, `address_zip` TEXT)", "fields": [ { "fieldPath": "id", @@ -384,12 +384,6 @@ "affinity": "TEXT", "notNull": false }, - { - "fieldPath": "age", - "columnName": "age", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "userName.firstName", "columnName": "user_firstName", @@ -449,7 +443,7 @@ "foreignKeys": [] }, { - "tableName": "pir_scan_log", + "tableName": "pir_events_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventTimeInMillis` INTEGER NOT NULL, `eventType` TEXT NOT NULL, PRIMARY KEY(`eventTimeInMillis`))", "fields": [ { @@ -505,12 +499,150 @@ }, "indices": [], "foreignKeys": [] + }, + { + "tableName": "pir_scan_complete_brokers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`brokerName` TEXT NOT NULL, `startTimeInMillis` INTEGER NOT NULL, `endTimeInMillis` INTEGER NOT NULL, PRIMARY KEY(`brokerName`))", + "fields": [ + { + "fieldPath": "brokerName", + "columnName": "brokerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeInMillis", + "columnName": "startTimeInMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeInMillis", + "columnName": "endTimeInMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "brokerName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pir_opt_out_complete_brokers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `brokerName` TEXT NOT NULL, `extractedProfile` TEXT NOT NULL, `startTimeInMillis` INTEGER NOT NULL, `endTimeInMillis` INTEGER NOT NULL, `isSubmitSuccess` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "brokerName", + "columnName": "brokerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedProfile", + "columnName": "extractedProfile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeInMillis", + "columnName": "startTimeInMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeInMillis", + "columnName": "endTimeInMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSubmitSuccess", + "columnName": "isSubmitSuccess", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pir_opt_out_action_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `brokerName` TEXT NOT NULL, `extractedProfile` TEXT NOT NULL, `completionTimeInMillis` INTEGER NOT NULL, `actionType` TEXT NOT NULL, `isError` INTEGER NOT NULL, `result` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "brokerName", + "columnName": "brokerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extractedProfile", + "columnName": "extractedProfile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completionTimeInMillis", + "columnName": "completionTimeInMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionType", + "columnName": "actionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isError", + "columnName": "isError", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4fda73a442b6411886706c42bedc64bc')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a82b3c3fcdfd42d3736c96fd4b979451')" ] } } \ No newline at end of file diff --git a/pir/pir-internal/src/main/AndroidManifest.xml b/pir/pir-internal/src/main/AndroidManifest.xml index 8b19cdb54a5d..08a5adb15d1d 100644 --- a/pir/pir-internal/src/main/AndroidManifest.xml +++ b/pir/pir-internal/src/main/AndroidManifest.xml @@ -1,26 +1,44 @@ + + + + + + + + android:name=".settings.PirWebViewActivity" + android:label="@string/pirDevDebugOptOutTitle" /> + + diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/brokers/PirDataUpdateObserver.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/brokers/PirDataUpdateObserver.kt index d93424f77812..42f1e947d420 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/brokers/PirDataUpdateObserver.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/brokers/PirDataUpdateObserver.kt @@ -22,8 +22,10 @@ import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.pir.internal.PirRemoteFeatures +import com.duckduckgo.pir.internal.store.PitTestingStore import com.duckduckgo.subscriptions.api.Subscriptions import com.squareup.anvil.annotations.ContributesMultibinding +import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -39,10 +41,14 @@ class PirDataUpdateObserver @Inject constructor( private val brokerJsonUpdater: BrokerJsonUpdater, private val subscriptions: Subscriptions, private val pirRemoteFeatures: PirRemoteFeatures, + private val testingStore: PitTestingStore, ) : MainProcessLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { coroutineScope.launch(dispatcherProvider.io()) { if (pirRemoteFeatures.allowPirRun().isEnabled() && subscriptions.getAccessToken() != null) { + if (testingStore.testerId == null) { + testingStore.testerId = UUID.randomUUID().toString() + } logcat { "PIR-update: Attempting to update all broker data" } if (brokerJsonUpdater.update()) { logcat { "PIR-update: Update successfully completed." } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/CPUUsageReader.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/CPUUsageReader.kt new file mode 100644 index 000000000000..77515009c3b7 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/CPUUsageReader.kt @@ -0,0 +1,73 @@ +/* + * 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.internal.callbacks + +import android.os.SystemClock +import android.system.Os +import android.system.OsConstants +import androidx.annotation.WorkerThread +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.util.InvalidPropertiesFormatException +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import logcat.logcat + +interface CPUUsageReader { + fun readCPUUsage(): Double +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealCPUUsageReader @Inject constructor() : CPUUsageReader { + + @WorkerThread + override fun readCPUUsage(): Double { + val pid = android.os.Process.myPid() + logcat { "PIR-MONITOR: Reading CPU load for process with pid=$pid" } + val procFile = File("/proc/$pid/stat") + val statsText = (FileReader(procFile)).buffered().use(BufferedReader::readText) + + val procArray = statsText.split(WHITE_SPACE) + if (procArray.size < PROC_SIZE) { + throw InvalidPropertiesFormatException("Unexpected /proc file size: " + procArray.size) + } + + val procCPUTimeSec = (procArray[UTIME_IDX].toLong() + procArray[STIME_IDX].toLong()) / CLOCK_SPEED_HZ + val systemUptimeSec = SystemClock.elapsedRealtime() / 1.seconds.inWholeMilliseconds.toDouble() + val procTimeSec = systemUptimeSec - (procArray[STARTTIME_IDX].toLong() / CLOCK_SPEED_HZ) + + return (100 * (procCPUTimeSec / procTimeSec)) / NUM_CORES + } + + companion object { + private val CLOCK_SPEED_HZ = Os.sysconf(OsConstants._SC_CLK_TCK).toDouble() + private val NUM_CORES = Os.sysconf(OsConstants._SC_NPROCESSORS_CONF) + + private val WHITE_SPACE = "\\s".toRegex() + + // Indices in /proc/[pid]/stat (https://linux.die.net/man/5/proc) + private val UTIME_IDX = 13 + private val STIME_IDX = 14 + private val STARTTIME_IDX = 21 + private val PROC_SIZE = 44 + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCallbacks.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCallbacks.kt new file mode 100644 index 000000000000..8c6c8eccd5a3 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCallbacks.kt @@ -0,0 +1,34 @@ +/* + * 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.internal.callbacks + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.AppScope +import kotlinx.coroutines.CoroutineScope + +interface PirCallbacks { + fun onPirJobStarted(coroutineScope: CoroutineScope) + fun onPirJobCompleted() + fun onPirJobStopped() +} + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = PirCallbacks::class, +) +@Suppress("unused") +private interface PirCallbacksPluginPoint diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCpuMonitor.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCpuMonitor.kt new file mode 100644 index 000000000000..9dbb0123bfb2 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/callbacks/PirCpuMonitor.kt @@ -0,0 +1,79 @@ +/* + * 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.internal.callbacks + +import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.pixels.PirPixelSender +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.asLog +import logcat.logcat + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PirCallbacks::class, +) +@SingleInstanceIn(AppScope::class) +class PirCpuMonitor @Inject constructor( + private val pixelSender: PirPixelSender, + private val dispatcherProvider: DispatcherProvider, + private val cpuUsageReader: CPUUsageReader, +) : PirCallbacks { + private val alertThresholds = listOf(30, 20, 10).sortedDescending() + private val monitorJob = ConflatedJob() + + override fun onPirJobStarted(coroutineScope: CoroutineScope) { + monitorJob += coroutineScope.launch(dispatcherProvider.io()) { + logcat { "PIR-MONITOR: ${this@PirCpuMonitor} onPirJobStarted " } + delay(10_000) // Add delay before measuring + while (isActive) { + try { + val avgCPUUsagePercent = cpuUsageReader.readCPUUsage() + logcat { "PIR-MONITOR: avgCPUUsagePercent: $avgCPUUsagePercent on ${android.os.Process.myPid()} " } + // If any threshold has been reached, we will a emit a pixel. + alertThresholds.forEach { + if (avgCPUUsagePercent > it) { + pixelSender.sendCPUUsageAlert(it) + } + } + delay(60_000) + } catch (e: Exception) { + logcat(LogPriority.ERROR) { e.asLog() } + monitorJob.cancel() + } + } + } + } + + override fun onPirJobCompleted() { + logcat { "PIR-MONITOR: ${this@PirCpuMonitor} onPirJobCompleted" } + monitorJob.cancel() + } + + override fun onPirJobStopped() { + logcat { "PIR-MONITOR: ${this@PirCpuMonitor} onPirJobStopped" } + monitorJob.cancel() + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/BrokerStepsParser.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/BrokerStepsParser.kt similarity index 55% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/BrokerStepsParser.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/BrokerStepsParser.kt index c4b1fe46d067..1cccee4b6612 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/BrokerStepsParser.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/BrokerStepsParser.kt @@ -14,12 +14,16 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.scan +package com.duckduckgo.pir.internal.common import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.pir.internal.scan.BrokerStepsParser.BrokerStep +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep.OptOutStep +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep.ScanStep import com.duckduckgo.pir.internal.scripts.models.BrokerAction +import com.duckduckgo.pir.internal.scripts.models.ExtractedProfile +import com.duckduckgo.pir.internal.store.PirRepository import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi @@ -42,17 +46,32 @@ interface BrokerStepsParser { stepsJson: String, ): BrokerStep? - data class BrokerStep( - var brokerName: String? = null, - val stepType: String, - val scanType: String, - val actions: List, - ) + sealed class BrokerStep( + open val brokerName: String = "", // this will be set later / not coming from json + open val stepType: String, + open val actions: List, + ) { + data class ScanStep( + override val brokerName: String = "", // this will be set later / not coming from json + override val stepType: String, + override val actions: List, + val scanType: String, + ) : BrokerStep(brokerName, stepType, actions) + + data class OptOutStep( + override val brokerName: String = "", // this will be set later / not coming from json + override val stepType: String, + override val actions: List, + val optOutType: String, + val profilesToOptOut: List = emptyList(), + ) : BrokerStep(brokerName, stepType, actions) + } } @ContributesBinding(AppScope::class) class RealBrokerStepsParser @Inject constructor( private val dispatcherProvider: DispatcherProvider, + private val repository: PirRepository, ) : BrokerStepsParser { val adapter: JsonAdapter by lazy { Moshi.Builder() @@ -63,10 +82,16 @@ class RealBrokerStepsParser @Inject constructor( .withSubtype(BrokerAction.Click::class.java, "click") .withSubtype(BrokerAction.FillForm::class.java, "fillForm") .withSubtype(BrokerAction.Navigate::class.java, "navigate") - .withSubtype(BrokerAction.GetCaptchInfo::class.java, "getCaptchaInfo") + .withSubtype(BrokerAction.GetCaptchaInfo::class.java, "getCaptchaInfo") .withSubtype(BrokerAction.SolveCaptcha::class.java, "solveCaptcha") .withSubtype(BrokerAction.EmailConfirmation::class.java, "emailConfirmation"), - ).add(KotlinJsonAdapterFactory()) + ) + .add( + PolymorphicJsonAdapterFactory.of(BrokerStep::class.java, "stepType") + .withSubtype(ScanStep::class.java, "scan") + .withSubtype(OptOutStep::class.java, "optOut"), + ) + .add(KotlinJsonAdapterFactory()) .build() .adapter(BrokerStep::class.java) } @@ -76,8 +101,19 @@ class RealBrokerStepsParser @Inject constructor( stepsJson: String, ): BrokerStep? = withContext(dispatcherProvider.io()) { return@withContext runCatching { - adapter.fromJson(stepsJson)?.apply { - this.brokerName = brokerName + adapter.fromJson(stepsJson)?.run { + if (this is OptOutStep) { + this.copy( + brokerName = brokerName, + profilesToOptOut = repository.getExtractProfileResultForBroker(brokerName)?.extractResults?.filter { + it.result + }?.map { + it.scrapedData + } ?: emptyList(), + ) + } else { + (this as ScanStep).copy(brokerName = brokerName) + } } }.onFailure { logcat(ERROR) { "PIR-SCAN: Parsing the steps failed due to: $it" } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/CaptchaResolver.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/CaptchaResolver.kt new file mode 100644 index 000000000000..fae1bddfa7d1 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/CaptchaResolver.kt @@ -0,0 +1,177 @@ +/* + * 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.internal.common + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError.CriticalFailure +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError.InvalidRequest +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError.SolutionNotReady +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError.TransientFailure +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError.UnableToSolveCaptcha +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverResult +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverResult.CaptchaFailure +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverResult.CaptchaSubmitSuccess +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverResult.SolveCaptchaSuccess +import com.duckduckgo.pir.internal.service.DbpService +import com.duckduckgo.pir.internal.service.DbpService.CaptchaSolutionMeta +import com.duckduckgo.pir.internal.service.DbpService.PirStartCaptchaSolutionBody +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import logcat.logcat +import retrofit2.HttpException + +interface CaptchaResolver { + /** + * Submits captcha information to the backend to start solving it, + * + * @param siteKey - value from the response we get after submitting the GetCaptchaInfo to the js layer. + * @param url - value from the response we get after submitting the GetCaptchaInfo to the js layer. + * @param type - value from the response we get after submitting the GetCaptchaInfo to the js layer. + * @param attemptId - Identifies the scan or the opt-out attempt + */ + suspend fun submitCaptchaInformation( + siteKey: String, + url: String, + type: String, + attemptId: String? = null, + ): CaptchaResolverResult + + /** + * Obtains the status of the solution for the captcha submitted with the given [transactionID] + * + * @param transactionID - obtained after submitting the information needed to solve captcha + * @param attemptId - Identifies the scan or the opt-out attempt + */ + suspend fun getCaptchaSolution( + transactionID: String, + attemptId: String? = null, + ): CaptchaResolverResult + + sealed class CaptchaResolverResult { + data class CaptchaSubmitSuccess( + val transactionID: String, + ) : CaptchaResolverResult() + + data class SolveCaptchaSuccess( + val token: String, + val meta: CaptchaSolutionMeta, + ) : CaptchaResolverResult() + + data class CaptchaFailure( + val type: CaptchaResolverError, + val message: String, + ) : CaptchaResolverResult() + } + + sealed class CaptchaResolverError { + data object SolutionNotReady : CaptchaResolverError() + data object UnableToSolveCaptcha : CaptchaResolverError() + data object CriticalFailure : CaptchaResolverError() + data object InvalidRequest : CaptchaResolverError() + data object TransientFailure : CaptchaResolverError() + } +} + +@ContributesBinding(AppScope::class) +class RealCaptchaResolver @Inject constructor( + private val dbpService: DbpService, + private val dispatcherProvider: DispatcherProvider, +) : CaptchaResolver { + override suspend fun submitCaptchaInformation( + siteKey: String, + url: String, + type: String, + attemptId: String?, + ): CaptchaResolverResult = withContext(dispatcherProvider.io()) { + // https://dub.duckduckgo.com/duckduckgo/dbp-api?tab=readme-ov-file#post-dbpcaptchav0submit + runCatching { + dbpService.submitCaptchaInformation( + PirStartCaptchaSolutionBody( + siteKey = siteKey, + url = url, + type = type, + ), + attemptId = attemptId, + ).run { + CaptchaSubmitSuccess( + transactionID = this.transactionId, + ) + } + }.getOrElse { error -> + if (error is HttpException) { + val errorMessage = error.message() + if (errorMessage.startsWith("INVALID_REQUEST")) { + CaptchaFailure( + type = InvalidRequest, + message = errorMessage, + ) + } else if (errorMessage.startsWith("FAILURE_TRANSIENT")) { + CaptchaFailure( + type = TransientFailure, + message = errorMessage, + ) + } else { + CaptchaFailure( + type = CriticalFailure, + message = errorMessage, + ) + } + } else { + CaptchaFailure( + type = CriticalFailure, + message = error.message ?: "Unknown error", + ) + } + } + } + + override suspend fun getCaptchaSolution( + transactionID: String, + attemptId: String?, + ): CaptchaResolverResult = withContext(dispatcherProvider.io()) { + // https://dub.duckduckgo.com/duckduckgo/dbp-api?tab=readme-ov-file#get-dbpcaptchav0resulttransactionidtransaction_id + runCatching { + dbpService.getCaptchaSolution(transactionID, attemptId).run { + logcat { "PIR-CAPTCHA: RESULT -> $this" } + when (message) { + "SOLUTION_NOT_READY" -> CaptchaFailure( + type = SolutionNotReady, + message = message, + ) + + "FAILURE" -> CaptchaFailure( + type = UnableToSolveCaptcha, + message = message, + ) + + else -> SolveCaptchaSuccess( + token = this.data, + meta = this.meta, + ) + } + } + }.getOrElse { + logcat { "PIR-CAPTCHA: Failure -> $it" } + CaptchaFailure( + type = InvalidRequest, + message = it.message ?: "Unknown error", + ) + } + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/NativeBrokerActionHandler.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/NativeBrokerActionHandler.kt new file mode 100644 index 000000000000..e566f10de2ab --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/NativeBrokerActionHandler.kt @@ -0,0 +1,264 @@ +/* + * 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.internal.common + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverError +import com.duckduckgo.pir.internal.common.CaptchaResolver.CaptchaResolverResult +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.GetCaptchaSolutionStatus +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.GetEmail +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.GetEmailStatus +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.SubmitCaptchaInfo +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Failure +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.CaptchaSolutionStatus +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.CaptchaTransactionIdReceived +import com.duckduckgo.pir.internal.service.DbpService.CaptchaSolutionMeta +import com.duckduckgo.pir.internal.store.PirRepository +import com.duckduckgo.pir.internal.store.PirRepository.ConfirmationStatus +import com.duckduckgo.pir.internal.store.PirRepository.ConfirmationStatus.Ready +import com.duckduckgo.pir.internal.store.PirRepository.ConfirmationStatus.Unknown +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import logcat.logcat + +interface NativeBrokerActionHandler { + suspend fun pushAction(nativeAction: NativeAction): NativeActionResult + + sealed class NativeAction(open val actionId: String) { + data class GetEmail( + override val actionId: String, + val brokerName: String, + ) : NativeAction(actionId) + + data class GetEmailStatus( + override val actionId: String, + val brokerName: String, + val email: String, + val pollingIntervalSeconds: Float, + ) : NativeAction(actionId) + + data class SubmitCaptchaInfo( + override val actionId: String, + val siteKey: String, + val url: String, + val type: String, + ) : NativeAction(actionId) + + data class GetCaptchaSolutionStatus( + override val actionId: String, + val transactionID: String, + ) : NativeAction(actionId) + } + + sealed class NativeActionResult { + data class Success( + val data: NativeSuccessData, + ) : NativeActionResult() { + sealed class NativeSuccessData { + data class Email( + val email: String, + ) : NativeSuccessData() + + data class EmailConfirmation( + val email: String, + val link: String, + val status: ConfirmationStatus, + ) : NativeSuccessData() + + data class CaptchaTransactionIdReceived( + val transactionID: String, + ) : NativeSuccessData() + + data class CaptchaSolutionStatus( + val status: CaptchaStatus, + ) : NativeSuccessData() { + sealed class CaptchaStatus { + data class Ready( + val token: String, + val meta: CaptchaSolutionMeta, + ) : CaptchaStatus() + + data object InProgress : CaptchaStatus() + } + } + } + } + + data class Failure( + val actionId: String, + val message: String, + val retryNativeAction: Boolean = false, + ) : NativeActionResult() + } +} + +class RealNativeBrokerActionHandler( + private val repository: PirRepository, + private val dispatcherProvider: DispatcherProvider, + private val captchaResolver: CaptchaResolver, +) : NativeBrokerActionHandler { + override suspend fun pushAction(nativeAction: NativeAction): NativeActionResult = withContext(dispatcherProvider.io()) { + when (nativeAction) { + is GetEmailStatus -> handleAwaitConfirmation(nativeAction) + is GetEmail -> handleGetEmail(nativeAction) + is SubmitCaptchaInfo -> handleSolveCaptcha(nativeAction) + is GetCaptchaSolutionStatus -> handleGetCaptchaSolutionStatus(nativeAction) + } + } + + private suspend fun handleAwaitConfirmation(action: GetEmailStatus): NativeActionResult { + return kotlin.runCatching { + var attempt = 0 + var result: Pair = Unknown to null + while (attempt < MAX_AWAIT_EMAIL_ATTEMPT) { + attempt++ + // https://dub.duckduckgo.com/duckduckgo/dbp-api?tab=readme-ov-file#get-dbpemv0linkseemail_address + result = repository.getEmailConfirmation(action.email) + logcat { "PIR-EMAIL: $result" } + if (result.first is Ready) { + return NativeActionResult.Success( + data = NativeSuccessData.EmailConfirmation( + email = action.email, + link = result.second!!, + status = result.first, + ), + ) + } else if (result.first is Unknown) { + return NativeActionResult.Failure( + actionId = action.actionId, + message = "Unable to confirm email: ${action.email} as email doesn't exist in the backend", + ) + } else if (attempt == MAX_AWAIT_EMAIL_ATTEMPT) { + // Final attempt failed + return NativeActionResult.Failure( + actionId = action.actionId, + message = "Timeout reached to confirm email: ${action.email}. Link is still pending", + ) + } else { + delay(action.pollingIntervalSeconds.toLong() * 1000) + } + } + + NativeActionResult.Failure( + actionId = action.actionId, + message = "Unable to confirm email: ${action.email}, last status: ${result.first} }", + ) + }.getOrElse { error -> + logcat { "PIR-EMAIL: $error" } + NativeActionResult.Failure( + actionId = action.actionId, + message = "Unknown error while getting email : ${error.message}", + ) + } + } + + private suspend fun handleGetEmail(action: GetEmail): NativeActionResult { + return kotlin.runCatching { + repository.getEmailForBroker(action.brokerName).run { + NativeActionResult.Success( + data = NativeSuccessData.Email(this), + ) + } + }.getOrElse { error -> + NativeActionResult.Failure( + actionId = action.actionId, + message = "Unknown error while getting email : ${error.message}", + ) + } + } + + private suspend fun handleSolveCaptcha(nativeAction: SubmitCaptchaInfo): NativeActionResult { + return captchaResolver.submitCaptchaInformation( + siteKey = nativeAction.siteKey, + url = nativeAction.url, + type = nativeAction.type, + ).run { + when (this) { + is CaptchaResolverResult.CaptchaSubmitSuccess -> NativeActionResult.Success( + CaptchaTransactionIdReceived( + this.transactionID, + ), + ) + + is CaptchaResolverResult.CaptchaFailure -> if (this.type == CaptchaResolverError.TransientFailure) { + // Transient failures mean that client should retry after a minute + NativeActionResult.Failure( + actionId = nativeAction.actionId, + message = this.message, + retryNativeAction = true, + ) + } else { + NativeActionResult.Failure( + actionId = nativeAction.actionId, + message = this.message, + retryNativeAction = false, + ) + } + + else -> NativeActionResult.Failure( + actionId = nativeAction.actionId, + message = "Invalid scenario", + retryNativeAction = false, + ) + } + } + } + + private suspend fun handleGetCaptchaSolutionStatus(nativeAction: GetCaptchaSolutionStatus): NativeActionResult { + return captchaResolver.getCaptchaSolution( + transactionID = nativeAction.transactionID, + ).run { + when (this) { + is CaptchaResolverResult.SolveCaptchaSuccess -> NativeActionResult.Success( + data = CaptchaSolutionStatus( + status = CaptchaSolutionStatus.CaptchaStatus.Ready( + token = this.token, + meta = this.meta, + ), + ), + ) + + is CaptchaResolverResult.CaptchaFailure -> if (this.type == CaptchaResolverError.SolutionNotReady) { + NativeActionResult.Success( + data = CaptchaSolutionStatus( + status = CaptchaSolutionStatus.CaptchaStatus.InProgress, + ), + ) + } else { + Failure( + actionId = nativeAction.actionId, + message = "Failed to resolve captcha", + retryNativeAction = false, + ) + } + + else -> Failure( + actionId = nativeAction.actionId, + message = "Invalid scenario", + retryNativeAction = false, + ) + } + } + } + + companion object { + private const val MAX_AWAIT_EMAIL_ATTEMPT = 3 + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunner.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunner.kt new file mode 100644 index 000000000000..1bb3393bb3a1 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunner.kt @@ -0,0 +1,1042 @@ +/* + * 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.internal.common + +import android.content.Context +import android.webkit.WebView +import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep.OptOutStep +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.GetCaptchaSolutionStatus +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeAction.SubmitCaptchaInfo +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Failure +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.CaptchaSolutionStatus +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.CaptchaSolutionStatus.CaptchaStatus.Ready +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.CaptchaTransactionIdReceived +import com.duckduckgo.pir.internal.common.NativeBrokerActionHandler.NativeActionResult.Success.NativeSuccessData.Email +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory.RunType +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerManualScanCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerManualScanStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutActionFailed +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutActionSucceeded +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerRecordOptOutCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerRecordOptOutStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScanActionFailed +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScanActionSucceeded +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScheduledScanCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScheduledScanStarted +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.AwaitCaptchaSolution +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.AwaitEmailConfirmation +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.BrokerCompleted +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.CompleteExecution +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.ExecuteBrokerAction +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.GetCaptchaSolution +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.GetEmail +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.HandleBroker +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.HandleNextProfileForBroker +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.Idle +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.LoadUrl +import com.duckduckgo.pir.internal.common.RealPirActionsRunner.Command.SendCaptchaSolution +import com.duckduckgo.pir.internal.scripts.BrokerActionProcessor +import com.duckduckgo.pir.internal.scripts.BrokerActionProcessor.ActionResultListener +import com.duckduckgo.pir.internal.scripts.models.BrokerAction +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.Click +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.EmailConfirmation +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.Expectation +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.GetCaptchaInfo +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.SolveCaptcha +import com.duckduckgo.pir.internal.scripts.models.DataSource.EXTRACTED_PROFILE +import com.duckduckgo.pir.internal.scripts.models.ExtractedProfile +import com.duckduckgo.pir.internal.scripts.models.ExtractedProfileParams +import com.duckduckgo.pir.internal.scripts.models.PirErrorReponse +import com.duckduckgo.pir.internal.scripts.models.PirScriptRequestData +import com.duckduckgo.pir.internal.scripts.models.PirScriptRequestData.UserProfile +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ClickResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExpectationResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExtractedResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.FillFormResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse.ResponseData +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.NavigateResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.SolveCaptchaResponse +import com.duckduckgo.pir.internal.scripts.models.ProfileQuery +import com.duckduckgo.pir.internal.scripts.models.asActionType +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import logcat.logcat + +interface PirActionsRunner { + /** + * This function is responsible for executing the [BrokerStep] passed on its own detached WebView + * + * @param profileQuery - Profile to be passed along actions in [BrokerStep] + * @param brokerSteps - List of [BrokerStep] each containing a broker + actions to be executed. + */ + suspend fun start( + profileQuery: ProfileQuery, + brokerSteps: List, + ): Result + + /** + * This function is responsible for executing the [BrokerStep] passed on the passed [webView]. + * This initializes everything necessary on the [webView]. + * + * @param webView - WebView in which we want to execute the actions on + * @param profileQuery - Profile to be passed along actions in [BrokerStep] + * @param brokerSteps - List of [BrokerStep] each containing a broker + actions to be executed. + */ + suspend fun startOn( + webView: WebView, + profileQuery: ProfileQuery, + brokerSteps: List, + ): Result + + /** + * Forcefully stops / aborts a runner if it is running. + */ + fun stop() +} + +internal class RealPirActionsRunner( + private val dispatcherProvider: DispatcherProvider, + private val pirDetachedWebViewProvider: PirDetachedWebViewProvider, + private val brokerActionProcessor: BrokerActionProcessor, + private val context: Context, + private val pirScriptToLoad: String, + private val runType: RunType, + private val currentTimeProvider: CurrentTimeProvider, + private val nativeBrokerActionHandler: NativeBrokerActionHandler, + private val pirRunStateHandler: PirRunStateHandler, +) : PirActionsRunner, ActionResultListener { + private val coroutineScope: CoroutineScope + get() = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) + + private var detachedWebView: WebView? = null + private val brokersToExecute: MutableList = mutableListOf() + + private val commandsFlow = MutableStateFlow(Idle) + private var timerJob: ConflatedJob = ConflatedJob() + private var commandsJob: ConflatedJob = ConflatedJob() + + override suspend fun start( + profileQuery: ProfileQuery, + brokerSteps: List, + ): Result { + if (brokerSteps.isEmpty()) { + logcat { "PIR-RUNNER ($this): No broker steps to execute ${Thread.currentThread().name}" } + return Result.success(Unit) + } + + brokersToExecute.clear() + brokersToExecute.addAll(brokerSteps) + + initializeDetachedWebView(profileQuery) + + return awaitResult() + } + + private suspend fun initializeDetachedWebView(profileQuery: ProfileQuery) { + withContext(dispatcherProvider.main()) { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers to execute $brokersToExecute" } + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers size: ${brokersToExecute.size}" } + detachedWebView = pirDetachedWebViewProvider.createInstance(context, pirScriptToLoad) { + onLoadingComplete(it, profileQuery) + } + + initializeRunner() + } + } + + override suspend fun startOn( + webView: WebView, + profileQuery: ProfileQuery, + brokerSteps: List, + ): Result { + if (brokerSteps.isEmpty()) { + logcat { "PIR-RUNNER ($this): No broker steps to execute ${Thread.currentThread().name}" } + return Result.success(Unit) + } + + brokersToExecute.clear() + brokersToExecute.addAll(brokerSteps) + + withContext(dispatcherProvider.main()) { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers to execute $brokersToExecute" } + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers size: ${brokersToExecute.size}" } + detachedWebView = pirDetachedWebViewProvider.setupWebView(webView, pirScriptToLoad) { + onLoadingComplete(it, profileQuery) + } + initializeRunner() + } + return awaitResult() + } + + private fun initializeRunner() { + brokerActionProcessor.register(detachedWebView!!, this@RealPirActionsRunner) + detachedWebView!!.loadUrl(DBP_INITIAL_URL) + } + + private suspend fun awaitResult(): Result { + return suspendCoroutine { continuation -> + commandsJob += coroutineScope.launch { + commandsFlow.asStateFlow().collect { + handleCommand(it, continuation) + } + } + } + } + + private fun onLoadingComplete( + url: String?, + profileQuery: ProfileQuery, + ) { + logcat { "PIR-RUNNER ($this): finished loading $url and latest action ${commandsFlow.value}" } + if (url == null) { + return + } + + // A completed initial scan means we are ready to run the scan for the brokers + if (url == DBP_INITIAL_URL) { + nextCommand( + HandleBroker( + commandsFlow.value.state.copy( + currentBrokerIndex = 0, + currentActionIndex = 0, + profileQuery = profileQuery, + ), + ), + ) + } else if (commandsFlow.value is LoadUrl) { + // If the current action is still navigate, it means we just finished loading and we can proceed to next action. + // Sometimes the loaded url gets redirected to another url (could be different domain too) so we can't really check here. + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Completed loading for ${commandsFlow.value}" } + nextCommand( + ExecuteBrokerAction( + commandsFlow.value.state.copy( + currentActionIndex = commandsFlow.value.state.currentActionIndex + 1, + ), + ), + ) + } else { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Ignoring $url as next action has been pushed" } + } + } + + private suspend fun handleCommand( + command: Command, + continuationResult: Continuation>, + ) { + logcat { "PIR-RUNNER ($this): Handle command: $command" } + when (command) { + Idle -> {} // Do nothing + is HandleBroker -> handleBrokerAction(command.state) + is ExecuteBrokerAction -> executeBrokerAction(command.state, command.actionRequestData) + is CompleteExecution -> { + continuationResult.resume(Result.success(Unit)) + cleanUpRunner() + } + // TODO add loading timeout + is LoadUrl -> withContext(dispatcherProvider.main()) { + detachedWebView!!.loadUrl(command.urlToLoad) + } + + is HandleNextProfileForBroker -> handleNextProfileForBroker(command.state) + is BrokerCompleted -> handleBrokerCompleted(command.state, command.isSuccess) + is GetEmail -> handleGetEmail(command.state) + is AwaitEmailConfirmation -> handleEmailConfirmation( + command.state, + command.pollingIntervalSeconds, + ) + + is GetCaptchaSolution -> handleGetCaptchaSolution( + command.state, + command.responseData, + command.isRetry, + ) + + is AwaitCaptchaSolution -> handleAwaitCaptchaSolution( + command.state, + command.pollingIntervalSeconds, + command.retries, + command.attempt, + ) + + is SendCaptchaSolution -> handleSendCaptchaSolution(command.state, command.callback) + } + } + + private suspend fun handleNextProfileForBroker(state: State) { + // We reset action to 0 and update the profile state to the next profile + val newState = state.copy( + currentActionIndex = 0, + extractedProfileState = state.extractedProfileState.copy( + currentExtractedProfileIndex = state.extractedProfileState.currentExtractedProfileIndex + 1, + ), + ) + + // Should only be run for opt out really + if (runType == RunType.OPTOUT) { + // Signal start for current run. + pirRunStateHandler.handleState( + BrokerRecordOptOutStarted( + brokerName = brokersToExecute[state.currentBrokerIndex].brokerName, + extractedProfile = newState.extractedProfileState.extractedProfile[state.extractedProfileState.currentExtractedProfileIndex], + ), + ) + } + // Restart for broker but with different profile + nextCommand( + ExecuteBrokerAction( + state = newState, + ), + ) + } + + private fun nextCommand(command: Command) { + coroutineScope.launch { + commandsFlow.emit(command) + } + } + + private suspend fun handleSendCaptchaSolution( + state: State, + callback: String, + ) { + withContext(dispatcherProvider.main()) { + detachedWebView?.evaluateJavascript(callback, null) + } + nextCommand( + ExecuteBrokerAction( + state = state.copy( + currentActionIndex = state.currentActionIndex + 1, + ), + ), + ) + } + + private suspend fun handleAwaitCaptchaSolution( + state: State, + pollingIntervalSeconds: Int, + retries: Int, + attempt: Int, + ) { + val broker = brokersToExecute[state.currentBrokerIndex] + + if (state.transactionID.isEmpty()) { + onError( + PirErrorReponse( + actionID = broker.actions[state.currentActionIndex].id, + message = "Unable to solve captcha", + ), + ) + } else { + nativeBrokerActionHandler.pushAction( + GetCaptchaSolutionStatus( + actionId = broker.actions[state.currentActionIndex].id, + transactionID = state.transactionID, + ), + ).run { + if (this is Success && this.data is CaptchaSolutionStatus) { + when (this.data.status) { + is Ready -> nextCommand( + ExecuteBrokerAction( + state = state, + actionRequestData = PirScriptRequestData.SolveCaptcha( + token = this.data.status.token, + ), + ), + ) + + else -> { + if (attempt == retries) { + onError( + PirErrorReponse( + actionID = broker.actions[state.currentActionIndex].id, + message = "Unable to solve captcha", + ), + ) + } else { + delay(pollingIntervalSeconds * 1000L) + nextCommand( + AwaitCaptchaSolution( + state = state, + attempt = attempt + 1, + ), + ) + } + } + } + } else if (this is Failure) { + onError( + PirErrorReponse( + actionID = broker.actions[state.currentActionIndex].id, + message = "Unable to solve captcha", + ), + ) + } + } + } + } + + private suspend fun handleGetCaptchaSolution( + state: State, + responseData: ResponseData, + isRetry: Boolean, + ) { + val broker = brokersToExecute[state.currentBrokerIndex] + nativeBrokerActionHandler.pushAction( + SubmitCaptchaInfo( + actionId = broker.actions[state.currentActionIndex].id, + siteKey = responseData.siteKey, + url = responseData.url, + type = responseData.type, + ), + ).also { + if (it is Success) { + nextCommand( + ExecuteBrokerAction( + state = state.copy( + currentActionIndex = state.currentActionIndex + 1, + transactionID = (it.data as CaptchaTransactionIdReceived).transactionID, + ), + ), + ) + } else if (it is Failure && !isRetry && it.retryNativeAction) { + delay(60_000) + nextCommand( + GetCaptchaSolution( + state = state, + responseData = responseData, + isRetry = true, + ), + ) + } else { + val result = it as Failure + onError( + PirErrorReponse( + actionID = it.actionId, + message = result.message, + ), + ) + } + } + } + + private suspend fun handleEmailConfirmation( + state: State, + pollingIntervalSeconds: Float, + ) { + val broker = brokersToExecute[state.currentBrokerIndex] + val extractedProfileState = state.extractedProfileState + if (extractedProfileState.extractedProfile.isNotEmpty()) { + nativeBrokerActionHandler.pushAction( + NativeAction.GetEmailStatus( + actionId = broker.actions[state.currentActionIndex].id, + brokerName = broker.brokerName, + email = extractedProfileState.extractedProfile[extractedProfileState.currentExtractedProfileIndex].email!!, + pollingIntervalSeconds = pollingIntervalSeconds, + ), + ).also { + if (it is Success) { + nextCommand( + LoadUrl( + state = state, + urlToLoad = (it.data as NativeSuccessData.EmailConfirmation).link, + ), + ) + } else { + val result = it as Failure + onError( + PirErrorReponse( + actionID = result.actionId, + message = result.message, + ), + ) + } + } + } + } + + private suspend fun handleGetEmail(state: State) { + val broker = brokersToExecute[state.currentBrokerIndex] + + nativeBrokerActionHandler.pushAction( + NativeAction.GetEmail( + actionId = broker.actions[state.currentActionIndex].id, + brokerName = broker.brokerName, + ), + ).also { + if (it is Success && state.extractedProfileState.extractedProfile.isNotEmpty()) { + val extractedProfileState = state.extractedProfileState + val extractedProfileWithEmail = + extractedProfileState.extractedProfile[extractedProfileState.currentExtractedProfileIndex].copy( + email = (it.data as Email).email, + ) + val updatedList = extractedProfileState.extractedProfile.toMutableList() + + updatedList[extractedProfileState.currentExtractedProfileIndex] = + extractedProfileWithEmail + + extractedProfileState.extractedProfile[extractedProfileState.currentExtractedProfileIndex] + nextCommand( + ExecuteBrokerAction( + state = state.copy( + extractedProfileState = extractedProfileState.copy( + extractedProfile = updatedList, + ), + ), + actionRequestData = UserProfile( + userProfile = state.profileQuery, + extractedProfile = extractedProfileWithEmail.run { + ExtractedProfileParams( + name = this.name, + profileUrl = this.profileUrl?.profileUrl, + fullName = state.profileQuery?.fullName, + email = this.email, + ) + }, + ), + ), + ) + } else { + val result = it as Failure + onError( + PirErrorReponse( + actionID = result.actionId, + message = result.message, + ), + ) + } + } + } + + private suspend fun handleBrokerAction(state: State) { + if (state.currentBrokerIndex >= brokersToExecute.size) { + nextCommand(CompleteExecution) + } else { + // Entry point of execution for a Broker + brokersToExecute.get(state.currentBrokerIndex).let { + emitBrokerStartPixel(it) + + nextCommand( + ExecuteBrokerAction( + commandsFlow.value.state.copy( + currentActionIndex = 0, + brokerStartTime = currentTimeProvider.currentTimeMillis(), + extractedProfileState = if (it is OptOutStep) { + ExtractedProfileState( + currentExtractedProfileIndex = 0, + extractedProfile = it.profilesToOptOut, + ) + } else { + ExtractedProfileState() + }, + ), + ), + ) + } + } + } + + private suspend fun handleBrokerCompleted( + state: State, + isSuccess: Boolean, + ) { + if (state.extractedProfileState.currentExtractedProfileIndex < state.extractedProfileState.extractedProfile.size - 1) { + if (runType == RunType.OPTOUT) { + // Signal complete for previous run. + pirRunStateHandler.handleState( + BrokerRecordOptOutCompleted( + brokerName = brokersToExecute[state.currentBrokerIndex].brokerName, + extractedProfile = state.extractedProfileState.extractedProfile[state.extractedProfileState.currentExtractedProfileIndex], + startTimeInMillis = state.brokerStartTime, + endTimeInMillis = currentTimeProvider.currentTimeMillis(), + isSubmitSuccess = isSuccess, + ), + ) + } + + // Broker is not yet completed as another profile can be run + nextCommand( + HandleNextProfileForBroker( + state = state, + ), + ) + } else { + // Exit point of execution for a Blocker + emitBrokerCompletePixel( + brokerName = brokersToExecute[state.currentBrokerIndex].brokerName, + state = state, + startTimeInMillis = state.brokerStartTime, + totalTimeMillis = currentTimeProvider.currentTimeMillis() - state.brokerStartTime, + isSuccess = isSuccess, + ) + nextCommand( + HandleBroker( + state = state.copy( + currentBrokerIndex = state.currentBrokerIndex + 1, + ), + ), + ) + } + } + + private suspend fun emitBrokerStartPixel(brokerStep: BrokerStep) { + when (runType) { + RunType.MANUAL -> pirRunStateHandler.handleState( + BrokerManualScanStarted( + brokerStep.brokerName, + currentTimeProvider.currentTimeMillis(), + ), + ) + + RunType.SCHEDULED -> pirRunStateHandler.handleState( + BrokerScheduledScanStarted( + brokerStep.brokerName, + currentTimeProvider.currentTimeMillis(), + ), + ) + + RunType.OPTOUT -> { + // When we get here it means we are starting a new process for a new broker + pirRunStateHandler.handleState( + BrokerOptOutStarted( + brokerStep.brokerName, + ), + ) + + // It also means we are starting it for the first profile. Succeeding profiles are handled in HandleNextProfileForBroker + pirRunStateHandler.handleState( + BrokerRecordOptOutStarted( + brokerStep.brokerName, + (brokerStep as OptOutStep).profilesToOptOut[0], + ), + ) + } + + else -> {} + } + } + + private suspend fun emitBrokerCompletePixel( + brokerName: String, + state: State, + startTimeInMillis: Long, + totalTimeMillis: Long, + isSuccess: Boolean, + ) { + when (runType) { + RunType.MANUAL -> + pirRunStateHandler.handleState( + BrokerManualScanCompleted( + brokerName = brokerName, + eventTimeInMillis = currentTimeProvider.currentTimeMillis(), + totalTimeMillis = totalTimeMillis, + isSuccess = isSuccess, + startTimeInMillis = startTimeInMillis, + ), + ) + + RunType.SCHEDULED -> pirRunStateHandler.handleState( + BrokerScheduledScanCompleted( + brokerName = brokerName, + eventTimeInMillis = currentTimeProvider.currentTimeMillis(), + totalTimeMillis = totalTimeMillis, + isSuccess = isSuccess, + startTimeInMillis = startTimeInMillis, + ), + ) + + RunType.OPTOUT -> { + pirRunStateHandler.handleState( + BrokerRecordOptOutCompleted( + brokerName = brokerName, + startTimeInMillis = startTimeInMillis, + endTimeInMillis = currentTimeProvider.currentTimeMillis(), + extractedProfile = state.extractedProfileState.extractedProfile[state.extractedProfileState.currentExtractedProfileIndex], + isSubmitSuccess = isSuccess, + ), + ) + pirRunStateHandler.handleState( + BrokerOptOutCompleted( + brokerName = brokerName, + startTimeInMillis = startTimeInMillis, + endTimeInMillis = currentTimeProvider.currentTimeMillis(), + ), + ) + } + + else -> {} + } + } + + private fun executeBrokerAction( + state: State, + requestData: PirScriptRequestData, + ) { + val currentBroker = brokersToExecute[state.currentBrokerIndex] + if (state.currentActionIndex == currentBroker.actions.size) { + nextCommand( + BrokerCompleted(state, true), + ) + } else { + val actionToExecute = currentBroker.actions[state.currentActionIndex] + + if (actionToExecute.needsEmail && !hasEmail(state.extractedProfileState)) { + nextCommand(GetEmail(state)) + } else { + // Adding a delay here similar to macOS - to ensure the site completes loading before executing anything. + if (actionToExecute is Click || actionToExecute is Expectation) { + runBlocking(dispatcherProvider.io()) { + delay(10_000) + } + } + + if (actionToExecute is EmailConfirmation) { + nextCommand( + AwaitEmailConfirmation( + state = state, + pollingIntervalSeconds = actionToExecute.pollingTime.toFloat(), + ), + ) + } else if (actionToExecute is SolveCaptcha && requestData !is PirScriptRequestData.SolveCaptcha) { + nextCommand( + AwaitCaptchaSolution( + state = state, + attempt = 0, + ), + ) + } else { + timerJob.cancel() + timerJob += coroutineScope.launch { + delay(60000) // 1 minute + // IF this timer completes, then timeout was reached + val currentState = commandsFlow.value.state + kotlin.runCatching { + val id = + brokersToExecute[currentState.currentBrokerIndex].actions[currentState.currentActionIndex].id + onError( + PirErrorReponse( + actionID = id, + message = "Local timeout", + ), + ) + } + } + + brokerActionProcessor.pushAction( + actionToExecute, + completeRequestData(state, actionToExecute, requestData), + ) + } + } + } + } + + private fun completeRequestData( + state: State, + actionToExecute: BrokerAction, + requestData: PirScriptRequestData, + ): PirScriptRequestData { + return if (actionToExecute.dataSource == EXTRACTED_PROFILE && (requestData as UserProfile).extractedProfile == null) { + val extractedProfileState = state.extractedProfileState + val extractedProfile = + extractedProfileState.extractedProfile[extractedProfileState.currentExtractedProfileIndex] + + UserProfile( + userProfile = requestData.userProfile, + extractedProfile = extractedProfile.run { + ExtractedProfileParams( + name = this.name, + profileUrl = this.profileUrl?.profileUrl, + fullName = state.profileQuery?.fullName, + email = this.email, + ) + }, + ) + } else { + requestData + } + } + + private fun hasEmail(extractedProfileState: ExtractedProfileState): Boolean { + return extractedProfileState.extractedProfile[extractedProfileState.currentExtractedProfileIndex].email != null + } + + private fun cleanUpRunner() { + timerJob.cancel() + nextCommand(Idle) + commandsJob.cancel() + coroutineScope.launch(dispatcherProvider.main()) { + detachedWebView?.stopLoading() + detachedWebView?.loadUrl("about:blank") + detachedWebView?.evaluateJavascript("window.stop();", null) + detachedWebView?.destroy() + logcat { "PIR-RUNNER ($this): Destroyed webview" } + } + } + + override fun stop() { + logcat { "PIR-RUNNER ($this): Stopping and resetting values" } + cleanUpRunner() + } + + override fun onSuccess(pirSuccessResponse: PirSuccessResponse) { + runBlocking(dispatcherProvider.main()) { + val lastState = commandsFlow.value.state + val currentBroker = brokersToExecute[lastState.currentBrokerIndex] + val currentAction = currentBroker.actions[lastState.currentActionIndex] + + if (pirSuccessResponse.actionID == currentAction.id) { + timerJob.cancel() + + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): onSuccess: $pirSuccessResponse" } + if (runType != RunType.OPTOUT) { + pirRunStateHandler.handleState( + BrokerScanActionSucceeded( + currentBroker.brokerName, + pirSuccessResponse, + ), + ) + } else { + lastState.extractedProfileState.extractedProfile.get(lastState.extractedProfileState.currentExtractedProfileIndex) + .let { + pirRunStateHandler.handleState( + BrokerOptOutActionSucceeded( + brokerName = currentBroker.brokerName, + extractedProfile = it, + completionTimeInMillis = currentTimeProvider.currentTimeMillis(), + actionType = pirSuccessResponse.actionType, + result = pirSuccessResponse, + ), + ) + } + } + when (pirSuccessResponse) { + is NavigateResponse -> { + nextCommand( + LoadUrl( + urlToLoad = pirSuccessResponse.response.url, + state = lastState, + ), + ) + } + + is ExtractedResponse -> { + nextCommand( + ExecuteBrokerAction( + state = lastState.copy( + currentActionIndex = lastState.currentActionIndex + 1, + ), + ), + ) + } + + is ClickResponse, is ExpectationResponse -> { + nextCommand( + ExecuteBrokerAction( + state = lastState.copy( + currentActionIndex = lastState.currentActionIndex + 1, + ), + ), + ) + } + + is FillFormResponse -> { + nextCommand( + ExecuteBrokerAction( + state = lastState.copy( + currentActionIndex = lastState.currentActionIndex + 1, + ), + ), + ) + } + + is GetCaptchaInfoResponse -> { + pirSuccessResponse.response?.let { + nextCommand( + GetCaptchaSolution( + state = lastState, + responseData = it, + isRetry = false, + ), + ) + } + } + + is SolveCaptchaResponse -> { + nextCommand( + SendCaptchaSolution( + state = lastState, + callback = pirSuccessResponse.response!!.callback.eval, + ), + ) + } + + else -> { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Do nothing for $pirSuccessResponse" } + } + } + } else { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Runner can't handle $pirSuccessResponse" } + } + } + } + + override fun onError(pirErrorReponse: PirErrorReponse) { + runBlocking(dispatcherProvider.main()) { + val lastState = commandsFlow.value.state + val currentBroker = brokersToExecute[lastState.currentBrokerIndex] + val currentAction = currentBroker.actions[lastState.currentActionIndex] + + if (pirErrorReponse.actionID == currentAction.id) { + timerJob.cancel() + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): onError: $pirErrorReponse" } + if (runType != RunType.OPTOUT) { + pirRunStateHandler.handleState( + BrokerScanActionFailed( + brokerName = currentBroker.brokerName, + actionType = currentAction.asActionType(), + pirErrorReponse = pirErrorReponse, + ), + ) + } else { + lastState.extractedProfileState.extractedProfile?.get(lastState.extractedProfileState.currentExtractedProfileIndex) + ?.let { + pirRunStateHandler.handleState( + BrokerOptOutActionFailed( + brokerName = currentBroker.brokerName, + extractedProfile = it, + completionTimeInMillis = currentTimeProvider.currentTimeMillis(), + actionType = currentAction.asActionType(), + result = pirErrorReponse, + ), + ) + } + } + if (currentAction is GetCaptchaInfo || currentAction is SolveCaptcha) { + nextCommand( + ExecuteBrokerAction( + lastState.copy( + currentActionIndex = lastState.currentActionIndex + 1, + ), + ), + ) + } else { + // If error happens we skip to next Broker as next steps will not make sense + nextCommand( + BrokerCompleted(lastState, isSuccess = false), + ) + } + } else { + logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Runner can't handle $pirErrorReponse" } + } + } + } + + sealed class Command(open val state: State) { + data object Idle : Command(State()) + data class HandleBroker( + override val state: State, + ) : Command(state) + + data class ExecuteBrokerAction( + override val state: State, + val actionRequestData: PirScriptRequestData = UserProfile( + userProfile = state.profileQuery, + ), + ) : Command(state) + + data class LoadUrl( + override val state: State, + val urlToLoad: String, + ) : Command(State()) + + data class HandleNextProfileForBroker( + override val state: State, + ) : Command(State()) + + data class GetEmail( + override val state: State, + ) : Command(State()) + + data class BrokerCompleted( + override val state: State, + val isSuccess: Boolean, + ) : Command(state) + + data class AwaitEmailConfirmation( + override val state: State, + val pollingIntervalSeconds: Float, + ) : Command(state) + + data class GetCaptchaSolution( + override val state: State, + val responseData: ResponseData, + val isRetry: Boolean, + ) : Command(state) + + data class AwaitCaptchaSolution( + override val state: State, + val pollingIntervalSeconds: Int = 5, + val retries: Int = 50, + val attempt: Int = 0, + ) : Command(state) + + data class SendCaptchaSolution( + override val state: State, + val callback: String, + ) : Command(state) + + data object CompleteExecution : Command(State()) + } + + data class State( + val currentBrokerIndex: Int = 0, + val currentActionIndex: Int = 0, + val brokerStartTime: Long = -1L, + val profileQuery: ProfileQuery? = null, + val extractedProfileState: ExtractedProfileState = ExtractedProfileState(), + val transactionID: String = "", + ) + + data class ExtractedProfileState( + val currentExtractedProfileIndex: Int = 0, + val extractedProfile: List = emptyList(), + ) + + companion object { + private const val DBP_INITIAL_URL = "dbp://blank" + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunnerFactory.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunnerFactory.kt similarity index 80% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunnerFactory.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunnerFactory.kt index b0ad346f7358..7d88cc23435c 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunnerFactory.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirActionsRunnerFactory.kt @@ -14,14 +14,12 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.component +package com.duckduckgo.pir.internal.common import android.content.Context import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.js.messaging.api.JsMessageHelper -import com.duckduckgo.pir.internal.pixels.PirPixelSender -import com.duckduckgo.pir.internal.scan.PirScan.RunType import com.duckduckgo.pir.internal.scripts.PirMessagingInterface import com.duckduckgo.pir.internal.scripts.RealBrokerActionProcessor import com.duckduckgo.pir.internal.store.PirRepository @@ -30,10 +28,11 @@ import javax.inject.Inject class PirActionsRunnerFactory @Inject constructor( private val pirDetachedWebViewProvider: PirDetachedWebViewProvider, private val dispatcherProvider: DispatcherProvider, - private val repository: PirRepository, private val jsMessageHelper: JsMessageHelper, private val currentTimeProvider: CurrentTimeProvider, - private val pixelSender: PirPixelSender, + private val pirRunStateHandler: PirRunStateHandler, + private val pirRepository: PirRepository, + private val captchaResolver: CaptchaResolver, ) { /** * Every instance of PirActionsRunner is created with its own instance of [PirMessagingInterface] and [RealBrokerActionProcessor] @@ -45,7 +44,6 @@ class PirActionsRunnerFactory @Inject constructor( ): PirActionsRunner { return RealPirActionsRunner( dispatcherProvider, - repository, pirDetachedWebViewProvider, RealBrokerActionProcessor( PirMessagingInterface( @@ -54,9 +52,20 @@ class PirActionsRunnerFactory @Inject constructor( ), context, pirScriptToLoad, - pixelSender, runType, currentTimeProvider, + RealNativeBrokerActionHandler( + pirRepository, + dispatcherProvider, + captchaResolver, + ), + pirRunStateHandler, ) } + + enum class RunType { + MANUAL, + SCHEDULED, + OPTOUT, + } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirDetachedWebViewProvider.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirDetachedWebViewProvider.kt similarity index 82% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirDetachedWebViewProvider.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirDetachedWebViewProvider.kt index 580035c79508..957523f8531b 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirDetachedWebViewProvider.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirDetachedWebViewProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.component +package com.duckduckgo.pir.internal.common import android.annotation.SuppressLint import android.content.Context @@ -32,29 +32,50 @@ import logcat.logcat interface PirDetachedWebViewProvider { /** - * This method returns an instance of webview created using the given [context] with every necessary + * This method returns an instance of WebView created using the given [context] with every necessary * configuration setup for pir to run. * * @param context in which the webview should run - could be service/activity * @param scriptToLoad the JS script that is needed for PIR to run. * @param onPageLoaded callback to receive whenever a url has finished loading. */ - fun getInstance( + fun createInstance( context: Context, scriptToLoad: String, onPageLoaded: (String?) -> Unit, ): WebView + + /** + * This method configures the [WebView] passed to be able to run pir scripts. + * + * @param webView in which PIR should run + * @param scriptToLoad the JS script that is needed for PIR to run. + * @param onPageLoaded callback to receive whenever a url has finished loading. + */ + fun setupWebView( + webView: WebView, + scriptToLoad: String, + onPageLoaded: (String?) -> Unit, + ): WebView } @ContributesBinding(AppScope::class) class RealPirDetachedWebViewProvider @Inject constructor() : PirDetachedWebViewProvider { @SuppressLint("SetJavaScriptEnabled") - override fun getInstance( + override fun createInstance( context: Context, scriptToLoad: String, onPageLoaded: (String?) -> Unit, ): WebView { - return WebView(context).apply { + return setupWebView(WebView(context), scriptToLoad, onPageLoaded) + } + + override fun setupWebView( + webView: WebView, + scriptToLoad: String, + onPageLoaded: (String?) -> Unit, + ): WebView { + return webView.apply { webChromeClient = object : WebChromeClient() { override fun onCreateWindow( view: WebView?, diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirJob.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirJob.kt new file mode 100644 index 000000000000..1ca0f726d419 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirJob.kt @@ -0,0 +1,45 @@ +/* + * 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.internal.common + +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.pir.internal.callbacks.PirCallbacks +import kotlinx.coroutines.CoroutineScope +import logcat.logcat + +abstract class PirJob(private val callbacks: PluginPoint) { + fun onJobStarted(coroutineScope: CoroutineScope) { + callbacks.getPlugins().forEach { + logcat { "PIR-CALLBACKS: Starting $it" } + it.onPirJobStarted(coroutineScope) + } + } + + fun onJobCompleted() { + callbacks.getPlugins().forEach { + logcat { "PIR-CALLBACKS: Completing $it" } + it.onPirJobCompleted() + } + } + + fun onJobStopped() { + callbacks.getPlugins().forEach { + logcat { "PIR-CALLBACKS: Stopping $it" } + it.onPirJobStopped() + } + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirRunStateHandler.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirRunStateHandler.kt new file mode 100644 index 000000000000..b2064f3849e4 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirRunStateHandler.kt @@ -0,0 +1,323 @@ +/* + * 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.internal.common + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerManualScanCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerManualScanStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutActionFailed +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutActionSucceeded +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerOptOutStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerRecordOptOutCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerRecordOptOutStarted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScanActionFailed +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScanActionSucceeded +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScheduledScanCompleted +import com.duckduckgo.pir.internal.common.PirRunStateHandler.PirRunState.BrokerScheduledScanStarted +import com.duckduckgo.pir.internal.pixels.PirPixelSender +import com.duckduckgo.pir.internal.scripts.models.ExtractedProfile +import com.duckduckgo.pir.internal.scripts.models.PirErrorReponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ClickResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExpectationResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExtractedResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.FillFormResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.NavigateResponse +import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.SolveCaptchaResponse +import com.duckduckgo.pir.internal.store.PirRepository +import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_ERROR +import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_STARTED +import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_SUCCESS +import com.duckduckgo.pir.internal.store.db.PirBrokerScanLog +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import javax.inject.Inject +import kotlinx.coroutines.withContext + +interface PirRunStateHandler { + suspend fun handleState(pirRunState: PirRunState) + + sealed class PirRunState(open val brokerName: String) { + data class BrokerManualScanStarted( + override val brokerName: String, + val eventTimeInMillis: Long, + ) : PirRunState(brokerName) + + data class BrokerManualScanCompleted( + override val brokerName: String, + val startTimeInMillis: Long, + val eventTimeInMillis: Long, + val totalTimeMillis: Long, + val isSuccess: Boolean, + ) : PirRunState(brokerName) + + data class BrokerScheduledScanStarted( + override val brokerName: String, + val eventTimeInMillis: Long, + ) : PirRunState(brokerName) + + data class BrokerScheduledScanCompleted( + override val brokerName: String, + val startTimeInMillis: Long, + val eventTimeInMillis: Long, + val totalTimeMillis: Long, + val isSuccess: Boolean, + ) : PirRunState(brokerName) + + data class BrokerScanActionSucceeded( + override val brokerName: String, + val pirSuccessResponse: PirSuccessResponse, + ) : PirRunState(brokerName) + + data class BrokerScanActionFailed( + override val brokerName: String, + val actionType: String, + val pirErrorReponse: PirErrorReponse, + ) : PirRunState(brokerName) + + data class BrokerOptOutStarted( + override val brokerName: String, + ) : PirRunState(brokerName) + + data class BrokerOptOutCompleted( + override val brokerName: String, + val startTimeInMillis: Long, + val endTimeInMillis: Long, + ) : PirRunState(brokerName) + + data class BrokerRecordOptOutStarted( + override val brokerName: String, + val extractedProfile: ExtractedProfile, + ) : PirRunState(brokerName) + + data class BrokerRecordOptOutCompleted( + override val brokerName: String, + val extractedProfile: ExtractedProfile, + val startTimeInMillis: Long, + val endTimeInMillis: Long, + val isSubmitSuccess: Boolean, + ) : PirRunState(brokerName) + + data class BrokerOptOutActionSucceeded( + override val brokerName: String, + val extractedProfile: ExtractedProfile, + val completionTimeInMillis: Long, + val actionType: String, + val result: PirSuccessResponse, + ) : PirRunState(brokerName) + + data class BrokerOptOutActionFailed( + override val brokerName: String, + val extractedProfile: ExtractedProfile, + val completionTimeInMillis: Long, + val actionType: String, + val result: PirErrorReponse, + ) : PirRunState(brokerName) + } +} + +@ContributesBinding(AppScope::class) +class RealPirRunStateHandler @Inject constructor( + private val repository: PirRepository, + private val pixelSender: PirPixelSender, + private val dispatcherProvider: DispatcherProvider, +) : PirRunStateHandler { + private val pirSuccessAdapter by lazy { + Moshi.Builder().add( + PolymorphicJsonAdapterFactory.of(PirSuccessResponse::class.java, "actionType") + .withSubtype(NavigateResponse::class.java, "navigate") + .withSubtype(ExtractedResponse::class.java, "extract") + .withSubtype(GetCaptchaInfoResponse::class.java, "getCaptchaInfo") + .withSubtype(SolveCaptchaResponse::class.java, "solveCaptcha") + .withSubtype(ClickResponse::class.java, "click") + .withSubtype(ExpectationResponse::class.java, "expectation") + .withSubtype(FillFormResponse::class.java, "fillForm"), + ).add(KotlinJsonAdapterFactory()) + .build().adapter(PirSuccessResponse::class.java) + } + private val pirErrorAdapter by lazy { + Moshi.Builder().build().adapter(PirErrorReponse::class.java) + } + + override suspend fun handleState(pirRunState: PirRunState) = withContext(dispatcherProvider.io()) { + when (pirRunState) { + is BrokerManualScanStarted -> handleBrokerManualScanStarted(pirRunState) + is BrokerManualScanCompleted -> handleBrokerManualScanCompleted(pirRunState) + is BrokerScheduledScanStarted -> handleBrokerScheduledScanStarted(pirRunState) + is BrokerScheduledScanCompleted -> handleBrokerScheduledScanCompleted(pirRunState) + is BrokerScanActionSucceeded -> handleBrokerScanActionSucceeded(pirRunState) + is BrokerScanActionFailed -> handleBrokerScanActionFailed(pirRunState) + is BrokerOptOutStarted -> handleBrokerOptOutStarted(pirRunState) + is BrokerOptOutCompleted -> handleBrokerOptOutCompleted(pirRunState) + is BrokerRecordOptOutStarted -> handleRecordOptOutStarted(pirRunState) + is BrokerRecordOptOutCompleted -> handleRecordOptOutCompleted(pirRunState) + is BrokerOptOutActionSucceeded -> handleBrokerOptOutActionSucceeded(pirRunState) + is BrokerOptOutActionFailed -> handleBrokerOptOutActionFailed(pirRunState) + else -> {} + } + } + + private suspend fun handleBrokerManualScanStarted(state: BrokerManualScanStarted) { + pixelSender.reportManualScanBrokerStarted(state.brokerName) + repository.saveBrokerScanLog( + PirBrokerScanLog( + eventTimeInMillis = state.eventTimeInMillis, + brokerName = state.brokerName, + eventType = BROKER_STARTED, + ), + ) + } + + private suspend fun handleBrokerManualScanCompleted(state: BrokerManualScanCompleted) { + pixelSender.reportManualScanBrokerCompleted( + brokerName = state.brokerName, + totalTimeInMillis = state.totalTimeMillis, + isSuccess = state.isSuccess, + ) + repository.saveBrokerScanLog( + PirBrokerScanLog( + eventTimeInMillis = state.eventTimeInMillis, + brokerName = state.brokerName, + eventType = if (state.isSuccess) BROKER_SUCCESS else BROKER_ERROR, + ), + ) + repository.saveScanCompletedBroker( + brokerName = state.brokerName, + startTimeInMillis = state.startTimeInMillis, + endTimeInMillis = state.eventTimeInMillis, + ) + } + + private suspend fun handleBrokerScheduledScanStarted(state: BrokerScheduledScanStarted) { + pixelSender.reportScheduledScanBrokerStarted(state.brokerName) + repository.saveBrokerScanLog( + PirBrokerScanLog( + eventTimeInMillis = state.eventTimeInMillis, + brokerName = state.brokerName, + eventType = BROKER_STARTED, + ), + ) + } + + private suspend fun handleBrokerScheduledScanCompleted(state: BrokerScheduledScanCompleted) { + pixelSender.reportScheduledScanBrokerCompleted( + brokerName = state.brokerName, + totalTimeInMillis = state.totalTimeMillis, + isSuccess = state.isSuccess, + ) + repository.saveBrokerScanLog( + PirBrokerScanLog( + eventTimeInMillis = state.eventTimeInMillis, + brokerName = state.brokerName, + eventType = if (state.isSuccess) BROKER_SUCCESS else BROKER_ERROR, + ), + ) + repository.saveScanCompletedBroker( + brokerName = state.brokerName, + startTimeInMillis = state.startTimeInMillis, + endTimeInMillis = state.eventTimeInMillis, + ) + } + + private suspend fun handleBrokerScanActionSucceeded(state: BrokerScanActionSucceeded) { + when (state.pirSuccessResponse) { + is NavigateResponse -> repository.saveNavigateResult( + state.brokerName, + state.pirSuccessResponse, + ) + + is ExtractedResponse -> repository.saveExtractProfileResult( + state.brokerName, + state.pirSuccessResponse, + ) + + else -> {} + } + } + + private suspend fun handleBrokerScanActionFailed(state: BrokerScanActionFailed) { + repository.saveErrorResult( + brokerName = state.brokerName, + actionType = state.actionType, + error = state.pirErrorReponse, + ) + } + + private fun handleBrokerOptOutStarted(state: BrokerOptOutStarted) { + pixelSender.reportBrokerOptOutStarted( + brokerName = state.brokerName, + ) + } + + private fun handleBrokerOptOutCompleted(state: BrokerOptOutCompleted) { + pixelSender.reportBrokerOptOutCompleted( + brokerName = state.brokerName, + totalTimeInMillis = state.endTimeInMillis - state.startTimeInMillis, + ) + } + + private fun handleRecordOptOutStarted(state: BrokerRecordOptOutStarted) { + pixelSender.reportRecordOptOutStarted( + brokerName = state.brokerName, + recordId = state.extractedProfile.profileUrl?.identifier ?: "Unavailable", + ) + } + + private suspend fun handleRecordOptOutCompleted(state: BrokerRecordOptOutCompleted) { + pixelSender.reportRecordOptOutCompleted( + brokerName = state.brokerName, + recordId = state.extractedProfile.profileUrl?.identifier ?: "Unavailable", + totalTimeInMillis = state.endTimeInMillis - state.startTimeInMillis, + isSuccess = state.isSubmitSuccess, + ) + repository.saveOptOutCompleted( + brokerName = state.brokerName, + extractedProfile = state.extractedProfile, + startTimeInMillis = state.startTimeInMillis, + endTimeInMillis = state.endTimeInMillis, + isSubmitSuccess = state.isSubmitSuccess, + ) + } + + private suspend fun handleBrokerOptOutActionSucceeded(state: BrokerOptOutActionSucceeded) { + repository.saveOptOutActionLog( + brokerName = state.brokerName, + extractedProfile = state.extractedProfile, + completionTimeInMillis = state.completionTimeInMillis, + actionType = state.actionType, + isError = false, + result = pirSuccessAdapter.toJson(state.result), + ) + } + + private suspend fun handleBrokerOptOutActionFailed(state: BrokerOptOutActionFailed) { + repository.saveOptOutActionLog( + brokerName = state.brokerName, + extractedProfile = state.extractedProfile, + completionTimeInMillis = state.completionTimeInMillis, + actionType = state.actionType, + isError = true, + result = pirErrorAdapter.toJson(state.result), + ) + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirUtils.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirUtils.kt new file mode 100644 index 000000000000..df986a324607 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/common/PirUtils.kt @@ -0,0 +1,41 @@ +/* + * 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.internal.common + +import java.io.File + +internal fun getMaximumParallelRunners(): Int { + return try { + // Get the directory containing CPU info + val cpuDir = File("/sys/devices/system/cpu/") + // Filter folders matching the pattern "cpu[0-9]+" + val cpuFiles = cpuDir.listFiles { file -> file.name.matches(Regex("cpu[0-9]+")) } + cpuFiles?.size ?: Runtime.getRuntime().availableProcessors() + } catch (e: Exception) { + // In case of an error, fall back to availableProcessors + Runtime.getRuntime().availableProcessors() + } +} + +internal fun List.splitIntoParts(parts: Int): List> { + return if (this.isEmpty()) { + emptyList() + } else { + val chunkSize = (this.size + parts - 1) / parts // Ensure rounding up + this.chunked(chunkSize) + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunner.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunner.kt deleted file mode 100644 index bb475ebcbc93..000000000000 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/component/PirActionsRunner.kt +++ /dev/null @@ -1,435 +0,0 @@ -/* - * 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.internal.component - -import android.content.Context -import android.webkit.WebView -import com.duckduckgo.common.utils.ConflatedJob -import com.duckduckgo.common.utils.CurrentTimeProvider -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.BrokerCompleted -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.CompleteExecution -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.ExecuteBrokerAction -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.HandleBroker -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.Idle -import com.duckduckgo.pir.internal.component.RealPirActionsRunner.Command.LoadUrl -import com.duckduckgo.pir.internal.pixels.PirPixelSender -import com.duckduckgo.pir.internal.scan.BrokerStepsParser.BrokerStep -import com.duckduckgo.pir.internal.scan.PirScan.RunType -import com.duckduckgo.pir.internal.scan.PirScan.RunType.MANUAL -import com.duckduckgo.pir.internal.scripts.BrokerActionProcessor -import com.duckduckgo.pir.internal.scripts.BrokerActionProcessor.ActionResultListener -import com.duckduckgo.pir.internal.scripts.models.PirErrorReponse -import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse -import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExtractedResponse -import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.NavigateResponse -import com.duckduckgo.pir.internal.scripts.models.ProfileQuery -import com.duckduckgo.pir.internal.scripts.models.asActionType -import com.duckduckgo.pir.internal.store.PirRepository -import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_ERROR -import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_STARTED -import com.duckduckgo.pir.internal.store.db.BrokerScanEventType.BROKER_SUCCESS -import com.duckduckgo.pir.internal.store.db.PirBrokerScanLog -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import logcat.logcat - -interface PirActionsRunner { - /** - * This function is responsible for executing the [BrokerStep] passed on its own detached WebView - * - * @param profileQuery - Profile to be passed along actions in [BrokerStep] - * @param brokerSteps - List of [BrokerStep] each containing a broker + actions to be executed. - */ - suspend fun start( - profileQuery: ProfileQuery, - brokerSteps: List, - ): Result - - /** - * Forcefully stops / aborts a runner if it is running. - */ - suspend fun stop() -} - -internal class RealPirActionsRunner( - private val dispatcherProvider: DispatcherProvider, - private val repository: PirRepository, - private val pirDetachedWebViewProvider: PirDetachedWebViewProvider, - private val brokerActionProcessor: BrokerActionProcessor, - private val context: Context, - private val pirScriptToLoad: String, - private val pirPixelSender: PirPixelSender, - private val runType: RunType, - private val currentTimeProvider: CurrentTimeProvider, -) : PirActionsRunner, ActionResultListener { - private val timerCoroutineScope: CoroutineScope - get() = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) - - private val commandCoroutineScope: CoroutineScope - get() = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) - - private var detachedWebView: WebView? = null - private var submittedProfileQuery: ProfileQuery? = null - private val brokersToExecute: MutableList = mutableListOf() - - private val commandsFlow = MutableStateFlow(Idle) - private var timerJob: ConflatedJob = ConflatedJob() - private var commandsJob: ConflatedJob = ConflatedJob() - - override suspend fun start( - profileQuery: ProfileQuery, - brokerSteps: List, - ): Result { - if (brokerSteps.isEmpty()) { - logcat { "PIR-RUNNER ($this): No broker steps to execute ${Thread.currentThread().name}" } - return Result.success(Unit) - } - - submittedProfileQuery = profileQuery - brokersToExecute.clear() - brokersToExecute.addAll(brokerSteps) - - initializeDetachedWebView() - - return suspendCoroutine { continuation -> - commandsJob += commandCoroutineScope.launch { - commandsFlow.asStateFlow().collect { - handleCommand(it, continuation) - } - } - } - } - - private suspend fun initializeDetachedWebView() { - withContext(dispatcherProvider.main()) { - /** - * 1. Create detached WebView - * 2. Prepare the detached WebView - load script and load the dbp url - * 3. Execute all steps for each Broker - * 4. Complete! - */ - - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers to execute $brokersToExecute" } - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): ${Thread.currentThread().name} Brokers size: ${brokersToExecute.size}" } - detachedWebView = pirDetachedWebViewProvider.getInstance(context, pirScriptToLoad) { - logcat { "PIR-RUNNER ($this): finished loading $it and latest action ${commandsFlow.value}" } - - // A completed initial scan means we are ready to run the scan for the brokers - if (it == DBP_INITIAL_URL) { - nextCommand( - HandleBroker( - commandsFlow.value.state.copy( - currentBrokerIndex = 0, - currentActionIndex = 0, - ), - ), - ) - } else if (commandsFlow.value is LoadUrl) { - // If the current action is still navigate, it means we just finished loading and we can proceed to next action. - // Sometimes the loaded url gets redirected to another url (could be different domain too) so we can't really check here. - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Completed loading for ${commandsFlow.value}" } - nextCommand( - ExecuteBrokerAction( - commandsFlow.value.state.copy( - currentActionIndex = commandsFlow.value.state.currentActionIndex + 1, - ), - ), - ) - } else { - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Ignoring $it as next action has been pushed" } - } - }.also { - brokerActionProcessor.register(it, this@RealPirActionsRunner) - } - - detachedWebView!!.loadUrl(DBP_INITIAL_URL) - } - } - - private suspend fun handleCommand( - command: Command, - continuationResult: Continuation>, - ) { - logcat { "PIR-RUNNER ($this): Handle command: $command" } - when (command) { - Idle -> {} // Do nothing - is HandleBroker -> handleBrokerAction(command.state) - is ExecuteBrokerAction -> executeBrokerAction(command.state) - is CompleteExecution -> { - continuationResult.resume(Result.success(Unit)) - cleanUpRunner() - } - // TODO add loading timeout - is LoadUrl -> commandCoroutineScope.launch(dispatcherProvider.main()) { - detachedWebView!!.loadUrl(command.urlToLoad) - } - - is BrokerCompleted -> handleBrokerCompleted(command.state, command.isSuccess) - } - } - - private fun nextCommand(command: Command) { - commandCoroutineScope.launch { - commandsFlow.emit(command) - } - } - - private suspend fun handleBrokerAction(state: State) { - if (state.currentBrokerIndex >= brokersToExecute.size) { - nextCommand(CompleteExecution) - } else { - // Entry point of execution for a Blocker - brokersToExecute[state.currentBrokerIndex].brokerName?.let { - emitBrokerStartPixel(it) - } - - nextCommand( - ExecuteBrokerAction( - commandsFlow.value.state.copy( - currentActionIndex = 0, - brokerStartTime = currentTimeProvider.currentTimeMillis(), - ), - ), - ) - } - } - - private suspend fun handleBrokerCompleted( - state: State, - isSuccess: Boolean, - ) { - // Exit point of execution for a Blocker - brokersToExecute[state.currentBrokerIndex].brokerName?.let { - emitBrokerCompletePixel( - brokerName = it, - totalTimeMillis = currentTimeProvider.currentTimeMillis() - state.brokerStartTime, - isSuccess = isSuccess, - ) - } - - nextCommand( - HandleBroker( - state = state.copy( - currentBrokerIndex = state.currentBrokerIndex + 1, - ), - ), - ) - } - - private suspend fun emitBrokerStartPixel(brokerName: String) { - if (runType == MANUAL) { - pirPixelSender.reportManualScanBrokerStarted(brokerName) - } else { - pirPixelSender.reportScheduledScanBrokerStarted(brokerName) - } - repository.saveBrokerScanLog( - PirBrokerScanLog( - eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - brokerName = brokerName, - eventType = BROKER_STARTED, - ), - ) - } - - private suspend fun emitBrokerCompletePixel( - brokerName: String, - totalTimeMillis: Long, - isSuccess: Boolean, - ) { - if (runType == MANUAL) { - pirPixelSender.reportManualScanBrokerCompleted( - brokerName = brokerName, - totalTimeInMillis = totalTimeMillis, - isSuccess = isSuccess, - ) - } else { - pirPixelSender.reportScheduledScanBrokerCompleted( - brokerName = brokerName, - totalTimeInMillis = totalTimeMillis, - isSuccess = isSuccess, - ) - } - repository.saveBrokerScanLog( - PirBrokerScanLog( - eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - brokerName = brokerName, - eventType = if (isSuccess) BROKER_SUCCESS else BROKER_ERROR, - ), - ) - } - - private fun executeBrokerAction(state: State) { - val currentBroker = brokersToExecute[state.currentBrokerIndex] - if (state.currentActionIndex == currentBroker.actions.size) { - nextCommand( - BrokerCompleted(state, true), - ) - } else { - val actionToExecute = currentBroker.actions[state.currentActionIndex] - - timerJob.cancel() - timerJob += timerCoroutineScope.launch { - delay(60000) // 1 minute - // IF this timer completes, then timeout was reached - val currentState = commandsFlow.value.state - kotlin.runCatching { - val id = - brokersToExecute[currentState.currentBrokerIndex].actions[currentState.currentActionIndex].id - onError( - PirErrorReponse( - actionID = id, - message = "Local timeout", - ), - ) - } - } - - brokerActionProcessor.pushAction(submittedProfileQuery!!, actionToExecute) - } - } - - private fun cleanUpRunner() { - timerJob.cancel() - nextCommand(Idle) - commandsJob.cancel() - commandCoroutineScope.launch(dispatcherProvider.main()) { - detachedWebView?.destroy() - } - } - - override suspend fun stop() { - logcat { "PIR-RUNNER ($this): Stopping and resetting values" } - cleanUpRunner() - } - - override fun onSuccess(pirSuccessResponse: PirSuccessResponse) { - runBlocking(dispatcherProvider.main()) { - val lastState = commandsFlow.value.state - val currentBroker = brokersToExecute[lastState.currentBrokerIndex] - val currentAction = currentBroker.actions[lastState.currentActionIndex] - - if (pirSuccessResponse.actionID == currentAction.id) { - timerJob.cancel() - - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): onSuccess: $pirSuccessResponse" } - when (pirSuccessResponse) { - is NavigateResponse -> { - repository.saveNavigateResult( - currentBroker.brokerName ?: "", - pirSuccessResponse, - ) - nextCommand( - LoadUrl( - urlToLoad = pirSuccessResponse.response.url, - state = lastState, - ), - ) - } - - is ExtractedResponse -> { - runBlocking { - repository.saveExtractProfileResult( - currentBroker.brokerName ?: "", - pirSuccessResponse, - ) - } - nextCommand( - ExecuteBrokerAction( - state = lastState.copy( - currentActionIndex = lastState.currentActionIndex + 1, - ), - ), - ) - } - - else -> { - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Do nothing for $pirSuccessResponse" } - } - } - } else { - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Runner can't handle $pirSuccessResponse" } - } - } - } - - override fun onError(pirErrorReponse: PirErrorReponse) { - runBlocking(dispatcherProvider.main()) { - val lastState = commandsFlow.value.state - val currentBroker = brokersToExecute[lastState.currentBrokerIndex] - val currentAction = currentBroker.actions[lastState.currentActionIndex] - - if (pirErrorReponse.actionID == currentAction.id) { - timerJob.cancel() - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): onError: $pirErrorReponse" } - repository.saveErrorResult( - brokerName = currentBroker.brokerName ?: "", - actionType = currentAction.asActionType(), - error = pirErrorReponse, - ) - // If error happens we skip to next Broker as next steps will not make sense - nextCommand( - BrokerCompleted(lastState, isSuccess = false), - ) - } else { - logcat { "PIR-RUNNER (${this@RealPirActionsRunner}): Runner can't handle $pirErrorReponse" } - } - } - } - - sealed class Command(open val state: State) { - data object Idle : Command(State()) - data class HandleBroker( - override val state: State, - ) : Command(state) - - data class ExecuteBrokerAction( - override val state: State, - ) : Command(state) - - data class LoadUrl( - override val state: State, - val urlToLoad: String, - ) : Command(State()) - - data class BrokerCompleted( - override val state: State, - val isSuccess: Boolean, - ) : Command(state) - - data object CompleteExecution : Command(State()) - } - - data class State( - val currentBrokerIndex: Int = 0, - val currentActionIndex: Int = 0, - val brokerStartTime: Long = -1L, - ) - - companion object { - private const val DBP_INITIAL_URL = "dbp://blank" - } -} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/di/PirModule.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/di/PirModule.kt index e276d1de5a70..3245c0df5600 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/di/PirModule.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/di/PirModule.kt @@ -22,12 +22,14 @@ import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.service.DbpService import com.duckduckgo.pir.internal.store.PirDatabase import com.duckduckgo.pir.internal.store.PirRepository import com.duckduckgo.pir.internal.store.RealPirDataStore import com.duckduckgo.pir.internal.store.RealPirRepository import com.duckduckgo.pir.internal.store.db.BrokerDao import com.duckduckgo.pir.internal.store.db.BrokerJsonDao +import com.duckduckgo.pir.internal.store.db.OptOutResultsDao import com.duckduckgo.pir.internal.store.db.ScanLogDao import com.duckduckgo.pir.internal.store.db.ScanResultsDao import com.duckduckgo.pir.internal.store.db.UserProfileDao @@ -81,6 +83,12 @@ class PirModule { return database.scanLogDao() } + @SingleInstanceIn(AppScope::class) + @Provides + fun provideOptOutResultsDao(database: PirDatabase): OptOutResultsDao { + return database.optOutResultsDao() + } + @Provides @SingleInstanceIn(AppScope::class) fun providePirRepository( @@ -93,6 +101,8 @@ class PirModule { moshi: Moshi, userProfileDao: UserProfileDao, scanLogDao: ScanLogDao, + dbpService: DbpService, + outResultsDao: OptOutResultsDao, ): PirRepository = RealPirRepository( moshi, dispatcherProvider, @@ -103,5 +113,7 @@ class PirModule { scanResultsDao, userProfileDao, scanLogDao, + dbpService, + outResultsDao, ) } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirForegroundOptOutService.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirForegroundOptOutService.kt new file mode 100644 index 000000000000..bd2f3dffb330 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirForegroundOptOutService.kt @@ -0,0 +1,133 @@ +/* + * 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.internal.optout + +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Process +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.utils.notification.checkPermissionAndNotify +import com.duckduckgo.di.scopes.ServiceScope +import com.duckduckgo.pir.internal.R +import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity +import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_CHANNEL_ID +import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_ID_STATUS_COMPLETE +import dagger.android.AndroidInjection +import java.util.concurrent.Executors +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import logcat.AndroidLogcatLogger +import logcat.LogPriority +import logcat.LogcatLogger +import logcat.logcat + +@InjectWith(scope = ServiceScope::class) +class PirForegroundOptOutService : Service(), CoroutineScope by MainScope() { + @Inject + lateinit var pirOptOut: PirOptOut + + @Inject + lateinit var notificationManagerCompat: NotificationManagerCompat + + private val serviceDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + // TODO find correct place. + LogcatLogger.install(AndroidLogcatLogger(LogPriority.DEBUG)) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + logcat { "PIR-OPT-OUT: PIR service started on ${Process.myPid()} thread: ${Thread.currentThread().name}" } + val notification: Notification = + createNotification(getString(R.string.pirOptOutNotificationMessageInProgress)) + startForeground(1, notification) + + synchronized(this) { + launch(serviceDispatcher) { + async { + val brokers = intent?.getStringExtra(EXTRA_BROKER_TO_OPT_OUT) + + val result = if (!brokers.isNullOrEmpty()) { + pirOptOut.execute(listOf(brokers), this@PirForegroundOptOutService, this) + } else { + pirOptOut.executeForBrokersWithRecords(this@PirForegroundOptOutService, this) + } + + if (result.isSuccess) { + notificationManagerCompat.checkPermissionAndNotify( + applicationContext, + NOTIF_ID_STATUS_COMPLETE, + createNotification(getString(R.string.pirOptOutNotificationMessageComplete)), + ) + } + stopSelf() + }.await() + } + } + + logcat { "PIR-OPT-OUT: START_NOT_STICKY" } + return START_NOT_STICKY + } + + override fun onDestroy() { + logcat { "PIR-OPT-OUT: PIR service destroyed" } + pirOptOut.stop() + } + + private fun createNotification(message: String): Notification { + val notificationIntent = Intent( + this, + PirDevSettingsActivity::class.java, + ) + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Builder(this, NOTIF_CHANNEL_ID) + .setContentTitle(getString(R.string.pirOptOutNotificationTitle)) + .setContentText(message) + .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo) + .setContentIntent(pendingIntent) + .build() + } + + companion object { + internal const val EXTRA_BROKER_TO_OPT_OUT = "EXTRA_BROKER_TO_OPT_OUT" + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirOptOut.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirOptOut.kt new file mode 100644 index 000000000000..20656cc52737 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/optout/PirOptOut.kt @@ -0,0 +1,295 @@ +/* + * 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.internal.optout + +import android.content.Context +import android.webkit.WebView +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.callbacks.PirCallbacks +import com.duckduckgo.pir.internal.common.BrokerStepsParser +import com.duckduckgo.pir.internal.common.BrokerStepsParser.BrokerStep.OptOutStep +import com.duckduckgo.pir.internal.common.PirActionsRunner +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory.RunType.OPTOUT +import com.duckduckgo.pir.internal.common.PirJob +import com.duckduckgo.pir.internal.common.getMaximumParallelRunners +import com.duckduckgo.pir.internal.common.splitIntoParts +import com.duckduckgo.pir.internal.scripts.PirCssScriptLoader +import com.duckduckgo.pir.internal.scripts.models.Address +import com.duckduckgo.pir.internal.scripts.models.ProfileQuery +import com.duckduckgo.pir.internal.store.PirRepository +import com.duckduckgo.pir.internal.store.db.EventType +import com.duckduckgo.pir.internal.store.db.PirEventLog +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import logcat.logcat + +interface PirOptOut { + /** + * This method can be used to execute pir opt-out for a given list of [brokers] names. + * + * @param brokers List of broker names + * @param context Context in which we want to create the detached WebView from + */ + suspend fun execute( + brokers: List, + context: Context, + coroutineScope: CoroutineScope, + ): Result + + /** + * This method can be used to execute pir opt out for all active brokers where the user's profile has been identified as a record. + * + * @param context Context in which we want to create the detached WebView from + */ + suspend fun executeForBrokersWithRecords( + context: Context, + coroutineScope: CoroutineScope, + ): Result + + /** + * This method should only be used if we want to debug opt-out and pass in a specific [webView] which will allow us to see the run in action. + * Do not use this for non-debug scenarios. + * + * @param brokers - brokers in which we want to run the opt out flow + * @param webView - attached/visible WebView in which we will run the opt-out flow. + */ + suspend fun debugExecute( + brokers: List, + webView: WebView, + coroutineScope: CoroutineScope, + ): Result + + /** + * This method takes care of stopping the scan and cleaning up resources used. + */ + fun stop() +} + +@ContributesBinding( + scope = AppScope::class, + boundType = PirOptOut::class, +) +@SingleInstanceIn(AppScope::class) +class RealPirOptOut @Inject constructor( + private val repository: PirRepository, + private val brokerStepsParser: BrokerStepsParser, + private val pirCssScriptLoader: PirCssScriptLoader, + private val pirActionsRunnerFactory: PirActionsRunnerFactory, + private val currentTimeProvider: CurrentTimeProvider, + callbacks: PluginPoint, +) : PirOptOut, PirJob(callbacks) { + private var profileQuery: ProfileQuery = ProfileQuery( + firstName = "William", + lastName = "Smith", + city = "Chicago", + state = "IL", + addresses = listOf( + Address( + city = "Chicago", + state = "IL", + ), + ), + birthYear = 1993, + fullName = "William Smith", + age = 32, + deprecated = false, + ) + + private val runners: MutableList = mutableListOf() + private var maxWebViewCount = 1 + + override suspend fun debugExecute( + brokers: List, + webView: WebView, + coroutineScope: CoroutineScope, + ): Result { + onJobStarted(coroutineScope) + emitStartPixel() + if (runners.isNotEmpty()) { + cleanRunners() + runners.clear() + } + obtainProfile() + + logcat { "PIR-OPT-OUT: Running opt-out on profile: $profileQuery on ${Thread.currentThread().name}" } + + runners.add( + pirActionsRunnerFactory.createInstance( + webView.context, + pirCssScriptLoader.getScript(), + OPTOUT, + ), + ) + + // Start each runner on a subset of the broker steps + return runBlocking { + brokers.mapNotNull { broker -> + repository.getBrokerOptOutSteps(broker)?.run { + brokerStepsParser.parseStep(broker, this) + } + }.filter { + (it as OptOutStep).profilesToOptOut.isNotEmpty() + }.also { list -> + runners[0].startOn(webView, profileQuery, list) + } + + logcat { "PIR-OPT-OUT: Optout completed for all runners" } + emitCompletedPixel() + onJobCompleted() + Result.success(Unit) + } + } + + override suspend fun execute( + brokers: List, + context: Context, + coroutineScope: CoroutineScope, + ): Result { + onJobStarted(coroutineScope) + emitStartPixel() + runBlocking { + if (runners.isNotEmpty()) { + cleanRunners() + runners.clear() + } + obtainProfile() + } + + logcat { "PIR-OPT-OUT: Running opt-out on profile: $profileQuery on ${Thread.currentThread().name}" } + + val script = runBlocking { + pirCssScriptLoader.getScript() + } + + val coreCount = getMaximumParallelRunners() + maxWebViewCount = if (brokers.size <= coreCount) { + brokers.size + } else { + coreCount + } + logcat { "PIR-OPT-OUT: Attempting to create $maxWebViewCount parallel runners on ${Thread.currentThread().name}" } + + // Initiate runners + var createCount = 0 + while (createCount != maxWebViewCount) { + runners.add( + pirActionsRunnerFactory.createInstance( + context, + script, + OPTOUT, + ), + ) + createCount++ + } + + // Start each runner on a subset of the broker steps + return runBlocking { + brokers.mapNotNull { broker -> + repository.getBrokerOptOutSteps(broker)?.run { + brokerStepsParser.parseStep(broker, this) + } + }.filter { + (it as OptOutStep).profilesToOptOut.isNotEmpty() + }.splitIntoParts(maxWebViewCount) + .mapIndexed { index, part -> + async { + runners[index].start(profileQuery, part) + } + }.awaitAll() + + logcat { "PIR-OPT-OUT: Optout completed for all runners" } + emitCompletedPixel() + onJobCompleted() + Result.success(Unit) + } + } + + private suspend fun obtainProfile() { + repository.getUserProfiles().also { + if (it.isNotEmpty()) { + // Temporarily taking the first profile only for the PoC. In the reality, more than 1 should be allowed. + val storedProfile = it[0] + profileQuery = ProfileQuery( + firstName = storedProfile.userName.firstName, + lastName = storedProfile.userName.lastName, + city = storedProfile.addresses.city, + state = storedProfile.addresses.state, + addresses = listOf( + Address( + city = storedProfile.addresses.city, + state = storedProfile.addresses.state, + ), + ), + birthYear = storedProfile.birthYear, + fullName = storedProfile.userName.middleName?.run { + "${storedProfile.userName.firstName} $this ${storedProfile.userName.lastName}" + } + ?: "${storedProfile.userName.firstName} ${storedProfile.userName.lastName}", + age = LocalDate.now().year - storedProfile.birthYear, + deprecated = false, + ) + } + } + } + + override suspend fun executeForBrokersWithRecords( + context: Context, + coroutineScope: CoroutineScope, + ): Result { + val brokers = repository.getBrokersForOptOut(formOptOutOnly = true) + return execute(brokers, context, coroutineScope) + } + + private fun cleanRunners() { + runners.forEach { + it.stop() + } + } + + override fun stop() { + logcat { "PIR-OPT-OUT: Stopping all runners" } + cleanRunners() + onJobStopped() + } + + private suspend fun emitStartPixel() { + repository.saveScanLog( + PirEventLog( + eventTimeInMillis = currentTimeProvider.currentTimeMillis(), + eventType = EventType.MANUAL_OPTOUT_STARTED, + ), + ) + } + + private suspend fun emitCompletedPixel() { + repository.saveScanLog( + PirEventLog( + eventTimeInMillis = currentTimeProvider.currentTimeMillis(), + eventType = EventType.MANUAL_OPTOUT_COMPLETED, + ), + ) + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/NetworkInfoProvider.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/NetworkInfoProvider.kt new file mode 100644 index 000000000000..573344afcec4 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/NetworkInfoProvider.kt @@ -0,0 +1,51 @@ +/* + * 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.internal.pixels + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface NetworkInfoProvider { + fun getCurrentNetworkInfo(): String +} + +@ContributesBinding(AppScope::class) +class RealNetworkInfoProvider @Inject constructor( + private val context: Context, +) : NetworkInfoProvider { + private val connectivityManager: ConnectivityManager by lazy { context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } + + override fun getCurrentNetworkInfo(): String { + val activeNetwork = connectivityManager.activeNetwork + val activeNetworkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + + return if (activeNetworkCapabilities == null || !activeNetworkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + "NO_CONNECTION" + } else { + when { + activeNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WIFI" + activeNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "CELLULAR" + activeNetworkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ETHERNET" + else -> "NO_CONNECTION" + } + } + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixel.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixel.kt index e4ad8207edd5..5efa9d6d96af 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixel.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixel.kt @@ -69,6 +69,31 @@ enum class PirPixel( PIR_INTERNAL_SCHEDULED_SCAN_BROKER_COMPLETED( baseName = "pir_internal_scheduled-scan_broker_completed", type = Count, + ), + + PIR_INTERNAL_OPT_OUT_BROKER_STARTED( + baseName = "pir_internal_optout_broker_started", + type = Count, + ), + + PIR_INTERNAL_OPT_OUT_BROKER_COMPLETED( + baseName = "pir_internal_optout_broker_completed", + type = Count, + ), + + PIR_INTERNAL_OPT_OUT_RECORD_STARTED( + baseName = "pir_internal_optout_record_started", + type = Count, + ), + + PIR_INTERNAL_OPT_OUT_RECORD_COMPLETED( + baseName = "pir_internal_optout_record_completed", + type = Count, + ), + + PIR_INTERNAL_CPU_USAGE( + baseName = "pir_internal_cpu_usage", + type = Count, ), ; constructor( diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelInterceptor.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelInterceptor.kt index d5162b449680..102a38da4518 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelInterceptor.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelInterceptor.kt @@ -18,9 +18,11 @@ package com.duckduckgo.pir.internal.pixels import android.content.Context import android.os.PowerManager +import android.util.Base64 import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.store.PitTestingStore import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject import okhttp3.Interceptor @@ -34,6 +36,8 @@ import org.json.JSONObject class PirPixelInterceptor @Inject constructor( private val context: Context, private val appBuildConfig: AppBuildConfig, + private val networkInfoProvider: NetworkInfoProvider, + private val testingStore: PitTestingStore, ) : PixelInterceptorPlugin, Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() @@ -47,7 +51,11 @@ class PirPixelInterceptor @Inject constructor( .put("os", appBuildConfig.sdkInt) .put("batteryOptimizations", (!isIgnoringBatteryOptimizations()).toString()) .put("man", appBuildConfig.manufacturer) - .toString(), + .put("networkInfo", networkInfoProvider.getCurrentNetworkInfo()) + .put("testerId", testingStore.testerId ?: "UNKNOWN") + .toString().toByteArray().run { + Base64.encodeToString(this, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE) + }, ) .build() } else { @@ -70,7 +78,7 @@ class PirPixelInterceptor @Inject constructor( companion object { private const val KEY_METADATA = "metadata" - private const val PIXEL_PREFIX = "m_pir-internal" + private const val PIXEL_PREFIX = "pir_internal" private val EXCEPTIONS = emptyList() } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelSender.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelSender.kt index 6e26e52d33cf..a7936487461a 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelSender.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/pixels/PirPixelSender.kt @@ -18,10 +18,15 @@ package com.duckduckgo.pir.internal.pixels import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_CPU_USAGE import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_MANUAL_SCAN_BROKER_COMPLETED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_MANUAL_SCAN_BROKER_STARTED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_MANUAL_SCAN_COMPLETED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_MANUAL_SCAN_STARTED +import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_OPT_OUT_BROKER_COMPLETED +import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_OPT_OUT_BROKER_STARTED +import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_OPT_OUT_RECORD_COMPLETED +import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_OPT_OUT_RECORD_STARTED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_BROKER_COMPLETED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_BROKER_STARTED import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_COMPLETED @@ -29,6 +34,7 @@ import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_S import com.duckduckgo.pir.internal.pixels.PirPixel.PIR_INTERNAL_SCHEDULED_SCAN_STARTED import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import logcat.logcat interface PirPixelSender { // Manual Scan Pixels @@ -105,6 +111,60 @@ interface PirPixelSender { totalTimeInMillis: Long, isSuccess: Boolean, ) + + /** + * Tells us when the opt-out process is started for a broker + * + * @param brokerName for which the opt-out is started for + */ + fun reportBrokerOptOutStarted( + brokerName: String, + ) + + /** + * Tells us when the opt-out process is completed for a broker. This means that all records for the broker has been completed. + * + * @param brokerName for which the opt-out is started for + * @param totalTimeInMillis How long it took to complete the optout for the broker. + */ + fun reportBrokerOptOutCompleted( + brokerName: String, + totalTimeInMillis: Long, + ) + + /** + * Tells us whenever an opt-out is started for a specific record on a broker + * + * @param brokerName for which the opt-out is started for + * @param recordId for which the opt-out is started for + */ + fun reportRecordOptOutStarted( + brokerName: String, + recordId: String, + ) + + /** + * Tells us whenever an opt-out is completed - could mean that the opt-out for the record was successful or failed. + * + * @param brokerName for which the opt-out is for + * @param recordId for which the opt-out is started for + * @param totalTimeInMillis How long it took to complete the opt-out for the record. + * @param isSuccess - if result was not an error, it is a success. + */ + fun reportRecordOptOutCompleted( + brokerName: String, + recordId: String, + totalTimeInMillis: Long, + isSuccess: Boolean, + ) + + /** + * Sends a pixel when the CPU Usage threshold has been reached while executing + * PIR related work. + * + * @param averageCpuUsagePercent - average CPU usage percent + */ + fun sendCPUUsageAlert(averageCpuUsagePercent: Int) } @ContributesBinding(AppScope::class) @@ -122,17 +182,17 @@ class RealPirPixelSender @Inject constructor( totalBrokerFailed: Int, ) { val params = mapOf( - "totalTimeInMillis" to totalTimeInMillis.toString(), - "totalParallelWebViews" to totalParallelWebViews.toString(), - "totalBrokerSuccess" to totalBrokerSuccess.toString(), - "totalBrokerFailed" to totalBrokerFailed.toString(), + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + PARAM_KEY_WEBVIEW_COUNT to totalParallelWebViews.toString(), + PARAM_KEY_TOTAL_BROKER_SUCCESS to totalBrokerSuccess.toString(), + PARAM_KEY_TOTAL_BROKER_FAILED to totalBrokerFailed.toString(), ) fire(PIR_INTERNAL_MANUAL_SCAN_COMPLETED, params) } override fun reportManualScanBrokerStarted(brokerName: String) { val params = mapOf( - "brokerName" to brokerName, + PARAM_KEY_BROKER_NAME to brokerName, ) fire(PIR_INTERNAL_MANUAL_SCAN_BROKER_STARTED, params) } @@ -143,9 +203,9 @@ class RealPirPixelSender @Inject constructor( isSuccess: Boolean, ) { val params = mapOf( - "brokerName" to brokerName, - "totalTimeInMillis" to totalTimeInMillis.toString(), - "isSuccess" to isSuccess.toString(), + PARAM_KEY_BROKER_NAME to brokerName, + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + PARAM_KEY_SUCCESS to isSuccess.toString(), ) fire(PIR_INTERNAL_MANUAL_SCAN_BROKER_COMPLETED, params) } @@ -165,10 +225,10 @@ class RealPirPixelSender @Inject constructor( totalBrokerFailed: Int, ) { val params = mapOf( - "totalTimeInMillis" to totalTimeInMillis.toString(), - "totalParallelWebViews" to totalParallelWebViews.toString(), - "totalBrokerSuccess" to totalBrokerSuccess.toString(), - "totalBrokerFailed" to totalBrokerFailed.toString(), + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + PARAM_KEY_WEBVIEW_COUNT to totalParallelWebViews.toString(), + PARAM_KEY_TOTAL_BROKER_SUCCESS to totalBrokerSuccess.toString(), + PARAM_KEY_TOTAL_BROKER_FAILED to totalBrokerFailed.toString(), ) fire(PIR_INTERNAL_SCHEDULED_SCAN_COMPLETED, params) } @@ -179,26 +239,89 @@ class RealPirPixelSender @Inject constructor( isSuccess: Boolean, ) { val params = mapOf( - "brokerName" to brokerName, - "totalTimeInMillis" to totalTimeInMillis.toString(), - "isSuccess" to isSuccess.toString(), + PARAM_KEY_BROKER_NAME to brokerName, + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + PARAM_KEY_SUCCESS to isSuccess.toString(), ) fire(PIR_INTERNAL_SCHEDULED_SCAN_BROKER_COMPLETED, params) } override fun reportScheduledScanBrokerStarted(brokerName: String) { val params = mapOf( - "brokerName" to brokerName, + PARAM_KEY_BROKER_NAME to brokerName, ) fire(PIR_INTERNAL_SCHEDULED_SCAN_BROKER_STARTED, params) } + override fun reportRecordOptOutStarted( + brokerName: String, + recordId: String, + ) { + val params = mapOf( + PARAM_KEY_BROKER_NAME to brokerName, + PARAM_KEY_RECORD_ID to recordId, + ) + fire(PIR_INTERNAL_OPT_OUT_RECORD_STARTED, params) + } + + override fun reportRecordOptOutCompleted( + brokerName: String, + recordId: String, + totalTimeInMillis: Long, + isSuccess: Boolean, + ) { + val params = mapOf( + PARAM_KEY_BROKER_NAME to brokerName, + PARAM_KEY_RECORD_ID to recordId, + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + PARAM_KEY_SUCCESS to isSuccess.toString(), + ) + fire(PIR_INTERNAL_OPT_OUT_RECORD_COMPLETED, params) + } + + override fun reportBrokerOptOutStarted(brokerName: String) { + val params = mapOf( + PARAM_KEY_BROKER_NAME to brokerName, + ) + fire(PIR_INTERNAL_OPT_OUT_BROKER_STARTED, params) + } + + override fun reportBrokerOptOutCompleted( + brokerName: String, + totalTimeInMillis: Long, + ) { + val params = mapOf( + PARAM_KEY_BROKER_NAME to brokerName, + PARAM_KEY_TOTAL_TIME to totalTimeInMillis.toString(), + ) + fire(PIR_INTERNAL_OPT_OUT_BROKER_COMPLETED, params) + } + + override fun sendCPUUsageAlert(averageCpuUsagePercent: Int) { + val params = mapOf( + PARAM_KEY_CPU_USAGE to averageCpuUsagePercent.toString(), + ) + fire(PIR_INTERNAL_CPU_USAGE, params) + } + private fun fire( pixel: PirPixel, params: Map = emptyMap(), ) { pixel.getPixelNames().forEach { (pixelType, pixelName) -> + logcat { "PIR-LOGGING: $pixelName params: $params" } pixelSender.fire(pixelName = pixelName, type = pixelType, parameters = params) } } + + companion object { + private const val PARAM_KEY_BROKER_NAME = "brokerName" + private const val PARAM_KEY_TOTAL_TIME = "totalTimeInMillis" + private const val PARAM_KEY_SUCCESS = "isSuccess" + private const val PARAM_KEY_WEBVIEW_COUNT = "totalParallelWebViews" + private const val PARAM_KEY_TOTAL_BROKER_SUCCESS = "totalBrokerSuccess" + private const val PARAM_KEY_TOTAL_BROKER_FAILED = "totalBrokerFailed" + private const val PARAM_KEY_RECORD_ID = "recordId" + private const val PARAM_KEY_CPU_USAGE = "cpuUsage" + } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirForegroundScanService.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirForegroundScanService.kt similarity index 95% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirForegroundScanService.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirForegroundScanService.kt index 8405d7daaf26..e020f7af59ce 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirForegroundScanService.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirForegroundScanService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.service +package com.duckduckgo.pir.internal.scan import android.app.Notification import android.app.PendingIntent @@ -28,8 +28,7 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.common.utils.notification.checkPermissionAndNotify import com.duckduckgo.di.scopes.ServiceScope import com.duckduckgo.pir.internal.R -import com.duckduckgo.pir.internal.scan.PirScan -import com.duckduckgo.pir.internal.scan.PirScan.RunType.MANUAL +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory.RunType.MANUAL import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_CHANNEL_ID import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_ID_STATUS_COMPLETE @@ -80,7 +79,7 @@ class PirForegroundScanService : Service(), CoroutineScope by MainScope() { synchronized(this) { launch(serviceDispatcher) { async { - val result = pirScan.execute(supportedBrokers, this@PirForegroundScanService, MANUAL) + val result = pirScan.execute(supportedBrokers, this@PirForegroundScanService, MANUAL, this) if (result.isSuccess) { notificationManagerCompat.checkPermissionAndNotify( applicationContext, diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirRemoteWorkerService.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirRemoteWorkerService.kt similarity index 95% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirRemoteWorkerService.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirRemoteWorkerService.kt index 82da1d4357a3..54365d4f8c34 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirRemoteWorkerService.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirRemoteWorkerService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.service +package com.duckduckgo.pir.internal.scan import android.os.Process import androidx.work.multiprocess.RemoteWorkerService diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScan.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScan.kt index 78c373efe2c3..5abb4133d9ae 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScan.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScan.kt @@ -18,20 +18,28 @@ package com.duckduckgo.pir.internal.scan import android.content.Context import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.pir.internal.component.PirActionsRunner -import com.duckduckgo.pir.internal.component.PirActionsRunnerFactory +import com.duckduckgo.pir.internal.callbacks.PirCallbacks +import com.duckduckgo.pir.internal.common.BrokerStepsParser +import com.duckduckgo.pir.internal.common.PirActionsRunner +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory.RunType +import com.duckduckgo.pir.internal.common.PirJob +import com.duckduckgo.pir.internal.common.getMaximumParallelRunners +import com.duckduckgo.pir.internal.common.splitIntoParts import com.duckduckgo.pir.internal.pixels.PirPixelSender -import com.duckduckgo.pir.internal.scan.PirScan.RunType import com.duckduckgo.pir.internal.scripts.PirCssScriptLoader +import com.duckduckgo.pir.internal.scripts.models.Address import com.duckduckgo.pir.internal.scripts.models.ProfileQuery import com.duckduckgo.pir.internal.store.PirRepository -import com.duckduckgo.pir.internal.store.db.PirScanLog -import com.duckduckgo.pir.internal.store.db.ScanEventType +import com.duckduckgo.pir.internal.store.db.EventType +import com.duckduckgo.pir.internal.store.db.PirEventLog import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn -import java.io.File +import java.time.LocalDate import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking @@ -52,6 +60,7 @@ interface PirScan { brokers: List, context: Context, runType: RunType, + coroutineScope: CoroutineScope, ): Result /** @@ -62,17 +71,13 @@ interface PirScan { suspend fun executeAllBrokers( context: Context, runType: RunType, + coroutineScope: CoroutineScope, ): Result /** * This method takes care of stopping the scan and cleaning up resources used. */ fun stop() - - enum class RunType { - MANUAL, - SCHEDULED, - } } @ContributesBinding( @@ -87,17 +92,23 @@ class RealPirScan @Inject constructor( private val pirActionsRunnerFactory: PirActionsRunnerFactory, private val pixelSender: PirPixelSender, private val currentTimeProvider: CurrentTimeProvider, -) : PirScan { + callbacks: PluginPoint, +) : PirScan, PirJob(callbacks) { private var profileQuery: ProfileQuery = ProfileQuery( firstName = "William", lastName = "Smith", city = "Chicago", state = "IL", - addresses = listOf(), + addresses = listOf( + Address( + city = "Chicago", + state = "IL", + ), + ), birthYear = 1993, fullName = "William Smith", - age = 34, + age = 32, deprecated = false, ) @@ -108,16 +119,20 @@ class RealPirScan @Inject constructor( brokers: List, context: Context, runType: RunType, + coroutineScope: CoroutineScope, ): Result { + onJobStarted(coroutineScope) val startTimeMillis = currentTimeProvider.currentTimeMillis() emitScanStartPixel(runType) // Clean up previous run's results runBlocking { if (runners.isNotEmpty()) { - stop() + runners.forEach { + it.stop() + } runners.clear() } - repository.deleteAllResults() + repository.deleteAllScanResults() repository.getUserProfiles().also { if (it.isNotEmpty()) { // Temporarily taking the first profile only for the PoC. In the reality, more than 1 should be allowed. @@ -127,13 +142,18 @@ class RealPirScan @Inject constructor( lastName = storedProfile.userName.lastName, city = storedProfile.addresses.city, state = storedProfile.addresses.state, - addresses = listOf(), + addresses = listOf( + Address( + city = storedProfile.addresses.city, + state = storedProfile.addresses.state, + ), + ), birthYear = storedProfile.birthYear, fullName = storedProfile.userName.middleName?.run { "${storedProfile.userName.firstName} $this ${storedProfile.userName.lastName}" } ?: "${storedProfile.userName.firstName} ${storedProfile.userName.lastName}", - age = storedProfile.age, + age = LocalDate.now().year - storedProfile.birthYear, deprecated = false, ) } @@ -145,7 +165,12 @@ class RealPirScan @Inject constructor( pirCssScriptLoader.getScript() } - maxWebViewCount = getMaximumParallelRunners() + val coreCount = getMaximumParallelRunners() + maxWebViewCount = if (brokers.size <= coreCount) { + brokers.size + } else { + coreCount + } logcat { "PIR-SCAN: Attempting to create $maxWebViewCount parallel runners on ${Thread.currentThread().name}" } // Initiate runners @@ -177,63 +202,47 @@ class RealPirScan @Inject constructor( logcat { "PIR-SCAN: Scan completed for all runners" } emitScanCompletedPixel( runType, - startTimeMillis - currentTimeProvider.currentTimeMillis(), + currentTimeProvider.currentTimeMillis() - startTimeMillis, maxWebViewCount, ) + onJobCompleted() Result.success(Unit) } } - private fun getMaximumParallelRunners(): Int { - return try { - // Get the directory containing CPU info - val cpuDir = File("/sys/devices/system/cpu/") - // Filter folders matching the pattern "cpu[0-9]+" - val cpuFiles = cpuDir.listFiles { file -> file.name.matches(Regex("cpu[0-9]+")) } - cpuFiles?.size ?: Runtime.getRuntime().availableProcessors() - } catch (e: Exception) { - // In case of an error, fall back to availableProcessors - Runtime.getRuntime().availableProcessors() - } - } - - private fun List.splitIntoParts(parts: Int): List> { - val chunkSize = (this.size + parts - 1) / parts // Ensure rounding up - return this.chunked(chunkSize) - } - override suspend fun executeAllBrokers( context: Context, runType: RunType, + coroutineScope: CoroutineScope, ): Result { - val brokers = runBlocking { - repository.getAllBrokersForScan() - } - return execute(brokers, context, runType) + val brokers = repository.getAllBrokersForScan() + return execute(brokers, context, runType, coroutineScope) } override fun stop() { logcat { "PIR-SCAN: Stopping all runners" } runners.forEach { - runBlocking { it.stop() } + it.stop() } + runners.clear() + onJobStopped() } private suspend fun emitScanStartPixel(runType: RunType) { if (runType == RunType.MANUAL) { pixelSender.reportManualScanStarted() repository.saveScanLog( - PirScanLog( + PirEventLog( eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - eventType = ScanEventType.MANUAL_SCAN_STARTED, + eventType = EventType.MANUAL_SCAN_STARTED, ), ) } else { pixelSender.reportScheduledScanStarted() repository.saveScanLog( - PirScanLog( + PirEventLog( eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - eventType = ScanEventType.SCHEDULED_SCAN_STARTED, + eventType = EventType.SCHEDULED_SCAN_STARTED, ), ) } @@ -252,9 +261,9 @@ class RealPirScan @Inject constructor( totalBrokerFailed = repository.getErrorResultsCount(), ) repository.saveScanLog( - PirScanLog( + PirEventLog( eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - eventType = ScanEventType.MANUAL_SCAN_COMPLETED, + eventType = EventType.MANUAL_SCAN_COMPLETED, ), ) } else { @@ -265,9 +274,9 @@ class RealPirScan @Inject constructor( totalBrokerFailed = repository.getErrorResultsCount(), ) repository.saveScanLog( - PirScanLog( + PirEventLog( eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - eventType = ScanEventType.SCHEDULED_SCAN_COMPLETED, + eventType = EventType.SCHEDULED_SCAN_COMPLETED, ), ) } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirScheduledScanWorker.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScheduledScanWorker.kt similarity index 82% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirScheduledScanWorker.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScheduledScanWorker.kt index e6e2946cb9a6..9aadb756a728 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirScheduledScanWorker.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirScheduledScanWorker.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.service +package com.duckduckgo.pir.internal.scan import android.content.Context import android.os.Process @@ -23,9 +23,10 @@ import androidx.work.multiprocess.RemoteCoroutineWorker import com.duckduckgo.anvil.annotations.ContributesWorker import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.pir.internal.scan.PirScan -import com.duckduckgo.pir.internal.scan.PirScan.RunType.SCHEDULED +import com.duckduckgo.pir.internal.common.PirActionsRunnerFactory.RunType.SCHEDULED import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import logcat.logcat @ContributesWorker(AppScope::class) @@ -39,9 +40,12 @@ class PirScheduledScanRemoteWorker( @Inject lateinit var dispatcherProvider: DispatcherProvider + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(dispatcherProvider.io() + serviceJob) + override suspend fun doRemoteWork(): Result { logcat { "PIR-WORKER ($this}: doRemoteWork ${Process.myPid()}" } - val result = pirScan.execute(supportedBrokers, context.applicationContext, SCHEDULED) + val result = pirScan.execute(supportedBrokers, context.applicationContext, SCHEDULED, serviceScope) return if (result.isSuccess) { logcat { "PIR-WORKER ($this}: Successfully completed!" } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirSupportedBrokers.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirSupportedBrokers.kt similarity index 97% rename from pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirSupportedBrokers.kt rename to pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirSupportedBrokers.kt index 7a65e562fbdf..4c446ec0b4d9 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/PirSupportedBrokers.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scan/PirSupportedBrokers.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.duckduckgo.pir.internal.service +package com.duckduckgo.pir.internal.scan internal val supportedBrokers = listOf( "AdvancedBackgroundChecks", diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/BrokerActionProcessor.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/BrokerActionProcessor.kt index 8e2990ef66ef..d4ec5dd6e2b2 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/BrokerActionProcessor.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/BrokerActionProcessor.kt @@ -38,7 +38,6 @@ import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.FillFormRes import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.GetCaptchaInfoResponse import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.NavigateResponse import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.SolveCaptchaResponse -import com.duckduckgo.pir.internal.scripts.models.ProfileQuery import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory @@ -59,11 +58,11 @@ interface BrokerActionProcessor { ) /** - * Executes the [action] for the given [profileQuery] + * Executes the [action] for the given [profileQuery] and/or [extractedProfile] */ fun pushAction( - profileQuery: ProfileQuery, action: BrokerAction, + requestParamsData: PirScriptRequestData, ) interface ActionResultListener { @@ -88,7 +87,7 @@ class RealBrokerActionProcessor( .withSubtype(BrokerAction.Click::class.java, "click") .withSubtype(BrokerAction.FillForm::class.java, "fillForm") .withSubtype(BrokerAction.Navigate::class.java, "navigate") - .withSubtype(BrokerAction.GetCaptchInfo::class.java, "getCaptchaInfo") + .withSubtype(BrokerAction.GetCaptchaInfo::class.java, "getCaptchaInfo") .withSubtype(BrokerAction.SolveCaptcha::class.java, "solveCaptcha") .withSubtype(BrokerAction.EmailConfirmation::class.java, "emailConfirmation"), ).add(KotlinJsonAdapterFactory()) @@ -131,8 +130,8 @@ class RealBrokerActionProcessor( } override fun pushAction( - profileQuery: ProfileQuery, action: BrokerAction, + requestParamsData: PirScriptRequestData, ) { logcat { "PIR-CSS: pushAction action: $action" } @@ -148,9 +147,7 @@ class RealBrokerActionProcessor( PirScriptRequestParams( state = ActionRequest( action = action, - data = UserProfile( - userProfile = profileQuery, - ), + data = requestParamsData, ), ), ).run { diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/BrokerAction.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/BrokerAction.kt index d6fa00c6078e..c067bbdf97ed 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/BrokerAction.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/BrokerAction.kt @@ -21,7 +21,7 @@ import com.duckduckgo.pir.internal.scripts.models.BrokerAction.EmailConfirmation import com.duckduckgo.pir.internal.scripts.models.BrokerAction.Expectation import com.duckduckgo.pir.internal.scripts.models.BrokerAction.Extract import com.duckduckgo.pir.internal.scripts.models.BrokerAction.FillForm -import com.duckduckgo.pir.internal.scripts.models.BrokerAction.GetCaptchInfo +import com.duckduckgo.pir.internal.scripts.models.BrokerAction.GetCaptchaInfo import com.duckduckgo.pir.internal.scripts.models.BrokerAction.Navigate import com.duckduckgo.pir.internal.scripts.models.BrokerAction.SolveCaptcha import com.duckduckgo.pir.internal.scripts.models.DataSource.USER_PROFILE @@ -47,41 +47,18 @@ sealed class BrokerAction( val selector: String, val noResultsSelector: String?, val profile: ExtractProfileSelectors, - ) : BrokerAction(id) { - data class ExtractProfileSelectors( - val name: ProfileSelector?, - val alternativeNamesList: ProfileSelector?, - val age: ProfileSelector?, - val addressFull: ProfileSelector?, - val addressFullList: ProfileSelector?, - val addressCityState: ProfileSelector?, - val addressCityStateList: ProfileSelector?, - val phone: ProfileSelector?, - val phoneList: ProfileSelector?, - val relativesList: ProfileSelector?, - val profileUrl: ProfileSelector?, - val reportedId: ProfileSelector?, - ) - - data class ProfileSelector( - val selector: String?, - val findElements: Boolean?, - val beforeText: String?, - val afterText: String?, - val separator: String?, - val identifierType: String?, - val identifier: String?, - ) - } + ) : BrokerAction(id) data class FillForm( override val id: String, - override val needsEmail: Boolean = false, + override val dataSource: DataSource? = DataSource.EXTRACTED_PROFILE, val elements: List, val selector: String, - ) : BrokerAction(id) + ) : BrokerAction(id) { + override val needsEmail: Boolean = elements.any { it.type == "email" } + } - data class GetCaptchInfo( + data class GetCaptchaInfo( override val id: String, val selector: String, ) : BrokerAction(id) @@ -95,7 +72,19 @@ sealed class BrokerAction( override val id: String, val elements: List, val selector: String?, - ) : BrokerAction(id) + val choice: List = emptyList(), + ) : BrokerAction(id) { + data class Choice( + val condition: Condition, + val elements: List, + ) + + data class Condition( + val left: String, + val operation: String, + val right: String, + ) + } data class Expectation( override val id: String, @@ -104,7 +93,9 @@ sealed class BrokerAction( data class ExpectationSelector( val type: String, val selector: String, - val expect: String, + val expect: String?, + val parent: String?, + val failSilently: Boolean?, ) } @@ -114,13 +105,48 @@ sealed class BrokerAction( ) : BrokerAction(id) } +data class ExtractProfileSelectors( + val name: ProfileSelector?, + val alternativeNamesList: ProfileSelector?, + val age: ProfileSelector?, + val addressFull: ProfileSelector?, + val addressFullList: ProfileSelector?, + val addressCityState: ProfileSelector?, + val addressCityStateList: ProfileSelector?, + val phone: ProfileSelector?, + val phoneList: ProfileSelector?, + val relativesList: ProfileSelector?, + val profileUrl: ProfileSelector?, + val reportedId: ProfileSelector?, +) + +data class ProfileSelector( + val selector: String?, + val findElements: Boolean?, + val beforeText: String?, + val afterText: String?, + val separator: String?, + val identifierType: String?, + val identifier: String?, +) + data class ElementSelector( val type: String, val selector: String, + val parent: ParentElement?, + val multiple: Boolean?, val min: String?, val max: String?, val failSilently: Boolean?, - val multiple: Boolean?, +) + +data class ParentElement( + val profileMatch: ProfileMatch, +) + +data class ProfileMatch( + val selector: String, + val profile: ExtractProfileSelectors, ) enum class DataSource { @@ -140,7 +166,7 @@ fun BrokerAction.asActionType(): String { is Expectation -> "expectation" is Click -> "click" is FillForm -> "fillForm" - is GetCaptchInfo -> "getCaptchaInfo" + is GetCaptchaInfo -> "getCaptchaInfo" is SolveCaptcha -> "solveCaptcha" is EmailConfirmation -> "emailConfirmation" } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptRequestParams.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptRequestParams.kt index 717476f37b07..adf0e932ebbc 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptRequestParams.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptRequestParams.kt @@ -31,7 +31,15 @@ sealed class PirScriptRequestData { ) : PirScriptRequestData() data class UserProfile( - val userProfile: ProfileQuery, - val extractedProfile: ExtractedProfile? = null, + val userProfile: ProfileQuery? = null, + val extractedProfile: ExtractedProfileParams? = null, ) : PirScriptRequestData() } + +data class ExtractedProfileParams( + val id: Int? = null, + val name: String? = null, + val profileUrl: String? = null, + val email: String? = null, + val fullName: String? = null, +) diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptResponseParams.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptResponseParams.kt index 0fce7d2ac14b..4ef1fc311ebb 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptResponseParams.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/scripts/models/PirScriptResponseParams.kt @@ -81,7 +81,10 @@ sealed class PirSuccessResponse( val response: ResponseData? = null, ) : PirSuccessResponse(actionID, actionType) { data class ResponseData( - val callback: String, + val callback: CallbackData, + ) + data class CallbackData( + val eval: String, ) } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/DbpService.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/DbpService.kt index 3c8f7c624f52..6e022811e83e 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/DbpService.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/service/DbpService.kt @@ -21,25 +21,56 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.moshi.Json import okhttp3.ResponseBody import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query import retrofit2.http.Streaming @ContributesServiceApi(AppScope::class) interface DbpService { @AuthRequired - @GET("$BASE_URL/main_config.json") + @GET("$BASE_URL/remote/v0/main_config.json") suspend fun getMainConfig( @Header("If-None-Match") etag: String?, ): Response @AuthRequired - @GET("$BASE_URL?name=all.zip&type=spec") + @GET("$BASE_URL/remote/v0?name=all.zip&type=spec") @Streaming suspend fun getBrokerJsonFiles(): ResponseBody + @AuthRequired + @GET("$BASE_URL/em/v0/generate") + suspend fun getEmail( + @Query("dataBroker") dataBrokerUrl: String, + @Query("attemptId") attemptId: String? = null, + ): PirGetEmailResponse + + @AuthRequired + @GET("$BASE_URL/em/v0/links") + suspend fun getEmailStatus( + @Query("e") emailAddress: String, + @Query("attemptId") attemptId: String? = null, + ): PirGetEmailStatusResponse + + @AuthRequired + @POST("$BASE_URL/captcha/v0/submit") + suspend fun submitCaptchaInformation( + @Body body: PirStartCaptchaSolutionBody, + @Query("attemptId") attemptId: String? = null, + ): PirStartCaptchaSolutionResponse + + @AuthRequired + @GET("$BASE_URL/captcha/v0/result") + suspend fun getCaptchaSolution( + @Query("transactionId") transactionId: String, + @Query("attemptId") attemptId: String? = null, + ): PirGetCaptchaSolutionResponse + companion object { - private const val BASE_URL = "https://dbp.duckduckgo.com/dbp/remote/v0" + private const val BASE_URL = "https://dbp.duckduckgo.com/dbp" } data class PirMainConfig( @@ -74,4 +105,46 @@ interface DbpService { val maintenanceScan: Int, val maxAttempts: Int?, ) + + data class PirGetEmailResponse( + val emailAddress: String, + val pattern: String, + ) + + data class PirGetEmailStatusResponse( + val link: String?, + val status: String, + ) + + data class PirStartCaptchaSolutionBody( + val siteKey: String, + val url: String, + val type: String, + val backend: String? = null, + ) + + data class PirStartCaptchaSolutionResponse( + val message: String, + val transactionId: String, + ) + + data class PirGetCaptchaSolutionResponse( + val message: String, + val data: String, + val meta: CaptchaSolutionMeta, + ) + + data class CaptchaSolutionMeta( + val backends: Map, + val type: String, + val lastUpdated: Float, + val lastBackend: String, + val timeToSolution: Float, + ) + + data class CaptchaSolutionBackend( + val solveAttempts: Int, + val pollAttempts: Int, + val error: Int, + ) } 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 new file mode 100644 index 000000000000..cc54c0744e69 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevOptOutActivity.kt @@ -0,0 +1,147 @@ +/* + * 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.internal.settings + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams +import com.duckduckgo.pir.internal.databinding.ActivityPirInternalOptoutBinding +import com.duckduckgo.pir.internal.optout.PirForegroundOptOutService +import com.duckduckgo.pir.internal.optout.PirForegroundOptOutService.Companion.EXTRA_BROKER_TO_OPT_OUT +import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_ID_STATUS_COMPLETE +import com.duckduckgo.pir.internal.store.PirRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(PirDevOptOutScreenNoParams::class) +class PirDevOptOutActivity : DuckDuckGoActivity() { + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var notificationManagerCompat: NotificationManagerCompat + + @Inject + lateinit var repository: PirRepository + + private lateinit var optOutAdapter: ArrayAdapter + private lateinit var dropDownAdapter: ArrayAdapter + private val binding: ActivityPirInternalOptoutBinding by viewBinding() + private val brokerOptions = mutableListOf() + private var selectedBroker: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupToolbar(binding.toolbar) + setupViews() + bindViews() + } + + private fun setupViews() { + optOutAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1) + dropDownAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item) + binding.optOutList.adapter = optOutAdapter + binding.optOut.setOnClickListener { + if (selectedBroker != null) { + notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) + Intent(this, PirForegroundOptOutService::class.java).apply { + putExtra(EXTRA_BROKER_TO_OPT_OUT, selectedBroker) + }.also { + startForegroundService(it) + } + } + } + + binding.optOutDebug.setOnClickListener { + notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) + if (selectedBroker != null) { + globalActivityStarter.start( + this, + PirDebugWebViewResultsScreenParams(listOf(selectedBroker!!)), + ) + } + } + + binding.debugForceKill.setOnClickListener { + stopService(Intent(this, PirForegroundOptOutService::class.java)) + lifecycleScope.launch { + repository.deleteAllOptOutData() + } + notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) + } + + binding.viewResults.setOnClickListener { + globalActivityStarter.start(this, PirResultsScreenParams.PirOptOutResultsScreen) + } + + binding.optOutBrokers.adapter = dropDownAdapter + dropDownAdapter.addAll(brokerOptions) + + binding.optOutBrokers.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + selectedBroker = brokerOptions[position] + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun bindViews() { + lifecycleScope.launch { + repository.getBrokersForOptOut(formOptOutOnly = true).also { + brokerOptions.addAll(it) + dropDownAdapter.clear() + dropDownAdapter.addAll(brokerOptions) + } + } + repository.getAllSuccessfullySubmittedOptOutFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { optOuts -> + optOutAdapter.clear() + optOutAdapter.addAll( + optOuts.map { + "${it.value} - ${it.key}" + }, + ) + } + .launchIn(lifecycleScope) + } +} + +object PirDevOptOutScreenNoParams : ActivityParams 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 new file mode 100644 index 000000000000..5f9d8858d8ba --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevScanActivity.kt @@ -0,0 +1,266 @@ +/* + * 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.internal.settings + +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.os.Process +import android.widget.Toast +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.multiprocess.RemoteListenableWorker +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams +import com.duckduckgo.pir.internal.R +import com.duckduckgo.pir.internal.databinding.ActivityPirInternalScanBinding +import com.duckduckgo.pir.internal.pixels.PirPixelSender +import com.duckduckgo.pir.internal.scan.PirForegroundScanService +import com.duckduckgo.pir.internal.scan.PirRemoteWorkerService +import com.duckduckgo.pir.internal.scan.PirScheduledScanRemoteWorker +import com.duckduckgo.pir.internal.scan.PirScheduledScanRemoteWorker.Companion.TAG_SCHEDULED_SCAN +import com.duckduckgo.pir.internal.settings.PirDevSettingsActivity.Companion.NOTIF_ID_STATUS_COMPLETE +import com.duckduckgo.pir.internal.store.PirRepository +import com.duckduckgo.pir.internal.store.PirRepository.ScanResult +import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ErrorResult +import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ExtractedProfileResult +import com.duckduckgo.pir.internal.store.db.Address +import com.duckduckgo.pir.internal.store.db.EventType +import com.duckduckgo.pir.internal.store.db.PirEventLog +import com.duckduckgo.pir.internal.store.db.UserName +import com.duckduckgo.pir.internal.store.db.UserProfile +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import logcat.logcat + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(PirDevScanScreenNoParams::class) +class PirDevScanActivity : DuckDuckGoActivity() { + @Inject + lateinit var repository: PirRepository + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + @Inject + lateinit var notificationManagerCompat: NotificationManagerCompat + + @Inject + lateinit var globalActivityStarter: GlobalActivityStarter + + @Inject + lateinit var workManager: WorkManager + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + @Inject + lateinit var pixelSender: PirPixelSender + + @Inject + lateinit var currentTimeProvider: CurrentTimeProvider + + private val binding: ActivityPirInternalScanBinding by viewBinding() + private val recordStringBuilder = StringBuilder() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupToolbar(binding.toolbar) + setupViews() + bindViews() + } + + private fun bindViews() { + repository.getAllScanResultsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + render(it) + } + .launchIn(lifecycleScope) + + repository.getTotalScannedBrokersFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { + binding.statusSitesScanned.text = getString(R.string.pirStatsStatusScanned, it) + } + .launchIn(lifecycleScope) + } + + private fun render(results: List) { + val allExtracted = results.filterIsInstance() + val allError = results.filterIsInstance() + val brokersWithRecords = allExtracted.filter { + it.extractResults.isNotEmpty() && it.extractResults.any { result -> + result.result + } + } + val brokersWithRecordsCount = brokersWithRecords.size + + val brokersWithNoRecords = allExtracted.filter { + it.extractResults.isEmpty() || it.extractResults.none { result -> + result.result + } + }.size + allError.size + + val totalRecordCount = brokersWithRecords.sumOf { + it.extractResults.filter { result -> result.result }.size + } + + with(binding) { + this.statusTotalRecords.text = + getString(R.string.pirStatsStatusRecords, totalRecordCount) + this.statusTotalBrokersFound.text = + getString(R.string.pirStatsStatusBrokerFound, brokersWithRecordsCount) + this.statusTotalAllClear.text = + getString(R.string.pirStatsStatusAllClear, brokersWithNoRecords) + recordStringBuilder.clear() + + recordStringBuilder.append("\nRecords found:\n") + brokersWithRecords.forEach { broker -> + val recordCount = broker.extractResults.filter { it.result }.size + recordStringBuilder.append("${broker.brokerName} - records: $recordCount\n") + } + this.records.text = recordStringBuilder.toString() + } + } + + private fun setupViews() { + binding.debugRunScan.setOnClickListener { + notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) + logcat { "PIR-SCAN: Attempting to start PirForegroundScanService from ${Process.myPid()}" } + lifecycleScope.launch { + if (useUserInput()) { + repository.replaceUserProfile( + UserProfile( + userName = UserName( + firstName = binding.profileFirstName.text, + middleName = binding.profileMiddleName.text.ifBlank { + null + }, + lastName = binding.profileLastName.text, + ), + addresses = Address( + city = binding.profileCity.text, + state = binding.profileState.text, + ), + birthYear = binding.profileBirthYear.text.toInt(), + ), + ) + } + } + startForegroundService(Intent(this, PirForegroundScanService::class.java)) + globalActivityStarter.start(this, PirResultsScreenParams.PirScanResultsScreen) + } + + binding.debugForceKill.setOnClickListener { + stopService(Intent(this, PirForegroundScanService::class.java)) + lifecycleScope.launch(dispatcherProvider.io()) { + repository.deleteAllScanResults() + repository.deleteAllUserProfiles() + repository.deleteAllLogs() + } + notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) + workManager.cancelUniqueWork(TAG_SCHEDULED_SCAN) + stopService(Intent(this, PirRemoteWorkerService::class.java)) + } + + binding.viewResults.setOnClickListener { + globalActivityStarter.start(this, PirResultsScreenParams.PirScanResultsScreen) + } + + binding.scheduleScan.setOnClickListener { + schedulePeriodicScan() + Toast.makeText(this, getString(R.string.pirMessageSchedule), Toast.LENGTH_SHORT).show() + } + } + + private fun schedulePeriodicScan() { + logcat { "PIR-SCHEDULED: Scheduling periodic scan appId: ${appBuildConfig.applicationId}" } + + val constraints = Constraints.Builder() + .setRequiresCharging(true) + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicWorkRequest = + PeriodicWorkRequest.Builder( + PirScheduledScanRemoteWorker::class.java, + 12, + TimeUnit.HOURS, + ) + .boundToPirProcess(appBuildConfig.applicationId) + .setConstraints(constraints) + .setInitialDelay(1, TimeUnit.MINUTES) + .build() + + pixelSender.reportScheduledScanScheduled() + lifecycleScope.launch { + repository.saveScanLog( + PirEventLog( + eventTimeInMillis = currentTimeProvider.currentTimeMillis(), + eventType = EventType.SCHEDULED_SCAN_SCHEDULED, + ), + ) + } + + workManager.enqueueUniquePeriodicWork( + TAG_SCHEDULED_SCAN, + ExistingPeriodicWorkPolicy.UPDATE, + periodicWorkRequest, + ) + } + + private fun PeriodicWorkRequest.Builder.boundToPirProcess(applicationId: String): PeriodicWorkRequest.Builder { + val componentName = ComponentName(applicationId, PirRemoteWorkerService::class.java.name) + val data = Data.Builder() + .putString(RemoteListenableWorker.ARGUMENT_PACKAGE_NAME, componentName.packageName) + .putString(RemoteListenableWorker.ARGUMENT_CLASS_NAME, componentName.className) + .build() + + return this.setInputData(data) + } + + private fun useUserInput(): Boolean { + return binding.profileFirstName.text.isNotBlank() && + binding.profileLastName.text.isNotBlank() && + binding.profileCity.text.isNotBlank() && + binding.profileState.text.isNotBlank() && + binding.profileBirthYear.text.isNotBlank() + } +} + +object PirDevScanScreenNoParams : ActivityParams diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsActivity.kt index bf5881695ba7..639cb056e212 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsActivity.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirDevSettingsActivity.kt @@ -18,83 +18,41 @@ package com.duckduckgo.pir.internal.settings import android.app.NotificationChannel import android.app.NotificationManager -import android.content.ComponentName -import android.content.Intent +import android.content.ClipData +import android.content.ClipboardManager import android.os.Bundle -import android.os.Process import android.widget.Toast -import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Observer -import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.work.Constraints -import androidx.work.Data -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import androidx.work.multiprocess.RemoteListenableWorker import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams -import com.duckduckgo.pir.internal.R import com.duckduckgo.pir.internal.databinding.ActivityPirInternalSettingsBinding -import com.duckduckgo.pir.internal.pixels.PirPixelSender -import com.duckduckgo.pir.internal.service.PirForegroundScanService -import com.duckduckgo.pir.internal.service.PirRemoteWorkerService -import com.duckduckgo.pir.internal.service.PirScheduledScanRemoteWorker -import com.duckduckgo.pir.internal.service.PirScheduledScanRemoteWorker.Companion.TAG_SCHEDULED_SCAN +import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirEventsResultsScreen import com.duckduckgo.pir.internal.store.PirRepository -import com.duckduckgo.pir.internal.store.PirRepository.ScanResult -import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ErrorResult -import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ExtractedProfileResult -import com.duckduckgo.pir.internal.store.db.Address -import com.duckduckgo.pir.internal.store.db.PirScanLog -import com.duckduckgo.pir.internal.store.db.ScanEventType.SCHEDULED_SCAN_SCHEDULED -import com.duckduckgo.pir.internal.store.db.UserName -import com.duckduckgo.pir.internal.store.db.UserProfile -import java.time.LocalDate -import java.util.concurrent.TimeUnit +import com.duckduckgo.pir.internal.store.PitTestingStore import javax.inject.Inject -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import logcat.logcat +import kotlinx.coroutines.withContext @InjectWith(ActivityScope::class) @ContributeToActivityStarter(PirSettingsScreenNoParams::class) class PirDevSettingsActivity : DuckDuckGoActivity() { - @Inject - lateinit var repository: PirRepository - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - - @Inject - lateinit var notificationManagerCompat: NotificationManagerCompat - @Inject lateinit var globalActivityStarter: GlobalActivityStarter @Inject - lateinit var workManager: WorkManager - - @Inject - lateinit var appBuildConfig: AppBuildConfig + lateinit var repository: PirRepository @Inject - lateinit var pixelSender: PirPixelSender + lateinit var testingStore: PitTestingStore @Inject - lateinit var currentTimeProvider: CurrentTimeProvider + lateinit var dispatcherProvider: DispatcherProvider private val binding: ActivityPirInternalSettingsBinding by viewBinding() @@ -107,158 +65,43 @@ class PirDevSettingsActivity : DuckDuckGoActivity() { bindViews() } - private fun bindViews() { - repository.getAllResultsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - render(it) - } - .launchIn(lifecycleScope) - - workManager.getWorkInfosForUniqueWorkLiveData(TAG_SCHEDULED_SCAN) - .observe( - this, - Observer { workInfoList -> - workInfoList.forEach { workInfo -> - logcat { "PIR-DEV: Work State: ${workInfo.state}" } - logcat { "PIR-DEV: Work State: ${workInfo.runAttemptCount}" } - } - }, - ) - } - - private fun render(results: List) { - val allExtracted = results.filterIsInstance() - val allError = results.filterIsInstance() - val brokersWithRecords = allExtracted.filter { - it.extractResults.isNotEmpty() - } - val totalSitesScanned = allExtracted.size + allError.size - val brokersWithRecordsCount = brokersWithRecords.size - - val brokersWithNoRecords = allExtracted.filter { - it.extractResults.isEmpty() - }.size + allError.size - - val totalRecordCount = brokersWithRecords.sumOf { - it.extractResults.size + private fun setupViews() { + binding.pirDebugScan.setOnClickListener { + globalActivityStarter.start(this, PirDevScanScreenNoParams) } - with(binding) { - this.statusSitesScanned.text = - getString(R.string.pirStatsStatusScanned, totalSitesScanned) - this.statusTotalRecords.text = - getString(R.string.pirStatsStatusRecords, totalRecordCount) - this.statusTotalBrokersFound.text = - getString(R.string.pirStatsStatusBrokerFound, brokersWithRecordsCount) - this.statusTotalAllClear.text = - getString(R.string.pirStatsStatusAllClear, brokersWithNoRecords) + binding.pirDebugOptOut.setOnClickListener { + globalActivityStarter.start(this, PirDevOptOutScreenNoParams) } - } - private fun setupViews() { - binding.debugRunScan.setOnClickListener { - notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) - logcat { "PIR-SCAN: Attempting to start PirForegroundScanService from ${Process.myPid()}" } - lifecycleScope.launch { - if (useUserInput()) { - repository.replaceUserProfile( - UserProfile( - userName = UserName( - firstName = binding.profileFirstName.text, - middleName = binding.profileMiddleName.text.ifBlank { - null - }, - lastName = binding.profileLastName.text, - ), - addresses = Address( - city = binding.profileCity.text, - state = binding.profileState.text, - ), - birthYear = binding.profileBirthYear.text.toInt(), - age = LocalDate.now().year - binding.profileBirthYear.text.toInt(), - ), - ) - } - } - startForegroundService(Intent(this, PirForegroundScanService::class.java)) - globalActivityStarter.start(this, PirResultsScreenNoParams) + binding.viewRunEvents.setOnClickListener { + globalActivityStarter.start(this, PirEventsResultsScreen) } - binding.debugForceKill.setOnClickListener { - stopService(Intent(this, PirForegroundScanService::class.java)) - lifecycleScope.launch(dispatcherProvider.io()) { - repository.deleteAllResults() - repository.deleteAllUserProfiles() - repository.deleteAllScanLogs() + binding.testerInfo.setSecondaryText(testingStore.testerId) + if (testingStore.testerId != null) { + binding.testerInfo.setLongClickListener { + copyDataToClipboard() } - notificationManagerCompat.cancel(NOTIF_ID_STATUS_COMPLETE) - workManager.cancelUniqueWork(TAG_SCHEDULED_SCAN) - stopService(Intent(this, PirRemoteWorkerService::class.java)) } + } - binding.viewResults.setOnClickListener { - globalActivityStarter.start(this, PirResultsScreenNoParams) - } + private fun copyDataToClipboard() { + val clipboardManager = getSystemService(ClipboardManager::class.java) - binding.scheduleScan.setOnClickListener { - schedulePeriodicScan() - Toast.makeText(this, getString(R.string.pirMessageSchedule), Toast.LENGTH_SHORT).show() - } + lifecycleScope.launch(dispatcherProvider.io()) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("", testingStore.testerId)) - binding.viewScanResults.setOnClickListener { - globalActivityStarter.start(this, PirScanResultsScreenNoParams) + withContext(dispatcherProvider.main()) { + Toast.makeText(this@PirDevSettingsActivity, "Testing ID copied to clipboard", Toast.LENGTH_SHORT).show() + } } } - private fun schedulePeriodicScan() { - logcat { "PIR-SCHEDULED: Scheduling periodic scan appId: ${appBuildConfig.applicationId}" } - - val constraints = Constraints.Builder() - .setRequiresCharging(true) - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val periodicWorkRequest = - PeriodicWorkRequest.Builder(PirScheduledScanRemoteWorker::class.java, 12, TimeUnit.HOURS) - .boundToPirProcess(appBuildConfig.applicationId) - .setConstraints(constraints) - .setInitialDelay(1, TimeUnit.MINUTES) - .build() - - pixelSender.reportScheduledScanScheduled() + private fun bindViews() { lifecycleScope.launch { - repository.saveScanLog( - PirScanLog( - eventTimeInMillis = currentTimeProvider.currentTimeMillis(), - eventType = SCHEDULED_SCAN_SCHEDULED, - ), - ) + binding.pirDebugOptOut.isEnabled = repository.getBrokersForOptOut(true).isNotEmpty() } - - workManager.enqueueUniquePeriodicWork( - TAG_SCHEDULED_SCAN, - ExistingPeriodicWorkPolicy.UPDATE, - periodicWorkRequest, - ) - } - - private fun PeriodicWorkRequest.Builder.boundToPirProcess(applicationId: String): PeriodicWorkRequest.Builder { - val componentName = ComponentName(applicationId, PirRemoteWorkerService::class.java.name) - val data = Data.Builder() - .putString(RemoteListenableWorker.ARGUMENT_PACKAGE_NAME, componentName.packageName) - .putString(RemoteListenableWorker.ARGUMENT_CLASS_NAME, componentName.className) - .build() - - return this.setInputData(data) - } - - private fun useUserInput(): Boolean { - return binding.profileFirstName.text.isNotBlank() && - binding.profileLastName.text.isNotBlank() && - binding.profileCity.text.isNotBlank() && - binding.profileState.text.isNotBlank() && - binding.profileBirthYear.text.isNotBlank() } private fun createNotificationChannel() { 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 086254de7ab7..a7e63af7e812 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 @@ -17,6 +17,7 @@ package com.duckduckgo.pir.internal.settings import android.os.Bundle +import android.widget.ArrayAdapter import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope @@ -27,18 +28,25 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams +import com.duckduckgo.navigation.api.getActivityParams +import com.duckduckgo.pir.internal.R import com.duckduckgo.pir.internal.databinding.ActivityPirInternalResultsBinding +import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirEventsResultsScreen +import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirOptOutResultsScreen +import com.duckduckgo.pir.internal.settings.PirResultsScreenParams.PirScanResultsScreen import com.duckduckgo.pir.internal.store.PirRepository -import com.duckduckgo.pir.internal.store.PirRepository.ScanResult import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ErrorResult import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ExtractedProfileResult import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.NavigateResult +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @InjectWith(ActivityScope::class) -@ContributeToActivityStarter(PirResultsScreenNoParams::class) +@ContributeToActivityStarter(PirResultsScreenParams::class) class PirResultsActivity : DuckDuckGoActivity() { @Inject lateinit var repository: PirRepository @@ -46,8 +54,14 @@ class PirResultsActivity : DuckDuckGoActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider + private val formatter = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.getDefault()) private val binding: ActivityPirInternalResultsBinding by viewBinding() + private val params: PirResultsScreenParams? + get() = intent.getActivityParams(PirResultsScreenParams::class.java) + + private lateinit var adapter: ArrayAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -56,39 +70,101 @@ class PirResultsActivity : DuckDuckGoActivity() { } private fun bindViews() { - repository.getAllResultsFlow() + adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1) + binding.scanLogList.adapter = adapter + + when (params) { + is PirEventsResultsScreen -> { + setTitle(R.string.pirDevViewRunEvents) + showAllEvents() + } + + is PirScanResultsScreen -> { + setTitle(R.string.pirDevViewScanResults) + showScanResults() + } + + is PirOptOutResultsScreen -> { + setTitle(R.string.pirDevViewOptOutResults) + showOptOutResults() + } + + null -> {} + } + } + + private fun showOptOutResults() { + repository.getAllOptOutActionLogFlow() .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - render(it) + .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) } - private fun render(results: List) { - val stringBuilder = StringBuilder() - results.forEach { - stringBuilder.append("BROKER NAME: ${it.brokerName}\nACTION EXECUTED: ${it.actionType}\n") - when (it) { - is NavigateResult -> { - stringBuilder.append("URL TO NAVIGATE: ${it.url}\n") - } + private fun showScanResults() { + repository.getAllScanResultsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanResults -> + scanResults.map { + val stringBuilder = StringBuilder() + stringBuilder.append("BROKER NAME: ${it.brokerName}\nACTION EXECUTED: ${it.actionType}\n") + when (it) { + is NavigateResult -> { + stringBuilder.append("URL TO NAVIGATE: ${it.url}\n") + } + + is ExtractedProfileResult -> { + val records = it.extractResults.filter { + it.result + }.size + stringBuilder.append("VALID RECORDS FOUND COUNT: $records\n") + } - is ExtractedProfileResult -> { - stringBuilder.append("PROFILE QUERY:\n ${it.profileQuery} \n") - stringBuilder.append("EXTRACTED DATA:\n") - it.extractResults.forEach { extract -> - stringBuilder.append("> ${extract.scrapedData.profileUrl?.profileUrl} \n") + is ErrorResult -> { + stringBuilder.append("*ERROR ENCOUNTERED: ${it.message}\n") + } } + stringBuilder.toString() + }.also { + render(it) } + } + .launchIn(lifecycleScope) + } - is ErrorResult -> { - stringBuilder.append("*ERROR ENCOUNTERED: ${it.message}\n") + private fun showAllEvents() { + repository.getAllEventLogsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { scanEvents -> + scanEvents.map { result -> + "Time: ${formatter.format(Date(result.eventTimeInMillis))}\nEVENT: ${result.eventType}\n" + }.also { + render(it) } } - stringBuilder.append("--------------------------\n") - } - binding.simpleScanResults.text = stringBuilder.toString() + .launchIn(lifecycleScope) + } + + private fun render(results: List) { + adapter.clear() + adapter.addAll(results) } } -object PirResultsScreenNoParams : ActivityParams +sealed class PirResultsScreenParams : ActivityParams { + data object PirEventsResultsScreen : PirResultsScreenParams() + data object PirScanResultsScreen : PirResultsScreenParams() + data object PirOptOutResultsScreen : PirResultsScreenParams() +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirScanResultsActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirScanResultsActivity.kt deleted file mode 100644 index ee198005ece0..000000000000 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirScanResultsActivity.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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.internal.settings - -import android.os.Bundle -import android.widget.ArrayAdapter -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import com.duckduckgo.anvil.annotations.ContributeToActivityStarter -import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.common.ui.DuckDuckGoActivity -import com.duckduckgo.common.ui.viewbinding.viewBinding -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.ActivityScope -import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams -import com.duckduckgo.pir.internal.databinding.ActivityPirInternalScanResultsBinding -import com.duckduckgo.pir.internal.store.PirRepository -import com.duckduckgo.pir.internal.store.db.PirScanLog -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -@InjectWith(ActivityScope::class) -@ContributeToActivityStarter(PirScanResultsScreenNoParams::class) -class PirScanResultsActivity : DuckDuckGoActivity() { - @Inject - lateinit var repository: PirRepository - - @Inject - lateinit var dispatcherProvider: DispatcherProvider - - private val formatter = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.getDefault()) - private val binding: ActivityPirInternalScanResultsBinding by viewBinding() - private lateinit var adapter: ArrayAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setupToolbar(binding.toolbar) - bindViews() - } - - private fun bindViews() { - adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1) - binding.scanLogList.adapter = adapter - repository.getAllScanEventsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .onEach { - render(it) - } - .launchIn(lifecycleScope) - } - - private fun render(results: List) { - adapter.clear() - adapter.addAll( - results.map { - "Time: ${formatter.format(Date(it.eventTimeInMillis))}\nEVENT: ${it.eventType}\n" - }, - ) - } -} - -object PirScanResultsScreenNoParams : ActivityParams diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirWebViewActivity.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirWebViewActivity.kt new file mode 100644 index 000000000000..576693f767ea --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/settings/PirWebViewActivity.kt @@ -0,0 +1,68 @@ +/* + * 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.internal.settings + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams +import com.duckduckgo.navigation.api.getActivityParams +import com.duckduckgo.pir.internal.databinding.ActivityPirWebviewBinding +import com.duckduckgo.pir.internal.optout.PirOptOut +import javax.inject.Inject +import kotlinx.coroutines.launch + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(PirDebugWebViewResultsScreenParams::class) +class PirWebViewActivity : DuckDuckGoActivity() { + @Inject + lateinit var pirOptOut: PirOptOut + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + private val binding: ActivityPirWebviewBinding by viewBinding() + private val params: PirDebugWebViewResultsScreenParams? + get() = intent.getActivityParams(PirDebugWebViewResultsScreenParams::class.java) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + val brokersToOptOut = params?.brokers + lifecycleScope.launch(dispatcherProvider.io()) { + if (!brokersToOptOut.isNullOrEmpty()) { + pirOptOut.debugExecute(brokersToOptOut, binding.pirDebugWebView, this).also { + finish() + } + } else { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + pirOptOut.stop() + } +} + +data class PirDebugWebViewResultsScreenParams(val brokers: List) : ActivityParams diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirDatabase.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirDatabase.kt index cb4f4c313a35..dc405149282c 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirDatabase.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirDatabase.kt @@ -29,8 +29,12 @@ import com.duckduckgo.pir.internal.store.db.BrokerOptOut import com.duckduckgo.pir.internal.store.db.BrokerScan import com.duckduckgo.pir.internal.store.db.BrokerSchedulingConfig import com.duckduckgo.pir.internal.store.db.ExtractProfileResult +import com.duckduckgo.pir.internal.store.db.OptOutActionLog +import com.duckduckgo.pir.internal.store.db.OptOutCompletedBroker +import com.duckduckgo.pir.internal.store.db.OptOutResultsDao import com.duckduckgo.pir.internal.store.db.PirBrokerScanLog -import com.duckduckgo.pir.internal.store.db.PirScanLog +import com.duckduckgo.pir.internal.store.db.PirEventLog +import com.duckduckgo.pir.internal.store.db.ScanCompletedBroker import com.duckduckgo.pir.internal.store.db.ScanErrorResult import com.duckduckgo.pir.internal.store.db.ScanLogDao import com.duckduckgo.pir.internal.store.db.ScanNavigateResult @@ -54,8 +58,11 @@ import com.squareup.moshi.Types ScanErrorResult::class, ExtractProfileResult::class, UserProfile::class, - PirScanLog::class, + PirEventLog::class, PirBrokerScanLog::class, + ScanCompletedBroker::class, + OptOutCompletedBroker::class, + OptOutActionLog::class, ], ) @TypeConverters(PirDatabaseConverters::class) @@ -65,6 +72,7 @@ abstract class PirDatabase : RoomDatabase() { abstract fun scanResultsDao(): ScanResultsDao abstract fun userProfileDao(): UserProfileDao abstract fun scanLogDao(): ScanLogDao + abstract fun optOutResultsDao(): OptOutResultsDao companion object { val ALL_MIGRATIONS: List diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirRepository.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirRepository.kt index e2cbec13f82b..518b30e479ee 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirRepository.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PirRepository.kt @@ -18,14 +18,18 @@ package com.duckduckgo.pir.internal.store import com.duckduckgo.common.utils.CurrentTimeProvider import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.pir.internal.scripts.models.ExtractedProfile import com.duckduckgo.pir.internal.scripts.models.PirErrorReponse import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExtractedResponse import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.ExtractedResponse.ScrapedData import com.duckduckgo.pir.internal.scripts.models.PirSuccessResponse.NavigateResponse import com.duckduckgo.pir.internal.scripts.models.ProfileQuery +import com.duckduckgo.pir.internal.service.DbpService import com.duckduckgo.pir.internal.service.DbpService.PirJsonBroker import com.duckduckgo.pir.internal.store.PirRepository.BrokerJson +import com.duckduckgo.pir.internal.store.PirRepository.ConfirmationStatus import com.duckduckgo.pir.internal.store.PirRepository.ScanResult +import com.duckduckgo.pir.internal.store.PirRepository.ScanResult.ExtractedProfileResult import com.duckduckgo.pir.internal.store.db.Broker import com.duckduckgo.pir.internal.store.db.BrokerDao import com.duckduckgo.pir.internal.store.db.BrokerJsonDao @@ -34,8 +38,12 @@ import com.duckduckgo.pir.internal.store.db.BrokerOptOut import com.duckduckgo.pir.internal.store.db.BrokerScan import com.duckduckgo.pir.internal.store.db.BrokerSchedulingConfig import com.duckduckgo.pir.internal.store.db.ExtractProfileResult +import com.duckduckgo.pir.internal.store.db.OptOutActionLog +import com.duckduckgo.pir.internal.store.db.OptOutCompletedBroker +import com.duckduckgo.pir.internal.store.db.OptOutResultsDao import com.duckduckgo.pir.internal.store.db.PirBrokerScanLog -import com.duckduckgo.pir.internal.store.db.PirScanLog +import com.duckduckgo.pir.internal.store.db.PirEventLog +import com.duckduckgo.pir.internal.store.db.ScanCompletedBroker import com.duckduckgo.pir.internal.store.db.ScanErrorResult import com.duckduckgo.pir.internal.store.db.ScanLogDao import com.duckduckgo.pir.internal.store.db.ScanNavigateResult @@ -43,8 +51,10 @@ import com.duckduckgo.pir.internal.store.db.ScanResultsDao import com.duckduckgo.pir.internal.store.db.UserProfile import com.duckduckgo.pir.internal.store.db.UserProfileDao import com.squareup.moshi.Moshi +import java.util.regex.Pattern import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import logcat.logcat @@ -70,6 +80,10 @@ interface PirRepository { suspend fun getBrokerScanSteps(name: String): String? + suspend fun getBrokerOptOutSteps(name: String): String? + + suspend fun getBrokersForOptOut(formOptOutOnly: Boolean): List + suspend fun saveNavigateResult( brokerName: String, navigateResponse: NavigateResponse, @@ -86,13 +100,17 @@ interface PirRepository { response: ExtractedResponse, ) - fun getAllResultsFlow(): Flow> + suspend fun getExtractProfileResultForBroker( + brokerName: String, + ): ExtractedProfileResult? + + fun getAllScanResultsFlow(): Flow> suspend fun getErrorResultsCount(): Int suspend fun getSuccessResultsCount(): Int - suspend fun deleteAllResults() + suspend fun deleteAllScanResults() suspend fun getUserProfiles(): List @@ -100,15 +118,52 @@ interface PirRepository { suspend fun replaceUserProfile(userProfile: UserProfile) - fun getAllScanEventsFlow(): Flow> + fun getAllEventLogsFlow(): Flow> fun getAllBrokerScanEventsFlow(): Flow> - suspend fun saveScanLog(pirScanLog: PirScanLog) + suspend fun saveScanLog(pirScanLog: PirEventLog) suspend fun saveBrokerScanLog(pirBrokerScanLog: PirBrokerScanLog) - suspend fun deleteAllScanLogs() + suspend fun deleteAllLogs() + + suspend fun getEmailForBroker(dataBroker: String): String + + suspend fun getEmailConfirmation(email: String): Pair + + fun getTotalScannedBrokersFlow(): Flow + + fun getTotalOptOutCompletedFlow(): Flow + + fun getAllOptOutActionLogFlow(): Flow> + + fun getAllSuccessfullySubmittedOptOutFlow(): Flow> + + suspend fun saveScanCompletedBroker( + brokerName: String, + startTimeInMillis: Long, + endTimeInMillis: Long, + ) + + suspend fun saveOptOutCompleted( + brokerName: String, + extractedProfile: ExtractedProfile, + startTimeInMillis: Long, + endTimeInMillis: Long, + isSubmitSuccess: Boolean, + ) + + suspend fun saveOptOutActionLog( + brokerName: String, + extractedProfile: ExtractedProfile, + completionTimeInMillis: Long, + actionType: String, + isError: Boolean, + result: String, + ) + + suspend fun deleteAllOptOutData() data class BrokerJson( val fileName: String, @@ -142,6 +197,12 @@ interface PirRepository { val message: String, ) : ScanResult(brokerName, completionTimeInMillis, actionType) } + + sealed class ConfirmationStatus(open val statusName: String) { + data object Ready : ConfirmationStatus("ready") + data object Pending : ConfirmationStatus("pending") + data object Unknown : ConfirmationStatus("unknown") + } } class RealPirRepository( @@ -154,9 +215,13 @@ class RealPirRepository( private val scanResultsDao: ScanResultsDao, private val userProfileDao: UserProfileDao, private val scanLogDao: ScanLogDao, + private val dbpService: DbpService, + private val optOutResultsDao: OptOutResultsDao, ) : PirRepository { private val profileQueryAdapter by lazy { moshi.adapter(ProfileQuery::class.java) } private val scrapedDataAdapter by lazy { moshi.adapter(ScrapedData::class.java) } + private val extractedProfileAdapter by lazy { moshi.adapter(ExtractedProfile::class.java) } + private val validExtractedProfilePattern by lazy { Pattern.compile(".*\"result\"\\s*:\\s*true.*") } override suspend fun getCurrentMainEtag(): String? = pirDataStore.mainConfigEtag @@ -237,6 +302,28 @@ class RealPirRepository( brokerDao.getScanJson(name) } + override suspend fun getBrokerOptOutSteps(name: String): String? = withContext(dispatcherProvider.io()) { + brokerDao.getOptOutJson(name) + } + + override suspend fun getBrokersForOptOut(formOptOutOnly: Boolean): List = withContext(dispatcherProvider.io()) { + scanResultsDao.getAllExtractProfileResult().filter { + it.extractResults.isNotEmpty() && it.extractResults.any { result -> + validExtractedProfilePattern.matcher(result).find() + } + }.map { + it.brokerName + }.run { + if (formOptOutOnly) { + this.filter { + brokerDao.getOptOutJson(it)?.contains("\"optOutType\":\"formOptOut\"") == true + } + } else { + this + } + } + } + override suspend fun saveNavigateResult( brokerName: String, navigateResponse: NavigateResponse, @@ -291,7 +378,21 @@ class RealPirRepository( } } - override fun getAllResultsFlow(): Flow> { + override suspend fun getExtractProfileResultForBroker(brokerName: String): ExtractedProfileResult? = withContext(dispatcherProvider.io()) { + return@withContext scanResultsDao.getExtractProfileResultForProfile(brokerName).firstOrNull()?.run { + ExtractedProfileResult( + brokerName = this.brokerName, + completionTimeInMillis = this.completionTimeInMillis, + actionType = this.actionType, + extractResults = this.extractResults.mapNotNull { + scrapedDataAdapter.fromJson(it) + }, + profileQuery = null, + ) + } + } + + override fun getAllScanResultsFlow(): Flow> { return combine( scanResultsDao.getAllNavigateResultsFlow(), scanResultsDao.getAllScanErrorResultsFlow(), @@ -331,11 +432,12 @@ class RealPirRepository( } } - override suspend fun deleteAllResults() { + override suspend fun deleteAllScanResults() { withContext(dispatcherProvider.io()) { scanResultsDao.deleteAllNavigateResults() scanResultsDao.deleteAllScanErrorResults() scanResultsDao.deleteAllExtractProfileResult() + scanResultsDao.deleteAllScanCompletedBroker() } } @@ -364,17 +466,17 @@ class RealPirRepository( } } - override fun getAllScanEventsFlow(): Flow> { - return scanLogDao.getAllScanEventsFlow() + override fun getAllEventLogsFlow(): Flow> { + return scanLogDao.getAllEventLogsFlow() } override fun getAllBrokerScanEventsFlow(): Flow> { return scanLogDao.getAllBrokerScanEventsFlow() } - override suspend fun saveScanLog(pirScanLog: PirScanLog) { + override suspend fun saveScanLog(pirScanLog: PirEventLog) { withContext(dispatcherProvider.io()) { - scanLogDao.insertScanEvent(pirScanLog) + scanLogDao.insertEventLog(pirScanLog) } } @@ -384,10 +486,107 @@ class RealPirRepository( } } - override suspend fun deleteAllScanLogs() { + override suspend fun deleteAllLogs() { withContext(dispatcherProvider.io()) { - scanLogDao.deleteAllScanEvents() + scanLogDao.deleteAllEventLogs() scanLogDao.deleteAllBrokerScanEvents() } } + + override suspend fun getEmailForBroker(dataBroker: String): String = withContext(dispatcherProvider.io()) { + return@withContext dbpService.getEmail(brokerDao.getBrokerDetails(dataBroker)!!.url).emailAddress + } + + override suspend fun getEmailConfirmation(email: String): Pair = withContext(dispatcherProvider.io()) { + return@withContext dbpService.getEmailStatus(email).run { + this.status.toConfirmationStatus() to this.link + } + } + + private fun String.toConfirmationStatus(): ConfirmationStatus { + return when (this) { + "pending" -> ConfirmationStatus.Pending + "ready" -> ConfirmationStatus.Ready + else -> ConfirmationStatus.Unknown + } + } + + override fun getTotalScannedBrokersFlow(): Flow { + return scanResultsDao.getScanCompletedBrokerFlow().map { it.size } + } + + override fun getTotalOptOutCompletedFlow(): Flow { + return optOutResultsDao.getOptOutCompletedBrokerFlow().map { it.size } + } + + override fun getAllSuccessfullySubmittedOptOutFlow(): Flow> { + return optOutResultsDao.getOptOutCompletedBrokerFlow().map { + it.filter { + it.isSubmitSuccess + }.map { + (extractedProfileAdapter.fromJson(it.extractedProfile)?.profileUrl?.identifier ?: "Unknown") to it.brokerName + }.distinct().toMap() + } + } + + override fun getAllOptOutActionLogFlow(): Flow> { + return optOutResultsDao.getOptOutActionLogFlow() + } + + override suspend fun saveScanCompletedBroker( + brokerName: String, + startTimeInMillis: Long, + endTimeInMillis: Long, + ) = withContext(dispatcherProvider.io()) { + scanResultsDao.insertScanCompletedBroker( + ScanCompletedBroker( + brokerName = brokerName, + startTimeInMillis = startTimeInMillis, + endTimeInMillis = endTimeInMillis, + ), + ) + } + + override suspend fun saveOptOutCompleted( + brokerName: String, + extractedProfile: ExtractedProfile, + startTimeInMillis: Long, + endTimeInMillis: Long, + isSubmitSuccess: Boolean, + ) = withContext(dispatcherProvider.io()) { + optOutResultsDao.insertOptOutCompletedBroker( + OptOutCompletedBroker( + brokerName = brokerName, + extractedProfile = extractedProfileAdapter.toJson(extractedProfile), + startTimeInMillis = startTimeInMillis, + endTimeInMillis = endTimeInMillis, + isSubmitSuccess = isSubmitSuccess, + ), + ) + } + + override suspend fun saveOptOutActionLog( + brokerName: String, + extractedProfile: ExtractedProfile, + completionTimeInMillis: Long, + actionType: String, + isError: Boolean, + result: String, + ) = withContext(dispatcherProvider.io()) { + optOutResultsDao.insertOptOutActionLog( + OptOutActionLog( + brokerName = brokerName, + extractedProfile = extractedProfileAdapter.toJson(extractedProfile), + completionTimeInMillis = completionTimeInMillis, + actionType = actionType, + isError = isError, + result = result, + ), + ) + } + + override suspend fun deleteAllOptOutData() = withContext(dispatcherProvider.io()) { + optOutResultsDao.deleteAllOptOutActionLog() + optOutResultsDao.deleteAllOptOutCompletedBroker() + } } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PitTestingStore.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PitTestingStore.kt new file mode 100644 index 000000000000..c354bba6dcd0 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/PitTestingStore.kt @@ -0,0 +1,59 @@ +/* + * 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.internal.store + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.duckduckgo.data.store.api.SharedPreferencesProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +/** + * This class is not meant to stay after the PoC. This should only contain optional testing specific data. + */ +interface PitTestingStore { + var testerId: String? +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealPitTestingStore @Inject constructor( + private val sharedPreferencesProvider: SharedPreferencesProvider, +) : PitTestingStore { + private val preferences: SharedPreferences by lazy { + sharedPreferencesProvider.getSharedPreferences( + FILENAME, + multiprocess = true, + migrate = false, + ) + } + + override var testerId: String? + get() = preferences.getString(KEY_TESTER_ID, null) + set(value) { + preferences.edit { + putString(KEY_TESTER_ID, value) + } + } + + companion object { + private const val FILENAME = "com.duckduckgo.pir.testing.v1" + private const val KEY_TESTER_ID = "KEY_TESTER_ID" + } +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutEntities.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutEntities.kt new file mode 100644 index 000000000000..b45c214f816e --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutEntities.kt @@ -0,0 +1,50 @@ +/* + * 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.internal.store.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Contains the brokers + extracted profiles where the opt-out flow has been completed. + * A broker can have multiple extracted profiles and for each broker + profile combination, + * we need to complete an opt out flow for. + */ +@Entity(tableName = "pir_opt_out_complete_brokers") +data class OptOutCompletedBroker( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val brokerName: String, + val extractedProfile: String, + val startTimeInMillis: Long, + val endTimeInMillis: Long, + val isSubmitSuccess: Boolean, +) + +/** + * This table contains ALL results from any action during the opt-out flow. + * This is mostly for logging purpose. + */ +@Entity(tableName = "pir_opt_out_action_log") +data class OptOutActionLog( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val brokerName: String, + val extractedProfile: String, + val completionTimeInMillis: Long, + val actionType: String, + val isError: Boolean, + val result: String, +) diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutResultsDao.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutResultsDao.kt new file mode 100644 index 000000000000..99542456dbc8 --- /dev/null +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/OptOutResultsDao.kt @@ -0,0 +1,47 @@ +/* + * 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.internal.store.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface OptOutResultsDao { + @Query("SELECT * FROM pir_opt_out_action_log ORDER BY completionTimeInMillis") + fun getOptOutActionLogFlow(): Flow> + + @Query("SELECT * FROM pir_opt_out_complete_brokers ORDER BY endTimeInMillis") + fun getOptOutCompletedBrokerFlow(): Flow> + + @Query("SELECT * FROM pir_opt_out_complete_brokers ORDER BY endTimeInMillis") + suspend fun getAllOptOutCompletedBrokers(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOptOutActionLog(optOutActionLog: OptOutActionLog) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOptOutCompletedBroker(optOutCompletedBroker: OptOutCompletedBroker) + + @Query("DELETE from pir_opt_out_action_log") + fun deleteAllOptOutActionLog() + + @Query("DELETE from pir_opt_out_complete_brokers") + fun deleteAllOptOutCompletedBroker() +} diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogDao.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogDao.kt index 3ced9f385d72..b48a572a94e5 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogDao.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogDao.kt @@ -24,20 +24,20 @@ import kotlinx.coroutines.flow.Flow @Dao interface ScanLogDao { - @Query("SELECT * FROM pir_scan_log ORDER BY eventTimeInMillis") - fun getAllScanEventsFlow(): Flow> + @Query("SELECT * FROM pir_events_log ORDER BY eventTimeInMillis") + fun getAllEventLogsFlow(): Flow> @Query("SELECT * FROM pir_broker_scan_log ORDER BY eventTimeInMillis") fun getAllBrokerScanEventsFlow(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertScanEvent(pirScanLog: PirScanLog) + fun insertEventLog(pirScanLog: PirEventLog) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertBrokerScanEvent(pirBrokerScanLog: PirBrokerScanLog) - @Query("DELETE from pir_scan_log") - fun deleteAllScanEvents() + @Query("DELETE from pir_events_log") + fun deleteAllEventLogs() @Query("DELETE from pir_broker_scan_log") fun deleteAllBrokerScanEvents() diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogEntities.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogEntities.kt index 36d58ae0433a..e913ef0bb5a7 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogEntities.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanLogEntities.kt @@ -19,18 +19,20 @@ package com.duckduckgo.pir.internal.store.db import androidx.room.Entity import androidx.room.PrimaryKey -@Entity(tableName = "pir_scan_log") -data class PirScanLog( +@Entity(tableName = "pir_events_log") +data class PirEventLog( @PrimaryKey val eventTimeInMillis: Long, - val eventType: ScanEventType, + val eventType: EventType, ) -enum class ScanEventType { +enum class EventType { MANUAL_SCAN_STARTED, MANUAL_SCAN_COMPLETED, SCHEDULED_SCAN_SCHEDULED, SCHEDULED_SCAN_STARTED, SCHEDULED_SCAN_COMPLETED, + MANUAL_OPTOUT_STARTED, + MANUAL_OPTOUT_COMPLETED, } @Entity(tableName = "pir_broker_scan_log") diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsDao.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsDao.kt index e8e5e0c35fb6..1f65396432ba 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsDao.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsDao.kt @@ -24,6 +24,12 @@ import kotlinx.coroutines.flow.Flow @Dao interface ScanResultsDao { + @Query("SELECT * FROM pir_scan_complete_brokers ORDER BY endTimeInMillis") + fun getScanCompletedBrokerFlow(): Flow> + + @Query("SELECT * FROM pir_scan_complete_brokers ORDER BY endTimeInMillis") + suspend fun getAllScanCompletedBrokers(): List + @Query("SELECT * FROM pir_scan_navigate_results ORDER BY completionTimeInMillis") fun getAllNavigateResultsFlow(): Flow> @@ -48,9 +54,15 @@ interface ScanResultsDao { @Query("SELECT * FROM pir_scan_extracted_profile ORDER BY completionTimeInMillis") fun getAllExtractProfileResult(): List + @Query("SELECT * FROM pir_scan_extracted_profile WHERE brokerName = :brokerName ORDER BY completionTimeInMillis") + fun getExtractProfileResultForProfile(brokerName: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertExtractProfileResult(extractProfileResult: ExtractProfileResult) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertScanCompletedBroker(scanCompletedBroker: ScanCompletedBroker) + @Query("DELETE from pir_scan_navigate_results") fun deleteAllNavigateResults() @@ -59,4 +71,7 @@ interface ScanResultsDao { @Query("DELETE from pir_scan_extracted_profile") fun deleteAllExtractProfileResult() + + @Query("DELETE from pir_scan_complete_brokers") + fun deleteAllScanCompletedBroker() } diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsEntities.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsEntities.kt index 2b76d4e65705..71e41b5e0a98 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsEntities.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/ScanResultsEntities.kt @@ -19,6 +19,35 @@ package com.duckduckgo.pir.internal.store.db import androidx.room.Entity import androidx.room.PrimaryKey +/** + * Contains the sites that have been scanned. + * Scanned means that the scan flow has been started and completed for the broker. + */ +@Entity(tableName = "pir_scan_complete_brokers") +data class ScanCompletedBroker( + @PrimaryKey val brokerName: String, + val startTimeInMillis: Long, + val endTimeInMillis: Long, +) + +/** + * Contains all the extracted profile result from a scan run. + * To know if an extracted profile exists for a broker, we need to check the extractResults and should not be empty. + */ +@Entity(tableName = "pir_scan_extracted_profile") +data class ExtractProfileResult( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val brokerName: String, + val completionTimeInMillis: Long, + val actionType: String, + val userData: String, + val extractResults: List, +) + +/** + * This table contains any navigation result (both for scan and opt-out). + * This is mostly for logging purpose. + */ @Entity(tableName = "pir_scan_navigate_results") data class ScanNavigateResult( @PrimaryKey(autoGenerate = true) val id: Int = 0, @@ -28,6 +57,10 @@ data class ScanNavigateResult( val completionTimeInMillis: Long, ) +/** + * This table contains ALL error result from any action (regardless if it is from scan and opt-out). + * This is mostly for logging purpose. + */ @Entity(tableName = "pir_scan_error") data class ScanErrorResult( @PrimaryKey(autoGenerate = true) val id: Int = 0, @@ -36,13 +69,3 @@ data class ScanErrorResult( val actionType: String, val message: String, ) - -@Entity(tableName = "pir_scan_extracted_profile") -data class ExtractProfileResult( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val brokerName: String, - val completionTimeInMillis: Long, - val actionType: String, - val userData: String, - val extractResults: List, -) diff --git a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/UserProfileEntities.kt b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/UserProfileEntities.kt index 6bd9f0c6abfb..314c895058f5 100644 --- a/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/UserProfileEntities.kt +++ b/pir/pir-internal/src/main/java/com/duckduckgo/pir/internal/store/db/UserProfileEntities.kt @@ -29,7 +29,6 @@ data class UserProfile( val addresses: Address, val birthYear: Int, val phone: String? = null, - val age: Int, ) data class UserName( diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_internal_optout.xml b/pir/pir-internal/src/main/res/layout/activity_pir_internal_optout.xml new file mode 100644 index 000000000000..fe9c01808054 --- /dev/null +++ b/pir/pir-internal/src/main/res/layout/activity_pir_internal_optout.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_internal_results.xml b/pir/pir-internal/src/main/res/layout/activity_pir_internal_results.xml index 6e3f3e904fca..104790dd4e09 100644 --- a/pir/pir-internal/src/main/res/layout/activity_pir_internal_results.xml +++ b/pir/pir-internal/src/main/res/layout/activity_pir_internal_results.xml @@ -38,26 +38,9 @@ app:popupTheme="@style/Widget.DuckDuckGo.PopUpOverflowMenu" /> - - - - - - - - - + android:layout_height="wrap_content" + android:layout_marginVertical="@dimen/keyline_4" /> \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan.xml b/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan.xml new file mode 100644 index 000000000000..c90047f3a33a --- /dev/null +++ b/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan.xml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan_results.xml b/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan_results.xml deleted file mode 100644 index b2569b3cd1b7..000000000000 --- a/pir/pir-internal/src/main/res/layout/activity_pir_internal_scan_results.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_internal_settings.xml b/pir/pir-internal/src/main/res/layout/activity_pir_internal_settings.xml index 3d3d6979e66e..b3b19754bd94 100644 --- a/pir/pir-internal/src/main/res/layout/activity_pir_internal_settings.xml +++ b/pir/pir-internal/src/main/res/layout/activity_pir_internal_settings.xml @@ -38,6 +38,13 @@ app:popupTheme="@style/Widget.DuckDuckGo.PopUpOverflowMenu" /> + + - - - - - - - - - - + android:layout_margin="@dimen/keyline_4" + android:orientation="vertical" + android:paddingBottom="@dimen/keyline_4"> - - - - - - - - - - - - + android:text="@string/pirDevSettingsScan" /> + android:enabled="false" + android:text="@string/pirDevSettingsOptOut" /> - - - - - - - - - - + android:text="@string/pirDevViewRunEvents" /> \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/layout/activity_pir_webview.xml b/pir/pir-internal/src/main/res/layout/activity_pir_webview.xml new file mode 100644 index 000000000000..6394856a4dc0 --- /dev/null +++ b/pir/pir-internal/src/main/res/layout/activity_pir_webview.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/pir/pir-internal/src/main/res/values/donottranslate.xml b/pir/pir-internal/src/main/res/values/donottranslate.xml index 0a496f7974c4..1c7b1fcc00d9 100644 --- a/pir/pir-internal/src/main/res/values/donottranslate.xml +++ b/pir/pir-internal/src/main/res/values/donottranslate.xml @@ -16,15 +16,23 @@ Pir Dev Settings + PIR Scan + PIR Dev Scan + PIR Dev Opt-out + PIR Dev Debug Opt-out + PIR Opt-out Run simple scan - Clear ALL services - View Full Results - View Scan Results + Reset scan + Reset optout + View All Scan Results + View All OptOut Results + View PIR Run events Schedule Scan Simple Scan Results Test Actions Scan (if empty uses default) Completion summary: + Submitted optouts: Sites Scanned: %1$d Total extracted profiles: %1$d Brokers with records: %1$d @@ -35,4 +43,10 @@ Manual scan is complete! View your results. Successfully schedule background scan! Scan events log + Pir Opt Out + Opt out is currently in progress… + Opt out is complete! View your results. + Debug opt out + Run opt out + Note: Only brokers with formOptOut are supported and will appear as an option for optout. \ No newline at end of file