diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f0cdb78b5d41a8d61674a861d9096017eea6a4ba..b8e4428a107435ff0b286196f86e1e856a51805f 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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 21674da79a06cf9163cc850c3ed73d8786317b87..f3b80518430f82de91ab6b920f6ef568727adf47 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 2ed72dde834933a9d42d21c2a000356b1715dff9..a6e9b202c076e555fd6f76a97cf0e3df7dacb231 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 301416f634d23184d6a4e2bd333a3de184029951..0000000000000000000000000000000000000000
--- 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 0000000000000000000000000000000000000000..9cab87aca4386ae474be5aee8ef4049a1aea7e4c
--- /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 f6239549867a1f4d496f0436dcd2c1943d18ae26..0000000000000000000000000000000000000000
--- 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 86ad15467c987bc37b6164147211303ba355d5aa..0000000000000000000000000000000000000000
--- 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 0000000000000000000000000000000000000000..e384d8d91f03356bcfbe0741c297e01720863a2f
--- /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)
+ )
+
+ }
+}