From 2f35b488e821da174b65669f566d4ca905c81b69 Mon Sep 17 00:00:00 2001 From: cutieway Date: Sun, 25 Oct 2026 15:33:54 +0200 Subject: [PATCH 1/8] Add token paste fallback for WebViews that cannot render the login page Discord's current web login does not run on older Android WebView builds (for example Huawei's Chromium 88), so the sign-in sheet stays blank and the token hook never fires. Offer a secondary path that accepts an existing token and verifies it through the same handshake. --- .../discordrp/ui/screens/login/LoginScreen.kt | 91 +++++++++++++++++++ app/src/main/res/values/strings.xml | 7 ++ 2 files changed, 98 insertions(+) diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt index 42c4056..57743dc 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt @@ -22,23 +22,33 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState 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 +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -104,6 +114,9 @@ fun LoginScreen( ) { val stage by viewModel.stage.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var manualTokenOpen by remember { mutableStateOf(false) } + var manualToken by remember { mutableStateOf("") } + var manualTokenVisible by remember { mutableStateOf(false) } LaunchedEffect(stage) { if (stage == LoginStage.Done) onDone() @@ -231,6 +244,17 @@ fun LoginScreen( ) } } + + TextButton( + onClick = { manualTokenOpen = true }, + enabled = stage != LoginStage.Verifying, + ) { + Text( + text = stringResource(R.string.login_token_manual), + style = MaterialTheme.typography.labelLarge, + color = Look.palette.inkFaint, + ) + } } } @@ -243,5 +267,72 @@ fun LoginScreen( TokenCapture(onToken = viewModel::onToken) } } + + if (manualTokenOpen) { + val cleanToken = manualToken.trim().removeSurrounding("\"").trim() + AlertDialog( + onDismissRequest = { manualTokenOpen = false }, + title = { Text(stringResource(R.string.login_token_manual_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.login_token_manual_help), + style = MaterialTheme.typography.bodySmall, + color = Look.palette.inkFaint, + ) + OutlinedTextField( + value = manualToken, + onValueChange = { manualToken = it.trim() }, + singleLine = true, + label = { Text(stringResource(R.string.login_token_field_label)) }, + visualTransformation = if (manualTokenVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { manualTokenVisible = !manualTokenVisible }, + ) { + Icon( + imageVector = if (manualTokenVisible) { + Icons.Rounded.VisibilityOff + } else { + Icons.Rounded.Visibility + }, + contentDescription = stringResource( + if (manualTokenVisible) { + R.string.login_token_hide + } else { + R.string.login_token_show + }, + ), + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = Look.gaps.snug), + ) + } + }, + confirmButton = { + TextButton( + enabled = cleanToken.isNotEmpty() && stage != LoginStage.Verifying, + onClick = { + manualTokenOpen = false + viewModel.onToken(cleanToken) + }, + ) { + Text(stringResource(R.string.login_token_manual_submit)) + } + }, + dismissButton = { + TextButton(onClick = { manualTokenOpen = false }) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74c04e4..bf587da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,6 +134,13 @@ You sign in on Discord\'s own page. Your login is stored only on this device. Sign in failed Discord did not accept the login. Try again. + Use an existing token + Use an existing token + On a computer signed in to Discord, open DevTools → Application → Local Storage → discord.com and copy the token value here. It stays on this device. + Token + Show token + Hide token + Sign in Back Cancel OK From 93e9a907c39c6cf5bd03faaa47f5a51ff8095f98 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:43:00 +0200 Subject: [PATCH 2/8] Repaint Discord login WebView after the page commits The first composited frame could stay white after the bottom sheet animation on the Huawei WebView; invalidate when the page is committed and finished. --- .../ui/screens/login/TokenCapture.kt | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt index 93392a0..1c82bac 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt @@ -31,41 +31,57 @@ fun TokenCapture(onToken: (String) -> Unit) { webView.destroy() }, factory = { context -> - WebView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { - settings.userAgentString = FALLBACK_UA - } + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { + settings.userAgentString = FALLBACK_UA + } - webViewClient = object : WebViewClient() { - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - stopLoading() - if (url.endsWith("/app")) { - loadUrl(TOKEN_HOOK) - visibility = View.GONE + webViewClient = object : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + stopLoading() + if (url.endsWith("/app")) { + loadUrl(TOKEN_HOOK) + visibility = View.GONE + } + return false + } + + // The first composited frame can stay white while the bottom + // sheet animates in; repaint once the page is committed. + override fun onPageCommitVisible(view: WebView, url: String) { + super.onPageCommitVisible(view, url) + view.postInvalidateOnAnimation() + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.post { + view.requestLayout() + view.invalidate() + } } - return false } - } - webChromeClient = object : WebChromeClient() { - override fun onJsAlert( - view: WebView, - url: String, - message: String, - result: JsResult, - ): Boolean { - onToken(message) - visibility = View.GONE - return true + webChromeClient = object : WebChromeClient() { + override fun onJsAlert( + view: WebView, + url: String, + message: String, + result: JsResult, + ): Boolean { + onToken(message) + visibility = View.GONE + return true + } } + loadUrl(LOGIN_URL) } - loadUrl(LOGIN_URL) - } - }) + }, + ) } From be3c5e5d268bbb2602501742276b5eccca4ed13c Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:37:15 +0200 Subject: [PATCH 3/8] Harden token entry and WebView capture --- .../discordrp/ui/screens/login/LoginScreen.kt | 27 ++++++-- .../ui/screens/login/TokenCapture.kt | 61 ++++++++++++++++--- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt index 57743dc..01ff533 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Visibility @@ -47,6 +48,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -100,7 +102,8 @@ class LoginViewModel @Inject constructor( if (_stage.value == LoginStage.Verifying) return _stage.value = LoginStage.Verifying viewModelScope.launch { - _stage.value = if (handshake.run(token)) LoginStage.Done else LoginStage.Failed + val accepted = runCatching { handshake.run(token) }.getOrDefault(false) + _stage.value = if (accepted) LoginStage.Done else LoginStage.Failed } } } @@ -118,6 +121,12 @@ fun LoginScreen( var manualToken by remember { mutableStateOf("") } var manualTokenVisible by remember { mutableStateOf(false) } + fun closeManualToken() { + manualTokenOpen = false + manualToken = "" + manualTokenVisible = false + } + LaunchedEffect(stage) { if (stage == LoginStage.Done) onDone() } @@ -246,7 +255,11 @@ fun LoginScreen( } TextButton( - onClick = { manualTokenOpen = true }, + onClick = { + manualToken = "" + manualTokenVisible = false + manualTokenOpen = true + }, enabled = stage != LoginStage.Verifying, ) { Text( @@ -271,7 +284,7 @@ fun LoginScreen( if (manualTokenOpen) { val cleanToken = manualToken.trim().removeSurrounding("\"").trim() AlertDialog( - onDismissRequest = { manualTokenOpen = false }, + onDismissRequest = ::closeManualToken, title = { Text(stringResource(R.string.login_token_manual_title)) }, text = { Column { @@ -284,6 +297,10 @@ fun LoginScreen( value = manualToken, onValueChange = { manualToken = it.trim() }, singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), label = { Text(stringResource(R.string.login_token_field_label)) }, visualTransformation = if (manualTokenVisible) { VisualTransformation.None @@ -320,7 +337,7 @@ fun LoginScreen( TextButton( enabled = cleanToken.isNotEmpty() && stage != LoginStage.Verifying, onClick = { - manualTokenOpen = false + closeManualToken() viewModel.onToken(cleanToken) }, ) { @@ -328,7 +345,7 @@ fun LoginScreen( } }, dismissButton = { - TextButton(onClick = { manualTokenOpen = false }) { + TextButton(onClick = ::closeManualToken) { Text(stringResource(R.string.common_cancel)) } }, diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt index 1c82bac..eca1b0a 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt @@ -1,27 +1,47 @@ package net.spacealtctrl.discordrp.ui.screens.login import android.annotation.SuppressLint +import android.net.Uri import android.os.Build import android.view.View import android.view.ViewGroup +import android.webkit.CookieManager import android.webkit.JsResult import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable import androidx.compose.ui.viewinterop.AndroidView private const val LOGIN_URL = "https://discord.com/login" +private const val TOKEN_ALERT_PREFIX = "discordrp-token:" private const val TOKEN_HOOK = "javascript:(function()%7Bvar%20i%3Ddocument.createElement('iframe')%3B" + "document.body.appendChild(i)%3B" + - "alert(i.contentWindow.localStorage.token.slice(1,-1))%7D)()" + "alert('discordrp-token:'%2Bi.contentWindow.localStorage.token.slice(1,-1))%7D)()" private const val FALLBACK_UA = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363" +private fun isDiscordUrl(raw: String?): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + if (uri.scheme != "https") return false + val host = uri.host?.lowercase() ?: return false + return host == "discord.com" || + host.endsWith(".discord.com") || + host == "discordapp.com" || + host.endsWith(".discordapp.com") +} + +private fun isDiscordAppUrl(raw: String?): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + return isDiscordUrl(raw) && uri.path?.trimEnd('/') == "/app" +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun TokenCapture(onToken: (String) -> Unit) { @@ -38,17 +58,31 @@ fun TokenCapture(onToken: (String) -> Unit) { ) settings.javaScriptEnabled = true settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.javaScriptCanOpenWindowsAutomatically = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { settings.userAgentString = FALLBACK_UA } webViewClient = object : WebViewClient() { + private var capturing = false + + private fun capture(view: WebView, url: String?) { + if (capturing || !isDiscordAppUrl(url)) return + capturing = true + view.loadUrl(TOKEN_HOOK) + view.visibility = View.GONE + } + @Deprecated("Deprecated in Java") override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - stopLoading() - if (url.endsWith("/app")) { - loadUrl(TOKEN_HOOK) - visibility = View.GONE + if (!isDiscordUrl(url)) return true + if (isDiscordAppUrl(url)) { + view.stopLoading() + capture(view, url) + return true } return false } @@ -66,6 +100,7 @@ fun TokenCapture(onToken: (String) -> Unit) { view.requestLayout() view.invalidate() } + capture(view, url) } } webChromeClient = object : WebChromeClient() { @@ -75,8 +110,20 @@ fun TokenCapture(onToken: (String) -> Unit) { message: String, result: JsResult, ): Boolean { - onToken(message) - visibility = View.GONE + // Only our prefixed alert on Discord's own origin is a + // token; every other page dialog stays with WebView. + if (!message.startsWith(TOKEN_ALERT_PREFIX) || !isDiscordUrl(url)) { + return false + } + val token = message.removePrefix(TOKEN_ALERT_PREFIX).trim() + if (token.isEmpty()) return true + + // The app keeps the token in Keystore-sealed prefs; drop + // Discord's web session copy so it is not stored twice. + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) + onToken(token) + view.visibility = View.GONE return true } } From 5f26887527ac8a5e3cf081402ec0ebd979a46534 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:37:15 +0200 Subject: [PATCH 4/8] Seal the stored Discord token with Android Keystore --- app/build.gradle.kts | 3 + .../discordrp/settings/TokenStorageTest.kt | 32 +++++++++ .../spacealtctrl/discordrp/settings/Stash.kt | 41 ++++++++++- .../discordrp/settings/TokenVault.kt | 72 +++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt create mode 100644 app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4f8f9b..e1b1635 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,7 @@ android { defaultConfig { applicationId = "net.spacealtctrl.discordrp" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 36 versionCode = (findProperty("versionCodeOverride") as String?)?.toInt() @@ -110,4 +111,6 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") } diff --git a/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt new file mode 100644 index 0000000..ff79ded --- /dev/null +++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt @@ -0,0 +1,32 @@ +package net.spacealtctrl.discordrp.settings + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TokenStorageTest { + @Test + fun tokenIsSealedAndNeverLeftInPlaintext() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val stash = Stash.of(context) + val previous = stash.authToken + try { + stash.authToken = "smoke-token" + assertEquals("smoke-token", stash.authToken) + + val prefs = context.getSharedPreferences("stash", Context.MODE_PRIVATE) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + } finally { + stash.authToken = previous + } + assertEquals(previous, stash.authToken) + assertEquals(previous.isNotBlank(), stash.signedIn) + } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt b/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt index f8d2c04..0482ed8 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt @@ -19,6 +19,9 @@ class Stash private constructor(context: Context) { private val _revisions = MutableStateFlow(0) val revisions: StateFlow = _revisions.asStateFlow() + @Volatile + private var tokenCache: String? = null + private fun write(block: SharedPreferences.Editor.() -> Unit) { val editor = prefs.edit() editor.block() @@ -27,8 +30,41 @@ class Stash private constructor(context: Context) { } var authToken: String - get() = prefs.getString(KEY_TOKEN, null).orEmpty() - set(value) = write { putString(KEY_TOKEN, value) } + get() = tokenCache ?: readToken().also { if (it.isNotEmpty()) tokenCache = it } + set(value) { + val token = value.trim() + if (token.isEmpty()) { + tokenCache = "" + write { + remove(KEY_TOKEN) + remove(KEY_TOKEN_ENC) + } + } else { + val sealed = TokenVault.encrypt(token) + tokenCache = token + write { + putString(KEY_TOKEN_ENC, sealed) + remove(KEY_TOKEN) + } + } + } + + private fun readToken(): String { + prefs.getString(KEY_TOKEN_ENC, null)?.let { sealed -> + TokenVault.decrypt(sealed)?.let { return it } + } + val legacy = prefs.getString(KEY_TOKEN, null).orEmpty() + if (legacy.isBlank()) return "" + + // One-time migration from the pre-Keystore format. If sealing fails the + // plaintext copy is still dropped; a token must not stay readable on disk. + val sealed = runCatching { TokenVault.encrypt(legacy) }.getOrNull() + write { + if (sealed != null) putString(KEY_TOKEN_ENC, sealed) + remove(KEY_TOKEN) + } + return if (sealed != null) legacy else "" + } var selfId: String get() = prefs.getString(KEY_SELF_ID, null).orEmpty() @@ -206,6 +242,7 @@ class Stash private constructor(context: Context) { } private const val KEY_TOKEN = "discord.token" + private const val KEY_TOKEN_ENC = "discord.token.enc" private const val KEY_SELF_ID = "discord.user.id" private const val KEY_SELF_BIO = "discord.user.bio" private const val KEY_SELF_NITRO = "discord.user.nitro" diff --git a/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt b/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt new file mode 100644 index 0000000..b880ca3 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt @@ -0,0 +1,72 @@ +package net.spacealtctrl.discordrp.settings + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Seals the Discord token with an Android Keystore key. The key is deliberately + * not user-authentication bound: the gateway service has to read the token while + * the screen is off. + */ +internal object TokenVault { + private const val KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "discordrp.token.v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + + @Synchronized + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + val body = Base64.encodeToString( + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), + Base64.NO_WRAP, + ) + return "$iv:$body" + } + + @Synchronized + fun decrypt(sealed: String): String? = try { + val parts = sealed.split(':', limit = 2) + if (parts.size != 2) { + null + } else { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec(TAG_BITS, Base64.decode(parts[0], Base64.NO_WRAP)), + ) + String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), Charsets.UTF_8) + } + } catch (_: Exception) { + // A missing or invalidated key means the stored token is unusable; the + // user has to sign in again rather than fall back to plaintext. + null + } + + private fun key(): SecretKey { + val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} From 89baeaa44fd9cb5eb8b6ad1c74efd903c59ca6d9 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:37:15 +0200 Subject: [PATCH 5/8] Verify update APK source and digest --- .../discordrp/update/UpdateHub.kt | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt index 4e5c687..690adfd 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt @@ -2,6 +2,7 @@ package net.spacealtctrl.discordrp.update import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.content.FileProvider import dagger.hilt.android.qualifiers.ApplicationContext import io.ktor.client.HttpClient @@ -29,10 +30,11 @@ import kotlinx.serialization.Serializable import net.spacealtctrl.discordrp.BuildConfig import net.spacealtctrl.discordrp.log.AppLog import java.io.File +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton -data class AppUpdate(val version: String, val apkUrl: String) +data class AppUpdate(val version: String, val apkUrl: String, val sha256: String?) sealed interface UpdateState { data object Idle : UpdateState @@ -100,10 +102,23 @@ class UpdateHub @Inject constructor( }.body() val version = release.tagName.removePrefix("v") val apk = release.assets.firstOrNull { it.name.endsWith(".apk") } + ?: return UpdateState.UpToDate + if (!isTrustedApkUrl(apk.downloadUrl)) { + log.warn(TAG, "Release asset is not on the expected GitHub path") + return UpdateState.Failed + } when { - apk == null -> UpdateState.UpToDate VersionOrder.isNewer(version, BuildConfig.VERSION_NAME) -> - UpdateState.Available(AppUpdate(version, apk.downloadUrl)) + UpdateState.Available( + AppUpdate( + version = version, + apkUrl = apk.downloadUrl, + sha256 = apk.digest + ?.takeIf { it.startsWith("sha256:") } + ?.removePrefix("sha256:") + ?.lowercase(), + ), + ) else -> UpdateState.UpToDate } } catch (e: CancellationException) { @@ -115,7 +130,9 @@ class UpdateHub @Inject constructor( private suspend fun download(update: AppUpdate): File { val dir = File(context.cacheDir, UPDATES_DIR).apply { mkdirs() } - val file = File(dir, "DiscordRP-${update.version}.apk") + val safeVersion = update.version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val file = File(dir, "DiscordRP-$safeVersion.apk") + val digest = MessageDigest.getInstance("SHA-256") http.prepareGet(update.apkUrl) { timeout { requestTimeoutMillis = DOWNLOAD_TIMEOUT_MS } }.execute { response -> @@ -128,13 +145,31 @@ class UpdateHub @Inject constructor( while (true) { val read = channel.readAvailable(buffer, 0, buffer.size) if (read == -1) break - if (read > 0) out.write(buffer, 0, read) + if (read > 0) { + out.write(buffer, 0, read) + digest.update(buffer, 0, read) + } } } } + val expected = update.sha256 + if (expected != null) { + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (actual != expected) { + file.delete() + error("download digest mismatch") + } + } return file } + private fun isTrustedApkUrl(raw: String): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + return uri.scheme == "https" && + uri.host == "github.com" && + uri.path?.startsWith("/spacealtctrl/DiscordRP/releases/download/") == true + } + private fun launchInstaller(apk: File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", apk) context.startActivity( @@ -148,6 +183,7 @@ class UpdateHub @Inject constructor( private data class FeedAsset( val name: String, @SerialName("browser_download_url") val downloadUrl: String, + val digest: String? = null, ) @Serializable From 134d92601426dae3ca3a10f24100794dfd02e1b6 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:40:17 +0200 Subject: [PATCH 6/8] Test legacy token migration --- .../discordrp/settings/TokenStorageTest.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt index ff79ded..cc714d0 100644 --- a/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt +++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt @@ -6,13 +6,32 @@ import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith +import org.junit.runners.MethodSorters @RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) class TokenStorageTest { @Test - fun tokenIsSealedAndNeverLeftInPlaintext() { + fun a_legacyTokenIsMigrated() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = context.getSharedPreferences("stash", Context.MODE_PRIVATE) + prefs.edit() + .putString("discord.token", "legacy-token") + .remove("discord.token.enc") + .commit() + + val stash = Stash.of(context) + assertEquals("legacy-token", stash.authToken) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + stash.authToken = "" + } + + @Test + fun b_tokenIsSealedAndNeverLeftInPlaintext() { val context = InstrumentationRegistry.getInstrumentation().targetContext val stash = Stash.of(context) val previous = stash.authToken From 3ea0333cc6106e188fb1bff447d46b91a789c9e9 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:41:03 +0200 Subject: [PATCH 7/8] Drop unused privileged media permission --- app/src/main/AndroidManifest.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f68969..1dba1e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,9 +9,6 @@ - From 11e2d05a6547faef559237b1576c559589465db3 Mon Sep 17 00:00:00 2001 From: lexi <173241226+cutieway@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:48:44 +0200 Subject: [PATCH 8/8] Clear WebView session when the login sheet closes --- .../spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt index eca1b0a..9b675f1 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt @@ -47,6 +47,10 @@ private fun isDiscordAppUrl(raw: String?): Boolean { fun TokenCapture(onToken: (String) -> Unit) { AndroidView( onRelease = { webView -> + // Never leave Discord's web session (and a possible token in its + // localStorage) behind after the login sheet closes. + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) webView.stopLoading() webView.destroy() },