diff --git a/core/provider/build.gradle.kts b/core/provider/build.gradle.kts index a7860d9d53..5aa47223e2 100644 --- a/core/provider/build.gradle.kts +++ b/core/provider/build.gradle.kts @@ -30,4 +30,8 @@ dependencies { implementation(projects.core.model) implementation(libs.kotlinx.serialization.json) implementation(libs.timber) + + testImplementation(projects.core.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) } diff --git a/core/provider/src/main/kotlin/com/merxury/blocker/provider/ComponentProvider.kt b/core/provider/src/main/kotlin/com/merxury/blocker/provider/ComponentProvider.kt index f7c57ae9c5..d61b6deb83 100644 --- a/core/provider/src/main/kotlin/com/merxury/blocker/provider/ComponentProvider.kt +++ b/core/provider/src/main/kotlin/com/merxury/blocker/provider/ComponentProvider.kt @@ -24,15 +24,17 @@ import android.os.Bundle import androidx.core.os.bundleOf import com.merxury.blocker.core.analytics.AnalyticsHelper import com.merxury.blocker.core.data.respository.component.ComponentRepository -import com.merxury.blocker.core.model.ComponentType.ACTIVITY +import com.merxury.blocker.core.dispatchers.BlockerDispatchers.IO +import com.merxury.blocker.core.dispatchers.Dispatcher +import com.merxury.blocker.core.model.ComponentType import com.merxury.blocker.core.model.data.ComponentInfo import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import timber.log.Timber @@ -44,69 +46,87 @@ class ComponentProvider : ContentProvider() { fun componentRepository(): ComponentRepository fun analyticsHelper(): AnalyticsHelper - } - override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { - "getComponents" -> getBlockedComponents(arg) - "blocks" -> controlComponent(arg, extras) - else -> null + fun json(): Json + + @Dispatcher(IO) + fun ioDispatcher(): CoroutineDispatcher } - private fun getBlockedComponents(packageName: String?): Bundle? = runBlocking { - if (packageName == null) return@runBlocking null - val appContext = context?.applicationContext ?: return@runBlocking null - val hintEntryPoint = EntryPointAccessors.fromApplication( + private fun entryPoint(): ComponentRepositoryEntryPoint? { + val appContext = context?.applicationContext ?: return null + return EntryPointAccessors.fromApplication( appContext, ComponentRepositoryEntryPoint::class.java, ) - // Do not get data from the DB directly, because the data may be uninitialized - val repository = hintEntryPoint.componentRepository() - val blockedComponents = repository.getComponentList(packageName).first() - .filter { it.ifwBlocked || it.pmBlocked } - .map { - ShareCmpInfo.Component( - it.packageName, - it.name, - block = true, - ) - } - val returnJson = Json.encodeToString(ShareCmpInfo(packageName, blockedComponents)) - return@runBlocking bundleOf("cmp_list" to returnJson) } - private fun controlComponent(packageName: String?, data: Bundle?): Bundle? = runBlocking { - if (packageName == null || data == null) return@runBlocking null - val rawString = data.getString("cmp_list") ?: return@runBlocking null - val appContext = context?.applicationContext ?: return@runBlocking null - val hintEntryPoint = EntryPointAccessors.fromApplication( - appContext, - ComponentRepositoryEntryPoint::class.java, - ) - val componentRepository = hintEntryPoint.componentRepository() - val analyticsHelper = hintEntryPoint.analyticsHelper() - try { - val shareCmpInfo = Json.decodeFromString(rawString) - Timber.d("controlComponent: $shareCmpInfo") - shareCmpInfo.components.forEach { component -> - val blockerComponent = ComponentInfo( - name = component.name, - packageName = packageName, - // The controller doesn't care about the type of the component - // It will query internally, so we just set it to ACTIVITY - // Just to avoid compilation error - type = ACTIVITY, + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { + METHOD_GET_COMPONENTS -> getBlockedComponents(arg) + METHOD_BLOCK_COMPONENTS -> controlComponent(arg, extras) + else -> null + } + + private fun getBlockedComponents(packageName: String?): Bundle? { + if (packageName == null) return null + val ep = entryPoint() ?: return null + return runBlocking(ep.ioDispatcher()) { + // Do not get data from the DB directly, because the data may be uninitialized + val blockedComponents = ep.componentRepository() + .getComponentList(packageName).first() + .filter { it.ifwBlocked || it.pmBlocked } + .map { + ShareCmpInfo.Component( + type = it.type.name, + name = it.name, + block = true, + ) + } + val returnJson = ep.json().encodeToString( + ShareCmpInfo.serializer(), + ShareCmpInfo(packageName, blockedComponents), + ) + bundleOf(KEY_COMPONENT_LIST to returnJson) + } + } + + private fun controlComponent(packageName: String?, data: Bundle?): Bundle? { + if (packageName == null || data == null) return null + val rawString = data.getString(KEY_COMPONENT_LIST) ?: return null + val ep = entryPoint() ?: return null + return runBlocking(ep.ioDispatcher()) { + try { + val shareCmpInfo = ep.json().decodeFromString(rawString) + Timber.d("controlComponent: $shareCmpInfo") + var successCount = 0 + shareCmpInfo.components.forEach { component -> + val componentType = try { + ComponentType.valueOf(component.type) + } catch (_: IllegalArgumentException) { + ComponentType.ACTIVITY + } + val blockerComponent = ComponentInfo( + name = component.name, + packageName = packageName, + type = componentType, + ) + val result = ep.componentRepository().controlComponent( + blockerComponent, + newState = !component.block, + ).first() + if (result) successCount++ + ep.analyticsHelper().logControlComponentViaProvider( + newState = !component.block, + ) + } + bundleOf( + KEY_SUCCESS_COUNT to successCount, + KEY_TOTAL_COUNT to shareCmpInfo.components.size, ) - componentRepository.controlComponent( - blockerComponent, - newState = !component.block, - ).first() - analyticsHelper.logControlComponentViaProvider(newState = !component.block) + } catch (e: Exception) { + Timber.e(e, "Error in controlComponent") + null } - // Returned, but seems that it's not used. - return@runBlocking data - } catch (e: Exception) { - Timber.e(e, "Error in controlComponent") - return@runBlocking null } } @@ -144,4 +164,12 @@ class ComponentProvider : ContentProvider() { } override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.com.merxury.blocker.component" + + companion object { + const val METHOD_GET_COMPONENTS = "getComponents" + const val METHOD_BLOCK_COMPONENTS = "blocks" + const val KEY_COMPONENT_LIST = "cmp_list" + const val KEY_SUCCESS_COUNT = "success_count" + const val KEY_TOTAL_COUNT = "total_count" + } } diff --git a/core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt b/core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt new file mode 100644 index 0000000000..97674c08ba --- /dev/null +++ b/core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt @@ -0,0 +1,54 @@ +/* + * 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.provider + +import com.merxury.blocker.core.analytics.AnalyticsEvent +import com.merxury.blocker.core.analytics.AnalyticsEvent.Param +import com.merxury.blocker.core.testing.util.TestAnalyticsHelper +import org.junit.Test +import kotlin.test.assertTrue + +class AnalyticsExtensionTest { + + private val analyticsHelper = TestAnalyticsHelper() + + @Test + fun givenNewStateTrue_whenLogControlComponentViaProvider_thenEventLoggedWithTrueState() { + analyticsHelper.logControlComponentViaProvider(newState = true) + assertTrue( + analyticsHelper.hasLogged( + AnalyticsEvent( + type = "control_component_via_provider_activated", + extras = listOf(Param(key = "new_state", value = "true")), + ), + ), + ) + } + + @Test + fun givenNewStateFalse_whenLogControlComponentViaProvider_thenEventLoggedWithFalseState() { + analyticsHelper.logControlComponentViaProvider(newState = false) + assertTrue( + analyticsHelper.hasLogged( + AnalyticsEvent( + type = "control_component_via_provider_activated", + extras = listOf(Param(key = "new_state", value = "false")), + ), + ), + ) + } +} diff --git a/core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt b/core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt new file mode 100644 index 0000000000..94f59482d3 --- /dev/null +++ b/core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt @@ -0,0 +1,66 @@ +/* + * 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.provider + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Test +import kotlin.test.assertEquals + +class ShareCmpInfoTest { + + @Test + fun givenShareCmpInfoWithComponents_whenSerializedAndDeserialized_thenRoundTripIsEqual() { + val original = ShareCmpInfo( + pkg = "com.example.app", + components = listOf( + ShareCmpInfo.Component(type = "ACTIVITY", name = "com.example.app.MainActivity", block = true), + ShareCmpInfo.Component(type = "SERVICE", name = "com.example.app.BackgroundService", block = false), + ), + ) + val json = Json.encodeToString(original) + val deserialized = Json.decodeFromString(json) + assertEquals(original, deserialized) + } + + @Test + fun givenValidJsonString_whenDeserialized_thenFieldsMatchExpected() { + val json = """ + { + "pkg": "com.example.app", + "components": [ + {"type": "RECEIVER", "name": "com.example.app.BootReceiver", "block": true} + ] + } + """.trimIndent() + val result = Json.decodeFromString(json) + assertEquals("com.example.app", result.pkg) + assertEquals(1, result.components.size) + assertEquals("RECEIVER", result.components[0].type) + assertEquals("com.example.app.BootReceiver", result.components[0].name) + assertEquals(true, result.components[0].block) + } + + @Test + fun givenEmptyComponentList_whenSerializedAndDeserialized_thenComponentsIsEmpty() { + val info = ShareCmpInfo(pkg = "com.example.app", components = emptyList()) + val json = Json.encodeToString(info) + val deserialized = Json.decodeFromString(json) + assertEquals(info, deserialized) + assertEquals(0, deserialized.components.size) + } +}