Loading app/src/main/AndroidManifest.xml +21 −2 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <!-- account management permissions not required for own accounts since API level 22 --> <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/> Loading @@ -31,9 +32,9 @@ <!-- permission for broadcast related to account changes event (added/removed) --> <permission android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS" android:name="${applicationId}.permission.ACCOUNT_EVENTS" android:protectionLevel="signature"/> <uses-permission android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS"/> <uses-permission android:name="${applicationId}.permission.ACCOUNT_EVENTS"/> <!-- android.permission-group.LOCATION --> <!-- getting the WiFi name (for "sync in Wifi only") requires Loading Loading @@ -740,6 +741,24 @@ </intent-filter> </receiver> <receiver android:name=".token.TokenRefreshReceiver" android:exported="true" android:permission="${applicationId}.permission.ACCOUNT_EVENTS"> <intent-filter> <action android:name="${applicationId}.action.REFRESH_TOKEN" /> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> <service android:name=".token.TokenUpdaterService" android:foregroundServiceType="specialUse"> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="updater" /> </service> <!-- provider to share debug info/logs --> <provider android:name="androidx.core.content.FileProvider" Loading app/src/main/kotlin/at/bitfire/davdroid/AccountSyncHelper.kt +5 −2 Original line number Diff line number Diff line Loading @@ -20,11 +20,12 @@ package at.bitfire.davdroid import android.accounts.AccountManager import android.content.Context import android.content.Intent import at.bitfire.davdroid.token.MurenaTokenManager object AccountSyncHelper { private const val ACTION_PREFIX = "foundation.e.accountmanager.action" const val ACCOUNT_EVENTS_PERMISSION = "foundation.e.accountmanager.permission.ACCOUNT_EVENTS" private const val ACTION_PREFIX = "${BuildConfig.APPLICATION_ID}.action" const val ACCOUNT_EVENTS_PERMISSION = "${BuildConfig.APPLICATION_ID}.permission.ACCOUNT_EVENTS" const val ACTION_ACCOUNT_REMOVED = "$ACTION_PREFIX.ACCOUNT_REMOVED" const val ACTION_ACCOUNT_ADDED = "$ACTION_PREFIX.ACCOUNT_ADDED" Loading @@ -36,6 +37,7 @@ object AccountSyncHelper { putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType) } context.sendBroadcast(intent, ACCOUNT_EVENTS_PERMISSION) MurenaTokenManager.handleTokenRefresh(context) } fun notifyAccountRemoved(context: Context, accountRemovedIntent: Intent) { Loading @@ -46,6 +48,7 @@ object AccountSyncHelper { }, ACCOUNT_EVENTS_PERMISSION ) MurenaTokenManager.cancelTokenRefreshAlarm(context) } } No newline at end of file app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt +6 −36 Original line number Diff line number Diff line Loading @@ -26,14 +26,13 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.annotation.AnyThread import at.bitfire.davdroid.OpenIdUtils import at.bitfire.davdroid.token.MurenaTokenManager import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.davdroid.ui.setup.LoginActivity import at.bitfire.davdroid.util.AuthStatePrefUtils import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors Loading Loading @@ -217,44 +216,15 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL return result } val tokenRequest = authState.createTokenRefreshRequest() val clientSecretString = accountManager.getUserData(account, AccountSettings.KEY_CLIENT_SECRET) val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString) val authorizationService = EntryPointAccessors.fromApplication(context, DefaultAccountAuthenticatorServiceEntryPoint::class.java) .authorizationService() authorizationService.performTokenRequest( tokenRequest, clientSecret ) { tokenResponse, ex -> 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( Loading app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt +5 −40 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading Loading @@ -208,47 +206,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, 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 } refreshAuthTokenAndSync(authState) if (authState != null && authState.needsTokenRefresh) { Logger.log.info("Token refresh needed for: ${account.name} used by authority: $authority.") MurenaTokenManager.handleTokenRefresh(context) } 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) } } } /** * Perform sync operation. * On unhandled exceptions, retry following fibonnacci sequence (if user pass valid retry times. Loading app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt 0 → 100644 +216 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ 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)) } Loading
app/src/main/AndroidManifest.xml +21 −2 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <!-- account management permissions not required for own accounts since API level 22 --> <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/> Loading @@ -31,9 +32,9 @@ <!-- permission for broadcast related to account changes event (added/removed) --> <permission android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS" android:name="${applicationId}.permission.ACCOUNT_EVENTS" android:protectionLevel="signature"/> <uses-permission android:name="foundation.e.accountmanager.permission.ACCOUNT_EVENTS"/> <uses-permission android:name="${applicationId}.permission.ACCOUNT_EVENTS"/> <!-- android.permission-group.LOCATION --> <!-- getting the WiFi name (for "sync in Wifi only") requires Loading Loading @@ -740,6 +741,24 @@ </intent-filter> </receiver> <receiver android:name=".token.TokenRefreshReceiver" android:exported="true" android:permission="${applicationId}.permission.ACCOUNT_EVENTS"> <intent-filter> <action android:name="${applicationId}.action.REFRESH_TOKEN" /> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> <service android:name=".token.TokenUpdaterService" android:foregroundServiceType="specialUse"> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="updater" /> </service> <!-- provider to share debug info/logs --> <provider android:name="androidx.core.content.FileProvider" Loading
app/src/main/kotlin/at/bitfire/davdroid/AccountSyncHelper.kt +5 −2 Original line number Diff line number Diff line Loading @@ -20,11 +20,12 @@ package at.bitfire.davdroid import android.accounts.AccountManager import android.content.Context import android.content.Intent import at.bitfire.davdroid.token.MurenaTokenManager object AccountSyncHelper { private const val ACTION_PREFIX = "foundation.e.accountmanager.action" const val ACCOUNT_EVENTS_PERMISSION = "foundation.e.accountmanager.permission.ACCOUNT_EVENTS" private const val ACTION_PREFIX = "${BuildConfig.APPLICATION_ID}.action" const val ACCOUNT_EVENTS_PERMISSION = "${BuildConfig.APPLICATION_ID}.permission.ACCOUNT_EVENTS" const val ACTION_ACCOUNT_REMOVED = "$ACTION_PREFIX.ACCOUNT_REMOVED" const val ACTION_ACCOUNT_ADDED = "$ACTION_PREFIX.ACCOUNT_ADDED" Loading @@ -36,6 +37,7 @@ object AccountSyncHelper { putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType) } context.sendBroadcast(intent, ACCOUNT_EVENTS_PERMISSION) MurenaTokenManager.handleTokenRefresh(context) } fun notifyAccountRemoved(context: Context, accountRemovedIntent: Intent) { Loading @@ -46,6 +48,7 @@ object AccountSyncHelper { }, ACCOUNT_EVENTS_PERMISSION ) MurenaTokenManager.cancelTokenRefreshAlarm(context) } } No newline at end of file
app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt +6 −36 Original line number Diff line number Diff line Loading @@ -26,14 +26,13 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.annotation.AnyThread import at.bitfire.davdroid.OpenIdUtils import at.bitfire.davdroid.token.MurenaTokenManager import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.davdroid.ui.setup.LoginActivity import at.bitfire.davdroid.util.AuthStatePrefUtils import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors Loading Loading @@ -217,44 +216,15 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL return result } val tokenRequest = authState.createTokenRefreshRequest() val clientSecretString = accountManager.getUserData(account, AccountSettings.KEY_CLIENT_SECRET) val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString) val authorizationService = EntryPointAccessors.fromApplication(context, DefaultAccountAuthenticatorServiceEntryPoint::class.java) .authorizationService() authorizationService.performTokenRequest( tokenRequest, clientSecret ) { tokenResponse, ex -> 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( Loading
app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt +5 −40 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading Loading @@ -208,47 +206,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, 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 } refreshAuthTokenAndSync(authState) if (authState != null && authState.needsTokenRefresh) { Logger.log.info("Token refresh needed for: ${account.name} used by authority: $authority.") MurenaTokenManager.handleTokenRefresh(context) } 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) } } } /** * Perform sync operation. * On unhandled exceptions, retry following fibonnacci sequence (if user pass valid retry times. Loading
app/src/main/kotlin/at/bitfire/davdroid/token/MurenaTokenManager.kt 0 → 100644 +216 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ 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)) }