Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/provider/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<ShareCmpInfo>(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<ShareCmpInfo>(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
}
}

Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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")),
),
),
)
}
}
Original file line number Diff line number Diff line change
@@ -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<ShareCmpInfo>(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<ShareCmpInfo>(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<ShareCmpInfo>(json)
assertEquals(info, deserialized)
assertEquals(0, deserialized.components.size)
}
}