diff --git a/app-compose/dependencies/marketReleaseRuntimeClasspath.txt b/app-compose/dependencies/marketReleaseRuntimeClasspath.txt index 6dcbc96123..f2fdfb0951 100644 --- a/app-compose/dependencies/marketReleaseRuntimeClasspath.txt +++ b/app-compose/dependencies/marketReleaseRuntimeClasspath.txt @@ -252,8 +252,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.21.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/NetworkException.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/NetworkException.kt new file mode 100644 index 0000000000..0feb2a2a82 --- /dev/null +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/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 + +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/di/NetworkModule.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/di/NetworkModule.kt index eaeae04a68..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 @@ -17,17 +17,17 @@ package com.merxury.blocker.core.network.di -import android.content.Context 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.fake.FakeAssetManager -import com.merxury.blocker.core.network.retrofit.RetrofitBlockerNetwork +import com.merxury.blocker.core.network.okhttp.OkHttpBlockerNetwork import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.serialization.json.Json import okhttp3.Call import okhttp3.OkHttpClient @@ -66,11 +66,7 @@ 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, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + ): BlockerNetworkDataSource = OkHttpBlockerNetwork(okHttpCallFactory, networkJson, ioDispatcher) } 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/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/okhttp/OkHttpBlockerNetwork.kt b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt new file mode 100644 index 0000000000..8de017fa69 --- /dev/null +++ b/core/network/src/main/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetwork.kt @@ -0,0 +1,112 @@ +/* + * 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.okhttp + +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 +import com.merxury.blocker.core.network.model.NetworkChangeList +import kotlinx.coroutines.CoroutineDispatcher +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, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : 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(ioDispatcher) { + 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/okhttp/OkHttpBlockerNetworkTest.kt b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt new file mode 100644 index 0000000000..8afd830c52 --- /dev/null +++ b/core/network/src/test/kotlin/com/merxury/blocker/core/network/okhttp/OkHttpBlockerNetworkTest.kt @@ -0,0 +1,123 @@ +/* + * 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.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 + +class OkHttpBlockerNetworkTest { + + private val json = Json { ignoreUnknownKeys = true } + private val network = OkHttpBlockerNetwork( + okhttpCallFactory = dagger.Lazy { error("Not used in these tests") }, + networkJson = json, + ioDispatcher = UnconfinedTestDispatcher(), + ) + + @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 2d1aa9cdc3..ce0e148ae0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -178,6 +178,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 {