From 336ea82506d9a0afb9818eb17d32f7c929a06d64 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 27 Apr 2026 15:34:55 +0200 Subject: [PATCH 1/4] feat: introduce SyncAdapterComponentManager --- .../main/kotlin/at/bitfire/davdroid/App.kt | 3 + .../at/bitfire/davdroid/TasksWatcher.kt | 2 + .../SyncAdapterComponentManager.kt | 253 ++++++++++++++++++ .../SyncAdapterComponentManagerTest.kt | 120 +++++++++ 4 files changed, 378 insertions(+) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManagerTest.kt diff --git a/app/src/main/kotlin/at/bitfire/davdroid/App.kt b/app/src/main/kotlin/at/bitfire/davdroid/App.kt index 40dc013ce..cc47d38fb 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 410908bb3..e8a6f3e55 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 000000000..ea05cc346 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -0,0 +1,253 @@ +/* + * 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 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) + ) + + 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) + if (updateDecision == ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED) { + return + } + + if (updateDecision == ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED) { + Logger.log.log( + Level.INFO, + "Skipping ${serviceClass.simpleName}: preserving ${stateName(currentState)} (authority=$authority)" + ) + return + } + + 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 { + if (componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER) { + return true + } + + if (componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) { + return true + } + + return false + } + + private fun stateName(state: Int): String { + if (state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + return "ENABLED" + } + + if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { + return "DISABLED" + } + + if (state == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) { + return "DEFAULT" + } + + if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER) { + return "DISABLED_USER" + } + + if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) { + return "DISABLED_UNTIL_USED" + } + + return "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 000000000..b8d3a45c4 --- /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 + ) + } +} -- GitLab From 9a79279c74da84e58f4dc09b59efd86c0f8f35a9 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 5 May 2026 13:48:00 +0200 Subject: [PATCH 2/4] chore(sync): annotate updateComponents as worker thread --- .../bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt index ea05cc346..1f1e741b3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -21,6 +21,7 @@ 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 @@ -52,6 +53,7 @@ object SyncAdapterComponentManager { SyncServiceSpec(YahooEmailSyncAdapterService::class.java, R.string.email_authority) ) + @WorkerThread fun updateComponents(context: Context) { val packageManager = context.packageManager -- GitLab From 3ad93193d2a13c6f777f311919192d3bc87db57f Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 5 May 2026 13:48:54 +0200 Subject: [PATCH 3/4] refactor(sync): replace conditional branches with when expressions --- .../SyncAdapterComponentManager.kt | 76 ++++++++++--------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt index 1f1e741b3..2bd8de342 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -101,27 +101,31 @@ object SyncAdapterComponentManager { } val updateDecision = decideComponentStateUpdate(currentState, desiredState) - if (updateDecision == ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED) { - return - } + when (updateDecision) { + ComponentStateUpdateDecision.SKIP_ALREADY_ALIGNED -> { + return + } - if (updateDecision == ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED) { - Logger.log.log( - Level.INFO, - "Skipping ${serviceClass.simpleName}: preserving ${stateName(currentState)} (authority=$authority)" - ) - return - } + ComponentStateUpdateDecision.SKIP_POLICY_CONTROLLED -> { + Logger.log.log( + Level.INFO, + "Skipping ${serviceClass.simpleName}: preserving ${stateName(currentState)} (authority=$authority)" + ) + return + } - val changed = setComponentEnabledSetting(packageManager, component, serviceClass, desiredState) - if (!changed) { - 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)" - ) + Logger.log.log( + Level.INFO, + "Set ${serviceClass.simpleName} to ${stateName(desiredState)} (authority=$authority)" + ) + } + } } private fun getManifestServiceEnabled( @@ -230,26 +234,30 @@ object SyncAdapterComponentManager { } private fun stateName(state: Int): String { - if (state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { - return "ENABLED" - } + return when (state) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> { + "ENABLED" + } - if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { - return "DISABLED" - } + PackageManager.COMPONENT_ENABLED_STATE_DISABLED -> { + "DISABLED" + } - if (state == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) { - return "DEFAULT" - } + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> { + "DEFAULT" + } - if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER) { - return "DISABLED_USER" - } + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER -> { + "DISABLED_USER" + } - if (state == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) { - return "DISABLED_UNTIL_USED" - } + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> { + "DISABLED_UNTIL_USED" + } - return "UNKNOWN($state)" + else -> { + "UNKNOWN($state)" + } + } } } -- GitLab From f97a869cb5c7cd394882350e50cf69fb6e9c9110 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 5 May 2026 13:51:01 +0200 Subject: [PATCH 4/4] refactor(sync): simplify policy-controlled state check --- .../syncadapter/SyncAdapterComponentManager.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt index 2bd8de342..5405d5df7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncAdapterComponentManager.kt @@ -222,15 +222,8 @@ object SyncAdapterComponentManager { } internal fun isPolicyControlledState(componentState: Int): Boolean { - if (componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER) { - return true - } - - if (componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) { - return true - } - - return false + return componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER || + componentState == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED } private fun stateName(state: Int): String { -- GitLab