Skip to content

Commit 87c02de

Browse files
committed
Add updater and worker logic
1 parent 66e70d5 commit 87c02de

5 files changed

Lines changed: 467 additions & 2 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.adblocking.impl
18+
19+
import android.content.Context
20+
import androidx.lifecycle.LifecycleOwner
21+
import androidx.work.BackoffPolicy
22+
import androidx.work.Constraints
23+
import androidx.work.CoroutineWorker
24+
import androidx.work.ExistingWorkPolicy
25+
import androidx.work.NetworkType
26+
import androidx.work.OneTimeWorkRequestBuilder
27+
import androidx.work.WorkManager
28+
import androidx.work.WorkerParameters
29+
import androidx.work.workDataOf
30+
import com.duckduckgo.adblocking.impl.domain.ScriptletUpdateResult
31+
import com.duckduckgo.adblocking.impl.domain.ScriptletUpdater
32+
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionConfigProvider
33+
import com.duckduckgo.adblocking.impl.remoteconfig.ScriptletsSettings
34+
import com.duckduckgo.anvil.annotations.ContributesWorker
35+
import com.duckduckgo.app.di.AppCoroutineScope
36+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
37+
import com.duckduckgo.common.utils.DispatcherProvider
38+
import com.duckduckgo.di.scopes.AppScope
39+
import com.squareup.anvil.annotations.ContributesMultibinding
40+
import com.squareup.moshi.JsonAdapter
41+
import dagger.SingleInstanceIn
42+
import kotlinx.coroutines.CoroutineScope
43+
import kotlinx.coroutines.flow.filterNotNull
44+
import kotlinx.coroutines.launch
45+
import kotlinx.coroutines.withContext
46+
import logcat.LogPriority.WARN
47+
import logcat.asLog
48+
import logcat.logcat
49+
import java.util.concurrent.TimeUnit
50+
import javax.inject.Inject
51+
52+
@ContributesWorker(AppScope::class)
53+
class ScriptletDownloadWorker(
54+
context: Context,
55+
workerParameters: WorkerParameters,
56+
) : CoroutineWorker(context, workerParameters) {
57+
58+
@Inject
59+
lateinit var updater: ScriptletUpdater
60+
61+
@Inject
62+
lateinit var settingsAdapter: JsonAdapter<ScriptletsSettings>
63+
64+
@Inject
65+
lateinit var dispatchers: DispatcherProvider
66+
67+
override suspend fun doWork(): Result = withContext(dispatchers.io()) {
68+
logcat { "Starting ScriptletDownloadWorker" }
69+
val settingsJson = inputData.getString(KEY_SETTINGS) ?: return@withContext Result.failure()
70+
val settings = runCatching { settingsAdapter.fromJson(settingsJson) }
71+
.onFailure { logcat(WARN) { "ScriptletDownloadWorker: failed to parse settings input: ${it.asLog()}" } }
72+
.getOrNull()
73+
?: return@withContext Result.failure()
74+
75+
when (updater.update(settings)) {
76+
ScriptletUpdateResult.Success -> Result.success()
77+
ScriptletUpdateResult.Retry -> Result.retry()
78+
}
79+
}
80+
81+
companion object {
82+
const val KEY_SETTINGS = "settings"
83+
}
84+
}
85+
86+
@ContributesMultibinding(scope = AppScope::class, boundType = MainProcessLifecycleObserver::class)
87+
@SingleInstanceIn(AppScope::class)
88+
class ScriptletDownloadWorkerScheduler @Inject constructor(
89+
private val workManager: WorkManager,
90+
private val configProvider: AdBlockingExtensionConfigProvider,
91+
private val settingsAdapter: JsonAdapter<ScriptletsSettings>,
92+
@AppCoroutineScope private val appScope: CoroutineScope,
93+
) : MainProcessLifecycleObserver {
94+
95+
override fun onCreate(owner: LifecycleOwner) {
96+
appScope.launch {
97+
configProvider.scriptletsSettings.filterNotNull().collect { settings ->
98+
enqueue(settings)
99+
}
100+
}
101+
}
102+
103+
private fun enqueue(settings: ScriptletsSettings) {
104+
val json = settingsAdapter.toJson(settings)
105+
val request = OneTimeWorkRequestBuilder<ScriptletDownloadWorker>()
106+
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
107+
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_INITIAL_SECONDS, TimeUnit.SECONDS)
108+
.setInputData(workDataOf(ScriptletDownloadWorker.KEY_SETTINGS to json))
109+
.build()
110+
workManager.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
111+
}
112+
113+
companion object {
114+
const val WORK_NAME = "AdBlockingExtensionScriptletDownloadWorker"
115+
private const val BACKOFF_INITIAL_SECONDS = 30L
116+
}
117+
}

ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/di/AdBlockingExtensionModule.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.adblocking.impl.di
1919
import com.duckduckgo.adblocking.impl.domain.PublicKeyProvider
2020
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionSettings
2121
import com.duckduckgo.adblocking.impl.remoteconfig.DomainJsonAdapter
22+
import com.duckduckgo.adblocking.impl.remoteconfig.ScriptletsSettings
2223
import com.duckduckgo.adblocking.impl.store.AD_BLOCKING_EXTENSION_MIGRATIONS
2324
import com.duckduckgo.adblocking.impl.store.AdBlockingExtensionDao
2425
import com.duckduckgo.adblocking.impl.store.AdBlockingExtensionDatabase
@@ -86,10 +87,22 @@ object AdBlockingExtensionModule {
8687

8788
@SingleInstanceIn(AppScope::class)
8889
@Provides
89-
fun provideAdBlockingExtensionSettingsAdapter(): JsonAdapter<AdBlockingExtensionSettings> =
90-
Moshi.Builder()
90+
fun provideMoshiBuilder(): Moshi.Builder = Moshi.Builder()
91+
92+
@SingleInstanceIn(AppScope::class)
93+
@Provides
94+
fun provideAdBlockingExtensionSettingsAdapter(moshiBuilder: Moshi.Builder): JsonAdapter<AdBlockingExtensionSettings> =
95+
moshiBuilder
9196
.add(Domain::class.java, DomainJsonAdapter().nullSafe())
9297
.add(KotlinJsonAdapterFactory())
9398
.build()
9499
.adapter(AdBlockingExtensionSettings::class.java)
100+
101+
@SingleInstanceIn(AppScope::class)
102+
@Provides
103+
fun provideScriptletsSettingsAdapter(moshiBuilder: Moshi.Builder): JsonAdapter<ScriptletsSettings> =
104+
moshiBuilder
105+
.add(KotlinJsonAdapterFactory())
106+
.build()
107+
.adapter(ScriptletsSettings::class.java)
95108
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.adblocking.impl.domain
18+
19+
import com.duckduckgo.adblocking.impl.AdBlockingExtensionRepository
20+
import com.duckduckgo.adblocking.impl.ScriptletDownloader
21+
import com.duckduckgo.adblocking.impl.remoteconfig.ScriptletsSettings
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.squareup.anvil.annotations.ContributesBinding
24+
import dagger.SingleInstanceIn
25+
import kotlinx.coroutines.async
26+
import kotlinx.coroutines.awaitAll
27+
import kotlinx.coroutines.coroutineScope
28+
import logcat.LogPriority.WARN
29+
import logcat.logcat
30+
import javax.inject.Inject
31+
32+
sealed interface ScriptletUpdateResult {
33+
data object Success : ScriptletUpdateResult
34+
data object Retry : ScriptletUpdateResult
35+
}
36+
37+
interface ScriptletUpdater {
38+
suspend fun update(settings: ScriptletsSettings): ScriptletUpdateResult
39+
}
40+
41+
@SingleInstanceIn(AppScope::class)
42+
@ContributesBinding(AppScope::class)
43+
class RealScriptletUpdater @Inject constructor(
44+
private val repository: AdBlockingExtensionRepository,
45+
private val downloader: ScriptletDownloader,
46+
private val validator: ScriptletSignatureValidator,
47+
) : ScriptletUpdater {
48+
49+
override suspend fun update(settings: ScriptletsSettings): ScriptletUpdateResult {
50+
if (settings.version == repository.getStoredVersion()) {
51+
logcat { "Version matches stored. Skipping" }
52+
return ScriptletUpdateResult.Success
53+
}
54+
55+
val downloaded = try {
56+
coroutineScope {
57+
settings.scriptlets.map { (name, entry) ->
58+
async {
59+
logcat { "Downloading ${entry.url}" }
60+
val bytes = downloader.download(entry.url).getOrElse {
61+
logcat(WARN) { "ScriptletUpdater: download failed for $name: ${it.message}" }
62+
throw ScriptletFailure()
63+
}
64+
65+
when (val validationResult = validator.validate(bytes, entry.signature)) {
66+
ScriptletValidationResult.Valid -> name to bytes
67+
is ScriptletValidationResult.Invalid -> {
68+
logcat(WARN) { "ScriptletUpdater: validation failed for $name: $validationResult" }
69+
throw ScriptletFailure()
70+
}
71+
}
72+
}
73+
}.awaitAll().toMap()
74+
}
75+
} catch (_: ScriptletFailure) {
76+
return ScriptletUpdateResult.Retry
77+
}
78+
79+
logcat { "Storing scriptlets" }
80+
repository.storeScriptlets(settings.version, downloaded)
81+
return ScriptletUpdateResult.Success
82+
}
83+
84+
private class ScriptletFailure : RuntimeException()
85+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.adblocking.impl
18+
19+
import com.duckduckgo.adblocking.impl.domain.RealScriptletUpdater
20+
import com.duckduckgo.adblocking.impl.domain.ScriptletSignatureValidator
21+
import com.duckduckgo.adblocking.impl.domain.ScriptletUpdateResult
22+
import com.duckduckgo.adblocking.impl.domain.ScriptletValidationResult
23+
import com.duckduckgo.adblocking.impl.remoteconfig.ScriptletEntry
24+
import com.duckduckgo.adblocking.impl.remoteconfig.ScriptletsSettings
25+
import kotlinx.coroutines.CompletableDeferred
26+
import kotlinx.coroutines.awaitCancellation
27+
import kotlinx.coroutines.test.runTest
28+
import org.junit.Assert.assertEquals
29+
import org.junit.Assert.assertTrue
30+
import org.junit.Test
31+
import org.mockito.kotlin.any
32+
import org.mockito.kotlin.check
33+
import org.mockito.kotlin.eq
34+
import org.mockito.kotlin.mock
35+
import org.mockito.kotlin.never
36+
import org.mockito.kotlin.verify
37+
import org.mockito.kotlin.whenever
38+
import java.io.IOException
39+
40+
class RealScriptletUpdaterTest {
41+
42+
private val repository: AdBlockingExtensionRepository = mock()
43+
private val downloader: ScriptletDownloader = mock()
44+
private val validator: ScriptletSignatureValidator = mock()
45+
46+
private val updater = RealScriptletUpdater(repository, downloader, validator)
47+
48+
private val isolatedPath = "scriptlets/isolated/ublock-filters.js"
49+
private val mainPath = "scriptlets/main/ublock-filters.js"
50+
private val isolatedEntry = ScriptletEntry(url = "https://cdn.example/isolated.js", signature = "iso-sig")
51+
private val mainEntry = ScriptletEntry(url = "https://cdn.example/main.js", signature = "main-sig")
52+
private val isolatedBytes = "isolated-bytes".toByteArray()
53+
private val mainBytes = "main-bytes".toByteArray()
54+
55+
private val validSettings = ScriptletsSettings(
56+
version = "2026.3.9",
57+
scriptlets = mapOf(isolatedPath to isolatedEntry, mainPath to mainEntry),
58+
)
59+
60+
@Test
61+
fun whenStoredVersionMatchesRemoteVersionThenUpdateReturnsSuccess() = runTest {
62+
whenever(repository.getStoredVersion()).thenReturn("2026.3.9")
63+
64+
assertEquals(ScriptletUpdateResult.Success, updater.update(validSettings))
65+
verify(downloader, never()).download(any())
66+
verify(repository, never()).storeScriptlets(any(), any())
67+
}
68+
69+
@Test
70+
fun whenSingleDownloadFailsThenUpdateReturnsRetryAndDoesNotPersist() = runTest {
71+
whenever(repository.getStoredVersion()).thenReturn("0.0.0")
72+
whenever(downloader.download(isolatedEntry.url)).thenReturn(kotlin.Result.success(isolatedBytes))
73+
whenever(downloader.download(mainEntry.url)).thenReturn(kotlin.Result.failure(IOException("boom")))
74+
whenever(validator.validate(isolatedBytes, isolatedEntry.signature)).thenReturn(ScriptletValidationResult.Valid)
75+
76+
assertEquals(ScriptletUpdateResult.Retry, updater.update(validSettings))
77+
verify(repository, never()).storeScriptlets(any(), any())
78+
}
79+
80+
@Test
81+
fun whenValidationFailsForAnyScriptletThenUpdateReturnsRetryAndDoesNotPersist() = runTest {
82+
whenever(repository.getStoredVersion()).thenReturn("0.0.0")
83+
whenever(downloader.download(isolatedEntry.url)).thenReturn(kotlin.Result.success(isolatedBytes))
84+
whenever(downloader.download(mainEntry.url)).thenReturn(kotlin.Result.success(mainBytes))
85+
whenever(validator.validate(isolatedBytes, isolatedEntry.signature)).thenReturn(ScriptletValidationResult.Valid)
86+
whenever(validator.validate(mainBytes, mainEntry.signature))
87+
.thenReturn(ScriptletValidationResult.Invalid.SignatureVerificationFailed)
88+
89+
assertEquals(ScriptletUpdateResult.Retry, updater.update(validSettings))
90+
verify(repository, never()).storeScriptlets(any(), any())
91+
}
92+
93+
@Test
94+
fun whenAllScriptletsValidThenUpdateReturnsSuccessAndStoresAllScriptlets() = runTest {
95+
whenever(repository.getStoredVersion()).thenReturn("0.0.0")
96+
whenever(downloader.download(isolatedEntry.url)).thenReturn(kotlin.Result.success(isolatedBytes))
97+
whenever(downloader.download(mainEntry.url)).thenReturn(kotlin.Result.success(mainBytes))
98+
whenever(validator.validate(isolatedBytes, isolatedEntry.signature)).thenReturn(ScriptletValidationResult.Valid)
99+
whenever(validator.validate(mainBytes, mainEntry.signature)).thenReturn(ScriptletValidationResult.Valid)
100+
101+
assertEquals(ScriptletUpdateResult.Success, updater.update(validSettings))
102+
verify(repository).storeScriptlets(
103+
eq("2026.3.9"),
104+
check { stored ->
105+
assertEquals(setOf(isolatedPath, mainPath), stored.keys)
106+
assertEquals(String(isolatedBytes), String(stored.getValue(isolatedPath)))
107+
assertEquals(String(mainBytes), String(stored.getValue(mainPath)))
108+
},
109+
)
110+
}
111+
112+
@Test
113+
fun whenSettingsHasNoScriptletsThenUpdateReturnsSuccessAndStoresEmptyMap() = runTest {
114+
whenever(repository.getStoredVersion()).thenReturn("0.0.0")
115+
116+
assertEquals(ScriptletUpdateResult.Success, updater.update(validSettings.copy(scriptlets = emptyMap())))
117+
verify(downloader, never()).download(any())
118+
verify(repository).storeScriptlets(eq("2026.3.9"), eq(emptyMap()))
119+
}
120+
121+
@Test
122+
fun whenOneDownloadFailsThenUpdateShortCircuitsAndCancelsInFlightDownloads() = runTest {
123+
whenever(repository.getStoredVersion()).thenReturn("0.0.0")
124+
val isolatedDownloadStarted = CompletableDeferred<Unit>()
125+
val shortCircuitingDownloader = object : ScriptletDownloader {
126+
override suspend fun download(url: String): kotlin.Result<ByteArray> = when (url) {
127+
isolatedEntry.url -> {
128+
isolatedDownloadStarted.complete(Unit)
129+
awaitCancellation()
130+
}
131+
mainEntry.url -> kotlin.Result.failure(IOException("boom"))
132+
else -> error("unexpected url: $url")
133+
}
134+
}
135+
val shortCircuitingUpdater = RealScriptletUpdater(repository, shortCircuitingDownloader, validator)
136+
137+
assertEquals(ScriptletUpdateResult.Retry, shortCircuitingUpdater.update(validSettings))
138+
assertTrue("isolated download should have started in parallel", isolatedDownloadStarted.isCompleted)
139+
verify(validator, never()).validate(any(), any())
140+
verify(repository, never()).storeScriptlets(any(), any())
141+
}
142+
}

0 commit comments

Comments
 (0)