From aab9d0cb6d6123aacd3945f69e4288f345305a5f Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Thu, 12 Mar 2026 09:12:11 -0700 Subject: [PATCH 1/6] fix: improve core:network module quality and test coverage - Fix integer division bug in BinaryFileWriter progress calculation (totalBytes.toDouble() / length instead of totalBytes / length) - Fix response body leak in downloadRules (use response.use{}) - Fix downloadRules using blocking .execute() instead of async .await() - Inject DI-provided Json instance instead of using global default - Replace silent error swallowing with NetworkException for proper error propagation to callers - Remove unused Retrofit dependencies (only OkHttp is used) - Rename RetrofitBlockerNetwork to OkHttpBlockerNetwork - Remove unused FakeAssetManager from production DI graph - Increase BinaryFileWriter chunk size from 1KB to 8KB - Update SyncWorker to handle network exceptions properly - Add comprehensive tests for JSON parsing, progress tracking, model mappers, and edge cases - Update dependency guard baseline --- .../marketReleaseRuntimeClasspath.txt | 2 - core/network/build.gradle.kts | 7 +- core/network/consumer-proguard-rules.pro | 6 +- .../blocker/core/network/di/NetworkModule.kt | 14 +- .../core/network/io/BinaryFileWriter.kt | 11 +- .../core/network/retrofit/NetworkException.kt | 24 ++++ .../network/retrofit/OkHttpBlockerNetwork.kt | 108 ++++++++++++++++ .../retrofit/RetrofitBlockerNetwork.kt | 118 ----------------- .../network/io/BinaryFileWriterUnitTest.kt | 87 +++++++++++-- .../network/model/NetworkModelMapperTest.kt | 115 +++++++++++++++++ .../retrofit/OkHttpBlockerNetworkTest.kt | 120 ++++++++++++++++++ gradle/libs.versions.toml | 1 + .../blocker/sync/workers/SyncWorker.kt | 86 +++++++------ 13 files changed, 503 insertions(+), 196 deletions(-) create mode 100644 core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt create mode 100644 core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt delete mode 100644 core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/RetrofitBlockerNetwork.kt create mode 100644 core/network/src/test/kotlin/com/merxury/blocker/core/network/model/NetworkModelMapperTest.kt create mode 100644 core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt diff --git a/app-compose/dependencies/marketReleaseRuntimeClasspath.txt b/app-compose/dependencies/marketReleaseRuntimeClasspath.txt index feebf0a8b0..4b4352c6ea 100644 --- a/app-compose/dependencies/marketReleaseRuntimeClasspath.txt +++ b/app-compose/dependencies/marketReleaseRuntimeClasspath.txt @@ -255,8 +255,6 @@ com.squareup.okhttp3:okhttp-android:5.3.2 com.squareup.okhttp3:okhttp:5.3.2 com.squareup.okio:okio-jvm:3.16.4 com.squareup.okio:okio:3.16.4 -com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0 -com.squareup.retrofit2:retrofit:3.0.0 commons-codec:commons-codec:1.20.0 dev.drewhamilton.poko:poko-annotations-jvm:0.21.1 dev.drewhamilton.poko:poko-annotations:0.21.1 diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 82496710af..8b32371005 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -36,10 +36,11 @@ dependencies { api(libs.kotlinx.datetime) api(projects.core.common) api(projects.core.model) - testImplementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp.logging) - implementation(libs.retrofit.core) - implementation(libs.retrofit.kotlin.serialization) + + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.truth) } diff --git a/core/network/consumer-proguard-rules.pro b/core/network/consumer-proguard-rules.pro index 457b86b0e1..367945873e 100644 --- a/core/network/consumer-proguard-rules.pro +++ b/core/network/consumer-proguard-rules.pro @@ -1,8 +1,4 @@ - # Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). - -keep,allowobfuscation,allowshrinking interface retrofit2.Call - -keep,allowobfuscation,allowshrinking class retrofit2.Response - # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. - -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation \ No newline at end of file + -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt index eaeae04a68..d605d892a4 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt @@ -17,16 +17,13 @@ package com.merxury.blocker.core.network.di -import android.content.Context import androidx.tracing.trace import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.network.BuildConfig -import com.merxury.blocker.core.network.fake.FakeAssetManager -import com.merxury.blocker.core.network.retrofit.RetrofitBlockerNetwork +import com.merxury.blocker.core.network.retrofit.OkHttpBlockerNetwork import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import okhttp3.Call @@ -66,11 +63,6 @@ internal object NetworkModule { @Singleton fun provideBlockerNetworkDataSource( okHttpCallFactory: dagger.Lazy, - ): BlockerNetworkDataSource = RetrofitBlockerNetwork(okHttpCallFactory) - - @Provides - @Singleton - fun providesFakeAssetManager( - @ApplicationContext context: Context, - ): FakeAssetManager = FakeAssetManager(context.assets::open) + networkJson: Json, + ): BlockerNetworkDataSource = OkHttpBlockerNetwork(okHttpCallFactory, networkJson) } diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriter.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriter.kt index e4af480e75..d4cca299c8 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriter.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriter.kt @@ -22,7 +22,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream -private const val CHUNK_SIZE = 1024 +private const val CHUNK_SIZE = 8192 class BinaryFileWriter( private val outputStream: OutputStream, @@ -31,19 +31,18 @@ class BinaryFileWriter( @Throws(IOException::class) fun write(inputStream: InputStream?, length: Long): Long { - if (length.toInt() == 0) { - Timber.w("Nothing to write, file length is 0") + if (length <= 0L) { + Timber.w("Nothing to write, file length is $length") return 0 } BufferedInputStream(inputStream).use { input -> - val dataBuffer = - ByteArray(CHUNK_SIZE) + val dataBuffer = ByteArray(CHUNK_SIZE) var readBytes: Int var totalBytes: Long = 0 while (input.read(dataBuffer).also { readBytes = it } != -1) { totalBytes += readBytes.toLong() outputStream.write(dataBuffer, 0, readBytes) - onProgressUpdate.invoke(totalBytes / length * 100.0) + onProgressUpdate.invoke(totalBytes.toDouble() / length * 100.0) } return totalBytes } diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt new file mode 100644 index 0000000000..16b320a2cd --- /dev/null +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Blocker + * + * 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 + * + * https://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.merxury.blocker.core.network.retrofit + +import java.io.IOException + +class NetworkException( + message: String, + cause: Throwable? = null, +) : IOException(message, cause) diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt new file mode 100644 index 0000000000..584af3e55a --- /dev/null +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2025 Blocker + * Copyright 2022 The Android Open Source Project + * + * 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 + * + * https://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.merxury.blocker.core.network.retrofit + +import com.merxury.blocker.core.model.preference.RuleServerProvider +import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB +import com.merxury.blocker.core.network.BlockerNetworkDataSource +import com.merxury.blocker.core.network.io.BinaryFileWriter +import com.merxury.blocker.core.network.model.NetworkChangeList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Call +import okhttp3.Request +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * OkHttp backed [BlockerNetworkDataSource] + */ +@Singleton +internal class OkHttpBlockerNetwork @Inject constructor( + private val okhttpCallFactory: dagger.Lazy, + private val networkJson: Json, +) : BlockerNetworkDataSource { + + override suspend fun getRuleLatestCommitId(provider: RuleServerProvider): NetworkChangeList { + val request = Request.Builder() + .url(provider.commitApiUrl) + .build() + val json = okhttpCallFactory.get() + .newCall(request) + .await() + .use { response -> + if (!response.isSuccessful) { + throw NetworkException("Failed to get latest commit id: HTTP ${response.code}") + } + response.body.string() + } + val commitId = getLatestCommitId(provider, json) + return NetworkChangeList(commitId) + } + + override suspend fun downloadRules( + provider: RuleServerProvider, + writer: BinaryFileWriter, + ): Long { + Timber.d("Downloading rules from ${provider.downloadLink}") + val request = Request.Builder() + .url(provider.downloadLink) + .build() + val response = okhttpCallFactory.get() + .newCall(request) + .await() + return response.use { + if (!it.isSuccessful) { + throw NetworkException("Failed to download rules: HTTP ${it.code}") + } + val responseBody = it.body + val contentLength = responseBody.contentLength() + if (contentLength == 0L) { + Timber.e("Response body is empty.") + return@use 0L + } + Timber.v("Zip length: $contentLength") + withContext(Dispatchers.IO) { + writer.write(responseBody.byteStream(), contentLength) + } + } + } + + internal fun getLatestCommitId(provider: RuleServerProvider, json: String): String { + if (json.isBlank()) { + throw NetworkException("Empty response body when fetching commit id") + } + val elements = networkJson.parseToJsonElement(json) + val firstElementInList = elements.jsonArray.firstOrNull() + ?: throw NetworkException("Empty commit list in response") + val commitId = if (provider == GITHUB) { + firstElementInList.jsonObject["sha"]?.jsonPrimitive?.content + } else { + firstElementInList.jsonObject["id"]?.jsonPrimitive?.content + } + if (commitId.isNullOrBlank()) { + throw NetworkException("Missing commit id in response JSON") + } + return commitId + } +} diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/RetrofitBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/RetrofitBlockerNetwork.kt deleted file mode 100644 index 28cdd5114b..0000000000 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/RetrofitBlockerNetwork.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2025 Blocker - * Copyright 2022 The Android Open Source Project - * - * 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 - * - * https://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.merxury.blocker.core.network.retrofit - -import com.merxury.blocker.core.model.preference.RuleServerProvider -import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB -import com.merxury.blocker.core.network.BlockerNetworkDataSource -import com.merxury.blocker.core.network.io.BinaryFileWriter -import com.merxury.blocker.core.network.model.NetworkChangeList -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Call -import okhttp3.Request -import retrofit2.Retrofit -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * [Retrofit] backed [BlockerNetworkDataSource] - */ -@Singleton -internal class RetrofitBlockerNetwork @Inject constructor( - private val okhttpCallFactory: dagger.Lazy, -) : BlockerNetworkDataSource { - - override suspend fun getRuleLatestCommitId(provider: RuleServerProvider): NetworkChangeList { - val request = Request.Builder() - .url(provider.commitApiUrl) - .build() - return try { - val json = okhttpCallFactory.get() - .newCall(request) - .await() - .body - .string() - val commitId = getLatestCommitId(provider, json) - NetworkChangeList(commitId) - } catch (e: Exception) { - Timber.e(e, "Failed to get latest commit id from $provider") - NetworkChangeList("") - } - } - - override suspend fun downloadRules( - provider: RuleServerProvider, - writer: BinaryFileWriter, - ): Long { - Timber.d("Downloading rules from ${provider.downloadLink}") - val request = Request.Builder() - .url(provider.downloadLink) - .build() - val response = okhttpCallFactory.get() - .newCall(request) - .execute() - if (!response.isSuccessful) { - Timber.e("Failed to download rules from ${provider.downloadLink}") - return 0 - } - val responseBody = response.body - val contentLength = responseBody.contentLength() - if (contentLength == 0L) { - Timber.e("Response body is empty.") - return 0 - } - Timber.v("Zip length: $contentLength") - return writer.write(responseBody.byteStream(), contentLength) - } - - private fun getLatestCommitId(provider: RuleServerProvider, json: String): String { - if (json.isBlank()) { - Timber.e("Json is blank, cannot get latest commit id.") - return "" - } - try { - val elements = Json.parseToJsonElement(json) - val firstElementInList = elements.jsonArray.firstOrNull() - if (firstElementInList == null) { - Timber.e("Cannot get first element in list.") - return "" - } - val commitId = if (provider == GITHUB) { - firstElementInList.jsonObject["sha"]?.jsonPrimitive?.content - } else { - firstElementInList.jsonObject["id"]?.jsonPrimitive?.content - } - if (commitId.isNullOrBlank()) { - Timber.e("Cannot get commit id from json.") - return "" - } - return commitId - } catch (e: SerializationException) { - Timber.e(e, "The given string is not a valid JSON") - return "" - } catch (e: IllegalArgumentException) { - Timber.e(e, "Malformed JSON string") - return "" - } - } -} diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriterUnitTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriterUnitTest.kt index 8671a439a5..068db09689 100644 --- a/core/network/src/test/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriterUnitTest.kt +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/io/BinaryFileWriterUnitTest.kt @@ -16,29 +16,94 @@ package com.merxury.blocker.core.network.io +import com.google.common.truth.Truth.assertThat import org.junit.Test -import java.io.PipedInputStream -import java.io.PipedOutputStream +import java.io.ByteArrayOutputStream class BinaryFileWriterUnitTest { - private val inputStream = PipedInputStream() - private val outputStream = PipedOutputStream(inputStream) @Test - fun givenInputStream_whenWrite_thenExpectWritten() { + fun givenContent_whenWrite_thenOutputMatchesInput() { val content = "Hello" - BinaryFileWriter(outputStream).use { + val output = ByteArrayOutputStream() + BinaryFileWriter(output).use { + val written = it.write(content.byteInputStream(), content.length.toLong()) + assertThat(written).isEqualTo(content.length.toLong()) + } + assertThat(output.toByteArray()).isEqualTo(content.toByteArray()) + } + + @Test + fun givenEmptyContent_whenWrite_thenReturnsZero() { + val output = ByteArrayOutputStream() + BinaryFileWriter(output).use { + val written = it.write("".byteInputStream(), 0L) + assertThat(written).isEqualTo(0L) + } + assertThat(output.size()).isEqualTo(0) + } + + @Test + fun givenLargeContent_whenWrite_thenAllBytesWritten() { + // Content larger than CHUNK_SIZE (8192) to test multi-chunk writing + val content = "A".repeat(20_000) + val output = ByteArrayOutputStream() + BinaryFileWriter(output).use { + val written = it.write(content.byteInputStream(), content.length.toLong()) + assertThat(written).isEqualTo(content.length.toLong()) + } + assertThat(output.toByteArray()).isEqualTo(content.toByteArray()) + } + + @Test + fun givenContent_whenWrite_thenProgressUpdatesReported() { + val content = "A".repeat(20_000) + val progressValues = mutableListOf() + val output = ByteArrayOutputStream() + BinaryFileWriter(output, onProgressUpdate = { progressValues.add(it) }).use { it.write(content.byteInputStream(), content.length.toLong()) } - assert(inputStream.readBytes().contentEquals(content.toByteArray())) + assertThat(progressValues).isNotEmpty() + // Progress should be monotonically increasing + progressValues.zipWithNext().forEach { (prev, next) -> + assertThat(next).isAtLeast(prev) + } + // Last progress should be 100% + assertThat(progressValues.last()).isWithin(0.01).of(100.0) } @Test - fun givenInputStreamEmpty_whenWrite_thenExpectNotWritten() { - val content = "" - BinaryFileWriter(outputStream).use { + fun givenContent_whenWrite_thenProgressIsNotAlwaysZero() { + // This specifically tests the integer division bug fix: + // Before the fix, totalBytes / length would always be 0 for intermediate chunks + val content = "A".repeat(20_000) + val progressValues = mutableListOf() + val output = ByteArrayOutputStream() + BinaryFileWriter(output, onProgressUpdate = { progressValues.add(it) }).use { it.write(content.byteInputStream(), content.length.toLong()) } - assert(inputStream.readBytes().contentEquals(content.toByteArray())) + // With the fix, intermediate progress values should be > 0 and < 100 + val intermediateValues = progressValues.dropLast(1) + assertThat(intermediateValues).isNotEmpty() + intermediateValues.forEach { value -> + assertThat(value).isGreaterThan(0.0) + } + } + + @Test + fun givenNegativeLength_whenWrite_thenReturnsZero() { + val output = ByteArrayOutputStream() + BinaryFileWriter(output).use { + val written = it.write("data".byteInputStream(), -1L) + assertThat(written).isEqualTo(0L) + } + } + + @Test + fun givenClose_whenCalled_thenOutputStreamIsClosed() { + val output = ByteArrayOutputStream() + val writer = BinaryFileWriter(output) + writer.close() + // ByteArrayOutputStream.close() is a no-op, but we verify no exception is thrown } } diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/model/NetworkModelMapperTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/model/NetworkModelMapperTest.kt new file mode 100644 index 0000000000..68d6511915 --- /dev/null +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/model/NetworkModelMapperTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Blocker + * + * 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 + * + * https://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.merxury.blocker.core.network.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NetworkModelMapperTest { + + @Test + fun givenNetworkComponentDetail_whenAsExternalModel_thenAllFieldsMapped() { + val network = NetworkComponentDetail( + name = "com.example.Service", + sdkName = "ExampleSDK", + description = "A test service", + disableEffect = "No effect", + contributor = "tester", + addedVersion = "1.0", + recommendToBlock = true, + ) + val external = network.asExternalModel() + assertThat(external.name).isEqualTo("com.example.Service") + assertThat(external.sdkName).isEqualTo("ExampleSDK") + assertThat(external.description).isEqualTo("A test service") + assertThat(external.disableEffect).isEqualTo("No effect") + assertThat(external.contributor).isEqualTo("tester") + assertThat(external.addedVersion).isEqualTo("1.0") + assertThat(external.recommendToBlock).isTrue() + } + + @Test + fun givenNetworkComponentDetailWithNulls_whenAsExternalModel_thenNullsPreserved() { + val network = NetworkComponentDetail(name = "com.example.Service") + val external = network.asExternalModel() + assertThat(external.name).isEqualTo("com.example.Service") + assertThat(external.sdkName).isNull() + assertThat(external.description).isNull() + assertThat(external.disableEffect).isNull() + assertThat(external.contributor).isNull() + assertThat(external.addedVersion).isNull() + assertThat(external.recommendToBlock).isFalse() + } + + @Test + fun givenComponentDetail_whenAsNetworkModel_thenRoundTrips() { + val original = NetworkComponentDetail( + name = "com.example.Receiver", + sdkName = "SDK", + description = "desc", + disableEffect = "effect", + contributor = "user", + addedVersion = "2.0", + recommendToBlock = false, + ) + val roundTripped = original.asExternalModel().asNetworkModel() + assertThat(roundTripped).isEqualTo(original) + } + + @Test + fun givenNetworkGeneralRule_whenAsExternalModel_thenAllFieldsMapped() { + val network = NetworkGeneralRule( + id = 1, + name = "Test Rule", + iconUrl = "https://example.com/icon.png", + company = "TestCo", + searchKeyword = listOf("keyword1", "keyword2"), + useRegexSearch = true, + description = "A test rule", + safeToBlock = true, + sideEffect = "None", + contributors = listOf("user1", "user2"), + ) + val external = network.asExternalModel() + assertThat(external.id).isEqualTo(1) + assertThat(external.name).isEqualTo("Test Rule") + assertThat(external.iconUrl).isEqualTo("https://example.com/icon.png") + assertThat(external.company).isEqualTo("TestCo") + assertThat(external.searchKeyword).containsExactly("keyword1", "keyword2") + assertThat(external.useRegexSearch).isTrue() + assertThat(external.description).isEqualTo("A test rule") + assertThat(external.safeToBlock).isTrue() + assertThat(external.sideEffect).isEqualTo("None") + assertThat(external.contributors).containsExactly("user1", "user2") + } + + @Test + fun givenNetworkGeneralRuleWithDefaults_whenAsExternalModel_thenDefaultsPreserved() { + val network = NetworkGeneralRule(id = 42, name = "Minimal Rule") + val external = network.asExternalModel() + assertThat(external.id).isEqualTo(42) + assertThat(external.name).isEqualTo("Minimal Rule") + assertThat(external.iconUrl).isNull() + assertThat(external.company).isNull() + assertThat(external.searchKeyword).isEmpty() + assertThat(external.useRegexSearch).isNull() + assertThat(external.description).isNull() + assertThat(external.safeToBlock).isNull() + assertThat(external.sideEffect).isNull() + assertThat(external.contributors).isEmpty() + } +} diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt new file mode 100644 index 0000000000..799370482e --- /dev/null +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2025 Blocker + * + * 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 + * + * https://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.merxury.blocker.core.network.retrofit + +import com.google.common.truth.Truth.assertThat +import com.merxury.blocker.core.model.preference.RuleServerProvider +import kotlinx.serialization.json.Json +import org.junit.Assert.assertThrows +import org.junit.Test + +class OkHttpBlockerNetworkTest { + + private val json = Json { ignoreUnknownKeys = true } + private val network = OkHttpBlockerNetwork( + okhttpCallFactory = dagger.Lazy { error("Not used in these tests") }, + networkJson = json, + ) + + @Test + fun givenGitHubJson_whenGetLatestCommitId_thenReturnsSha() { + val githubJson = """[{"sha": "abc123", "node_id": "xyz"}]""" + val result = network.getLatestCommitId(RuleServerProvider.GITHUB, githubJson) + assertThat(result).isEqualTo("abc123") + } + + @Test + fun givenGitLabJson_whenGetLatestCommitId_thenReturnsId() { + val gitlabJson = """[{"id": "def456", "short_id": "def4"}]""" + val result = network.getLatestCommitId(RuleServerProvider.GITLAB, gitlabJson) + assertThat(result).isEqualTo("def456") + } + + @Test + fun givenEmptyJson_whenGetLatestCommitId_thenThrows() { + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, "") + } + } + + @Test + fun givenBlankJson_whenGetLatestCommitId_thenThrows() { + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, " ") + } + } + + @Test + fun givenEmptyArray_whenGetLatestCommitId_thenThrows() { + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, "[]") + } + } + + @Test + fun givenMissingShaField_whenGetLatestCommitId_thenThrows() { + val json = """[{"node_id": "xyz"}]""" + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, json) + } + } + + @Test + fun givenMissingIdField_whenGetLatestCommitId_thenThrows() { + val json = """[{"short_id": "def4"}]""" + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITLAB, json) + } + } + + @Test + fun givenInvalidJson_whenGetLatestCommitId_thenThrows() { + assertThrows(Exception::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, "not json") + } + } + + @Test + fun givenGitHubJsonWithExtraFields_whenGetLatestCommitId_thenReturnsSha() { + val githubJson = """[{ + "sha": "abc123", + "node_id": "xyz", + "commit": {"message": "test"}, + "author": {"login": "user"} + }]""" + val result = network.getLatestCommitId(RuleServerProvider.GITHUB, githubJson) + assertThat(result).isEqualTo("abc123") + } + + @Test + fun givenMultipleCommits_whenGetLatestCommitId_thenReturnsFirst() { + val githubJson = """[ + {"sha": "first123"}, + {"sha": "second456"} + ]""" + val result = network.getLatestCommitId(RuleServerProvider.GITHUB, githubJson) + assertThat(result).isEqualTo("first123") + } + + @Test + fun givenBlankSha_whenGetLatestCommitId_thenThrows() { + val githubJson = """[{"sha": ""}]""" + assertThrows(NetworkException::class.java) { + network.getLatestCommitId(RuleServerProvider.GITHUB, githubJson) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04483c10d5..341c853e1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -180,6 +180,7 @@ material = { group = "com.google.android.material", name = "material", version.r material-kolor = { group = "com.materialkolor", name = "material-kolor", version.ref = "materialKolor" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3-junit4", version.ref = "okhttp" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } diff --git a/sync/work/src/main/kotlin/com/merxury/blocker/sync/workers/SyncWorker.kt b/sync/work/src/main/kotlin/com/merxury/blocker/sync/workers/SyncWorker.kt index f9d3c4bfa5..ad94124ed6 100644 --- a/sync/work/src/main/kotlin/com/merxury/blocker/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/kotlin/com/merxury/blocker/sync/workers/SyncWorker.kt @@ -38,6 +38,7 @@ import com.merxury.blocker.core.dispatchers.BlockerDispatchers.IO import com.merxury.blocker.core.dispatchers.Dispatcher import com.merxury.blocker.core.git.GitClient import com.merxury.blocker.core.git.RepositoryInfo +import com.merxury.blocker.core.model.preference.RuleServerProvider import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.rule.work.CopyRulesToStorageWorker import com.merxury.blocker.core.utils.AppDebugChecker @@ -114,17 +115,32 @@ internal class SyncWorker @AssistedInject constructor( Timber.d("Syncing rule...") val provider = userDataRepository.userData.first().ruleServerProvider val localCommitId = getChangeListVersions().ruleCommitId - val latestCommitId = network.getRuleLatestCommitId(provider) - .ruleCommitId - if (localCommitId.isNotBlank() && localCommitId == latestCommitId) { - Timber.i("Local rule is up to date, skip syncing rules.") - return true + try { + val latestCommitId = network.getRuleLatestCommitId(provider) + .ruleCommitId + if (localCommitId.isNotBlank() && localCommitId == latestCommitId) { + Timber.i("Local rule is up to date, skip syncing rules.") + return true + } + Timber.i( + "Last synced commit id: $localCommitId, latest commit id: $latestCommitId" + + ", start pulling rule...", + ) + waitForCopyTaskFinish() + return syncFromRemote(provider, latestCommitId) + } catch (e: Exception) { + if (appDebugChecker.isDebugMode()) { + throw e + } + Timber.e(e, "Failed to sync rules from remote") + return false } - Timber.i( - "Last synced commit id: $localCommitId, latest commit id: $latestCommitId" + - ", start pulling rule...", - ) - waitForCopyTaskFinish() + } + + private suspend fun syncFromRemote( + provider: RuleServerProvider, + latestCommitId: String, + ): Boolean { val mainBranchName = "main" val repoInfo = RepositoryInfo( remoteName = provider.name, @@ -133,41 +149,31 @@ internal class SyncWorker @AssistedInject constructor( branch = mainBranchName, ) val gitClient = gitClientFactory.create(repoInfo, filesDir) - // Detect the folder is a git repository or not val projectFolder = filesDir.resolve(repoInfo.repoName) val gitFolder = projectFolder.resolve(".git") - try { - if (projectFolder.exists()) { - if (!gitFolder.exists()) { - // Repo not initialized, delete the folder and clone again - Timber.i("Local rule folder is not a git repository, delete and clone again") - projectFolder.deleteRecursively() - gitClient.cloneRepository() + if (projectFolder.exists()) { + if (!gitFolder.exists()) { + // Repo not initialized, delete the folder and clone again + Timber.i("Local rule folder is not a git repository, delete and clone again") + projectFolder.deleteRecursively() + gitClient.cloneRepository() + } else { + val trackingRemote = gitClient.getTrackingRemote() + if (trackingRemote != null && trackingRemote != provider.name) { + Timber.i( + "Provider changed from $trackingRemote to ${provider.name}," + + " switching remote", + ) + gitClient.setRemote(repoInfo.url, provider.name) + gitClient.resetToRemote(provider.name, mainBranchName) } else { - val trackingRemote = gitClient.getTrackingRemote() - if (trackingRemote != null && trackingRemote != provider.name) { - Timber.i( - "Provider changed from $trackingRemote to ${provider.name}," + - " switching remote", - ) - gitClient.setRemote(repoInfo.url, provider.name) - gitClient.resetToRemote(provider.name, mainBranchName) - } else { - gitClient.setRemote(repoInfo.url, provider.name) - gitClient.pull() - } + gitClient.setRemote(repoInfo.url, provider.name) + gitClient.pull() } - } else { - // Repo not exists, clone the repository - gitClient.cloneRepository() } - } catch (e: Exception) { - // If it is in the debug mode, throw the exception - if (appDebugChecker.isDebugMode()) { - throw e - } - Timber.e(e, "Failed to sync rules from remote") - return false + } else { + // Repo not exists, clone the repository + gitClient.cloneRepository() } // write latest commit id to preference updateChangeListVersions { From fcbc2e5cecf8299d4a2a4767a7d14ede209cb8ef Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Thu, 12 Mar 2026 09:24:43 -0700 Subject: [PATCH 2/6] refactor: move NetworkException to core.network package NetworkException belongs in the top-level network package, not the retrofit subpackage, since Retrofit has been removed. --- .../blocker/core/network/{retrofit => }/NetworkException.kt | 2 +- .../blocker/core/network/retrofit/OkHttpBlockerNetwork.kt | 1 + .../blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) rename core/network/src/main/kotlin/com/merxury/blocker/core/network/{retrofit => }/NetworkException.kt (93%) diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/NetworkException.kt similarity index 93% rename from core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt rename to core/network/src/main/kotlin/com/merxury/blocker/core/network/NetworkException.kt index 16b320a2cd..0feb2a2a82 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/NetworkException.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/NetworkException.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.merxury.blocker.core.network.retrofit +package com.merxury.blocker.core.network import java.io.IOException diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt index 584af3e55a..62b3145c88 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt @@ -20,6 +20,7 @@ package com.merxury.blocker.core.network.retrofit import com.merxury.blocker.core.model.preference.RuleServerProvider import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB import com.merxury.blocker.core.network.BlockerNetworkDataSource +import com.merxury.blocker.core.network.NetworkException import com.merxury.blocker.core.network.io.BinaryFileWriter import com.merxury.blocker.core.network.model.NetworkChangeList import kotlinx.coroutines.Dispatchers diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt index 799370482e..17ba624945 100644 --- a/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt @@ -18,6 +18,7 @@ package com.merxury.blocker.core.network.retrofit import com.google.common.truth.Truth.assertThat import com.merxury.blocker.core.model.preference.RuleServerProvider +import com.merxury.blocker.core.network.NetworkException import kotlinx.serialization.json.Json import org.junit.Assert.assertThrows import org.junit.Test From 7e2fafa9bc1ea8eaa4c3745c2196c5694e2c38cc Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Thu, 12 Mar 2026 09:28:29 -0700 Subject: [PATCH 3/6] refactor: rename retrofit package to okhttp Move ContinuationCallback, OkHttpBlockerNetwork, and its test from the retrofit subpackage to okhttp, completing the Retrofit removal. --- .../kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt | 2 +- .../core/network/{retrofit => okhttp}/ContinuationCallback.kt | 2 +- .../core/network/{retrofit => okhttp}/OkHttpBlockerNetwork.kt | 2 +- .../network/{retrofit => okhttp}/OkHttpBlockerNetworkTest.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename core/network/src/main/kotlin/com/merxury/blocker/core/network/{retrofit => okhttp}/ContinuationCallback.kt (97%) rename core/network/src/main/kotlin/com/merxury/blocker/core/network/{retrofit => okhttp}/OkHttpBlockerNetwork.kt (98%) rename core/network/src/test/kotlin/com/merxury/blocker/core/network/{retrofit => okhttp}/OkHttpBlockerNetworkTest.kt (98%) diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt index d605d892a4..1ec039b46c 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt @@ -20,7 +20,7 @@ package com.merxury.blocker.core.network.di import androidx.tracing.trace import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.network.BuildConfig -import com.merxury.blocker.core.network.retrofit.OkHttpBlockerNetwork +import com.merxury.blocker.core.network.okhttp.OkHttpBlockerNetwork import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/ContinuationCallback.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/ContinuationCallback.kt similarity index 97% rename from core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/ContinuationCallback.kt rename to core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/ContinuationCallback.kt index 61cc2ecefd..957d93c128 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/ContinuationCallback.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/ContinuationCallback.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.merxury.blocker.core.network.retrofit +package com.merxury.blocker.core.network.okhttp import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CompletionHandler diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt similarity index 98% rename from core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt rename to core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt index 62b3145c88..97117db449 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetwork.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.merxury.blocker.core.network.retrofit +package com.merxury.blocker.core.network.okhttp import com.merxury.blocker.core.model.preference.RuleServerProvider import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt similarity index 98% rename from core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt rename to core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt index 17ba624945..f5e7548fa2 100644 --- a/core/network/src/test/kotlin/com/merxury/blocker/core/network/retrofit/OkHttpBlockerNetworkTest.kt +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.merxury.blocker.core.network.retrofit +package com.merxury.blocker.core.network.okhttp import com.google.common.truth.Truth.assertThat import com.merxury.blocker.core.model.preference.RuleServerProvider From c8560e4f71c7277b0a8be4bc9a5d0f982e280ad2 Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Thu, 12 Mar 2026 09:38:12 -0700 Subject: [PATCH 4/6] fix: inject CoroutineDispatcher instead of hardcoding Dispatchers.IO Use the project's @Dispatcher(IO) qualifier to inject the IO dispatcher, consistent with the rest of the codebase and testable with UnconfinedTestDispatcher. --- .../com/merxury/blocker/core/network/di/NetworkModule.kt | 6 +++++- .../blocker/core/network/okhttp/OkHttpBlockerNetwork.kt | 7 +++++-- .../core/network/okhttp/OkHttpBlockerNetworkTest.kt | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt index 1ec039b46c..4fa960fa1e 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt @@ -18,6 +18,8 @@ package com.merxury.blocker.core.network.di import androidx.tracing.trace +import com.merxury.blocker.core.dispatchers.BlockerDispatchers.IO +import com.merxury.blocker.core.dispatchers.Dispatcher import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.network.BuildConfig import com.merxury.blocker.core.network.okhttp.OkHttpBlockerNetwork @@ -25,6 +27,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.serialization.json.Json import okhttp3.Call import okhttp3.OkHttpClient @@ -64,5 +67,6 @@ internal object NetworkModule { fun provideBlockerNetworkDataSource( okHttpCallFactory: dagger.Lazy, networkJson: Json, - ): BlockerNetworkDataSource = OkHttpBlockerNetwork(okHttpCallFactory, networkJson) + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + ): BlockerNetworkDataSource = OkHttpBlockerNetwork(okHttpCallFactory, networkJson, ioDispatcher) } diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt index 97117db449..a20bebb6b5 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt @@ -19,11 +19,13 @@ package com.merxury.blocker.core.network.okhttp import com.merxury.blocker.core.model.preference.RuleServerProvider import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB +import com.merxury.blocker.core.dispatchers.BlockerDispatchers.IO +import com.merxury.blocker.core.dispatchers.Dispatcher import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.network.NetworkException import com.merxury.blocker.core.network.io.BinaryFileWriter import com.merxury.blocker.core.network.model.NetworkChangeList -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray @@ -42,6 +44,7 @@ import javax.inject.Singleton internal class OkHttpBlockerNetwork @Inject constructor( private val okhttpCallFactory: dagger.Lazy, private val networkJson: Json, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : BlockerNetworkDataSource { override suspend fun getRuleLatestCommitId(provider: RuleServerProvider): NetworkChangeList { @@ -83,7 +86,7 @@ internal class OkHttpBlockerNetwork @Inject constructor( return@use 0L } Timber.v("Zip length: $contentLength") - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { writer.write(responseBody.byteStream(), contentLength) } } diff --git a/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt index f5e7548fa2..8afd830c52 100644 --- a/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt @@ -19,6 +19,7 @@ package com.merxury.blocker.core.network.okhttp import com.google.common.truth.Truth.assertThat import com.merxury.blocker.core.model.preference.RuleServerProvider import com.merxury.blocker.core.network.NetworkException +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.serialization.json.Json import org.junit.Assert.assertThrows import org.junit.Test @@ -29,6 +30,7 @@ class OkHttpBlockerNetworkTest { private val network = OkHttpBlockerNetwork( okhttpCallFactory = dagger.Lazy { error("Not used in these tests") }, networkJson = json, + ioDispatcher = UnconfinedTestDispatcher(), ) @Test From e3ff486ae9737a9aba9997caa412c4a3cf26ba27 Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Fri, 13 Mar 2026 11:08:47 -0700 Subject: [PATCH 5/6] fix: resolve spotless import ordering Change-Id: Icaeaa6549d2f53c35f3da6a4f7bbf48f8ef69007 --- .../blocker/core/network/okhttp/OkHttpBlockerNetwork.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt index a20bebb6b5..8de017fa69 100644 --- a/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt @@ -17,10 +17,10 @@ package com.merxury.blocker.core.network.okhttp -import com.merxury.blocker.core.model.preference.RuleServerProvider -import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB import com.merxury.blocker.core.dispatchers.BlockerDispatchers.IO import com.merxury.blocker.core.dispatchers.Dispatcher +import com.merxury.blocker.core.model.preference.RuleServerProvider +import com.merxury.blocker.core.model.preference.RuleServerProvider.GITHUB import com.merxury.blocker.core.network.BlockerNetworkDataSource import com.merxury.blocker.core.network.NetworkException import com.merxury.blocker.core.network.io.BinaryFileWriter From c7248f1e073e33a4e82541e4c5e4da890abf9634 Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Sat, 20 Jun 2026 21:44:37 -0700 Subject: [PATCH 6/6] chore: normalize gradlew.bat line endings per .gitattributes --- gradlew.bat | 186 ++++++++++++++++++++++++++-------------------------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/gradlew.bat b/gradlew.bat index e509b2dd8f..c4bdd3ab8e 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega