From cf7892e373280151d9c0bca397a4210800034c95 Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Mon, 9 Mar 2026 22:46:14 -0700 Subject: [PATCH 1/4] fix: improve core:provider module security and correctness - Fix bug where packageName was passed as component type in ShareCmpInfo - Add signature-level permission to protect exported ContentProvider - Extract magic strings to companion object constants - Return success/failure counts from controlComponent instead of raw input - Parse actual ComponentType from ShareCmpInfo instead of hardcoding ACTIVITY - Add unit tests for ShareCmpInfo serialization and AnalyticsExtension --- core/provider/build.gradle.kts | 4 ++ core/provider/src/main/AndroidManifest.xml | 6 ++ .../blocker/provider/ComponentProvider.kt | 42 ++++++++---- .../provider/AnalyticsExtensionTest.kt | 54 +++++++++++++++ .../blocker/provider/ShareCmpInfoTest.kt | 66 +++++++++++++++++++ 5 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt create mode 100644 core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt 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/AndroidManifest.xml b/core/provider/src/main/AndroidManifest.xml index f2555ef692..4c08c6cc24 100644 --- a/core/provider/src/main/AndroidManifest.xml +++ b/core/provider/src/main/AndroidManifest.xml @@ -18,12 +18,18 @@ + + + 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..8a16d8cad9 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,7 +24,7 @@ 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.model.ComponentType import com.merxury.blocker.core.model.data.ComponentInfo import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -47,8 +47,8 @@ class ComponentProvider : ContentProvider() { } override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { - "getComponents" -> getBlockedComponents(arg) - "blocks" -> controlComponent(arg, extras) + METHOD_GET_COMPONENTS -> getBlockedComponents(arg) + METHOD_BLOCK_COMPONENTS -> controlComponent(arg, extras) else -> null } @@ -65,18 +65,18 @@ class ComponentProvider : ContentProvider() { .filter { it.ifwBlocked || it.pmBlocked } .map { ShareCmpInfo.Component( - it.packageName, - it.name, + type = it.type.name, + name = it.name, block = true, ) } val returnJson = Json.encodeToString(ShareCmpInfo(packageName, blockedComponents)) - return@runBlocking bundleOf("cmp_list" to returnJson) + return@runBlocking bundleOf(KEY_COMPONENT_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 rawString = data.getString(KEY_COMPONENT_LIST) ?: return@runBlocking null val appContext = context?.applicationContext ?: return@runBlocking null val hintEntryPoint = EntryPointAccessors.fromApplication( appContext, @@ -87,23 +87,29 @@ class ComponentProvider : ContentProvider() { try { val shareCmpInfo = 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, - // 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, + type = componentType, ) - componentRepository.controlComponent( + val result = componentRepository.controlComponent( blockerComponent, newState = !component.block, ).first() + if (result) successCount++ analyticsHelper.logControlComponentViaProvider(newState = !component.block) } - // Returned, but seems that it's not used. - return@runBlocking data + return@runBlocking bundleOf( + KEY_SUCCESS_COUNT to successCount, + KEY_TOTAL_COUNT to shareCmpInfo.components.size, + ) } catch (e: Exception) { Timber.e(e, "Error in controlComponent") return@runBlocking null @@ -144,4 +150,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..2cf7d10b3b --- /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 logControlComponentViaProvider_enableState() { + analyticsHelper.logControlComponentViaProvider(newState = true) + assertTrue( + analyticsHelper.hasLogged( + AnalyticsEvent( + type = "control_component_via_provider_activated", + extras = listOf(Param(key = "new_state", value = "true")), + ), + ), + ) + } + + @Test + fun logControlComponentViaProvider_disableState() { + 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..d97a436a79 --- /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 serializeAndDeserialize_roundTrip() { + 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 deserialize_fromJsonString() { + 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 serialize_emptyComponents() { + 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) + } +} From b39b057fb60b172a88d50310e1cbb29f44796a94 Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Mon, 9 Mar 2026 22:49:45 -0700 Subject: [PATCH 2/4] refactor: rename test methods to given_when_then convention --- .../com/merxury/blocker/provider/AnalyticsExtensionTest.kt | 4 ++-- .../kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index 2cf7d10b3b..97674c08ba 100644 --- a/core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt +++ b/core/provider/src/test/kotlin/com/merxury/blocker/provider/AnalyticsExtensionTest.kt @@ -27,7 +27,7 @@ class AnalyticsExtensionTest { private val analyticsHelper = TestAnalyticsHelper() @Test - fun logControlComponentViaProvider_enableState() { + fun givenNewStateTrue_whenLogControlComponentViaProvider_thenEventLoggedWithTrueState() { analyticsHelper.logControlComponentViaProvider(newState = true) assertTrue( analyticsHelper.hasLogged( @@ -40,7 +40,7 @@ class AnalyticsExtensionTest { } @Test - fun logControlComponentViaProvider_disableState() { + fun givenNewStateFalse_whenLogControlComponentViaProvider_thenEventLoggedWithFalseState() { analyticsHelper.logControlComponentViaProvider(newState = false) assertTrue( analyticsHelper.hasLogged( 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 index d97a436a79..94f59482d3 100644 --- a/core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt +++ b/core/provider/src/test/kotlin/com/merxury/blocker/provider/ShareCmpInfoTest.kt @@ -24,7 +24,7 @@ import kotlin.test.assertEquals class ShareCmpInfoTest { @Test - fun serializeAndDeserialize_roundTrip() { + fun givenShareCmpInfoWithComponents_whenSerializedAndDeserialized_thenRoundTripIsEqual() { val original = ShareCmpInfo( pkg = "com.example.app", components = listOf( @@ -38,7 +38,7 @@ class ShareCmpInfoTest { } @Test - fun deserialize_fromJsonString() { + fun givenValidJsonString_whenDeserialized_thenFieldsMatchExpected() { val json = """ { "pkg": "com.example.app", @@ -56,7 +56,7 @@ class ShareCmpInfoTest { } @Test - fun serialize_emptyComponents() { + fun givenEmptyComponentList_whenSerializedAndDeserialized_thenComponentsIsEmpty() { val info = ShareCmpInfo(pkg = "com.example.app", components = emptyList()) val json = Json.encodeToString(info) val deserialized = Json.decodeFromString(json) From ac19c5ce85fac33f0f0c91aac78d84429181441e Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Mon, 9 Mar 2026 22:51:51 -0700 Subject: [PATCH 3/4] fix: revert permission change and use Dispatchers.IO for runBlocking - Revert signature permission since provider needs to be open to external apps - Use runBlocking(Dispatchers.IO) to avoid blocking the caller's thread --- core/provider/src/main/AndroidManifest.xml | 6 ------ .../com/merxury/blocker/provider/ComponentProvider.kt | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/core/provider/src/main/AndroidManifest.xml b/core/provider/src/main/AndroidManifest.xml index 4c08c6cc24..f2555ef692 100644 --- a/core/provider/src/main/AndroidManifest.xml +++ b/core/provider/src/main/AndroidManifest.xml @@ -18,18 +18,12 @@ - - - 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 8a16d8cad9..493e4071d3 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 @@ -30,6 +30,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString @@ -52,7 +53,7 @@ class ComponentProvider : ContentProvider() { else -> null } - private fun getBlockedComponents(packageName: String?): Bundle? = runBlocking { + private fun getBlockedComponents(packageName: String?): Bundle? = runBlocking(Dispatchers.IO) { if (packageName == null) return@runBlocking null val appContext = context?.applicationContext ?: return@runBlocking null val hintEntryPoint = EntryPointAccessors.fromApplication( @@ -74,7 +75,7 @@ class ComponentProvider : ContentProvider() { return@runBlocking bundleOf(KEY_COMPONENT_LIST to returnJson) } - private fun controlComponent(packageName: String?, data: Bundle?): Bundle? = runBlocking { + private fun controlComponent(packageName: String?, data: Bundle?): Bundle? = runBlocking(Dispatchers.IO) { if (packageName == null || data == null) return@runBlocking null val rawString = data.getString(KEY_COMPONENT_LIST) ?: return@runBlocking null val appContext = context?.applicationContext ?: return@runBlocking null From 1ee3035157a99d25eff78871552f2970d5f70baa Mon Sep 17 00:00:00 2001 From: Mercury Li Date: Mon, 9 Mar 2026 22:53:56 -0700 Subject: [PATCH 4/4] refactor: inject CoroutineDispatcher and Json via Hilt EntryPoint - Replace hardcoded Dispatchers.IO with @Dispatcher(IO) from EntryPoint - Inject Json instance from Hilt graph instead of creating locally - Extract entryPoint() helper to reduce duplication - Move null checks before runBlocking to avoid unnecessary coroutine creation --- .../blocker/provider/ComponentProvider.kt | 129 ++++++++++-------- 1 file changed, 71 insertions(+), 58 deletions(-) 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 493e4071d3..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,16 +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.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.Dispatchers +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 @@ -45,6 +46,19 @@ class ComponentProvider : ContentProvider() { fun componentRepository(): ComponentRepository fun analyticsHelper(): AnalyticsHelper + + fun json(): Json + + @Dispatcher(IO) + fun ioDispatcher(): CoroutineDispatcher + } + + private fun entryPoint(): ComponentRepositoryEntryPoint? { + val appContext = context?.applicationContext ?: return null + return EntryPointAccessors.fromApplication( + appContext, + ComponentRepositoryEntryPoint::class.java, + ) } override fun call(method: String, arg: String?, extras: Bundle?): Bundle? = when (method) { @@ -53,67 +67,66 @@ class ComponentProvider : ContentProvider() { else -> null } - private fun getBlockedComponents(packageName: String?): Bundle? = runBlocking(Dispatchers.IO) { - if (packageName == null) return@runBlocking null - val appContext = context?.applicationContext ?: return@runBlocking null - val hintEntryPoint = 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( - type = it.type.name, - name = it.name, - block = true, - ) - } - val returnJson = Json.encodeToString(ShareCmpInfo(packageName, blockedComponents)) - return@runBlocking bundleOf(KEY_COMPONENT_LIST to returnJson) + 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? = runBlocking(Dispatchers.IO) { - if (packageName == null || data == null) return@runBlocking null - val rawString = data.getString(KEY_COMPONENT_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") - var successCount = 0 - shareCmpInfo.components.forEach { component -> - val componentType = try { - ComponentType.valueOf(component.type) - } catch (_: IllegalArgumentException) { - ComponentType.ACTIVITY + 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, + ) } - val blockerComponent = ComponentInfo( - name = component.name, - packageName = packageName, - type = componentType, + bundleOf( + KEY_SUCCESS_COUNT to successCount, + KEY_TOTAL_COUNT to shareCmpInfo.components.size, ) - val result = componentRepository.controlComponent( - blockerComponent, - newState = !component.block, - ).first() - if (result) successCount++ - analyticsHelper.logControlComponentViaProvider(newState = !component.block) + } catch (e: Exception) { + Timber.e(e, "Error in controlComponent") + null } - return@runBlocking bundleOf( - KEY_SUCCESS_COUNT to successCount, - KEY_TOTAL_COUNT to shareCmpInfo.components.size, - ) - } catch (e: Exception) { - Timber.e(e, "Error in controlComponent") - return@runBlocking null } }