From 91afad0ce32479066c5101eb85daca3ae18b7017 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Thu, 30 Apr 2026 09:03:48 +0200 Subject: [PATCH 1/2] refactor: replace proactive token alarms with reactive OIDC refresh and authenticator error bundles --- app/src/main/AndroidManifest.xml | 19 -- .../at/bitfire/davdroid/AccountSyncHelper.kt | 7 +- .../DefaultAccountAuthenticatorService.kt | 54 ++--- .../davdroid/syncadapter/SyncManager.kt | 8 - .../davdroid/token/MurenaTokenManager.kt | 216 ------------------ .../token/OidcAccountTokenRefresher.kt | 124 ++++++++++ .../davdroid/token/TokenRefreshReceiver.kt | 37 --- .../davdroid/token/TokenUpdaterService.kt | 102 --------- .../AccountSyncHelperTokenRefreshTest.kt | 102 +++++++++ 9 files changed, 249 insertions(+), 420 deletions(-) delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/token/OidcAccountTokenRefresher.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/token/TokenUpdaterService.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/AccountSyncHelperTokenRefreshTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 21674da79..f3b805184 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ - @@ -753,24 +752,6 @@ - - - - - - - - - - - - authState ?: return@handleTokenRefresh + return successResult(account, accessToken) + } - val result = Bundle() - result.putString(AccountManager.KEY_ACCOUNT_NAME, account?.name) - result.putString(AccountManager.KEY_ACCOUNT_TYPE, account?.type) - result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) - response?.onResult(result) - }) + private fun successResult(account: Account, accessToken: String): Bundle { + val result = Bundle() + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) + result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) + result.putString(AccountManager.KEY_AUTHTOKEN, accessToken) + return result + } + private fun errorResult(message: String): Bundle { val result = Bundle() result.putInt( - AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, + AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION ) + result.putString(AccountManager.KEY_ERROR_MESSAGE, message) return result } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt index 2ed72dde8..a6e9b202c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -34,7 +34,6 @@ import at.bitfire.dav4jvm.property.GetCTag import at.bitfire.dav4jvm.property.GetETag import at.bitfire.dav4jvm.property.ScheduleTag import at.bitfire.dav4jvm.property.SyncToken -import at.bitfire.davdroid.token.MurenaTokenManager import at.bitfire.davdroid.Constants import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R @@ -204,13 +203,6 @@ abstract class SyncManager, out CollectionType: L * Call performSync with default retry values */ fun performSync() { - val authState = accountSettings.credentials().authState - - if (authState != null && authState.needsTokenRefresh) { - Logger.log.info("Token refresh needed for: ${account.name} used by authority: $authority.") - MurenaTokenManager.handleTokenRefresh(context) - } - performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt deleted file mode 100644 index 301416f63..000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (C) 2025 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.token - -import android.accounts.AccountManager -import android.annotation.SuppressLint -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.R -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.ui.NetworkUtils -import dagger.hilt.android.EntryPointAccessors -import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationException -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.logging.Level -import kotlin.time.Duration.Companion.minutes - -object MurenaTokenManager { - - const val ACTION_REFRESH = "${BuildConfig.APPLICATION_ID}.action.REFRESH_TOKEN" - private const val PENDING_INTENT_REQUEST_CODE = 1001 - - // Returns a PendingIntent for scheduling or triggering token refresh. - fun getPendingIntent(context: Context, noCreate: Boolean = false): PendingIntent? { - val flags = if (noCreate) { - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - } - - val intent = Intent(context, TokenRefreshReceiver::class.java).apply { - action = ACTION_REFRESH - } - - return PendingIntent.getBroadcast(context, PENDING_INTENT_REQUEST_CODE, intent, flags) - } - - // Cancels any scheduled refresh alarm for the active murena account. - fun cancelTokenRefreshAlarm(context: Context) { - val alarmManager = context.getSystemService(AlarmManager::class.java) - val pendingIntent = getPendingIntent(context, noCreate = true) - - if (pendingIntent != null) { - alarmManager?.cancel(pendingIntent) - pendingIntent.cancel() - Logger.log.info("Token refresh alarm cancelled") - } else { - Logger.log.info("No existing token refresh alarm to cancel") - } - } - - // Schedules a refresh before the token expires, or refreshes immediately if already expired. - fun handleTokenRefresh(context: Context, onComplete: ((AuthState?) -> Unit)? = null) { - val credentials = getAccountSettings(context)?.credentials() ?: run { - Logger.log.warning("No account credentials found, cannot schedule refresh.") - return - } - - val authState = credentials.authState ?: run { - Logger.log.warning("Missing AuthState, cannot schedule refresh.") - return - } - - val expiration = authState.accessTokenExpirationTime ?: run { - Logger.log.warning("Missing token expiration, forcing immediate refresh.") - refreshAuthToken(context, onComplete) - return - } - - if (NetworkUtils.isConnectedToNetwork(context)) { - // Stop token service if its running - val intent = Intent(TokenUpdaterService.ACTION_STOP_TOKEN_SERVICE).apply { - setPackage(context.packageName) - } - context.sendBroadcast(intent) - - // Request at least 2 minutes early. - val refreshAt = expiration.minus(2.minutes.inWholeMilliseconds) - if (refreshAt <= System.currentTimeMillis()) { - Logger.log.info("Token expired or near expiry, refreshing immediately.") - refreshAuthToken(context, onComplete) - return - } else { - setTokenRefreshAlarm(context, refreshAt) - } - } else { - Logger.log.warning("No internet connection, starting service") - val intent = Intent(context, TokenUpdaterService::class.java) - context.startService(intent) - } - - onComplete?.invoke(authState) - } - - // Schedules an exact alarm to refresh the auth token at the specified time. - @SuppressLint("ScheduleExactAlarm") - private fun setTokenRefreshAlarm(context: Context, timeInMillis: Long) { - val alarmManager = context.getSystemService(AlarmManager::class.java) - val pendingIntent = getPendingIntent(context) - - if (alarmManager == null || pendingIntent == null) { - Logger.log.warning("could not schedule token refresh alarm.") - return - } - - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent) - Logger.log.info("Next token refresh scheduled at ${timeInMillis.asDateString()}") - } - - // Refreshes the authentication token and updates stored credentials if successful. - private fun refreshAuthToken(context: Context, onComplete: ((AuthState?) -> Unit)? = null) { - try { - val httpEntryPoint = EntryPointAccessors.fromApplication( - context, HttpClient.HttpClientEntryPoint::class.java - ) - - val authService = httpEntryPoint.authorizationService() - val accountSettings = getAccountSettings(context) ?: run { - Logger.log.warning("No account settings found during token refresh.") - return - } - - val credentials = accountSettings.credentials() - val authState = credentials.authState ?: run { - Logger.log.warning("Missing AuthState during token refresh.") - return - } - - if (isInvalidGrant(authState.authorizationException)) { - Logger.log.warning("Invalid grant detected, user re-auth required.") - cancelTokenRefreshAlarm(context) - return - } - - val tokenRequest = authState.createTokenRefreshRequest() - authService.performTokenRequest(tokenRequest) { response, exception -> - when { - response != null && exception == null -> { - authState.update(response, null) - accountSettings.credentials(credentials.copy(authState = authState)) - Logger.log.info("Token refreshed for ${accountSettings.account.name}") - - // Schedule at least 2 minutes early for the new token. - val refreshAt = authState.accessTokenExpirationTime?.minus(2.minutes.inWholeMilliseconds) - if (refreshAt != null) { - setTokenRefreshAlarm(context, refreshAt) - } - - onComplete?.invoke(authState) - } - - isInvalidGrant(exception) -> { - Logger.log.log(Level.SEVERE, "Invalid grant: refresh cancelled, User must re-authenticate.", exception) - cancelTokenRefreshAlarm(context) - } - - else -> { - Logger.log.log(Level.SEVERE, "Token refresh failed: unknown error, retrying in 5 minutes.") - setTokenRefreshAlarm(context, System.currentTimeMillis() + 5.minutes.inWholeMilliseconds) - } - } - } - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Token refresh failed due to unexpected exception.", e) - } finally { - onComplete?.invoke(null) - } - } - - // Checks whether the given AuthorizationException indicates an invalid grant (requires re-login). - private fun isInvalidGrant(ex: AuthorizationException?): Boolean { - val invalidGrant = AuthorizationException.TokenRequestErrors.INVALID_GRANT - return ex?.code == invalidGrant.code && ex.error == invalidGrant.error - } - - // Retrieves the Murena account settings for the currently active account, if available. - // We only allow one murena account. - fun getAccountSettings(context: Context): AccountSettings? { - val accountType = context.getString(R.string.eelo_account_type) - val account = AccountManager.get(context) - .getAccountsByType(accountType) - .firstOrNull() - - return account?.let { AccountSettings(context, it) } ?: run { - Logger.log.info("No Murena account found.") - null - } - } - - private fun Long.asDateString(): String = - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(this)) -} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/token/OidcAccountTokenRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/token/OidcAccountTokenRefresher.kt new file mode 100644 index 000000000..9cab87aca --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/token/OidcAccountTokenRefresher.kt @@ -0,0 +1,124 @@ +/* + * 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.token + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.OpenIdUtils +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.util.AuthStatePrefUtils +import at.bitfire.davdroid.util.setAndVerifyUserData +import com.nextcloud.android.sso.OidcTokenRefresher +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +object OidcAccountTokenRefresher { + + private val accountLocks = ConcurrentHashMap() + + fun refreshAccountIfNeeded(context: Context, account: Account): AuthState? { + val lock = accountLocks.computeIfAbsent(account.name) { Any() } + return synchronized(lock) { refreshAccountIfNeededLocked(context, account) } + } + + private fun refreshAccountIfNeededLocked(context: Context, account: Account): AuthState? { + val accountSettings = try { + AccountSettings(context, account) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Unable to initialize account settings for ${account.name}", e) + return null + } + + val credentials = accountSettings.credentials() + val authState = credentials.authState + if (authState == null) { + return null + } + if (!authState.needsTokenRefresh) { + return authState + } + + val accountManager = AccountManager.get(context) + val updatedAuthState = try { + OidcTokenRefresher.refreshAuthState( + context = context, + account = account, + getClientAuth = { + OpenIdUtils.getClientAuthentication(credentials.clientSecret) + }, + readAuthState = { + val authStateString = + accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE) + if (authStateString.isNullOrBlank()) { + null + } else { + AuthState.jsonDeserialize(authStateString) + } + }, + writeAuthState = { refreshedAuthState -> + val serializedAuthState = refreshedAuthState.jsonSerializeString() + accountManager.setAndVerifyUserData( + account, + AccountSettings.KEY_AUTH_STATE, + serializedAuthState + ) + AuthStatePrefUtils.saveAuthState(context, account, serializedAuthState) + } + ) + } catch (e: AuthorizationException) { + Logger.log.log(Level.WARNING, "OIDC authorization failure while refreshing ${account.name}", e) + return null + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Token refresh failed for ${account.name}", e) + return null + } + + if (updatedAuthState == null) { + return null + } + + val accessToken = updatedAuthState.accessToken + if (accessToken.isNullOrBlank()) { + return null + } + + accountManager.setAuthToken(account, Constants.AUTH_TOKEN_TYPE, accessToken) + return updatedAuthState + } + + fun refreshMurenaAccountIfNeeded(context: Context, accountName: String? = null): AuthState? { + val accountType = context.getString(R.string.eelo_account_type) + val accounts = AccountManager.get(context).getAccountsByType(accountType) + val matchingAccount = accounts.firstOrNull { + accountName.isNullOrBlank() || AccountUtils.matchesAccountName(it, accountName) + } + if (matchingAccount == null) { + return null + } + + return refreshAccountIfNeeded(context, matchingAccount) + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt deleted file mode 100644 index f62395498..000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2025 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.token - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import at.bitfire.davdroid.log.Logger - -class TokenRefreshReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Intent.ACTION_BOOT_COMPLETED, - MurenaTokenManager.ACTION_REFRESH -> { - MurenaTokenManager.handleTokenRefresh(context) - } - else -> Logger.log.warning("Received unknown action: ${intent.action}") - } - } -} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/token/TokenUpdaterService.kt b/app/src/main/kotlin/at/bitfire/davdroid/token/TokenUpdaterService.kt deleted file mode 100644 index 86ad15467..000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/token/TokenUpdaterService.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2025 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.token - -import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkRequest -import android.os.Binder -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import androidx.core.content.ContextCompat -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.log.Logger -import kotlin.time.Duration.Companion.seconds - -class TokenUpdaterService : Service() { - - companion object { - const val ACTION_STOP_TOKEN_SERVICE = "${BuildConfig.APPLICATION_ID}.action.STOP_TOKEN_SERVICE" - } - - private val context by lazy { applicationContext } - private val connectivityManager by lazy { - getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - } - - private val refreshDelay = 10.seconds.inWholeMilliseconds - - private val handler = Handler(Looper.getMainLooper()) - - private val stopReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - ACTION_STOP_TOKEN_SERVICE -> stopSelf() - else -> Logger.log.warning("Received unknown action: ${intent.action}") - } - } - } - - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - Logger.log.info("Network became available.") - // Send broadcast 10 seconds delayed, let's wait till internet is stable. - handler.postDelayed({ - val intent = Intent(context, TokenRefreshReceiver::class.java).apply { - action = MurenaTokenManager.ACTION_REFRESH - } - sendBroadcast(intent) - }, refreshDelay) - } - - override fun onLost(network: Network) { - Logger.log.warning("Network lost, waiting to reconnect.") - } - } - - override fun onCreate() { - super.onCreate() - - ContextCompat.registerReceiver(this, stopReceiver, IntentFilter(ACTION_STOP_TOKEN_SERVICE), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - - connectivityManager.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback) - } - - override fun onDestroy() { - super.onDestroy() - handler.removeCallbacksAndMessages(null) - try { - connectivityManager.unregisterNetworkCallback(networkCallback) - unregisterReceiver(stopReceiver) - } catch (e: Exception) { - Logger.log.warning("Error during service cleanup: ${e.message}") - } - Logger.log.info("Service stopped and cleaned up.") - } - - override fun onBind(intent: Intent?): IBinder? = null -} diff --git a/app/src/test/kotlin/at/bitfire/davdroid/AccountSyncHelperTokenRefreshTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/AccountSyncHelperTokenRefreshTest.kt new file mode 100644 index 000000000..e384d8d91 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/AccountSyncHelperTokenRefreshTest.kt @@ -0,0 +1,102 @@ +package at.bitfire.davdroid + +import android.accounts.AccountManager +import android.content.Context +import android.content.Intent +import at.bitfire.davdroid.token.OidcAccountTokenRefresher +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AccountSyncHelperTokenRefreshTest { + + private lateinit var context: Context + private lateinit var accountManager: AccountManager + + @Before + fun setUp() { + context = mockk(relaxed = true) + accountManager = mockk() + + every { + context.getString(R.string.eelo_account_type) + } returns "e.foundation.webdav.eelo" + + mockkStatic(AccountManager::class) + every { + AccountManager.get(context) + } returns accountManager + every { + accountManager.getAccountsByType("e.foundation.webdav.eelo") + } returns emptyArray() + mockkObject(OidcAccountTokenRefresher) + every { + OidcAccountTokenRefresher.refreshMurenaAccountIfNeeded(context, any()) + } returns null + + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `notifyAccountAdded sends account added broadcast with expected account extras`() { + val accountName = "alice" + val intentSlot = slot() + + AccountSyncHelper.notifyAccountAdded(context, accountName) + + verify(exactly = 1) { + context.sendBroadcast(capture(intentSlot), AccountSyncHelper.ACCOUNT_EVENTS_PERMISSION) + } + + val broadcastIntent = intentSlot.captured + assertEquals(AccountSyncHelper.ACTION_ACCOUNT_ADDED, broadcastIntent.action) + assertEquals(accountName, broadcastIntent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)) + assertEquals( + "e.foundation.webdav.eelo", + broadcastIntent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE) + ) + + verify(exactly = 1) { + OidcAccountTokenRefresher.refreshMurenaAccountIfNeeded(context, accountName) + } + } + + @Test + fun `notifyAccountRemoved sends account removed broadcast with expected account extras`() { + val accountRemovedIntent = Intent().apply { + putExtra(AccountManager.KEY_ACCOUNT_NAME, "alice") + } + val intentSlot = slot() + + AccountSyncHelper.notifyAccountRemoved(context, accountRemovedIntent) + + verify(exactly = 1) { + context.sendBroadcast(capture(intentSlot), AccountSyncHelper.ACCOUNT_EVENTS_PERMISSION) + } + + val broadcastIntent = intentSlot.captured + assertEquals(AccountSyncHelper.ACTION_ACCOUNT_REMOVED, broadcastIntent.action) + assertEquals( + "alice", + broadcastIntent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) + ) + + } +} -- GitLab From 27190a6d94f3d2f34ae98d63942d2673be3d6d6f Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 5 May 2026 11:44:25 +0200 Subject: [PATCH 2/2] ci: add generate-apks manual job on MRs --- .gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f0cdb78b5..b8e4428a1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,6 +79,7 @@ build: init_submodules: stage: gitlab_release rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_REF_PROTECTED == "true"' script: - git clone https://gitlab.e.foundation/e/os/system-apps-update-info.git systemAppsUpdateInfo @@ -89,6 +90,8 @@ init_submodules: generate-apks: stage: gitlab_release rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: manual - if: '$CI_COMMIT_REF_PROTECTED == "true"' needs: - job: init_submodules -- GitLab