diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f7cc61d16129220498cef4f6adf50717e4c6e1ec..4cd2f8b6d0391c26f60c98dca5a60b2f9fc4eec5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
+
@@ -31,9 +32,9 @@
-
+
- authState.update(tokenResponse, ex)
- accountManager.setUserData(
- account,
- AccountSettings.KEY_AUTH_STATE,
- authState.jsonSerializeString()
- )
- accountManager.setUserData(
- account,
- AccountSettings.KEY_CLIENT_SECRET,
- clientSecretString
- )
-
- AuthStatePrefUtils.saveAuthState(context, account!!, authState.jsonSerializeString())
+ MurenaTokenManager.handleTokenRefresh(context, onComplete = { authState ->
+ authState ?: return@handleTokenRefresh
val result = Bundle()
- result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
- result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
+ 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)
-
- try {
- authorizationService.dispose()
- } catch (e: Exception) {
- Logger.log.log(Level.WARNING, "Failed to dispose AuthorizationService", e)
- }
- }
+ })
val result = Bundle()
result.putInt(
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 d1394b3d7723366c75cb5ba05c5e47c76d2a0231..d1396e2ec2760688f867240fee8b8c9c7ca4bbe4 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt
@@ -34,12 +34,11 @@ 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.OpenIdUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
-import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.log.Logger
@@ -70,7 +69,6 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
-import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.HttpUrl
import okhttp3.RequestBody
@@ -208,45 +206,12 @@ abstract class SyncManager, out CollectionType: L
fun performSync() {
val authState = accountSettings.credentials().authState
- if (authState == null || !authState.needsTokenRefresh) {
- performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME)
- return
+ if (authState != null && authState.needsTokenRefresh) {
+ Logger.log.info("Token refresh needed for: ${account.name} used by authority: $authority.")
+ MurenaTokenManager.handleTokenRefresh(context)
}
- refreshAuthTokenAndSync(authState)
- }
-
- private fun refreshAuthTokenAndSync(authState: AuthState) {
- val tokenRequest = authState.createTokenRefreshRequest()
- val clientSecretString = accountSettings.credentials().clientSecret
- val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString)
-
- val authorizationService =
- EntryPointAccessors.fromApplication(context, SyncManagerEntryPoint::class.java)
- .authorizationService()
-
- authorizationService.performTokenRequest(tokenRequest, clientSecret) { tokenResponse, ex ->
- authState.update(tokenResponse, ex)
- accountSettings.credentials(
- Credentials(
- account.name,
- null,
- authState,
- null,
- clientSecret = clientSecretString
- )
- )
-
- executor.execute {
- performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME)
- }
-
- try {
- authorizationService.dispose()
- } catch (e: Exception) {
- Logger.log.log(Level.INFO, "failed to dispose oidc authorizationService", e)
- }
- }
+ 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
new file mode 100644
index 0000000000000000000000000000000000000000..301416f634d23184d6a4e2bd333a3de184029951
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt
@@ -0,0 +1,216 @@
+/*
+ * 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/TokenRefreshReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f6239549867a1f4d496f0436dcd2c1943d18ae26
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/token/TokenRefreshReceiver.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
new file mode 100644
index 0000000000000000000000000000000000000000..86ad15467c987bc37b6164147211303ba355d5aa
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/token/TokenUpdaterService.kt
@@ -0,0 +1,102 @@
+/*
+ * 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
+}