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
+ )
+ }
+}