diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aac6af3..6863e9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,19 +33,19 @@ android { buildConfigField( "String", "AUTHRESS_APPLICATION_ID", - "\"${(findProperty("authressApplicationId") as? String) ?: "numaeel_android"}\"", + // Matches the web app's VITE_AUTHRESS_APPLICATION_ID; the previous + // value was invented and no such application exists in Authress. + "\"${(findProperty("authressApplicationId") as? String) ?: "app_2EAWGEdtzaeCj7b45DsDtt"}\"", ) + // Redirect target for the Authress login flow, in the scheme://host/path + // form the SDK documents. MainActivity is the only component that claims + // it — see its intent filter. buildConfigField( "String", - "OAUTH_REDIRECT_SCHEME", - "\"ch.rhosys.email\"", + "OAUTH_REDIRECT_URI", + "\"ch.rhosys.email://auth/callback\"", ) - manifestPlaceholders["oauthRedirectScheme"] = "ch.rhosys.email" - // Required by the AppAuth library's own manifest (RedirectUriReceiverActivity), - // even though our redirect is actually captured by MainActivity's intent-filter below. - manifestPlaceholders["appAuthRedirectScheme"] = "ch.rhosys.email" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -139,7 +139,7 @@ dependencies { implementation(libs.security.crypto) implementation(libs.biometric) - implementation(libs.appauth) + implementation(libs.androidx.browser) implementation(libs.glance.appwidget) implementation(libs.glance.material3) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a162c16..c0c39d4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,12 +63,17 @@ - + - + (null) } @@ -43,6 +50,24 @@ class MainActivity : FragmentActivity() { } } + /** + * The React Native SDK subscribes to Linking 'url' events for this; on native + * Android the equivalent is the launch intent plus onNewIntent, which is what + * the SDK's own Android setup instructions tell you to forward. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleAuthRedirect(intent) + } + + private fun handleAuthRedirect(intent: Intent?) { + val appContainer = (application as EmailApp).appContainer + val uri = intent?.data ?: return + if (!appContainer.authManager.isRedirect(uri)) return + lifecycleScope.launch { appContainer.authManager.completeAuthenticationRequest(uri) } + } + override fun onStart() { super.onStart() SyncForegroundService.start(this) diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt new file mode 100644 index 0000000..e0663e5 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthStorageManager.kt @@ -0,0 +1,61 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import org.json.JSONObject + +/** + * PKCE state between starting a login and returning from the browser, ported + * from authStorageManager.ts. The code verifier must survive the app being + * killed while the Custom Tab is in front, so it goes to encrypted storage + * rather than memory. + */ +class AuthStorageManager(context: Context) { + + private val prefs = EncryptedSharedPreferences.create( + context, + "authress_pending_auth", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + data class PendingAuthentication( + val codeVerifier: String, + val authenticationRequestId: String, + val redirectUrl: String, + ) + + fun setAuthenticationRequest(state: PendingAuthentication?) { + if (state == null) { + prefs.edit().remove(KEY_PENDING).apply() + return + } + val json = JSONObject() + .put("codeVerifier", state.codeVerifier) + .put("authenticationRequestId", state.authenticationRequestId) + .put("redirectUrl", state.redirectUrl) + prefs.edit().putString(KEY_PENDING, json.toString()).apply() + } + + fun getAuthenticationRequest(): PendingAuthentication? { + val raw = prefs.getString(KEY_PENDING, null) ?: return null + return runCatching { + val json = JSONObject(raw) + PendingAuthentication( + codeVerifier = json.getString("codeVerifier"), + authenticationRequestId = json.getString("authenticationRequestId"), + redirectUrl = json.getString("redirectUrl"), + ) + }.getOrNull() + } + + fun clear() { + prefs.edit().clear().apply() + } + + private companion object { + const val KEY_PENDING = "authress-pending-auth" + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt deleted file mode 100644 index 5513b4b..0000000 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressAuthManager.kt +++ /dev/null @@ -1,102 +0,0 @@ -package ch.rhosys.email.data.auth - -import android.app.Activity -import android.content.Context -import android.net.Uri -import androidx.activity.result.ActivityResultLauncher -import ch.rhosys.email.BuildConfig -import kotlinx.coroutines.suspendCancellableCoroutine -import net.openid.appauth.AuthState -import net.openid.appauth.AuthorizationException -import net.openid.appauth.AuthorizationRequest -import net.openid.appauth.AuthorizationResponse -import net.openid.appauth.AuthorizationService -import net.openid.appauth.AuthorizationServiceConfiguration -import net.openid.appauth.ResponseTypeValues -import net.openid.appauth.TokenResponse -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * OIDC login against Authress (decision #6): social logins, passkeys, or - * email/password are all handled by Authress's hosted login page — the app - * only speaks standard OAuth2/OIDC via AppAuth, so no credential UI lives here. - */ -class AuthressAuthManager(private val context: Context, private val tokenStore: TokenStore) { - - private val service = AuthorizationService(context) - - private val serviceConfig = AuthorizationServiceConfiguration( - Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/authorize"), - Uri.parse("https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/oauth/token"), - ) - - private val redirectUri = Uri.parse("${BuildConfig.OAUTH_REDIRECT_SCHEME}:/oauth2redirect") - - fun buildAuthRequestIntent() = service.getAuthorizationRequestIntent( - AuthorizationRequest.Builder( - serviceConfig, - BuildConfig.AUTHRESS_APPLICATION_ID, - ResponseTypeValues.CODE, - redirectUri, - ).setScope("openid profile email offline_access").build(), - ) - - fun launchSignIn(launcher: ActivityResultLauncher) { - launcher.launch(buildAuthRequestIntent()) - } - - suspend fun handleAuthResponse(data: android.content.Intent): Result { - val response = AuthorizationResponse.fromIntent(data) - val exception = AuthorizationException.fromIntent(data) - if (response == null) return Result.failure(exception ?: IllegalStateException("Sign-in cancelled")) - - return runCatching { - val tokenResponse = exchangeToken(response) - tokenStore.accessToken = tokenResponse.accessToken - tokenStore.refreshToken = tokenResponse.refreshToken - tokenStore.accessTokenExpiresAt = tokenResponse.accessTokenExpirationTime ?: 0L - } - } - - private suspend fun exchangeToken(response: AuthorizationResponse): TokenResponse = - suspendCancellableCoroutine { cont -> - service.performTokenRequest(response.createTokenExchangeRequest()) { tokenResponse, ex -> - when { - tokenResponse != null -> cont.resume(tokenResponse) - ex != null -> cont.resumeWithException(ex) - else -> cont.resumeWithException(IllegalStateException("Token exchange failed")) - } - } - } - - suspend fun refreshAccessToken(): Boolean { - val refreshToken = tokenStore.refreshToken ?: return false - val authState = AuthState(serviceConfig) - return suspendCancellableCoroutine { cont -> - service.performTokenRequest( - net.openid.appauth.TokenRequest.Builder(serviceConfig, BuildConfig.AUTHRESS_APPLICATION_ID) - .setGrantType(net.openid.appauth.GrantTypeValues.REFRESH_TOKEN) - .setRefreshToken(refreshToken) - .build(), - ) { tokenResponse, ex -> - if (tokenResponse != null) { - tokenStore.accessToken = tokenResponse.accessToken - tokenResponse.refreshToken?.let { tokenStore.refreshToken = it } - tokenStore.accessTokenExpiresAt = tokenResponse.accessTokenExpirationTime ?: 0L - cont.resume(true) - } else { - cont.resume(false) - } - } - } - } - - fun signOut() { - tokenStore.clear() - } - - fun dispose() { - service.dispose() - } -} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt new file mode 100644 index 0000000..e4ba1a8 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressCookieJar.kt @@ -0,0 +1,119 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import org.json.JSONArray +import org.json.JSONObject + +/** + * The Authress session lives in cookies rather than an access/refresh pair: + * `authorization` carries the bearer token and `user` carries the identity token. + * + * Structured to match authStorageManager.ts. The SDK keeps two things — the + * platform cookie jar that its HTTP calls read and write, and a mirror of it in + * encrypted storage — and moves between them with explicit backupCookies and + * restoreCookies at defined points. That split is reproduced here rather than + * collapsed into a single always-persisted store, so the call sites line up with + * the SDK's one for one. + * + * `lastValue` behaviour is preserved: when several calls set the same cookie name + * on different paths, the last value written wins. + */ +class AuthressCookieJar(context: Context, private val authressHost: String) : CookieJar { + + private val prefs = EncryptedSharedPreferences.create( + context, + "authress_cookies", + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + /** The live jar, equivalent to the SDK's native cookie store. */ + private val cookies = linkedMapOf() + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (!url.host.equals(authressHost, ignoreCase = true)) return + cookies.forEach { cookie -> + // An expiry in the past is a deletion. + if (cookie.expiresAt < System.currentTimeMillis()) { + this.cookies.remove(cookie.name) + } else { + this.cookies[cookie.name] = cookie.value + } + } + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + if (!url.host.equals(authressHost, ignoreCase = true)) return emptyList() + return cookies.map { (name, value) -> + Cookie.Builder() + .name(name) + .value(value) + .domain(authressHost) + .path("/") + .secure() + .httpOnly() + .build() + } + } + + /** The bearer token used for API calls. */ + @Synchronized + fun authorizationCookie(): String? = cookies[COOKIE_AUTHORIZATION] + + /** The identity token, carrying the user's profile claims. */ + @Synchronized + fun userCookie(): String? = cookies[COOKIE_USER] + + /** + * Mirrors the live jar into encrypted storage. The SDK calls this after a + * successful token exchange and after a successful session check. + */ + @Synchronized + fun backupCookies() { + if (cookies.isEmpty()) return + val array = JSONArray() + cookies.forEach { (name, value) -> + array.put(JSONObject().put("name", name).put("value", value)) + } + prefs.edit().putString(KEY_COOKIES, array.toString()).apply() + } + + /** + * Repopulates the live jar from the backup, and only when the jar is empty — + * the SDK returns early if the platform store already holds cookies, so a + * live session is never overwritten by a stale mirror. + */ + @Synchronized + fun restoreCookies() { + if (cookies.isNotEmpty()) return + val raw = prefs.getString(KEY_COOKIES, null) ?: return + runCatching { + val array = JSONArray(raw) + for (i in 0 until array.length()) { + val entry = array.getJSONObject(i) + cookies[entry.getString("name")] = entry.getString("value") + } + } + } + + /** Clears the live jar and the backup together. */ + @Synchronized + fun clear() { + cookies.clear() + prefs.edit().remove(KEY_COOKIES).apply() + } + + private companion object { + const val KEY_COOKIES = "authress-cookies" + const val COOKIE_AUTHORIZATION = "authorization" + const val COOKIE_USER = "user" + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt new file mode 100644 index 0000000..e45e5b6 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -0,0 +1,256 @@ +package ch.rhosys.email.data.auth + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import ch.rhosys.email.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * Port of loginClient.ts from @authress/login-react-native. + * + * Authress is not a plain OAuth provider and this is deliberately not an + * authorize/token exchange. The flow the SDK implements is: + * + * 1. POST /api/authentication with the applicationId, the redirect URL and a + * PKCE S256 challenge. Authress answers with an `authenticationUrl` to open + * and an `authenticationRequestId` to correlate the response. + * 2. Open that URL in a Custom Tab. The user picks a provider, passkey or + * password on the Authress-hosted page. + * 3. Authress redirects back to the app's deep link with `code` and + * `authenticationRequestId` query parameters. + * 4. POST /api/authentication/{authenticationRequestId}/tokens with the code, + * the stored code verifier and the redirect URI. + * + * The session is then held in cookies rather than in a token pair — + * see [AuthressCookieJar]. + */ +class AuthressLoginClient( + private val context: Context, + private val cookieJar: AuthressCookieJar, + httpClient: OkHttpClient, +) { + /** The SDK's HttpClient appends /api to the origin; every path below is relative to it. */ + private val loginUrl = "https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}/api" + + private val origin = "https://${BuildConfig.AUTHRESS_CUSTOM_DOMAIN}" + + private val redirectUri = BuildConfig.OAUTH_REDIRECT_URI + + private val storage = AuthStorageManager(context) + + /** The Authress calls carry the session cookie and must not carry our API bearer. */ + private val http = httpClient.newBuilder().cookieJar(cookieJar).build() + + class AuthressException(message: String, val status: Int? = null) : Exception(message) + + private val _sessionEstablished = MutableStateFlow(false) + + /** + * Emits when a session exists. The SDK resolves an internal promise at the + * same points; a flow is the idiomatic equivalent for Compose to collect. + */ + val sessionEstablished: StateFlow = _sessionEstablished.asStateFlow() + + init { + // The SDK restores cookies from encrypted storage in its constructor, + // before anything reads a token. + cookieJar.restoreCookies() + _sessionEstablished.value = getToken() != null + } + + data class AuthenticationResponse( + val authenticationUrl: String, + val authenticationRequestId: String, + ) + + // ── authenticate ──────────────────────────────────────────────────────── + + /** + * Begins the login flow and opens the Authress-hosted login page. Returns + * once the browser has been launched; completion arrives via the deep link. + */ + suspend fun authenticate(connectionId: String? = null): Result = runCatching { + storage.setAuthenticationRequest(null) + + val codes = JwtManager.getAuthCodes() + val body = JSONObject() + .put("redirectUrl", redirectUri) + .put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID) + .put("codeChallenge", codes.codeChallenge) + .put("codeChallengeMethod", "S256") + .apply { connectionId?.let { put("connectionId", it) } } + + val response = post("/authentication", body) + val authenticationUrl = response.getString("authenticationUrl") + val authenticationRequestId = response.getString("authenticationRequestId") + + storage.setAuthenticationRequest( + AuthStorageManager.PendingAuthentication( + codeVerifier = codes.codeVerifier, + authenticationRequestId = authenticationRequestId, + redirectUrl = redirectUri, + ), + ) + + withContext(Dispatchers.Main) { + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl)) + } + } + + // ── completeAuthenticationRequest ─────────────────────────────────────── + + /** + * Completes the flow from the deep link. Mirrors the SDK: a mismatched or + * missing pending request is an error, but a 4xx from the token exchange is + * treated as success-and-clean-up, because it most often means the code was + * already redeemed. + */ + suspend fun completeAuthenticationRequest(uri: Uri): Result = runCatching { + val code = uri.getQueryParameter("code").orEmpty() + val authenticationRequestId = uri.getQueryParameter("authenticationRequestId").orEmpty() + + val pending = storage.getAuthenticationRequest() + ?: throw AuthressException("No authentication request in progress") + if (pending.authenticationRequestId != authenticationRequestId) { + throw AuthressException("Authentication request mismatch") + } + + val body = JSONObject() + .put("code", code) + .put("codeVerifier", pending.codeVerifier) + .put("redirectUri", pending.redirectUrl) + + try { + post("/authentication/$authenticationRequestId/tokens", body) + } catch (e: AuthressException) { + val status = e.status + if (status != null && status < 500) { + // Code already used — the session is established, nothing to do. + storage.setAuthenticationRequest(null) + _sessionEstablished.value = getToken() != null + return@runCatching + } + throw e + } + + cookieJar.backupCookies() + storage.setAuthenticationRequest(null) + _sessionEstablished.value = getToken() != null + } + + /** True when the redirect belongs to this client. */ + fun isRedirect(uri: Uri?): Boolean = + uri != null && uri.toString().startsWith(redirectUri) + + // ── session ───────────────────────────────────────────────────────────── + + /** + * The bearer token for API calls, read from the `authorization` cookie and + * checked against the issuer, as the SDK's getToken does. + */ + fun getToken(): String? { + val token = cookieJar.authorizationCookie() ?: return null + val payload = JwtManager.decode(token) ?: return null + if (payload.optString("iss") != origin) return null + return token + } + + val isSignedIn: Boolean get() = getToken() != null + + /** The identity token's claims, for showing who is signed in. */ + fun getUserIdentity(): JSONObject? { + val payload = JwtManager.decode(cookieJar.userCookie()) ?: return null + if (payload.optString("iss") != origin) return null + return payload + } + + /** + * Validates the session server-side and refreshes the cookie when the current + * token has expired. The SDK calls PATCH /session for this. + */ + suspend fun userIsLoggedIn(): Boolean { + if (getToken() != null) return true + return runCatching { + patch("/session", JSONObject()) + val loggedIn = getToken() != null + if (loggedIn) cookieJar.backupCookies() + loggedIn.also { _sessionEstablished.value = it } + }.getOrDefault(false) + } + + /** + * Waits until a bearer token is available, then returns it. Blocks until + * [authenticate] plus [completeAuthenticationRequest], or [userIsLoggedIn], + * establishes a session. This is the SDK's documented way to obtain the value + * for an Authorization header, and is what [ch.rhosys.email.data.remote.api.AuthInterceptor] + * uses — reading the cookie directly would race a session that is mid-refresh. + * + * Returns null if no token arrives within [timeoutInMillis]; 0 means do not + * wait at all, matching the SDK. + */ + suspend fun waitForToken(timeoutInMillis: Long = 5000): String? { + getToken()?.let { return it } + if (timeoutInMillis == 0L) return null + + return withTimeoutOrNull(timeoutInMillis) { + // Resolved by completeAuthenticationRequest or a successful session check. + _sessionEstablished.first { it } + getToken() + } + } + + /** Ends the server session first, while the cookie can still identify it. */ + suspend fun logout(): Result = runCatching { + runCatching { delete("/session") } + cookieJar.clear() + storage.clear() + _sessionEstablished.value = false + } + + // ── HTTP ──────────────────────────────────────────────────────────────── + + private suspend fun post(path: String, body: JSONObject): JSONObject = + execute(Request.Builder().url(loginUrl + path).post(body.toBody())) + + private suspend fun patch(path: String, body: JSONObject): JSONObject = + execute(Request.Builder().url(loginUrl + path).patch(body.toBody())) + + private suspend fun delete(path: String): JSONObject = + execute(Request.Builder().url(loginUrl + path).delete()) + + private fun JSONObject.toBody() = toString().toRequestBody(JSON) + + private suspend fun execute(builder: Request.Builder): JSONObject = withContext(Dispatchers.IO) { + val request = builder + .header("Content-Type", "application/json") + .header("X-Powered-By", "Authress Login SDK; Android; ${BuildConfig.VERSION_NAME}") + .build() + + http.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw AuthressException( + "Authress ${request.method} ${request.url.encodedPath} failed: ${response.code} $text", + status = response.code, + ) + } + runCatching { JSONObject(text) }.getOrDefault(JSONObject()) + } + } + + private companion object { + val JSON = "application/json".toMediaType() + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt new file mode 100644 index 0000000..ddb6ebd --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt @@ -0,0 +1,47 @@ +package ch.rhosys.email.data.auth + +import android.util.Base64 +import org.json.JSONObject +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Port of jwtManager.ts from @authress/login-react-native. + * + * Base64url without padding throughout, matching the SDK's `b64urlEncode`. + */ +object JwtManager { + + private const val B64_URL = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + + data class AuthCodes(val codeVerifier: String, val codeChallenge: String) + + /** + * PKCE pair. The SDK derives the verifier from 16 random 32-bit values rendered + * as a comma-joined decimal string, then base64url-encodes that; the challenge + * is base64url(SHA-256(verifier)). + */ + fun getAuthCodes(): AuthCodes { + val random = SecureRandom() + val words = IntArray(16) { random.nextInt() } + val joined = words.joinToString(",") { (it.toLong() and 0xFFFFFFFFL).toString() } + val codeVerifier = Base64.encodeToString(joined.toByteArray(Charsets.UTF_8), B64_URL) + val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(Charsets.UTF_8)) + return AuthCodes(codeVerifier, Base64.encodeToString(digest, B64_URL)) + } + + /** + * Decodes a JWT payload without verifying the signature — the SDK does the same, + * because the token arrives over TLS from the issuer it is then checked against. + * `exp` is shortened by 10 seconds as a clock-skew buffer, matching the SDK. + */ + fun decode(token: String?): JSONObject? { + if (token.isNullOrBlank()) return null + return runCatching { + val payloadSegment = token.split(".").getOrNull(1) ?: return null + val json = JSONObject(String(Base64.decode(payloadSegment, B64_URL), Charsets.UTF_8)) + if (json.has("exp")) json.put("exp", json.getLong("exp") - 10) + json + }.getOrNull() + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt b/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt index a776836..383e501 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/TokenStore.kt @@ -6,9 +6,12 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey /** - * OAuth tokens at rest, decision #50: EncryptedSharedPreferences backed by the - * Android Keystore. No AWS credentials ever touch the device — the backend - * proxies all API calls, so this store only ever holds the Authress session. + * Local state that outlives a session, in EncryptedSharedPreferences backed by + * the Android Keystore. + * + * Deliberately no access or refresh token: the Authress session is held in + * cookies, managed by AuthressCookieJar, exactly as the login SDK does it. The + * only thing kept here is which account the user last had selected. */ class TokenStore(context: Context) { private val masterKey = MasterKey.Builder(context) @@ -23,32 +26,16 @@ class TokenStore(context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) - var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit().putString(KEY_ACCESS_TOKEN, value).apply() - - var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit().putString(KEY_REFRESH_TOKEN, value).apply() - - var accessTokenExpiresAt: Long - get() = prefs.getLong(KEY_EXPIRES_AT, 0L) - set(value) = prefs.edit().putLong(KEY_EXPIRES_AT, value).apply() - var activeAccountId: String? get() = prefs.getString(KEY_ACTIVE_ACCOUNT, null) set(value) = prefs.edit().putString(KEY_ACTIVE_ACCOUNT, value).apply() - val isSignedIn: Boolean get() = accessToken != null fun clear() { prefs.edit().clear().apply() } private companion object { - const val KEY_ACCESS_TOKEN = "access_token" - const val KEY_REFRESH_TOKEN = "refresh_token" - const val KEY_EXPIRES_AT = "access_token_expires_at" const val KEY_ACTIVE_ACCOUNT = "active_account_id" } } diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt index a94e6cd..9e77785 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/AuthInterceptor.kt @@ -1,12 +1,23 @@ package ch.rhosys.email.data.remote.api -import ch.rhosys.email.data.auth.TokenStore +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response -class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor { +/** + * Attaches the Authress session token to API calls. + * + * The token comes from the login client's waitForToken, which is what the SDK + * documents for an Authorization header: it returns immediately when a valid + * session exists, and otherwise waits briefly for one being established rather + * than firing a request that is certain to be rejected. + * + * runBlocking is safe here — OkHttp interceptors already run on a background + * dispatcher, never the main thread. + */ +class AuthInterceptor(private val tokenProvider: suspend () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = tokenStore.accessToken + val token = runBlocking { tokenProvider() } val request = chain.request().newBuilder().apply { if (token != null) addHeader("Authorization", "Bearer $token") }.build() diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index 5b32fc7..ba8447c 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -3,7 +3,8 @@ package ch.rhosys.email.di import android.content.Context import androidx.room.Room import ch.rhosys.email.BuildConfig -import ch.rhosys.email.data.auth.AuthressAuthManager +import ch.rhosys.email.data.auth.AuthressCookieJar +import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.auth.TokenStore import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.remote.api.AuthInterceptor @@ -37,7 +38,14 @@ import java.util.concurrent.TimeUnit class AppContainer(private val context: Context) { val tokenStore: TokenStore by lazy { TokenStore(context) } - val authManager: AuthressAuthManager by lazy { AuthressAuthManager(context, tokenStore) } + + private val cookieJar: AuthressCookieJar by lazy { + AuthressCookieJar(context, BuildConfig.AUTHRESS_CUSTOM_DOMAIN) + } + + val authManager: AuthressLoginClient by lazy { + AuthressLoginClient(context, cookieJar, okHttpClient) + } private val moshi: Moshi by lazy { // SignalDto is a polymorphic union discriminated by `type`; Moshi needs the @@ -49,7 +57,7 @@ class AppContainer(private val context: Context) { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() - .addInterceptor(AuthInterceptor(tokenStore)) + .addInterceptor(AuthInterceptor { authManager.waitForToken() }) .apply { if (BuildConfig.DEBUG) { addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) diff --git a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt index f45c5b0..b9759cc 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt @@ -1,7 +1,5 @@ package ch.rhosys.email.presentation.auth -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -11,6 +9,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,8 +23,12 @@ import ch.rhosys.email.di.LocalAppContainer import kotlinx.coroutines.launch /** - * Decision #6: Authress-hosted login (social/passkey/password) via AppAuth. - * No credential fields live in this app — sign-in opens the hosted page. + * Authress-hosted login — social, passkey or password. No credential fields live + * in this app; Continue opens the hosted page in a Custom Tab. + * + * There is no activity result to wait on: the flow completes when Authress + * redirects back to the app's deep link, which MainActivity forwards to the + * login client. This screen just watches for the session to appear. */ @Composable fun LoginScreen(onSignedIn: () -> Unit) { @@ -33,21 +37,14 @@ fun LoginScreen(onSignedIn: () -> Unit) { var isLoading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val data = result.data ?: run { isLoading = false; return@rememberLauncherForActivityResult } - scope.launch { - isLoading = true - container.authManager.handleAuthResponse(data) - .onSuccess { - runCatching { container.accountRepository.refresh() } - isLoading = false - onSignedIn() - } - .onFailure { - isLoading = false - error = it.message - } - } + val hasSession by container.authManager.sessionEstablished.collectAsState() + + LaunchedEffect(hasSession) { + if (!hasSession) return@LaunchedEffect + isLoading = true + runCatching { container.accountRepository.refresh() } + isLoading = false + onSignedIn() } Column( @@ -64,7 +61,17 @@ fun LoginScreen(onSignedIn: () -> Unit) { if (isLoading) { CircularProgressIndicator() } else { - Button(onClick = { isLoading = true; container.authManager.launchSignIn(launcher) }) { + Button(onClick = { + isLoading = true + error = null + scope.launch { + container.authManager.authenticate() + .onFailure { + isLoading = false + error = it.message + } + } + }) { Text("Continue") } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt new file mode 100644 index 0000000..cb70440 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt @@ -0,0 +1,237 @@ +package ch.rhosys.email.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ch.rhosys.email.ui.theme.CatppuccinColors +import ch.rhosys.email.ui.theme.CatppuccinFlavor +import ch.rhosys.email.ui.theme.Latte +import ch.rhosys.email.ui.theme.Mocha +import ch.rhosys.email.ui.theme.palette + +/** + * Theme picker. + * + * Every tile paints itself from the flavour it represents rather than from the + * active theme, so the row is a set of previews instead of five identically + * coloured chips. Each one shows the surface, the text and subtext ramp, the + * accent colours, and a miniature mail row, which is what actually changes when + * the flavour is applied. + * + * `null` means follow the system setting; that tile previews both halves it can + * resolve to. + */ +@Composable +fun ThemePicker( + selected: CatppuccinFlavor?, + onSelect: (CatppuccinFlavor?) -> Unit, + modifier: Modifier = Modifier, +) { + // Two per row, laid out manually so this can live inside a scrolling Column + // without nesting a lazy grid. + val options: List = listOf(null) + CatppuccinFlavor.entries + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + options.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { flavor -> + ThemeTile( + flavor = flavor, + isSelected = selected == flavor, + onClick = { onSelect(flavor) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a lone trailing tile at half width instead of stretching it. + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun ThemeTile( + flavor: CatppuccinFlavor?, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // System has no palette of its own: it previews both halves it can resolve to, + // and borrows Mocha's ramp for the footer so the label stays legible. + val palette = flavor?.palette() ?: Mocha + val name = flavor?.label ?: "System" + val mode = when { + flavor == null -> "Follows device" + flavor.isDark -> "Dark" + else -> "Light" + } + + Box( + modifier = modifier + .height(168.dp) + .clip(RoundedCornerShape(14.dp)) + .background(palette.base) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) palette.mauve else palette.surface1, + shape = RoundedCornerShape(14.dp), + ) + .clickable(role = Role.RadioButton, onClick = onClick), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (flavor == null) { + SystemSplitPreview() + } else { + FlavorPreview(palette) + } + + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .background(palette.mauve), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Check, + contentDescription = "Selected", + tint = palette.crust, + modifier = Modifier.size(14.dp), + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(palette.mantle) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + name, + color = palette.text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text(mode, color = palette.subtext0, fontSize = 11.sp) + } + } + } +} + +/** A miniature mail row plus the accent ramp — the parts a flavour actually changes. */ +@Composable +private fun FlavorPreview(palette: CatppuccinColors) { + Column( + modifier = Modifier.fillMaxSize().padding(10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + MiniMailRow(palette, accent = palette.mauve, emphasised = true) + MiniMailRow(palette, accent = palette.blue, emphasised = false) + Spacer(Modifier.weight(1f)) + AccentRamp(palette) + } +} + +@Composable +private fun MiniMailRow(palette: CatppuccinColors, accent: Color, emphasised: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(modifier = Modifier.size(14.dp).clip(RoundedCornerShape(7.dp)).background(accent)) + Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) { + // Sender line: brighter and wider when the row is "urgent". + Bar(color = if (emphasised) palette.text else palette.subtext1, widthFraction = if (emphasised) 0.7f else 0.5f) + Bar(color = palette.subtext0, widthFraction = 0.9f, height = 4.dp) + } + } +} + +@Composable +private fun Bar(color: Color, widthFraction: Float, height: androidx.compose.ui.unit.Dp = 5.dp) { + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(height) + .clip(RoundedCornerShape(3.dp)) + .background(color), + ) +} + +@Composable +private fun AccentRamp(palette: CatppuccinColors) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + listOf( + palette.mauve, palette.blue, palette.teal, + palette.green, palette.yellow, palette.peach, palette.red, + ).forEach { swatch -> + Box( + modifier = Modifier + .weight(1f) + .height(10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(swatch), + ) + } + } +} + +/** Splits the preview between the two palettes the system setting resolves to. */ +@Composable +private fun SystemSplitPreview() { + Row(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxSize().background(Latte.base)) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + MiniMailRow(Latte, accent = Latte.mauve, emphasised = true) + Spacer(Modifier.weight(1f)) + Text("Light", color = Latte.subtext0, fontSize = 10.sp) + } + } + Box(modifier = Modifier.weight(1f).fillMaxSize().background(Mocha.base)) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + MiniMailRow(Mocha, accent = Mocha.mauve, emphasised = true) + Spacer(Modifier.weight(1f)) + Text("Dark", color = Mocha.subtext0, fontSize = 10.sp) + } + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt index af8f15a..bfb3638 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import ch.rhosys.email.di.LocalAppContainer @@ -41,7 +42,7 @@ fun RootNavGraph() { val onboarded = container.preferencesStore.hasCompletedOnboarding.first() gate = when { !onboarded -> RootGate.ONBOARDING - !container.tokenStore.isSignedIn -> RootGate.LOGIN + !container.authManager.isSignedIn -> RootGate.LOGIN else -> RootGate.APP } } @@ -49,7 +50,7 @@ fun RootNavGraph() { when (gate) { RootGate.LOADING -> CircularProgressIndicator() RootGate.ONBOARDING -> OnboardingScreen(onFinished = { - gate = if (container.tokenStore.isSignedIn) RootGate.APP else RootGate.LOGIN + gate = if (container.authManager.isSignedIn) RootGate.APP else RootGate.LOGIN }) RootGate.LOGIN -> LoginScreen(onSignedIn = { gate = RootGate.APP }) RootGate.APP -> { @@ -65,6 +66,14 @@ private fun AppNavHost() { val navController = rememberNavController() val container = LocalAppContainer.current + // The login SDK recommends calling userIsLoggedIn on every route change: it + // is what revalidates the session and refreshes an expired token, via + // PATCH /session. Without it a stale bearer is sent until the app restarts. + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + LaunchedEffect(currentRoute) { + container.authManager.userIsLoggedIn() + } + AppScaffold(navController) { modifier -> NavHost(navController = navController, startDestination = Destination.Inbox.route, modifier = modifier) { composable(Destination.Inbox.route) { diff --git a/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt index 99c0fd1..a974347 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/onboarding/OnboardingScreen.kt @@ -30,17 +30,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.presentation.components.ThemePicker import ch.rhosys.email.ui.theme.CatppuccinFlavor import kotlinx.coroutines.launch -/** Decision #20: 5-step onboarding wizard shown on first launch. */ +/** + * Onboarding, shown once on first launch. + * + * Biometric lock is deliberately not offered here. It is a security preference + * someone changes when they want it, not a decision worth stopping a first + * launch for, and it lives in Settings alongside the rest of them. + */ @Composable fun OnboardingScreen(onFinished: () -> Unit) { val container = LocalAppContainer.current val scope = rememberCoroutineScope() - val pagerState = rememberPagerState(pageCount = { 5 }) + val pagerState = rememberPagerState(pageCount = { StepCount }) var themeFlavor by remember { mutableStateOf(null) } - var biometricLockWanted by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> @@ -48,36 +54,27 @@ fun OnboardingScreen(onFinished: () -> Unit) { 0 -> WelcomeStep() 1 -> NotificationPermissionStep() 2 -> ThemeStep(selected = themeFlavor, onSelect = { themeFlavor = it }) - 3 -> BiometricStep(enabled = biometricLockWanted, onToggle = { biometricLockWanted = it }) - 4 -> ReadyStep() + 3 -> ReadyStep() } } Row( modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - TextButton(onClick = { - scope.launch { - container.preferencesStore.setThemeFlavor(themeFlavor) - container.preferencesStore.setBiometricLockEnabled(biometricLockWanted) - container.preferencesStore.setOnboardingCompleted(true) - onFinished() - } - }) { Text("Skip") } + TextButton(onClick = { scope.launch { finish(container, themeFlavor, onFinished) } }) { + Text("Skip") + } Button(onClick = { scope.launch { - if (pagerState.currentPage < 4) { + if (pagerState.currentPage < StepCount - 1) { pagerState.animateScrollToPage(pagerState.currentPage + 1) } else { - container.preferencesStore.setThemeFlavor(themeFlavor) - container.preferencesStore.setBiometricLockEnabled(biometricLockWanted) - container.preferencesStore.setOnboardingCompleted(true) - onFinished() + finish(container, themeFlavor, onFinished) } } }) { - Text(if (pagerState.currentPage < 4) "Next" else "Get started") + Text(if (pagerState.currentPage < StepCount - 1) "Next" else "Get started") } } } @@ -115,23 +112,21 @@ private fun NotificationPermissionStep() { @Composable private fun ThemeStep(selected: CatppuccinFlavor?, onSelect: (CatppuccinFlavor?) -> Unit) { StepScaffold("Pick a look", "You can change this later in Settings.") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CatppuccinFlavor.entries.forEach { flavor -> - FilterChip(selected = selected == flavor, onClick = { onSelect(flavor) }, label = { Text(flavor.label) }) - } - } - } -} - -@Composable -private fun BiometricStep(enabled: Boolean, onToggle: (Boolean) -> Unit) { - StepScaffold("Lock it down", "Require Face or Fingerprint unlock every time you open Numaeel.") { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Enable biometric lock") - Switch(checked = enabled, onCheckedChange = onToggle, modifier = Modifier.padding(start = 8.dp)) - } + ThemePicker(selected = selected, onSelect = onSelect) } } @Composable private fun ReadyStep() = StepScaffold("You're all set", "Let's get to inbox zero.") + +private const val StepCount = 4 + +private suspend fun finish( + container: ch.rhosys.email.di.AppContainer, + themeFlavor: CatppuccinFlavor?, + onFinished: () -> Unit, +) { + container.preferencesStore.setThemeFlavor(themeFlavor) + container.preferencesStore.setOnboardingCompleted(true) + onFinished() +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt index 7ec87ee..ab88e26 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt @@ -1,7 +1,6 @@ package ch.rhosys.email.presentation.settings import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -31,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.presentation.components.ThemePicker import ch.rhosys.email.presentation.components.rememberViewModel import ch.rhosys.email.ui.theme.CatppuccinFlavor @@ -104,15 +103,17 @@ private fun AppPreferencesSection( ) { Column(modifier = Modifier.padding(12.dp)) { Text("Theme", style = MaterialTheme.typography.titleMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 4.dp)) { - CatppuccinFlavor.entries.forEach { flavor -> - FilterChip( - selected = uiState.themeFlavor == flavor, - onClick = { onThemeSelected(if (uiState.themeFlavor == flavor) null else flavor) }, - label = { Text(flavor.label) }, - ) - } - } + Text( + "Each tile is drawn in the theme it applies.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), + ) + ThemePicker( + selected = uiState.themeFlavor, + onSelect = onThemeSelected, + modifier = Modifier.padding(bottom = 8.dp), + ) ListItem( headlineContent = { Text("Biometric lock") }, supportingContent = { Text("Require Face/Fingerprint unlock to open the app") }, diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt index d33a1a5..3099680 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt @@ -2,7 +2,7 @@ package ch.rhosys.email.presentation.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import ch.rhosys.email.data.auth.AuthressAuthManager +import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.local.PreferencesStore import ch.rhosys.email.data.repository.SettingsRepository import ch.rhosys.email.data.remote.dto.AccountUserDto @@ -38,7 +38,7 @@ class SettingsViewModel( private val settingsRepository: SettingsRepository, private val accountRepository: AccountRepository, private val preferencesStore: PreferencesStore, - private val authManager: AuthressAuthManager, + private val authManager: AuthressLoginClient, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -123,7 +123,6 @@ class SettingsViewModel( fun setThemeFlavor(flavor: CatppuccinFlavor?) = viewModelScope.launch { preferencesStore.setThemeFlavor(flavor) } fun setBiometricLockEnabled(enabled: Boolean) = viewModelScope.launch { preferencesStore.setBiometricLockEnabled(enabled) } - fun signOut() { - authManager.signOut() - } + /** Ends the server session before clearing local cookies, as the SDK does. */ + fun signOut() = viewModelScope.launch { authManager.logout() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5c4c79..a44c48a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ markwon = "4.6.2" paging = "3.3.0" security-crypto = "1.1.0-alpha06" biometric = "1.1.0" -appauth = "0.11.1" +browser = "1.8.0" glance = "1.1.0" datastore = "1.1.1" posthog = "3.9.1" @@ -69,7 +69,7 @@ paging-compose = { module = "androidx.paging:paging-compose", security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -appauth = { module = "net.openid:appauth", version.ref = "appauth" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" } diff --git a/todo.md b/todo.md index cd6733b..c867924 100644 --- a/todo.md +++ b/todo.md @@ -6,36 +6,36 @@ Open work on the Android app, most blocking first. ## Blocking a working build -### Authress application ID +### ~~Authress application ID~~ — resolved -`app/build.gradle.kts` still defaults `authressApplicationId` to `numaeel_android`, -a value invented alongside the fictional Numaeel product. Login against -`login.rhosys.cloud` will fail until this is a real application registered in -Authress. +Now defaults to `app_2EAWGEdtzaeCj7b45DsDtt`, taken from the web app's +`VITE_AUTHRESS_APPLICATION_ID`. Still overridable with +`-PauthressApplicationId=` per environment. -Override per-environment with `-PauthressApplicationId=`, or change the -default once the real id is known. +### ~~OAuth endpoints and redirect handling~~ — resolved -### OAuth redirect is claimed twice +Authress is not a plain OAuth provider, so there is no authorize/token exchange +to point at. The app now ports @authress/login-react-native directly: -`MainActivity` declares an intent filter for `ch.rhosys.email:/oauth2redirect` -(`AndroidManifest.xml`), and AppAuth's own `RedirectUriReceiverActivity` claims -the same scheme through the `appAuthRedirectScheme` manifest placeholder -(`app/build.gradle.kts`). Two components match the same redirect, so resolution -is non-deterministic. +``` +POST /api/authentication -> authenticationUrl + authenticationRequestId +open authenticationUrl in a Custom Tab (the real browser, so passkeys work) +redirect to ch.rhosys.email://auth/callback -> code + authenticationRequestId +POST /api/authentication/{id}/tokens -> session cookies +``` -If MainActivity wins, sign-in breaks silently: it never reads the incoming -intent — there is no `onNewIntent` override and `getIntent()` appears nowhere in -`app/src` — so the authorization code is dropped. AppAuth needs its own receiver -to complete the exchange, which makes the comment on the placeholder -("our redirect is actually captured by MainActivity's intent-filter") backwards. +The session lives in the `authorization` and `user` cookies rather than an +access/refresh pair. `userIsLoggedIn()` refreshes it via `PATCH /session` and is +called on every route change, per the SDK's own recommendation; `waitForToken()` +supplies the Authorization header. -Fix is most likely to delete the MainActivity filter and let AppAuth handle it. -Worth doing alongside the Authress application id, since both block login. +The duplicate redirect claim is gone with AppAuth: MainActivity is now the only +component matching the scheme, and it forwards the redirect through +`onNewIntent` as the SDK's Android setup describes. -Separately, a custom-scheme redirect can be registered by any app on the device. -Prefer an HTTPS App Link redirect on `email.rhosys.cloud` once assetlinks.json is -served (see the Play Store section). +The browser deliberately does not share cookies with the app — it does not need +to. The token exchange is made by the app's own HTTP client, so the session +cookie arrives there. ### App name @@ -85,7 +85,7 @@ can come back. | Compose a new thread | Drafts post to `/threads/{threadId}/signals`; there is no route for a draft with no thread. Reply and forward work | | Send later / undo send | No scheduling parameter, no cancel route. Sending is immediate | | Attachment download | Attachments carry a fixed `url` and are opened directly; there is no download endpoint | -| MFA / passkey management | No endpoints | +| MFA / passkey management | Not on the email API — but the login service has `GET`/`DELETE /api/session/devices`, which the SDK exposes as getDevices/deleteDevice. The Settings tab could be rebuilt against those | | Billing | `billingPlan` is readable on an account, but there are no billing endpoints | | Support tickets | No endpoint. `SupportData` in the spec is a signal workflow type, not a ticket API | | Per-address sender blocking | Sender policy applies to a whole domain on an alias |