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..cc714d0
--- /dev/null
+++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt
@@ -0,0 +1,51 @@
+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.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 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
+ 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/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 @@
-
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()
+ }
+}
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..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,25 +20,37 @@ 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
+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.KeyboardType
+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
@@ -90,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
}
}
}
@@ -104,6 +117,15 @@ 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) }
+
+ fun closeManualToken() {
+ manualTokenOpen = false
+ manualToken = ""
+ manualTokenVisible = false
+ }
LaunchedEffect(stage) {
if (stage == LoginStage.Done) onDone()
@@ -231,6 +253,21 @@ fun LoginScreen(
)
}
}
+
+ TextButton(
+ onClick = {
+ manualToken = ""
+ manualTokenVisible = false
+ manualTokenOpen = true
+ },
+ enabled = stage != LoginStage.Verifying,
+ ) {
+ Text(
+ text = stringResource(R.string.login_token_manual),
+ style = MaterialTheme.typography.labelLarge,
+ color = Look.palette.inkFaint,
+ )
+ }
}
}
@@ -243,5 +280,76 @@ fun LoginScreen(
TokenCapture(onToken = viewModel::onToken)
}
}
+
+ if (manualTokenOpen) {
+ val cleanToken = manualToken.trim().removeSurrounding("\"").trim()
+ AlertDialog(
+ onDismissRequest = ::closeManualToken,
+ 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,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ autoCorrectEnabled = false,
+ ),
+ 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 = {
+ closeManualToken()
+ viewModel.onToken(cleanToken)
+ },
+ ) {
+ Text(stringResource(R.string.login_token_manual_submit))
+ }
+ },
+ dismissButton = {
+ 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 93392a0..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
@@ -1,71 +1,138 @@
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) {
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()
},
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
+ 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
+ }
- 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
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+ if (!isDiscordUrl(url)) return true
+ if (isDiscordAppUrl(url)) {
+ view.stopLoading()
+ capture(view, url)
+ return true
+ }
+ 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()
+ }
+ capture(view, url)
}
- 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 {
+ // 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
+ }
}
+ loadUrl(LOGIN_URL)
}
- loadUrl(LOGIN_URL)
- }
- })
+ },
+ )
}
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
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