The server half went to rustfmt's defaults earlier today; this is the same move for the app, and the same reasoning. Kotlin ships no formatter with the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org owned now (it moved from facebook/ktfmt), is a formatter rather than a configurable linter, and has essentially nothing to tune -- which is what rule 27 is asking for. ktlint's .editorconfig surface is the thing that rule warns against, and detekt is static analysis, whose job Android Lint already does here. One setting, and it is a choice between the tool's own two styles rather than a tuning: kotlinLangStyle() is the 4-space one, which is what this code already was. The 2-space default would have reindented every file to say nothing. ./gradlew :androidApp:ktfmtFormat to apply ./gradlew :androidApp:ktfmtCheck to verify Formatting only. The one thing worth checking by hand was the generated PEM constant, since a leading newline there costs Android's CertificateFactory its preamble sniff and fails at runtime nowhere near the cause: ktfmt moved `.trimMargin()` onto its own line and left the template alone, and the regenerated constant still starts at the opening quotes. Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and the APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
122 lines
4.8 KiB
Kotlin
122 lines
4.8 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import android.security.keystore.KeyGenParameterSpec
|
|
import android.security.keystore.KeyProperties
|
|
import android.util.Base64
|
|
import androidx.core.content.edit
|
|
import java.security.KeyStore
|
|
import javax.crypto.Cipher
|
|
import javax.crypto.KeyGenerator
|
|
import javax.crypto.SecretKey
|
|
import javax.crypto.spec.GCMParameterSpec
|
|
|
|
/**
|
|
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
|
|
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
|
|
* or by typing the fields into the settings screen.
|
|
*/
|
|
data class ServerSettings(val host: String, val port: Int, val token: String) {
|
|
val baseUrl: String
|
|
get() = "https://$host:$port"
|
|
}
|
|
|
|
private const val PREFS_NAME = "server"
|
|
private const val KEY_HOST = "host"
|
|
private const val KEY_PORT = "port"
|
|
private const val KEY_TOKEN = "token"
|
|
|
|
fun loadServerSettings(context: Context): ServerSettings? {
|
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
val host = prefs.getString(KEY_HOST, null) ?: return null
|
|
val port = prefs.getInt(KEY_PORT, 0)
|
|
val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
|
|
val token = unseal(sealed) ?: return null
|
|
if (port == 0 || token.isEmpty()) return null
|
|
return ServerSettings(host, port, token)
|
|
}
|
|
|
|
fun saveServerSettings(context: Context, settings: ServerSettings) {
|
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
|
putString(KEY_HOST, settings.host)
|
|
putInt(KEY_PORT, settings.port)
|
|
putString(KEY_TOKEN, seal(settings.token))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the enrollment URI the server's QR carries:
|
|
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is missing -- a malformed
|
|
* scan shouldn't clobber a working enrollment.
|
|
*/
|
|
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
|
|
if (uri.scheme != "aiapp" || uri.host != "enroll") return null
|
|
val host = uri.getQueryParameter("host") ?: return null
|
|
val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
|
|
val token = uri.getQueryParameter("token") ?: return null
|
|
if (host.isEmpty() || token.isEmpty()) return null
|
|
return ServerSettings(host, port, token)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token sealing. The token is the credential for remote code execution on
|
|
// the backend, so it is stored AES-GCM-encrypted under an Android Keystore
|
|
// key (hardware-backed where the device has it) rather than in plain
|
|
// preferences. Hand-rolled (~40 lines) instead of Jetpack's
|
|
// EncryptedSharedPreferences because that library is deprecated with no
|
|
// drop-in successor -- Google's own guidance is now "use Keystore directly".
|
|
|
|
private const val KEYSTORE = "AndroidKeyStore"
|
|
private const val KEY_ALIAS = "aiapp-token-key"
|
|
private const val GCM_TAG_BITS = 128
|
|
|
|
private fun tokenKey(): SecretKey {
|
|
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let {
|
|
return it
|
|
}
|
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
|
generator.init(
|
|
KeyGenParameterSpec.Builder(
|
|
KEY_ALIAS,
|
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
)
|
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
.build()
|
|
)
|
|
return generator.generateKey()
|
|
}
|
|
|
|
/** iv:ciphertext, both base64 -- the stored form of the token. */
|
|
private fun seal(token: String): String {
|
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
|
|
val ciphertext = cipher.doFinal(token.encodeToByteArray())
|
|
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
|
|
":" +
|
|
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
|
|
}
|
|
|
|
/**
|
|
* Null on any failure -- e.g. the Keystore key was lost to a device reset or the app's data was
|
|
* restored onto another device, where the key never travels. The caller treats that as "not
|
|
* enrolled"; re-scanning the QR (or `--rotate-token`) is the recovery, so failing soft here is
|
|
* right.
|
|
*/
|
|
private fun unseal(sealed: String): String? =
|
|
try {
|
|
val (ivB64, dataB64) =
|
|
sealed.split(":", limit = 2).let { if (it.size != 2) return null else it[0] to it[1] }
|
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
cipher.init(
|
|
Cipher.DECRYPT_MODE,
|
|
tokenKey(),
|
|
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
|
|
)
|
|
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|