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