From a0026f9f07376a932d8157130a4189106f546fbd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 10:08:23 +0000 Subject: [PATCH 1/2] Compute the anti-abuse hash on Authress calls; stamp User-Agent on every request The login SDK port never sent antiAbuseHash on /authentication or /authentication/{id}/tokens, so Authress rejected the calls. Added JwtManager.calculateAntiAbuseHash (the SDK's proof-of-work: search a fine-tuner until base64url(SHA-256(timestamp;fineTuner;values)) starts with "00") and wired it into both request bodies. Also, only the Authress calls carried an identifying header (X-Powered-By), and even those relied on OkHttp's default User-Agent. Added a UserAgentInterceptor on the shared OkHttpClient so both the Authress session client and the Email API client send a real User-Agent on every request. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XRMBgZXVCEVwBuBWapy5J7 --- .../email/data/auth/AuthressLoginClient.kt | 12 ++++++++ .../ch/rhosys/email/data/auth/JwtManager.kt | 30 +++++++++++++++++++ .../data/remote/api/UserAgentInterceptor.kt | 20 +++++++++++++ .../java/ch/rhosys/email/di/AppContainer.kt | 2 ++ 4 files changed, 64 insertions(+) create mode 100644 app/src/main/java/ch/rhosys/email/data/remote/api/UserAgentInterceptor.kt 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 index e45e5b6..7bc703a 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -85,11 +85,15 @@ class AuthressLoginClient( storage.setAuthenticationRequest(null) val codes = JwtManager.getAuthCodes() + val antiAbuseHash = JwtManager.calculateAntiAbuseHash( + mapOf("connectionId" to connectionId, "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID), + ) val body = JSONObject() .put("redirectUrl", redirectUri) .put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID) .put("codeChallenge", codes.codeChallenge) .put("codeChallengeMethod", "S256") + .put("antiAbuseHash", antiAbuseHash) .apply { connectionId?.let { put("connectionId", it) } } val response = post("/authentication", body) @@ -127,10 +131,18 @@ class AuthressLoginClient( throw AuthressException("Authentication request mismatch") } + val antiAbuseHash = JwtManager.calculateAntiAbuseHash( + mapOf( + "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID, + "authenticationRequestId" to authenticationRequestId, + "code" to code, + ), + ) val body = JSONObject() .put("code", code) .put("codeVerifier", pending.codeVerifier) .put("redirectUri", pending.redirectUrl) + .put("antiAbuseHash", antiAbuseHash) try { post("/authentication/$authenticationRequestId/tokens", body) 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 index ddb6ebd..1e8b2f6 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt @@ -30,6 +30,36 @@ object JwtManager { return AuthCodes(codeVerifier, Base64.encodeToString(digest, B64_URL)) } + /** + * Proof-of-work anti-abuse hash required on `/authentication` and + * `/authentication/{id}/tokens` calls, matching the SDK's `calculateAntiAbuseHash`: + * a fine-tuner is searched until base64url(SHA-256("timestamp;fineTuner;valueString")) + * starts with "00". `valueString` is the non-empty prop values (nested maps + * flattened by their sorted keys) joined with "|". + */ + fun calculateAntiAbuseHash(props: Map): String { + val timestamp = System.currentTimeMillis() + val valueString = props.values + .filterNot { it == null || it == "" || it == false } + .joinToString("|") { value -> + if (value is Map<*, *>) { + value.keys.map { it.toString() }.sorted() + .joinToString("-") { key -> value[key].toString() } + } else { + value.toString() + } + } + + var fineTuner = 0 + while (true) { + fineTuner++ + val input = "$timestamp;$fineTuner;$valueString" + val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) + val hash = Base64.encodeToString(digest, B64_URL) + if (hash.startsWith("00")) return "v2;$timestamp;$fineTuner;$hash" + } + } + /** * 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. diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/UserAgentInterceptor.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/UserAgentInterceptor.kt new file mode 100644 index 0000000..c45ad96 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/UserAgentInterceptor.kt @@ -0,0 +1,20 @@ +package ch.rhosys.email.data.remote.api + +import ch.rhosys.email.BuildConfig +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Stamps a real User-Agent on every request. Installed on the shared + * OkHttpClient in [ch.rhosys.email.di.AppContainer], so it covers both the + * Email API calls and the Authress session calls (whose client is built off + * the same instance) instead of leaving OkHttp's default `okhttp/`. + */ +class UserAgentInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("User-Agent", "Numaeel-Android/${BuildConfig.VERSION_NAME}") + .build() + return chain.proceed(request) + } +} 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 ba8447c..aaf3619 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -9,6 +9,7 @@ import ch.rhosys.email.data.auth.TokenStore import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.remote.api.AuthInterceptor import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.api.UserAgentInterceptor import ch.rhosys.email.data.repository.AccountRepositoryImpl import ch.rhosys.email.data.repository.ComposeRepositoryImpl import ch.rhosys.email.data.repository.LabelRepositoryImpl @@ -57,6 +58,7 @@ class AppContainer(private val context: Context) { private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() + .addInterceptor(UserAgentInterceptor()) .addInterceptor(AuthInterceptor { authManager.waitForToken() }) .apply { if (BuildConfig.DEBUG) { From 4404b02a665336af2dd5cc2b6fc2b1dc6e9a9049 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 11:58:05 +0000 Subject: [PATCH 2/2] Close the @authress/login SDK parity gaps; add an in-app Application Logs tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared the Kotlin port against @authress/login (web) call-for-call: - Fixed a real bug in the antiAbuseHash algorithm: array-valued props (e.g. audiences) must stringify as JS would (comma-joined, no brackets) when they fall through untouched into the hash's join step — Kotlin's default List.toString() produces "[a, b]" instead of "a,b", which would have kept producing a hash the backend can't reproduce for any call using array props. - Expanded authenticate() to accept the same options the web/RN SDKs support (tenantLookupIdentifier, inviteId, responseLocation, flowType, scopes, audiences, connectionProperties, multiAccount), with the antiAbuseHash prop order matching the web SDK's authenticate() exactly. - Added linkIdentity, getUserProfile, getDevices, and deleteDevice — present in the RN SDK but missing from this port; closes the MFA/passkey gap noted in todo.md (the Settings UI for device management is still a follow-up). - Added antiAbuseHash to linkIdentity, matching the web SDK's key order. Also added an AppLogger (Room-backed) that every Authress call now reports failures to, and a Settings > Logs tab so a user can review and share (via the system share sheet) what happened when reporting a problem back to us, instead of it only ever reaching logcat. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XRMBgZXVCEVwBuBWapy5J7 --- .../email/data/auth/AuthressLoginClient.kt | 168 ++++++++++++++++-- .../ch/rhosys/email/data/auth/JwtManager.kt | 15 +- .../rhosys/email/data/local/EmailDatabase.kt | 6 +- .../ch/rhosys/email/data/local/dao/LogDao.kt | 25 +++ .../email/data/local/entity/LogEntryEntity.kt | 19 ++ .../ch/rhosys/email/data/log/AppLogger.kt | 45 +++++ .../java/ch/rhosys/email/di/AppContainer.kt | 11 +- .../presentation/settings/SettingsScreen.kt | 79 +++++++- .../settings/SettingsViewModel.kt | 13 +- todo.md | 22 ++- 10 files changed, 378 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/data/local/dao/LogDao.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/local/entity/LogEntryEntity.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/log/AppLogger.kt 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 index 7bc703a..eea89e0 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import ch.rhosys.email.BuildConfig +import ch.rhosys.email.data.log.AppLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,10 +16,14 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray import org.json.JSONObject +import java.io.IOException /** - * Port of loginClient.ts from @authress/login-react-native. + * Port of loginClient.ts from @authress/login-react-native, cross-checked + * against the more complete @authress/login (web) SDK for antiAbuseHash + * ordering and the session/device/profile endpoints the RN SDK also exposes. * * Authress is not a plain OAuth provider and this is deliberately not an * authorize/token exchange. The flow the SDK implements is: @@ -35,11 +40,18 @@ import org.json.JSONObject * * The session is then held in cookies rather than in a token pair — * see [AuthressCookieJar]. + * + * Every request carries an `antiAbuseHash` (a proof-of-work computed by + * [JwtManager.calculateAntiAbuseHash]) — Authress rejects `/authentication` + * and `/authentication/{id}/tokens` calls without one. The prop order passed + * into that hash matters (it's part of what's hashed), so each call site + * below mirrors the exact key order the web SDK uses for the equivalent call. */ class AuthressLoginClient( private val context: Context, private val cookieJar: AuthressCookieJar, httpClient: OkHttpClient, + private val logger: AppLogger, ) { /** 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" @@ -75,18 +87,41 @@ class AuthressLoginClient( val authenticationRequestId: String, ) + /** Mirrors the web SDK's AuthenticationParameters that make sense for a native app (redirectUrl is fixed to the app's own deep link). */ + data class AuthenticationOptions( + val connectionId: String? = null, + val tenantLookupIdentifier: String? = null, + val inviteId: String? = null, + val responseLocation: String? = null, + val flowType: String? = null, + val scopes: List? = null, + val audiences: List? = null, + val connectionProperties: Map? = null, + val multiAccount: Boolean? = null, + ) + + data class Device(val deviceId: String, val name: 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 { + suspend fun authenticate(options: AuthenticationOptions = AuthenticationOptions()): Result = runCatching { storage.setAuthenticationRequest(null) val codes = JwtManager.getAuthCodes() + // Key order matches @authress/login's authenticate(): connectionId, + // tenantLookupIdentifier, inviteId, applicationId, audiences. val antiAbuseHash = JwtManager.calculateAntiAbuseHash( - mapOf("connectionId" to connectionId, "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID), + linkedMapOf( + "connectionId" to options.connectionId, + "tenantLookupIdentifier" to options.tenantLookupIdentifier, + "inviteId" to options.inviteId, + "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID, + "audiences" to options.audiences, + ), ) val body = JSONObject() .put("redirectUrl", redirectUri) @@ -94,7 +129,17 @@ class AuthressLoginClient( .put("codeChallenge", codes.codeChallenge) .put("codeChallengeMethod", "S256") .put("antiAbuseHash", antiAbuseHash) - .apply { connectionId?.let { put("connectionId", it) } } + .apply { + options.connectionId?.let { put("connectionId", it) } + options.tenantLookupIdentifier?.let { put("tenantLookupIdentifier", it) } + options.inviteId?.let { put("inviteId", it) } + options.responseLocation?.let { put("responseLocation", it) } + options.flowType?.let { put("flowType", it) } + options.scopes?.let { put("scopes", JSONArray(it)) } + options.audiences?.let { put("audiences", JSONArray(it)) } + options.connectionProperties?.let { put("connectionProperties", JSONObject(it)) } + options.multiAccount?.let { put("multiAccount", it) } + } val response = post("/authentication", body) val authenticationUrl = response.getString("authenticationUrl") @@ -111,7 +156,7 @@ class AuthressLoginClient( withContext(Dispatchers.Main) { CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl)) } - } + }.onFailure { logger.error("Authress", "authenticate() failed", it) } // ── completeAuthenticationRequest ─────────────────────────────────────── @@ -131,8 +176,10 @@ class AuthressLoginClient( throw AuthressException("Authentication request mismatch") } + // Key order matches @authress/login's token exchange: client_id + // (applicationId), authenticationRequestId, code. val antiAbuseHash = JwtManager.calculateAntiAbuseHash( - mapOf( + linkedMapOf( "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID, "authenticationRequestId" to authenticationRequestId, "code" to code, @@ -160,7 +207,7 @@ class AuthressLoginClient( cookieJar.backupCookies() storage.setAuthenticationRequest(null) _sessionEstablished.value = getToken() != null - } + }.onFailure { logger.error("Authress", "completeAuthenticationRequest() failed", it) } /** True when the redirect belongs to this client. */ fun isRedirect(uri: Uri?): Boolean = @@ -231,8 +278,97 @@ class AuthressLoginClient( _sessionEstablished.value = false } + // ── linkIdentity ──────────────────────────────────────────────────────── + + /** + * Links a new identity to the currently signed-in user, following the same + * `/authentication` + deep-link flow as [authenticate] (the redirect lands + * back in [completeAuthenticationRequest]), but with `linkIdentity: true`. + * Requires an existing session. Mirrors the RN SDK's `linkIdentity`, plus + * the antiAbuseHash the web SDK sends for the same call. + */ + suspend fun linkIdentity(connectionId: String? = null, tenantLookupIdentifier: String? = null): Result = runCatching { + if (connectionId == null && tenantLookupIdentifier == null) { + throw AuthressException("connectionId or tenantLookupIdentifier must be specified") + } + if (getToken() == null) throw AuthressException("Not logged in") + + storage.setAuthenticationRequest(null) + val codes = JwtManager.getAuthCodes() + // Key order matches @authress/login's linkIdentity(): connectionId, + // tenantLookupIdentifier, applicationId. + val antiAbuseHash = JwtManager.calculateAntiAbuseHash( + linkedMapOf( + "connectionId" to connectionId, + "tenantLookupIdentifier" to tenantLookupIdentifier, + "applicationId" to BuildConfig.AUTHRESS_APPLICATION_ID, + ), + ) + val body = JSONObject() + .put("redirectUrl", redirectUri) + .put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID) + .put("codeChallenge", codes.codeChallenge) + .put("codeChallengeMethod", "S256") + .put("linkIdentity", true) + .put("antiAbuseHash", antiAbuseHash) + .apply { + connectionId?.let { put("connectionId", it) } + tenantLookupIdentifier?.let { put("tenantLookupIdentifier", 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)) + } + AuthenticationResponse(authenticationUrl, authenticationRequestId) + }.onFailure { logger.error("Authress", "linkIdentity() failed", it) } + + // ── profile & devices ────────────────────────────────────────────────── + + /** The signed-in user's full profile, including linked identities. */ + suspend fun getUserProfile(): Result = runCatching { + if (getToken() == null) throw AuthressException("Not logged in") + get("/session/profile") + }.onFailure { logger.error("Authress", "getUserProfile() failed", it) } + + /** MFA devices registered to the current user; empty (not an error) if none exist or the user is signed out. */ + suspend fun getDevices(): Result> = runCatching { + if (getToken() == null) return@runCatching emptyList() + val response = try { + get("/session/devices") + } catch (e: AuthressException) { + if (e.status == 401 || e.status == 404) return@runCatching emptyList() + throw e + } + val devices = response.optJSONArray("devices") ?: JSONArray() + (0 until devices.length()).map { index -> + val device = devices.getJSONObject(index) + Device(deviceId = device.getString("deviceId"), name = device.optString("name")) + } + }.onFailure { logger.error("Authress", "getDevices() failed", it) } + + /** Removes an MFA device from the current user's profile. */ + suspend fun deleteDevice(deviceId: String): Result = runCatching { + delete("/session/devices/$deviceId") + Unit + }.onFailure { logger.error("Authress", "deleteDevice() failed", it) } + // ── HTTP ──────────────────────────────────────────────────────────────── + private suspend fun get(path: String): JSONObject = + execute(Request.Builder().url(loginUrl + path).get()) + private suspend fun post(path: String, body: JSONObject): JSONObject = execute(Request.Builder().url(loginUrl + path).post(body.toBody())) @@ -250,12 +386,20 @@ class AuthressLoginClient( .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) { + val response = try { + http.newCall(request).execute() + } catch (e: IOException) { + logger.warn("Authress", "${request.method} ${request.url.encodedPath} network failure", e) + throw e + } + + response.use { + val text = it.body?.string().orEmpty() + if (!it.isSuccessful) { + logger.warn("Authress", "${request.method} ${request.url.encodedPath} failed: ${it.code} $text") throw AuthressException( - "Authress ${request.method} ${request.url.encodedPath} failed: ${response.code} $text", - status = response.code, + "Authress ${request.method} ${request.url.encodedPath} failed: ${it.code} $text", + status = it.code, ) } runCatching { JSONObject(text) }.getOrDefault(JSONObject()) 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 index 1e8b2f6..c49a6dc 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/JwtManager.kt @@ -34,19 +34,22 @@ object JwtManager { * Proof-of-work anti-abuse hash required on `/authentication` and * `/authentication/{id}/tokens` calls, matching the SDK's `calculateAntiAbuseHash`: * a fine-tuner is searched until base64url(SHA-256("timestamp;fineTuner;valueString")) - * starts with "00". `valueString` is the non-empty prop values (nested maps - * flattened by their sorted keys) joined with "|". + * starts with "00". `valueString` is the non-empty prop values joined with "|" — + * plain objects (maps) are flattened by their sorted keys' values joined with "-"; + * everything else, including lists, stringifies the way JS would when a value + * falls through untouched into `Array.prototype.join('|')`: a list joins its + * elements with "," (no brackets, unlike Kotlin's default `List.toString()`). */ fun calculateAntiAbuseHash(props: Map): String { val timestamp = System.currentTimeMillis() val valueString = props.values .filterNot { it == null || it == "" || it == false } .joinToString("|") { value -> - if (value is Map<*, *>) { - value.keys.map { it.toString() }.sorted() + when (value) { + is Map<*, *> -> value.keys.map { it.toString() }.sorted() .joinToString("-") { key -> value[key].toString() } - } else { - value.toString() + is List<*> -> value.joinToString(",") + else -> value.toString() } } diff --git a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt index 4ff4572..03cdd60 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import ch.rhosys.email.data.local.dao.AccountDao import ch.rhosys.email.data.local.dao.LabelDao +import ch.rhosys.email.data.local.dao.LogDao import ch.rhosys.email.data.local.dao.RuleDao import ch.rhosys.email.data.local.dao.SignalDao import ch.rhosys.email.data.local.dao.TemplateDao @@ -13,6 +14,7 @@ import ch.rhosys.email.data.local.dao.ViewDao import ch.rhosys.email.data.local.entity.AccountEntity import ch.rhosys.email.data.local.entity.AliasEntity import ch.rhosys.email.data.local.entity.LabelEntity +import ch.rhosys.email.data.local.entity.LogEntryEntity import ch.rhosys.email.data.local.entity.RuleEntity import ch.rhosys.email.data.local.entity.SignalEntity import ch.rhosys.email.data.local.entity.TemplateEntity @@ -32,8 +34,9 @@ import ch.rhosys.email.data.local.entity.ViewEntity entities = [ AccountEntity::class, AliasEntity::class, ThreadEntity::class, SignalEntity::class, LabelEntity::class, RuleEntity::class, TemplateEntity::class, ViewEntity::class, + LogEntryEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) @TypeConverters(Converters::class) @@ -45,6 +48,7 @@ abstract class EmailDatabase : RoomDatabase() { abstract fun ruleDao(): RuleDao abstract fun templateDao(): TemplateDao abstract fun viewDao(): ViewDao + abstract fun logDao(): LogDao companion object { const val NAME = "numaeel.db" diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/LogDao.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/LogDao.kt new file mode 100644 index 0000000..2db377d --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/local/dao/LogDao.kt @@ -0,0 +1,25 @@ +package ch.rhosys.email.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import ch.rhosys.email.data.local.entity.LogEntryEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface LogDao { + @Query("SELECT * FROM log_entries ORDER BY id DESC") + fun observeAll(): Flow> + + @Insert + suspend fun insert(entry: LogEntryEntity) + + /** Keeps the table bounded to the most recent [limit] entries. */ + @Query( + "DELETE FROM log_entries WHERE id NOT IN (SELECT id FROM log_entries ORDER BY id DESC LIMIT :limit)", + ) + suspend fun trimTo(limit: Int) + + @Query("DELETE FROM log_entries") + suspend fun clear() +} diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/LogEntryEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/LogEntryEntity.kt new file mode 100644 index 0000000..a0452a0 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/LogEntryEntity.kt @@ -0,0 +1,19 @@ +package ch.rhosys.email.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A single application log entry, persisted so the user can review what + * happened (particularly around sign-in and the Authress session calls) and + * report it back to us from the Settings > Logs tab. + */ +@Entity(tableName = "log_entries") +data class LogEntryEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val timestamp: Long, + val level: String, + val tag: String, + val message: String, + val detail: String?, +) diff --git a/app/src/main/java/ch/rhosys/email/data/log/AppLogger.kt b/app/src/main/java/ch/rhosys/email/data/log/AppLogger.kt new file mode 100644 index 0000000..6806c71 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/log/AppLogger.kt @@ -0,0 +1,45 @@ +package ch.rhosys.email.data.log + +import ch.rhosys.email.data.local.dao.LogDao +import ch.rhosys.email.data.local.entity.LogEntryEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +enum class LogLevel { INFO, WARN, ERROR } + +/** + * App-wide diagnostic log, persisted to Room so the user can review it from + * Settings > Logs and report it back to us — most useful around sign-in, + * where failures otherwise only ever reach logcat. + */ +class AppLogger(private val dao: LogDao, private val scope: CoroutineScope) { + + fun observeAll(): Flow> = dao.observeAll() + + fun info(tag: String, message: String) = write(LogLevel.INFO, tag, message, null) + fun warn(tag: String, message: String, throwable: Throwable? = null) = write(LogLevel.WARN, tag, message, throwable) + fun error(tag: String, message: String, throwable: Throwable? = null) = write(LogLevel.ERROR, tag, message, throwable) + + suspend fun clear() = dao.clear() + + private fun write(level: LogLevel, tag: String, message: String, throwable: Throwable?) { + scope.launch(Dispatchers.IO) { + dao.insert( + LogEntryEntity( + timestamp = System.currentTimeMillis(), + level = level.name, + tag = tag, + message = message, + detail = throwable?.stackTraceToString(), + ), + ) + dao.trimTo(MAX_ENTRIES) + } + } + + private companion object { + const val MAX_ENTRIES = 500 + } +} 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 aaf3619..750bfa2 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -7,6 +7,7 @@ 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.log.AppLogger import ch.rhosys.email.data.remote.api.AuthInterceptor import ch.rhosys.email.data.remote.api.EmailApiService import ch.rhosys.email.data.remote.api.UserAgentInterceptor @@ -25,6 +26,9 @@ import ch.rhosys.email.domain.repository.RuleRepository import ch.rhosys.email.domain.repository.TemplateRepository import ch.rhosys.email.domain.repository.ThreadRepository import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -38,14 +42,19 @@ import java.util.concurrent.TimeUnit */ class AppContainer(private val context: Context) { + /** Long-lived scope for background writes (log entries) that must outlive any single screen. */ + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val tokenStore: TokenStore by lazy { TokenStore(context) } + val appLogger: AppLogger by lazy { AppLogger(database.logDao(), appScope) } + private val cookieJar: AuthressCookieJar by lazy { AuthressCookieJar(context, BuildConfig.AUTHRESS_CUSTOM_DOMAIN) } val authManager: AuthressLoginClient by lazy { - AuthressLoginClient(context, cookieJar, okHttpClient) + AuthressLoginClient(context, cookieJar, okHttpClient, appLogger) } private val moshi: Moshi by lazy { 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 ab88e26..88f4e7f 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,6 +1,8 @@ package ch.rhosys.email.presentation.settings +import android.content.Intent 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 @@ -27,11 +29,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import ch.rhosys.email.data.local.entity.LogEntryEntity 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 +import java.text.DateFormat +import java.util.Date @Composable fun SettingsScreen( @@ -40,13 +46,19 @@ fun SettingsScreen( ) { val container = LocalAppContainer.current val viewModel = rememberViewModel { - SettingsViewModel(container.settingsRepository, container.accountRepository, container.preferencesStore, container.authManager) + SettingsViewModel( + container.settingsRepository, + container.accountRepository, + container.preferencesStore, + container.authManager, + container.appLogger, + ) } val uiState by viewModel.uiState.collectAsState() var tabIndex by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } // No Security tab: the API has no MFA endpoints. No billing either. - val tabs = listOf("Aliases", "Email & Forwarding", "Users") + val tabs = listOf("Aliases", "Email & Forwarding", "Users", "Logs") LaunchedEffect(tabIndex) { when (tabIndex) { @@ -78,6 +90,7 @@ fun SettingsScreen( onVerifyForwarding = viewModel::verifyForwardingTarget, ) 2 -> UsersTab(uiState) + 3 -> LogsTab(uiState, onClear = viewModel::clearLogs) } } @@ -194,3 +207,65 @@ private fun UsersTab(uiState: SettingsUiState) { } } } + +/** + * Diagnostic log of what the app has done — sign-in failures, session + * refreshes, API errors — so a user can review and share it when reporting + * a problem back to us, rather than us only having their word for it. + */ +@Composable +private fun LogsTab(uiState: SettingsUiState, onClear: () -> Unit) { + val context = LocalContext.current + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + enabled = uiState.logs.isNotEmpty(), + onClick = { + val shareText = uiState.logs.reversed().joinToString("\n\n") { it.toShareText() } + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Numaeel application logs") + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(intent, "Share logs")) + }, + ) { Text("Share") } + TextButton(enabled = uiState.logs.isNotEmpty(), onClick = onClear) { Text("Clear") } + } + HorizontalDivider() + if (uiState.logs.isEmpty()) { + Text( + "No issues logged yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(uiState.logs, key = { it.id }) { entry -> + ListItem( + headlineContent = { + Text( + entry.message, + color = if (entry.level == "ERROR") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + }, + supportingContent = { Text("${entry.level} · ${entry.tag} · ${formatLogTimestamp(entry.timestamp)}") }, + ) + HorizontalDivider() + } + } + } + } +} + +private fun LogEntryEntity.toShareText(): String { + val header = "[$level] ${formatLogTimestamp(timestamp)} $tag: $message" + return if (detail != null) "$header\n$detail" else header +} + +private fun formatLogTimestamp(timestamp: Long): String = + DateFormat.getDateTimeInstance().format(Date(timestamp)) 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 3099680..0856b72 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 @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.local.PreferencesStore +import ch.rhosys.email.data.local.entity.LogEntryEntity +import ch.rhosys.email.data.log.AppLogger import ch.rhosys.email.data.repository.SettingsRepository import ch.rhosys.email.data.remote.dto.AccountUserDto import ch.rhosys.email.data.remote.dto.DnsRecordDto @@ -21,8 +23,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** - * MFA devices and plan/billing are absent: the API exposes no endpoints for - * either. DNS records hang off an individual domain rather than the account. + * Plan/billing is absent: the API exposes no endpoints for it. DNS records + * hang off an individual domain rather than the account. */ data class SettingsUiState( val aliases: List = emptyList(), @@ -32,6 +34,7 @@ data class SettingsUiState( val accountUsers: List = emptyList(), val themeFlavor: CatppuccinFlavor? = null, val biometricLockEnabled: Boolean = false, + val logs: List = emptyList(), ) class SettingsViewModel( @@ -39,6 +42,7 @@ class SettingsViewModel( private val accountRepository: AccountRepository, private val preferencesStore: PreferencesStore, private val authManager: AuthressLoginClient, + private val appLogger: AppLogger, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -61,8 +65,13 @@ class SettingsViewModel( viewModelScope.launch { preferencesStore.biometricLockEnabled.collect { enabled -> _uiState.value = _uiState.value.copy(biometricLockEnabled = enabled) } } + viewModelScope.launch { + appLogger.observeAll().collect { logs -> _uiState.value = _uiState.value.copy(logs = logs) } + } } + fun clearLogs() = viewModelScope.launch { appLogger.clear() } + fun loadForwardingAndDomains() { val accountId = activeAccountId.value ?: return viewModelScope.launch { diff --git a/todo.md b/todo.md index c867924..a78bfd7 100644 --- a/todo.md +++ b/todo.md @@ -37,6 +37,26 @@ The browser deliberately does not share cookies with the app — it does not nee to. The token exchange is made by the app's own HTTP client, so the session cookie arrives there. +### ~~Anti-abuse hash and SDK parity~~ — resolved + +`AuthressLoginClient` was never sending `antiAbuseHash` on `/authentication` or +`/authentication/{id}/tokens`, and only Authress calls carried an identifying +header while every request — Authress and Email API alike — fell back to +OkHttp's default `User-Agent`. Fixed: + +- `JwtManager.calculateAntiAbuseHash` ports the SDK's proof-of-work (search a + `fineTuner` until `base64url(SHA-256(timestamp;fineTuner;values))` starts + with `"00"`), wired into every Authress call that needs one, with the prop + order matching `@authress/login` (web) call-for-call. +- `UserAgentInterceptor` on the shared `OkHttpClient` stamps a real + `User-Agent` on every request. +- Compared against `@authress/login` (web) for method parity and added what + the RN SDK has but this port didn't: `linkIdentity`, `getUserProfile`, + `getDevices`, `deleteDevice` (closes the MFA/passkey gap below — the + underlying calls exist now, a Settings UI for device management doesn't). +- Failures from any Authress call now go through `AppLogger` and are visible + in Settings > Logs, which the user can share to report a problem. + ### App name The user-visible name is still **Numaeel**, an invented brand. It appears in: @@ -85,7 +105,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 | 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 | +| MFA / passkey management UI | `AuthressLoginClient.getUserProfile/getDevices/deleteDevice` now exist (see resolved note above); there is still no Settings UI backed by them | | 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 |