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 |