Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit aad8ec08 authored by Jonathan Klee's avatar Jonathan Klee
Browse files

Merge branch '000-a15-SyncAdapterComponentManager' into 'main'

feat: introduce SyncAdapterComponentManager

See merge request !200
parents cab53042 f97a869c
Loading
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -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()

+2 −0
Original line number Diff line number Diff line
@@ -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)
        }
    }
+256 −0
Original line number Diff line number Diff line
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

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)"
            }
        }
    }
}
+120 −0
Original line number Diff line number Diff line

/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */


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