Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS
Compose app mirroring local-updater's stack (single :androidApp module, pinned CA, HttpURLConnection transport) plus what this app needs on top: a bearer token sealed with an Android Keystore AES-GCM key, an aiapp://enroll intent filter so scanning the server's terminal QR with the stock camera enrolls the phone with no QR library, an SSE client that resumes by transcript cursor, and a transcript renderer folding the common event model into user bubbles, streaming text, collapsible tool cards, and answerable question cards. Verified on the tdep emulator against the real server: enrollment deep link, list, spawn, streamed echo turn, question answer round trip, tool card expansion, adjustResize keyboard behavior. Build is warning-clean (compose.* accessors replaced with direct dependencies). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
967fc814ab
commit
213bc72b64
24 files changed
+2086
No files matched your search
@@ -0,0 +1,118 @@
|
||||
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 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))
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in new issue
Block a user