Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
@@ -8,6 +8,9 @@
|
||||
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="Iris"
|
||||
@@ -38,8 +41,22 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="chat" />
|
||||
</intent-filter>
|
||||
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- QR pairing (docs/20): full-screen scanner launched from the
|
||||
Connect screen. Not exported; started only by our own app. -->
|
||||
<activity
|
||||
android:name="iris.platform.QrScanActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
|
||||
import iris.platform.AndroidSecureStore
|
||||
import iris.platform.AppBridge
|
||||
import iris.platform.syncNtfyListener
|
||||
import iris.util.PairLink
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||
|
||||
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
|
||||
// screen with.
|
||||
private val pendingPair = mutableStateOf<PairLink?>(null)
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||
|
||||
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val chatId by deepLinkChatId
|
||||
val threadId by deepLinkThreadId
|
||||
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
||||
val pair by pendingPair
|
||||
IrisApp(
|
||||
store = store,
|
||||
deepLinkChatId = chatId,
|
||||
deepLinkThreadId = threadId,
|
||||
deepLinkPair = pair,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +75,14 @@ class MainActivity : ComponentActivity() {
|
||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||
private fun handleDeepLink(intent: Intent?) {
|
||||
val data = intent?.data
|
||||
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
|
||||
// the whole payload is in the query string; parse it with the shared
|
||||
// PairLink parser.
|
||||
if (data?.scheme == "iris" && data.host == "pair") {
|
||||
val link = PairLink.parse(data.toString())
|
||||
if (link != null) pendingPair.value = link
|
||||
return
|
||||
}
|
||||
val chatId =
|
||||
intent?.getStringExtra("chat_id")
|
||||
?: data?.pathSegments?.firstOrNull()
|
||||
|
||||
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
|
||||
val markdownVersion = "0.44.0"
|
||||
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
||||
val sqldelightVersion = "2.3.2"
|
||||
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
|
||||
val cameraxVersion = "1.5.1"
|
||||
val mlKitVersion = "16.1.1"
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
@@ -111,6 +114,13 @@ kotlin {
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
// SAF picker (rememberLauncherForActivityResult).
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
|
||||
// uses the camera2 CameraX backend and ML Kit's barcode model.
|
||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
|
||||
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||
// the ntfy listener is the fallback). The google-services plugin is
|
||||
// applied conditionally in the app module.
|
||||
|
||||
@@ -33,7 +33,7 @@ actual fun postSystemNotification(
|
||||
threadId: String?,
|
||||
) {
|
||||
val context = AndroidEnv.context
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -53,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
// exception: the app must display them itself.
|
||||
if (message.notification != null) return
|
||||
val data = message.data
|
||||
val chatId = data["chat_id"] ?: "android:default"
|
||||
val chatId = data["chat_id"] ?: "default"
|
||||
val threadId = data["thread_id"]
|
||||
val title = data["title"] ?: "Iris"
|
||||
val body = data["body"] ?: data["title"].orEmpty()
|
||||
|
||||
@@ -100,7 +100,7 @@ class NtfyListenerService : Service() {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val chatId = data?.str("chat_id") ?: "android:default"
|
||||
val chatId = data?.str("chat_id") ?: "default"
|
||||
val threadId = data?.str("thread_id")
|
||||
// The short preview rides in the SSE `data:` field; fall back to the
|
||||
// X-Title, then a generic label.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package iris.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
|
||||
* scanned text (or null on cancel) to [onResult].
|
||||
*/
|
||||
@Composable
|
||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val text =
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onResult(text)
|
||||
}
|
||||
Button(onClick = {
|
||||
val activity = context.resolveActivity()
|
||||
if (activity != null) {
|
||||
launcher.launch(Intent(activity, QrScanActivity::class.java))
|
||||
}
|
||||
}) {
|
||||
Text("Scan QR")
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
|
||||
private fun Context.resolveActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.resolveActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package iris.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
|
||||
/**
|
||||
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
|
||||
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
|
||||
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
|
||||
*
|
||||
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
|
||||
* stopped in [onDestroy].
|
||||
*/
|
||||
class QrScanActivity : ComponentActivity() {
|
||||
companion object {
|
||||
/** Intent extra carrying the scanned QR text. */
|
||||
const val EXTRA_QR = "qr"
|
||||
}
|
||||
|
||||
private lateinit var previewView: PreviewView
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var settled = false
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) startCamera() else finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
previewView =
|
||||
PreviewView(this).apply {
|
||||
layoutParams =
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
setContentView(previewView)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (!settled) setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
val provider = future.get()
|
||||
cameraProvider = provider
|
||||
val preview =
|
||||
Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analyzer =
|
||||
ImageAnalysis
|
||||
.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
this,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analyzer,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// No usable camera (e.g. headless emulator): bail out.
|
||||
finish()
|
||||
return@addListener
|
||||
}
|
||||
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
|
||||
analyzeImage(proxy)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzeImage(proxy: ImageProxy) {
|
||||
val mediaImage = proxy.image
|
||||
if (mediaImage == null) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
|
||||
BarcodeScanning
|
||||
.getClient()
|
||||
.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
|
||||
if (text != null) finishWithResult(text)
|
||||
}.addOnCompleteListener { proxy.close() }
|
||||
}
|
||||
|
||||
private fun finishWithResult(text: String) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cameraProvider?.unbindAll()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -26,6 +27,7 @@ import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.rememberBackgroundImage
|
||||
import iris.util.PairLink
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
@@ -39,6 +41,7 @@ fun IrisApp(
|
||||
store: SecureStore,
|
||||
deepLinkChatId: String? = null,
|
||||
deepLinkThreadId: String? = null,
|
||||
deepLinkPair: PairLink? = null,
|
||||
) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
@@ -66,10 +69,11 @@ fun IrisApp(
|
||||
// untouched, so the UI keeps its proportions at any size.
|
||||
CompositionLocalProvider(
|
||||
LocalUserTheme provides theme,
|
||||
LocalDensity provides Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
LocalDensity provides
|
||||
Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
) {
|
||||
// The color goes through Surface's `color` parameter: a
|
||||
// Modifier.background on the Surface would be painted *under* the
|
||||
@@ -95,20 +99,34 @@ fun IrisApp(
|
||||
)
|
||||
}
|
||||
val s = state
|
||||
// QR pairing (docs/20): an iris://pair deep link prefills
|
||||
// the Connect screen, taking priority over stored creds.
|
||||
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
||||
val pairToken = deepLinkPair?.token ?: store.token
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
GatewayClient.State.Disconnected -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = pairUrl,
|
||||
prefillToken = pairToken,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
else -> {
|
||||
ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
// M9: full-screen HTML artifact preview (opened from an
|
||||
// artifact card in the chat); covers everything while open.
|
||||
@@ -117,4 +135,4 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,16 @@ class ChatStore {
|
||||
var streamingEnabled: Boolean = true
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LANE = "android:default"
|
||||
const val DEFAULT_LANE = "default"
|
||||
|
||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
|
||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
||||
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
|
||||
* thread lane can never collide with a chat id (chat ids are direct,
|
||||
* e.g. `chan_1`, and never contain `:`). */
|
||||
fun laneKey(
|
||||
chatId: String,
|
||||
threadId: String?,
|
||||
|
||||
@@ -9,7 +9,7 @@ interface SecureStore {
|
||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||
var serverUrl: String
|
||||
|
||||
/** ANDROID_TOKEN presented in the auth header. */
|
||||
/** IRIS_TOKEN presented in the auth header. */
|
||||
var token: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* HTTP client for the hermes android gateway (docs/19).
|
||||
* HTTP client for the hermes iris gateway (docs/19).
|
||||
*
|
||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
||||
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
||||
*
|
||||
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
||||
* (the Connect screen hides it there).
|
||||
*/
|
||||
@Composable
|
||||
expect fun QrScanButton(onResult: (String?) -> Unit)
|
||||
@@ -134,7 +134,7 @@ class IrisController(
|
||||
}
|
||||
|
||||
/** Home channel id (from hello.ack; default until then). */
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
private val _homeChannel = MutableStateFlow("default")
|
||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||
|
||||
/** Lanes whose full history has been loaded this session (in-memory; reset
|
||||
@@ -408,7 +408,7 @@ class IrisController(
|
||||
) {
|
||||
if (isAppForeground()) return
|
||||
if (text.isBlank()) return
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
val chatName = channels.byId(id)?.name
|
||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||
}
|
||||
|
||||
@@ -1314,7 +1314,7 @@ private fun SystemMessage(msg: MessageItem) {
|
||||
|
||||
/** M7: header title pill — channel avatar + channel name. Automation
|
||||
* channels show their chat_id as a subtitle (tap the pill to copy it) so
|
||||
* the user can target them for cron delivery (`deliver="android:<chat_id>"`)
|
||||
* the user can target them for cron delivery (`deliver="iris:<chat_id>"`)
|
||||
* without asking the agent which channel is in use. */
|
||||
@Composable
|
||||
private fun TitlePill(
|
||||
|
||||
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.PairLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -91,11 +94,25 @@ fun ConnectScreen(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (!isDesktop) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
QrScanButton { raw ->
|
||||
if (raw != null) {
|
||||
val link = PairLink.parse(raw)
|
||||
if (link != null) {
|
||||
url = link.url
|
||||
token = link.token
|
||||
} else {
|
||||
error = "Couldn't read that QR code."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
@@ -129,7 +146,7 @@ fun ConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
||||
"hermes gateway setup on the gateway host.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
||||
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
||||
* tapped `iris://pair` link (docs/20).
|
||||
*/
|
||||
data class PairLink(
|
||||
val url: String,
|
||||
val token: String,
|
||||
) {
|
||||
companion object {
|
||||
/** Default gateway HTTP port (docs/19). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/**
|
||||
* Parse a pairing link into a [PairLink], or return null on any
|
||||
* malformation.
|
||||
*
|
||||
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
||||
* - required query params: `host` (non-empty) and `token` (non-empty).
|
||||
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
||||
* - `secure` defaults to `0`; `1` selects https.
|
||||
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
||||
*/
|
||||
fun parse(raw: String): PairLink? {
|
||||
val qIdx = raw.indexOf('?')
|
||||
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
||||
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
||||
|
||||
// authority is "iris://pair": scheme, then "://", then the URI host.
|
||||
val sep = authority.indexOf("://")
|
||||
if (sep <= 0) return null
|
||||
val scheme = authority.substring(0, sep)
|
||||
val uriHost = authority.substring(sep + 3)
|
||||
if (scheme.lowercase() != "iris") return null
|
||||
if (uriHost.lowercase() != "pair") return null
|
||||
|
||||
val params = parseQuery(query)
|
||||
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
if (host.isEmpty() || token.isEmpty()) return null
|
||||
|
||||
val portRaw = params["port"]
|
||||
val port =
|
||||
if (portRaw.isNullOrEmpty()) {
|
||||
DEFAULT_PORT
|
||||
} else {
|
||||
portRaw.toIntOrNull() ?: return null
|
||||
}
|
||||
if (port !in 1..65535) return null
|
||||
val secure = params["secure"]?.toIntOrNull() ?: 0
|
||||
val urlScheme = if (secure == 1) "https" else "http"
|
||||
return PairLink("$urlScheme://$host:$port", token)
|
||||
}
|
||||
|
||||
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
||||
private fun parseQuery(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
val map = LinkedHashMap<String, String>()
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
if (eq < 0) {
|
||||
map[pair] = ""
|
||||
} else {
|
||||
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
||||
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
||||
* two-hex-digit escape is kept literally.
|
||||
*/
|
||||
private fun percentDecode(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '%' && i + 2 < s.length) {
|
||||
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
||||
if (code != null) {
|
||||
sb.append(code.toChar())
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class ChatStoreCacheTest {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
@@ -21,15 +21,15 @@ class ChatStoreCacheTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadFromCacheEmptyIsNoOp() {
|
||||
val store = ChatStore()
|
||||
store.addPending("hello", "android:default")
|
||||
store.addPending("hello", "default")
|
||||
store.loadFromCache(emptyMap())
|
||||
assertEquals(1, store.lanes.value["android:default"]!!.size)
|
||||
assertEquals(1, store.lanes.value["default"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -39,7 +39,7 @@ class ChatStoreCacheTest {
|
||||
// to it, and the final answer.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
||||
@@ -51,13 +51,13 @@ class ChatStoreCacheTest {
|
||||
// the tool card between the user message and the answer — not push
|
||||
// it to the end.
|
||||
store.loadHistory(
|
||||
"android:default",
|
||||
"default",
|
||||
listOf(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,7 +65,7 @@ class ChatStoreCacheTest {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
||||
@@ -73,11 +73,11 @@ class ChatStoreCacheTest {
|
||||
),
|
||||
)
|
||||
store.loadHistory(
|
||||
"android:default",
|
||||
"default",
|
||||
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
||||
)
|
||||
// A card whose anchor is unknown falls to the end (degenerate case).
|
||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -87,7 +87,7 @@ class ChatStoreCacheTest {
|
||||
// card minted by the PREVIOUS process.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
@@ -99,7 +99,7 @@ class ChatStoreCacheTest {
|
||||
store.onFrame(
|
||||
Frame(
|
||||
type = TYPE_TOOL_START,
|
||||
chatId = "android",
|
||||
chatId = "iris:other",
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ToolStartPayload.serializer(),
|
||||
@@ -107,7 +107,7 @@ class ChatStoreCacheTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val ids = store.lanes.value["android:default"]!!.map { it.id }
|
||||
val ids = store.lanes.value["default"]!!.map { it.id }
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
|
||||
* this button on desktop (`isDesktop`), so this is never rendered.
|
||||
*/
|
||||
@Composable
|
||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||
// No-op on desktop.
|
||||
}
|
||||
@@ -43,24 +43,24 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
||||
),
|
||||
"android:default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadLanes()
|
||||
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys)
|
||||
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
|
||||
// Tool cards are persisted and restored at their anchored position
|
||||
// (after the message they follow, before the answer).
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
|
||||
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
|
||||
// Messages ordered by ts.
|
||||
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts)
|
||||
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts)
|
||||
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
|
||||
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +68,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
||||
@@ -77,7 +77,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,7 +85,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
||||
@@ -93,7 +93,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,7 +101,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
msg("m1", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
||||
@@ -109,7 +109,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
// The process died before tool.end — the open card is closed as
|
||||
// interrupted, not left spinning.
|
||||
val open = lane.first { it.id == "tool_1" } as ToolItem
|
||||
@@ -123,9 +123,9 @@ class ChatDbTest {
|
||||
@Test
|
||||
fun saveLanesReplacesPreviousSnapshot() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
|
||||
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
|
||||
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -133,7 +133,7 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveLanes(
|
||||
mapOf(
|
||||
"android:default" to
|
||||
"default" to
|
||||
listOf(
|
||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
||||
@@ -141,7 +141,7 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
val lane = db.loadLanes()["android:default"]!!
|
||||
val lane = db.loadLanes()["default"]!!
|
||||
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
||||
// A pending send becomes failed (tap to retry); the gateway never
|
||||
// acknowledged it before the process died.
|
||||
@@ -156,7 +156,7 @@ class ChatDbTest {
|
||||
@Test
|
||||
fun systemMessagesAreNotPersisted() {
|
||||
val db = newDb()
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ class ChatDbTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item)))
|
||||
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
|
||||
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
|
||||
assertEquals("gpt", loaded.runtime?.model)
|
||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
||||
}
|
||||
@@ -193,26 +193,26 @@ class ChatDbTest {
|
||||
val db = newDb()
|
||||
db.saveChannels(
|
||||
listOf(
|
||||
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "android:chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
|
||||
ChannelInfo(chatId = "default", name = "General", isDefault = true),
|
||||
ChannelInfo(chatId = "chan_1", name = "Work"),
|
||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
|
||||
),
|
||||
)
|
||||
val loaded = db.loadChannels()
|
||||
assertEquals(3, loaded.size)
|
||||
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name)
|
||||
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
|
||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
||||
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault)
|
||||
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaRoundTripAndClearAll() {
|
||||
val db = newDb()
|
||||
assertNull(db.metaGet("last_lane"))
|
||||
db.metaPut("last_lane", "android:chan_1")
|
||||
assertEquals("android:chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
|
||||
db.metaPut("last_lane", "chan_1")
|
||||
assertEquals("chan_1", db.metaGet("last_lane"))
|
||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
|
||||
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
|
||||
db.clearAll()
|
||||
assertTrue(db.loadLanes().isEmpty())
|
||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
|
||||
* `pairing.qr_payload` on the gateway side.
|
||||
*/
|
||||
class PairLinkTest {
|
||||
private val token = "ab".repeat(32) // 64 hex chars
|
||||
|
||||
@Test
|
||||
fun parsesValidLink() {
|
||||
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
|
||||
assertEquals("http://192.168.1.50:8791", link?.url)
|
||||
assertEquals(token, link?.token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsPortTo8791() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secureOneSelectsHttps() {
|
||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
|
||||
assertEquals("https://10.0.0.5:8443", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemeIsCaseInsensitive() {
|
||||
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
|
||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentDecodesHost() {
|
||||
// A hostname with a space would be %20 on the wire.
|
||||
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
|
||||
assertEquals("http://my host:8791", link?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEmptyToken() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMissingHost() {
|
||||
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBadPort() {
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
|
||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongScheme() {
|
||||
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWrongHost() {
|
||||
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNoAuthority() {
|
||||
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsBoundaryPorts() {
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
|
||||
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user