diff --git a/app/src/main/kotlin/at/bitfire/davdroid/App.kt b/app/src/main/kotlin/at/bitfire/davdroid/App.kt index 40dc013cea0bc6e63fdab19e8c7223325f825c95..cc47d38fb7cf24d2ab8e587f810142cd5d6dfe4a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/App.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/App.kt @@ -15,6 +15,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener +import at.bitfire.davdroid.syncadapter.SyncAdapterComponentManager import at.bitfire.davdroid.syncadapter.SyncUtils import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationUtils @@ -103,6 +104,8 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide // don't block UI for some background checks thread { + SyncAdapterComponentManager.updateComponents(this) + // watch for account changes/deletions accountsUpdatedListener.listen() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/TasksWatcher.kt b/app/src/main/kotlin/at/bitfire/davdroid/TasksWatcher.kt index 410908bb365a376a6d66584b7e41003f82aea974..e8a6f3e55865fba61e5771ad39d39c422dcd30d4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/TasksWatcher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/TasksWatcher.kt @@ -6,6 +6,7 @@ package at.bitfire.davdroid import android.content.Context import android.content.Intent +import at.bitfire.davdroid.syncadapter.SyncAdapterComponentManager import at.bitfire.davdroid.syncadapter.SyncUtils.updateTaskSync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,6 +25,7 @@ class TasksWatcher protected constructor( override fun onReceive(context: Context, intent: Intent) { CoroutineScope(Dispatchers.Default).launch { + SyncAdapterComponentManager.updateComponents(context) updateTaskSync(context) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..5405d5df724761828402c934e41dbc7741f94a25 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package at.bitfire.davdroid.syncadapter + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.annotation.WorkerThread +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import java.util.logging.Level + +object SyncAdapterComponentManager { + + internal enum class ComponentStateUpdateDecision { + SKIP_ALREADY_ALIGNED, + SKIP_POLICY_CONTROLLED, + UPDATE_REQUIRED + } + + private data class SyncServiceSpec( + val serviceClass: Class<*>, + val authorityResId: Int + ) + + private val optionalSyncServices = listOf( + SyncServiceSpec(MurenaTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(MurenaNotesSyncAdapterService::class.java, R.string.notes_authority), + SyncServiceSpec(MurenaEmailSyncAdapterService::class.java, R.string.email_authority), + SyncServiceSpec(MurenaMediaSyncAdapterService::class.java, R.string.media_authority), + SyncServiceSpec(MurenaAppDataSyncAdapterService::class.java, R.string.app_data_authority), + SyncServiceSpec(MurenaMeteredEdriveSyncAdapterService::class.java, R.string.metered_edrive_authority), + SyncServiceSpec(MurenaPasswordSyncAdapterService::class.java, R.string.password_authority), + SyncServiceSpec(GoogleTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(GoogleEmailSyncAdapterService::class.java, R.string.email_authority), + SyncServiceSpec(YahooTasksSyncAdapterService::class.java, R.string.task_authority), + SyncServiceSpec(YahooEmailSyncAdapterService::class.java, R.string.email_authority) + ) + + @WorkerThread + fun updateComponents(context: Context) { + val packageManager = context.packageManager + + optionalSyncServices + .groupBy { context.getString(it.authorityResId) } + .forEach { (authority, services) -> + val isProviderAvailable = hasProvider(packageManager, authority) + for (service in services) { + updateSingleComponent( + packageManager = packageManager, + context = context, + serviceClass = service.serviceClass, + providerAvailable = isProviderAvailable, + authority = authority + ) + } + } + } + + private fun hasProvider(packageManager: PackageManager, authority: String): Boolean { + return packageManager.resolveContentProvider(authority, 0) != null + } + + private fun updateSingleComponent( + packageManager: PackageManager, + context: Context, + serviceClass: Class<*>, + providerAvailable: Boolean, + authority: String + ) { + val component = ComponentName(context, serviceClass) + val manifestServiceEnabled = getManifestServiceEnabled(packageManager, component, serviceClass) + if (manifestServiceEnabled == null) { + return + } + + val desiredState = resolveDesiredState( + providerAvailable = providerAvailable, + manifestServiceEnabled = manifestServiceEnabled + ) + + val currentState = getComponentEnabledSetting(packageManager, component, serviceClass) + if (currentState == null) { + return + } + + val updateDecision = decideComponentStateUpdate(currentState, desiredState) + when (updateDecision) { + ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED -> { + return + } + + ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED -> { + Logger.log.log( + Level.INFO, + "Skipping ${serviceClass.simpleName}: preserving ${stateName(currentState)} (authority=$authority)" + ) + return + } + + ComponentStateUpdateDecision.UPDATE_REQUIRED -> { + val changed = setComponentEnabledSetting(packageManager, component, serviceClass, desiredState) + if (!changed) { + return + } + + Logger.log.log( + Level.INFO, + "Set ${serviceClass.simpleName} to ${stateName(desiredState)} (authority=$authority)" + ) + } + } + } + + private fun getManifestServiceEnabled( + packageManager: PackageManager, + component: ComponentName, + serviceClass: Class<*> + ): Boolean? { + return try { + @Suppress("DEPRECATION") + val serviceInfo = packageManager.getServiceInfo(component, 0) + serviceInfo.enabled + } catch (e: PackageManager.NameNotFoundException) { + Logger.log.log( + Level.WARNING, + "Skipping ${serviceClass.simpleName}: service not declared in manifest", + e + ) + null + } catch (e: IllegalArgumentException) { + Logger.log.log( + Level.WARNING, + "Skipping ${serviceClass.simpleName}: invalid component reference", + e + ) + null + } + } + + private fun getComponentEnabledSetting( + packageManager: PackageManager, + component: ComponentName, + serviceClass: Class<*> + ): Int? { + return try { + packageManager.getComponentEnabledSetting(component) + } catch (e: IllegalArgumentException) { + Logger.log.log( + Level.WARNING, + "Skipping ${serviceClass.simpleName}: unable to read component state", + e + ) + null + } + } + + private fun setComponentEnabledSetting( + packageManager: PackageManager, + component: ComponentName, + serviceClass: Class<*>, + state: Int + ): Boolean { + return try { + packageManager.setComponentEnabledSetting( + component, + state, + PackageManager.DONT_KILL_APP + ) + true + } catch (e: IllegalArgumentException) { + Logger.log.log( + Level.WARNING, + "Skipping ${serviceClass.simpleName}: unable to update component state", + e + ) + false + } + } + + internal fun resolveDesiredState(providerAvailable: Boolean, manifestServiceEnabled: Boolean): Int { + if (providerAvailable == manifestServiceEnabled) { + return PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } + + if (providerAvailable) { + return PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + + return PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + + internal fun decideComponentStateUpdate( + currentState: Int, + desiredState: Int + ): ComponentStateUpdateDecision { + if (currentState == desiredState) { + return ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED + } + + if (isPolicyControlledState(currentState)) { + return ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED + } + + return ComponentStateUpdateDecision.UPDATE_REQUIRED + } + + internal fun isPolicyControlledState(componentState: Int): Boolean { + return componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER || + componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED + } + + private fun stateName(state: Int): String { + return when (state) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> { + "ENABLED" + } + + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> { + "DISABLED" + } + + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> { + "DEFAULT" + } + + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> { + "DISABLED_USER" + } + + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> { + "DISABLED_UNTIL_USED" + } + + else -> { + "UNKNOWN($state)" + } + } + } +} diff --git a/app/src/test/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManagerTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b8d3a45c4aec5fd8ffdbaad0b68d6a2941c7cd56 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManagerTest.kt @@ -0,0 +1,120 @@ + +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + + +package at.bitfire.davdroid.syncadapter + +import android.content.pm.PackageManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class SyncAdapterComponentManagerTest { + + @Test + fun `resolveDesiredState returns default when provider and manifest match as enabled`() { + val desiredState = SyncAdapterComponentManager.resolveDesiredState( + providerAvailable = true, + manifestServiceEnabled = true + ) + + assertEquals(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, desiredState) + } + + @Test + fun `resolveDesiredState returns default when provider and manifest match as disabled`() { + val desiredState = SyncAdapterComponentManager.resolveDesiredState( + providerAvailable = false, + manifestServiceEnabled = false + ) + + assertEquals(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, desiredState) + } + + @Test + fun `resolveDesiredState returns enabled when provider is available and manifest is disabled`() { + val desiredState = SyncAdapterComponentManager.resolveDesiredState( + providerAvailable = true, + manifestServiceEnabled = false + ) + + assertEquals(PackageManager.COMPONENT_ENABLED_STATE_ENABLED, desiredState) + } + + @Test + fun `resolveDesiredState returns disabled when provider is unavailable and manifest is enabled`() { + val desiredState = SyncAdapterComponentManager.resolveDesiredState( + providerAvailable = false, + manifestServiceEnabled = true + ) + + assertEquals(PackageManager.COMPONENT_ENABLED_STATE_DISABLED, desiredState) + } + + @Test + fun `decideComponentStateUpdate skips when state is already aligned`() { + val decision = SyncAdapterComponentManager.decideComponentStateUpdate( + currentState = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, + desiredState = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + ) + + assertEquals( + SyncAdapterComponentManager.ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED, + decision + ) + } + + @Test + fun `decideComponentStateUpdate skips when state is disabled by user`() { + val decision = SyncAdapterComponentManager.decideComponentStateUpdate( + currentState = PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, + desiredState = PackageManager.COMPONENT_ENABLED_STATE_ENABLED + ) + + assertEquals( + SyncAdapterComponentManager.ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED, + decision + ) + } + + @Test + fun `decideComponentStateUpdate skips when state is disabled until used`() { + val decision = SyncAdapterComponentManager.decideComponentStateUpdate( + currentState = PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED, + desiredState = PackageManager.COMPONENT_ENABLED_STATE_ENABLED + ) + + assertEquals( + SyncAdapterComponentManager.ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED, + decision + ) + } + + @Test + fun `decideComponentStateUpdate requests update when state is mutable and different`() { + val decision = SyncAdapterComponentManager.decideComponentStateUpdate( + currentState = PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + desiredState = PackageManager.COMPONENT_ENABLED_STATE_ENABLED + ) + + assertEquals( + SyncAdapterComponentManager.ComponentStateUpdateDecision.UPDATE_REQUIRED, + decision + ) + } +}