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..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,22 +87,59 @@ 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( + 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) .put("applicationId", BuildConfig.AUTHRESS_APPLICATION_ID) .put("codeChallenge", codes.codeChallenge) .put("codeChallengeMethod", "S256") - .apply { connectionId?.let { put("connectionId", it) } } + .put("antiAbuseHash", antiAbuseHash) + .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") @@ -107,7 +156,7 @@ class AuthressLoginClient( withContext(Dispatchers.Main) { CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(authenticationUrl)) } - } + }.onFailure { logger.error("Authress", "authenticate() failed", it) } // ── completeAuthenticationRequest ─────────────────────────────────────── @@ -127,10 +176,20 @@ class AuthressLoginClient( throw AuthressException("Authentication request mismatch") } + // Key order matches @authress/login's token exchange: client_id + // (applicationId), authenticationRequestId, code. + val antiAbuseHash = JwtManager.calculateAntiAbuseHash( + linkedMapOf( + "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) @@ -148,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 = @@ -219,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())) @@ -238,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 ddb6ebd..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 @@ -30,6 +30,39 @@ 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 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 -> + when (value) { + is Map<*, *> -> value.keys.map { it.toString() }.sorted() + .joinToString("-") { key -> value[key].toString() } + is List<*> -> value.joinToString(",") + 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/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/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..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,8 +7,10 @@ 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 import ch.rhosys.email.data.repository.AccountRepositoryImpl import ch.rhosys.email.data.repository.ComposeRepositoryImpl import ch.rhosys.email.data.repository.LabelRepositoryImpl @@ -24,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 @@ -37,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 { @@ -57,6 +67,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) { 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 |